diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dc40a4..eb0dc2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Grid-mode `get_process_output` now returns whitespace-normalized text to avoid sending padded terminal rows and repeated blank lines over MCP. +- MCP responses now use slimmer defaults: tool-call JSON is no longer + duplicated into text content, large output and scratchpad reads are + capped with truncation metadata, and `whoami` / `get_project_status` + only include full tool lists when `include_tools` is requested. ### Fixed - Injected agent input now sends the submit Enter as a separated, diff --git a/internal/app/child.go b/internal/app/child.go index 91f96f4..e134cd2 100644 --- a/internal/app/child.go +++ b/internal/app/child.go @@ -532,6 +532,12 @@ func (c *Child) StreamRead(since int64) ([]byte, int64) { return out, end } +func (c *Child) StreamOffset() int64 { + c.ringMu.Lock() + defer c.ringMu.Unlock() + return c.ringWrites +} + func (c *Child) signal(sig syscall.Signal) error { pty := c.PTY() if pty == nil { diff --git a/internal/app/host.go b/internal/app/host.go index 4f5ad49..bb7914b 100644 --- a/internal/app/host.go +++ b/internal/app/host.go @@ -65,6 +65,15 @@ type toolHost struct { timers *timerManager } +const ( + defaultMCPContentBytes = 12_000 + maxMCPContentBytes = 65_536 + defaultMCPTailBytes = 8_000 + defaultScratchpadReadBytes = 12_000 + defaultSearchLineBytes = 2_000 + maxSearchMatches = 50 +) + func newToolHost(sess *Session, pads *scratchpad.Store, launcher *Launcher, presets preset.Set, tr *trust.Store, cols, rows uint16) *toolHost { h := &toolHost{ sess: sess, @@ -353,8 +362,8 @@ func (h *toolHost) GetProcessStatus(callerID, processID string) (mcp.ProcessStat return st, nil } -func (h *toolHost) GetProjectStatus(callerID string) (mcp.ProjectStatus, error) { - caller := h.WhoAmI(callerID) +func (h *toolHost) GetProjectStatus(callerID string, includeTools bool) (mcp.ProjectStatus, error) { + caller := h.WhoAmI(callerID, includeTools) processes := h.ListProcesses(callerID, "") pads, _ := h.pads.List() return mcp.ProjectStatus{ @@ -365,7 +374,8 @@ func (h *toolHost) GetProjectStatus(callerID string) (mcp.ProjectStatus, error) }, nil } -func (h *toolHost) GetProcessOutput(callerID, processID, mode string, sinceOffset int64) (mcp.ProcessOutput, error) { +func (h *toolHost) GetProcessOutput(callerID string, args mcp.ProcessOutputArgs) (mcp.ProcessOutput, error) { + processID, mode, sinceOffset := args.ProcessID, args.Mode, args.SinceOffset c := h.sess.FindChild(processID) if c == nil { return mcp.ProcessOutput{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID) @@ -399,11 +409,12 @@ func (h *toolHost) GetProcessOutput(callerID, processID, mode string, sinceOffse if c.Kind == KindAgent { txt = applyChromeTrim(txt, h.chromeHintsFor(c.PresetRef)) } - out.Content = normalizeGridText(txt) + content := normalizeGridText(txt) + out.Content, out.ContentBytes, out.Truncated, out.TruncatedBytes = capTextMiddle(content, capLimit(args.MaxBytes, defaultMCPContentBytes)) return out, nil case "stream": b, end := c.StreamRead(sinceOffset) - out.Content = string(stripANSIBytes(nil, b)) + out.Content, out.ContentBytes, out.Truncated, out.TruncatedBytes = capBytesTail(stripANSIBytes(nil, b), capLimit(args.MaxBytes, defaultMCPContentBytes)) out.NewOffset = end return out, nil default: @@ -411,34 +422,46 @@ func (h *toolHost) GetProcessOutput(callerID, processID, mode string, sinceOffse } } -func (h *toolHost) GetProcessRawOutput(callerID, processID string, sinceOffset int64) (mcp.RawOutput, error) { - c := h.sess.FindChild(processID) +func (h *toolHost) GetProcessRawOutput(callerID string, args mcp.RawOutputArgs) (mcp.RawOutput, error) { + c := h.sess.FindChild(args.ProcessID) if c == nil { - return mcp.RawOutput{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID) + return mcp.RawOutput{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", args.ProcessID) } - b, end := c.StreamRead(sinceOffset) + b, end := c.StreamRead(args.SinceOffset) + content, contentBytes, truncated, truncatedBytes := capBytesTail(b, capLimit(args.MaxBytes, defaultMCPContentBytes)) return mcp.RawOutput{ - Content: string(b), - NewOffset: end, - Status: string(c.Status()), + Content: content, + NewOffset: end, + Status: string(c.Status()), + ContentBytes: contentBytes, + Truncated: truncated, + TruncatedBytes: truncatedBytes, }, nil } -func (h *toolHost) SearchOutput(callerID, processID, pattern, kind string, limit int) (mcp.SearchResult, error) { - c := h.sess.FindChild(processID) +func (h *toolHost) SearchOutput(callerID string, args mcp.SearchOutputArgs) (mcp.SearchResult, error) { + c := h.sess.FindChild(args.ProcessID) if c == nil { - return mcp.SearchResult{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", processID) + return mcp.SearchResult{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q", args.ProcessID) } - re, err := regexp.Compile(pattern) + re, err := regexp.Compile(args.Pattern) if err != nil { return mcp.SearchResult{}, mcp.Errorf(mcp.ErrorKindInvalidArgs, "regex: %v", err) } b, _ := c.StreamRead(0) - if kind == "rendered" { + if args.Kind == "rendered" { b = stripANSIBytes(nil, b) } text := string(b) lines := strings.Split(text, "\n") + limit := args.Limit + if limit <= 0 { + limit = 10 + } + if limit > maxSearchMatches { + limit = maxSearchMatches + } + lineLimit := capLimit(args.MaxBytes, defaultSearchLineBytes) matches := make([]mcp.SearchMatch, 0, limit) truncated := false for i, line := range lines { @@ -447,6 +470,8 @@ func (h *toolHost) SearchOutput(callerID, processID, pattern, kind string, limit truncated = true break } + line, _, lineTruncated, _ := capTextTail(line, lineLimit) + truncated = truncated || lineTruncated matches = append(matches, mcp.SearchMatch{LineNo: i + 1, Text: line}) } } @@ -588,6 +613,7 @@ func (h *toolHost) SendInput(callerID string, args mcp.SendInputArgs) (mcp.SendI if err != nil { return mcp.SendInputResult{}, err } + tailSince := c.StreamOffset() if err := c.InjectAsOrchestrator(payload); err != nil { return mcp.SendInputResult{}, err } @@ -599,7 +625,12 @@ func (h *toolHost) SendInput(callerID string, args mcp.SendInputArgs) (mcp.SendI } if mode != "none" { time.Sleep(time.Duration(args.WaitMS) * time.Millisecond) - tail, err := h.GetProcessOutput(callerID, args.ProcessID, mode, 0) + tail, err := h.GetProcessOutput(callerID, mcp.ProcessOutputArgs{ + ProcessID: args.ProcessID, + Mode: mode, + SinceOffset: tailSince, + MaxBytes: capLimit(args.TailMaxBytes, defaultMCPTailBytes), + }) if err == nil { res.Tail = &tail } @@ -813,8 +844,30 @@ func (h *toolHost) TimerList(callerID string) ([]mcp.TimerInfo, error) { func (h *toolHost) ScratchpadList() ([]scratchpad.Entry, error) { return h.pads.List() } -func (h *toolHost) ScratchpadRead(name string) (string, string, error) { - return h.pads.Read(name) +func (h *toolHost) ScratchpadRead(args mcp.ScratchpadReadArgs) (mcp.ScratchpadReadResult, error) { + content, rev, err := h.pads.Read(args.Name) + if err != nil { + return mcp.ScratchpadReadResult{}, err + } + offset := args.Offset + if offset < 0 { + offset = 0 + } + if offset > len(content) { + offset = len(content) + } + limited, contentBytes, truncated, truncatedBytes := capTextHead(content[offset:], capLimit(args.MaxBytes, defaultScratchpadReadBytes)) + next := offset + contentBytes + return mcp.ScratchpadReadResult{ + Content: limited, + Revision: rev, + Offset: offset, + NextOffset: next, + ContentBytes: contentBytes, + TotalBytes: len(content), + Truncated: truncated, + TruncatedBytes: truncatedBytes, + }, nil } func (h *toolHost) ScratchpadWrite(name, content, expectedRevision string) (string, error) { @@ -841,7 +894,7 @@ func (h *toolHost) ScratchpadDelete(name string) error { return err } -func (h *toolHost) WhoAmI(callerID string) mcp.WhoAmI { +func (h *toolHost) WhoAmI(callerID string, includeTools bool) mcp.WhoAmI { w := mcp.WhoAmI{ ProcessID: callerID, Role: h.CallerRole(callerID), @@ -849,7 +902,9 @@ func (h *toolHost) WhoAmI(callerID string) mcp.WhoAmI { Path: h.sess.projectDir, Key: h.sess.projectKey, }, - AvailableTools: availableToolsForRole(h.CallerRole(callerID)), + } + if includeTools { + w.AvailableTools = availableToolsForRole(h.CallerRole(callerID)) } if c := h.sess.FindChild(callerID); c != nil { w.Name = c.DisplayName() @@ -1043,6 +1098,51 @@ func normalizeGridText(s string) string { return strings.Join(out, "\n") } +func capLimit(requested, def int) int { + if requested <= 0 { + requested = def + } + if requested > maxMCPContentBytes { + requested = maxMCPContentBytes + } + if requested < 0 { + return 0 + } + return requested +} + +func capBytesTail(b []byte, limit int) (string, int, bool, int) { + if limit <= 0 || len(b) <= limit { + return string(b), len(b), false, 0 + } + dropped := len(b) - limit + return string(b[dropped:]), limit, true, dropped +} + +func capTextTail(s string, limit int) (string, int, bool, int) { + return capBytesTail([]byte(s), limit) +} + +func capTextHead(s string, limit int) (string, int, bool, int) { + if limit <= 0 || len(s) <= limit { + return s, len(s), false, 0 + } + return s[:limit], limit, true, len(s) - limit +} + +func capTextMiddle(s string, limit int) (string, int, bool, int) { + if limit <= 0 || len(s) <= limit { + return s, len(s), false, 0 + } + const marker = "\n...[truncated]...\n" + if limit <= len(marker)+2 { + return s[len(s)-limit:], limit, true, len(s) - limit + } + head := (limit - len(marker)) / 2 + tail := limit - len(marker) - head + return s[:head] + marker + s[len(s)-tail:], limit, true, len(s) - limit +} + // stripANSIBytes is the byte-slice form of stripANSI. Skips the // string conversion and the regex DFA — useful when the caller will // itself walk the result line-by-line (SearchOutput) or feed it to a diff --git a/internal/app/host_test.go b/internal/app/host_test.go index d037c0f..fb09441 100644 --- a/internal/app/host_test.go +++ b/internal/app/host_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/hjbdev/patterm/internal/mcp" + "github.com/hjbdev/patterm/internal/scratchpad" ) // mkChild builds a Child without starting a PTY. Use sparingly — the @@ -134,6 +135,42 @@ func TestWrapSubAgentPromptEmptyStaysEmpty(t *testing.T) { } } +func TestMCPContentCapsPreferRecentStreamBytes(t *testing.T) { + got, gotBytes, truncated, dropped := capBytesTail([]byte("abcdefghijklmnop"), 6) + if got != "klmnop" || gotBytes != 6 || !truncated || dropped != 10 { + t.Fatalf("capBytesTail = (%q, %d, %v, %d)", got, gotBytes, truncated, dropped) + } +} + +func TestMCPGridCapKeepsHeadAndTail(t *testing.T) { + got, gotBytes, truncated, dropped := capTextMiddle("abcdefghijklmnopqrstuvwxyz", 24) + if gotBytes != 24 || !truncated || dropped != 2 { + t.Fatalf("capTextMiddle metadata = (%d, %v, %d), content %q", gotBytes, truncated, dropped, got) + } + if !strings.Contains(got, "...[truncated]...") { + t.Fatalf("capTextMiddle missing marker: %q", got) + } +} + +func TestScratchpadReadPagesLargeContent(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + store, err := scratchpad.Open("test-project") + if err != nil { + t.Fatalf("scratchpad open: %v", err) + } + if _, err := store.Write("notes.md", "abcdefghijklmnopqrstuvwxyz", ""); err != nil { + t.Fatalf("scratchpad write: %v", err) + } + h := &toolHost{pads: store} + res, err := h.ScratchpadRead(mcp.ScratchpadReadArgs{Name: "notes.md", Offset: 5, MaxBytes: 7}) + if err != nil { + t.Fatalf("ScratchpadRead: %v", err) + } + if res.Content != "fghijkl" || !res.Truncated || res.NextOffset != 12 || res.TotalBytes != 26 { + t.Fatalf("ScratchpadRead result = %+v", res) + } +} + func TestHelpLifecycleTopicCoversCleanup(t *testing.T) { resp := helpFor("lifecycle") if resp.Topic != "lifecycle" { diff --git a/internal/app/timers.go b/internal/app/timers.go index 4e16761..45c366d 100644 --- a/internal/app/timers.go +++ b/internal/app/timers.go @@ -561,14 +561,16 @@ func (m *timerManager) TimerList(ownerID string) []mcp.TimerInfo { if t.status != timerStatusPending && t.status != timerStatusPaused { continue } + body, bodyTruncated := timerBodyPreview(t.body) info := mcp.TimerInfo{ - ID: t.id, - Label: t.label, - Body: t.body, - Kind: string(t.kind), - Status: t.status, - OwnerID: t.ownerID, - WatchedIDs: append([]string(nil), t.watched...), + ID: t.id, + Label: t.label, + Body: body, + BodyTruncated: bodyTruncated, + Kind: string(t.kind), + Status: t.status, + OwnerID: t.ownerID, + WatchedIDs: append([]string(nil), t.watched...), } if t.status == timerStatusPending && !t.firesAt.IsZero() { info.FiresAtUnixMS = t.firesAt.UnixMilli() @@ -581,6 +583,14 @@ func (m *timerManager) TimerList(ownerID string) []mcp.TimerInfo { return out } +func timerBodyPreview(body string) (string, bool) { + const max = 500 + if len(body) <= max { + return body, false + } + return body[:max], true +} + // activeForChild returns the nearest pending or paused timer attached // to child id (either owned by it or watching it). Used by the sidebar // for the "⏱ 12s" indicator. nil when none. diff --git a/internal/mcp/mcp_test.go b/internal/mcp/mcp_test.go index 066f080..7d7bfd4 100644 --- a/internal/mcp/mcp_test.go +++ b/internal/mcp/mcp_test.go @@ -134,16 +134,16 @@ func (h *blockingToolHost) ListProcesses(string, string) []ProcessInfo { return func (h *blockingToolHost) GetProcessStatus(string, string) (ProcessStatus, error) { return ProcessStatus{ProcessInfo: ProcessInfo{ID: "p_fast", Status: "running"}}, nil } -func (h *blockingToolHost) GetProjectStatus(string) (ProjectStatus, error) { +func (h *blockingToolHost) GetProjectStatus(string, bool) (ProjectStatus, error) { return ProjectStatus{}, nil } -func (h *blockingToolHost) GetProcessOutput(string, string, string, int64) (ProcessOutput, error) { +func (h *blockingToolHost) GetProcessOutput(string, ProcessOutputArgs) (ProcessOutput, error) { return ProcessOutput{}, nil } -func (h *blockingToolHost) GetProcessRawOutput(string, string, int64) (RawOutput, error) { +func (h *blockingToolHost) GetProcessRawOutput(string, RawOutputArgs) (RawOutput, error) { return RawOutput{}, nil } -func (h *blockingToolHost) SearchOutput(string, string, string, string, int) (SearchResult, error) { +func (h *blockingToolHost) SearchOutput(string, SearchOutputArgs) (SearchResult, error) { return SearchResult{}, nil } func (h *blockingToolHost) WaitForPattern(string, string, string, float64, string) (bool, string, error) { @@ -178,13 +178,13 @@ func (h *blockingToolHost) TimerList(string) ([]TimerInfo, error) { return nil, nil } func (h *blockingToolHost) ScratchpadList() ([]scratchpad.Entry, error) { return nil, nil } -func (h *blockingToolHost) ScratchpadRead(string) (string, string, error) { - return "", "", nil +func (h *blockingToolHost) ScratchpadRead(ScratchpadReadArgs) (ScratchpadReadResult, error) { + return ScratchpadReadResult{}, nil } func (h *blockingToolHost) ScratchpadWrite(string, string, string) (string, error) { return "", nil } func (h *blockingToolHost) ScratchpadAppend(string, string) error { return nil } func (h *blockingToolHost) ScratchpadDelete(string) error { return nil } -func (h *blockingToolHost) WhoAmI(string) WhoAmI { return WhoAmI{} } +func (h *blockingToolHost) WhoAmI(string, bool) WhoAmI { return WhoAmI{} } func (h *blockingToolHost) Help(string, string) HelpResponse { return HelpResponse{} } diff --git a/internal/mcp/protocol.go b/internal/mcp/protocol.go index 73e32f0..eb60e53 100644 --- a/internal/mcp/protocol.go +++ b/internal/mcp/protocol.go @@ -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:] …`, 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:]`, 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:]` (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:]`. 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:]`. 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 +} diff --git a/internal/mcp/protocol_test.go b/internal/mcp/protocol_test.go index c025bf2..55a23a1 100644 --- a/internal/mcp/protocol_test.go +++ b/internal/mcp/protocol_test.go @@ -2,6 +2,7 @@ package mcp import ( "encoding/json" + "strings" "testing" ) @@ -43,6 +44,9 @@ func TestInitializeReturnsCapabilities(t *testing.T) { if !ok || instructions == "" { t.Fatalf("instructions missing or wrong type: %+v", parsed.Result) } + if len(instructions) > 320 { + t.Fatalf("instructions too verbose: %d chars", len(instructions)) + } } func TestInitializedNotificationSuppressesResponse(t *testing.T) { @@ -74,6 +78,9 @@ func TestToolsListReturnsConcreteSchemas(t *testing.T) { if parsed.Error != nil { t.Fatalf("tools/list returned error: %+v", parsed.Error) } + if len(resp) > 12000 { + t.Fatalf("tools/list response too large: %d bytes", len(resp)) + } tools, ok := parsed.Result["tools"].([]interface{}) if !ok { t.Fatalf("tools not array: %+v", parsed.Result) @@ -112,6 +119,27 @@ func TestToolsListReturnsConcreteSchemas(t *testing.T) { } } +func TestWrapToolResultDoesNotDuplicateStructuredJSON(t *testing.T) { + result := ProcessOutput{ + Content: strings.Repeat("x", 1024), + Mode: "stream", + NewOffset: 2048, + ContentBytes: 1024, + } + wrapped := wrapToolResult(result) + if wrapped["structuredContent"] == nil { + t.Fatalf("structuredContent missing: %#v", wrapped) + } + content := wrapped["content"].([]map[string]any) + text := content[0]["text"].(string) + if strings.Contains(text, result.Content) { + t.Fatalf("content duplicated structured payload: %q", text) + } + if !strings.Contains(text, "stream output") { + t.Fatalf("summary text should identify output, got %q", text) + } +} + func TestPingReturnsEmptyObject(t *testing.T) { s := &Server{} req := []byte(`{"jsonrpc":"2.0","id":3,"method":"ping"}`) diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index f005026..dadcf0a 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -74,10 +74,10 @@ type ToolHost interface { // Inspection. ListProcesses(callerID, kindFilter string) []ProcessInfo GetProcessStatus(callerID, processID string) (ProcessStatus, error) - GetProjectStatus(callerID string) (ProjectStatus, error) - GetProcessOutput(callerID, processID, mode string, sinceOffset int64) (ProcessOutput, error) - GetProcessRawOutput(callerID, processID string, sinceOffset int64) (RawOutput, error) - SearchOutput(callerID, processID, pattern, kind string, limit int) (SearchResult, error) + GetProjectStatus(callerID string, includeTools bool) (ProjectStatus, error) + GetProcessOutput(callerID string, args ProcessOutputArgs) (ProcessOutput, error) + GetProcessRawOutput(callerID string, args RawOutputArgs) (RawOutput, error) + SearchOutput(callerID string, args SearchOutputArgs) (SearchResult, error) WaitForPattern(callerID, processID, pattern string, timeoutSeconds float64, scope string) (matched bool, snippet string, err error) GetProcessPorts(callerID, processID string) ([]PortSighting, error) @@ -98,13 +98,13 @@ type ToolHost interface { // Scratchpads. ScratchpadList() ([]scratchpad.Entry, error) - ScratchpadRead(name string) (content string, revision string, err error) + ScratchpadRead(args ScratchpadReadArgs) (ScratchpadReadResult, error) ScratchpadWrite(name, content, expectedRevision string) (revision string, err error) ScratchpadAppend(name, content string) error ScratchpadDelete(name string) error // Meta. - WhoAmI(callerID string) WhoAmI + WhoAmI(callerID string, includeTools bool) WhoAmI Help(callerID, topic string) HelpResponse } @@ -157,6 +157,10 @@ type ProjectStatus struct { Scratchpads []scratchpad.Entry `json:"scratchpads"` } +type ProjectStatusArgs struct { + IncludeTools bool `json:"include_tools"` +} + // ProjectMeta is the project root info echoed in many payloads. type ProjectMeta struct { Path string `json:"path"` @@ -166,23 +170,42 @@ type ProjectMeta struct { // ProcessOutput is the get_process_output payload. SPEC §7 enriches // the old read_output result with screen geometry + version. type ProcessOutput struct { - Content string `json:"content"` - Mode string `json:"mode"` - NewOffset int64 `json:"new_offset,omitempty"` - ActiveScreen string `json:"active_screen,omitempty"` - Rows int `json:"rows,omitempty"` - Cols int `json:"cols,omitempty"` - Cursor Cursor `json:"cursor"` - IdleMS int64 `json:"idle_ms,omitempty"` - Status string `json:"status,omitempty"` - ScreenVersion int64 `json:"screen_version,omitempty"` + Content string `json:"content"` + Mode string `json:"mode"` + NewOffset int64 `json:"new_offset,omitempty"` + ActiveScreen string `json:"active_screen,omitempty"` + Rows int `json:"rows,omitempty"` + Cols int `json:"cols,omitempty"` + Cursor Cursor `json:"cursor"` + IdleMS int64 `json:"idle_ms,omitempty"` + Status string `json:"status,omitempty"` + ScreenVersion int64 `json:"screen_version,omitempty"` + ContentBytes int `json:"content_bytes,omitempty"` + Truncated bool `json:"truncated,omitempty"` + TruncatedBytes int `json:"truncated_bytes,omitempty"` +} + +type ProcessOutputArgs struct { + ProcessID string `json:"process_id"` + Mode string `json:"mode"` + SinceOffset int64 `json:"since_offset"` + MaxBytes int `json:"max_bytes"` } // RawOutput is the get_process_raw_output payload — ANSI preserved. type RawOutput struct { - Content string `json:"content"` - NewOffset int64 `json:"new_offset"` - Status string `json:"status,omitempty"` + Content string `json:"content"` + NewOffset int64 `json:"new_offset"` + Status string `json:"status,omitempty"` + ContentBytes int `json:"content_bytes,omitempty"` + Truncated bool `json:"truncated,omitempty"` + TruncatedBytes int `json:"truncated_bytes,omitempty"` +} + +type RawOutputArgs struct { + ProcessID string `json:"process_id"` + SinceOffset int64 `json:"since_offset"` + MaxBytes int `json:"max_bytes"` } // SearchResult is search_output's payload. @@ -191,6 +214,14 @@ type SearchResult struct { Truncated bool `json:"truncated"` } +type SearchOutputArgs struct { + ProcessID string `json:"process_id"` + Pattern string `json:"pattern"` + Kind string `json:"kind"` + Limit int `json:"limit"` + MaxBytes int `json:"max_bytes"` +} + type SearchMatch struct { LineNo int `json:"line_no"` Text string `json:"text"` @@ -245,6 +276,7 @@ type TimerInfo struct { ID string `json:"timer_id"` Label string `json:"label,omitempty"` Body string `json:"body,omitempty"` + BodyTruncated bool `json:"body_truncated,omitempty"` Kind string `json:"kind"` // "delay" | "idle_any" | "idle_all" Status string `json:"status"` // "pending" | "paused" OwnerID string `json:"owner_process_id"` @@ -281,13 +313,14 @@ type SpawnProcessArgs struct { // SendInputArgs is the input shape for send_input — covers text / // paste / key with the optional wait+tail tail-after-send. type SendInputArgs struct { - ProcessID string `json:"process_id"` - Kind string `json:"kind"` // "text" | "paste" | "key" - Text string `json:"text"` - Key string `json:"key"` - Submit *bool `json:"submit"` - WaitMS int `json:"wait_ms"` - TailMode string `json:"tail_mode"` // "none" | "stream" | "grid" + ProcessID string `json:"process_id"` + Kind string `json:"kind"` // "text" | "paste" | "key" + Text string `json:"text"` + Key string `json:"key"` + Submit *bool `json:"submit"` + WaitMS int `json:"wait_ms"` + TailMode string `json:"tail_mode"` // "none" | "stream" | "grid" + TailMaxBytes int `json:"tail_max_bytes"` } // SendInputResult is the return shape of send_input. @@ -306,6 +339,27 @@ type WhoAmI struct { AvailableTools []string `json:"available_tools"` } +type WhoAmIArgs struct { + IncludeTools bool `json:"include_tools"` +} + +type ScratchpadReadArgs struct { + Name string `json:"name"` + Offset int `json:"offset"` + MaxBytes int `json:"max_bytes"` +} + +type ScratchpadReadResult struct { + Content string `json:"content"` + Revision string `json:"revision"` + Offset int `json:"offset,omitempty"` + NextOffset int `json:"next_offset,omitempty"` + ContentBytes int `json:"content_bytes,omitempty"` + TotalBytes int `json:"total_bytes,omitempty"` + Truncated bool `json:"truncated,omitempty"` + TruncatedBytes int `json:"truncated_bytes,omitempty"` +} + // HelpResponse is the help return shape. type HelpResponse struct { Topic string `json:"topic"` @@ -507,61 +561,51 @@ func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any, return st, 0, "", nil case "get_project_status": - ps, err := h.GetProjectStatus(callerID) + var p ProjectStatusArgs + _ = unmarshalParamsOptional(params, &p) + ps, err := h.GetProjectStatus(callerID, p.IncludeTools) if err != nil { return mapToolError(err) } return ps, 0, "", nil case "get_process_output": - var p struct { - ProcessID string `json:"process_id"` - Mode string `json:"mode"` - SinceOffset int64 `json:"since_offset"` - } + var p ProcessOutputArgs if err := unmarshalParams(params, &p); err != nil { return nil, codeInvalidParams, err.Error(), nil } if p.Mode == "" { p.Mode = "grid" } - out, err := h.GetProcessOutput(callerID, p.ProcessID, p.Mode, p.SinceOffset) + out, err := h.GetProcessOutput(callerID, p) if err != nil { return mapToolError(err) } return out, 0, "", nil case "get_process_raw_output": - var p struct { - ProcessID string `json:"process_id"` - SinceOffset int64 `json:"since_offset"` - } + var p RawOutputArgs if err := unmarshalParams(params, &p); err != nil { return nil, codeInvalidParams, err.Error(), nil } - out, err := h.GetProcessRawOutput(callerID, p.ProcessID, p.SinceOffset) + out, err := h.GetProcessRawOutput(callerID, p) if err != nil { return mapToolError(err) } return out, 0, "", nil case "search_output": - var p struct { - ProcessID string `json:"process_id"` - Pattern string `json:"pattern"` - Kind string `json:"kind"` - Limit int `json:"limit"` - } + var p SearchOutputArgs if err := unmarshalParams(params, &p); err != nil { return nil, codeInvalidParams, err.Error(), nil } if p.Limit <= 0 { - p.Limit = 20 + p.Limit = 10 } if p.Kind == "" { p.Kind = "rendered" } - res, err := h.SearchOutput(callerID, p.ProcessID, p.Pattern, p.Kind, p.Limit) + res, err := h.SearchOutput(callerID, p) if err != nil { return mapToolError(err) } @@ -731,17 +775,15 @@ func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any, return entries, 0, "", nil case "scratchpad_read": - var p struct { - Name string `json:"name"` - } + var p ScratchpadReadArgs if err := unmarshalParams(params, &p); err != nil { return nil, codeInvalidParams, err.Error(), nil } - content, rev, err := h.ScratchpadRead(p.Name) + res, err := h.ScratchpadRead(p) if err != nil { return nil, codeInternal, err.Error(), nil } - return map[string]any{"content": content, "revision": rev}, 0, "", nil + return res, 0, "", nil case "scratchpad_write": var p struct { @@ -790,7 +832,9 @@ func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any, return map[string]any{"ok": true}, 0, "", nil case "whoami": - return h.WhoAmI(callerID), 0, "", nil + var p WhoAmIArgs + _ = unmarshalParamsOptional(params, &p) + return h.WhoAmI(callerID, p.IncludeTools), 0, "", nil case "help": var p struct {