Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 35 additions & 4 deletions packages/core/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2282,9 +2282,13 @@ export class Agent {
}

case 'callback_result': {
// Route to the originating session if known, otherwise a fresh system session
// 会话身份由契约解析(`resolveTurnSession` 读 `extra.sessionHint`,
// 见 `deliverCallback`——它把发起轮的 `originSessionId` 表态为 `memory`)。
// 旧实现写的是 `originSessionId ?? `sys_${this.id}_${ts}``:**一旦 origin
// 缺失,每完成一次就新建一个会话**,把后台结果从原会话里孤立出去(磁盘上
// 实测累计 200+ 个 `sys_*` 空壳会话)。契约规定「推不出来就保持当前会话,
// 绝不静默新建」,所以这里不再兜底造 id。
const originSessionId = extra.originSessionId as string | undefined;
const cbSessionId = originSessionId ?? `sys_${this.id}_${ts}`;
// Replay the scenario the callback was registered from (`deliverCallback`),
// defaulting to `heartbeat`. Hardcoding `heartbeat` for callbacks gave the
// turn the **reflex** pack — no `file_write` / `file_edit` / `shell_execute` /
Expand All @@ -2293,13 +2297,17 @@ export class Agent {
// therefore only *notify* on completion, never continue the task, which
// contradicts `background_exec`'s documented contract.
const cbScenario = asAgentScenario(extra.scenario) ?? 'heartbeat';
const cbOpts = buildHandleOpts({ scenario: cbScenario });
// origin 已知 → 显式回到发起轮;未知 → 不传,交由 resolveTurnSession
// 的「保持当前会话」纪律处理。
if (originSessionId) cbOpts.sessionId = originSessionId;
let reply = await this.handleMessage(
item.payload.content + markerSuffix,
undefined,
undefined,
buildHandleOpts({ sessionId: cbSessionId, scenario: cbScenario }),
cbOpts,
);
if (needsMarker) reply = await this.ensureCompletionMarker(reply, cbSessionId);
if (needsMarker) reply = await this.ensureCompletionMarker(reply, cbOpts.sessionId ?? this.currentSessionId);
resolveResponse(reply);
return reply;
}
Expand Down Expand Up @@ -2428,6 +2436,7 @@ export class Agent {
* - new :显式新对话(用户点了「新对话」)
* - system :系统/内部会话(heartbeat / task / report / announce / a2a / channel)—— 保持既有语义,
* 不动当前会话(各分支自己决定系统、任务、频道的会话 id)
* - memory :继续指定的**内存会话**(异步回调回到发起它的那一轮)
* - unknown :入口没表态 → **告警**并保持当前会话(不新建、不静默)
*
* 另外:只要 hint 带 DB 身份,就顺手在**处理该 item 的工作区**里写 DB→内存绑定。
Expand All @@ -2447,6 +2456,22 @@ export class Agent {
case 'system':
// 系统/内部会话由各自分支决定 sessionId,这里不干预当前会话。
break;
case 'memory':
// 继续指定的内存会话(`sess_*`)——异步回调回到发起它的那一轮。
// MemoryStore 会按 id 惰性从磁盘加载,所以会话冷掉也能重接。
// 解析不到(已被清理)则告警并保持当前会话:与 `unknown` 同一条纪律,
// **绝不静默新建**(旧行为就是在这里上游造 `sys_*` 空壳会话)。
if (this.memory.getSession(hint.memorySessionId)) {
this.currentSessionId = hint.memorySessionId;
} else {
log.warn('Turn names a memory session that is no longer resolvable — keeping the current session', {
agentId: this.id,
itemId: item.id,
sourceType: item.sourceType,
memorySessionId: hint.memorySessionId,
});
}
break;
case 'unknown':
log.warn('Turn has NO session identity — keeping the current session (never silently starting a new one)', {
agentId: this.id,
Expand Down Expand Up @@ -7035,6 +7060,12 @@ export class Agent {
extra: {
callbackId: cb.callbackId,
originSessionId: cb.originSessionId,
// 会话身份契约:回调属于**发起它的那一轮**。显式表态为 `memory`,
// 否则该轮会落到 `unknown`(每条回调都告警、且不写 DB→内存绑定),
// 消费端只能自己拼兜底会话 id——后台结果因此被孤立到独立会话。
sessionHint: cb.originSessionId
? { kind: 'memory' as const, memorySessionId: cb.originSessionId }
: undefined,
callbackType: cb.type,
correlationId: cb.correlationId,
exitCode: cb.exitCode,
Expand Down
11 changes: 10 additions & 1 deletion packages/core/src/session-hint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@
* channelKey / 什么都不传)决定的;一旦某个入口漏传,agent 就会静默开一个新会话
* ——「同一会话后续请求看不到历史」反复出现,根因就在这里。
*
* 契约:**每个入口都必须显式表态这一轮是什么会话**,四种可能:
* 契约:**每个入口都必须显式表态这一轮是什么会话**,五种可能:
* - new 显式新对话(用户点了「新对话」)
* - existing 继续一个已存在的 DB 会话(带 DB 身份,可带绑定内存会话与历史)
* - memory 继续一个**内存会话**(`sess_*`)——异步回调回到发起它的那一轮
* - system 系统/内部会话(heartbeat / task / report / announce / a2a / channel)
* - unknown 入口没表态 → **必须告警**,并按「不改变当前会话」处理,绝不静默新建
*
Expand All @@ -31,6 +32,13 @@ export type TurnSessionHint =
messages?: Array<{ role: string; content: string }>;
isRetry?: boolean;
}
/**
* 继续一个**内存会话**(`sess_*`)。用于「本会话里发出的东西必须由本会话处理」
* 的异步场景:`background_exec` / `a2a_reply` 等回调携带发起轮的 `originSessionId`,
* 就是这一种。没有它,回调会落到 `unknown`(告警且不写 DB→内存绑定),消费端
* 只能自己拼一个兜底会话 id —— 那正是「后台结果被孤立到独立会话」的根因。
*/
| { kind: 'memory'; memorySessionId: string }
| {
kind: 'system';
role: 'heartbeat' | 'task' | 'report' | 'announce' | 'a2a' | 'channel' | 'workflow' | 'federation';
Expand Down Expand Up @@ -124,6 +132,7 @@ export function describeTurnSessionHint(hint: TurnSessionHint): string {
switch (hint.kind) {
case 'new': return `new(db=${hint.dbSessionId ?? '-'})`;
case 'existing': return `existing(db=${hint.dbSessionId}, preferred=${hint.preferredMemorySessionId ?? '-'})`;
case 'memory': return `memory(${hint.memorySessionId})`;
case 'system': return `system(${hint.role}${hint.key ? `,key=${hint.key}` : ''})`;
case 'unknown': return `unknown(${hint.reason})`;
}
Expand Down
47 changes: 47 additions & 0 deletions packages/core/test/callback-scenario.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,4 +219,51 @@ describe('异步回调的场景回放', () => {

expect(scen).toEqual(['task_execution', 'heartbeat']);
});

// ── 会话身份契约:本会话里发出的东西,必须由本会话处理 ────────────────────
// 旧实现消费端写的是 `originSessionId ?? `sys_${this.id}_${ts}``:一旦 origin
// 缺失就**每完成一次新建一个会话**,把后台结果从原会话里孤立出去(磁盘上实测
// 累计 200+ 个 `sys_*` 空壳会话)。契约规定「推不出来就保持当前会话」。
it('会话身份:deliverCallback 把发起会话表态为 memory 契约(否则落到 unknown:告警且不绑定)', () => {
const agent = createTestAgent();
const seen: Array<Record<string, unknown>> = [];
vi.spyOn(agent as unknown as {
enqueueToMailbox: (type: string, p: { extra?: Record<string, unknown> }) => void;
}, 'enqueueToMailbox').mockImplementation((_t, p) => { seen.push(p.extra ?? {}); });

agent.deliverCallback({
callbackId: 'cb1', type: 'background_exec', deliveryMode: 'in_session',
originSessionId: 'sess_origin_1', summary: 's', content: 'c',
});
// mailbox 分支是「新注意力周期」,没有发起会话 —— 不得表态 memory。
agent.deliverCallback({
callbackId: 'cb2', type: 'wakeup', deliveryMode: 'mailbox', summary: 's', content: 'c',
});

expect(seen[0]!.sessionHint).toEqual({ kind: 'memory', memorySessionId: 'sess_origin_1' });
expect(seen[1]!.sessionHint).toBeUndefined();
});

it('会话身份:回调回到发起会话;无 origin 时保持当前会话,绝不新建 sys_* 空壳', async () => {
const agent = createTestAgent();
const seen: Array<string | undefined> = [];
vi.spyOn(agent as unknown as {
handleMessage: (c: string, s?: string, si?: unknown, o?: { sessionId?: string }) => Promise<string>;
}, 'handleMessage').mockImplementation(async (_c, _s, _si, o) => { seen.push(o?.sessionId); return 'done'; });

const core = agent as unknown as { processMailboxItemCore: (i: MailboxItem) => Promise<unknown> };

// ① 契约表态 memory → 回到发起轮
await core.processMailboxItemCore(cbItem({
originSessionId: 'sess_origin_1',
sessionHint: { kind: 'memory', memorySessionId: 'sess_origin_1' },
callbackType: 'background_exec',
}));
// ② origin 缺失 → 保持当前会话(不传 sessionId),不得兜底造 id
await core.processMailboxItemCore(cbItem({ callbackType: 'background_exec' }));

expect(seen[0]).toBe('sess_origin_1');
expect(seen[1]).toBeUndefined();
for (const sid of seen) expect(String(sid ?? '')).not.toMatch(/^sys_/);
});
});
49 changes: 41 additions & 8 deletions packages/org-manager/src/api-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3697,8 +3697,11 @@ export class APIServer {
const avatarDir = join(homedir(), '.markus', 'avatars');
mkdirSync(avatarDir, { recursive: true });
const filename = `${targetType}_${targetId}.${ext}`;
writeFileSync(join(avatarDir, filename), buf);
const avatarUrl = `/api/avatars/${filename}`;
const savedPath = join(avatarDir, filename);
writeFileSync(savedPath, buf);
// 文件名是稳定的(agent_<id>.<ext>),换了图 URL 却不变 → 浏览器会命中旧缓存。
// 追加版本号,保证每次上传都得到一个新 URL(GET 路由按 pathname 匹配,忽略 query)。
const avatarUrl = `/api/avatars/${filename}?v=${Date.now()}`;
if (targetType === 'user' && this.storage) {
this.storage.userRepo.updateAvatarUrl(targetId, avatarUrl);
const human = this.orgService.getHumanUser(targetId);
Expand All @@ -3718,7 +3721,9 @@ export class APIServer {
}
const filePath = join(homedir(), '.markus', 'avatars', filename);
if (existsSync(filePath) && statSync(filePath).isFile()) {
this.serveStaticFile(res, filePath);
// 头像可被覆盖,绝不能进 immutable 长缓存(否则换头像后前端仍显示旧图)。
// 兼容历史未带 ?v= 的存量 URL,统一走重验证。
this.serveStaticFile(res, filePath, req, { cacheControl: 'no-cache' });
} else {
this.json(res, 404, { error: 'Avatar not found' });
}
Expand Down Expand Up @@ -4342,10 +4347,18 @@ export class APIServer {
if (this.storage) {
const dbAgents = this.storage.agentRepo.listAll();
const avatarMap = new Map(dbAgents.filter((a: any) => a.avatarUrl).map((a: any) => [a.id, a.avatarUrl]));
if (avatarMap.size > 0) {
// 显示用头衔覆盖(与 avatarUrl 同一套合并方式):Team Chat 头部点改的那一行。
// 列表接口的 role 原本直接来自角色模板,这里用本 Agent 的 profile 覆盖它。
const titleMap = new Map<string, string>();
for (const row of dbAgents as Array<{ id: string; profile?: { displayTitle?: string } | null }>) {
const t = row.profile?.displayTitle;
if (t) titleMap.set(row.id, t);
}
if (avatarMap.size > 0 || titleMap.size > 0) {
agents = agents.map(a => {
const av = avatarMap.get(a.id as string);
return av ? { ...a, avatarUrl: av } : a;
const t = titleMap.get(a.id as string);
return av || t ? { ...a, ...(av ? { avatarUrl: av } : {}), ...(t ? { role: t } : {}) } : a;
});
}
}
Expand Down Expand Up @@ -5662,6 +5675,18 @@ export class APIServer {
effectiveHeartbeatMs = agent.setHeartbeatInterval(Number(body['heartbeatIntervalMs']));
}

// 显示用头衔(Team Chat 头部点改的那一行)。写入本 Agent 的 profile JSON,
// 不改角色模板 —— 模板由多个 Agent 共享,改了会串到别的 Agent。
const titleProvided = body['title'] !== undefined;
if (titleProvided) {
const title = stripHtmlTags(String(body['title'])).trim();
if (title.length > 60) {
this.json(res, 400, { error: 'title must be 60 characters or fewer' });
return;
}
cfg.profile = { ...((cfg.profile as Record<string, unknown>) ?? {}), displayTitle: title || undefined };
}

// Persist config changes to DB
if (this.storage) {
try {
Expand All @@ -5671,6 +5696,7 @@ export class APIServer {
skills: body['skills'] as unknown,
llmConfig: cfg.llmConfig,
heartbeatIntervalMs: effectiveHeartbeatMs,
profile: titleProvided ? cfg.profile : undefined,
});
} catch (persistErr) {
log.warn('Failed to persist agent config to DB', { agentId, error: String(persistErr) });
Expand Down Expand Up @@ -6348,7 +6374,7 @@ EXPLANATION_END`;
this.json(res, 200, {
id: agent.id,
name: agent.config.name,
role: agent.role.name,
role: (storedAgent?.profile as { displayTitle?: string } | null | undefined)?.displayTitle || agent.role.name,
roleDescription: agent.role.description,
agentRole: agent.config.agentRole,
avatarUrl: storedAgent?.avatarUrl ?? undefined,
Expand Down Expand Up @@ -13246,7 +13272,12 @@ EXPLANATION_END`;
return null;
}

private serveStaticFile(res: ServerResponse, filePath: string, req?: IncomingMessage): void {
private serveStaticFile(
res: ServerResponse,
filePath: string,
req?: IncomingMessage,
opts?: { cacheControl?: string },
): void {
const ext = filePath.split('.').pop()?.toLowerCase() ?? '';
const MIME: Record<string, string> = {
html: 'text/html; charset=utf-8',
Expand All @@ -13267,7 +13298,9 @@ EXPLANATION_END`;
};
const contentType = MIME[ext] ?? 'application/octet-stream';
const body = readFileSync(filePath);
const cacheControl = ext === 'html' ? 'no-cache' : 'public, max-age=31536000, immutable';
// 调用方可覆盖缓存策略:内容会变的资源(如用户/Agent 头像)文件路径稳定,
// 沿用 immutable 长缓存会导致浏览器一直显示旧图,必须改为可重验证。
const cacheControl = opts?.cacheControl ?? (ext === 'html' ? 'no-cache' : 'public, max-age=31536000, immutable');

const COMPRESSIBLE = new Set(['html', 'js', 'mjs', 'css', 'json', 'svg', 'map']);
const acceptEncoding = req?.headers?.['accept-encoding'] ?? '';
Expand Down
40 changes: 40 additions & 0 deletions packages/org-manager/test/api-server-extended.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,46 @@ describe('APIServer extended route coverage', () => {
const res = await request(ctx.server, 'GET', avatarUrl);
expect([200, 404]).toContain(res.status);
});

// 回归:头像文件名是稳定的(agent_<id>.<ext>),若 URL 不随内容变化,
// 浏览器会长期命中旧缓存 → 换头像后顶部头像与 L1/L2 列表仍是旧图。
it('avatar upload returns a versioned URL so a re-upload is not masked by cache', async () => {
const first = await request(ctx.server, 'POST', '/api/avatars/upload', {
type: 'agent',
id: AGENT_A,
image: 'data:image/png;base64,iVBORw0KGgo=',
});
expect(first.status).toBe(200);
const firstUrl = String(first.json.avatarUrl);
expect(firstUrl).toMatch(/^\/api\/avatars\/[^?]+\?v=\d+$/);

await new Promise(resolve => setTimeout(resolve, 3));
const second = await request(ctx.server, 'POST', '/api/avatars/upload', {
type: 'agent',
id: AGENT_A,
image: 'data:image/png;base64,iVBORw0KGgo=',
});
const secondUrl = String(second.json.avatarUrl);
// 文件路径不变(稳定文件名),但 URL 必须变,否则前端拿不到新图。
expect(secondUrl.split('?')[0]).toBe(firstUrl.split('?')[0]);
expect(secondUrl).not.toBe(firstUrl);
});

// 回归:头像绝不能走静态资源的 immutable 长缓存(max-age=1y)。
it('avatar responses are revalidated instead of immutably cached', async () => {
const upload = await request(ctx.server, 'POST', '/api/avatars/upload', {
type: 'agent',
id: AGENT_A,
image: 'data:image/png;base64,iVBORw0KGgo=',
});
// 带 ?v= 的新 URL 与历史遗留的不带版本号 URL,都必须可重验证。
for (const url of [String(upload.json.avatarUrl), '/api/avatars/agent_legacy.png']) {
const res = await request(ctx.server, 'GET', url);
expect(res.status).toBe(200);
expect(res.headers['Cache-Control']).toBe('no-cache');
expect(res.headers['Cache-Control']).not.toContain('immutable');
}
});
});

describe('Uploads and sessions', () => {
Expand Down
7 changes: 7 additions & 0 deletions packages/shared/src/types/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,13 @@ export interface AgentProfile {
requireApprovalFor?: string[];
/** Working directory constraint — agent tools restricted to this path */
workspacePath?: string;
/**
* 显示用头衔覆盖 —— 用户在 Team Chat 头部点改的那一行。
*
* 留空时用角色模板自带的 `role.name`。**不写回角色模板**:模板是多个 Agent
* 共享的,改模板会串到其它 Agent,所以只在本 Agent 的 `agents.profile` JSON 里存。
*/
displayTitle?: string;
}

/** 并发处理配置(智能体设置 → 并发处理)。默认开启 = 智能体并发处理消息。 */
Expand Down
3 changes: 2 additions & 1 deletion packages/storage/src/sqlite-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1169,7 +1169,7 @@ export class SqliteAgentRepo {
this.db.prepare('UPDATE agents SET avatar_url = ?, updated_at = ? WHERE id = ?').run(avatarUrl, now(), id);
}

updateConfig(id: string, data: { name?: string; agentRole?: string; skills?: unknown; llmConfig?: unknown; computeConfig?: unknown; heartbeatIntervalMs?: number }) {
updateConfig(id: string, data: { name?: string; agentRole?: string; skills?: unknown; llmConfig?: unknown; computeConfig?: unknown; heartbeatIntervalMs?: number; profile?: unknown }) {
const sets: string[] = ['updated_at = ?'];
const vals: SqlParams = [now()];
if (data.name !== undefined) { sets.push('name = ?'); vals.push(data.name); }
Expand All @@ -1178,6 +1178,7 @@ export class SqliteAgentRepo {
if (data.llmConfig !== undefined) { sets.push('llm_config = ?'); vals.push(toJson(data.llmConfig)); }
if (data.computeConfig !== undefined) { sets.push('compute_config = ?'); vals.push(toJson(data.computeConfig)); }
if (data.heartbeatIntervalMs !== undefined) { sets.push('heartbeat_interval_ms = ?'); vals.push(data.heartbeatIntervalMs); }
if (data.profile !== undefined) { sets.push('profile = ?'); vals.push(toJson(data.profile)); }
vals.push(id);
this.db.prepare(`UPDATE agents SET ${sets.join(', ')} WHERE id = ?`).run(...vals);
}
Expand Down
Loading
Loading