Documentation
¶
Overview ¶
Package runtrace persists a case-scoped agent run as JobRunLog + JobRunEvent records so the case agent page can list it and show its per-call timeline.
It is the single home for the gollem trace.Handler that turns LLM / tool call boundaries into JobRunEvent rows, shared by the Job runner (pkg/usecase/job) and the mention hosts (pkg/usecase/agent/casebound and .../threadcase). The Job runner drives its own JobRunLog lifecycle (lease, suspend/resume, reflection) and uses only Handler + Sequencer here; the mention hosts, which have no such lifecycle, use Recorder for the whole open/close.
Index ¶
- func AddRunTotals(log *model.JobRunLog, h *Handler)
- func FinishRun(ctx context.Context, repo interfaces.Repository, key model.JobRunKey, ...)
- func Truncate(s string, max int) string
- type CallStats
- type Handler
- func (h *Handler) AddEvent(ctx context.Context, kind string, data any)
- func (h *Handler) CallStats() CallStats
- func (h *Handler) EmitRunError(ctx context.Context, stage, message string) error
- func (h *Handler) EndAgentExecute(ctx context.Context, err error)
- func (h *Handler) EndChildAgent(ctx context.Context, err error)
- func (h *Handler) EndLLMCall(ctx context.Context, data *trace.LLMCallData, err error)
- func (h *Handler) EndSubAgent(ctx context.Context, err error)
- func (h *Handler) EndToolExec(ctx context.Context, result map[string]any, err error)
- func (h *Handler) EnterReflectionPhase()
- func (h *Handler) Finish(ctx context.Context) error
- func (h *Handler) StartAgentExecute(ctx context.Context) context.Context
- func (h *Handler) StartChildAgent(ctx context.Context, name string) context.Context
- func (h *Handler) StartLLMCall(ctx context.Context) context.Context
- func (h *Handler) StartSubAgent(ctx context.Context, name string) context.Context
- func (h *Handler) StartToolExec(ctx context.Context, toolName string, args map[string]any) context.Context
- type OpenParams
- type Recorder
- type Routing
- type ToolCallStats
- type Usage
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func AddRunTotals ¶ added in v0.3.0
AddRunTotals folds the handler's counted tokens and call counts into the run log. Callers invoke it once per handler, immediately before persisting the log (Suspend / Finish), so a suspended turn's totals survive into the resumed turn's. Both arguments may be nil (the resume prepare-failure path has no handler), in which case the log is left untouched.
func FinishRun ¶ added in v0.3.0
func FinishRun( ctx context.Context, repo interfaces.Repository, key model.JobRunKey, runID string, usage Usage, execErr error, endedAt time.Time, )
FinishRun ends a run record from a process that does not hold its Recorder.
It is the durable counterpart of Recorder.Finish: it reloads the RUNNING log the run opened, folds in the supplied usage, and transitions it — execErr nil → SUCCESS, non-nil → FAILED. Reloading rather than carrying the Recorder is the whole point: an agent run spans many transitions and may end on another instance entirely.
Every persistence failure is non-fatal (errutil.Handle), for the same reason Recorder.Finish treats them that way: the run record is observability, and losing it must never fail the turn that produced it.
func Truncate ¶
Truncate returns s capped at max bytes, snapped back to a UTF-8 rune boundary so the result never contains a partial multi-byte character. Firestore rejects strings that are not valid UTF-8, and a blind byte-slice can leave a trailing fragment of a JP/CJK rune which would make the doc unwritable.
Types ¶
type CallStats ¶ added in v0.3.0
type CallStats struct {
LLMCalls int64
LLMDurationMs int64
ToolCalls int64
ToolDurationMs int64
// ToolByName splits ToolCalls / ToolDurationMs per tool. Nil when no tool
// ran. Keys are restricted to the tool names the model was offered, plus
// unregisteredToolKey for anything else.
ToolByName map[string]ToolCallStats
}
CallStats is the wall-clock time and call counts one Handler observed inside LLM and tool calls. The durations are the SUM of the per-call spans: planexec wires one Handler into sub-agents that run concurrently, so the sum can exceed the turn's own elapsed time. The counts mirror JobRunLog.LLMCallCount / ToolCallCount.
type Handler ¶
type Handler struct {
// contains filtered or unexported fields
}
Handler is a gollem trace.Handler that appends one JobRunEvent per LLM call (LLM_REQUEST + LLM_RESPONSE) and one per tool execution (TOOL_CALL). It is wired once per run via gollem.WithTrace(handler).
Sequence numbers are allocated by the repository inside each write, not by the Handler. That is what lets several Handlers — a resumed turn's, another instance's claim of the same durable run, and the run owner's own RUN_ERROR — append to one timeline without agreeing on a counter first.
func NewHandler ¶
func NewHandler( eventRepo interfaces.JobRunEventRepository, routing Routing, clock func() time.Time, ) *Handler
NewHandler constructs a Handler bound to the given run. clock defaults to time.Now().UTC() when nil.
func (*Handler) CallStats ¶ added in v0.3.0
CallStats returns what this handler has observed so far. Like runTotals it is a per-handler tally, not the run's lifetime total: a resumed run builds a fresh handler. The returned map is a copy, so the caller may keep it without sharing state with the handler. Safe on a nil receiver.
func (*Handler) EmitRunError ¶
EmitRunError appends a RUN_ERROR event. Called by the run's owner on lifecycle failures (prepare / execute / finish stages).
It orders correctly against the per-call events without sharing anything with them: the Sequence is allocated by the repository, so the owner and the handler cannot pick the same number even when they append at the same moment.
func (*Handler) EndAgentExecute ¶
EndAgentExecute is a no-op for the same reason.
func (*Handler) EndChildAgent ¶
EndChildAgent mirrors EndSubAgent.
func (*Handler) EndLLMCall ¶
EndLLMCall appends two events: LLM_REQUEST built from data.Request + data.Model, then LLM_RESPONSE built from data.Response + tokens + elapsed wall-clock. If err is non-nil the response event still captures whatever partial data was returned; the surrounding run failure is recorded separately via EmitRunError.
BOTH events are stamped with the call's COMPLETION time: OccurredAt is not the append time, but it is not the call's start either. The measured call latency lives in LLMResponsePayload.DurationMs — never derive it from the gap between the two events, which only reflects how long the two appends took. See the JobRunEvent.OccurredAt godoc.
func (*Handler) EndSubAgent ¶
EndSubAgent clears the AgentLabel back to empty.
func (*Handler) EndToolExec ¶
EndToolExec appends a single TOOL_CALL event with ParentSequence pointing at the LLM_RESPONSE that asked for the call. The event's OccurredAt is the execution's completion time; its measured duration is the StartedAt/EndedAt pair inside ToolCallPayload.
func (*Handler) EnterReflectionPhase ¶
func (h *Handler) EnterReflectionPhase()
EnterReflectionPhase relabels subsequent events as the reflection phase. The Job runner calls it once, after the executor returns and before invoking the reflector, so the reflection agent's LLM / tool events are attributed to "reflection". Safe because the run is single-threaded at that point.
func (*Handler) Finish ¶
Finish is a no-op: events have been appended per-call. The hook is kept for trace.Handler contract compatibility.
func (*Handler) StartAgentExecute ¶
StartAgentExecute is a no-op: run lifecycle is tracked by JobRunLog, not the per-event timeline.
func (*Handler) StartChildAgent ¶
StartChildAgent mirrors StartSubAgent for the child-agent variant.
func (*Handler) StartLLMCall ¶
StartLLMCall records the start time in a context-scoped span. No event is appended yet; we have no LLMCallData until End.
A call opened INSIDE a tool execution — a knowledge tool's embedding, webfetch's page analysis — inherits that tool's conversation, so its request is recorded as a diff against the tool's own message list rather than against the agent's. The link is made from the enclosing span rather than from a context value the caller could add, because the context a handler is handed at End* is the one it returned at Start* (trace.Multi records them per handler), so a value added by the runtime after the claim opened never arrives here.
func (*Handler) StartSubAgent ¶
StartSubAgent flags the handler so subsequent events carry the named AgentLabel, and scopes a conversation of its own to the returned context: a sub-agent's message list is not a continuation of its caller's.
On the durable runtime nothing reaches this hook — a sub-agent there is a separate Process, so it gets a separate claim and its own Handler, which already separates the two conversations. This covers a gollem-driven agent, where the same Handler serves both.
func (*Handler) StartToolExec ¶
func (h *Handler) StartToolExec(ctx context.Context, toolName string, args map[string]any) context.Context
StartToolExec caches the tool name + args + start timestamp on a context-scoped span, together with the LLM_RESPONSE the call answers. No event is appended yet; we need the result before we can emit a complete TOOL_CALL.
The parent is resolved HERE rather than at the end. A tool that reaches an LLM itself records an LLM_RESPONSE of its own while it runs, and the last response seen by then is that one — so an end-time lookup would point the tool call at an event nested inside it.
type OpenParams ¶
type OpenParams struct {
Repo interfaces.Repository
WorkspaceID string
CaseID int64
JobID string // fresh per-turn id for a mention run; a config id for a Job
RunID string
TraceID string
EventType string // provenance, e.g. model.EventTypeMention
ExecutorKind string // model.ExecutorKindSingleLoop / ExecutorKindPlanexec
SystemPrompt string
StartedAt time.Time
// Clock supplies wall-clock time for the per-event handler and the Close
// timestamp. nil → time.Now().UTC(). Tests inject a fixed clock.
Clock func() time.Time
}
OpenParams collects the inputs Open needs. The caller owns RunID / TraceID so it can align the TraceID with its own durable trace recorder (the Cloud Storage archive), keeping both trace sinks correlated.
type Recorder ¶
type Recorder struct {
// contains filtered or unexported fields
}
Recorder manages the JobRunLog lifecycle for a case-scoped agent run that does NOT go through the Job runner — a Slack mention handled by the casebound / threadcase hosts. It is the mention-side counterpart to the lifecycle the Job runner drives inline: Open creates the RUNNING JobRunLog and the per-event Handler, and Finish transitions the log to its terminal stage and materialises the JobRun summary doc (so ListByCase surfaces the run on the case agent page).
The parent JobRun doc is materialised at Finish (via RecordRun), not at Open: the mention hosts serialise concurrent turns through their own per-thread session lock, so Recorder must not take the Job lease (that would falsely exclude a concurrent mention on a different thread of the same case). A run therefore surfaces in the list at Finish.
Because each mention turn uses its OWN fresh JobID, its parent JobRun doc exists only once that turn Finishes. A hard process kill between Open and Finish therefore leaves a RUNNING log whose parent JobRun doc was never written, so ListByCase never returns it and the orphan stays invisible — no perpetual-RUNNING row pollutes the list. This is an accepted edge case: the deferred Finish runs on normal returns and on panics recovered by async.Dispatch, so only an abrupt process death (SIGKILL / OOM) can reach it.
func Open ¶
func Open(ctx context.Context, p OpenParams) (*Recorder, error)
Open creates the RUNNING JobRunLog and returns a Recorder whose Handler the caller wires into gollem (or planexec's RunRequest.TraceHandler). A failure here is returned to the caller, which treats it as non-fatal (the turn still runs, just untraced) — creating the log is observability, not part of the turn's success contract.
func (*Recorder) Finish ¶
Finish transitions the run's JobRunLog to its terminal stage and materialises the JobRun summary doc so the run surfaces in ListByCase. execErr nil → SUCCESS; non-nil → FAILED (and a RUN_ERROR event is appended). Every persistence failure is non-fatal (errutil.Handle): the trace is observability, so a trace write failing must not fail the caller's turn.
It is deliberately named Finish, not Close: this ends a run record, it does not close an io.Closer resource (safe.Close is for those).
type Routing ¶
type Routing struct {
WorkspaceID string
CaseID int64
JobID string
RunID string
TraceID string
// ProcessID names the Process this Handler records for. It belongs here
// rather than on the individual hooks for the same reason the identifiers
// above do: one Handler serves exactly one claim of one Process, so the value
// is fixed for the Handler's whole life.
//
// Empty for a caller that drives no child Processes (the in-process Job
// executor), which is the same thing an event written before the field
// existed carries.
ProcessID string
}
Routing carries the immutable identifiers stamped on every JobRunEvent emitted by a Handler instance. Captured at construction time so individual hook calls do not need to thread them through.
type ToolCallStats ¶ added in v0.3.0
ToolCallStats is one tool's share of CallStats.
type Usage ¶ added in v0.3.0
type Usage struct {
InputTokens int64
OutputTokens int64
CacheCreationInputTokens int64
CacheReadInputTokens int64
LLMCalls int64
ToolCalls int64
// CostNanoUSD is what those tokens cost, priced at the rate of the model the
// run generated through. It is carried rather than derived because the token
// counts alone cannot be priced later: which model ran is the run's own fact,
// and a configured price may change afterwards.
CostNanoUSD int64
// Model is the provider's own name for the model the run generated through —
// the value an operator can match against a provider's billing. Empty when
// the caller could not resolve it.
Model string
}
Usage is what a run consumed, as counted by whoever ran it.
A durable agent run needs this because its Recorder does not survive: the run finishes on whichever instance committed its terminal transition, which is not necessarily the one that opened the log. The runtime meters usage on the Process itself, so the caller reads it from there and hands it over.