Structured subagent announce output + include run outcome (#835)

* docs: clarify subagent announce status

* Make subagent announce structured and include run outcome

* fix: stabilize sub-agent announce status (#835) (thanks @roshanasingh4)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Roshan Singh
2026-01-15 10:18:07 +05:30
committed by GitHub
parent 2e0325e3bf
commit 1baa55c145
6 changed files with 244 additions and 6 deletions

View File

@@ -0,0 +1,103 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const sendSpy = vi.fn(async () => ({}));
vi.mock("../gateway/call.js", () => ({
callGateway: vi.fn(async (req: unknown) => {
const typed = req as { method?: string; params?: { message?: string } };
if (typed.method === "send") {
return await sendSpy(typed);
}
if (typed.method === "agent.wait") {
return { status: "error", startedAt: 10, endedAt: 20, error: "boom" };
}
if (typed.method === "sessions.patch") return {};
if (typed.method === "sessions.delete") return {};
return {};
}),
}));
vi.mock("./tools/agent-step.js", () => ({
runAgentStep: vi.fn(async () => "did some stuff"),
readLatestAssistantReply: vi.fn(async () => "raw subagent reply"),
}));
vi.mock("./tools/sessions-announce-target.js", () => ({
resolveAnnounceTarget: vi.fn(async () => ({
provider: "telegram",
to: "+15550001111",
accountId: "default",
})),
}));
vi.mock("./tools/sessions-send-helpers.js", () => ({
isAnnounceSkip: () => false,
}));
vi.mock("../config/sessions.js", () => ({
loadSessionStore: vi.fn(async () => ({ entries: {} })),
resolveAgentIdFromSessionKey: () => "main",
resolveStorePath: () => "/tmp/sessions.json",
}));
vi.mock("../config/config.js", () => ({
loadConfig: () => ({
session: { mainKey: "agent:main:main" },
}),
}));
describe("subagent announce formatting", () => {
beforeEach(() => {
sendSpy.mockClear();
});
it("wraps unstructured announce into Status/Result/Notes", async () => {
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:test",
childRunId: "run-123",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "do thing",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: true,
startedAt: 10,
endedAt: 20,
});
expect(sendSpy).toHaveBeenCalled();
const msg = sendSpy.mock.calls[0]?.[0]?.params?.message as string;
expect(msg).toContain("Status:");
expect(msg).toContain("Status: error");
expect(msg).toContain("Result:");
expect(msg).toContain("Notes:");
expect(msg).toContain("boom");
});
it("keeps runtime status even when announce reply is structured", async () => {
const agentStep = await import("./tools/agent-step.js");
vi.mocked(agentStep.runAgentStep).mockResolvedValueOnce(
"- **Status:** success\n\n- **Result:** did some stuff\n\n- **Notes:** all good",
);
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:test",
childRunId: "run-456",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "do thing",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: true,
startedAt: 10,
endedAt: 20,
});
const msg = sendSpy.mock.calls[0]?.[0]?.params?.message as string;
expect(msg).toContain("Status: error");
expect(msg).toContain("Result:");
expect(msg).toContain("Notes:");
});
});

View File

