返回 DeepSeek-Reasonix
completestep.go
根目录 / internal / tool / builtin / completestep.go
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
498 lines GO