返回 DeepSeek-Reasonix
finalization.go
根目录 / internal / agent / finalization.go
1 package agent
2
3 import (
4 "context"
5 "fmt"
6 "strings"
7
8 "reasonix/internal/event"
9 "reasonix/internal/i18n"
10 "reasonix/internal/provider"
11 "reasonix/internal/tool"
12 )
13
14 // landCause is why a turn was told to finalize. kind selects the pause the Run
15 // ends with, so a host can tell "spent its budget" from "hit the round assert".
16 type landCause struct {
17 kind string
18 axis string
19 detail string
20 }
21
22 // turnFinalizer is implemented only by host-consumed terminal tools owned by
23 // this package. A successful finalizer carries the turn's result as structured
24 // data, so no provider-generated prose acknowledgement is needed afterwards.
25 type turnFinalizer interface {
26 finalizesTurn()
27 }
28
29 func (a *Agent) providerToolSchemas() []provider.ToolSchema {
30 if a == nil || a.svc.tools == nil || !provider.SupportsTools(a.svc.prov) {
31 return []provider.ToolSchema{}
32 }
33 schemas := a.svc.tools.Schemas()
34 visible := schemas[:0]
35 for _, schema := range schemas {
36 if !retiredTool(schema.Name) {
37 visible = append(visible, schema)
38 }
39 }
40 schemas = visible
41 if !provider.NativeToolSearchEnabled(a.svc.prov) {
42 return schemas
43 }
44 return provider.ApplyNativeToolSearch(schemas, deferredMCPSchemas(a.svc.tools), a.svc.prov)
45 }
46
47 func deferredMCPSchemas(reg *tool.Registry) []provider.ToolSchema {
48 if reg == nil {
49 return nil
50 }
51 var extra []provider.ToolSchema
52 for _, name := range reg.AllNames() {
53 if retiredTool(name) {
54 continue
55 }
56 if !strings.HasPrefix(name, "mcp__") {
57 continue
58 }
59 if reg.ProviderVisible(name) {
60 continue
61 }
62 target, ok := reg.Get(name)
63 if !ok {
64 continue
65 }
66 extra = append(extra, provider.ToolSchema{
67 Name: target.Name(),
68 Description: target.Description(),
69 Parameters: target.Schema(),
70 })
71 }
72 return extra
73 }
74
75 func (a *Agent) singleTurnFinalizer(calls []provider.ToolCall) bool {
76 if a == nil || a.svc.tools == nil || len(calls) != 1 {
77 return false
78 }
79 t, _, ambiguous := a.svc.tools.ResolveCall(calls[0].Name)
80 if t == nil || len(ambiguous) > 0 {
81 return false
82 }
83 _, ok := t.(turnFinalizer)
84 return ok
85 }
86
87 func (a *Agent) allowsBoundaryTurnFinalizer(ctx context.Context, state *turnRuntime, calls []provider.ToolCall) bool {
88 if state == nil || !state.graceRound || !a.singleTurnFinalizer(calls) {
89 return false
90 }
91 _, ok := planSubmissionFromContext(ctx)
92 return ok
93 }
94
95 func (a *Agent) successfulTurnFinalizer(ctx context.Context, calls []provider.ToolCall, batch batchExecution) bool {
96 if !a.singleTurnFinalizer(calls) || len(batch.outcomes) != 1 {
97 return false
98 }
99 outcome := batch.outcomes[0]
100 if outcome.errMsg != "" || outcome.blocked {
101 return false
102 }
103 submission, ok := planSubmissionFromContext(ctx)
104 if !ok {
105 return false
106 }
107 _, submitted := submission.Plan()
108 return submitted
109 }
110
111 func (c landCause) nudge(state *turnRuntime, submitPlan bool) string {
112 close := "Do not call any more tools. Synthesize a final answer from the work already completed: what was accomplished, what remains, and any decision the user should make."
113 if submitPlan {
114 close = "Do not call any more research tools. Synthesize the evidence already collected into the best executor-ready plan you can, label remaining uncertainty, and call submit_plan now."
115 }
116 if c.kind == "task_budget" {
117 return fmt.Sprintf("This task has reached its %s budget. %s Use the evidence already collected and label what is still uncertain; the user can continue in the next message.", c.axis, close)
118 }
119 tail := fmt.Sprintf("The user can increase %s or continue in the next turn if more work is needed.", state.runMaxStepsKey)
120 return fmt.Sprintf("Your tool-call round limit (%s) has been reached. %s %s", state.runMaxStepsKey, close, tail)
121 }
122
123 func (c landCause) noticeText() string {
124 if c.kind == "task_budget" {
125 return i18n.M.TaskBudget
126 }
127 return toolBudgetNoticeText()
128 }
129
130 // armFinalizationRound is the single place a turn is told to stop using tools.
131 // The grace round it sets — not the wording — is what enforces that: research
132 // calls in the next round are paired and refused by stopUnexecutedBoundaryCalls;
133 // a host-consumed structured finalizer is the only exception.
134 func (a *Agent) armFinalizationRound(ctx context.Context, state *turnRuntime, cause landCause) error {
135 if state.graceRound {
136 return nil
137 }
138 state.graceRound = true
139 state.landCause = cause
140 _, canSubmitPlan := planSubmissionFromContext(ctx)
141 if err := a.appendCommittedMessages(ctx, "finalization-nudge", HostGeneratedUserMessage(a.withTurnPreferences(cause.nudge(state, canSubmitPlan)))); err != nil {
142 return err
143 }
144 a.svc.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Code: event.NoticeCodeToolBudget,
145 Text: cause.noticeText(), Detail: cause.detail})
146 return nil
147 }
148
149 // gracePause is the resumable stop a finalized turn ends with, chosen by what
150 // caused the landing.
151 func (a *Agent) gracePause(state *turnRuntime) error {
152 if state.landCause.kind == "task_budget" {
153 return &taskBudgetPause{axis: state.landCause.axis, detail: state.landCause.detail}
154 }
155 return &maxStepsPause{steps: state.runMaxSteps, key: state.runMaxStepsKey}
156 }
157
158 // taskBudgetPause ends a Run that spent its task budget. The work is saved and
159 // the next message continues it — with a fresh budget, because the user
160 // deciding to continue is the approval that a round counter cannot ask for.
161 type taskBudgetPause struct {
162 axis string
163 detail string
164 }
165
166 func (e *taskBudgetPause) Error() string {
167 return fmt.Sprintf("paused after reaching this task's %s budget (%s) — the work so far is saved; send another message to continue", e.axis, e.detail)
168 }
169
169 lines GO