package herdr import ( "bufio" "context" "encoding/json" "fmt" "orchestra/internal/continuity" "os" "regexp" "strings" ) // ToolCall is a harness-agnostic normalization of one tool/function // invocation and its outcome, extracted from a harness's own transcript or // rollout — the source S11 named as missing ("transcript/tool-call // introspection this repo doesn't have a source for"). Kind distinguishes // what Key means: "file" (Key is a repo-relative or absolute path, from a // tool whose input carried file_path) or "command" (Key is a shell command // string). A call whose input matched neither shape has Kind=="" and Key=="" — // still counted for occupancy-adjacent bookkeeping elsewhere, but ignored by // the detectors below, which only reason about file edits and commands. type ToolCall struct { Name string Kind string Key string Success bool IsTest bool } // ActivityReader is optional, mirroring every other Face-B capability in // this package (TurnBoundary, HandoffRequester, ...): an adapter without a // tool-call source for its harness simply doesn't implement it, and callers // (Coordinator.rotate, TurnDecision) skip thrash/milestone detection rather // than guessing from an absent signal. type ActivityReader interface { Activity(context.Context, Session) ([]ToolCall, error) } // editToolNames are the tool/function names whose input is a full file // rewrite or patch — the ones the "same file edited too many times without // resolving" thrash rule cares about. Read/Grep/Glob-style tools also carry // a file_path in their input (Kind=="file") but are not edits and must not // count toward this rule. var editToolNames = map[string]bool{ "Edit": true, "Write": true, "MultiEdit": true, "NotebookEdit": true, // Codex/opencode function names are unverified against a live rollout // (see CodexActivity's doc comment) — these are best-effort guesses at // what an edit-shaped tool call would be named there. "apply_patch": true, "edit_file": true, "patch_file": true, } func isEditTool(name string) bool { return editToolNames[name] } // testCommandRe matches shell invocations that run a test suite, for the // "N consecutive failed test runs" thrash rule (spec §5.3). var testCommandRe = regexp.MustCompile(`(?i)\b(go test|pytest|py\.test|npm (run )?test|yarn test|pnpm test|cargo test|make test|jest|mvn test|rspec|ctest)\b`) // toolCallMeta pulls (kind, key) out of a tool call's JSON input/arguments — // shared by every harness's parser since Claude's tool "input" and Codex's // function "arguments" are both a flat JSON object using the same // conventional field names (file_path, command). func toolCallMeta(input json.RawMessage) (kind, key string) { var m map[string]any if json.Unmarshal(input, &m) != nil { return "", "" } if v, ok := m["file_path"].(string); ok && v != "" { return "file", v } if v, ok := m["command"].(string); ok && v != "" { return "command", strings.TrimSpace(v) } return "", "" } // ClaudeActivity parses a Claude Code transcript's assistant tool_use blocks // paired with their matching user tool_result blocks — the same file // ClaudeSessionFile/ClaudeUsage already open, so no new resolution path is // needed. Each tool_use is matched to its result by tool_use_id; an // unresolved tool_use (session still mid-turn) is simply dropped, not // reported as a call. func ClaudeActivity(path string) ([]ToolCall, error) { f, err := os.Open(path) if err != nil { return nil, err } defer f.Close() type block struct { Type string `json:"type"` ID string `json:"id"` Name string `json:"name"` Input json.RawMessage `json:"input"` ToolUseID string `json:"tool_use_id"` IsError bool `json:"is_error"` } type entry struct { Message struct { Content []block `json:"content"` } `json:"message"` } pending := map[string]ToolCall{} var calls []ToolCall s := bufio.NewScanner(f) s.Buffer(make([]byte, 1<<20), 10<<20) for s.Scan() { var e entry if json.Unmarshal(s.Bytes(), &e) != nil { continue } for _, b := range e.Message.Content { switch b.Type { case "tool_use": kind, key := toolCallMeta(b.Input) pending[b.ID] = ToolCall{Name: b.Name, Kind: kind, Key: key, IsTest: isTestCommand(kind, key)} case "tool_result": if tc, ok := pending[b.ToolUseID]; ok { tc.Success = !b.IsError calls = append(calls, tc) delete(pending, b.ToolUseID) } } } } return calls, s.Err() } // CodexActivity parses the real Codex rollout shape, confirmed live against // this machine's own ~/.codex/sessions on 2026-07-28 — the previous version // of this function assumed an unverified "function_call"/"function_call_output" // payload shape (flat name/arguments/call_id, mirroring CodexUsage's // token_count wrapper) that turned out not to exist in any real rollout file. // The real shape has two independent signals: // // 1. File edits: an `event_msg` with `payload.type == "patch_apply_end"` // carries `changes` (map of absolute path -> diff) and a top-level // `success` bool directly — no pairing needed, and a much more reliable // "same file edited M times" source than trying to parse it out of a // tool call's input. // 2. Shell commands: Codex's actual tool surface is a single freeform // `custom_tool_call` named "exec" whose `input` is a JS snippet calling // `tools.exec_command({cmd:"...", ...})` — not a flat arguments object. // codexExecCommands extracts every embedded cmd string in source order. // Success is read from the paired `custom_tool_call_output`'s text // blocks: a failed script's output observably starts with "Script // error:" on this machine's real transcripts (both a JS syntax error and // an apply_patch verification failure took this form) — there is no // structured exit-code field, so this remains a text heuristic, just a // confirmed one rather than a guessed one. func CodexActivity(path string) ([]ToolCall, error) { f, err := os.Open(path) if err != nil { return nil, err } defer f.Close() type outputBlock struct { Text string `json:"text"` } type payload struct { Type string `json:"type"` CallID string `json:"call_id"` Name string `json:"name"` Input string `json:"input"` Output []outputBlock `json:"output"` Success bool `json:"success"` Changes map[string]json.RawMessage `json:"changes"` } type entry struct { Type string `json:"type"` Payload payload `json:"payload"` } pending := map[string][]ToolCall{} var calls []ToolCall s := bufio.NewScanner(f) s.Buffer(make([]byte, 1<<20), 10<<20) for s.Scan() { var e entry if json.Unmarshal(s.Bytes(), &e) != nil { continue } switch { case e.Type == "event_msg" && e.Payload.Type == "patch_apply_end": for path := range e.Payload.Changes { calls = append(calls, ToolCall{Name: "apply_patch", Kind: "file", Key: path, Success: e.Payload.Success}) } case e.Type == "response_item" && e.Payload.Type == "custom_tool_call": var pendingCalls []ToolCall if e.Payload.Name == "exec" { for _, cmd := range codexExecCommands(e.Payload.Input) { pendingCalls = append(pendingCalls, ToolCall{Name: e.Payload.Name, Kind: "command", Key: cmd, IsTest: isTestCommand("command", cmd)}) } } // Retain a resolved call without an extractable command as activity: // it is useful for ordering, but deliberately carries no key. if len(pendingCalls) == 0 { pendingCalls = []ToolCall{{Name: e.Payload.Name}} } pending[e.Payload.CallID] = pendingCalls case e.Type == "response_item" && e.Payload.Type == "custom_tool_call_output": if pendingCalls, ok := pending[e.Payload.CallID]; ok { failed := false for _, o := range e.Payload.Output { if strings.HasPrefix(strings.TrimSpace(o.Text), "Script error:") { failed = true break } } for _, tc := range pendingCalls { tc.Success = !failed calls = append(calls, tc) } delete(pending, e.Payload.CallID) } } } return calls, s.Err() } // codexExecCmdRe extracts `cmd:"..."` arguments out of an "exec" // custom-tool-call's JS-scripted input. A single script can invoke several // commands; their source order is the observable execution order. var codexExecCmdRe = regexp.MustCompile(`cmd\s*:\s*"((?:[^"\\]|\\.)*)"`) func codexExecCommands(input string) []string { matches := codexExecCmdRe.FindAllStringSubmatch(input, -1) commands := make([]string, 0, len(matches)) for _, m := range matches { cmd := strings.TrimSpace(strings.NewReplacer(`\"`, `"`, `\n`, "\n", `\t`, "\t", `\\`, `\`).Replace(m[1])) if cmd != "" { commands = append(commands, cmd) } } return commands } // OpenCodeActivity has no verified source. OpenCodeUsage already only reads // aggregate token counts per message file (~/.local/share/opencode/storage/ // message/), not per-tool-call records, and nobody has confirmed opencode's // on-disk format exposes tool name/input/outcome at that granularity the way // Claude's transcript and (probably) Codex's rollout do. Refusing loudly here // matches the existing convention in this file (CLIAdapter.resolveSessionFile's // opencode default case) rather than fabricating a parser against an unknown // shape. func OpenCodeActivity(string) ([]ToolCall, error) { return nil, fmt.Errorf("herdr: opencode tool-call activity has no verified source yet; check the message storage format against a live session first") } func isTestCommand(kind, key string) bool { return kind == "command" && testCommandRe.MatchString(key) } // ThrashConfig tunes DetectThrash's three independent circuit breakers (spec // §5.3: "N failed test runs / same file M times / identical tool calls"). // A zero value for any field means "use the package default" — see // withDefaults — so a zero-value ThrashConfig{} is usable as-is. type ThrashConfig struct { MaxConsecutiveTestFailures int MaxSameFileEdits int MaxIdenticalCalls int } func (c ThrashConfig) withDefaults() ThrashConfig { if c.MaxConsecutiveTestFailures <= 0 { c.MaxConsecutiveTestFailures = 3 } if c.MaxSameFileEdits <= 0 { c.MaxSameFileEdits = 5 } if c.MaxIdenticalCalls <= 0 { c.MaxIdenticalCalls = 4 } return c } // DetectThrash evaluates a window of recent tool calls (oldest first, as // returned by an ActivityReader) against the three thrash rules named in // §5.3, returning whether any tripped and a populated continuity.DeadEnd per // rule that did — meant to be handed straight to // CLIAdapter.RequestHandoffReason so the resulting handoff's dead_ends // (§6.1) aren't left for the agent to invent from scratch. func DetectThrash(calls []ToolCall, cfg ThrashConfig) (bool, []continuity.DeadEnd) { cfg = cfg.withDefaults() var deadEnds []continuity.DeadEnd // Rule 1: N consecutive failed test runs, most recent first. streak := 0 var lastFailedCmd string for i := len(calls) - 1; i >= 0; i-- { c := calls[i] if !c.IsTest { continue } if c.Success { break } streak++ lastFailedCmd = c.Key } if streak >= cfg.MaxConsecutiveTestFailures { deadEnds = append(deadEnds, continuity.DeadEnd{ Tried: lastFailedCmd, WhyFailed: fmt.Sprintf("failed %d consecutive times", streak), }) } // Rule 2: the same file edited M times without the loop resolving. editCounts := map[string]int{} for _, c := range calls { if c.Kind != "file" || c.Key == "" || !isEditTool(c.Name) { continue } editCounts[c.Key]++ } for path, n := range editCounts { if n >= cfg.MaxSameFileEdits { deadEnds = append(deadEnds, continuity.DeadEnd{ Tried: "editing " + path, WhyFailed: fmt.Sprintf("edited %d times without resolving", n), }) } } // Rule 3: the identical tool call (same name+kind+key) repeated back to // back, a sign of a stuck retry loop rather than incremental progress. // Deliberately excludes test commands (re-running the same test after a // fix attempt is expected — that's rule 1's job to catch if it keeps // failing) and non-edit file calls like Read (re-reading the same file // across turns is normal and not a sign of being stuck). run := 0 var prevName, prevKind, prevKey string for _, c := range calls { relevant := (c.Kind == "command" && !c.IsTest) || (c.Kind == "file" && isEditTool(c.Name)) if !relevant || c.Key == "" { run = 0 continue } if c.Name == prevName && c.Kind == prevKind && c.Key == prevKey { run++ } else { run = 1 prevName, prevKind, prevKey = c.Name, c.Kind, c.Key } if run >= cfg.MaxIdenticalCalls { deadEnds = append(deadEnds, continuity.DeadEnd{ Tried: fmt.Sprintf("%s(%s)", c.Name, c.Key), WhyFailed: fmt.Sprintf("repeated identically %d times in a row", run), }) run = 0 // one dead end per repeated run is enough } } return len(deadEnds) > 0, deadEnds } // DetectMilestone recognizes the simplest unambiguous "a coherent unit of // work just finished" signal available from tool-call history alone (spec // §5.3): the most recent call was a successful `git commit`. Anything // fuzzier (a passing test suite, a finished subtask) needs a definition of // "coherent unit" this repo has no source for yet, so it is deliberately not // guessed at here. func DetectMilestone(calls []ToolCall) bool { if len(calls) == 0 { return false } last := calls[len(calls)-1] return last.Success && last.Kind == "command" && strings.HasPrefix(last.Key, "git commit") }