Reduce MCP token usage
This commit is contained in:
@@ -3,6 +3,8 @@ package mcp
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/hjbdev/patterm/internal/scratchpad"
|
||||
)
|
||||
|
||||
// MCP protocol surface. The patterm server originally exposed each
|
||||
@@ -43,7 +45,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. When you `send_message` a sub-agent, its reply comes back into YOUR pane as `[sub-agent:<name>] …`, 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."
|
||||
const serverInstructions = "You are inside patterm. Use these MCP tools; do not launch patterm or poke its Unix socket yourself. Use spawn_agent for sub-agents, close spawned panes when done, and use timer_fire_when_idle_* instead of wait_for_pattern to wait for send_message replies."
|
||||
|
||||
// toolDescriptor is the shape returned by `tools/list`. inputSchema is
|
||||
// a JSON Schema object — we provide a minimal `{type: "object"}` schema
|
||||
@@ -76,37 +78,41 @@ func objectSchema(properties map[string]any, required []string) map[string]any {
|
||||
}
|
||||
|
||||
func stringProp(desc string) map[string]any {
|
||||
return map[string]any{"type": "string", "description": desc}
|
||||
_ = desc
|
||||
return map[string]any{"type": "string"}
|
||||
}
|
||||
|
||||
func numberProp(desc string) map[string]any {
|
||||
return map[string]any{"type": "number", "description": desc}
|
||||
_ = desc
|
||||
return map[string]any{"type": "number"}
|
||||
}
|
||||
|
||||
func integerProp(desc string) map[string]any {
|
||||
return map[string]any{"type": "integer", "description": desc}
|
||||
_ = desc
|
||||
return map[string]any{"type": "integer"}
|
||||
}
|
||||
|
||||
func booleanProp(desc string) map[string]any {
|
||||
return map[string]any{"type": "boolean", "description": desc}
|
||||
_ = desc
|
||||
return map[string]any{"type": "boolean"}
|
||||
}
|
||||
|
||||
func arrayOfStringsProp(desc string) map[string]any {
|
||||
_ = desc
|
||||
return map[string]any{
|
||||
"type": "array",
|
||||
"description": desc,
|
||||
"items": map[string]any{"type": "string"},
|
||||
"type": "array",
|
||||
"items": map[string]any{"type": "string"},
|
||||
}
|
||||
}
|
||||
|
||||
// toolCatalog is the full list advertised via tools/list. Descriptions
|
||||
// are intentionally short — clients are expected to fetch help() for
|
||||
// detail. Schemas mirror the param structs in tools.go.
|
||||
func toolCatalog() []toolDescriptor {
|
||||
return []toolDescriptor{
|
||||
func toolCatalog(role CallerRole) []toolDescriptor {
|
||||
tools := []toolDescriptor{
|
||||
{
|
||||
Name: "spawn_agent",
|
||||
Description: "Spawn a sub-agent from an agent preset and optionally seed it with initial instructions. This is the ONLY correct way to start a sub-agent under you — do not shell out to `claude` / `codex` / `opencode` and do not poke patterm's Unix socket via perl / nc / socat. Either bypasses caller identity and the new agent lands as a stray top-level tab instead of your child. Caller owns lifecycle: when the sub-agent's work is done (it reports back via send_message, or you no longer need it), call close_process on its process_id to free the pane and tear down the PTY. See help('spawning') and help('lifecycle').",
|
||||
Description: "Spawn a sub-agent from an agent preset.",
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"agent": stringProp("Preset name (e.g. \"claude\", \"codex\")."),
|
||||
"agent_instructions": stringProp("Initial prompt typed into the agent after it's ready."),
|
||||
@@ -115,14 +121,14 @@ func toolCatalog() []toolDescriptor {
|
||||
},
|
||||
{
|
||||
Name: "spawn_process",
|
||||
Description: "Spawn a process: a terminal, a process preset, or a freeform argv command. Caller owns lifecycle: when the process is no longer needed, call close_process to remove its entry (live children are SIGKILL'd first). See help('lifecycle').",
|
||||
Description: "Spawn a terminal, process preset, or argv command.",
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"kind": stringProp("\"terminal\" or \"command\"."),
|
||||
"preset": stringProp("Process preset name (mutually exclusive with argv)."),
|
||||
"argv": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "Argv vector for freeform commands."},
|
||||
"argv": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
||||
"name": stringProp("Display name for the pane."),
|
||||
"working_dir": stringProp("Working directory for the spawned process."),
|
||||
"env": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "string"}, "description": "Extra environment variables."},
|
||||
"env": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "string"}},
|
||||
"shell": booleanProp("Run argv through sh -lc."),
|
||||
}, nil),
|
||||
},
|
||||
@@ -188,7 +194,9 @@ func toolCatalog() []toolDescriptor {
|
||||
{
|
||||
Name: "get_project_status",
|
||||
Description: "One-shot orientation: project, caller, processes, scratchpads.",
|
||||
InputSchema: objectSchema(nil, nil),
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"include_tools": booleanProp("Include available_tools in caller metadata."),
|
||||
}, nil),
|
||||
},
|
||||
{
|
||||
Name: "get_process_output",
|
||||
@@ -197,6 +205,7 @@ func toolCatalog() []toolDescriptor {
|
||||
"process_id": stringProp("Target process id."),
|
||||
"mode": stringProp("\"grid\" (default) or \"stream\"."),
|
||||
"since_offset": integerProp("Watermark offset from a previous call."),
|
||||
"max_bytes": integerProp("Maximum content bytes to return."),
|
||||
}, []string{"process_id"}),
|
||||
},
|
||||
{
|
||||
@@ -205,6 +214,7 @@ func toolCatalog() []toolDescriptor {
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"process_id": stringProp("Target process id."),
|
||||
"since_offset": integerProp("Byte offset from a previous call."),
|
||||
"max_bytes": integerProp("Maximum content bytes to return."),
|
||||
}, []string{"process_id"}),
|
||||
},
|
||||
{
|
||||
@@ -214,12 +224,13 @@ func toolCatalog() []toolDescriptor {
|
||||
"process_id": stringProp("Target process id."),
|
||||
"pattern": stringProp("Regex pattern."),
|
||||
"kind": stringProp("\"rendered\" (default) or \"raw\"."),
|
||||
"limit": integerProp("Max matches (default 20)."),
|
||||
"limit": integerProp("Max matches (default 10)."),
|
||||
"max_bytes": integerProp("Max bytes per returned match line."),
|
||||
}, []string{"process_id", "pattern"}),
|
||||
},
|
||||
{
|
||||
Name: "wait_for_pattern",
|
||||
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:<name>]`, 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.",
|
||||
Description: "Block until pattern appears in the target process output.",
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"process_id": stringProp("Target process id."),
|
||||
"pattern": stringProp("Regex pattern."),
|
||||
@@ -238,18 +249,19 @@ func toolCatalog() []toolDescriptor {
|
||||
Name: "send_input",
|
||||
Description: "Type text, paste a block, or fire a named key into a process. Optional tail-after-send.",
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"process_id": stringProp("Target process id."),
|
||||
"kind": stringProp("\"text\", \"paste\", or \"key\"."),
|
||||
"text": stringProp("Text payload for kind=text/paste."),
|
||||
"key": stringProp("Named key for kind=key (e.g. \"enter\", \"escape\")."),
|
||||
"submit": booleanProp("Whether to append a submit keystroke."),
|
||||
"wait_ms": integerProp("After sending, wait this many ms before tailing."),
|
||||
"tail_mode": stringProp("\"none\" (default), \"stream\", or \"grid\"."),
|
||||
"process_id": stringProp("Target process id."),
|
||||
"kind": stringProp("\"text\", \"paste\", or \"key\"."),
|
||||
"text": stringProp("Text payload for kind=text/paste."),
|
||||
"key": stringProp("Named key for kind=key (e.g. \"enter\", \"escape\")."),
|
||||
"submit": booleanProp("Whether to append a submit keystroke."),
|
||||
"wait_ms": integerProp("After sending, wait this many ms before tailing."),
|
||||
"tail_mode": stringProp("\"none\" (default), \"stream\", or \"grid\"."),
|
||||
"tail_max_bytes": integerProp("Maximum bytes in returned tail."),
|
||||
}, []string{"process_id", "kind"}),
|
||||
},
|
||||
{
|
||||
Name: "send_message",
|
||||
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:<name>]` (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.",
|
||||
Description: "Send a tagged message to a parent or child process.",
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"target_process_id": stringProp("Recipient process id."),
|
||||
"message": stringProp("Message body."),
|
||||
@@ -283,7 +295,7 @@ func toolCatalog() []toolDescriptor {
|
||||
},
|
||||
{
|
||||
Name: "timer_fire_when_idle_any",
|
||||
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:<name>]`. Schedules a timer that fires when any watched process enters idle (already-idle entries excluded), or when max_wait_seconds elapses.",
|
||||
Description: "Fire when any watched process becomes idle.",
|
||||
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 +306,7 @@ func toolCatalog() []toolDescriptor {
|
||||
},
|
||||
{
|
||||
Name: "timer_fire_when_idle_all",
|
||||
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:<name>]`. Schedules a timer that fires when all watched processes are idle (already-idle entries count as satisfied), or when max_wait_seconds elapses.",
|
||||
Description: "Fire when all watched processes are idle.",
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"watched": arrayOfStringsProp("Process ids to watch."),
|
||||
"body": stringProp("Message delivered verbatim to the owning agent when the timer fires."),
|
||||
@@ -338,7 +350,9 @@ func toolCatalog() []toolDescriptor {
|
||||
Name: "scratchpad_read",
|
||||
Description: "Read a scratchpad entry, returning content and revision.",
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"name": stringProp("Scratchpad name."),
|
||||
"name": stringProp("Scratchpad name."),
|
||||
"offset": integerProp("Byte offset to start reading."),
|
||||
"max_bytes": integerProp("Maximum content bytes to return."),
|
||||
}, []string{"name"}),
|
||||
},
|
||||
{
|
||||
@@ -367,8 +381,10 @@ func toolCatalog() []toolDescriptor {
|
||||
},
|
||||
{
|
||||
Name: "whoami",
|
||||
Description: "Return the caller's identity, role, parent, project metadata, and available tools.",
|
||||
InputSchema: objectSchema(nil, nil),
|
||||
Description: "Return caller identity, role, parent, and project metadata.",
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"include_tools": booleanProp("Include full available tool list."),
|
||||
}, nil),
|
||||
},
|
||||
{
|
||||
Name: "help",
|
||||
@@ -378,6 +394,16 @@ func toolCatalog() []toolDescriptor {
|
||||
}, nil),
|
||||
},
|
||||
}
|
||||
if role != RoleSubAgent {
|
||||
return tools
|
||||
}
|
||||
filtered := tools[:0]
|
||||
for _, tool := range tools {
|
||||
if tool.Name != "spawn_agent" {
|
||||
filtered = append(filtered, tool)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// handleProtocolMethod handles MCP protocol-level methods. Returns
|
||||
@@ -416,7 +442,14 @@ func (s *Server) handleProtocolMethod(callerID, method string, params json.RawMe
|
||||
return map[string]any{}, true, 0, "", nil
|
||||
|
||||
case "tools/list":
|
||||
return map[string]any{"tools": toolCatalog()}, true, 0, "", nil
|
||||
role := RoleOrchestrator
|
||||
s.mu.Lock()
|
||||
host := s.host
|
||||
s.mu.Unlock()
|
||||
if host != nil {
|
||||
role = host.CallerRole(callerID)
|
||||
}
|
||||
return map[string]any{"tools": toolCatalog(role)}, true, 0, "", nil
|
||||
|
||||
case "tools/call":
|
||||
var p struct {
|
||||
@@ -472,25 +505,12 @@ func (s *Server) handleProtocolMethod(callerID, method string, params json.RawMe
|
||||
return nil, false, 0, "", nil
|
||||
}
|
||||
|
||||
// wrapToolResult turns a structured tool result into an MCP tools/call
|
||||
// response. Plain strings (e.g. "ok") become text content; structured
|
||||
// values are JSON-encoded into a single text block and also exposed
|
||||
// under structuredContent so capable clients can read the shape.
|
||||
// wrapToolResult turns a tool result into an MCP tools/call response.
|
||||
// Structured values are exposed once under structuredContent; content
|
||||
// carries only a short model-readable summary to avoid duplicating
|
||||
// large JSON payloads into the transcript.
|
||||
func wrapToolResult(result any) map[string]any {
|
||||
var text string
|
||||
switch v := result.(type) {
|
||||
case nil:
|
||||
text = "ok"
|
||||
case string:
|
||||
text = v
|
||||
default:
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
text = fmt.Sprintf("%v", v)
|
||||
} else {
|
||||
text = string(b)
|
||||
}
|
||||
}
|
||||
text := summarizeToolResult(result)
|
||||
out := map[string]any{
|
||||
"content": []map[string]any{{"type": "text", "text": text}},
|
||||
"isError": false,
|
||||
@@ -505,3 +525,70 @@ func wrapToolResult(result any) map[string]any {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func summarizeToolResult(result any) string {
|
||||
switch v := result.(type) {
|
||||
case nil:
|
||||
return "ok"
|
||||
case string:
|
||||
return v
|
||||
case ProcessInfo:
|
||||
return fmt.Sprintf("%s %s %s", v.ID, v.Kind, v.Status)
|
||||
case []ProcessInfo:
|
||||
return fmt.Sprintf("%d processes", len(v))
|
||||
case ProcessStatus:
|
||||
return fmt.Sprintf("%s %s %s", v.ID, v.Kind, v.Status)
|
||||
case ProjectStatus:
|
||||
return fmt.Sprintf("%d processes, %d scratchpads", len(v.Processes), len(v.Scratchpads))
|
||||
case ProcessOutput:
|
||||
return outputSummary(v.Mode, v.ContentBytes, v.Truncated, v.NewOffset)
|
||||
case RawOutput:
|
||||
return outputSummary("raw", v.ContentBytes, v.Truncated, v.NewOffset)
|
||||
case SearchResult:
|
||||
if v.Truncated {
|
||||
return fmt.Sprintf("%d matches (truncated)", len(v.Matches))
|
||||
}
|
||||
return fmt.Sprintf("%d matches", len(v.Matches))
|
||||
case SendInputResult:
|
||||
if v.Tail != nil {
|
||||
return "ok; tail included"
|
||||
}
|
||||
return "ok"
|
||||
case TimerHandle:
|
||||
return "timer " + v.ID
|
||||
case TimerFireWhenIdleResponse:
|
||||
if v.ID != "" {
|
||||
return fmt.Sprintf("%s timer %s", v.Status, v.ID)
|
||||
}
|
||||
return v.Status
|
||||
case []TimerInfo:
|
||||
return fmt.Sprintf("%d timers", len(v))
|
||||
case []scratchpad.Entry:
|
||||
return fmt.Sprintf("%d scratchpads", len(v))
|
||||
case ScratchpadReadResult:
|
||||
if v.Truncated {
|
||||
return fmt.Sprintf("%d/%d bytes from offset %d", v.ContentBytes, v.TotalBytes, v.Offset)
|
||||
}
|
||||
return fmt.Sprintf("%d bytes", v.ContentBytes)
|
||||
case WhoAmI:
|
||||
if v.ProcessID == "" {
|
||||
return string(v.Role)
|
||||
}
|
||||
return fmt.Sprintf("%s %s", v.ProcessID, v.Role)
|
||||
case HelpResponse:
|
||||
return fmt.Sprintf("help: %s", v.Topic)
|
||||
default:
|
||||
return "ok"
|
||||
}
|
||||
}
|
||||
|
||||
func outputSummary(mode string, bytes int, truncated bool, offset int64) string {
|
||||
s := fmt.Sprintf("%s output: %d bytes", mode, bytes)
|
||||
if offset > 0 {
|
||||
s += fmt.Sprintf(", offset %d", offset)
|
||||
}
|
||||
if truncated {
|
||||
s += " (truncated)"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user