Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion mcp/sicore-a2a-adapter/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(" ");
Expand Down
48 changes: 44 additions & 4 deletions mcp/sicore-a2a-adapter/src/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>[] }).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"));
Expand Down
34 changes: 27 additions & 7 deletions mcp/sicore-a2a-adapter/src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const STATUS_TO_A2A: Record<string, string> = {
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: {
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -110,15 +110,32 @@ type ToolResult = {
type TaskToolView = Omit<SiclawTask, "result"> & {
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 } : {}),
};
}

Expand Down Expand Up @@ -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");
Expand Down
Loading