From f274567c5c3ea7e48b8344c3f57f71820edf82bc Mon Sep 17 00:00:00 2001 From: r Date: Wed, 22 Jul 2026 21:18:10 +0800 Subject: [PATCH 1/3] feat(mcp): steer callers toward parallel, evidence-cited investigations Mirror of sicore feat/a2a-mcp-caller-guidance (the two instruction surfaces are a verbatim-sync contract). The instructions read as a serial protocol, but the A2A task layer runs independent investigations concurrently and the agent answers capability and evidence/confidence requests well when asked. Tell consuming models to fan out independent hypotheses, probe bound clusters before guessing target names, and request source citations for diagnostic conclusions. Text-only; 30 tests green. --- mcp/sicore-a2a-adapter/src/server.ts | 5 ++++- mcp/sicore-a2a-adapter/src/tools.ts | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) 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.ts b/mcp/sicore-a2a-adapter/src/tools.ts index 4ea8677e..29fb9be7 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: { From 51db8c92c35ace110e4ee348a53a8478f0e24a3e Mon Sep 17 00:00:00 2001 From: r Date: Wed, 22 Jul 2026 21:46:53 +0800 Subject: [PATCH 2/3] feat(mcp): surface a bounded progress_tail in working task responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Working responses deliberately withheld the partial report for compactness, which left pollers with only progress_chars and the last tool-event status string — enough to know the task is alive, not enough to relay what the investigation is doing. Expose the last 400 runes of the partial report as progress_tail (text + structuredContent), keeping the compactness property: the tail is bounded regardless of report size and terminal responses are unchanged. Also count progress_chars in code points instead of UTF-16 units so the number matches the sicore HTTP endpoint's rune count on CJK reports. Mirror of sicore feat/a2a-mcp-progress-tail (verbatim-sync contract). --- mcp/sicore-a2a-adapter/src/tools.test.ts | 33 +++++++++++++++++++++--- mcp/sicore-a2a-adapter/src/tools.ts | 25 +++++++++++++++--- 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/mcp/sicore-a2a-adapter/src/tools.test.ts b/mcp/sicore-a2a-adapter/src/tools.test.ts index e0e4fcdf..9996cee7 100644 --- a/mcp/sicore-a2a-adapter/src/tools.test.ts +++ b/mcp/sicore-a2a-adapter/src/tools.test.ts @@ -80,23 +80,48 @@ 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("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 29fb9be7..39c6d4a7 100644 --- a/mcp/sicore-a2a-adapter/src/tools.ts +++ b/mcp/sicore-a2a-adapter/src/tools.ts @@ -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,14 +110,28 @@ type ToolResult = { type TaskToolView = Omit & { result?: string | null; progress_chars: number; + progress_tail?: string; wait_error?: string; }; +// 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(""); +} + function taskView(task: SiclawTask, includeTerminalResult: boolean): TaskToolView { const { result, ...summary } = task; + const tail = task.is_terminal ? undefined : progressTail(result); return { ...summary, - progress_chars: result?.length ?? 0, + progress_chars: result ? Array.from(result).length : 0, + ...(tail !== undefined ? { progress_tail: tail } : {}), ...(includeTerminalResult && task.is_terminal ? { result } : {}), }; } @@ -165,8 +179,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"); From 99bf063d8a70401ca9d5759e45d46777c7a1532e Mon Sep 17 00:00:00 2001 From: r Date: Wed, 22 Jul 2026 22:06:59 +0800 Subject: [PATCH 3/3] fix(mcp): gate progress_tail on the report flag so listings stay clean MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding on the sicore mirror (MR !693): taskView added the working-task tail unconditionally, so siclaw_list_tasks rows started carrying investigation text — listings exist to recover task IDs and deliberately withhold stored reports. Rename the includeTerminalResult parameter to includeReport and gate BOTH the terminal result and the working progress_tail on it; add a regression test that a working task's listing row carries neither. Mirror of the same fix on sicore feat/a2a-mcp-progress-tail. --- mcp/sicore-a2a-adapter/src/tools.test.ts | 15 +++++++++++++++ mcp/sicore-a2a-adapter/src/tools.ts | 9 ++++++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/mcp/sicore-a2a-adapter/src/tools.test.ts b/mcp/sicore-a2a-adapter/src/tools.test.ts index 9996cee7..d5c29c56 100644 --- a/mcp/sicore-a2a-adapter/src/tools.test.ts +++ b/mcp/sicore-a2a-adapter/src/tools.test.ts @@ -122,6 +122,21 @@ describe("tool contract", () => { 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 39c6d4a7..2870fac6 100644 --- a/mcp/sicore-a2a-adapter/src/tools.ts +++ b/mcp/sicore-a2a-adapter/src/tools.ts @@ -125,14 +125,17 @@ function progressTail(text: string | null | undefined): string | undefined { return "…" + runes.slice(-PROGRESS_TAIL_MAX_RUNES).join(""); } -function taskView(task: SiclawTask, includeTerminalResult: boolean): TaskToolView { +// 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 = task.is_terminal ? undefined : progressTail(result); + const tail = includeReport && !task.is_terminal ? progressTail(result) : undefined; return { ...summary, progress_chars: result ? Array.from(result).length : 0, ...(tail !== undefined ? { progress_tail: tail } : {}), - ...(includeTerminalResult && task.is_terminal ? { result } : {}), + ...(includeReport && task.is_terminal ? { result } : {}), }; }