diff --git a/CHANGELOG.md b/CHANGELOG.md index 8218f46c2..3fdd7770b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ - Agents: treat message tool errors as failures so fallback replies still send; require `to` + `message` for `action=send`. (#717) — thanks @theglove44. - Agents: route subagent transcripts to the target agent sessions directory and add regression coverage. (#708) — thanks @xMikeMickelson. - Agents/Tools: preserve action enums when flattening tool schemas. (#708) — thanks @xMikeMickelson. +- Gateway/Agents: canonicalize main session aliases for store writes and add regression coverage. (#709) — thanks @xMikeMickelson. - Agents: reset sessions and retry when auto-compaction overflows instead of crashing the gateway. ## 2026.1.10 diff --git a/src/agents/subagent-announce.ts b/src/agents/subagent-announce.ts index c48e6e9d6..235da5d72 100644 --- a/src/agents/subagent-announce.ts +++ b/src/agents/subagent-announce.ts @@ -213,8 +213,11 @@ function buildSubagentAnnouncePrompt(params: { params.subagentReply ? `Sub-agent result: ${params.subagentReply}` : "Sub-agent result: (not available).", - 'Reply exactly "ANNOUNCE_SKIP" to stay silent.', - "Any other reply will be posted to the requester chat provider.", + "", + "**You MUST announce your result.** The requester is waiting for your response.", + "Provide a brief, useful summary of what you accomplished.", + 'Only reply "ANNOUNCE_SKIP" if the task completely failed with no useful output.', + "Your reply will be posted to the requester chat.", ].filter(Boolean); return lines.join("\n"); } diff --git a/src/agents/subagent-registry.ts b/src/agents/subagent-registry.ts index 3f9a9a5c3..14dca3f03 100644 --- a/src/agents/subagent-registry.ts +++ b/src/agents/subagent-registry.ts @@ -68,7 +68,9 @@ function ensureListener() { onAgentEvent((evt) => { if (!evt || evt.stream !== "lifecycle") return; const entry = subagentRuns.get(evt.runId); - if (!entry) return; + if (!entry) { + return; + } const phase = evt.data?.phase; if (phase === "start") { const startedAt = @@ -148,18 +150,23 @@ export function registerSubagentRun(params: { }); ensureListener(); if (archiveAfterMs) startSweeper(); - void probeImmediateCompletion(params.runId); + // Wait for subagent completion via gateway RPC (cross-process). + // The in-process lifecycle listener is a fallback for embedded runs. + void waitForSubagentCompletion(params.runId); } -async function probeImmediateCompletion(runId: string) { +// Default wait timeout: 10 minutes. This covers most subagent runs. +const DEFAULT_SUBAGENT_WAIT_TIMEOUT_MS = 10 * 60 * 1000; + +async function waitForSubagentCompletion(runId: string) { try { const wait = (await callGateway({ method: "agent.wait", params: { runId, - timeoutMs: 0, + timeoutMs: DEFAULT_SUBAGENT_WAIT_TIMEOUT_MS, }, - timeoutMs: 2000, + timeoutMs: DEFAULT_SUBAGENT_WAIT_TIMEOUT_MS + 10_000, })) as { status?: string; startedAt?: number; endedAt?: number }; if (wait?.status !== "ok" && wait?.status !== "error") return; const entry = subagentRuns.get(runId); diff --git a/src/gateway/server-bridge.ts b/src/gateway/server-bridge.ts index 6f676d26d..fa3d616a5 100644 --- a/src/gateway/server-bridge.ts +++ b/src/gateway/server-bridge.ts @@ -89,6 +89,7 @@ import { readSessionMessages, resolveGatewaySessionStoreTarget, resolveSessionModelRef, + resolveSessionStoreKey, resolveSessionTranscriptCandidates, type SessionsPatchResult, } from "./session-utils.js"; @@ -918,8 +919,12 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { clientRunId, }); + const storeKey = resolveSessionStoreKey({ + cfg, + sessionKey: p.sessionKey, + }); if (store) { - store[p.sessionKey] = sessionEntry; + store[storeKey] = sessionEntry; if (storePath) { await saveSessionStore(storePath, store); } @@ -1032,12 +1037,15 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { if (text.length > 20_000) return; const sessionKeyRaw = typeof obj.sessionKey === "string" ? obj.sessionKey.trim() : ""; - const mainKey = normalizeMainKey(loadConfig().session?.mainKey); - const sessionKey = sessionKeyRaw.length > 0 ? sessionKeyRaw : mainKey; + const cfg = loadConfig(); + const rawMainKey = normalizeMainKey(cfg.session?.mainKey); + const sessionKey = + sessionKeyRaw.length > 0 ? sessionKeyRaw : rawMainKey; const { storePath, store, entry } = loadSessionEntry(sessionKey); + const storeKey = resolveSessionStoreKey({ cfg, sessionKey }); const now = Date.now(); const sessionId = entry?.sessionId ?? randomUUID(); - store[sessionKey] = { + store[storeKey] = { sessionId, updatedAt: now, thinkingLevel: entry?.thinkingLevel, @@ -1112,9 +1120,14 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { const sessionKey = sessionKeyRaw.length > 0 ? sessionKeyRaw : `node-${nodeId}`; const { storePath, store, entry } = loadSessionEntry(sessionKey); + const nodeCfg = loadConfig(); + const nodeStoreKey = resolveSessionStoreKey({ + cfg: nodeCfg, + sessionKey, + }); const now = Date.now(); const sessionId = entry?.sessionId ?? randomUUID(); - store[sessionKey] = { + store[nodeStoreKey] = { sessionId, updatedAt: now, thinkingLevel: entry?.thinkingLevel, diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index 386755492..ba1e633ae 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -11,7 +11,6 @@ import { import { registerAgentRunContext } from "../../infra/agent-events.js"; import { resolveOutboundTarget } from "../../infra/outbound/targets.js"; import { DEFAULT_CHAT_PROVIDER } from "../../providers/registry.js"; -import { normalizeMainKey } from "../../routing/session-key.js"; import { defaultRuntime } from "../../runtime.js"; import { resolveSendPolicy } from "../../sessions/send-policy.js"; import { @@ -29,7 +28,7 @@ import { validateAgentParams, validateAgentWaitParams, } from "../protocol/index.js"; -import { loadSessionEntry } from "../session-utils.js"; +import { loadSessionEntry, resolveSessionStoreKey } from "../session-utils.js"; import { formatForLog } from "../ws-log.js"; import { waitForAgentJob } from "./agent-job.js"; import type { GatewayRequestHandlers } from "./types.js"; @@ -189,22 +188,22 @@ export const agentHandlers: GatewayRequestHandlers = { ); return; } + resolvedSessionId = sessionId; + const canonicalSessionKey = resolveSessionStoreKey({ + cfg, + sessionKey: requestedSessionKey, + }); + const agentId = resolveAgentIdFromSessionKey(canonicalSessionKey); + const mainSessionKey = resolveAgentMainSessionKey({ cfg, agentId }); if (store) { - store[requestedSessionKey] = nextEntry; + store[canonicalSessionKey] = nextEntry; if (storePath) { await saveSessionStore(storePath, store); } } - resolvedSessionId = sessionId; - const agentId = resolveAgentIdFromSessionKey(requestedSessionKey); - const mainSessionKey = resolveAgentMainSessionKey({ - cfg, - agentId, - }); - const rawMainKey = normalizeMainKey(cfg.session?.mainKey); if ( - requestedSessionKey === mainSessionKey || - requestedSessionKey === rawMainKey + canonicalSessionKey === mainSessionKey || + canonicalSessionKey === "global" ) { context.addChatRun(idem, { sessionKey: requestedSessionKey, diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index ac9647182..5f6ef9f6d 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -32,6 +32,7 @@ import { loadSessionEntry, readSessionMessages, resolveSessionModelRef, + resolveSessionStoreKey, } from "../session-utils.js"; import { formatForLog } from "../ws-log.js"; import type { GatewayRequestHandlers } from "./types.js"; @@ -306,8 +307,12 @@ export const chatHandlers: GatewayRequestHandlers = { clientRunId, }); + const storeKey = resolveSessionStoreKey({ + cfg, + sessionKey: p.sessionKey, + }); if (store) { - store[p.sessionKey] = sessionEntry; + store[storeKey] = sessionEntry; if (storePath) { await saveSessionStore(storePath, store); } diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index 9fa2cf36c..ce1afe8a6 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -1,9 +1,14 @@ +import os from "node:os"; +import path from "node:path"; import { describe, expect, test } from "vitest"; +import type { ClawdbotConfig } from "../config/config.js"; import type { SessionEntry } from "../config/sessions.js"; import { capArrayByJsonBytes, classifySessionKey, parseGroupKey, + resolveGatewaySessionStoreTarget, + resolveSessionStoreKey, } from "./session-utils.js"; describe("gateway session utils", () => { @@ -31,4 +36,62 @@ describe("gateway session utils", () => { const entry = { chatType: "group" } as SessionEntry; expect(classifySessionKey("main", entry)).toBe("group"); }); + + test("resolveSessionStoreKey maps main aliases to default agent main", () => { + const cfg = { + session: { mainKey: "work" }, + agents: { list: [{ id: "ops", default: true }] }, + } as ClawdbotConfig; + expect(resolveSessionStoreKey({ cfg, sessionKey: "main" })).toBe( + "agent:ops:work", + ); + expect(resolveSessionStoreKey({ cfg, sessionKey: "work" })).toBe( + "agent:ops:work", + ); + }); + + test("resolveSessionStoreKey canonicalizes bare keys to default agent", () => { + const cfg = { + session: { mainKey: "main" }, + agents: { list: [{ id: "ops", default: true }] }, + } as ClawdbotConfig; + expect(resolveSessionStoreKey({ cfg, sessionKey: "group:123" })).toBe( + "agent:ops:group:123", + ); + expect( + resolveSessionStoreKey({ cfg, sessionKey: "agent:alpha:main" }), + ).toBe("agent:alpha:main"); + }); + + test("resolveSessionStoreKey honors global scope", () => { + const cfg = { + session: { scope: "global", mainKey: "work" }, + agents: { list: [{ id: "ops", default: true }] }, + } as ClawdbotConfig; + expect(resolveSessionStoreKey({ cfg, sessionKey: "main" })).toBe("global"); + const target = resolveGatewaySessionStoreTarget({ cfg, key: "main" }); + expect(target.canonicalKey).toBe("global"); + expect(target.agentId).toBe("ops"); + }); + + test("resolveGatewaySessionStoreTarget uses canonical key for main alias", () => { + const storeTemplate = path.join( + os.tmpdir(), + "clawdbot-session-utils", + "{agentId}", + "sessions.json", + ); + const cfg = { + session: { mainKey: "main", store: storeTemplate }, + agents: { list: [{ id: "ops", default: true }] }, + } as ClawdbotConfig; + const target = resolveGatewaySessionStoreTarget({ cfg, key: "main" }); + expect(target.canonicalKey).toBe("agent:ops:main"); + expect(target.storeKeys).toEqual( + expect.arrayContaining(["agent:ops:main", "main"]), + ); + expect(target.storePath).toBe( + path.resolve(storeTemplate.replace("{agentId}", "ops")), + ); + }); }); diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index 9ddc90b8d..960ac9021 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -14,7 +14,7 @@ import { resolveStateDir } from "../config/paths.js"; import { buildGroupDisplayName, loadSessionStore, - resolveAgentIdFromSessionKey, + resolveMainSessionKey, resolveSessionTranscriptPath, resolveStorePath, type SessionEntry, @@ -167,13 +167,18 @@ export function capArrayByJsonBytes( export function loadSessionEntry(sessionKey: string) { const cfg = loadConfig(); const sessionCfg = cfg.session; - const agentId = resolveAgentIdFromSessionKey(sessionKey); + const canonicalKey = resolveSessionStoreKey({ cfg, sessionKey }); + const agentId = resolveSessionStoreAgentId(cfg, canonicalKey); const storePath = resolveStorePath(sessionCfg?.store, { agentId }); const store = loadSessionStore(storePath); - const parsed = parseAgentSessionKey(sessionKey); - const legacyKey = parsed?.rest; - const entry = store[sessionKey] ?? (legacyKey ? store[legacyKey] : undefined); - return { cfg, storePath, store, entry }; + const parsed = parseAgentSessionKey(canonicalKey); + const legacyKey = + parsed?.rest ?? parseAgentSessionKey(sessionKey)?.rest ?? undefined; + const entry = + store[canonicalKey] ?? + store[sessionKey] ?? + (legacyKey ? store[legacyKey] : undefined); + return { cfg, storePath, store, entry, canonicalKey }; } export function classifySessionKey( @@ -283,6 +288,38 @@ function canonicalizeSessionKeyForAgent(agentId: string, key: string): string { return `agent:${normalizeAgentId(agentId)}:${key}`; } +function resolveDefaultStoreAgentId(cfg: ClawdbotConfig): string { + return normalizeAgentId(resolveDefaultAgentId(cfg)); +} + +export function resolveSessionStoreKey(params: { + cfg: ClawdbotConfig; + sessionKey: string; +}): string { + const raw = params.sessionKey.trim(); + if (!raw) return raw; + if (raw === "global" || raw === "unknown") return raw; + const rawMainKey = normalizeMainKey(params.cfg.session?.mainKey); + if (raw === "main" || raw === rawMainKey) { + return resolveMainSessionKey(params.cfg); + } + if (raw.startsWith("agent:")) return raw; + const agentId = resolveDefaultStoreAgentId(params.cfg); + return canonicalizeSessionKeyForAgent(agentId, raw); +} + +function resolveSessionStoreAgentId( + cfg: ClawdbotConfig, + canonicalKey: string, +): string { + if (canonicalKey === "global" || canonicalKey === "unknown") { + return resolveDefaultStoreAgentId(cfg); + } + const parsed = parseAgentSessionKey(canonicalKey); + if (parsed?.agentId) return normalizeAgentId(parsed.agentId); + return resolveDefaultStoreAgentId(cfg); +} + function canonicalizeSpawnedByForAgent( agentId: string, spawnedBy?: string, @@ -304,40 +341,29 @@ export function resolveGatewaySessionStoreTarget(params: { storeKeys: string[]; } { const key = params.key.trim(); - const agentId = resolveAgentIdFromSessionKey(key); + const canonicalKey = resolveSessionStoreKey({ + cfg: params.cfg, + sessionKey: key, + }); + const agentId = resolveSessionStoreAgentId(params.cfg, canonicalKey); const storeConfig = params.cfg.session?.store; const storePath = resolveStorePath(storeConfig, { agentId }); - if (key === "global" || key === "unknown") { - return { agentId, storePath, canonicalKey: key, storeKeys: [key] }; + if (canonicalKey === "global" || canonicalKey === "unknown") { + const storeKeys = key && key !== canonicalKey ? [canonicalKey, key] : [key]; + return { agentId, storePath, canonicalKey, storeKeys }; } - const parsed = parseAgentSessionKey(key); - if (parsed) { - return { - agentId, - storePath, - canonicalKey: key, - storeKeys: [key, parsed.rest], - }; - } - - if (key.startsWith("subagent:")) { - const canonical = canonicalizeSessionKeyForAgent(agentId, key); - return { - agentId, - storePath, - canonicalKey: canonical, - storeKeys: [canonical, key], - }; - } - - const canonical = canonicalizeSessionKeyForAgent(agentId, key); + const parsed = parseAgentSessionKey(canonicalKey); + const storeKeys = new Set(); + storeKeys.add(canonicalKey); + if (parsed?.rest) storeKeys.add(parsed.rest); + if (key && key !== canonicalKey) storeKeys.add(key); return { agentId, storePath, - canonicalKey: canonical, - storeKeys: [canonical, key], + canonicalKey, + storeKeys: Array.from(storeKeys), }; }