| 1 | package builtin |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "strconv" |
| 8 | "strings" |
| 9 | |
| 10 | "reasonix/internal/evidence" |
| 11 | "reasonix/internal/instruction" |
| 12 | "reasonix/internal/provider" |
| 13 | "reasonix/internal/tool" |
| 14 | ) |
| 15 | |
| 16 | func init() { tool.RegisterBuiltin(completeStep{}) } |
| 17 | |
| 18 | // completeStep records an evidence-backed completion of one step of an approved |
| 19 | // plan. Like todo_write it has no host side effects — the claim and its evidence |
| 20 | // live in the call's args, which a frontend renders as a signed-off step. Its |
| 21 | // reason for existing is the enforcement in Execute: a completion with no evidence |
| 22 | // is rejected, so the model can't flip a step to "done" without showing why it is |
| 23 | // done (the verification it ran, the diff/files it changed, or a manual check). |
| 24 | // It complements todo_write — todo_write keeps the list moving (one item |
| 25 | // in_progress), complete_step is the formal sign-off of a finished step. |
| 26 | type completeStep struct{} |
| 27 | |
| 28 | type stepEvidence struct { |
| 29 | Kind string `json:"kind"` |
| 30 | Summary string `json:"summary"` |
| 31 | Command string `json:"command,omitempty"` |
| 32 | Paths []string `json:"paths,omitempty"` |
| 33 | } |
| 34 | |
| 35 | // validEvidenceKinds are the evidence forms a completion may cite. "checkpoint" |
| 36 | // (main's fourth kind) is omitted — v2 has no checkpoint system. |
| 37 | var validEvidenceKinds = map[string]bool{ |
| 38 | "verification": true, // a command/test was run; cite it and its outcome |
| 39 | "review": true, // a completed built-in review run, fresh for any later mutation |
| 40 | "diff": true, // a concrete code change; cite what changed |
| 41 | "files": true, // files created/edited/inspected; cite the paths |
| 42 | "manual": true, // a manual check; cite what was confirmed and how |
| 43 | } |
| 44 | |
| 45 | func (completeStep) Name() string { return "complete_step" } |
| 46 | |
| 47 | func (completeStep) Description() string { |
| 48 | return "Record the evidence-backed completion of ONE step of an approved plan. Call it as you finish each step instead of silently moving on: it signs the step off with PROOF it is done — the verification you ran (command + result), a completed built-in review that is fresh for any later changes, the diff/files you changed, or a manual check. A completion with no evidence is REJECTED, so don't claim a step is done until you can show why. The host advances the task list for you when you sign off — it marks this step completed and moves the next to in_progress, so you don't need a separate todo_write to mark completions. Fields: `step` (which step — its title or number, matching the task list), `result` (what is now true/changed), `evidence` (≥1 item, each with `kind` = verification|review|diff|files|manual and a `summary`, plus optional `command`/`paths`), and optional `notes`." |
| 49 | } |
| 50 | |
| 51 | func (completeStep) Schema() json.RawMessage { |
| 52 | return json.RawMessage(`{ |
| 53 | "type":"object", |
| 54 | "properties":{ |
| 55 | "step":{"type":"string","description":"Which plan step this completes — its title or number, matching the task list."}, |
| 56 | "step_index":{"type":"integer","minimum":1,"description":"Optional 1-based task-list item number. Prefer this when the step title is long or easy to mistype."}, |
| 57 | "result":{"type":"string","description":"What is now true or changed as a result of finishing this step."}, |
| 58 | "evidence":{ |
| 59 | "type":"array", |
| 60 | "minItems":1, |
| 61 | "description":"Proof the step is done. At least one item is required.", |
| 62 | "items":{ |
| 63 | "type":"object", |
| 64 | "properties":{ |
| 65 | "kind":{"type":"string","enum":["verification","review","diff","files","manual"],"description":"verification = a command/test was run (command REQUIRED); review = a built-in review run completed and, after changes, inspected the latest changed result (the verdict/findings still apply separately); diff = a concrete code change (paths REQUIRED); files = files created/edited/inspected (paths REQUIRED); manual = a manual check."}, |
| 66 | "summary":{"type":"string","description":"The evidence itself: the test result, what the diff does, or what was confirmed."}, |
| 67 | "command":{"type":"string","description":"REQUIRED for verification evidence: the command as it actually ran (e.g. \"go test ./...\") — it is checked against this session's real command history."}, |
| 68 | "paths":{"type":"array","items":{"type":"string"},"description":"REQUIRED for diff/files evidence: the files this evidence refers to, as the paths were passed to the tools that touched them."} |
| 69 | }, |
| 70 | "required":["kind","summary"] |
| 71 | } |
| 72 | }, |
| 73 | "notes":{"type":"string","description":"Optional caveats, follow-ups, or anything deferred."} |
| 74 | }, |
| 75 | "required":["result","evidence"] |
| 76 | }`) |
| 77 | } |
| 78 | |
| 79 | // ReadOnly is true: complete_step only records a claim (no filesystem or process |
| 80 | // effect), so it never needs approval and stays available alongside todo_write. |
| 81 | func (completeStep) ReadOnly() bool { return true } |
| 82 | |
| 83 | // PlanModeSafe reports false: although complete_step is read-only, it signs off a |
| 84 | // completed execution step, which is meaningful only after plan approval — not |
| 85 | // during planning. This explicit phase opt-out is the Plan gate's enforced |
| 86 | // exception to the ordinary Permissions/Sandbox path. |
| 87 | func (completeStep) PlanModeSafe() bool { return false } |
| 88 | |
| 89 | func (completeStep) Execute(ctx context.Context, args json.RawMessage) (string, error) { |
| 90 | var p struct { |
| 91 | Step string `json:"step"` |
| 92 | StepIndex int `json:"step_index"` |
| 93 | Result string `json:"result"` |
| 94 | Evidence []stepEvidence `json:"evidence"` |
| 95 | Notes string `json:"notes"` |
| 96 | } |
| 97 | if err := json.Unmarshal(args, &p); err != nil { |
| 98 | return "", fmt.Errorf("invalid args: %w", err) |
| 99 | } |
| 100 | step := completeStepIdentity(p.Step, p.StepIndex) |
| 101 | if step == "" { |
| 102 | return "", fmt.Errorf("step or step_index is required — name the plan step you are completing, or cite its 1-based task-list number") |
| 103 | } |
| 104 | if p.StepIndex < 0 { |
| 105 | return "", fmt.Errorf("step_index must be a positive 1-based task-list number") |
| 106 | } |
| 107 | if strings.TrimSpace(p.Result) == "" { |
| 108 | return "", fmt.Errorf("result is required — state what is now true after finishing this step") |
| 109 | } |
| 110 | if len(p.Evidence) == 0 { |
| 111 | return "", fmt.Errorf("at least one evidence item is required — don't mark a step complete without showing why it's done (run a check, cite the diff, or confirm manually)") |
| 112 | } |
| 113 | kinds := make([]string, 0, len(p.Evidence)) |
| 114 | for i, e := range p.Evidence { |
| 115 | if !validEvidenceKinds[e.Kind] { |
| 116 | return "", fmt.Errorf("evidence %d: invalid kind %q (want verification|diff|files|manual)", i+1, e.Kind) |
| 117 | } |
| 118 | if strings.TrimSpace(e.Summary) == "" { |
| 119 | return "", fmt.Errorf("evidence %d: summary is required — the evidence is the summary, not just its kind", i+1) |
| 120 | } |
| 121 | kinds = append(kinds, e.Kind) |
| 122 | } |
| 123 | |
| 124 | todoMatch, hasTodo, err := verifyTodoStep(ctx, step) |
| 125 | if err != nil { |
| 126 | return "", err |
| 127 | } |
| 128 | hostVerified, manualUnverified, err := verifyStepEvidence(ctx, p.Evidence) |
| 129 | if err != nil { |
| 130 | if hasTodo && todoMatch.Status == "in_progress" { |
| 131 | return "", fmt.Errorf("%v; todo %d %q remains in_progress — repair the evidence and retry this step before moving on", err, todoMatch.Index, todoMatch.Content) |
| 132 | } |
| 133 | return "", err |
| 134 | } |
| 135 | projectVerified, err := verifyProjectChecks(ctx, p.Evidence) |
| 136 | if err != nil { |
| 137 | return "", err |
| 138 | } |
| 139 | hostStatus := "" |
| 140 | if _, ok := evidence.FromContext(ctx); ok { |
| 141 | hostStatus = fmt.Sprintf(" Host evidence: host-verified %d, manual/unverified %d.", hostVerified, manualUnverified) |
| 142 | } |
| 143 | todoStatus := "" |
| 144 | if hasTodo { |
| 145 | todoStatus = fmt.Sprintf(" Todo step: todo-matched %d (%q).", todoMatch.Index, todoMatch.Content) |
| 146 | } |
| 147 | projectStatus := "" |
| 148 | if projectVerified > 0 { |
| 149 | projectStatus = fmt.Sprintf(" Project checks: project checks %d.", projectVerified) |
| 150 | } |
| 151 | advanceStatus := " The host advanced the task list; continue with the next step." |
| 152 | if hasTodo && todoMatch.Status == "completed" { |
| 153 | advanceStatus = " The matched todo was already completed; the task list is unchanged." |
| 154 | } |
| 155 | return fmt.Sprintf("Step %q signed off with %d evidence item(s) [%s].%s%s", |
| 156 | step, len(p.Evidence), strings.Join(kinds, ", "), hostStatus+todoStatus+projectStatus, advanceStatus), nil |
| 157 | } |
| 158 | |
| 159 | func completeStepIdentity(step string, stepIndex int) string { |
| 160 | if stepIndex > 0 { |
| 161 | return strconv.Itoa(stepIndex) |
| 162 | } |
| 163 | return strings.TrimSpace(step) |
| 164 | } |
| 165 | |
| 166 | func verifyStepEvidence(ctx context.Context, items []stepEvidence) (hostVerified int, manualUnverified int, err error) { |
| 167 | ledger, ok := evidence.FromContext(ctx) |
| 168 | if !ok { |
| 169 | return 0, 0, nil |
| 170 | } |
| 171 | for i, e := range items { |
| 172 | switch e.Kind { |
| 173 | case "verification": |
| 174 | command := strings.TrimSpace(e.Command) |
| 175 | if command == "" { |
| 176 | return 0, 0, fmt.Errorf("evidence %d: verification command is required for host verification — cite the command you ran, or use kind \"manual\"", i+1) |
| 177 | } |
| 178 | if !ledger.HasSuccessfulCommand(command) && !verifyCommandFromSession(ctx, command) { |
| 179 | if ledger.HasFailedCommand(command) { |
| 180 | return 0, 0, fmt.Errorf("evidence %d: verification command %q ran but exited non-zero, so it can't prove the step; if the non-zero exit is itself the expected proof (e.g. a file is gone), re-run it so it succeeds (append \"|| true\") and sign off again", i+1, command) |
| 181 | } |
| 182 | hint := allCommandHints(ctx, ledger) |
| 183 | return 0, 0, fmt.Errorf("evidence %d: verification command %q has no matching successful receipt — cite the command exactly as it ran in the session%s", i+1, command, hint) |
| 184 | } |
| 185 | _, deliveryHasMutation := ledger.LatestSuccessfulMutationIndex() |
| 186 | if evidence.DeliveryProfileFromContext(ctx) && deliveryHasMutation && !evidence.IsDeliveryVerificationCommand(command) { |
| 187 | return 0, 0, fmt.Errorf("evidence %d: command %q ran successfully but is not a recognized delivery verification; do not cite an opaque command as verification. Use a project test/check/lint command, or for JavaScript syntax use node --check <file> (a read-only extraction pipeline ending in node --check also works). If this was only a visible/manual inspection, cite kind manual or files without a command, then rerun and cite a recognized verifier after any opaque mutation", i+1, command) |
| 188 | } |
| 189 | hostVerified++ |
| 190 | case "review": |
| 191 | if !ledger.HasCompletedReview() { |
| 192 | return 0, 0, fmt.Errorf("evidence %d: review evidence requires a completed review run in this turn; after a mutation, the review must be newer and cover the changed result", i+1) |
| 193 | } |
| 194 | hostVerified++ |
| 195 | case "diff": |
| 196 | if len(e.Paths) == 0 { |
| 197 | return 0, 0, fmt.Errorf("evidence %d: diff evidence requires paths for host verification — cite the files you changed", i+1) |
| 198 | } |
| 199 | if !ledger.HasSuccessfulWrite(e.Paths) && !verifyPathsFromSession(ctx, e.Paths, true) { |
| 200 | return 0, 0, fmt.Errorf("evidence %d: diff paths have no matching successful writer receipt in this turn%s", i+1, receiptHint("files written this turn", ledger.TouchedPaths(8, true))) |
| 201 | } |
| 202 | hostVerified++ |
| 203 | case "files": |
| 204 | if len(e.Paths) == 0 { |
| 205 | return 0, 0, fmt.Errorf("evidence %d: files evidence requires paths for host verification — cite the files you touched", i+1) |
| 206 | } |
| 207 | if !ledger.HasSuccessfulReadOrWrite(e.Paths) && !ledger.HasSuccessfulBashMentioningPaths(e.Paths) && !verifyPathsFromSession(ctx, e.Paths, false) { |
| 208 | return 0, 0, fmt.Errorf("evidence %d: file paths have no matching successful read/write receipt in this turn%s", i+1, receiptHint("files touched this turn", ledger.TouchedPaths(8, false))) |
| 209 | } |
| 210 | hostVerified++ |
| 211 | case "manual": |
| 212 | manualUnverified++ |
| 213 | } |
| 214 | } |
| 215 | return hostVerified, manualUnverified, nil |
| 216 | } |
| 217 | |
| 218 | func verifyProjectChecks(ctx context.Context, items []stepEvidence) (int, error) { |
| 219 | checks := instruction.FromContext(ctx) |
| 220 | if len(checks) == 0 { |
| 221 | return 0, nil |
| 222 | } |
| 223 | ledger, ok := evidence.FromContext(ctx) |
| 224 | if !ok { |
| 225 | return 0, nil |
| 226 | } |
| 227 | after, ok := latestWriteBackedEvidenceIndex(ledger, items) |
| 228 | if !ok { |
| 229 | return 0, nil |
| 230 | } |
| 231 | for _, check := range checks { |
| 232 | command := strings.TrimSpace(check.Command) |
| 233 | if command == "" { |
| 234 | continue |
| 235 | } |
| 236 | if !ledger.HasSuccessfulCommandAfter(command, after) { |
| 237 | return 0, fmt.Errorf("project check %q from %s has no matching successful bash receipt after the latest matching write in this turn", command, checkSource(check)) |
| 238 | } |
| 239 | } |
| 240 | return len(checks), nil |
| 241 | } |
| 242 | |
| 243 | func latestWriteBackedEvidenceIndex(ledger *evidence.Ledger, items []stepEvidence) (int, bool) { |
| 244 | latest := -1 |
| 245 | for _, item := range items { |
| 246 | switch item.Kind { |
| 247 | case "diff", "files": |
| 248 | if i, ok := ledger.LatestSuccessfulWriteIndex(item.Paths); ok && i > latest { |
| 249 | latest = i |
| 250 | } |
| 251 | } |
| 252 | } |
| 253 | return latest, latest >= 0 |
| 254 | } |
| 255 | |
| 256 | func checkSource(check instruction.VerifyCheck) string { |
| 257 | source := strings.TrimSpace(check.SourcePath) |
| 258 | if source == "" { |
| 259 | source = "project memory" |
| 260 | } |
| 261 | if check.Line > 0 { |
| 262 | return fmt.Sprintf("%s:%d", source, check.Line) |
| 263 | } |
| 264 | return source |
| 265 | } |
| 266 | |
| 267 | func verifyTodoStep(ctx context.Context, step string) (evidence.TodoStepMatch, bool, error) { |
| 268 | ledger, ok := evidence.FromContext(ctx) |
| 269 | var todos []evidence.TodoItem |
| 270 | if ok { |
| 271 | todos, _ = ledger.LatestTodos() |
| 272 | } |
| 273 | if len(todos) == 0 { |
| 274 | todos, _ = evidence.TodoStateFromContext(ctx) |
| 275 | } |
| 276 | if len(todos) == 0 { |
| 277 | return evidence.TodoStepMatch{}, false, nil |
| 278 | } |
| 279 | match, found := evidence.MatchStep(step, todos) |
| 280 | if !found { |
| 281 | allCompleted := true |
| 282 | for _, todo := range todos { |
| 283 | if strings.TrimSpace(todo.Status) != "completed" { |
| 284 | allCompleted = false |
| 285 | break |
| 286 | } |
| 287 | } |
| 288 | if allCompleted { |
| 289 | last := len(todos) - 1 |
| 290 | return evidence.TodoStepMatch{}, true, fmt.Errorf("step %q has no matching todo_write item and every current todo is already completed; this is a renewal sign-off, so retry complete_step with step_index %d (the final existing todo %q) and the fresh evidence — do not invent a new step or rewrite the completed list", step, last+1, todos[last].Content) |
| 291 | } |
| 292 | return evidence.TodoStepMatch{}, true, fmt.Errorf("step %q has no matching todo_write item in the current task list; cite a todo verbatim or by number: %s", step, todoListInventory(todos)) |
| 293 | } |
| 294 | switch match.Status { |
| 295 | case "in_progress": |
| 296 | if unfinished, ok := evidence.FirstUnfinishedSubStep(todos, match.Index-1); ok && unfinished >= 0 { |
| 297 | return evidence.TodoStepMatch{}, true, fmt.Errorf("step %q matches phase %d %q whose sub-steps are unfinished; complete sub-step %d %q first, then sign the phase off", step, match.Index, match.Content, unfinished+1, todos[unfinished].Content) |
| 298 | } |
| 299 | return match, true, nil |
| 300 | case "completed": |
| 301 | return match, true, nil |
| 302 | case "", "pending": |
| 303 | current := "" |
| 304 | for i, todo := range todos { |
| 305 | if strings.TrimSpace(todo.Status) != "in_progress" { |
| 306 | continue |
| 307 | } |
| 308 | // The deepest in_progress item is the signable end of the current |
| 309 | // chain: prefer an active sub-step over its phase header. |
| 310 | current = fmt.Sprintf("; finish todo %d %q first", i+1, todo.Content) |
| 311 | if todo.Level == 1 { |
| 312 | break |
| 313 | } |
| 314 | } |
| 315 | return evidence.TodoStepMatch{}, true, fmt.Errorf("step %q matches pending todo %d %q; complete_step only signs the current in_progress item%s", step, match.Index, match.Content, current) |
| 316 | default: |
| 317 | return evidence.TodoStepMatch{}, true, fmt.Errorf("step %q matches todo %d (%q) but its status is %q; complete_step requires in_progress or completed", step, match.Index, match.Content, match.Status) |
| 318 | } |
| 319 | } |
| 320 | |
| 321 | func todoInventory(ledger *evidence.Ledger) string { |
| 322 | todos, ok := ledger.LatestTodos() |
| 323 | if !ok || len(todos) == 0 { |
| 324 | return "(no todos recorded this turn)" |
| 325 | } |
| 326 | return todoListInventory(todos) |
| 327 | } |
| 328 | |
| 329 | func todoListInventory(todos []evidence.TodoItem) string { |
| 330 | parts := make([]string, 0, len(todos)) |
| 331 | for i, t := range todos { |
| 332 | content := t.Content |
| 333 | if r := []rune(content); len(r) > 60 { |
| 334 | content = string(r[:60]) + "…" |
| 335 | } |
| 336 | parts = append(parts, fmt.Sprintf("%d) %q", i+1, content)) |
| 337 | if len(parts) == 12 && len(todos) > 12 { |
| 338 | parts = append(parts, fmt.Sprintf("… %d more", len(todos)-12)) |
| 339 | break |
| 340 | } |
| 341 | } |
| 342 | return strings.Join(parts, ", ") |
| 343 | } |
| 344 | |
| 345 | // verifyCommandFromSession scans the full conversation history (not just the |
| 346 | // per-turn ledger) so a complete_step can cite a command that ran in an |
| 347 | // earlier turn (the ledger resets per turn) or via a named tool instead of |
| 348 | // bash. Calls whose recorded result is an error or a block are skipped — they |
| 349 | // prove the command was attempted, not that it succeeded. |
| 350 | func verifyCommandFromSession(ctx context.Context, command string) bool { |
| 351 | msgs, ok := evidence.SessionMessagesFromContext(ctx) |
| 352 | if !ok { |
| 353 | return false |
| 354 | } |
| 355 | lookup := strings.TrimSuffix(strings.TrimSuffix(strings.TrimSpace(command), "..."), "…") |
| 356 | if lookup == "" { |
| 357 | return false |
| 358 | } |
| 359 | toolName := firstWord(lookup) |
| 360 | failed := failedCallIDs(msgs) |
| 361 | |
| 362 | for _, msg := range msgs { |
| 363 | for _, tc := range msg.ToolCalls { |
| 364 | if failed[tc.ID] { |
| 365 | continue |
| 366 | } |
| 367 | cmd := extractCommandFromCall(tc.Name, tc.Arguments) |
| 368 | if cmd == "" { |
| 369 | continue |
| 370 | } |
| 371 | if evidence.CommandMatches(lookup, cmd) { |
| 372 | return true |
| 373 | } |
| 374 | if toolName != "" && toolName != "bash" && tc.Name == toolName { |
| 375 | return true |
| 376 | } |
| 377 | } |
| 378 | } |
| 379 | return false |
| 380 | } |
| 381 | |
| 382 | // verifyPathsFromSession is the diff/files analogue of verifyCommandFromSession: |
| 383 | // it lets a completion cite a file written or read in an earlier turn (the |
| 384 | // per-turn ledger only has this turn). wantWrite restricts to writer tools. |
| 385 | func verifyPathsFromSession(ctx context.Context, paths []string, wantWrite bool) bool { |
| 386 | msgs, ok := evidence.SessionMessagesFromContext(ctx) |
| 387 | if !ok { |
| 388 | return false |
| 389 | } |
| 390 | return evidence.PathsProvenInSession(msgs, paths, wantWrite) |
| 391 | } |
| 392 | |
| 393 | func failedCallIDs(msgs []provider.Message) map[string]bool { |
| 394 | failed := map[string]bool{} |
| 395 | for _, msg := range msgs { |
| 396 | if msg.Role != provider.RoleTool || msg.ToolCallID == "" { |
| 397 | continue |
| 398 | } |
| 399 | if strings.HasPrefix(msg.Content, "error:") || strings.HasPrefix(msg.Content, "blocked:") { |
| 400 | failed[msg.ToolCallID] = true |
| 401 | } |
| 402 | } |
| 403 | return failed |
| 404 | } |
| 405 | |
| 406 | func receiptHint(label string, items []string) string { |
| 407 | if len(items) == 0 { |
| 408 | return "" |
| 409 | } |
| 410 | for i, item := range items { |
| 411 | if len(item) > 80 { |
| 412 | items[i] = item[:80] + "…" |
| 413 | } |
| 414 | } |
| 415 | return fmt.Sprintf("; %s: %q — cite one as it actually ran, or run the check now", label, items) |
| 416 | } |
| 417 | |
| 418 | // allCommandHints builds a combined hint from both the per-turn ledger and the |
| 419 | // full session history, so the model can self-correct a mismatched citation. |
| 420 | func allCommandHints(ctx context.Context, ledger *evidence.Ledger) string { |
| 421 | seen := map[string]bool{} |
| 422 | var cmds []string |
| 423 | if ledger != nil { |
| 424 | for _, c := range ledger.SuccessfulCommands(8) { |
| 425 | if !seen[c] { |
| 426 | seen[c] = true |
| 427 | cmds = append(cmds, c) |
| 428 | } |
| 429 | } |
| 430 | } |
| 431 | if msgs, ok := evidence.SessionMessagesFromContext(ctx); ok { |
| 432 | failed := failedCallIDs(msgs) |
| 433 | for _, msg := range msgs { |
| 434 | for _, tc := range msg.ToolCalls { |
| 435 | if failed[tc.ID] { |
| 436 | continue |
| 437 | } |
| 438 | if tc.Name == "todo_write" || tc.Name == "complete_step" { |
| 439 | continue |
| 440 | } |
| 441 | c := extractCommandFromCall(tc.Name, tc.Arguments) |
| 442 | if c == "" || seen[c] { |
| 443 | continue |
| 444 | } |
| 445 | seen[c] = true |
| 446 | cmds = append(cmds, c) |
| 447 | if len(cmds) >= 12 { |
| 448 | break |
| 449 | } |
| 450 | } |
| 451 | if len(cmds) >= 12 { |
| 452 | break |
| 453 | } |
| 454 | } |
| 455 | } |
| 456 | if len(cmds) == 0 { |
| 457 | return "" |
| 458 | } |
| 459 | // Truncate long entries for readability. |
| 460 | for i, c := range cmds { |
| 461 | if len(c) > 80 { |
| 462 | cmds[i] = c[:80] + "…" |
| 463 | } |
| 464 | } |
| 465 | return fmt.Sprintf("; commands that ran: %q — pick the matching one and retry complete_step", cmds) |
| 466 | } |
| 467 | |
| 468 | func firstWord(s string) string { |
| 469 | s = strings.TrimSpace(s) |
| 470 | if idx := strings.IndexAny(s, " \t\n"); idx >= 0 { |
| 471 | return s[:idx] |
| 472 | } |
| 473 | return s |
| 474 | } |
| 475 | |
| 476 | // extractCommandFromCall extracts the bash "command" argument from a tool call |
| 477 | // args JSON, or returns the tool name + path for non-bash tools. |
| 478 | func extractCommandFromCall(name string, argsJSON string) string { |
| 479 | if name == "bash" { |
| 480 | var args struct { |
| 481 | Command string `json:"command"` |
| 482 | } |
| 483 | if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { |
| 484 | return "" |
| 485 | } |
| 486 | return strings.TrimSpace(args.Command) |
| 487 | } |
| 488 | // For non-bash tools, return "name path" so the command "ls ." can match |
| 489 | // against a tool call `ls` with path `.`. |
| 490 | var args struct { |
| 491 | Path string `json:"path"` |
| 492 | } |
| 493 | if err := json.Unmarshal([]byte(argsJSON), &args); err != nil || args.Path == "" { |
| 494 | return name |
| 495 | } |
| 496 | return name + " " + args.Path |
| 497 | } |
| 498 |