trace

package
v0.7.1-rc.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 5, 2026 License: Apache-2.0 Imports: 17 Imported by: 0

Documentation

Overview

Package trace is the record of everything one run did, kept only when somebody asked for it.

It exists because of what debugging a bad turn costs today. The model-call log beside it (internal/calllog) is the INDEX: one line per call, always on, small enough to grep, and deliberately holding none of the person's own words. That is the right shape for "which call was slow" and the wrong shape for "what exactly did we send, and what exactly came back" — the question somebody has after a reply that made no sense, a tool that refused, or a route that went somewhere they did not expect. Answering that needs bodies, and bodies do not belong in a file that is always on and rotates at 32 MB, where one long turn evicts the failure the person came for.

So: ONE SWITCH, ONE FOLDER PER RUN, AND NOTHING WRITTEN WHEN IT IS OFF. The switch has three doors — the environment pin CODEAF_DEBUG, a --debug flag on chat, do and exec, and /debug inside a conversation — and when none of them was used, For returns nil after two atomic loads and every method on that nil recorder is a no-op. A feeder site therefore costs one call and one nil check on the runs nobody is debugging, which is what lets the feeders sit on the hot path at all.

THE THREE DOORS MEAN THE SAME RECORD AND NOT THE SAME SCOPE, and the run id on the context is what draws the line. The pin and the flag were handed to THIS PROCESS on purpose, so they turn the record on process-wide (Enable): every run the process opens is recorded, each into its own folder. /debug was typed inside ONE conversation, and a process can hold several — an engine host holds one per person sitting in front of it — so it turns the record on for that run and no other (EnableRun). A switch that could not tell them apart would land one person's prompts, files and replies in a folder somebody else asked for, which is the one thing a record of a person's own data may never do.

THE RECORD IS A PERSON'S OWN DATA. It holds their prompts, their files and the model's whole reply, so it lives under the state root and nowhere else, its folder is 0700 and its files 0600, and no authorization header or key value is ever written into it (Scrub). A run keeps its own folder, named by a run id that every record in it carries, and old folders are pruned WHOLE (see KeepRuns) — never a rotation inside a run, because a rotation inside a run is exactly how the failure being investigated gets deleted mid-run.

A WRITE FAILURE IS NEVER A FAILED RUN. Everything here follows calllog's discipline: a full disk, a read-only home or a path that is a directory silences this run's recorder after ONE line on stderr naming the path, and the run carries on exactly as it would have with the switch off.

Index

Constants

View Source
const (
	// EnvVar is the switch's spelling in a shell. It is exported so the manual,
	// the settings footer and the doors all say the word the code reads.
	EnvVar = "CODEAF_DEBUG"
	// BodiesEnvVar is the switch's OLD name — the pin that used to put request
	// and response bodies on every line of the model-call log. It means the
	// same thing as EnvVar for one release, so a person with the old word in a
	// shell history gets the record rather than silence.
	BodiesEnvVar = "CODEAF_CALL_LOG_BODIES"
	// MaxMBEnvVar and KeepEnvVar move the two ceilings below. They are pins
	// rather than settings rows for the reason the bodies pin was: they are
	// turned for one investigation, in a shell, on purpose.
	MaxMBEnvVar = "CODEAF_TRACE_MAX_MB"
	KeepEnvVar  = "CODEAF_TRACE_KEEP"

	// DirName and TraceDirName put the record beside the model-call log rather
	// than under it, because "where does codeaf keep what it wrote down" has
	// one answer and this is the second thing in it.
	DirName      = "logs"
	TraceDirName = "trace"
	// EventsFileName is the run's one appended file: tool calls and decisions,
	// JSON Lines, in the order they happened. Call bodies are files of their
	// own beside it, under CallsDirName, because a body is megabytes and a
	// reader wants exactly one of them.
	EventsFileName = "events.jsonl"
	CallsDirName   = "calls"
	// RunFileName is the run's own header, written by the door that opened it:
	// which door, which model was asked for, which build, which folder, when.
	// It is the one file a switched-on run always has, so a folder is never a
	// pile of bodies with nothing saying what the run was.
	RunFileName = "run.json"

	// MaxRunBytes is what ONE run's folder may hold. A quarter of a gigabyte is
	// a very long agentic run with every body kept whole, and it is a ceiling
	// rather than a rotation on purpose: when a run reaches it the record says
	// so on its last line and stops, keeping everything it already had. The
	// alternative — evicting the oldest records to make room — throws away the
	// beginning of the run, which is where the decision that went wrong nearly
	// always is.
	MaxRunBytes = 256 << 20
	// KeepRuns is how many run folders survive. Twenty is a few days of
	// debugging, pruned oldest-first and WHOLE, so a run that is kept is
	// complete and a run that is not is simply gone.
	KeepRuns = 20
)

Variables

This section is empty.

Functions

func Announce

func Announce(ctx context.Context, w io.Writer)

