diff --git a/internal/agent/loop.go b/internal/agent/loop.go index 69c87ecfc..0ef7041d1 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -213,6 +213,26 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options) exposed, _ := partitionToolsCached(registry, permissionMode, options, loaded, toolDefCache) toolPartitionSpan.End() + // Prompt-prefix fingerprint: hash the seven cacheable sub-components + // of this turn's request (system prompt sections, project context, + // skills, partitioned tool list, tool schemas) and emit one trace + // event. Stable across turns => cacheable. Drift in any sub-hash + // names the sub-component that broke the cache for this turn. The + // computation is pure and cheap (seven SHA-256s of small strings) + // and is a no-op when tracing is off. + if options.Trace != nil { + fp := ComputePrefixFingerprint(options, exposed) + options.Trace.EmitPrefixHash(trace.PrefixHash{ + BaseInstructionsHash: fp.BaseInstructionsHash, + ConfirmationPolicyHash: fp.ConfirmationPolicyHash, + ProjectContextHash: fp.ProjectContextHash, + SkillsHash: fp.SkillsHash, + ToolsHash: fp.ToolsHash, + SchemaHash: fp.SchemaHash, + CompletePrefixHash: fp.CompletePrefixHash, + }) + } + // PROACTIVE compaction: if the history is approaching the model's // context window, summarize the oldest middle before building the // request. A no-op when ContextWindow == 0 (compaction disabled). diff --git a/internal/agent/prompt_fingerprint.go b/internal/agent/prompt_fingerprint.go new file mode 100644 index 000000000..53daa97c0 --- /dev/null +++ b/internal/agent/prompt_fingerprint.go @@ -0,0 +1,300 @@ +package agent + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/Gitlawb/zero/internal/zeroruntime" +) + +// promptSubstrings are the seven cacheable sub-components of the prompt a run +// sends to a model. Each is the literal content the sub-component would emit +// into the system prompt or tool list, hashed so a downstream observer can +// detect drift turn-over-turn. The substrings are produced by +// buildPromptSubstrings and consumed by ComputePrefixFingerprint. +type promptSubstrings struct { + baseInstructions string + confirmationPolicy string + projectContext string + skills string + tools string + schema string +} + +// buildPromptSubstrings assembles the seven cacheable sub-components of the +// prompt without joining them. The system prompt sections are produced by the +// same private builders that buildSystemPrompt uses, so the substrings are +// byte-identical to what would have appeared in the joined prompt. The tools +// and schema substrings are derived from the partitioned tool list (caller- +// provided) so this helper does not need to re-run the partition. +// +// This is the seam ComputePrefixFingerprint reads from. It exists as a +// separate function (rather than returning both prompt and substrings from +// buildSystemPrompt) so existing callers of buildSystemPrompt are unaffected +// and the substrings helper is independently testable. +func buildPromptSubstrings(options Options, exposed []zeroruntime.ToolDefinition) promptSubstrings { + core := strings.TrimSpace(options.SystemPrompt) + if core == "" { + core = strings.TrimSpace(coreSystemPrompt) + } + if core == "" { + core = fallbackSystemPrompt + } + + // The confirmation policy is appended unconditionally in buildSystemPrompt; + // the substring here is the same constant. + policy := strings.TrimSpace(confirmationPolicy) + + // projectContext is the joined output of workspaceContext, which itself + // walks the AGENTS.md / ZERO.md / .zero/AGENTS.md chain. Hashing the + // joined string captures every guideline file's contribution and the + // ordering between them. + project := workspaceContext(options.Cwd) + + // skills is the joined output of skillsContext, with each skill on its own + // line. The substring is the literal text the model would see. + skills := skillsContext(options) + + // tools and schema are derived from the partitioned tool list. The list + // passed in is already in stable order (partitionToolsCached is the + // canonical stable partitioner) so concatenating in slice order is + // sufficient — we sort names again defensively in case a future caller + // hands us a list from a different partitioner. + toolsSubstr, schemaSubstr := toolSubstrings(exposed) + + return promptSubstrings{ + baseInstructions: core, + confirmationPolicy: policy, + projectContext: project, + skills: skills, + tools: toolsSubstr, + schema: schemaSubstr, + } +} + +// toolSubstrings extracts a stable, name-sorted digest of the partitioned tool +// list. The tools substring is "name1\nname2\n..." (sorted) so a reordering of +// the partition produces a different hash. The schema substring is the +// concatenation of each tool's JSON schema in name order, so a schema change +// is detected independently of the tool ordering. +func toolSubstrings(exposed []zeroruntime.ToolDefinition) (toolsSubstr, schemaSubstr string) { + if len(exposed) == 0 { + return "", "" + } + names := make([]string, 0, len(exposed)) + byName := make(map[string]zeroruntime.ToolDefinition, len(exposed)) + for _, def := range exposed { + names = append(names, def.Name) + byName[def.Name] = def + } + sort.Strings(names) + var toolsSB, schemaSB strings.Builder + for i, name := range names { + if i > 0 { + toolsSB.WriteByte('\n') + } + toolsSB.WriteString(name) + def := byName[name] + // Parameters is rendered to a canonical JSON string per tool. The + // schema-render cache in internal/agent (used by partitionToolsCached) + // guarantees the same render is byte-identical across turns for the + // same tool, so this substring is a stable hash input. + schemaSB.WriteString(name) + schemaSB.WriteByte('\n') + schemaSB.WriteString(canonicalSchemaString(def.Parameters)) + schemaSB.WriteByte('\n') + } + return toolsSB.String(), schemaSB.String() +} + +// canonicalSchemaString renders a tool's parameter schema to a stable string. +// tools.ToolDef.Parameters is a map[string]any in the common case; Go's +// fmt.Sprintf("%v", m) iterates a map in random order, which would produce +// a different hash for the same Parameters value across calls and defeat +// the fingerprint. encoding/json marshals maps with keys sorted +// alphabetically, so json.Marshal is the primary stable render. The +// fallback for the rare non-JSON-compatible value (functions, channels, +// NaN/Inf floats, cyclic references) is a stable key-sorted stringifier +// that walks the value with sorted keys for maps and a leading +// "__non_json:" prefix so a future schema change is visible in the trace +// (a SchemaHash collision from the same value) rather than a silent hash +// drift. +// +// Note: SchemaHash is a stability signal, not a wire-identity signal. The +// bytes json.Marshal produces for a Go map[string]any may differ from +// the bytes the provider actually sends on the wire (provider encoders +// use their own JSON conventions; an Anthropic schema may serialize with +// different whitespace, key order, or number formatting). A consumer +// must NOT assume SchemaHash matches the provider's wire schema — only +// that two turns with the same Go-side Parameters produce the same +// SchemaHash. This is sufficient for the trace's purpose (drift +// detection) but not for a content-based equality check. +func canonicalSchemaString(params map[string]any) string { + if len(params) == 0 { + return "" + } + data, err := json.Marshal(params) + if err != nil { + return "__non_json:" + stableStringify(params) + } + return string(data) +} + +// stableStringify renders v to a deterministic string. Maps have their keys +// sorted alphabetically; slices are walked in order; primitives use their +// natural Go format. The function is recursive but bounded by the size of +// the input, so a cyclic reference is not reachable here (the json.Marshal +// fallback is hit when json.Marshal itself returns an error, which for the +// common case is a non-JSON-compatible value, not a cycle — cycles are +// rare in tool schemas and acceptable to mis-render in the fallback path +// since the trace's contract is "the hash is stable for the same input," +// not "the fallback is lossless"). Used only when json.Marshal fails. +func stableStringify(v any) string { + var sb strings.Builder + writeStable(&sb, v) + return sb.String() +} + +func writeStable(sb *strings.Builder, v any) { + switch x := v.(type) { + case nil: + sb.WriteString("null") + case bool: + if x { + sb.WriteString("true") + } else { + sb.WriteString("false") + } + case string: + sb.WriteByte('"') + sb.WriteString(x) + sb.WriteByte('"') + case float64: + // Use %g for compact, deterministic float rendering. NaN and Inf are + // not JSON-encodable (which is why we are in the fallback path) and + // render as "NaN" / "+Inf" / "-Inf" — distinct, stable strings. + fmt.Fprintf(sb, "%g", x) + case float32: + fmt.Fprintf(sb, "%g", float64(x)) + case int: + fmt.Fprintf(sb, "%d", x) + case int64: + fmt.Fprintf(sb, "%d", x) + case int32: + fmt.Fprintf(sb, "%d", x) + case uint: + fmt.Fprintf(sb, "%d", x) + case uint64: + fmt.Fprintf(sb, "%d", x) + case uint32: + fmt.Fprintf(sb, "%d", x) + case map[string]any: + sb.WriteByte('{') + keys := make([]string, 0, len(x)) + for k := range x { + keys = append(keys, k) + } + sort.Strings(keys) + for i, k := range keys { + if i > 0 { + sb.WriteByte(',') + } + sb.WriteByte('"') + sb.WriteString(k) + sb.WriteString(`":`) + writeStable(sb, x[k]) + } + sb.WriteByte('}') + case []any: + sb.WriteByte('[') + for i, item := range x { + if i > 0 { + sb.WriteByte(',') + } + writeStable(sb, item) + } + sb.WriteByte(']') + default: + // Last-resort: include the type so distinct values produce distinct + // strings even if their default format collides. Without the type + // tag, fmt.Sprintf("%v", x) for two different types could produce + // the same bytes (rare, but the type prefix makes it impossible). + fmt.Fprintf(sb, "<%T:%v>", x, x) + } +} + +// ComputePrefixFingerprint returns a trace.PrefixHash (a 7-field fingerprint +// of the prompt prefix) for one turn of a run. The seven sub-hashes are +// independent SHA-256s of the corresponding sub-component; the complete-prefix +// hash is a SHA-256 of the canonical concatenation of the other six, so any +// sub-component drift is observable both individually and in aggregate. +// +// I/O side effects: buildPromptSubstrings calls workspaceContext, which runs +// git (gitBranchForPrompt, FindProjectGitRoot) and reads the AGENTS.md / +// ZERO.md / .zero/AGENTS.md chain plus the repo map. The same workspace- +// context work is performed separately by buildSystemPrompt a few lines +// later in the request-build path, so on every traced turn the file and +// git reads happen twice. The duplication is intentional for this PR (a +// follow-up will pass substrings through) and is a known perf cost only on +// the opt-in trace path. +// +// A stable CompletePrefixHash across turns means the four captured +// sub-components (baseInstructions, confirmationPolicy, projectContext, +// skills) are byte-identical. It does NOT rule out drift in the seven +// uncaptured sections of buildSystemPrompt (modelPromptAddendum, +// sessionRuntimeContext, approvedCommandPrefixContext, workspaceSeedContext, +// userGuidelines, specialistDelegationContext, responseStyleContext). For +// default Options the four captured substrings are the full prompt; for +// non-default Options the seven uncaptured sections may contribute, and a +// consumer correlating CompletePrefix stability with cached_input_tokens +// must cross-check the model_switches counter (modelPromptAddendum changes +// on a model switch) to disambiguate "no drift" from "drift in an +// uncaptured section." + +func ComputePrefixFingerprint(options Options, exposed []zeroruntime.ToolDefinition) prefixFingerprint { + subs := buildPromptSubstrings(options, exposed) + base := sha256hex(subs.baseInstructions) + policy := sha256hex(subs.confirmationPolicy) + project := sha256hex(subs.projectContext) + skills := sha256hex(subs.skills) + toolsH := sha256hex(subs.tools) + schema := sha256hex(subs.schema) + complete := sha256hex(strings.Join([]string{ + base, policy, project, skills, toolsH, schema, + }, "|")) + return prefixFingerprint{ + BaseInstructionsHash: base, + ConfirmationPolicyHash: policy, + ProjectContextHash: project, + SkillsHash: skills, + ToolsHash: toolsH, + SchemaHash: schema, + CompletePrefixHash: complete, + } +} + +// prefixFingerprint is the agent-side shape of a prompt-prefix fingerprint. It +// is converted to a trace.PrefixHash at the loop boundary (see EmitPrefixHash +// in loop.go) so the trace package does not need to import this one. The field +// names match the trace.PrefixHash JSON tags 1:1. +type prefixFingerprint struct { + BaseInstructionsHash string + ConfirmationPolicyHash string + ProjectContextHash string + SkillsHash string + ToolsHash string + SchemaHash string + CompletePrefixHash string +} + +// sha256hex returns the hex-encoded SHA-256 of s. Empty input produces the +// hash of the empty string, which is a constant; callers that want to +// distinguish "absent" from "empty" should check s before calling. +func sha256hex(s string) string { + sum := sha256.Sum256([]byte(s)) + return hex.EncodeToString(sum[:]) +} diff --git a/internal/agent/prompt_fingerprint_test.go b/internal/agent/prompt_fingerprint_test.go new file mode 100644 index 000000000..c6858f734 --- /dev/null +++ b/internal/agent/prompt_fingerprint_test.go @@ -0,0 +1,243 @@ +package agent + +import ( + "strings" + "testing" + + "github.com/Gitlawb/zero/internal/zeroruntime" +) + +// TestComputePrefixFingerprintStableAcrossCalls asserts the headline property +// the trace is meant to expose: two ComputePrefixFingerprint calls over the +// same Options and the same exposed tool list produce byte-identical hashes. +// This is the regression catch for "did anyone introduce non-determinism into +// the prompt prefix." +func TestComputePrefixFingerprintStableAcrossCalls(t *testing.T) { + opts := Options{Cwd: t.TempDir(), SystemPrompt: "core"} + exposed := []zeroruntime.ToolDefinition{ + {Name: "read_file", Parameters: map[string]any{"type": "object"}}, + {Name: "grep", Parameters: map[string]any{"type": "object"}}, + } + first := ComputePrefixFingerprint(opts, exposed) + second := ComputePrefixFingerprint(opts, exposed) + if first != second { + t.Fatalf("fingerprint must be stable across calls with the same input:\n first=%+v\n second=%+v", first, second) + } + if first.CompletePrefixHash == "" { + t.Fatalf("CompletePrefixHash must be non-empty for a non-trivial prompt") + } +} + +// TestComputePrefixFingerprintToolsHashIsNameSetSensitive asserts the +// name-set sensitivity of the ToolsHash. The test name used to read as +// "reorder changes hash" but the assertion is the opposite: the hash is +// name-set sensitive, not name-order sensitive (toolSubstrings sorts +// names before hashing). The partitioner is expected to produce a stable +// order, so a name-order change is not a hash signal — a name-set change +// is. The test asserts both: permutation of the same tools produces the +// same hash, and adding a tool produces a different hash. +func TestComputePrefixFingerprintToolsHashIsNameSetSensitive(t *testing.T) { + opts := Options{Cwd: t.TempDir(), SystemPrompt: "core"} + a := []zeroruntime.ToolDefinition{ + {Name: "read_file", Parameters: map[string]any{"schema": "schema-read"}}, + {Name: "grep", Parameters: map[string]any{"schema": "schema-grep"}}, + } + b := []zeroruntime.ToolDefinition{ + {Name: "grep", Parameters: map[string]any{"schema": "schema-grep"}}, + {Name: "read_file", Parameters: map[string]any{"schema": "schema-read"}}, + } + // toolSubstrings sorts names internally, so the ToolsHash is identical + // for a permutation of the same tools. We assert that here to document + // the contract: name-order independence, name-set sensitivity. + fpA := ComputePrefixFingerprint(opts, a) + fpB := ComputePrefixFingerprint(opts, b) + if fpA.ToolsHash != fpB.ToolsHash { + t.Fatalf("ToolsHash must be name-set sensitive, not name-order sensitive: a=%s b=%s", fpA.ToolsHash, fpB.ToolsHash) + } + // A different tool set must produce a different ToolsHash. + c := append([]zeroruntime.ToolDefinition{}, a...) + c = append(c, zeroruntime.ToolDefinition{Name: "bash", Parameters: map[string]any{"schema": "schema-bash"}}) + fpC := ComputePrefixFingerprint(opts, c) + if fpA.ToolsHash == fpC.ToolsHash { + t.Fatalf("ToolsHash must change when the tool set changes: a=%s c=%s", fpA.ToolsHash, fpC.ToolsHash) + } +} + +// TestComputePrefixFingerprintSchemaChangeChangesSchemaHash asserts that a +// change to a tool's Parameters schema is visible in the SchemaHash. A schema +// edit that should be cache-stable (e.g. field reorder, doc tweak) will move +// the hash and surface in the trace; a schema edit that legitimately +// invalidates the cache also moves the hash, which is the right behavior. +func TestComputePrefixFingerprintSchemaChangeChangesSchemaHash(t *testing.T) { + opts := Options{Cwd: t.TempDir(), SystemPrompt: "core"} + a := []zeroruntime.ToolDefinition{ + {Name: "read_file", Parameters: map[string]any{"version": "v1"}}, + } + b := []zeroruntime.ToolDefinition{ + {Name: "read_file", Parameters: map[string]any{"version": "v2"}}, + } + fpA := ComputePrefixFingerprint(opts, a) + fpB := ComputePrefixFingerprint(opts, b) + if fpA.SchemaHash == fpB.SchemaHash { + t.Fatalf("SchemaHash must change when tool Parameters change: a=%s b=%s", fpA.SchemaHash, fpB.SchemaHash) + } +} + +// TestComputePrefixFingerprintSystemPromptChangeChangesBaseHash asserts the +// most common drift path: a system-prompt edit (e.g. an agent.md update, a +// model addendum change) must move the base-instructions hash, which moves +// the complete-prefix hash, which is what the trace is for. +func TestComputePrefixFingerprintSystemPromptChangeChangesBaseHash(t *testing.T) { + optsA := Options{Cwd: t.TempDir(), SystemPrompt: "core v1"} + optsB := Options{Cwd: t.TempDir(), SystemPrompt: "core v2"} + exposed := []zeroruntime.ToolDefinition{{Name: "noop", Parameters: map[string]any{}}} + fpA := ComputePrefixFingerprint(optsA, exposed) + fpB := ComputePrefixFingerprint(optsB, exposed) + if fpA.BaseInstructionsHash == fpB.BaseInstructionsHash { + t.Fatalf("BaseInstructionsHash must change when SystemPrompt changes: a=%s b=%s", fpA.BaseInstructionsHash, fpB.BaseInstructionsHash) + } + if fpA.CompletePrefixHash == fpB.CompletePrefixHash { + t.Fatalf("CompletePrefixHash must change when any sub-hash changes: a=%s b=%s", fpA.CompletePrefixHash, fpB.CompletePrefixHash) + } +} + +// TestComputePrefixFingerprintCompleteIsAggregateOfSubHashes asserts the +// canonical-join property: CompletePrefixHash must depend on every sub-hash. +// This is the regression catch for "did anyone reorder or drop a sub-hash +// from the canonical join without updating CompletePrefixHash." +func TestComputePrefixFingerprintCompleteIsAggregateOfSubHashes(t *testing.T) { + opts := Options{Cwd: t.TempDir(), SystemPrompt: "core"} + exposed := []zeroruntime.ToolDefinition{{Name: "read_file", Parameters: map[string]any{"k": "v"}}} + fp := ComputePrefixFingerprint(opts, exposed) + // Manually compute what the canonical join should be. + expected := sha256hex(strings.Join([]string{ + fp.BaseInstructionsHash, + fp.ConfirmationPolicyHash, + fp.ProjectContextHash, + fp.SkillsHash, + fp.ToolsHash, + fp.SchemaHash, + }, "|")) + if fp.CompletePrefixHash != expected { + t.Fatalf("CompletePrefixHash must equal sha256hex of the canonical join of the other six:\n got: %s\n expected: %s", fp.CompletePrefixHash, expected) + } +} + +// TestBuildPromptSubstringsDefaultOptions asserts the invariants of the +// substrings helper for default Options: which substrings are non-empty, +// which are empty, and that the substring-to-hash round-trip is lossless. +func TestBuildPromptSubstringsDefaultOptions(t *testing.T) { + opts := Options{ + Cwd: t.TempDir(), + SystemPrompt: "test core", + } + subs := buildPromptSubstrings(opts, nil) + // Invariants the trace depends on (default Options): + // 1. baseInstructions substring equals the core system prompt bytes. + // 2. confirmationPolicy substring is non-empty (the embedded policy + // is always present, post TrimSpace). + // 3. skills, tools, and schema substrings are empty (no skills or + // tools configured for default Options). + if subs.baseInstructions != "test core" { + t.Fatalf("baseInstructions substring must equal the core system prompt: got %q want %q", subs.baseInstructions, "test core") + } + if subs.confirmationPolicy == "" { + t.Fatalf("confirmationPolicy substring must be non-empty (the embedded policy is always present)") + } + if subs.skills != "" { + t.Fatalf("skills substring must be empty for default Options: got %q", subs.skills) + } + // Round-trip: the corresponding fingerprint hashes must equal + // sha256hex of the substrings, with no truncation or reformatting + // between the two layers. + fp := ComputePrefixFingerprint(opts, nil) + if fp.BaseInstructionsHash != sha256hex(subs.baseInstructions) { + t.Fatalf("BaseInstructionsHash in fingerprint must equal sha256hex of the substring") + } + if fp.ConfirmationPolicyHash != sha256hex(subs.confirmationPolicy) { + t.Fatalf("ConfirmationPolicyHash in fingerprint must equal sha256hex of the substring") + } +} + +// TestCanonicalSchemaStringStableForMapParameters asserts the headline +// property the trace needs: the same map[string]any produces the same +// canonical string across calls, even though Go's fmt.Sprintf("%v", m) +// iterates a map in random order. json.Marshal sorts map keys +// alphabetically, which is the fix. +func TestCanonicalSchemaStringStableForMapParameters(t *testing.T) { + params := map[string]any{ + "description": "read a file", + "properties": map[string]any{ + "path": map[string]any{"type": "string"}, + }, + "required": []any{"path"}, + "type": "object", + } + first := canonicalSchemaString(params) + second := canonicalSchemaString(params) + if first != second { + t.Fatalf("canonicalSchemaString must be stable across calls for the same Parameters:\n first=%s\n second=%s", first, second) + } + if first == "" { + t.Fatal("canonicalSchemaString must produce a non-empty string for a non-empty map") + } + // Two maps with the same key/value pairs (different declaration + // order in source) must produce identical canonical strings. This + // is the property that defeats Go's map iteration randomization. + reordered := map[string]any{ + "type": "object", + "required": []any{"path"}, + "properties": map[string]any{ + "path": map[string]any{"type": "string"}, + }, + "description": "read a file", + } + if canonicalSchemaString(params) != canonicalSchemaString(reordered) { + t.Fatal("canonicalSchemaString must produce identical output for maps with the same key/value pairs in different declaration orders") + } +} + +// TestCanonicalSchemaStringFallbackStableForNonJSONValue exercises the +// non-JSON fallback in canonicalSchemaString: when Parameters contains a +// value json.Marshal cannot encode (here, a channel), the function must +// fall back to a key-sorted stringification that is stable across calls +// and across map-iteration order. The previous fallback used +// fmt.Sprintf("%v", params), which iterates the map in random order and +// produced a different hash for the same value across calls — defeating +// the fingerprint. The current fallback is stable by construction. +func TestCanonicalSchemaStringFallbackStableForNonJSONValue(t *testing.T) { + ch := make(chan int) // channels are not JSON-encodable + params := map[string]any{ + "description": "a tool with a non-JSON value", + "channel": ch, + "z_first": 1, + "a_second": 2, + } + first := canonicalSchemaString(params) + second := canonicalSchemaString(params) + if first == "" { + t.Fatal("canonicalSchemaString must produce a non-empty string for the fallback path") + } + if first != second { + t.Fatalf("canonicalSchemaString fallback must be stable across calls:\n first=%s\n second=%s", first, second) + } + // A re-ordered map (same key/value pairs, different declaration order) + // must produce the same fallback string. This is the property the + // key-sorted stringifier provides that fmt.Sprintf("%v", m) does not. + reordered := map[string]any{ + "a_second": 2, + "z_first": 1, + "channel": ch, + "description": "a tool with a non-JSON value", + } + if canonicalSchemaString(params) != canonicalSchemaString(reordered) { + t.Fatalf("canonicalSchemaString fallback must produce identical output for maps with the same key/value pairs in different declaration orders:\n first=%s\n second=%s", first, canonicalSchemaString(reordered)) + } + // The fallback must include the "__non_json:" prefix so a consumer + // can tell the bytes are not a JSON-marshaled schema (a hash collision + // between a json.Marshal result and a stableStringify result is + // possible in theory; the prefix makes the source observable). + if !strings.HasPrefix(first, "__non_json:") { + t.Fatalf("canonicalSchemaString fallback must start with the __non_json: prefix, got: %s", first) + } +} diff --git a/internal/trace/emit.go b/internal/trace/emit.go index a1e1a70ed..2eae76c79 100644 --- a/internal/trace/emit.go +++ b/internal/trace/emit.go @@ -94,6 +94,29 @@ func WriteNDJSON(w io.Writer, t *TurnTrace) error { return err } } + + // Prefix fingerprints are emitted after counters in insertion (turn) + // order. The order is the order EmitPrefixHash was called, which is the + // order the agent loop computed each turn's fingerprint, which is the + // order a downstream consumer needs to correlate a prefix_hash event + // with the cached_input_tokens counter for that turn. Sorting by + // complete_prefix hash would destroy that correlation, so we do not + // sort. The slice is already a deep copy from Finish (see + // Recorder.Finish) so it is safe to range over without copying. + for _, p := range t.PrefixHashes { + if err := enc.Encode(map[string]any{ + "type": "prefix_hash", + "base_instructions": p.BaseInstructionsHash, + "confirmation_policy": p.ConfirmationPolicyHash, + "project_context": p.ProjectContextHash, + "skills": p.SkillsHash, + "tools": p.ToolsHash, + "schema": p.SchemaHash, + "complete_prefix": p.CompletePrefixHash, + }); err != nil { + return err + } + } return nil } diff --git a/internal/trace/parse.go b/internal/trace/parse.go index 490918c14..b02aa4c5c 100644 --- a/internal/trace/parse.go +++ b/internal/trace/parse.go @@ -97,6 +97,25 @@ func ReadNDJSON(r io.Reader) (*TurnTrace, error) { } name, _ := obj["name"].(string) t.Counters = append(t.Counters, Counter{Name: name, Value: parseInt64(obj["value"])}) + case "prefix_hash": + if !sawTraceHeader { + return nil, errors.New("parse trace: not a valid NDJSON trace (no type:trace header)") + } + // Round-trip the seven prefix-hash fields. Missing fields are + // accepted as empty strings (a partially-written trace from a + // crashed emitter must not fatal a parse). The decoder tolerates + // any shape the encoder produced and the seven keys are the + // contract — adding a new field is non-breaking; renaming one + // requires a schema version bump. + t.PrefixHashes = append(t.PrefixHashes, PrefixHash{ + BaseInstructionsHash: stringField(obj, "base_instructions"), + ConfirmationPolicyHash: stringField(obj, "confirmation_policy"), + ProjectContextHash: stringField(obj, "project_context"), + SkillsHash: stringField(obj, "skills"), + ToolsHash: stringField(obj, "tools"), + SchemaHash: stringField(obj, "schema"), + CompletePrefixHash: stringField(obj, "complete_prefix"), + }) default: // Unknown event type: tolerate (forward-compat) but only after a // header has been seen. @@ -117,12 +136,21 @@ func ReadNDJSON(r io.Reader) (*TurnTrace, error) { if !sawTraceHeader { return nil, errors.New("parse trace: non-empty input had no type:trace header") } - if len(t.Spans) == 0 && len(t.Counters) == 0 { - return nil, errors.New("parse trace: header present but no spans or counters recovered (corrupt or truncated)") + if len(t.Spans) == 0 && len(t.Counters) == 0 && len(t.PrefixHashes) == 0 { + return nil, errors.New("parse trace: header present but no spans, counters, or prefix hashes recovered (corrupt or truncated)") } return t, nil } +// stringField returns obj[key] as a string, or "" if the key is missing or +// the value is not a string. JSON-marshaled trace events always emit +// string fields as JSON strings, so the type assertion is the right +// narrowing; a missing key produces an empty hash, which is what the +// encoder would have produced for the absent value. +func stringField(obj map[string]any, key string) string { + s, _ := obj[key].(string) + return s +} func parseTime(v any) time.Time { s, _ := v.(string) if s == "" { diff --git a/internal/trace/recorder.go b/internal/trace/recorder.go index 1327f97fd..b4cf43ff5 100644 --- a/internal/trace/recorder.go +++ b/internal/trace/recorder.go @@ -172,6 +172,22 @@ func (r *Recorder) StampFirstUsefulAction() { r.tr.FirstUsefulActionAt = time.Now() } +// EmitPrefixHash records one prompt-prefix fingerprint on the trace. Multiple +// calls are allowed within a run (one per turn, typically) and accumulate in +// order. The first call after Finish is a no-op; later calls are also no-ops +// because the trace has been sealed. +func (r *Recorder) EmitPrefixHash(p PrefixHash) { + if r == nil { + return + } + r.mu.Lock() + defer r.mu.Unlock() + if r.finished { + return + } + r.tr.PrefixHashes = append(r.tr.PrefixHashes, p) +} + // Finish stamps CompletedAt, derives each span's parent (by interval // containment) and exclusive time, and returns a snapshot of the trace. Calling // Finish more than once returns the same snapshot. @@ -190,6 +206,7 @@ func (r *Recorder) Finish() *TurnTrace { // Copy the slices so callers cannot mutate the recorder's state. snap.Spans = append([]Span(nil), r.tr.Spans...) snap.Counters = append([]Counter(nil), r.tr.Counters...) + snap.PrefixHashes = append([]PrefixHash(nil), r.tr.PrefixHashes...) return &snap } diff --git a/internal/trace/trace.go b/internal/trace/trace.go index 3c86fedf9..c7443661f 100644 --- a/internal/trace/trace.go +++ b/internal/trace/trace.go @@ -82,16 +82,52 @@ type Counter struct { // TurnTrace is the finished record for one agent.Run. It is the value // emitters serialize; it is not mutated after Finish returns a snapshot. type TurnTrace struct { - SessionID string `json:"session_id"` - RunID string `json:"run_id"` - Profile string `json:"profile,omitempty"` - StartedAt time.Time `json:"started_at"` - FirstVisibleEventAt time.Time `json:"first_visible_event_at,omitempty"` - FirstUsefulActionAt time.Time `json:"first_useful_action_at,omitempty"` - FirstTokenAt time.Time `json:"first_token_at,omitempty"` - CompletedAt time.Time `json:"completed_at"` - Spans []Span `json:"spans"` - Counters []Counter `json:"counters"` + SessionID string `json:"session_id"` + RunID string `json:"run_id"` + Profile string `json:"profile,omitempty"` + StartedAt time.Time `json:"started_at"` + FirstVisibleEventAt time.Time `json:"first_visible_event_at,omitempty"` + FirstUsefulActionAt time.Time `json:"first_useful_action_at,omitempty"` + FirstTokenAt time.Time `json:"first_token_at,omitempty"` + CompletedAt time.Time `json:"completed_at"` + Spans []Span `json:"spans"` + Counters []Counter `json:"counters"` + PrefixHashes []PrefixHash `json:"prefix_hashes,omitempty"` +} + +// PrefixHash is one fingerprint of the prompt prefix emitted by an agent run. +// The seven fields decompose the cacheable part of the request so a downstream +// observer can detect which sub-component drifted turn-over-turn: +// base_instructions (the embedded core system prompt), confirmation_policy, +// project_context (AGENTS.md / ZERO.md chain), skills, tools, schema (tool +// JSON schemas), and complete_prefix (SHA-256 of the canonical concatenation +// of the other six). All hashes are hex-encoded SHA-256. +// +// Scope: the fingerprint covers 4 of 11 sections of buildSystemPrompt. The +// seven sections NOT covered — modelPromptAddendum, sessionRuntimeContext, +// approvedCommandPrefixContext, workspaceSeedContext, userGuidelines, +// specialistDelegationContext, responseStyleContext — fire only for +// non-default Options. For default Options (the common case), the four +// captured substrings are the full prompt and the fingerprint is accurate. +// +// A run with a stable CompletePrefix across turns means the four captured +// sub-components are byte-identical. A run where CompletePrefix changes +// names the captured sub-component that drifted, but does NOT rule out +// drift in the seven uncaptured sections. modelPromptAddendum in particular +// changes on a model switch (a model_switches counter the trace already +// emits), so a consumer correlating CompletePrefix stability with +// cached_input_tokens must cross-check the model_switches counter to +// disambiguate "no drift" from "drift in an uncaptured section." The fields +// are emitted as a "prefix_hash" event in the NDJSON trace (see +// WriteNDJSON). +type PrefixHash struct { + BaseInstructionsHash string `json:"base_instructions"` + ConfirmationPolicyHash string `json:"confirmation_policy"` + ProjectContextHash string `json:"project_context"` + SkillsHash string `json:"skills"` + ToolsHash string `json:"tools"` + SchemaHash string `json:"schema"` + CompletePrefixHash string `json:"complete_prefix"` } // WallDuration is the total traced wall time of the run. @@ -229,5 +265,6 @@ func OptionalEventKeys() []string { "counter:" + CounterAcceptanceChecks, "counter:" + CounterPollingTurn, "counter:" + CounterModelSwitches, + "event:prefix_hash", } } diff --git a/internal/trace/trace_test.go b/internal/trace/trace_test.go index 08507cc4d..19db69e82 100644 --- a/internal/trace/trace_test.go +++ b/internal/trace/trace_test.go @@ -370,6 +370,27 @@ func TestReadNDJSONRoundTrip(t *testing.T) { r.Counter(CounterModelRequests, 3) r.Counter(CounterToolCalls, 7) r.StampFirstToken() + // Two prefix_hash events so the round-trip test covers the third + // event type. Insertion order is preserved by the parser (no sort + // is applied on read or write). + r.EmitPrefixHash(PrefixHash{ + BaseInstructionsHash: "b1", + ConfirmationPolicyHash: "c1", + ProjectContextHash: "p1", + SkillsHash: "s1", + ToolsHash: "t1", + SchemaHash: "x1", + CompletePrefixHash: "complete1", + }) + r.EmitPrefixHash(PrefixHash{ + BaseInstructionsHash: "b2", + ConfirmationPolicyHash: "c2", + ProjectContextHash: "p2", + SkillsHash: "s2", + ToolsHash: "t2", + SchemaHash: "x2", + CompletePrefixHash: "complete2", + }) original := r.Finish() var buf bytes.Buffer @@ -398,6 +419,20 @@ func TestReadNDJSONRoundTrip(t *testing.T) { if parsed.FirstTokenAt.IsZero() { t.Fatal("first_token_at lost in round-trip") } + // prefix_hash round-trip: two events, in insertion order, with all + // seven sub-hash fields preserved exactly. + if len(parsed.PrefixHashes) != 2 { + t.Fatalf("expected 2 prefix_hash events after round-trip, got %d", len(parsed.PrefixHashes)) + } + if parsed.PrefixHashes[0].CompletePrefixHash != "complete1" || parsed.PrefixHashes[1].CompletePrefixHash != "complete2" { + t.Fatalf("prefix_hash insertion order lost: got %+v want [complete1, complete2]", parsed.PrefixHashes) + } + if parsed.PrefixHashes[0].BaseInstructionsHash != "b1" || parsed.PrefixHashes[0].SchemaHash != "x1" { + t.Fatalf("prefix_hash sub-hashes lost on first event: got %+v", parsed.PrefixHashes[0]) + } + if parsed.PrefixHashes[1].BaseInstructionsHash != "b2" || parsed.PrefixHashes[1].SchemaHash != "x2" { + t.Fatalf("prefix_hash sub-hashes lost on second event: got %+v", parsed.PrefixHashes[1]) + } } func TestReadNDJSONRejectsNonTrace(t *testing.T) { @@ -411,10 +446,12 @@ func TestReadNDJSONRejectsNonTrace(t *testing.T) { } func TestReadNDJSONRejectsHeaderOnly(t *testing.T) { - // A header with no recoverable spans/counters is corrupt/truncated, not empty. + // A header with no recoverable spans, counters, or prefix hashes is + // corrupt/truncated, not empty. prefix_hash is now a third valid + // event type, so a header with only prefix_hash events is accepted. header := `{"type":"trace","name":"run","session_id":"s","run_id":"r"}` + "\n" if _, err := ReadNDJSON(strings.NewReader(header)); err == nil { - t.Fatal("expected error for a trace header with no spans or counters") + t.Fatal("expected error for a trace header with no spans, counters, or prefix hashes") } }