Merge branch 'land/pr-709'

This commit is contained in:
Peter Steinberger
2026-01-12 01:06:34 +00:00
8 changed files with 174 additions and 57 deletions

View File

@@ -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: 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: 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. - 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. - Agents: reset sessions and retry when auto-compaction overflows instead of crashing the gateway.
## 2026.1.10 ## 2026.1.10

View File

@@ -213,8 +213,11 @@ function buildSubagentAnnouncePrompt(params: {
params.subagentReply params.subagentReply
? `Sub-agent result: ${params.subagentReply}` ? `Sub-agent result: ${params.subagentReply}`
: "Sub-agent result: (not available).", : "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); ].filter(Boolean);
return lines.join("\n"); return lines.join("\n");
} }

View File

@@ -68,7 +68,9 @@ function ensureListener() {
onAgentEvent((evt) => { onAgentEvent((evt) => {
if (!evt || evt.stream !== "lifecycle") return; if (!evt || evt.stream !== "lifecycle") return;
const entry = subagentRuns.get(evt.runId); const entry = subagentRuns.get(evt.runId);
if (!entry) return; if (!entry) {
return;
}
const phase = evt.data?.phase; const phase = evt.data?.phase;
if (phase === "start") { if (phase === "start") {
const startedAt = const startedAt =
@@ -148,18 +150,23 @@ export function registerSubagentRun(params: {
}); });
ensureListener(); ensureListener();
if (archiveAfterMs) startSweeper(); 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 { try {
const wait = (await callGateway({ const wait = (await callGateway({
method: "agent.wait", method: "agent.wait",
params: { params: {
runId, 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 }; })) as { status?: string; startedAt?: number; endedAt?: number };
if (wait?.status !== "ok" && wait?.status !== "error") return; if (wait?.status !== "ok" && wait?.status !== "error") return;
const entry = subagentRuns.get(runId); const entry = subagentRuns.get(runId);

View File

@@ -89,6 +89,7 @@ import {
readSessionMessages, readSessionMessages,
resolveGatewaySessionStoreTarget, resolveGatewaySessionStoreTarget,
resolveSessionModelRef, resolveSessionModelRef,
resolveSessionStoreKey,
resolveSessionTranscriptCandidates, resolveSessionTranscriptCandidates,
type SessionsPatchResult, type SessionsPatchResult,
} from "./session-utils.js"; } from "./session-utils.js";
@@ -918,8 +919,12 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
clientRunId, clientRunId,
}); });
const storeKey = resolveSessionStoreKey({
cfg,
sessionKey: p.sessionKey,
});
if (store) { if (store) {
store[p.sessionKey] = sessionEntry; store[storeKey] = sessionEntry;
if (storePath) { if (storePath) {
await saveSessionStore(storePath, store); await saveSessionStore(storePath, store);
} }
@@ -1032,12 +1037,15 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
if (text.length > 20_000) return; if (text.length > 20_000) return;
const sessionKeyRaw = const sessionKeyRaw =
typeof obj.sessionKey === "string" ? obj.sessionKey.trim() : ""; typeof obj.sessionKey === "string" ? obj.sessionKey.trim() : "";
const mainKey = normalizeMainKey(loadConfig().session?.mainKey); const cfg = loadConfig();
const sessionKey = sessionKeyRaw.length > 0 ? sessionKeyRaw : mainKey; const rawMainKey = normalizeMainKey(cfg.session?.mainKey);
const sessionKey =
sessionKeyRaw.length > 0 ? sessionKeyRaw : rawMainKey;
const { storePath, store, entry } = loadSessionEntry(sessionKey); const { storePath, store, entry } = loadSessionEntry(sessionKey);
const storeKey = resolveSessionStoreKey({ cfg, sessionKey });
const now = Date.now(); const now = Date.now();
const sessionId = entry?.sessionId ?? randomUUID(); const sessionId = entry?.sessionId ?? randomUUID();
store[sessionKey] = { store[storeKey] = {
sessionId, sessionId,
updatedAt: now, updatedAt: now,
thinkingLevel: entry?.thinkingLevel, thinkingLevel: entry?.thinkingLevel,
@@ -1112,9 +1120,14 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
const sessionKey = const sessionKey =
sessionKeyRaw.length > 0 ? sessionKeyRaw : `node-${nodeId}`; sessionKeyRaw.length > 0 ? sessionKeyRaw : `node-${nodeId}`;
const { storePath, store, entry } = loadSessionEntry(sessionKey); const { storePath, store, entry } = loadSessionEntry(sessionKey);
const nodeCfg = loadConfig();
const nodeStoreKey = resolveSessionStoreKey({
cfg: nodeCfg,
sessionKey,
});
const now = Date.now(); const now = Date.now();
const sessionId = entry?.sessionId ?? randomUUID(); const sessionId = entry?.sessionId ?? randomUUID();
store[sessionKey] = { store[nodeStoreKey] = {
sessionId, sessionId,
updatedAt: now, updatedAt: now,
thinkingLevel: entry?.thinkingLevel, thinkingLevel: entry?.thinkingLevel,

View File

@@ -11,7 +11,6 @@ import {
import { registerAgentRunContext } from "../../infra/agent-events.js"; import { registerAgentRunContext } from "../../infra/agent-events.js";
import { resolveOutboundTarget } from "../../infra/outbound/targets.js"; import { resolveOutboundTarget } from "../../infra/outbound/targets.js";
import { DEFAULT_CHAT_PROVIDER } from "../../providers/registry.js"; import { DEFAULT_CHAT_PROVIDER } from "../../providers/registry.js";
import { normalizeMainKey } from "../../routing/session-key.js";
import { defaultRuntime } from "../../runtime.js"; import { defaultRuntime } from "../../runtime.js";
import { resolveSendPolicy } from "../../sessions/send-policy.js"; import { resolveSendPolicy } from "../../sessions/send-policy.js";
import { import {
@@ -29,7 +28,7 @@ import {
validateAgentParams, validateAgentParams,
validateAgentWaitParams, validateAgentWaitParams,
} from "../protocol/index.js"; } from "../protocol/index.js";
import { loadSessionEntry } from "../session-utils.js"; import { loadSessionEntry, resolveSessionStoreKey } from "../session-utils.js";
import { formatForLog } from "../ws-log.js"; import { formatForLog } from "../ws-log.js";
import { waitForAgentJob } from "./agent-job.js"; import { waitForAgentJob } from "./agent-job.js";
import type { GatewayRequestHandlers } from "./types.js"; import type { GatewayRequestHandlers } from "./types.js";
@@ -189,22 +188,22 @@ export const agentHandlers: GatewayRequestHandlers = {
); );
return; return;
} }
resolvedSessionId = sessionId;
const canonicalSessionKey = resolveSessionStoreKey({
cfg,
sessionKey: requestedSessionKey,
});
const agentId = resolveAgentIdFromSessionKey(canonicalSessionKey);
const mainSessionKey = resolveAgentMainSessionKey({ cfg, agentId });
if (store) { if (store) {
store[requestedSessionKey] = nextEntry; store[canonicalSessionKey] = nextEntry;
if (storePath) { if (storePath) {
await saveSessionStore(storePath, store); await saveSessionStore(storePath, store);
} }
} }
resolvedSessionId = sessionId;
const agentId = resolveAgentIdFromSessionKey(requestedSessionKey);
const mainSessionKey = resolveAgentMainSessionKey({
cfg,
agentId,
});
const rawMainKey = normalizeMainKey(cfg.session?.mainKey);
if ( if (
requestedSessionKey === mainSessionKey || canonicalSessionKey === mainSessionKey ||
requestedSessionKey === rawMainKey canonicalSessionKey === "global"
) { ) {
context.addChatRun(idem, { context.addChatRun(idem, {
sessionKey: requestedSessionKey, sessionKey: requestedSessionKey,

View File

@@ -32,6 +32,7 @@ import {
loadSessionEntry, loadSessionEntry,
readSessionMessages, readSessionMessages,
resolveSessionModelRef, resolveSessionModelRef,
resolveSessionStoreKey,
} from "../session-utils.js"; } from "../session-utils.js";
import { formatForLog } from "../ws-log.js"; import { formatForLog } from "../ws-log.js";
import type { GatewayRequestHandlers } from "./types.js"; import type { GatewayRequestHandlers } from "./types.js";
@@ -306,8 +307,12 @@ export const chatHandlers: GatewayRequestHandlers = {
clientRunId, clientRunId,
}); });
const storeKey = resolveSessionStoreKey({
cfg,
sessionKey: p.sessionKey,
});
if (store) { if (store) {
store[p.sessionKey] = sessionEntry; store[storeKey] = sessionEntry;
if (storePath) { if (storePath) {
await saveSessionStore(storePath, store); await saveSessionStore(storePath, store);
} }

View File

@@ -1,9 +1,14 @@
import os from "node:os";
import path from "node:path";
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import type { SessionEntry } from "../config/sessions.js"; import type { SessionEntry } from "../config/sessions.js";
import { import {
capArrayByJsonBytes, capArrayByJsonBytes,
classifySessionKey, classifySessionKey,
parseGroupKey, parseGroupKey,
resolveGatewaySessionStoreTarget,
resolveSessionStoreKey,
} from "./session-utils.js"; } from "./session-utils.js";
describe("gateway session utils", () => { describe("gateway session utils", () => {
@@ -31,4 +36,62 @@ describe("gateway session utils", () => {
const entry = { chatType: "group" } as SessionEntry; const entry = { chatType: "group" } as SessionEntry;
expect(classifySessionKey("main", entry)).toBe("group"); 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")),
);
});
}); });

View File

@@ -14,7 +14,7 @@ import { resolveStateDir } from "../config/paths.js";
import { import {
buildGroupDisplayName, buildGroupDisplayName,
loadSessionStore, loadSessionStore,
resolveAgentIdFromSessionKey, resolveMainSessionKey,
resolveSessionTranscriptPath, resolveSessionTranscriptPath,
resolveStorePath, resolveStorePath,
type SessionEntry, type SessionEntry,
@@ -167,13 +167,18 @@ export function capArrayByJsonBytes<T>(
export function loadSessionEntry(sessionKey: string) { export function loadSessionEntry(sessionKey: string) {
const cfg = loadConfig(); const cfg = loadConfig();
const sessionCfg = cfg.session; 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 storePath = resolveStorePath(sessionCfg?.store, { agentId });
const store = loadSessionStore(storePath); const store = loadSessionStore(storePath);
const parsed = parseAgentSessionKey(sessionKey); const parsed = parseAgentSessionKey(canonicalKey);
const legacyKey = parsed?.rest; const legacyKey =
const entry = store[sessionKey] ?? (legacyKey ? store[legacyKey] : undefined); parsed?.rest ?? parseAgentSessionKey(sessionKey)?.rest ?? undefined;
return { cfg, storePath, store, entry }; const entry =
store[canonicalKey] ??
store[sessionKey] ??
(legacyKey ? store[legacyKey] : undefined);
return { cfg, storePath, store, entry, canonicalKey };
} }
export function classifySessionKey( export function classifySessionKey(
@@ -283,6 +288,38 @@ function canonicalizeSessionKeyForAgent(agentId: string, key: string): string {
return `agent:${normalizeAgentId(agentId)}:${key}`; 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( function canonicalizeSpawnedByForAgent(
agentId: string, agentId: string,
spawnedBy?: string, spawnedBy?: string,
@@ -304,40 +341,29 @@ export function resolveGatewaySessionStoreTarget(params: {
storeKeys: string[]; storeKeys: string[];
} { } {
const key = params.key.trim(); 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 storeConfig = params.cfg.session?.store;
const storePath = resolveStorePath(storeConfig, { agentId }); const storePath = resolveStorePath(storeConfig, { agentId });
if (key === "global" || key === "unknown") { if (canonicalKey === "global" || canonicalKey === "unknown") {
return { agentId, storePath, canonicalKey: key, storeKeys: [key] }; const storeKeys = key && key !== canonicalKey ? [canonicalKey, key] : [key];
return { agentId, storePath, canonicalKey, storeKeys };
} }
const parsed = parseAgentSessionKey(key); const parsed = parseAgentSessionKey(canonicalKey);
if (parsed) { const storeKeys = new Set<string>();
return { storeKeys.add(canonicalKey);
agentId, if (parsed?.rest) storeKeys.add(parsed.rest);
storePath, if (key && key !== canonicalKey) storeKeys.add(key);
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);
return { return {
agentId, agentId,
storePath, storePath,
canonicalKey: canonical, canonicalKey,
storeKeys: [canonical, key], storeKeys: Array.from(storeKeys),
}; };
} }