Reduce MCP token usage

This commit is contained in:
2026-05-29 13:16:05 +01:00
parent da46340a82
commit 51aac9f447
9 changed files with 453 additions and 137 deletions

View File

@@ -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 {

View File

@@ -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

View File

@@ -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" {

View File

@@ -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.