372 lines
13 KiB
Go
372 lines
13 KiB
Go
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")
|
|
}
|