diff --git a/mcp/sicore-a2a-adapter/src/server.ts b/mcp/sicore-a2a-adapter/src/server.ts index b65ccd66..562a8d78 100644 --- a/mcp/sicore-a2a-adapter/src/server.ts +++ b/mcp/sicore-a2a-adapter/src/server.ts @@ -6,7 +6,10 @@ import { createToolHandler, TOOL_DEFINITIONS } from "./tools.js"; export const SERVER_INSTRUCTIONS = [ "Use siclaw_investigate for operational questions that require the configured Siclaw SRE agent.", - "When it returns a non-terminal task, keep the current turn open and call siclaw_wait_task with the same task_id until terminal, unless the user requests fire-and-forget, asks to stop, or the overall investigation deadline is exhausted.", + "Independent questions or hypotheses can run concurrently: submit each as its own task with a distinct (or omitted) context_id instead of investigating them serially.", + "When unsure what the agent can reach, first ask it to list its bound clusters and data sources.", + "For diagnostic questions, ask it to cite the data sources it consulted and its confidence in the conclusion.", + "When a call returns a non-terminal task, keep the current turn open and call siclaw_wait_task with the same task_id until terminal, unless the user requests fire-and-forget, asks to stop, or the overall investigation deadline is exhausted.", "Never resubmit the same question merely because a task is still working.", "Use siclaw_list_tasks to recover server-side tasks after a client restart.", ].join(" "); diff --git a/mcp/sicore-a2a-adapter/src/tools.test.ts b/mcp/sicore-a2a-adapter/src/tools.test.ts index e0e4fcdf..d5c29c56 100644 --- a/mcp/sicore-a2a-adapter/src/tools.test.ts +++ b/mcp/sicore-a2a-adapter/src/tools.test.ts @@ -80,23 +80,63 @@ describe("tool contract", () => { expect(result.structuredContent).toMatchObject({ state: "completed", result: "root cause" }); }); - it("keeps a working response compact until the terminal result", async () => { + it("keeps a working response compact but surfaces a progress tail", async () => { const api = fakeApi(); vi.mocked(api.getTask).mockResolvedValue({ ...task(), - result: "partial evidence that should not be repeated", + result: "partial evidence worth relaying to the caller", updated_at: "2026-07-18T00:00:00.000Z", }); const result = await createToolHandler(api)("siclaw_get_task", { task_id: "task-1" }); - expect(result.content[0].text).not.toContain("partial evidence that should not be repeated"); + expect(result.content[0].text).toContain("progress_tail (latest excerpt):"); + expect(result.content[0].text).toContain("partial evidence worth relaying to the caller"); expect(result.content[0].text).toContain("siclaw_wait_task"); expect(result.structuredContent).toMatchObject({ state: "working", - progress_chars: 44, + progress_chars: 45, + progress_tail: "partial evidence worth relaying to the caller", }); expect(result.structuredContent).not.toHaveProperty("result"); }); + it("bounds the progress tail to the last 400 runes of a long partial report", async () => { + const api = fakeApi(); + const partial = "x".repeat(700) + "the latest narrative sentence"; + vi.mocked(api.getTask).mockResolvedValue({ + ...task(), + result: partial, + updated_at: "2026-07-18T00:00:00.000Z", + }); + const result = await createToolHandler(api)("siclaw_get_task", { task_id: "task-1" }); + const tail = (result.structuredContent as { progress_tail: string }).progress_tail; + expect(tail.startsWith("…")).toBe(true); + expect(tail.endsWith("the latest narrative sentence")).toBe(true); + expect(Array.from(tail).length).toBe(401); + expect(result.structuredContent).toMatchObject({ progress_chars: 729 }); + expect(result.content[0].text).not.toContain(partial); + }); + + it("omits the progress tail from terminal responses", async () => { + const result = await createToolHandler(fakeApi())("siclaw_get_task", { task_id: "task-1" }); + expect(result.structuredContent).not.toHaveProperty("progress_tail"); + expect(result.structuredContent).toMatchObject({ state: "completed", result: "root cause" }); + }); + + it("withholds the progress tail from task listings", async () => { + const api = fakeApi(); + vi.mocked(api.listTasks).mockResolvedValue({ + tasks: [{ ...task(), result: "partial investigation narrative" }], + total_size: 1, + page_size: 20, + next_page_token: null, + }); + const result = await createToolHandler(api)("siclaw_list_tasks", {}); + const row = (result.structuredContent as { tasks: Record[] }).tasks[0]; + expect(row).not.toHaveProperty("progress_tail"); + expect(row).not.toHaveProperty("result"); + expect(result.content[0].text).not.toContain("partial investigation narrative"); + }); + it("keeps the submitted task_id when the bounded wait fails after submission", async () => { const api = fakeApi(); vi.mocked(api.waitForTask).mockRejectedValue(new Error("Sicore A2A request timed out")); diff --git a/mcp/sicore-a2a-adapter/src/tools.ts b/mcp/sicore-a2a-adapter/src/tools.ts index 4ea8677e..2870fac6 100644 --- a/mcp/sicore-a2a-adapter/src/tools.ts +++ b/mcp/sicore-a2a-adapter/src/tools.ts @@ -12,7 +12,7 @@ const STATUS_TO_A2A: Record = { export const TOOL_DEFINITIONS = [ { name: "siclaw_investigate", - description: "Ask the configured Siclaw SRE agent to investigate an operational question. This creates an asynchronous Sicore A2A task. Reuse context_id to continue the same investigation. If the returned task is not terminal, do not submit it again: call siclaw_wait_task until it finishes unless the user explicitly requested fire-and-forget. The configured A2A key fixes which Siclaw agent is used.", + description: "Ask the configured Siclaw SRE agent to investigate an operational question. This creates an asynchronous Sicore A2A task. Reuse context_id to continue the same investigation; tasks with distinct context_ids run in parallel server-side, so submit independent hypotheses concurrently. If the returned task is not terminal, do not submit it again: call siclaw_wait_task until it finishes unless the user explicitly requested fire-and-forget. The configured A2A key fixes which Siclaw agent is used.", inputSchema: { type: "object", properties: { @@ -41,7 +41,7 @@ export const TOOL_DEFINITIONS = [ }, { name: "siclaw_wait_task", - description: "Wait for an existing Siclaw investigation without creating another task. Use this as the same-turn watchdog: call it repeatedly while the task is non-terminal, unless the user asks to stop or the overall investigation deadline is exhausted. Working responses are compact; the full report is returned once the task reaches a terminal state.", + description: "Wait for an existing Siclaw investigation without creating another task. Use this as the same-turn watchdog: call it repeatedly while the task is non-terminal, unless the user asks to stop or the overall investigation deadline is exhausted. Working responses are compact — a short progress_tail excerpt of the investigation narrative plus counters; the full report is returned once the task reaches a terminal state.", inputSchema: { type: "object", properties: { @@ -110,15 +110,32 @@ type ToolResult = { type TaskToolView = Omit & { result?: string | null; progress_chars: number; + progress_tail?: string; wait_error?: string; }; -function taskView(task: SiclawTask, includeTerminalResult: boolean): TaskToolView { +// Working responses withhold the partial report for compactness, but expose a +// bounded tail so pollers can relay what the investigation is doing right now. +const PROGRESS_TAIL_MAX_RUNES = 400; + +function progressTail(text: string | null | undefined): string | undefined { + if (!text) return undefined; + const runes = Array.from(text); + if (runes.length <= PROGRESS_TAIL_MAX_RUNES) return text; + return "…" + runes.slice(-PROGRESS_TAIL_MAX_RUNES).join(""); +} + +// includeReport gates all report content — the full result on terminal tasks +// AND the progress_tail excerpt on working ones. Listings pass false: they +// exist to recover task IDs and must never carry investigation text. +function taskView(task: SiclawTask, includeReport: boolean): TaskToolView { const { result, ...summary } = task; + const tail = includeReport && !task.is_terminal ? progressTail(result) : undefined; return { ...summary, - progress_chars: result?.length ?? 0, - ...(includeTerminalResult && task.is_terminal ? { result } : {}), + progress_chars: result ? Array.from(result).length : 0, + ...(tail !== undefined ? { progress_tail: tail } : {}), + ...(includeReport && task.is_terminal ? { result } : {}), }; } @@ -165,8 +182,11 @@ function taskText(task: SiclawTask, waitError?: string): string { if (waitError) lines.push(`wait_error: ${waitError} (status polling failed, but the task was already created)`); if (task.is_terminal && task.result) lines.push("", task.result); if (!task.is_terminal) { - const progressChars = task.result?.length ?? 0; - if (progressChars > 0) lines.push(`progress_chars: ${progressChars} (partial report withheld until terminal)`); + const progressChars = task.result ? Array.from(task.result).length : 0; + if (progressChars > 0) { + lines.push(`progress_chars: ${progressChars} (full report is withheld until terminal)`); + lines.push("progress_tail (latest excerpt):", progressTail(task.result)!); + } lines.push("", "The investigation is still running. Call siclaw_wait_task with the task_id; do not submit the same investigation again."); } return lines.join("\n");