Repository navigation
Expand file tree
/
Copy pathcapture.go
More file actions
222 lines (212 loc) · 7.91 KB
/
Copy pathcapture.go
File metadata and controls
222 lines (212 loc) · 7.91 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
package agent
import (
"context"
"strings"
"time"
"unicode/utf8"
"pta/db"
"pta/intercept"
"pta/sidequestion"
"normasdk/agentcore"
"normasdk/harness"
"normasdk/llm"
)
// captureRun drives one agent turn-to-completion over Session.Prompt and emits a
// coalesced ActivityRecord per execution step (tool_use / tool_result / text /
// thinking / result). It is shared by every LLM agent in the system (worker,
// planner, …) so their execution is visible instead of a black box — the old
// agentcore.Run discarded every event. The emitted records carry only
// Kind/Tool/ToolUseID/IsError/Summary/Detail; the caller's emit fills in
// IntentID/Worker. Returns the final assistant text + terminal error.
//
// KindText/KindThinking arrive as streaming deltas (one event per fragment); a
// contiguous run is coalesced into a single record so the trace shows whole
// messages, not dozens of fragments.
func captureRun(ctx context.Context, opts agentcore.Options, input string, emit func(db.Activity)) (string, harness.TerminalReason, error) {
s := agentcore.NewSession(opts)
defer s.Close() // release the session's background-task manager (temp dir + processes)
return captureRunSession(ctx, s, input, emit)
}
// captureRunSession is captureRun over an existing session, so a caller can run
// multiple prompts on the SAME conversation (e.g. a settlement round that reuses
// the worker's accumulated context after the main run hit max_turns).
func captureRunSession(ctx context.Context, s *agentcore.Session, input string, emit func(db.Activity)) (string, harness.TerminalReason, error) {
ctx, auditTrace := intercept.WithTrace(ctx, input, approvalHistory(s.Messages()))
defer auditTrace.Finish()
var reason harness.TerminalReason
rec := func(r db.Activity) {
if r.Kind == "text" || r.Kind == "tool_result" {
auditTrace.Append(db.InterceptContextEntry{Kind: r.Kind, Tool: r.Tool, ToolUseID: r.ToolUseID, Text: r.Detail, IsError: r.IsError})
}
if emit != nil {
emit(r)
}
}
toolNames := map[string]string{} // tool_use id -> name, to label results
var tbuf strings.Builder
var tkind string
flush := func() {
if tbuf.Len() == 0 {
return
}
s := strings.TrimSpace(tbuf.String())
k := tkind
tbuf.Reset()
tkind = ""
if s != "" {
rec(db.Activity{Kind: k, Summary: firstLine(s, 200), Detail: s})
}
}
addDelta := func(kind, text string) {
if text == "" {
return
}
if tkind != "" && tkind != kind {
flush()
}
tkind = kind
tbuf.WriteString(text)
}
lastTool := &runTrace{startedAt: time.Now()}
var lastUsage *llm.Usage
var finalText string
var rerr error
for ev, err := range s.Prompt(ctx, input) {
if err != nil {
flush()
if ctx.Err() != nil { // engine/user cancellation, not a provider failure
sum, detail := terminalText(ctx, &harness.Terminal{Reason: reason, Err: ctx.Err()}, lastTool)
rec(activityWithUsage(db.Activity{Kind: "result", Summary: firstLine(sum, 400), Detail: detail}, lastUsage))
return finalText, reason, ctx.Err()
}
rec(activityWithUsage(db.Activity{Kind: "result", IsError: true, Summary: "Execution error: " + err.Error(), Detail: err.Error()}, lastUsage))
return finalText, reason, err
}
switch ev.Kind {
case harness.KindToolUse:
if ev.ToolUse == nil {
continue
}
flush()
toolNames[ev.ToolUse.ID] = ev.ToolUse.Name
in := string(ev.ToolUse.Input)
lastTool.start(ev.ToolUse.ID, ev.ToolUse.Name, in)
auditTrace.Start(ev.ToolUse.ID, ev.ToolUse.Name, ev.ToolUse.Input)
rec(db.Activity{Kind: "tool_use", Tool: ev.ToolUse.Name, ToolUseID: ev.ToolUse.ID,
Summary: ev.ToolUse.Name + " " + firstLine(in, 200), Detail: in})
case harness.KindToolResult:
if ev.ToolResult == nil {
continue
}
flush()
out := blocksText(ev.ToolResult.Content)
lastTool.done(ev.ToolResult.ToolUseID)
auditTrace.Complete(ev.ToolResult.ToolUseID, out, ev.ToolResult.IsError)
rec(db.Activity{Kind: "tool_result", Tool: toolNames[ev.ToolResult.ToolUseID], ToolUseID: ev.ToolResult.ToolUseID,
IsError: ev.ToolResult.IsError, Summary: firstLine(out, 200), Detail: out})
case harness.KindText:
addDelta("text", ev.Text)
case harness.KindThinking:
addDelta("thinking", ev.Text)
case harness.KindUsage:
// live cumulative token usage (per model turn). Emitted as a non-rendered
// "usage" activity carrying only the token fields; the UI uses the latest
// one for a running session's live token count. Don't flush() here — the
// buffered final-answer text must stay for the KindResult de-dup.
if ev.Usage != nil {
u := *ev.Usage
lastUsage = &u
rec(db.Activity{Kind: "usage",
InputTokens: &u.InputTokens, OutputTokens: &u.OutputTokens,
CacheReadTokens: &u.CacheReadTokens, CacheWriteTokens: &u.CacheWriteTokens})
}
case harness.KindResult:
if ev.Terminal != nil {
if ev.Terminal.Reason != harness.ReasonAbortedStreaming {
sidequestion.Finish(ctx, ev.Terminal.Messages)
}
finalText = ev.Terminal.Text
reason = ev.Terminal.Reason
// the buffered tail text usually equals Terminal.Text (final answer);
// drop it to avoid a duplicate record, the result row carries it.
if tkind == "text" && strings.TrimSpace(tbuf.String()) == strings.TrimSpace(ev.Terminal.Text) {
tbuf.Reset()
tkind = ""
}
flush() // flush any trailing thinking / non-final text
sum, detail := ev.Terminal.Text, ev.Terminal.Text
if sum == "" || ev.Terminal.Reason == harness.ReasonAbortedTools || ev.Terminal.Reason == harness.ReasonAbortedStreaming {
sum, detail = terminalText(ctx, ev.Terminal, lastTool)
}
u := ev.Terminal.Usage // cumulative token usage for this session
rec(db.Activity{Kind: "result", IsError: ev.Terminal.Err != nil,
Summary: firstLine(sum, 400), Detail: detail,
InputTokens: &u.InputTokens, OutputTokens: &u.OutputTokens,
CacheReadTokens: &u.CacheReadTokens, CacheWriteTokens: &u.CacheWriteTokens})
if ev.Terminal.Err != nil {
rerr = ev.Terminal.Err
}
}
}
}
flush() // safety: any unflushed text if the stream ended without KindResult
return finalText, reason, rerr
}
func activityWithUsage(activity db.Activity, usage *llm.Usage) db.Activity {
if usage == nil {
return activity
}
u := *usage
activity.InputTokens = &u.InputTokens
activity.OutputTokens = &u.OutputTokens
activity.CacheReadTokens = &u.CacheReadTokens
activity.CacheWriteTokens = &u.CacheWriteTokens
return activity
}
// blocksText concatenates the text of a tool-result's content blocks.
func blocksText(blocks []llm.ContentBlock) string {
var b strings.Builder
for _, bl := range blocks {
if bl.Type == llm.BlockText && bl.Text != "" {
if b.Len() > 0 {
b.WriteByte('\n')
}
b.WriteString(bl.Text)
}
}
return b.String()
}
// firstLine returns a single-line, rune-capped preview for the summary column.
func firstLine(s string, max int) string {
s = strings.TrimSpace(s)
if before, _, found := strings.Cut(s, "\n"); found {
s = before
}
if utf8.RuneCountInString(s) > max {
s = string([]rune(s)[:max]) + "…"
}
return s
}
// Preserve the recorded session's visible messages, excluding thinking blocks.
// This is audit context; the judge receives only bounded, paired execution
// evidence selected from it, never assistant prose or thinking blocks.
func approvalHistory(messages []llm.Message) []db.InterceptContextEntry {
var entries []db.InterceptContextEntry
for _, message := range messages {
for _, block := range message.Content {
entry := db.InterceptContextEntry{Kind: string(message.Role)}
switch block.Type {
case llm.BlockText:
entry.Text = block.Text
case llm.BlockToolUse:
entry.Kind, entry.Tool, entry.ToolUseID, entry.Text = "tool_use", block.Name, block.ID, string(block.Input)
case llm.BlockToolResult:
entry.Kind, entry.ToolUseID, entry.Text, entry.IsError = "tool_result", block.ToolUseID, blocksText(block.Content), block.IsError
default:
continue
}
entries = append(entries, entry)
}
}
return entries
}