@@ -183,6 +183,85 @@ export function buildSubagentSystemPrompt(params: {
return lines.join("\n");
}
export type SubagentRunOutcome = {
status: "ok" | "error" | "timeout" | "unknown";
error?: string;
};
const ANNOUNCE_SECTION_RE = /^\s*[-*]?\s*(?:\*\*)?(status|result|notes)(?:\*\*)?\s*:\s*(.*)$/i;
function parseAnnounceSections(announce: string) {
const sections = {
status: [] as string[],
result: [] as string[],
notes: [] as string[],
};
let current: keyof typeof sections | null = null;
let sawSection = false;
for (const line of announce.split(/\r?\n/)) {
const match = line.match(ANNOUNCE_SECTION_RE);
if (match) {
const key = match[1]?.toLowerCase() as keyof typeof sections;
current = key;
sawSection = true;
const rest = match[2]?.trim();
if (rest) sections[key].push(rest);
continue;
}
if (current) sections[current].push(line);
}
const normalize = (lines: string[]) => {
const joined = lines.join("\n").trim();
return joined.length > 0 ? joined : undefined;
};
return {
sawSection,
status: normalize(sections.status),
result: normalize(sections.result),
notes: normalize(sections.notes),
};
}
function normalizeAnnounceBody(params: {
outcome: SubagentRunOutcome;
announceReply: string;
statsLine?: string;
}) {
const announce = params.announceReply.trim();
const statsLine = params.statsLine?.trim();
const statusLabel =
params.outcome.status === "ok"
? "success"
: params.outcome.status === "timeout"
? "timeout"
: params.outcome.status === "unknown"
? "unknown"
: "error";
const parsed = parseAnnounceSections(announce);
const resultText = parsed.result ?? (announce || "(not available)");
const notesParts: string[] = [];
if (parsed.notes) notesParts.push(parsed.notes);
if (params.outcome.error) notesParts.push(`- Error: ${params.outcome.error}`);
const notesBlock = notesParts.length ? notesParts.join("\n") : "- (none)";
const message = [
`Status: ${statusLabel}`,
"",
"Result:",
resultText,
"",
"Notes:",
notesBlock,
].join("\n");
return statsLine ? `${message}\n\n${statsLine}` : message;
}
function buildSubagentAnnouncePrompt(params: {
requesterSessionKey?: string;
requesterChannel?: string;
@@ -202,6 +281,10 @@ function buildSubagentAnnouncePrompt(params: {
"",
"**You MUST announce your result.** The requester is waiting for your response.",
"Provide a brief, useful summary of what you accomplished.",
"Reply with Result and Notes only (no Status line; status is added by the system).",
"Format:",
"Result: <summary>",
"Notes: <extra context>",
'Only reply "ANNOUNCE_SKIP" if the task completely failed with no useful output.',
"Your reply will be posted to the requester chat.",
].filter(Boolean);
@@ -222,10 +305,12 @@ export async function runSubagentAnnounceFlow(params: {
startedAt?: number;
endedAt?: number;
label?: string;
outcome?: SubagentRunOutcome;
}): Promise<boolean> {
let didAnnounce = false;
try {
let reply = params.roundOneReply;
let outcome: SubagentRunOutcome | undefined = params.outcome;
if (!reply && params.waitForCompletion !== false) {
const waitMs = Math.min(params.timeoutMs, 60_000);
const wait = (await callGateway({
@@ -235,8 +320,30 @@ export async function runSubagentAnnounceFlow(params: {
timeoutMs: waitMs,
},
timeoutMs: waitMs + 2000,
})) as { status?: string };
if (wait?.status !== "ok") return false;
})) as {
status?: string;
error?: string;
startedAt?: number;
endedAt?: number;
};
if (wait?.status === "timeout") {
outcome = { status: "timeout" };
} else if (wait?.status === "error") {
outcome = { status: "error", error: wait.error };
} else if (wait?.status === "ok") {
outcome = { status: "ok" };
}
if (typeof wait?.startedAt === "number" && !params.startedAt) {
params.startedAt = wait.startedAt;
}
if (typeof wait?.endedAt === "number" && !params.endedAt) {
params.endedAt = wait.endedAt;
}
if (wait?.status === "timeout") {
// No lifecycle end seen before timeout. Still attempt an announce so
// requesters are not left hanging.
if (!outcome) outcome = { status: "timeout" };
}
reply = await readLatestAssistantReply({
sessionKey: params.childSessionKey,
});
@@ -248,6 +355,8 @@ export async function runSubagentAnnounceFlow(params: {
});
}
if (!outcome) outcome = { status: "unknown" };
const announceTarget = await resolveAnnounceTarget({
sessionKey: params.requesterSessionKey,
displayKey: params.requesterDisplayKey,
@@ -278,7 +387,11 @@ export async function runSubagentAnnounceFlow(params: {
startedAt: params.startedAt,
endedAt: params.endedAt,
});
const message = statsLine ? `${announceReply.trim()}\n\n${statsLine}` : announceReply.trim();
const message = normalizeAnnounceBody({
outcome,
announceReply,
statsLine,
});
await callGateway({
method: "send",

View File

@@ -1,7 +1,7 @@
import { loadConfig } from "../config/config.js";
import { callGateway } from "../gateway/call.js";
import { onAgentEvent } from "../infra/agent-events.js";
import { runSubagentAnnounceFlow } from "./subagent-announce.js";
import { runSubagentAnnounceFlow, type SubagentRunOutcome } from "./subagent-announce.js";
import {
loadSubagentRegistryFromDisk,
saveSubagentRegistryToDisk,
@@ -20,6 +20,7 @@ export type SubagentRunRecord = {
createdAt: number;
startedAt?: number;
endedAt?: number;
outcome?: SubagentRunOutcome;
archiveAtMs?: number;
announceCompletedAt?: number;
announceHandled: boolean;
@@ -62,6 +63,7 @@ function resumeSubagentRun(runId: string) {
startedAt: entry.startedAt,
endedAt: entry.endedAt,
label: entry.label,
outcome: entry.outcome,
});
void announce.then((didAnnounce) => {
finalizeSubagentAnnounce(runId, entry.cleanup, didAnnounce);
@@ -176,6 +178,12 @@ function ensureListener() {
const endedAt =
typeof evt.data?.endedAt === "number" ? (evt.data.endedAt as number) : Date.now();
entry.endedAt = endedAt;
if (phase === "error") {
const error = typeof evt.data?.error === "string" ? (evt.data.error as string) : undefined;
entry.outcome = { status: "error", error };
} else {
entry.outcome = { status: "ok" };
}
persistSubagentRuns();
if (!beginSubagentAnnounce(evt.runId)) {
@@ -194,6 +202,7 @@ function ensureListener() {
startedAt: entry.startedAt,
endedAt: entry.endedAt,
label: entry.label,
outcome: entry.outcome,
});
void announce.then((didAnnounce) => {
finalizeSubagentAnnounce(evt.runId, entry.cleanup, didAnnounce);
@@ -272,7 +281,7 @@ async function waitForSubagentCompletion(runId: string, waitTimeoutMs: number) {
timeoutMs,
},
timeoutMs: timeoutMs + 10_000,
})) as { status?: string; startedAt?: number; endedAt?: number };
})) as { status?: string; startedAt?: number; endedAt?: number; error?: string };
if (wait?.status !== "ok" && wait?.status !== "error") return;
const entry = subagentRuns.get(runId);
if (!entry) return;
@@ -289,6 +298,11 @@ async function waitForSubagentCompletion(runId: string, waitTimeoutMs: number) {
entry.endedAt = Date.now();
mutated = true;
}
entry.outcome =
wait.status === "error"
? { status: "error", error: wait.error }
: { status: "ok" };
mutated = true;
if (mutated) persistSubagentRuns();
if (!beginSubagentAnnounce(runId)) return;
const announce = runSubagentAnnounceFlow({
@@ -304,6 +318,7 @@ async function waitForSubagentCompletion(runId: string, waitTimeoutMs: number) {
startedAt: entry.startedAt,
endedAt: entry.endedAt,
label: entry.label,
outcome: entry.outcome,
});
void announce.then((didAnnounce) => {
finalizeSubagentAnnounce(runId, entry.cleanup, didAnnounce);