返回 DeepSeek-Reasonix
subagent_report.go
根目录 / internal / agent / subagent_report.go
1 package agent
2
3 import (
4 "context"
5 "fmt"
6 "strings"
7
8 "reasonix/internal/event"
9 "reasonix/internal/evidence"
10 "reasonix/internal/tool"
11 )
12
13 // hostReceiptsMaxItems bounds each rendered line so a long child run cannot
14 // crowd the parent's context out with paths and commands.
15 const hostReceiptsMaxItems = 8
16
17 const hostReceiptsHeader = "Host receipts (recorded by the host as the sub-agent ran, not claimed by it):"
18
19 const hostReceiptsViolationLabel = "OUTSIDE DECLARED write_paths"
20
21 // splitHostReceipts separates a child's own prose from the host attestation
22 // appended to it. Aggregates truncate prose to fit a budget; the attestation is
23 // bounded already and must never be the part that gets cut.
24 func splitHostReceipts(answer string) (prose, receipts string) {
25 idx := strings.LastIndex(answer, hostReceiptsHeader)
26 if idx < 0 {
27 return answer, ""
28 }
29 return strings.TrimRight(answer[:idx], "\n"), strings.TrimSpace(answer[idx:])
30 }
31
32 // boundedHostReceipts trims an attestation to fit limit bytes. The header and
33 // any violation line always survive: a parent may lose the detail of what
34 // changed, but never the fact that a write left the declared claim.
35 func boundedHostReceipts(receipts string, limit int) string {
36 if receipts == "" || len(receipts) <= limit {
37 return receipts
38 }
39 lines := strings.Split(receipts, "\n")
40 var violations []string
41 for _, line := range lines[1:] {
42 if strings.Contains(line, hostReceiptsViolationLabel) {
43 violations = append(violations, line)
44 }
45 }
46 if len(violations) == 0 {
47 return utf8Prefix(lines[0], limit)
48 }
49 // A claim escape outranks the header it would normally sit under.
50 if withHeader := strings.Join(append(lines[:1:1], violations...), "\n"); len(withHeader) <= limit {
51 return withHeader
52 }
53 return utf8Prefix(strings.Join(violations, "\n"), limit)
54 }
55
56 // decorateExecutionReceipt records what the host itself observed about one tool
57 // call. The output length and the process outcome never come from model
58 // arguments, which is what makes the resulting attestation trustworthy.
59 func decorateExecutionReceipt(rec *evidence.Receipt, result string, ex *tool.ShellExecution) {
60 if rec == nil {
61 return
62 }
63 rec.ObserveOutput(result)
64 if ex == nil {
65 return
66 }
67 if ex.ExitCode != nil {
68 code := *ex.ExitCode
69 rec.ExitCode = &code
70 }
71 rec.Verification = ex.Verification
72 rec.Interrupted = ex.State == tool.ShellStateCancelled
73 }
74
75 // composeSubagentAnswer assembles everything the parent is shown for one child
76 // run: the model's completion report when the child submitted one, the
77 // child's own prose, then the host's receipts.
78 func composeSubagentAnswer(ctx context.Context, answer string, sub *Agent, claims WritePathSet, delegationText string) string {
79 summary := sub.EvidenceSummary()
80 report, hasReport := sub.CompletionReport()
81 if hasReport {
82 answer = strings.TrimSpace(formatCompletionReport(report) + "\n\n" + answer)
83 }
84 recordDelegationAudit(ctx, summary, claims, hasReport, delegationText)
85 return appendHostReceipts(answer, summary, claims)
86 }
87
88 // recordDelegationAudit emits one structured receipt per child run. It reports
89 // what the host observed, so an orchestration
90 // benchmark can separate real gains from extra tokens spent. delegationText is
91 // the parent-authored task before host framing, which is what makes the
92 // evidence-origin split a host record rather than a claim.
93 func recordDelegationAudit(ctx context.Context, summary evidence.ChildEvidenceSummary, claims WritePathSet, hasReport bool, delegationText string) {
94 audit := evidence.DelegationAudit{
95 Depth: SubagentDepth(ctx),
96 ToolCalls: len(summary.Receipts),
97 MutationPaths: summary.MutationPaths(),
98 ClaimViolations: len(claimViolations(summary, claims)),
99 HasReport: hasReport,
100 }
101 audit.Mutations = len(audit.MutationPaths)
102 audit.ClassifyEvidenceOrigin(delegationText, summary.EvidencePaths())
103 _, sink, _, _ := CallContext(ctx)
104 event.RecordDelegationAudit(sink, audit)
105 }
106
107 // formatCompletionReport labels model claims separately from execution facts.
108 func formatCompletionReport(report evidence.CompletionReport) string {
109 var b strings.Builder
110 b.WriteString("Model-reported status: ")
111 b.WriteString(string(report.Status))
112 b.WriteString("\nsummary: ")
113 b.WriteString(report.Summary)
114 for _, c := range report.Criteria {
115 b.WriteString("\n " + c.ID + " " + string(c.Status))
116 if proof := criterionProof(c); proof != "" {
117 b.WriteString(" — " + proof)
118 }
119 }
120 for _, u := range report.Unresolved {
121 b.WriteString("\nunresolved: " + u)
122 }
123 return b.String()
124 }
125
126 func criterionProof(c evidence.AcceptanceCriterion) string {
127 var parts []string
128 for _, e := range c.Evidence {
129 switch {
130 case strings.TrimSpace(e.Command) != "":
131 parts = append(parts, e.Command)
132 case len(e.Paths) > 0:
133 parts = append(parts, strings.Join(e.Paths, " "))
134 case strings.TrimSpace(e.Summary) != "":
135 parts = append(parts, e.Kind+": "+e.Summary)
136 }
137 }
138 return joinBoundedReceipts(parts)
139 }
140
141 // appendHostReceipts attaches the host's own attestation to a child's answer.
142 // A child cannot write, suppress, or contradict these lines. An empty block is
143 // omitted entirely, so read-only research children stay exactly as cheap as
144 // they were before.
145 func appendHostReceipts(answer string, summary evidence.ChildEvidenceSummary, claims WritePathSet) string {
146 block := formatHostReceipts(summary, claims)
147 if block == "" {
148 return answer
149 }
150 if strings.TrimSpace(answer) == "" {
151 return block
152 }
153 return answer + "\n\n" + block
154 }
155
156 // claimViolations returns the mutations the host observed outside the write
157 // claim the child declared. Tool-level confinement already refuses these, so a
158 // non-empty result means a write reached the workspace through a surface the
159 // claim could not bind — the parent must not treat the run as scoped.
160 func claimViolations(summary evidence.ChildEvidenceSummary, claims WritePathSet) []string {
161 if claims.Empty() {
162 return nil
163 }
164 var out []string
165 for _, p := range summary.MutationPaths() {
166 if !claims.AllowsPath(p) {
167 out = append(out, p)
168 }
169 }
170 return out
171 }
172
173 // formatHostReceipts renders only what the host is willing to attest to: files
174 // the child really changed, and commands whose outcome the host observed.
175 // Ordinary reads and greps are excluded on purpose — they are not claims a
176 // parent has to adjudicate, and every rendered line costs parent context.
177 func formatHostReceipts(summary evidence.ChildEvidenceSummary, claims WritePathSet) string {
178 changed := receiptDisplayPaths(summary.MutationPaths())
179 commands := hostReceiptCommands(summary)
180 violations := receiptDisplayPaths(claimViolations(summary, claims))
181 if len(changed) == 0 && len(commands) == 0 {
182 return ""
183 }
184 var b strings.Builder
185 b.WriteString(hostReceiptsHeader)
186 if len(changed) > 0 {
187 b.WriteString("\n changed: ")
188 b.WriteString(joinBoundedReceipts(changed))
189 }
190 if len(commands) > 0 {
191 b.WriteString("\n commands: ")
192 b.WriteString(joinBoundedReceipts(commands))
193 }
194 if len(violations) > 0 {
195 b.WriteString("\n " + hostReceiptsViolationLabel + ": ")
196 b.WriteString(joinBoundedReceipts(violations))
197 }
198 return b.String()
199 }
200
201 func receiptDisplayPaths(paths []string) []string {
202 out := make([]string, len(paths))
203 for i, value := range paths {
204 out[i] = strings.ReplaceAll(value, `\`, "/")
205 }
206 return out
207 }
208
209 // hostReceiptCommands keeps only shell receipts carrying an outcome worth
210 // attesting: a host-classified verification, or a command that did not succeed.
211 func hostReceiptCommands(summary evidence.ChildEvidenceSummary) []string {
212 var out []string
213 seen := map[string]bool{}
214 for _, r := range summary.Receipts {
215 cmd := strings.TrimSpace(r.Command)
216 outcome := hostReceiptOutcome(r)
217 if cmd == "" || outcome == "" || seen[cmd] {
218 continue
219 }
220 seen[cmd] = true
221 out = append(out, cmd+outcome)
222 }
223 return out
224 }
225
226 func hostReceiptOutcome(r evidence.Receipt) string {
227 var parts []string
228 switch r.Verification {
229 case evidence.VerificationPassed:
230 parts = append(parts, "verification passed")
231 case evidence.VerificationFailed:
232 parts = append(parts, "verification failed")
233 }
234 switch {
235 case r.ExitCode != nil && (*r.ExitCode != 0 || len(parts) > 0):
236 parts = append(parts, fmt.Sprintf("exit %d", *r.ExitCode))
237 case r.ExitCode == nil && !r.Success:
238 parts = append(parts, "did not complete")
239 }
240 if len(parts) == 0 {
241 return ""
242 }
243 return " (" + strings.Join(parts, ", ") + ")"
244 }
245
246 func joinBoundedReceipts(items []string) string {
247 if len(items) <= hostReceiptsMaxItems {
248 return strings.Join(items, ", ")
249 }
250 return strings.Join(items[:hostReceiptsMaxItems], ", ") +
251 fmt.Sprintf(" (+%d more)", len(items)-hostReceiptsMaxItems)
252 }
253
253 lines GO