From 0c960fa859aeaf5f17672924716f5beb6ea6c56e Mon Sep 17 00:00:00 2001 From: Harry Bayliss Date: Fri, 15 May 2026 16:08:07 +0100 Subject: [PATCH] Clarify sub-agent reply routing in MCP tool descriptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A sub-agent's reply to send_message lands in the caller's own pane tagged [sub-agent:], not in the sub-agent's output. The descriptions for wait_for_pattern, send_message, both timer_fire_when_idle_*, and the server-instructions preamble now spell this out, along with the canonical send_message → timer_fire_when_idle_any → read-own-pane pattern. help('readiness') and help('coordination') updated to match. Previously agents reached for wait_for_pattern on the sub-agent and deadlocked until timeout because the reply had already been delivered to their own pane. --- CHANGELOG.md | 13 +++++++++++++ internal/app/host.go | 18 ++++++++++++------ internal/mcp/protocol.go | 10 +++++----- 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 337fd35..c193e15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,19 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). child agent, even though you were still within that thread. ### Changed +- MCP tool descriptions and `help('coordination')` / + `help('readiness')` now spell out that a sub-agent's reply to + `send_message` lands in the caller's own pane (tagged + `[sub-agent:]`), not in the sub-agent's output. The canonical + wait-for-reply pattern — `send_message` → `timer_fire_when_idle_any` + on the sub-agent → read your own pane — is now called out on + `send_message`, `wait_for_pattern`, both `timer_fire_when_idle_*`, + the help topics, and the server-instructions preamble every agent + reads at startup. Previously `wait_for_pattern` was the obvious + blocking primitive in the catalog, and agents routinely called it + against the sub-agent for a reply that had already arrived in their + own pane, deadlocking until the wait timed out. No behaviour + changes; descriptions only. - Agent-initiated `spawn_agent` and `spawn_process` MCP calls no longer steal viewport focus from the currently active tab. The new child still appears in the sidebar and tab bar; switch to it diff --git a/internal/app/host.go b/internal/app/host.go index 9c77990..bdb36c7 100644 --- a/internal/app/host.go +++ b/internal/app/host.go @@ -1134,9 +1134,10 @@ func helpFor(topic string) mcp.HelpResponse { } case "coordination": return mcp.HelpResponse{ - Topic: "coordination", - Content: "send_message tags the message with the caller's role (parent → [orchestrator], child → [sub-agent:]). Siblings must route through their parent. request_human_attention raises a UI notification when you can't safely decide.", - RelatedTools: []string{"send_message", "request_human_attention"}, + Topic: "coordination", + Content: "send_message tags the message with the caller's role (parent → [orchestrator], child → [sub-agent:]). Siblings must route through their parent. request_human_attention raises a UI notification when you can't safely decide.\n\n" + + "Reply routing: a sub-agent's reply to your send_message lands in YOUR pane tagged `[sub-agent:]`, not in the sub-agent's output. Anti-pattern: `wait_for_pattern(sub_agent, …)` to wait for a reply — the sub-agent is already idle, its output won't change, and the call spins to timeout. Pattern: send_message → timer_fire_when_idle_any([sub_agent_id], body=\"[system] sub-agent finished\") → when the timer fires, the reply is already queued as your next user turn (or visible via get_process_output on your own pane).", + RelatedTools: []string{"send_message", "request_human_attention", "timer_fire_when_idle_any", "timer_fire_when_idle_all"}, } case "scratchpads": return mcp.HelpResponse{ @@ -1161,9 +1162,14 @@ func helpFor(topic string) mcp.HelpResponse { } case "readiness": return mcp.HelpResponse{ - Topic: "readiness", - Content: "A pane is 'idle' once nothing has been written to its PTY for ~1s (SPEC §11). Treat idle as a signal to read, not a guarantee of completion. wait_for_pattern lets you wait on a known terminal marker for stronger evidence.", - RelatedTools: []string{"wait_for_pattern", "get_process_status"}, + Topic: "readiness", + Content: "A pane is 'idle' once nothing has been written to its PTY for ~1s (SPEC §11). Treat idle as a signal to read, not a guarantee of completion.\n\n" + + "Waiting for a sub-agent's reply (canonical pattern):\n" + + " 1. send_message(sub_agent_id, request)\n" + + " 2. timer_fire_when_idle_any(watched=[sub_agent_id], body=\"[system] sub-agent done\")\n" + + " 3. When the timer fires you re-enter as a fresh user turn; the sub-agent's reply is already in your own pane tagged `[sub-agent:]` (read via get_process_output on yourself if you need it explicitly).\n\n" + + "wait_for_pattern is for waiting on text a process emits in its OWN output (a shell prompt, a build's \"tests passed\" line). It does NOT see send_message replies, because those land in the caller's pane, not the target's — calling wait_for_pattern on a sub-agent to wait for its reply deadlocks until timeout.", + RelatedTools: []string{"wait_for_pattern", "get_process_status", "timer_fire_when_idle_any", "send_message"}, } case "permissions": return mcp.HelpResponse{ diff --git a/internal/mcp/protocol.go b/internal/mcp/protocol.go index 07e33aa..73cb65b 100644 --- a/internal/mcp/protocol.go +++ b/internal/mcp/protocol.go @@ -43,7 +43,7 @@ var serverInfo = map[string]any{ // up as sub-agents and won't be tied into the patterm lifecycle. // // Keep this short — clients vary in how much they surface to the LLM. -const serverInstructions = "You are already running INSIDE patterm; the `patterm` MCP server is connected over the same stdio MCP transport you use for any other MCP server. Use the MCP tools you see in tools/list — do NOT (a) try to launch `patterm` or `patterm mcp-stdio` yourself, (b) poke the Unix socket through perl / nc / socat / curl, or (c) shell out to `claude` / `codex` / `opencode` to start a peer. Any of those bypasses caller-identity and the new agent will land as a stray top-level tab instead of a child under you. Start with `whoami` for your role and the full tool list, then `help('topics')` for orientation. `spawn_agent` is the only correct way to start a sub-agent; `spawn_process` is for non-LLM commands; `list_processes` / `get_process_output` inspect them; `send_input` / `send_message` drive them. Whatever you spawn is yours to `close_process` when done." +const serverInstructions = "You are already running INSIDE patterm; the `patterm` MCP server is connected over the same stdio MCP transport you use for any other MCP server. Use the MCP tools you see in tools/list — do NOT (a) try to launch `patterm` or `patterm mcp-stdio` yourself, (b) poke the Unix socket through perl / nc / socat / curl, or (c) shell out to `claude` / `codex` / `opencode` to start a peer. Any of those bypasses caller-identity and the new agent will land as a stray top-level tab instead of a child under you. Start with `whoami` for your role and the full tool list, then `help('topics')` for orientation. `spawn_agent` is the only correct way to start a sub-agent; `spawn_process` is for non-LLM commands; `list_processes` / `get_process_output` inspect them; `send_input` / `send_message` drive them. Whatever you spawn is yours to `close_process` when done. When you `send_message` a sub-agent, its reply comes back into YOUR pane as `[sub-agent:] …`, not into the sub-agent's output — to wait for it, use `timer_fire_when_idle_any([sub_agent])` and then read your own pane; do NOT `wait_for_pattern` on the sub-agent, that will deadlock until timeout." // toolDescriptor is the shape returned by `tools/list`. inputSchema is // a JSON Schema object — we provide a minimal `{type: "object"}` schema @@ -219,7 +219,7 @@ func toolCatalog() []toolDescriptor { }, { Name: "wait_for_pattern", - Description: "Block until pattern appears in process output or timeout elapses.", + Description: "Block until pattern appears in the TARGET process's own output, or timeout elapses. Use this for waiting on text the target itself will emit (a shell prompt, a build's \"tests passed\" line, etc.). Anti-pattern: do NOT use this to wait for a sub-agent's reply to send_message — replies are routed into the CALLER's pane tagged `[sub-agent:]`, not into the sub-agent's output, so this call will spin to timeout. For sub-agent coordination use `timer_fire_when_idle_any` and then read your own pane.", InputSchema: objectSchema(map[string]any{ "process_id": stringProp("Target process id."), "pattern": stringProp("Regex pattern."), @@ -249,7 +249,7 @@ func toolCatalog() []toolDescriptor { }, { Name: "send_message", - Description: "Deliver a text message to another process as orchestrator-owned input.", + Description: "Deliver a text message to another process as orchestrator-owned input. Fire-and-forget: returns immediately, without waiting for the recipient to read or act. If the recipient replies via send_message, that reply arrives in YOUR pane tagged `[sub-agent:]` (child→parent) or `[orchestrator]` (parent→child) — NOT in the recipient's output. To wait for a sub-agent's reply, schedule `timer_fire_when_idle_any([sub_agent_id], body=…)` and then read your own pane when the timer fires. Do not `wait_for_pattern` on the recipient for a reply; it will deadlock.", InputSchema: objectSchema(map[string]any{ "target_process_id": stringProp("Recipient process id."), "message": stringProp("Message body."), @@ -283,7 +283,7 @@ func toolCatalog() []toolDescriptor { }, { Name: "timer_fire_when_idle_any", - Description: "Schedule a timer that fires when any watched process enters idle (already-idle entries excluded), or when max_wait_seconds elapses.", + Description: "Canonical way to wait for a sub-agent to finish working: send_message the sub-agent, then schedule this with watched=[sub_agent_id]; when it fires, the reply is already sitting in your own pane tagged `[sub-agent:]`. Schedules a timer that fires when any watched process enters idle (already-idle entries excluded), or when max_wait_seconds elapses.", InputSchema: objectSchema(map[string]any{ "watched": arrayOfStringsProp("Process ids to watch."), "body": stringProp("Message delivered verbatim to the owning agent when the timer fires."), @@ -294,7 +294,7 @@ func toolCatalog() []toolDescriptor { }, { Name: "timer_fire_when_idle_all", - Description: "Schedule a timer that fires when all watched processes are idle (already-idle entries count as satisfied), or when max_wait_seconds elapses.", + Description: "Canonical way to wait for several sub-agents to finish working in parallel: send_message each one, then schedule this with watched=[…ids]; when it fires, each reply is in your own pane tagged `[sub-agent:]`. Schedules a timer that fires when all watched processes are idle (already-idle entries count as satisfied), or when max_wait_seconds elapses.", InputSchema: objectSchema(map[string]any{ "watched": arrayOfStringsProp("Process ids to watch."), "body": stringProp("Message delivered verbatim to the owning agent when the timer fires."),