Files
BaaRai 4cde1a889f Budgets: revive clears the settle state, document config, allow update_finding_report in wrap-up
- resetSettledRun clears settling and the budget ledger; a done/failed task
  revived to running now plans and claims again (was gated by the old wrap-up).
  resetTimeoutRevival calls it. Test: TestResetSettledRunRestartsBudget.
- docker-compose.yml passes the four ARTEX_*_BUDGET_* variables through.
- .env.example and README document the variables, the wrap-up, the in-memory
  ledger and the practical floor.
- llmrec: the usage sink runs even without a PostgreSQL recorder; comment fixed.
- BudgetWrapupBlocks allows update_finding_report.
2026-11-12 18:48:41 +01:00

406 lines
13 KiB
Go

// Package llmrec implements a recording decorator for llm.Provider. It intercepts
// every Stream call, captures the full request and accumulated response, and
// persists them to PostgreSQL for later inspection.
package llmrec
import (
"context"
"encoding/json"
"iter"
"log"
"strings"
"time"
"github.com/Autumn-27/artex/db"
"github.com/Autumn-27/norma/llm"
"github.com/Autumn-27/norma/transcript"
)
type taskIDContextKey struct{}
type workerContextKey struct{}
// WithTaskID attaches the owning task registry id to an LLM call. Session ids
// are based on exploration ids, which are not interchangeable with task ids.
func WithTaskID(ctx context.Context, taskID string) context.Context {
taskID = strings.TrimSpace(taskID)
if taskID == "" {
return ctx
}
return context.WithValue(ctx, taskIDContextKey{}, taskID)
}
// TaskIDFrom returns the explicit task registry id attached by the task runtime.
func TaskIDFrom(ctx context.Context) string {
if ctx == nil {
return ""
}
taskID, _ := ctx.Value(taskIDContextKey{}).(string)
return strings.TrimSpace(taskID)
}
// WithWorker overrides the agent-lane ("worker") label for an LLM call. The lane
// is normally parsed from the transcript session id (exp<N>-<role>); calls made
// outside the engine's worker/planner sessions — e.g. the intercept fallback judge
// — carry no such session, so they attach their lane explicitly here. This lets the
// usage ledger single out that spend (worker='judge') for the config page.
func WithWorker(ctx context.Context, worker string) context.Context {
worker = strings.TrimSpace(worker)
if worker == "" {
return ctx
}
return context.WithValue(ctx, workerContextKey{}, worker)
}
// workerFrom returns the explicit lane override, or "" when none is set.
func workerFrom(ctx context.Context) string {
if ctx == nil {
return ""
}
worker, _ := ctx.Value(workerContextKey{}).(string)
return strings.TrimSpace(worker)
}
type usageSinkContextKey struct{}
// WithUsageSink attaches a callback that receives the usage of every metered
// call made under ctx — the same value, at the same point, as the llm_usage row.
// The task runtime uses it to meter run budgets.
func WithUsageSink(ctx context.Context, sink func(llm.Usage)) context.Context {
return context.WithValue(ctx, usageSinkContextKey{}, sink)
}
// Recorder wraps an llm.Provider and records every completion call.
type Recorder struct {
inner llm.Provider
pg *db.DB
model string // model name (from config, not in CompletionRequest)
prof string // LLM profile name (from llm_profiles)
// thinkingType / reasoningEffort are the config-level thinking params (thinking
// toggle / thinking effort). norma's buildBody() injects them from the provider
// config into the actual HTTP body; they never appear on CompletionRequest, so
// the Recorder carries its own copy here and writes it into the recording.
thinkingType string
reasoningEffort string
enabled func() bool // reports whether recording is currently on; nil = always record
}
// Wrap returns a Provider that records calls to pg when enabled() reports true.
// profName is the LLM profile name (e.g. "default"); may be empty. thinkingType /
// reasoningEffort are the config-level thinking params actually sent to the API
// (empty = not sent). A nil enabled predicate records unconditionally.
func Wrap(inner llm.Provider, pg *db.DB, model, profName, thinkingType, reasoningEffort string, enabled func() bool) *Recorder {
return &Recorder{
inner: inner, pg: pg, model: model, prof: profName,
thinkingType: thinkingType, reasoningEffort: reasoningEffort, enabled: enabled,
}
}
// parseSession extracts task id and worker role from session strings like
// "exp1-worker-i3" → ("1", "worker") or "exp2-planner" → ("2", "planner").
func parseSession(s string) (taskID, worker string) {
// format: exp<N>-<role>[-suffix]
if !strings.HasPrefix(s, "exp") {
return "", ""
}
rest := s[3:] // after "exp"
// split task number
i := strings.IndexByte(rest, '-')
if i < 0 {
return rest, ""
}
taskID = rest[:i]
rest = rest[i+1:]
// worker role is up to the next '-' (e.g. "worker" from "worker-i3")
if j := strings.IndexByte(rest, '-'); j >= 0 {
worker = rest[:j]
} else {
worker = rest
}
return taskID, worker
}
// Stream implements llm.Provider. It delegates to the inner provider, accumulates
// the streamed events to reconstruct the response, and records the full exchange.
//
// The session identifier (e.g. "exp1-worker-i3") is carried on ctx by the norma
// harness via transcript.WithSessionID; reading it per-call is race-free even when
// planner and multiple workers share one Recorder instance.
func (r *Recorder) Stream(ctx context.Context, req llm.CompletionRequest) iter.Seq2[llm.StreamEvent, error] {
// Body recording (the heavy debug trace: full request/response) is gated by the
// llm_record setting. Lightweight usage metering always runs — it powers token
// stats and must be complete even for interrupted/failed runs, so it is NOT
// gated. Only body serialization + response accumulation are skipped when off.
recordBodies := r.enabled == nil || r.enabled()
start := time.Now()
session := transcript.SessionIDFrom(ctx)
parsedID, worker := parseSession(session)
if ov := workerFrom(ctx); ov != "" {
worker = ov
}
expID := db.ParseExpID(parsedID)
taskID := TaskIDFrom(ctx)
if taskID == "" {
// Backward compatibility for non-task callers. For task calls, the task
// runtime always supplies the registry id explicitly.
taskID = parsedID
}
// Serialize the request only when storing bodies (this is the expensive part).
// Alongside it, attach a Capture so the HTTP transport can hand back the
// untouched wire bodies — the normalized view below cannot reconstruct them
// (tool schemas are dropped, tool_use blocks never reach this layer, and the
// SSE framing is already decoded). See capture.go.
reqBody := ""
var capt *Capture
if recordBodies {
reqBody = r.serializeRequest(req)
ctx, capt = NewCapture(ctx)
}
return func(yield func(llm.StreamEvent, error) bool) {
var (
textBuf strings.Builder
thinkingBuf strings.Builder
usage llm.Usage
stopReason string
streamErr error
)
finished := false
finish := func(err error) {
if finished {
return
}
finished = true
status := "ok"
if err != nil {
status = "error"
}
// Lightweight metering row — always written.
r.recordUsage(ctx, taskID, expID, worker, usage, int(time.Since(start).Milliseconds()), status)
// Heavy trace row — only when body recording is on.
if recordBodies {
r.record(req, session, taskID, worker, reqBody, capt, start, textBuf.String(), thinkingBuf.String(), usage, stopReason, err)
}
}
defer func() { finish(ctx.Err()) }()
for ev, err := range r.inner.Stream(ctx, req) {
if err != nil {
streamErr = err
finish(streamErr)
if !yield(ev, err) {
return
}
return
}
// Always track usage (cheap); accumulate text/thinking only for bodies.
// Anthropic (and the other providers) split token usage across events:
// message_start carries ONLY the input side (input + cache), message_delta
// ONLY the output. They must be FOLDED with Add — overwriting on delta
// would zero out the input already counted at start (mirrors the SDK's own
// llm.Accumulator; see norma/llm/accumulate.go).
switch ev.Type {
case llm.SETextDelta:
if recordBodies {
textBuf.WriteString(ev.Text)
}
case llm.SEThinkingDelta:
if recordBodies {
thinkingBuf.WriteString(ev.Text)
}
case llm.SEMessageStart:
usage.Add(ev.Usage)
case llm.SEMessageDelta:
if ev.StopReason != "" {
stopReason = ev.StopReason
}
usage.Add(ev.Usage)
}
if !yield(ev, err) {
return
}
}
// Stream completed normally.
finish(streamErr)
}
}
// Complete implements the atomic completion path while preserving the same
// usage metering, normalized body recording and raw wire capture as Stream.
func (r *Recorder) Complete(ctx context.Context, req llm.CompletionRequest) (llm.Message, string, llm.Usage, error) {
recordBodies := r.enabled == nil || r.enabled()
start := time.Now()
session := transcript.SessionIDFrom(ctx)
parsedID, worker := parseSession(session)
if ov := workerFrom(ctx); ov != "" {
worker = ov
}
expID := db.ParseExpID(parsedID)
taskID := TaskIDFrom(ctx)
if taskID == "" {
taskID = parsedID
}
reqBody := ""
var capt *Capture
if recordBodies {
reqBody = r.serializeRequest(req)
ctx, capt = NewCapture(ctx)
}
msg, stopReason, usage, err := r.inner.Complete(ctx, req)
status := "ok"
if err != nil {
status = "error"
}
r.recordUsage(ctx, taskID, expID, worker, usage, int(time.Since(start).Milliseconds()), status)
if recordBodies {
r.record(req, session, taskID, worker, reqBody, capt, start, msg.Text(), thinkingText(msg), usage, stopReason, err)
}
return msg, stopReason, usage, err
}
func thinkingText(msg llm.Message) string {
var b strings.Builder
for _, block := range msg.Content {
if block.Type == llm.BlockThinking && block.Thinking != "" {
b.WriteString(block.Thinking)
}
}
return b.String()
}
// recordUsage appends one lightweight metering row to llm_usage (no bodies). Skips
// zero-token calls with no model, which carry nothing worth metering. The ctx
// usage sink (if any) sees these calls even when no PostgreSQL recorder is set
// (r.pg == nil), so a budget also counts calls that are not persisted.
func (r *Recorder) recordUsage(ctx context.Context, taskID string, expID int64, worker string, usage llm.Usage, latencyMs int, status string) {
if r.model == "" && usage.InputTokens == 0 && usage.OutputTokens == 0 &&
usage.CacheReadTokens == 0 && usage.CacheWriteTokens == 0 {
return
}
if sink, _ := ctx.Value(usageSinkContextKey{}).(func(llm.Usage)); sink != nil {
sink(usage)
}
if r.pg == nil {
return
}
err := r.pg.InsertLLMUsage(&db.LLMUsage{
TaskID: taskID,
ExplorationID: expID,
Worker: worker,
Model: r.model,
ProfileName: r.prof,
LatencyMs: latencyMs,
InputTokens: usage.InputTokens,
OutputTokens: usage.OutputTokens,
CacheRead: usage.CacheReadTokens,
CacheWrite: usage.CacheWriteTokens,
Status: status,
})
if err != nil {
log.Printf("[llmusage] insert: %v", err)
}
}
// record persists one LLM call to PostgreSQL before the provider stream returns.
// Keeping the write inside the owning task operation means task deletion can
// drain calls and then remove records without a late async insert recreating one.
func (r *Recorder) record(req llm.CompletionRequest, session, taskID, worker, reqBody string, capt *Capture, start time.Time, text, thinking string, usage llm.Usage, stopReason string, streamErr error) {
latency := int(time.Since(start).Milliseconds())
status := "ok"
errMsg := ""
if streamErr != nil {
status = "error"
errMsg = streamErr.Error()
}
// Build response body JSON.
resp := map[string]any{
"text": text,
"stop_reason": stopReason,
"usage": map[string]int{
"input_tokens": usage.InputTokens,
"output_tokens": usage.OutputTokens,
"cache_read_tokens": usage.CacheReadTokens,
"cache_write_tokens": usage.CacheWriteTokens,
},
}
if thinking != "" {
resp["thinking"] = thinking
}
respBody, _ := json.Marshal(resp)
model := ""
if len(req.System) > 0 {
// model is not in CompletionRequest; use the configured model name
}
model = r.model
rec := &db.LLMRecord{
SessionID: session,
TaskID: taskID,
Worker: worker,
Model: model,
ProfileName: r.prof,
LatencyMs: latency,
InputTokens: usage.InputTokens,
OutputTokens: usage.OutputTokens,
CacheRead: usage.CacheReadTokens,
CacheWrite: usage.CacheWriteTokens,
Status: status,
Error: errMsg,
RequestBody: reqBody,
ResponseBody: string(respBody),
// Raw wire bodies; empty when the transport did not fill the Capture
// (e.g. a provider dialing through a client without the capture hook, or
// a call that failed before any HTTP request went out).
RawRequest: capt.RawRequest(),
RawResponse: capt.RawResponse(),
}
if err := r.pg.InsertLLMRecord(rec); err != nil {
log.Printf("[llmrec] insert: %v", err)
}
}
// serializeRequest builds a JSON representation of the completion request.
func (r *Recorder) serializeRequest(req llm.CompletionRequest) string {
m := map[string]any{
"system": req.System,
"messages": req.Messages,
"max_tokens": req.MaxTokens,
}
// Record the thinking params actually sent on this call. type takes the effective
// value: the per-request override req.Thinking wins over the config-level
// thinkingType (matching norma buildBody's decision — e.g. compaction summaries
// force disabled); effort has no per-request override, so the config value is
// used directly. When both are empty, no thinking field is written.
effType := r.thinkingType
if req.Thinking != "" {
effType = req.Thinking
}
if effType != "" || r.reasoningEffort != "" {
m["thinking"] = map[string]string{"type": effType, "effort": r.reasoningEffort}
}
if len(req.Tools) > 0 {
// Store tool names only (full schemas are huge).
names := make([]string, len(req.Tools))
for i, t := range req.Tools {
names[i] = t.Name
}
m["tools"] = names
m["tools_count"] = len(req.Tools)
}
if req.Temperature != nil {
m["temperature"] = *req.Temperature
}
if len(req.Stop) > 0 {
m["stop"] = req.Stop
}
b, _ := json.Marshal(m)
return string(b)
}