Announce prints the one line a door leaves behind: where the record went. It prints NOTHING when the run wrote nothing, because a path to a folder that does not exist is a door telling somebody to go and look at an empty room — and a run nobody switched on has no recorder to have written anything, which is why this asks the recorder rather than the switch.

func Begin

func Begin(ctx context.Context) context.Context

Begin mints this invocation's run id at the door and returns the context carrying it. Every door calls it once, switch on or off: minting an id costs eight bytes of entropy, and a door that only minted one when the switch was already on could not answer /debug.

func Dir

func Dir(run string) string

Dir names one run's folder, whether or not anything has been written into it. It is what /debug prints when it turns the record on, before there is anything to print about.

func Enable

func Enable()

Enable turns the record on for the rest of the process, and it is what the environment pin and the --debug flag call — those two were given to THIS PROCESS, so every run it opens is recorded, each into its own folder. There is deliberately no way to turn it off again, because the only reason to ask for the record is that something already went wrong and half a record is worse than none.

func EnableRun

func EnableRun(ctx context.Context) string

EnableRun turns the record on for ONE run — the run whose id is on the given context — and returns that id, or the empty string where the context belongs to no run and there is therefore nothing to record. It is what /debug calls.

THE RUN ID ON THE CONTEXT IS THE SCOPE OF THE SWITCH. One process can hold several conversations, so a /debug typed in one of them must not start writing another's prompts and replies into a folder its person never asked for. The process-wide flip is Enable, and only the pin and the flag reach it.

func Enabled

func Enabled() bool

Enabled reports whether this process is keeping the record of EVERY run it opens. It is what a door asks before saying so; a single conversation asks EnabledRun, because the answer for one run is not the answer for the process.

func EnabledRun

func EnabledRun(ctx context.Context) bool

EnabledRun reports whether THIS run is being recorded — because the process is recording all of them, or because somebody typed /debug in this one. It is what a conversation asks before saying "the record is already on".

func NewRunID

func NewRunID() string

NewRunID mints the token every record in one run carries. EIGHT BYTES, which is sixteen hex characters, because the id names a FOLDER: two runs that minted the same id would open the same folder and os.MkdirAll would merge them silently, and a record of two runs read as one is worse than no record. Four bytes — calllog's width, which this began as — is a collision every few tens of thousands of runs on one machine; eight makes it unlikely enough to stop reasoning about, and is still short enough to sit in a folder name a person is typing.

func NodeFrom

func NodeFrom(ctx context.Context) string

NodeFrom is the work a record belongs to, or the empty string where nobody named one. The emptiness law applies to the file as much as to the screen: a record with no node says nothing rather than naming a node called "".

func OpenRun

func OpenRun(ctx context.Context, header RunHeader)

OpenRun writes the run's header, and is what every door calls once the switch has been read. It is a no-op when the record is off, so a door is one line either way.

IT IS ALSO WHAT CREATES THE FOLDER, which is the whole reason it exists: a person who turned the record on wants to be told where it went, and a door that only announced a folder something else had already written into could say nothing at all on a run that reached no feeder.

func RunFrom

func RunFrom(ctx context.Context) string

RunFrom is the run a record belongs to: the id the caller carried, and this process's own where a caller could not carry one.

func Scrub

func Scrub(body []byte) []byte

Scrub returns the bytes with every credential it can recognize replaced. It returns the input unchanged when there is nothing to find, so the common case costs one pass and no allocation.

func ScrubRegistered

func ScrubRegistered(body []byte) []byte

ScrubRegistered removes only the exact credential bytes handed to Secret. It exists beside Scrub because semantic ingress must preserve diagnostics that merely resemble credentials, while records and other output sinks need Scrub's broader defence against unregistered key and header shapes.

func Secret

func Secret(value string)

Secret registers a value that must never appear in the record. Every door calls it with each credential this profile is configured with (config.Credentials), because a shape only ever catches the shapes somebody thought of and the exact value catches the rest.

EIGHT CHARACTERS IS THE FLOOR, AND THE REASON IS NOT TIDINESS. Registered values are replaced by literal match anywhere in a body, so a short one would redact ORDINARY TEXT: a placeholder like "none" or "test", a truncated paste, an empty row read as "" — each would turn every occurrence of those letters in a person's own prompts and the model's replies into `[redacted]`, and a record full of holes is a record nobody can debug from. No real credential is shorter than eight characters, so the floor costs nothing true.

func WithNode

func WithNode(ctx context.Context, node string) context.Context

WithNode names the piece of work whose records these are — a plan node, a leaf, a named errand — for every record written under the returned context.

