-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathtracker_capture.go
More file actions
294 lines (277 loc) · 12.1 KB
/
Copy pathtracker_capture.go
File metadata and controls
294 lines (277 loc) · 12.1 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
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
// ABOUTME: Config.Capture seam — the library-owned wiring that makes tracker.Run produce the same on-disk run capture (run.json + spec artifacts + identity-bearing activity.jsonl) the CLI does.
// ABOUTME: Wires one JSONLEventHandler across EventHandler/AgentEvents/LLMTrace with the SessionOwned de-dup correct, so embedders never hit the double-log trap or hand-compose the three seams.
package tracker
import (
"os"
"path"
"path/filepath"
"github.com/2389-research/dippin-lang/ir"
"github.com/2389-research/dippin-lang/parser"
"github.com/2389-research/tracker/agent"
"github.com/2389-research/tracker/internal/diag"
"github.com/2389-research/tracker/llm"
"github.com/2389-research/tracker/pipeline"
)
// CaptureConfig turns on run capture for a library run. Set Config.Capture to a
// (possibly empty) value and the library produces the same artifacts the CLI
// does — run.json, the spec (source + expanded IR + redacted params), and an
// identity-bearing activity.jsonl — without the caller wiring a
// JSONLEventHandler across EventHandler, AgentEvents, and LLMTrace, and without
// tripping the SessionOwned double-log trap that hand-wiring invites.
//
// The zero value is enough: on the Run / NewEngineWithContext path the library
// fills Source and Workflow from the source it parses. The raw
// EventHandler/AgentEvents/LLMTrace seams keep working alongside capture — the
// capture handler is combined with whatever the caller also set.
type CaptureConfig struct {
// Source is the .dip verbatim, stored as a spec artifact (workflow.dip).
// Left empty on the Run/NewEngineWithContext path, the library fills it with
// the source string it parsed.
Source string
// SourcePath is where Source was read from, recorded for display.
SourcePath string
// Workflow is the expanded IR, stored as workflow.ir.json. dippin expands
// subgraphs at compile time, so the expanded IR — not the authored source
// alone — is what explains the run's events. Left nil on the
// Run/NewEngineWithContext path, the library fills it by parsing.
Workflow *ir.Workflow
// CaptureRawLLM stores raw provider streaming chunks in activity.jsonl. Off
// by default: per-token raw chunks are debugging payload, not run telemetry.
// The CLI wires this from --verbose.
CaptureRawLLM bool
// ForcedBundleMismatch, when set, makes capture write a
// bundle_mismatch_forced audit entry at run-start (before any engine event),
// recording that a resume proceeded against a different .dipx bundle than its
// checkpoint claimed. Nil for new runs and matched resumes.
ForcedBundleMismatch *ForcedBundleMismatch
}
// ForcedBundleMismatch records that a resume ran against a bundle whose
// content-addressed identity differs from the one stored in the checkpoint,
// allowed through by an explicit override. Both identities are preserved so a
// post-hoc auditor sees exactly what was overridden.
type ForcedBundleMismatch struct {
RunID string // resume run ID (needed to open the correct activity log)
OriginalIdentity string // identity stored in the checkpoint ("sha256:<hex>" or "")
CurrentIdentity string // identity of the bundle actually executed ("sha256:<hex>" or "")
}
// captureState is the library's live capture wiring for one run: the handler
// that writes activity.jsonl, the artifact base it snapshots to, and the spec
// to finalize once the run ID is known.
type captureState struct {
handler *pipeline.JSONLEventHandler
artifactBase string
spec pipeline.SpecArtifacts
// costLimited records whether ANY --max-cost ceiling was configured, so
// finalizeCapture can warn when the run consumed unpriced usage the ceiling
// could not bound (#518). It is computed from the *resolved* budget
// (ResolveBudgetLimits), so it covers both an explicit CLI --max-cost /
// library Config.Budget value and a ceiling that comes only from the .dip
// `defaults:` block — the latter is a documented, supported way to set the
// ceiling, and #518 exists precisely to make its silent bypass visible.
costLimited bool
}
// setupCapture wires the capture handler into cfg when Config.Capture is set,
// returning the live state for finalize/close. It mutates cfg: it defaults an
// empty ArtifactDir (so both the engine and the capture handler write under the
// same base, matching the CLI's <workdir>/.tracker/runs) and combines the
// capture handler with any EventHandler/AgentEvents/LLMTrace the caller already
// set. Returns nil (a no-op) when capture is off.
//
// This is the single wiring path shared by the library and the CLI, so the two
// cannot drift: the CLI sets Config.Capture and only its presentation handlers,
// and this function adds the JSONL/spec/finalize surface for both.
//
// graph is read only to resolve the effective --max-cost ceiling (via
// ResolveBudgetLimits) for the captureState.costLimited flag, so a ceiling that
// comes only from the .dip `defaults:` block still arms the #518 unpriced
// warning.
func setupCapture(cfg *Config, workDir string, graph *pipeline.Graph) *captureState {
if cfg.Capture == nil {
return nil
}
if cfg.ArtifactDir == "" {
cfg.ArtifactDir = filepath.Join(workDir, ".tracker", "runs")
}
h := pipeline.NewJSONLEventHandler(cfg.ArtifactDir)
h.SetCaptureRawLLM(cfg.Capture.CaptureRawLLM)
h.SetBundleIdentity(cfg.BundleIdentity)
if fm := cfg.Capture.ForcedBundleMismatch; fm != nil {
// Written before the engine fires, so the audit trail carries the
// override signal even though the engine event chain has not started.
h.WriteBundleMismatchForced(fm.RunID, fm.OriginalIdentity, fm.CurrentIdentity)
}
cfg.EventHandler = combineCapturePipeline(cfg.EventHandler, h)
cfg.AgentEvents = combineCaptureAgent(cfg.AgentEvents, h)
cfg.LLMTrace = combineCaptureTrace(cfg.LLMTrace, h)
return &captureState{
handler: h,
artifactBase: cfg.ArtifactDir,
costLimited: ResolveBudgetLimits(cfg.Budget, graph).MaxCostCents > 0,
spec: pipeline.SpecArtifacts{
SourcePath: cfg.Capture.SourcePath,
Source: cfg.Capture.Source,
Workflow: cfg.Capture.Workflow,
Params: cfg.Params,
BundleIdentity: cfg.BundleIdentity,
},
}
}
// combineCapturePipeline fans the pipeline-event stream to the caller's handler
// (if any) and the capture handler.
func combineCapturePipeline(existing pipeline.PipelineEventHandler, capture pipeline.PipelineEventHandler) pipeline.PipelineEventHandler {
if existing == nil {
return capture
}
return pipeline.PipelineMultiHandler(existing, capture)
}
// combineCaptureAgent fans agent events to the caller's handler (if any) and
// the capture handler's WriteAgentEvent.
func combineCaptureAgent(existing agent.EventHandler, h *pipeline.JSONLEventHandler) agent.EventHandler {
if existing == nil {
return agent.EventHandlerFunc(h.WriteAgentEvent)
}
return agent.EventHandlerFunc(func(evt agent.Event) {
existing.HandleEvent(evt)
h.WriteAgentEvent(evt)
})
}
// combineCaptureTrace fans the raw LLM trace to the caller's observer (if any)
// and the capture handler's observer. The capture side goes through
// LLMTraceObserver, which drops SessionOwned events so a session's calls are
// not logged twice (once as agent llm_* events, once here).
func combineCaptureTrace(existing llm.TraceObserver, h *pipeline.JSONLEventHandler) llm.TraceObserver {
obs := h.LLMTraceObserver()
if existing == nil {
return obs
}
return llm.TraceObserverFunc(func(evt llm.TraceEvent) {
existing.HandleTraceEvent(evt)
obs(evt)
})
}
// fillCaptureFromSource returns a CaptureConfig with Source and Workflow filled
// from the parsed source when the caller left them empty. Used on the
// Run/NewEngineWithContext path, where the library holds the source string, so
// an embedder gets the same spec artifacts the CLI records at load time. DOT
// sources have no dippin IR, so they are left as-is. Parse failures leave
// Workflow nil — capture is best-effort telemetry and the run is validated
// separately.
func fillCaptureFromSource(cc *CaptureConfig, source, format string, ref SourceRef) *CaptureConfig {
if format == "" {
format = detectSourceFormat(source)
}
if format != "dip" {
return cc
}
out := *cc
if out.Source == "" {
out.Source = source
}
if out.Workflow == nil {
out.Workflow = parseWorkflowForCapture(source, ref)
}
return &out
}
// parseWorkflowForCapture parses source into expanded IR for spec capture,
// mirroring loadDippinPipeline in the CLI. Returns nil on any parse or
// directive-resolution failure — best-effort telemetry never blocks a run.
func parseWorkflowForCapture(source string, ref SourceRef) *ir.Workflow {
filename, embedFile := captureAnchor(source, ref)
workflow, perr := parser.NewParser(source, filename).Parse()
if perr != nil {
return nil
}
// Same routing as loadDIPSource: a built-in's sidecars live in the embed
// FS, anything else resolves from disk next to the file (or cwd).
var rerr error
if embedFile != "" {
rerr = pipeline.ResolveFileDirectivesFS(workflow, embeddedWorkflows, path.Dir(embedFile))
} else {
rerr = parser.ResolveFileDirectives(workflow, filepath.Dir(filename))
}
if rerr != nil {
return nil
}
return workflow
}
// captureAnchor mirrors loadDIPSource's SourceRef resolution for the capture
// parse: it returns the parser filename and, for a built-in, its embed-FS path.
func captureAnchor(source string, ref SourceRef) (filename, embedFile string) {
switch {
case ref.Path != "":
return ref.Path, ""
case ref.Builtin != "":
if info, ok := LookupWorkflow(ref.Builtin); ok {
return info.File, info.File
}
return inlineSourceName, ""
}
if info, ok := embeddedWorkflowForSource(source); ok {
return info.File, info.File
}
return inlineSourceName, ""
}
// finalizeCapture writes the spec artifacts and run.json for a finished run,
// mirroring the CLI's finalizeRunCapture. Called from Engine.Run once the run
// ID exists (the first point it does) and while the capture handler is still
// open, so the manifest reads a fully written activity log. Failures warn and
// continue: losing telemetry is not worth failing a run that already did its
// work, and a missing manifest can be rebuilt later from the run dir.
func (e *Engine) finalizeCapture(runID string) {
if e.capture == nil || runID == "" {
return
}
runDir := filepath.Join(e.capture.artifactBase, runID)
if _, err := os.Stat(runDir); err != nil {
// No run directory means the run never wrote artifacts (a pre-execution
// failure). Nothing to describe.
return
}
spec := e.capture.spec
if spec.Source != "" || spec.Workflow != nil {
if _, err := pipeline.WriteSpecArtifacts(runDir, spec); err != nil {
diag.Warnf("warning: spec capture failed: %v", err)
}
}
m, err := pipeline.WriteRunManifest(runDir, runID)
if err != nil {
diag.Warnf("warning: run manifest failed: %v", err)
return
}
// Surface unpriced usage the --max-cost ceiling could not bound (#518). A
// warning, never a halt: a genuinely-free local model legitimately costs $0.
pipeline.WarnUnpricedBudget(m.Totals, e.capture.costLimited)
}
// closeCapture closes the capture handler, flushing the sentinel-stripped
// activity.jsonl snapshot to the run dir. No-op when capture is off.
func (e *Engine) closeCapture() {
if e.capture == nil || e.capture.handler == nil {
return
}
_ = e.capture.handler.Close()
}
// attachClientObservers wires the token tracker, and any Config.LLMTrace
// observer, onto whichever *llm.Client backs this run (the auto-created client
// or a *llm.Client supplied via Config.LLMClient). A non-*llm.Client completer
// carries no observable transport, so it is a no-op there.
func attachClientObservers(client *llm.Client, completer agent.Completer, tokenTracker *llm.TokenTracker, cfg Config) {
c := client
if c == nil {
if lc, ok := completer.(*llm.Client); ok {
c = lc
}
}
if c == nil {
return
}
// Guard the double-count foot-gun: if the caller supplied a *llm.Client that
// already has this exact tracker attached (and passed the same tracker as
// Config.TokenTracker), adding it again would count every token twice. Skip
// the re-add when it is already present.
if !c.HasMiddleware(tokenTracker) {
c.AddMiddleware(tokenTracker)
}
if cfg.LLMTrace != nil {
c.AddTraceObserver(cfg.LLMTrace)
}
}