THE RECORD ANSWERS "WHICH WORK WAS THIS?" AND NOT ONLY "WHICH RUN?". A long agentic run is dozens of calls across a plan, and a folder in which they are distinguishable only by their timestamps is a folder somebody has to reconstruct the plan from. The node is carried rather than passed because the feeder sites are deep — a provider retrying a call knows nothing about plans — and because it is exactly how the model-call log already carries the same fact (provider's WithCallNode, which will call this too, so that one context value is set in one place and both records name the same work).

func WithRun

func WithRun(ctx context.Context, id string) context.Context

WithRun puts a run id on a context, where every feeder reads it from.

Types

type CallBody

type CallBody struct {
	// CallID is the token the model-call log already mints per attempt. It is
	// what joins this file to that line and to the conversation's own transcript.
	CallID string
	// Node is the piece of work this call belonged to — a plan node, a leaf, a
	// named errand. A feeder that knows it names it; every other feeder leaves
	// it empty and the recorder fills it from the context ([trace.WithNode]),
	// which is where the deep sites carry it.
	Node  string
	Model string
	// Request and Response are the wire bodies as bytes. They are written as
	// JSON where they are JSON and as a string otherwise, so a reader gets one
	// document to read rather than JSON quoted inside JSON.
	Request  []byte
	Response []byte
	// Error is the provider's own sentence where the call failed, unclipped:
	// the reason to keep the record is that the exact words are what is wrong.
	Error string
	// Finish is how the reply ended — "stop", "length", "tool_calls" — and
	// Reasoning is the thinking text where the endpoint returned it separately
	// from the answer.
	Finish    string
	Reasoning string
}

CallBody is one model call, whole: what went out, what came back, and how it ended. It is written as a file of its own named by the call id, because a body is the one record a reader wants exactly one of, and because a megabyte-long line in the events file would make that file unreadable for every other purpose.

type Decision

type Decision struct {
	CallID string
	// Node is the piece of work this choice was made for, filled from the
	// context when the feeder does not name one — see [CallBody.Node].
	Node         string
	Kind         string
	Subject      string
	Choice       string
	Reason       string
	Alternatives []string
}

Decision is a choice the run made, with the reason it made it. Kind is what KIND of choice it was ("lane", "hedge", "effort"), Subject is what the choice was about, Choice is what was chosen, and Alternatives are what was not — the four together being what somebody reconstructing a run actually asks for.

type Recorder

type Recorder struct {
	// contains filtered or unexported fields
}

Recorder is one run's folder. EVERY METHOD IS A NO-OP ON A NIL RECEIVER, so a feeder site is one call and one nil check and never a branch around the call itself — which is the property that lets these sit on the hot path.

func For

func For(ctx context.Context) *Recorder

For is the recorder every feeder site calls. It returns nil when nobody asked for a record — two atomic loads and a nil return — and nil when there is no run to belong to, because a record nothing can be joined to is worse than no record.

func (*Recorder) Call

func (r *Recorder) Call(ctx context.Context, body CallBody)

Call writes one model call's bodies. The context is taken for the shape every feeder site already has; the run is the recorder's own, because a recorder handed a context from another run would write a record under the wrong id.

func (*Recorder) Decision

func (r *Recorder) Decision(ctx context.Context, decision Decision)

Decision appends one choice and its reason.

func (*Recorder) Folder

func (r *Recorder) Folder() string

Folder is where this run's record is, whether or not anything is in it yet.

func (*Recorder) Header

func (r *Recorder) Header(ctx context.Context, header RunHeader)

Header writes run.json. One document, 0600, beside the events file.

func (*Recorder) Tool

func (r *Recorder) Tool(ctx context.Context, event ToolEvent)

Tool appends one tool call.

func (*Recorder) Wrote

func (r *Recorder) Wrote() bool

Wrote reports whether anything reached the disk, which is what a door asks before printing the folder's path at exit.

type RunHeader

type RunHeader struct {
	// Command is the door's own word: "chat", "resume", "do", "exec".
	Command string
	// Model is what was ASKED for, empty where the door was given no pin — the
	// emptiness law, so an absent pin never reads as a model somebody chose.
	Model string
	// Build is the binary this run came out of, which is the first thing anybody
	// reading a record from another machine needs.
	Build string
	// Workspace is the folder the run was pointed at.
	Workspace string
	// Started is when the door opened. It is the header's own field rather than
	// the ts every record carries, because the two differ on a header written
	// after a slow launch.
	Started time.Time
}

RunHeader is the run's own first record: which door opened it, what it was asked to use, and where. It is written by the door the moment the record is switched on, before anything can fail — because the question a person asks of a folder they found afterwards is "which run was this?", and a folder of call bodies with nothing saying what the run WAS is a folder they have to guess at.

type ToolEvent

type ToolEvent struct {
	CallID string
	// Node is the piece of work this tool call belonged to, filled from the
	// context when the feeder does not name one — see [CallBody.Node].
	Node     string
	Name     string
	Args     string
	Result   string
	Started  time.Time
	Duration time.Duration
	Status   string
	// Refuser is who said no — the approval policy, the guardian, a budget —
	// and Reason is their own sentence for it.
	Refuser string
	Reason  string
}

ToolEvent is one tool call as it ran. Status is "ok", "failed" or "refused", and the third is a fact the other two cannot carry: a tool a gate refused never ran at all, and a record in which a refusal reads like a failure is a record that sends somebody debugging the tool instead of the gate.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL