From 706cbd5df4ccdfad347b19eaef4d462c26801256 Mon Sep 17 00:00:00 2001 From: MMinasyan Date: Sat, 24 Oct 2026 23:28:00 +0400 Subject: [PATCH 1/3] feat(engine): bind tool dispatch to each prepared request --- agent/contract.go | 20 ++-- agent/foundation_composition_test.go | 21 ++-- agent/run.go | 29 +++-- agent/run_cancel_test.go | 4 +- agent/run_loop_test.go | 73 +++++++----- agent/run_test.go | 164 ++++++++++++++++++++++----- harness/admission_test.go | 2 +- harness/codec_test.go | 48 +++++++- harness/compact.go | 9 +- harness/context.go | 28 +++-- harness/context_test.go | 18 +-- harness/effect.go | 135 ++++++++++++---------- harness/effect_test.go | 96 ++++++++++------ harness/graph_test.go | 15 ++- harness/hooks_test.go | 50 +++++--- harness/model_effect_test.go | 2 +- harness/observe_test.go | 12 +- harness/payload.go | 66 +++++++++-- harness/public_test.go | 36 ++++-- harness/schema.go | 16 ++- harness/snapshot.go | 3 + internal/storage/sqlite.go | 6 +- internal/storage/sqlite_test.go | 40 +++---- runtime/compact_lifecycle_test.go | 8 +- runtime/protocol_paging_test.go | 3 +- 25 files changed, 614 insertions(+), 290 deletions(-) diff --git a/agent/contract.go b/agent/contract.go index b76d08ae..84193fcd 100644 --- a/agent/contract.go +++ b/agent/contract.go @@ -1,6 +1,6 @@ // Package agent defines the caller-driven model/tool effect contracts of the // fixed Lightcode Agent, its one stream assembler, and its execution loop. A -// later Harness layer supplies plain functions for context, model effects, and +// later Harness layer supplies plain functions for requests, model effects, and // tool effects; this package owns turning an accepted model.Stream into exactly // one finalized model.Output, sequencing those caller boundaries under one // cancellation invariant, and validating what those @@ -18,13 +18,18 @@ import ( ) type ( - ContextSource func(context.Context) ([]model.Message, error) + // RequestSource supplies one complete logical request per Engine round; + // the Engine validates it and owns the validated copy from that point on. + RequestSource func(context.Context) (model.Request, error) AssemblyCallback func(model.ModelRef, model.Stream) (model.Output, error) ModelEffect func(context.Context, model.Request, AssemblyCallback) (ModelSettlement, error) - ToolEffect func(context.Context, model.ToolCall) (model.ToolResult, error) + // ToolEffect runs one started call. The names are an owned copy of the + // producing request's advertised tool names, independent of the mutable + // request handed to the model effect. + ToolEffect func(context.Context, model.ToolCall, []string) (model.ToolResult, error) ) type ModelDisposition string @@ -64,11 +69,10 @@ func newBoundaryViolation(boundary, detail string) *ProtocolError { } type Invocation struct { - ExpectedModel model.ModelRef // complete identity every callback-produced source must equal for the whole run. - Tools []model.ToolDefinition // owned immutable advertised tools; names unique, parameters object-shaped as validated at their own accepting boundary. - Context ContextSource // nil is a protocol violation at invocation entry — never normalized to any other context. - ModelEffect ModelEffect // exactly one per invocation; its settlements are validated against the closed disposition table before anything proceeds from them. - ToolEffect ToolEffect // invoked once per started call in assembled order; nil is likewise rejected at entry. + ExpectedModel model.ModelRef // complete identity every callback-produced source must equal for the whole run. + Request RequestSource // supplies one complete request per round; nil is a protocol violation at invocation entry — never normalized to any other request. + ModelEffect ModelEffect // exactly one per invocation; its settlements are validated against the closed disposition table before anything proceeds from them. + ToolEffect ToolEffect // invoked once per started call in assembled order, carrying the producing request's advertised names; nil is likewise rejected at entry. } type ModelSettlement struct { diff --git a/agent/foundation_composition_test.go b/agent/foundation_composition_test.go index c8ed2b29..ac164a7b 100644 --- a/agent/foundation_composition_test.go +++ b/agent/foundation_composition_test.go @@ -36,7 +36,7 @@ data: [DONE] ` -// TestFoundationCompositionOverRealTransport composes the two public packages exactly as a future caller will own them, from outside both packages and with no production adapter or test-only production API: a real model.Transport speaking to an httptest OpenAI-compatible SSE server, the normal Agent assembly callback invoked from inside a test model effect, a fake tool effect, a context source whose snapshot changes between the two model requests, and the complete Agent run loop over one tool round and a final text response. +// TestFoundationCompositionOverRealTransport composes the two public packages exactly as a future caller will own them, from outside both packages and with no production adapter or test-only production API: a real model.Transport speaking to an httptest OpenAI-compatible SSE server, the normal Agent assembly callback invoked from inside a test model effect, a fake tool effect, a request source whose snapshot changes between the two model requests, and the complete Agent run loop over one tool round and a final text response. func TestFoundationCompositionOverRealTransport(t *testing.T) { ref := model.ModelRef{Provider: "acme", Model: "m-1"} @@ -71,15 +71,16 @@ func TestFoundationCompositionOverRealTransport(t *testing.T) { ToolCallID: "call-1", Content: []model.ContentPart{{Kind: model.PartText, Text: "echoed"}}, })) - snapshots := [][]model.Message{base, roundTrip} + requests := [][]model.Message{base, roundTrip} + advertised := []model.ToolDefinition{{Name: "echo", Description: "echo its argument", Parameters: json.RawMessage(`{"type":"object"}`)}} sourceCalls := 0 - contextSource := func(context.Context) ([]model.Message, error) { + requestSource := func(context.Context) (model.Request, error) { sourceCalls++ - if sourceCalls > len(snapshots) { - return nil, fmt.Errorf("context source ran out of snapshots after %d calls", sourceCalls) + if sourceCalls > len(requests) { + return model.Request{}, fmt.Errorf("request source ran out of snapshots after %d calls", sourceCalls) } - return snapshots[sourceCalls-1], nil + return model.Request{Messages: requests[sourceCalls-1], Tools: advertised}, nil } var reqs []model.Request @@ -97,15 +98,17 @@ func TestFoundationCompositionOverRealTransport(t *testing.T) { } var dispatched []model.ToolCall - toolEffect := func(_ context.Context, call model.ToolCall) (model.ToolResult, error) { + toolEffect := func(_ context.Context, call model.ToolCall, names []string) (model.ToolResult, error) { dispatched = append(dispatched, call) + if len(names) != 1 || names[0] != "echo" { + return model.ToolResult{}, fmt.Errorf("dispatch names = %v, want the producing request's [echo]", names) + } return model.ToolResult{CallID: call.ID, Status: model.ResultSuccess, Content: "echoed"}, nil } res, err := agent.Run(context.Background(), agent.Invocation{ ExpectedModel: ref, - Tools: []model.ToolDefinition{{Name: "echo", Description: "echo its argument", Parameters: json.RawMessage(`{"type":"object"}`)}}, - Context: contextSource, + Request: requestSource, ModelEffect: modelEffect, ToolEffect: toolEffect, }) diff --git a/agent/run.go b/agent/run.go index 99698e5b..c44bc8e2 100644 --- a/agent/run.go +++ b/agent/run.go @@ -11,7 +11,7 @@ import ( // Agent-observed run-context cancellation and interrupted tool results share this detail. const detailInterrupted = "agent interrupted" -// Run performs one caller-driven Agent invocation over the three supplied boundaries: it validates every entry value before anything runs, then repeatedly obtains a fresh context snapshot, invokes one model effect under per-effect callback discipline (expected identity before assembly, exactly one accepted callback behind every settled output and none behind an outputless settlement, with any callback failure remembered and never ignorable), and dispatches the resulting tool batch sequentially in assembled order until a terminal result settles. One cancellation invariant governs the whole loop: the run context is checked before every caller effect starts, observed cancellation returns an interruption that never discards an already-settled authoritative value, and a non-nil caller error comes back verbatim with no terminal result (a cancellation-shaped error out of an active effect is instead a boundary-protocol violation, because active effects settle cancellation through their settlement). Agent retains no state after the invocation returns and owns no retry, persistence, events, signals, adaptation, tool-lookup, or history behavior. +// Run performs one caller-driven Agent invocation over the three supplied boundaries: it validates every entry value before anything runs, then repeatedly obtains a fresh request from the caller's request source, validates and owns it, extracts the request's advertised tool names before handing the owned request to one model effect under per-effect callback discipline (expected identity before assembly, exactly one accepted callback behind every settled output and none behind an outputless settlement, with any callback failure remembered and never ignorable), and dispatches the resulting tool batch sequentially in assembled order against that request's names until a terminal result settles. One cancellation invariant governs the whole loop: the run context is checked before every caller effect starts, observed cancellation returns an interruption that never discards an already-settled authoritative value, and a non-nil caller error comes back verbatim with no terminal result (a cancellation-shaped error out of an active effect is instead a boundary-protocol violation, because active effects settle cancellation through their settlement). Agent retains no state after the invocation returns and owns no retry, persistence, events, signals, adaptation, tool-lookup, or history behavior. func Run(ctx context.Context, inv Invocation) (TerminalResult, error) { if ctx == nil { // rejected outright: a nil run context is never normalized to any other context. return TerminalResult{}, newBoundaryViolation("agent", "run requires a non-nil run context") @@ -19,12 +19,8 @@ func Run(ctx context.Context, inv Invocation) (TerminalResult, error) { if !nonzeroSource(inv.ExpectedModel) { return TerminalResult{}, newBoundaryViolation("agent", "run requires a nonzero expected model identity") } - if inv.Context == nil || inv.ModelEffect == nil || inv.ToolEffect == nil { - return TerminalResult{}, newBoundaryViolation("agent", "run requires non-nil context, model and tool effect functions") - } - advertised, err := model.NewRequest(model.Request{Tools: inv.Tools}) // non-empty unique names and object-shaped schemas are exactly what the landed request constructor enforces; its owned deep copy is the immutable advertised list for every later request. - if err != nil { - return TerminalResult{}, fmt.Errorf("%w: %v", newBoundaryViolation("agent", "advertised tools are invalid"), err) + if inv.Request == nil || inv.ModelEffect == nil || inv.ToolEffect == nil { + return TerminalResult{}, newBoundaryViolation("agent", "run requires non-nil request, model and tool effect functions") } var lastSettled *model.Output // the most recent authoritative model settlement; only a completed one may be carried by a later interruption. @@ -34,28 +30,27 @@ func Run(ctx context.Context, inv Invocation) (TerminalResult, error) { return retainedInterruption(lastSettled, nil) } - msgs, err := inv.Context(ctx) + req, err := inv.Request(ctx) if err != nil { if isCancellation(err) && ctx.Err() != nil { // a cancellation report maps to interruption only under a genuinely done run context. return retainedInterruption(lastSettled, nil) } return TerminalResult{}, err // any other non-nil source error is infrastructure and wins over concurrent cancellation: returned with no terminal result. } - req, err := model.NewRequest(model.Request{Messages: msgs}) // fresh context messages validate and own at this trust boundary; the advertised tools were entry-validated once and attach below without repeating schema validation. + owned, err := model.NewRequest(req) // one validation point per round covering the supplied messages and tools; the owned deep copy is independent of everything the source retains. if err != nil { - return TerminalResult{}, fmt.Errorf("%w: %v", newBoundaryViolation("agent", "context snapshot is invalid"), err) + return TerminalResult{}, fmt.Errorf("%w: %v", newBoundaryViolation("agent", "request is invalid"), err) } - req.Tools = make([]model.ToolDefinition, len(advertised.Tools)) // one fresh deep copy of the entry-validated tool list per request: model-effect callbacks never gain an alias to the immutable advertised baseline, so their mutations cannot reach later requests or the invocation input. - for i, def := range advertised.Tools { - def.Parameters = model.CloneRaw(def.Parameters) - req.Tools[i] = def + names := make([]string, len(owned.Tools)) // the round's advertised names, copied out of the owned request before the model effect may mutate it. + for i, def := range owned.Tools { + names[i] = def.Name } if ctx.Err() != nil { return retainedInterruption(lastSettled, nil) } cb := &callbackProtocol{ctx: ctx, expected: inv.ExpectedModel} // one observation scope per logical model effect; its callback closes over the run context so no caller can substitute another cancellation channel. - set, err := inv.ModelEffect(ctx, req, cb.assemble) + set, err := inv.ModelEffect(ctx, owned, cb.assemble) if err != nil { if isCancellation(err) { // an active model effect settles cancellation through an interruption disposition, never through a Go error. return TerminalResult{}, newBoundaryViolation("model", "model effect reported cancellation as an error instead of an interruption settlement") @@ -96,7 +91,9 @@ func Run(ctx context.Context, inv Invocation) (TerminalResult, error) { if ctx.Err() != nil { // no later effect starts: this call and everything after it remain unstarted. return interruptionTerminal(detailInterrupted, set.Output, calls[i:]) } - res, err := inv.ToolEffect(ctx, call) + call.Arguments = model.CloneRaw(call.Arguments) // the tool boundary receives owned containers cloned from the retained settled output, so its mutations cannot rewrite it. + call.Extra = call.Extra.Clone() + res, err := inv.ToolEffect(ctx, call, names) if err != nil { if isCancellation(err) { // an active tool effect settles cancellation through an interrupted result, never through a Go error. return TerminalResult{}, newBoundaryViolation("tool", "tool effect reported cancellation as an error instead of an interrupted result") diff --git a/agent/run_cancel_test.go b/agent/run_cancel_test.go index b1b92b2b..0f87c72c 100644 --- a/agent/run_cancel_test.go +++ b/agent/run_cancel_test.go @@ -61,8 +61,8 @@ func TestRunCancelledDuringSnapshot(t *testing.T) { testassert.Require(t, f.sourceCalls == 1 && f.modelCalls == 0 && f.toolCalls == 0, "source=%d model=%d tool=%d, want 1/0/0", f.sourceCalls, f.modelCalls, f.toolCalls) } -// TestRunContextSourceCancellationRouting pins both halves of the source rule: a cancellation report under a genuinely done run context maps to Agent interruption, while the same-shaped report under a live context is an ordinary infrastructure error returned verbatim with no terminal. -func TestRunContextSourceCancellationRouting(t *testing.T) { +// TestRunRequestSourceCancellationRouting pins both halves of the source rule: a cancellation report under a genuinely done run context maps to Agent interruption, while the same-shaped report under a live context is an ordinary infrastructure error returned verbatim with no terminal. +func TestRunRequestSourceCancellationRouting(t *testing.T) { wrapped := func() error { return fmt.Errorf("snapshot read: %w", context.Canceled) } t.Run("done-context-interruption", func(t *testing.T) { diff --git a/agent/run_loop_test.go b/agent/run_loop_test.go index a3bd1e89..a5f75983 100644 --- a/agent/run_loop_test.go +++ b/agent/run_loop_test.go @@ -44,41 +44,62 @@ func TestRunContinuationUsesFreshSnapshotPerEffect(t *testing.T) { testassert.Require(t, res.LastOutput != nil && res.LastOutput.Status == model.OutputCompleted, "last output = %#v, want the final completed settlement", res.LastOutput) } -// TestRunRequestToolsAreFreshCopiesPerEffect pins the per-request tool ownership rule: every model effect receives its own deep copy of the entry-validated advertised list — slice and Parameters bytes — so a model-effect callback mutating the handed request's tool slice and bytes in place cannot reach the immutable advertised baseline, any later request, or the invocation input. +// TestRunRequestToolsAreFreshCopiesPerEffect pins the per-request ownership rule: every model effect receives its own validated owned copy — tool slice and Parameters bytes, and messages alike — and the round's advertised dispatch names are extracted from that copy before the effect runs, so a model effect mutating the handed request's tool definitions and message text in place cannot alter the names its own round dispatches, any later round's request, or the data the request source itself retains. Different rounds advertise different subsets. func TestRunRequestToolsAreFreshCopiesPerEffect(t *testing.T) { f := &runFakes{} - advertised := advTools() - inv := f.invocation() - inv.Tools = advertised - var handed []model.ToolDefinition // what each request carried before the callback corrupted it - f.modelSet = func(call int, req model.Request, cb AssemblyCallback) (ModelSettlement, error) { - handed = append(handed, model.ToolDefinition{Name: req.Tools[0].Name, Parameters: append([]byte(nil), req.Tools[0].Parameters...)}) - if len(req.Tools) > 0 { // corrupt the handed request's tool entry in place: element fields and Parameters bytes. + roundTools := [][]model.ToolDefinition{ + { + {Name: "t", Description: "d", Parameters: json.RawMessage(`{"type":"object"}`)}, + {Name: "u", Description: "d", Parameters: json.RawMessage(`{"type":"object"}`)}, + }, + {{Name: "t", Description: "d", Parameters: json.RawMessage(`{"type":"object"}`)}}, + } + f.tools = func(call int) ([]model.ToolDefinition, error) { + if call > len(roundTools) { + return nil, errScriptExhausted + } + return roundTools[call-1], nil + } + sourceMsgs := userSnapshot("go") // one snapshot shared by every round: an alias from a handed request back into it would reach every later round and this baseline. + f.snapshot = func(int) ([]model.Message, error) { return sourceMsgs, nil } + + callsOut := mkCallsTerminalOutput() // the first round settles a dispatched batch; the second ends with final text. + f.modelSet = func(call int, req model.Request, _ AssemblyCallback) (ModelSettlement, error) { + if call == 1 { // corrupt the handed round-1 request in place: tool name, Parameters bytes, and message text. req.Tools[0].Name = "mutated" - if len(req.Tools[0].Parameters) > 0 { - req.Tools[0].Parameters[0] = 'X' - } + req.Tools[0].Parameters[0] = 'X' + req.Messages[0].Content[0].Text = "mutated" + return ModelSettlement{Disposition: DispoReady, Output: callsOut}, nil } - return setScript( - ModelSettlement{Disposition: DispoContinue, Output: mkOutput(model.OutputErrored, "boom", nil)}, - ModelSettlement{Disposition: DispoReady, Output: mkOutput(model.OutputCompleted, "", nil)}, - )(call, req, cb) + return ModelSettlement{Disposition: DispoReady, Output: mkOutput(model.OutputCompleted, "", nil)}, nil } - res, err := Run(context.Background(), inv) + res, err := Run(context.Background(), f.invocation()) testassert.Require(t, err == nil && res.Status == TerminalSuccess, "run = %#v, %v; want success", res, err) - testassert.Equal(t, f.modelCalls, 2) - // Every request started from the pristine advertised values. - for i, got := range handed { - testassert.Require(t, got.Name == "t" && string(got.Parameters) == `{"type":"object"}`, "request %d carried a mutated tool: %#v", i+1, got) + testassert.Require(t, f.sourceCalls == 2 && f.modelCalls == 2 && f.toolCalls == 3, "source=%d model=%d tool=%d, want 2/2/3", f.sourceCalls, f.modelCalls, f.toolCalls) + + // The round-1 mutation landed in the handed request — every pristine assertion below is not vacuous. + testassert.Require(t, f.reqs[0].Tools[0].Name == "mutated" && f.reqs[0].Tools[0].Parameters[0] == 'X' && f.reqs[0].Messages[0].TextContent() == "mutated", "recorded round-1 request shows no mutation: %#v", f.reqs[0]) + // Round 1's dispatch names are the request's advertisement as extracted before its model effect ran. + testassert.Require(t, len(f.ads) == 3 && len(f.ads[0]) == 2 && f.ads[0][0] == "t" && f.ads[0][1] == "u", "round-1 dispatch names = %v, want the pristine [t u] advertisement", f.ads[0]) + // Round 2 carries its own pristine single-name subset and an intact snapshot. + testassert.Require(t, len(f.reqs[1].Tools) == 1 && f.reqs[1].Tools[0].Name == "t" && string(f.reqs[1].Tools[0].Parameters) == `{"type":"object"}`, "round-2 tools = %#v, want the pristine subset", f.reqs[1].Tools) + testassert.Require(t, f.reqs[1].Messages[0].TextContent() == "go", "round-2 message = %q, want the intact snapshot", f.reqs[1].Messages[0].TextContent()) + // The source's own data keeps exactly its entry values. + testassert.Require(t, sourceMsgs[0].Content[0].Text == "go", "the source's snapshot was mutated through a handed request: %q", sourceMsgs[0].Content[0].Text) + for i, want := range []model.ToolDefinition{{Name: "t", Parameters: json.RawMessage(`{"type":"object"}`)}, {Name: "u", Parameters: json.RawMessage(`{"type":"object"}`)}} { + if i >= len(roundTools[0]) { + break + } + testassert.Require(t, roundTools[0][i].Name == want.Name && string(roundTools[0][i].Parameters) == string(want.Parameters), "source round-1 tool %d mutated: %#v", i, roundTools[0][i]) } - // The mutation actually landed in the recorded requests — the check above is not vacuous. - testassert.Require(t, f.reqs[0].Tools[0].Name == "mutated" && f.reqs[1].Tools[0].Name == "mutated", "recorded requests show %q/%q, want the callback's in-place mutations", f.reqs[0].Tools[0].Name, f.reqs[1].Tools[0].Name) - // The invocation input keeps exactly its own entry values. - testassert.Require(t, advertised[0].Name == "t" && string(advertised[0].Parameters) == `{"type":"object"}`, "advertised baseline mutated through a request alias: %#v", advertised[0]) - // Each request owns independent storage: no request aliases the advertised bytes, and successive requests share nothing. - testassert.Require(t, &f.reqs[0].Tools[0].Parameters[0] != &advertised[0].Parameters[0], "%v", "a request gained an alias to the advertised baseline bytes") + + // No alias from a callback into source data or a later request: every round owns independent storage. + testassert.Require(t, &f.reqs[0].Tools[0].Parameters[0] != &roundTools[0][0].Parameters[0], "%v", "a request gained an alias to the source's tool bytes") + testassert.Require(t, &f.reqs[0].Tools[1].Parameters[0] != &roundTools[0][1].Parameters[0], "%v", "a request gained an alias to the source's tool bytes") + testassert.Require(t, &f.reqs[1].Tools[0].Parameters[0] != &roundTools[1][0].Parameters[0], "%v", "a request gained an alias to the source's tool bytes") testassert.Require(t, &f.reqs[0].Tools[0].Parameters[0] != &f.reqs[1].Tools[0].Parameters[0], "%v", "successive requests share tool Parameters backing") + testassert.Require(t, &f.reqs[0].Messages[0].Content[0] != &sourceMsgs[0].Content[0], "%v", "a request gained an alias to the source's message content") } // TestRunToolBatchSettlesInOrderThenContinues pins sequential in-order dispatch with a settled complete non-interrupted batch feeding the next fresh-context effect, which then ends the run. diff --git a/agent/run_test.go b/agent/run_test.go index ac458a9c..8bbd9cf7 100644 --- a/agent/run_test.go +++ b/agent/run_test.go @@ -20,6 +20,7 @@ var errScriptExhausted = errors.New("caller fake script exhausted") // runFakes scripts and records the three caller boundaries of one Invocation: every call is counted, requests and started calls are recorded in order, and a script function receives the 1-based invocation number. Exhausting a script fails the run through the recorded counters rather than silently extending it. A script that settles an output-bearing disposition without touching the callback receives one protocol-plain callback invocation (testRef over a minimal valid stream) unless noCallbackFill is set — scripts that exercise the callback protocol itself manage the callback explicitly. type runFakes struct { snapshot func(call int) ([]model.Message, error) + tools func(call int) ([]model.ToolDefinition, error) modelSet func(call int, req model.Request, cb AssemblyCallback) (ModelSettlement, error) toolRes func(call int, c model.ToolCall) (model.ToolResult, error) noCallbackFill bool @@ -27,16 +28,26 @@ type runFakes struct { modelCalls int toolCalls int reqs []model.Request + ads [][]string calls []model.ToolCall } -func (f *runFakes) context() ContextSource { - return func(context.Context) ([]model.Message, error) { +func (f *runFakes) request() RequestSource { + return func(context.Context) (model.Request, error) { f.sourceCalls++ - if f.snapshot == nil { - return userSnapshot("go"), nil + msgs, tools := userSnapshot("go"), advTools() + var err error + if f.snapshot != nil { + if msgs, err = f.snapshot(f.sourceCalls); err != nil { + return model.Request{}, err + } + } + if f.tools != nil { + if tools, err = f.tools(f.sourceCalls); err != nil { + return model.Request{}, err + } } - return f.snapshot(f.sourceCalls) + return model.Request{Messages: msgs, Tools: tools}, nil } } @@ -64,9 +75,10 @@ func (f *runFakes) model() ModelEffect { } func (f *runFakes) tool() ToolEffect { - return func(_ context.Context, c model.ToolCall) (model.ToolResult, error) { + return func(_ context.Context, c model.ToolCall, names []string) (model.ToolResult, error) { f.toolCalls++ f.calls = append(f.calls, c) + f.ads = append(f.ads, names) if f.toolRes == nil { return model.ToolResult{CallID: c.ID, Status: model.ResultSuccess}, nil } @@ -75,7 +87,7 @@ func (f *runFakes) tool() ToolEffect { } func (f *runFakes) invocation() Invocation { - return Invocation{ExpectedModel: testRef, Tools: advTools(), Context: f.context(), ModelEffect: f.model(), ToolEffect: f.tool()} + return Invocation{ExpectedModel: testRef, Request: f.request(), ModelEffect: f.model(), ToolEffect: f.tool()} } func userSnapshot(text string) []model.Message { @@ -154,7 +166,7 @@ func requireRunInterrupted(t *testing.T, res TerminalResult, err error, wantDeta testassert.Require(t, res.Status == TerminalInterruption && res.Detail == wantDetail, "interruption = %s/%q, want interruption/%q", res.Status, res.Detail, wantDetail) } -// TestRunInvalidInvocation pins every invalid entry value returning the typed boundary-protocol error with boundary "agent" and a zero terminal result before any caller function runs: a nil run context is rejected, never normalized, and the same no-run guarantee holds for every other row. +// TestRunInvalidInvocation pins every invalid entry value returning the typed boundary-protocol error with boundary "agent" and a zero terminal result before any caller function runs: a nil run context is rejected, never normalized, and the same no-run guarantee holds for every other row. Invalid per-round requests are rejected at the round's single validation point before any model or tool boundary runs. func TestRunInvalidInvocation(t *testing.T) { rows := []struct { name string @@ -164,25 +176,9 @@ func TestRunInvalidInvocation(t *testing.T) { {"nil-run-context", func() context.Context { return nil }, nil}, {"zero-expected-model", nil, func(inv *Invocation) { inv.ExpectedModel = model.ModelRef{} }}, {"partial-expected-model", nil, func(inv *Invocation) { inv.ExpectedModel = model.ModelRef{Provider: "acme"} }}, - {"nil-context-source", nil, func(inv *Invocation) { inv.Context = nil }}, + {"nil-request-source", nil, func(inv *Invocation) { inv.Request = nil }}, {"nil-model-effect", nil, func(inv *Invocation) { inv.ModelEffect = nil }}, {"nil-tool-effect", nil, func(inv *Invocation) { inv.ToolEffect = nil }}, - {"empty-tool-name", nil, func(inv *Invocation) { - inv.Tools = []model.ToolDefinition{{Name: "", Parameters: json.RawMessage(`{}`)}} - }}, - {"duplicate-tool-names", nil, func(inv *Invocation) { - inv.Tools = []model.ToolDefinition{{Name: "t", Parameters: json.RawMessage(`{}`)}, {Name: "t", Parameters: json.RawMessage(`{}`)}} - }}, - {"array-parameters", nil, func(inv *Invocation) { - inv.Tools = []model.ToolDefinition{{Name: "t", Parameters: json.RawMessage(`[1,2]`)}} - }}, - {"primitive-parameters", nil, func(inv *Invocation) { - inv.Tools = []model.ToolDefinition{{Name: "t", Parameters: json.RawMessage(`3`)}} - }}, - {"null-parameters", nil, func(inv *Invocation) { - inv.Tools = []model.ToolDefinition{{Name: "t", Parameters: json.RawMessage(`null`)}} - }}, - {"absent-parameters", nil, func(inv *Invocation) { inv.Tools = []model.ToolDefinition{{Name: "t"}} }}, } for _, row := range rows { @@ -203,6 +199,58 @@ func TestRunInvalidInvocation(t *testing.T) { testassert.Require(t, f.sourceCalls == 0 && f.modelCalls == 0 && f.toolCalls == 0, "rejected invocation ran caller functions: source=%d model=%d tool=%d", f.sourceCalls, f.modelCalls, f.toolCalls) }) } + + roundRows := []struct { + name string + req func(f *runFakes) + }{ + {"invalid-message", func(f *runFakes) { + f.snapshot = func(int) ([]model.Message, error) { + return []model.Message{{Role: "bogus", Content: []model.ContentPart{{Kind: model.PartText, Text: "x"}}}}, nil + } + }}, + {"empty-tool-name", func(f *runFakes) { + f.tools = func(int) ([]model.ToolDefinition, error) { + return []model.ToolDefinition{{Name: "", Parameters: json.RawMessage(`{}`)}}, nil + } + }}, + {"duplicate-tool-names", func(f *runFakes) { + f.tools = func(int) ([]model.ToolDefinition, error) { + return []model.ToolDefinition{{Name: "t", Parameters: json.RawMessage(`{}`)}, {Name: "t", Parameters: json.RawMessage(`{}`)}}, nil + } + }}, + {"array-parameters", func(f *runFakes) { + f.tools = func(int) ([]model.ToolDefinition, error) { + return []model.ToolDefinition{{Name: "t", Parameters: json.RawMessage(`[1,2]`)}}, nil + } + }}, + {"primitive-parameters", func(f *runFakes) { + f.tools = func(int) ([]model.ToolDefinition, error) { + return []model.ToolDefinition{{Name: "t", Parameters: json.RawMessage(`3`)}}, nil + } + }}, + {"null-parameters", func(f *runFakes) { + f.tools = func(int) ([]model.ToolDefinition, error) { + return []model.ToolDefinition{{Name: "t", Parameters: json.RawMessage(`null`)}}, nil + } + }}, + {"absent-parameters", func(f *runFakes) { + f.tools = func(int) ([]model.ToolDefinition, error) { + return []model.ToolDefinition{{Name: "t"}}, nil + } + }}, + } + for _, row := range roundRows { + t.Run(row.name, func(t *testing.T) { + f := &runFakes{} + row.req(f) + + res, err := Run(context.Background(), f.invocation()) + requireBoundaryViolation(t, err, "agent") + requireZeroTerminal(t, res) + testassert.Require(t, f.modelCalls == 0 && f.toolCalls == 0, "rejected request ran caller effects: model=%d tool=%d", f.modelCalls, f.toolCalls) + }) + } } // TestRunSuccessSingleEffect pins the clean end of a minimal run: one fresh snapshot, one counted model effect whose request carries the validated advertised tools, and a success terminal whose last output is the settlement's own transferred pointer — no second copy. @@ -219,17 +267,17 @@ func TestRunSuccessSingleEffect(t *testing.T) { testassert.Require(t, len(f.reqs[0].Messages) == 1 && f.reqs[0].Messages[0].TextContent() == "go", "request messages = %#v, want the fresh snapshot", f.reqs[0].Messages) } -// TestRunNoAdvertisedToolsSucceeds pins that an absent advertised list is a valid entry value — tool advertisement selection is not Agent's to make. +// TestRunNoAdvertisedToolsSucceeds pins that a request with an absent advertised list is a valid round value — tool advertisement selection is not Agent's to make. func TestRunNoAdvertisedToolsSucceeds(t *testing.T) { f := &runFakes{} out := mkOutput(model.OutputCompleted, "", nil) f.modelSet = readyOutputs(out) - inv := f.invocation() - inv.Tools = nil + f.tools = func(int) ([]model.ToolDefinition, error) { return nil, nil } - res, err := Run(context.Background(), inv) + res, err := Run(context.Background(), f.invocation()) testassert.Require(t, err == nil && res.Status == TerminalSuccess, "run = %#v, %v; want success", res, err) testassert.Require(t, len(f.reqs[0].Tools) == 0, "request carries tools %#v", f.reqs[0].Tools) + testassert.Require(t, len(f.ads) == 0 && f.toolCalls == 0, "dispatch names = %v tool calls = %d, want none", f.ads, f.toolCalls) } // TestRunAssemblyCallbackProducesSettlement pins the callback wiring: the model effect receives a working assembler bound to the run context, its returned output settles as a ready disposition verbatim, and the handed-over stream is released exactly once. @@ -271,7 +319,7 @@ func TestRunSubstitutedValidSettlementOutputAccepted(t *testing.T) { testassert.Equal(t, stream.closeCount, 1) } -// TestRunRawToolArgumentsPassUnchanged pins byte-for-byte argument transit through every JSON shape — object, array, null, malformed, and absent — into the caller-owned tool boundary; Agent never decodes, rejects, or rewrites them, and the whole batch continues to a final text response. +// TestRunRawToolArgumentsPassUnchanged pins byte-for-byte argument transit through every JSON shape — object, array, null, malformed, and absent — into the caller-owned tool boundary; Agent never decodes, rejects, or rewrites them, and the whole batch continues to a final text response. The final rows pin the accepting-boundary ownership at dispatch: a tool effect mutating the argument bytes and extra values it received in place cannot rewrite the settled output's own call records — neither a success terminal's last output nor an interruption terminal's retained output and unstarted suffix. func TestRunRawToolArgumentsPassUnchanged(t *testing.T) { args := []json.RawMessage{json.RawMessage(`{"a":[1,{"b":2}]}`), json.RawMessage(`[1,2]`), json.RawMessage(`null`), json.RawMessage(`{oops`), nil} calls := make([]model.ToolCall, 0, len(args)) @@ -290,4 +338,60 @@ func TestRunRawToolArgumentsPassUnchanged(t *testing.T) { testassert.Require(t, string(seen.Arguments) == string(want) && len(seen.Arguments) == len(want), "call %d arguments = %q, want verbatim %q", i, string(seen.Arguments), string(want)) testassert.Require(t, seen.ID == calls[i].ID && seen.Name == calls[i].Name, "call %d identity = %q/%q, want %q/%q", i, seen.ID, seen.Name, calls[i].ID, calls[i].Name) } + + // A tool effect mutating the accepted call's argument bytes and extra values in place leaves the settled output's own records untouched. + arg := json.RawMessage(`{"keep":1}`) + mutCalls := []model.ToolCall{ + {ID: "u0", Name: "fn0", Arguments: arg, Extra: model.Extra{"k": json.RawMessage(`1`)}}, + {ID: "u1", Name: "fn1", Arguments: arg, Extra: model.Extra{"k": json.RawMessage(`1`)}}, + } + mutOut := mkOutput(model.OutputCompleted, "", mutCalls) + fm := &runFakes{} + fm.modelSet = readyOutputs(mutOut, mkOutput(model.OutputCompleted, "", nil)) + fm.toolRes = func(_ int, c model.ToolCall) (model.ToolResult, error) { + if len(c.Arguments) > 0 { + c.Arguments[0] = 'Z' + } + c.Extra["k"] = json.RawMessage(`999`) + return model.ToolResult{CallID: c.ID, Status: model.ResultSuccess}, nil + } + res, err = Run(context.Background(), fm.invocation()) + testassert.Require(t, err == nil && res.Status == TerminalSuccess, "run = %#v, %v; want success", res, err) + testassert.Equal(t, fm.toolCalls, 2) + for i, seen := range fm.calls { + testassert.Require(t, string(seen.Arguments) == `Z"keep":1}` && string(seen.Extra["k"]) == `999`, "dispatched call %d shows no mutation: arguments=%q extra=%q", i, string(seen.Arguments), string(seen.Extra["k"])) + } + for i, oc := range mutOut.Message.ToolCalls { + testassert.Require(t, string(oc.Arguments) == `{"keep":1}`, "settled output call %d arguments = %q, want the unmutated %q", i, string(oc.Arguments), `{"keep":1}`) + testassert.Require(t, string(oc.Extra["k"]) == `1`, "settled output call %d extra k = %q, want the unmutated 1", i, string(oc.Extra["k"])) + } + + // The same guarantee for an interruption terminal: the mutated dispatched call settles interrupted, the retained output and the unstarted suffix carry the original bytes. + intOut := mkOutput(model.OutputCompleted, "", []model.ToolCall{ + {ID: "v0", Name: "fn0", Arguments: arg, Extra: model.Extra{"k": json.RawMessage(`1`)}}, + {ID: "v1", Name: "fn1", Arguments: arg, Extra: model.Extra{"k": json.RawMessage(`1`)}}, + {ID: "v2", Name: "fn2", Arguments: arg, Extra: model.Extra{"k": json.RawMessage(`1`)}}, + }) + fi := &runFakes{} + fi.modelSet = readyOutputs(intOut) + fi.toolRes = func(call int, c model.ToolCall) (model.ToolResult, error) { + if len(c.Arguments) > 0 { + c.Arguments[0] = 'Z' + } + c.Extra["k"] = json.RawMessage(`999`) + if call == 2 { + return model.ToolResult{CallID: c.ID, Status: model.ResultInterrupted, Content: "stopped"}, nil + } + return model.ToolResult{CallID: c.ID, Status: model.ResultSuccess}, nil + } + res, err = Run(context.Background(), fi.invocation()) + testassert.NoError(t, err) + testassert.Require(t, res.Status == TerminalInterruption && res.LastOutput == intOut, "terminal = %#v, want interruption carrying the settled output", res) + testassert.Require(t, len(res.UnstartedCalls) == 1 && res.UnstartedCalls[0].ID == "v2", "unstarted calls = %v, want only v2", joinedIDs(res.UnstartedCalls)) + for i, oc := range intOut.Message.ToolCalls { + testassert.Require(t, string(oc.Arguments) == `{"keep":1}` && string(oc.Extra["k"]) == `1`, "retained output call %d was rewritten: arguments=%q extra=%q", i, string(oc.Arguments), string(oc.Extra["k"])) + } + for _, oc := range res.UnstartedCalls { + testassert.Require(t, string(oc.Arguments) == `{"keep":1}` && string(oc.Extra["k"]) == `1`, "unstarted call %s was rewritten: arguments=%q extra=%q", oc.ID, string(oc.Arguments), string(oc.Extra["k"])) + } } diff --git a/harness/admission_test.go b/harness/admission_test.go index 7cd5a7dd..c6714dae 100644 --- a/harness/admission_test.go +++ b/harness/admission_test.go @@ -1165,7 +1165,7 @@ func TestHarnessCancellationGatesPublication(t *testing.T) { func TestReadOperationReturnsOwnedRecords(t *testing.T) { t.Run("active effect pointer", func(t *testing.T) { fixture := validTestGraph() - fixture.ops[0].State.ActiveEffect = &ActiveEffect{Kind: EffectModel, ResultEntryID: hexID(3)} + fixture.ops[0].State.ActiveEffect = &ActiveEffect{Kind: EffectModel, ResultEntryID: hexID(3), RequestID: hexID(99)} h := newTestHarness(t, fixture.storage(t), nil) rec, err := h.ReadOperation(context.Background(), testSessionID, testOpID) testassert.NoError(t, err) diff --git a/harness/codec_test.go b/harness/codec_test.go index 47a2766e..5cfb73b5 100644 --- a/harness/codec_test.go +++ b/harness/codec_test.go @@ -120,6 +120,8 @@ func validAssistantEntry(operationID string) assistantEntry { SessionID: testSessionID, EntryID: testEntryID, OperationID: operationID, + RequestID: hexID(7), + Advertised: []string{"echo"}, Status: model.OutputCompleted, Source: testModelRef(), Content: []model.ContentPart{model.ContentPart{Kind: model.PartText, Text: "hi"}}, @@ -280,9 +282,19 @@ func TestEntryPayloadRoundTrip(t *testing.T) { encode: func() (json.RawMessage, error) { v := validAssistantEntry(testOpID) v.Usage = testUsage(3) + v.Advertised = []string{"echo", "extra"} return encodeAssistantEntry(v) }, - decode: func(env Entry) error { _, err := decodeAssistantEntry(env); return err }, + decode: func(env Entry) error { + v, err := decodeAssistantEntry(env) + if err != nil { + return err + } + if v.RequestID != hexID(7) || len(v.Advertised) != 2 || v.Advertised[0] != "echo" || v.Advertised[1] != "extra" { + return fmt.Errorf("request_id/advertised round-trip lost: %q %v", v.RequestID, v.Advertised) + } + return nil + }, }, { name: "assistant operationless fork copy", @@ -598,7 +610,8 @@ func TestHookResultStatusRules(t *testing.T) { // TestHookActiveEffectCodecRules proves the active-effect codec rules: a hook // effect requires its hook ID and the matching first pending call, while the -// model and tool effects forbid a hook ID. +// model and tool effects forbid a hook ID; the request id rule is closed by +// kind — a model effect requires one, tool and hook effects forbid it. func TestHookActiveEffectCodecRules(t *testing.T) { op := validOperationRecord() assistant := validAssistantEntry(testOpID) @@ -626,7 +639,10 @@ func TestHookActiveEffectCodecRules(t *testing.T) { {"hook effect without a hook id", ActiveEffect{Kind: EffectHook, ResultEntryID: hexID(9), ToolCallID: "call-1"}, "hook active effect requires its hook id"}, {"hook effect without a tool call id", ActiveEffect{Kind: EffectHook, ResultEntryID: hexID(9), HookID: "cap.hook.one"}, "hook active effect requires its tool call id"}, {"hook effect on a non-pending call", ActiveEffect{Kind: EffectHook, ResultEntryID: hexID(9), ToolCallID: "call-9", HookID: "cap.hook.one"}, "hook active effect must address the matching first pending call"}, - {"model effect with a hook id", ActiveEffect{Kind: EffectModel, ResultEntryID: hexID(9), HookID: "cap.hook.one"}, "model active effect omits the hook id"}, + {"hook effect with a request id", ActiveEffect{Kind: EffectHook, ResultEntryID: hexID(9), RequestID: hexID(8), ToolCallID: "call-1", HookID: "cap.hook.one"}, "hook active effect omits the request id"}, + {"model effect without a request id", ActiveEffect{Kind: EffectModel, ResultEntryID: hexID(9)}, "model active effect requires its request id"}, + {"model effect with a hook id", ActiveEffect{Kind: EffectModel, ResultEntryID: hexID(9), RequestID: hexID(8), HookID: "cap.hook.one"}, "model active effect omits the hook id"}, + {"tool effect with a request id", ActiveEffect{Kind: EffectTool, ResultEntryID: testResultID, RequestID: hexID(8), ToolCallID: "call-1"}, "tool active effect omits the request id"}, {"tool effect with a hook id", ActiveEffect{Kind: EffectTool, ResultEntryID: testResultID, ToolCallID: "call-1", HookID: "cap.hook.one"}, "tool active effect omits the hook id"}, {"unknown kind", ActiveEffect{Kind: "widget", ResultEntryID: hexID(9), ToolCallID: "call-1", HookID: "cap.hook.one"}, `active effect kind "widget" is not one of model, tool or hook`}, } { @@ -708,7 +724,7 @@ func TestOperationStateEncodingShape(t *testing.T) { } effect := validOperationRecord() - effect.State.ActiveEffect = &ActiveEffect{Kind: EffectModel, ResultEntryID: hexID(3)} // model effect: no tool call id + effect.State.ActiveEffect = &ActiveEffect{Kind: EffectModel, ResultEntryID: hexID(3), RequestID: hexID(8)} // model effect: carries its request id, no tool call id raw, err = encodeOperationRegister(effect) testassert.NoError(t, err) stateObj, err = objectMember(wireObject(t, raw), "state", true) @@ -719,6 +735,10 @@ func TestOperationStateEncodingShape(t *testing.T) { _, present := effectObj["tool_call_id"] testassert.Require(t, !present, "model active effect carries tool_call_id: %s", raw) } + { + requestID, err := stringMember(effectObj, "request_id", true) + testassert.Require(t, err == nil && requestID == hexID(8), "model active effect request_id = %q (%v), want %s", requestID, err, hexID(8)) + } } // TestRegisterPayloadRejectsInvalidWire proves the register payloads enforce @@ -1113,6 +1133,24 @@ func TestCodecRejectsInvalidValues(t *testing.T) { _, err := encodeAssistantEntry(v) return err }}, + {"assistant missing request id", func() error { + v := validAssistantEntry(testOpID) + v.RequestID = "" + _, err := encodeAssistantEntry(v) + return err + }}, + {"assistant duplicate advertised names", func() error { + v := validAssistantEntry(testOpID) + v.Advertised = []string{"echo", "echo"} + _, err := encodeAssistantEntry(v) + return err + }}, + {"assistant empty advertised name", func() error { + v := validAssistantEntry(testOpID) + v.Advertised = []string{""} + _, err := encodeAssistantEntry(v) + return err + }}, {"tool result denied without content", func() error { v := validToolResultEntry(testOpID) v.Status = model.ResultDenied @@ -1206,7 +1244,7 @@ func TestCodecRejectsInvalidValues(t *testing.T) { }}, {"operation model effect with call id", func() error { v := validOperationRecord() - v.State.ActiveEffect = &ActiveEffect{Kind: EffectModel, ResultEntryID: hexID(3), ToolCallID: "call-1"} + v.State.ActiveEffect = &ActiveEffect{Kind: EffectModel, ResultEntryID: hexID(3), RequestID: hexID(8), ToolCallID: "call-1"} _, err := encodeOperationRegister(v) return err }}, diff --git a/harness/compact.go b/harness/compact.go index 1eeda077..9efa37bb 100644 --- a/harness/compact.go +++ b/harness/compact.go @@ -216,15 +216,14 @@ func (h *Harness) runCompaction(execCtx context.Context, c *coordinator, operati effect := h.compactModelEffect(c, operationID, exec, capture, &reported) res, runErr := agent.Run(execCtx, agent.Invocation{ ExpectedModel: capture.Compact.Model, - Tools: nil, - Context: func(context.Context) ([]model.Message, error) { - return []model.Message{ + Request: func(context.Context) (model.Request, error) { + return model.Request{Messages: []model.Message{ {Role: model.RoleSystem, Content: []model.ContentPart{{Kind: model.PartText, Text: capture.Compact.SystemPrompt}}}, {Role: model.RoleUser, Content: []model.ContentPart{{Kind: model.PartText, Text: strings.Join(lines, "\n")}}}, - }, nil + }}, nil }, ModelEffect: effect, - ToolEffect: func(context.Context, model.ToolCall) (model.ToolResult, error) { + ToolEffect: func(context.Context, model.ToolCall, []string) (model.ToolResult, error) { return model.ToolResult{}, &agent.ProtocolError{Boundary: "tool", Detail: "compact agent has no tools"} }, }) diff --git a/harness/context.go b/harness/context.go index 5dabd80f..7c1d5e63 100644 --- a/harness/context.go +++ b/harness/context.go @@ -12,36 +12,40 @@ import ( ) // errSteeringBoundary is the one package-private non-cancellation context -// token: a conversation boundary returns it instead of projecting another +// token: a conversation boundary returns it instead of supplying another // request while steering waits. Agent.Run hands a non-cancellation context // error back verbatim with a zero terminal, and the outer terminal settlement // consumes this token by exact identity before its guards. It is produced -// only by the contextSource closure and is never persisted, returned +// only by the requestSource closure and is never persisted, returned // publicly, or latched in Wait. var errSteeringBoundary = errors.New("harness: steering boundary") -// contextSource returns the Agent context boundary of one Operation: the +// requestSource returns the Agent request boundary of one Operation: the // first call projects the newly admitted input only (first-request // protection), and every later call selects the conversation boundary — -// when steering waits it returns errSteeringBoundary instead of projecting +// when steering waits it returns errSteeringBoundary instead of supplying // another request, leaving every head buffered for the ordinary post-terminal -// drain — otherwise it projects the fresh model.Message history. Selection -// consumes no head and performs no storage, reservation, cancellation or -// observation. Physical retries and the compact Agent's own context callback -// never reach this boundary. -func (h *Harness) contextSource(c *coordinator, operationID string) agent.ContextSource { +// drain — otherwise it supplies the fresh model.Message history together with +// the captured tools. Selection consumes no head and performs no storage, +// reservation, cancellation or observation. Physical retries and the compact +// Agent's own request callback never reach this boundary. +func (h *Harness) requestSource(c *coordinator, operationID string, capture ExecutionCapture) agent.RequestSource { firstRequest := true - return func(ctx context.Context) ([]model.Message, error) { + return func(ctx context.Context) (model.Request, error) { if !firstRequest { c.mu.Lock() waiting := len(c.steering) > 0 c.mu.Unlock() if waiting { - return nil, errSteeringBoundary + return model.Request{}, errSteeringBoundary } } firstRequest = false - return h.projectContext(c, operationID) + messages, err := h.projectContext(c, operationID) + if err != nil { + return model.Request{}, err + } + return model.Request{Messages: messages, Tools: capture.Tools}, nil } } diff --git a/harness/context_test.go b/harness/context_test.go index 03576111..f689142d 100644 --- a/harness/context_test.go +++ b/harness/context_test.go @@ -75,7 +75,7 @@ func projectionFollowUp(entryID, operationID, text, settlementID string, sequenc // boundary token is ever returned. func projectionMessages(t *testing.T, h *Harness, c *coordinator) []model.Message { t.Helper() - msgs, err := h.contextSource(c, testOpID)(context.Background()) + msgs, err := fixtureProjection(h, c)(context.Background()) testassert.NoError(t, err) return msgs } @@ -101,12 +101,12 @@ func assertProjectionEqual(t *testing.T, got, want []model.Message) { testassert.Require(t, reflect.DeepEqual(got, want), "projected messages = %+v, want %+v", got, want) } -// TestContextSourceSummarizedProjection proves the summarized projection: with +// TestRequestSourceSummarizedProjection proves the summarized projection: with // the Session register's CompactionEntryID set, the projected messages are // exactly the system message, one summary message carrying the payload's // verbatim framing and model identity, and the post-boundary entries in // sequence order — nothing at or before the boundary projects. -func TestContextSourceSummarizedProjection(t *testing.T) { +func TestRequestSourceSummarizedProjection(t *testing.T) { fixture := validTestGraph() fixture.entries = append(fixture.entries, projectionCompaction(hexID(3), hexID(2), 3)) followInput, followSettlement, followOp := projectionFollowUp(hexID(4), "op-2", "second", hexID(6), 4, 6) @@ -128,11 +128,11 @@ func TestContextSourceSummarizedProjection(t *testing.T) { assertProjectionEqual(t, got, want) } -// TestContextSourceUncompactedProjectionGolden proves the empty-field +// TestRequestSourceUncompactedProjectionGolden proves the empty-field // projection is byte-identical to the pre-change behavior: the full history // projects and the committed compaction entry (present in the graph, unnamed // by the register) still projects no message. -func TestContextSourceUncompactedProjectionGolden(t *testing.T) { +func TestRequestSourceUncompactedProjectionGolden(t *testing.T) { fixture := validTestGraph() fixture.entries = append(fixture.entries, projectionCompaction(hexID(3), hexID(2), 3)) h, c := newProjectionFixture(t, fixture) @@ -146,11 +146,11 @@ func TestContextSourceUncompactedProjectionGolden(t *testing.T) { assertProjectionEqual(t, got, want) } -// TestContextSourceTwoSequentialCompactions proves two sequential compaction +// TestRequestSourceTwoSequentialCompactions proves two sequential compaction // entries: only the register-named entry's summary projects — the earlier // compaction entry produces no second summary — and the entries after the // named boundary alone project. -func TestContextSourceTwoSequentialCompactions(t *testing.T) { +func TestRequestSourceTwoSequentialCompactions(t *testing.T) { fixture := validTestGraph() fixture.entries = append(fixture.entries, projectionCompaction(hexID(3), hexID(2), 3)) secondInput, secondSettlement, secondOp := projectionFollowUp(hexID(4), "op-2", "second", hexID(7), 4, 7) @@ -186,13 +186,13 @@ func projectionSignal(entryID string, sequence int64) testEntry { } } -// TestContextSourceProjectsEntriesAfterTheBoundary proves the frozen-boundary +// TestRequestSourceProjectsEntriesAfterTheBoundary proves the frozen-boundary // projection rule: the named compaction entry's summary projects, then exactly // the entries after the sequence of the boundary entry its payload records — // an entry committed between the boundary target and the compaction entry // still projects — and a compaction-kind boundary target hides every message // kind, leaving the summary alone. -func TestContextSourceProjectsEntriesAfterTheBoundary(t *testing.T) { +func TestRequestSourceProjectsEntriesAfterTheBoundary(t *testing.T) { t.Run("the entry between the boundary target and the compaction projects", func(t *testing.T) { fixture := validTestGraph() fixture.entries = append(fixture.entries, projectionSignal(hexID(3), 3), projectionCompaction(hexID(4), hexID(2), 4)) diff --git a/harness/effect.go b/harness/effect.go index fd6ca466..e5150eb2 100644 --- a/harness/effect.go +++ b/harness/effect.go @@ -46,7 +46,8 @@ type modelEffectIntent struct { sessionID string expected model.ModelRef resultID string - compaction bool // true for the compact effect's intent: its usage is summary usage + requestID string // identity of the prepared request this intent's effect consumed + compaction bool // true for the compact effect's intent: its usage is summary usage } // modelResult is the settlement content of one logical model effect: the @@ -85,7 +86,6 @@ type modelResult struct { func (h *Harness) modelEffect(c *coordinator, operationID string, exec Execution, capture ExecutionCapture) agent.ModelEffect { attempt := exec.Model normalize := exec.NormalizeTool - advertised := advertisedToolNames(capture) return func(ctx context.Context, req model.Request, assemble agent.AssemblyCallback) (agent.ModelSettlement, error) { // The compaction trigger sits before every model boundary, before any // intent commits: the newest reported conversation usage after the @@ -130,6 +130,13 @@ func (h *Harness) modelEffect(c *coordinator, operationID string, exec Execution } req.Messages = messages } + // The advertisement of this logical effect is the validated request's + // own ordered tool names: the record it produces is gated by them, + // never by a later capture. + names := make([]string, len(req.Tools)) + for i, def := range req.Tools { + names[i] = def.Name + } intent, err := h.beginModelEffect(ctx, c, operationID) if err != nil { // An intent transaction aborted by cancellation settles the @@ -206,7 +213,7 @@ func (h *Harness) modelEffect(c *coordinator, operationID string, exec Execution } } } - assistant, reported, err := newAssistantEntry(intent.sessionID, operationID, intent.resultID, owned.Output, normalize, advertised) + assistant, reported, err := newAssistantEntry(intent.sessionID, operationID, intent.resultID, intent.requestID, owned.Output, normalize, names) if err != nil { return agent.ModelSettlement{}, err } @@ -325,9 +332,10 @@ func publishedCallIDs(c *coordinator, operationID string) map[string]bool { } // beginModelEffect commits the one active-effect intent of a logical model -// effect: it reserves the effect's result entry identity and records it in the -// Operation register. The reserved identity stays free until the model result -// or the consuming settlement commits under it. +// effect: it reserves the effect's result entry identity and a fresh request +// identity for the prepared request the effect consumes, and records both in +// the Operation register. The reserved identity stays free until the model +// result or the consuming settlement commits under it. func (h *Harness) beginModelEffect(ctx context.Context, c *coordinator, operationID string) (modelEffectIntent, error) { c.mu.Lock() op, ok := c.graph.Operation(operationID) @@ -349,6 +357,11 @@ func (h *Harness) beginModelEffect(ctx context.Context, c *coordinator, operatio c.mu.Unlock() return modelEffectIntent{}, fmt.Errorf("%w: %v", ErrStorage, err) } + requestID, err := newHexID() + if err != nil { + c.mu.Unlock() + return modelEffectIntent{}, fmt.Errorf("%w: %v", ErrStorage, err) + } var updated OperationRecord err = h.deps.Storage.Transact(ctx, func(tx Transaction) error { key := RegisterKey{SessionID: op.Admission.SessionID, Kind: RegisterOperation, OperationID: operationID} @@ -368,7 +381,7 @@ func (h *Harness) beginModelEffect(ctx context.Context, c *coordinator, operatio return fmt.Errorf("%w: operation %q revision %d changed concurrently to %d", errRevisionRace, operationID, op.Revision, reg.Revision) } next := current - next.State.ActiveEffect = &ActiveEffect{Kind: EffectModel, ResultEntryID: resultID} + next.State.ActiveEffect = &ActiveEffect{Kind: EffectModel, ResultEntryID: resultID, RequestID: requestID} payload, err := encodeOperationRegister(next) if err != nil { return err @@ -397,6 +410,7 @@ func (h *Harness) beginModelEffect(ctx context.Context, c *coordinator, operatio sessionID: op.Admission.SessionID, expected: op.Admission.Execution.Model, resultID: resultID, + requestID: requestID, }, nil } @@ -855,17 +869,6 @@ func newSignalEntry(tx Transaction, sessionID, operationID string, kind SignalKi return &adopted, nil } -// advertisedToolNames returns the committed advertised tool-name set of one -// capture: the single advertisement authority of the model and tool -// boundaries. -func advertisedToolNames(capture ExecutionCapture) map[string]bool { - names := make(map[string]bool, len(capture.Tools)) - for _, tool := range capture.Tools { - names[tool.Name] = true - } - return names -} - // normalizeCallArguments runs one NormalizeTool invocation at the Harness // boundary: the callback receives an owned copy of the completed call and // must return exactly one complete JSON object, whose bytes the Harness @@ -892,16 +895,17 @@ func normalizeCallArguments(normalize func(model.ToolCall) (json.RawMessage, err var errMalformedNormalization = errors.New("malformed normalization") // newAssistantEntry builds one assistant entry from one validated model -// output under the given entry identity, reserving one result identity per -// completed call. An output without an eligible model-visible payload writes +// output under the given entry and request identity, reserving one result +// identity per completed call and recording the producing request's ordered +// advertised names. An output without an eligible model-visible payload writes // no assistant entry and returns its reported usage for the consuming -// settlement entry instead. Before the entry commits, every advertised -// completed call is normalized through the execution's pure callback: its -// successful owned object populates the nested call's normalized_arguments -// member; a failed or malformed normalization, and every unknown or -// unadvertised name, leave the member absent while the original raw argument -// bytes are always retained. -func newAssistantEntry(sessionID, operationID, entryID string, out *model.Output, normalize func(model.ToolCall) (json.RawMessage, error), advertised map[string]bool) (*assistantEntry, *UsageCount, error) { +// settlement entry instead. Before the entry commits, every completed call +// whose name the producing request advertised is normalized through the +// execution's pure callback: its successful owned object populates the nested +// call's normalized_arguments member; a failed or malformed normalization, and +// every unknown or unadvertised name, leave the member absent while the +// original raw argument bytes are always retained. +func newAssistantEntry(sessionID, operationID, entryID, requestID string, out *model.Output, normalize func(model.ToolCall) (json.RawMessage, error), advertised []string) (*assistantEntry, *UsageCount, error) { if out == nil { return nil, nil, nil } @@ -909,9 +913,18 @@ func newAssistantEntry(sessionID, operationID, entryID string, out *model.Output SessionID: sessionID, EntryID: entryID, OperationID: operationID, + RequestID: requestID, + Advertised: advertised, Status: out.Status, Source: out.Source, } + if len(entry.Advertised) == 0 { + entry.Advertised = nil + } + advertisedNames := make(map[string]bool, len(advertised)) + for _, name := range advertised { + advertisedNames[name] = true + } var usage *UsageCount if out.Usage != nil { usage = &UsageCount{ @@ -932,7 +945,7 @@ func newAssistantEntry(sessionID, operationID, entryID string, out *model.Output ArgumentsBase64: base64.StdEncoding.EncodeToString(call.Arguments), Extra: call.Extra, } - if advertised[call.Name] { + if advertisedNames[call.Name] { if normalized, err := normalizeCallArguments(normalize, call); err == nil { record.NormalizedArguments = normalized } @@ -1039,16 +1052,16 @@ func interruptedToolResult(callID string) model.ToolResult { return model.ToolResult{CallID: callID, Status: model.ResultInterrupted, Content: interruptedToolResultContent} } -// pendingToolCallRecord resolves one published call's pending reservation and -// its committed assistant tool-call record from the coordinator's validated -// view: the boundary consumes the durable record, never call data from model -// JSON. -func pendingToolCallRecord(c *coordinator, operationID, callID string) (PendingToolCall, toolCallRecord, error) { +// pendingToolCallRecord resolves one published call's pending reservation, its +// committed assistant tool-call record, and the producing assistant entry's +// advertised-name set from the coordinator's validated view: the boundary +// consumes the durable records, never call data from model JSON. +func pendingToolCallRecord(c *coordinator, operationID, callID string) (PendingToolCall, toolCallRecord, map[string]bool, error) { c.mu.Lock() defer c.mu.Unlock() op, ok := c.graph.Operation(operationID) if !ok { - return PendingToolCall{}, toolCallRecord{}, fmt.Errorf("%w: operation %q in session %q", ErrNotFound, operationID, c.graph.Session.Identity.SessionID) + return PendingToolCall{}, toolCallRecord{}, nil, fmt.Errorf("%w: operation %q in session %q", ErrNotFound, operationID, c.graph.Session.Identity.SessionID) } var pending PendingToolCall found := false @@ -1059,7 +1072,7 @@ func pendingToolCallRecord(c *coordinator, operationID, callID string) (PendingT } } if !found { - return PendingToolCall{}, toolCallRecord{}, invalidInput("operation %q has no pending call %q; every call settles exactly once", operationID, callID) + return PendingToolCall{}, toolCallRecord{}, nil, invalidInput("operation %q has no pending call %q; every call settles exactly once", operationID, callID) } for _, entry := range c.graph.Entries { if entry.Envelope.ID != pending.AssistantEntry.EntryID || entry.Assistant == nil { @@ -1067,11 +1080,15 @@ func pendingToolCallRecord(c *coordinator, operationID, callID string) (PendingT } for _, record := range entry.Assistant.ToolCalls { if record.ID == callID { - return pending, record, nil + advertised := make(map[string]bool, len(entry.Assistant.Advertised)) + for _, name := range entry.Assistant.Advertised { + advertised[name] = true + } + return pending, record, advertised, nil } } } - return PendingToolCall{}, toolCallRecord{}, invalidInput("operation %q pending call %q has no committed assistant record", operationID, callID) + return PendingToolCall{}, toolCallRecord{}, nil, invalidInput("operation %q pending call %q has no committed assistant record", operationID, callID) } // unavailableToolResult is the ordinary unavailable-tool error result the @@ -1110,27 +1127,28 @@ func boundedToolDiagnostic(cause error) string { // toolEffect encloses one concrete tool execution in the shared Harness tool // boundary. In order: it resolves the call's pending reservation and committed -// assistant record, and pre-start cancellation wins before anything else; the -// advertisement gate rejects names outside the committed advertised tool set -// with the ordinary unavailable-tool error before any hook or preparer runs; -// the selected argument hooks then run in configured order, each as its own -// settled effect, and a settled hook chain hands the final committed value on; -// without hooks (or for an unhooked call) the original assistant's committed -// normalized arguments are the selected input, and only an invalid unhooked -// original is normalized again to obtain its useful validation diagnostic. It -// then prepares the plan outside locks, rejects invalid plan shapes with the -// existing internal-validation error, and evaluates every declaration through -// the fixed boundary — including immediate-success plans — before -// beginPendingCallEffect. A denial settles only that original call with status denied -// and no metadata, no concrete effect and no tool active intent; later calls -// still run. An allowed executor commits intent, executes once behind the -// cancellation-before-start gate, and commits one validated outcome — a -// returned real outcome wins a cancellation race. +// assistant record together with the producing assistant entry's advertised +// set, and pre-start cancellation wins before anything else; the advertisement +// gate rejects names outside that committed set with the ordinary +// unavailable-tool error before any hook or preparer runs — the Engine-passed +// names are never consulted as authority; the selected argument hooks then run +// in configured order, each as its own settled effect, and a settled hook +// chain hands the final committed value on; without hooks (or for an unhooked +// call) the original assistant's committed normalized arguments are the +// selected input, and only an invalid unhooked original is normalized again to +// obtain its useful validation diagnostic. It then prepares the plan outside +// locks, rejects invalid plan shapes with the existing internal-validation +// error, and evaluates every declaration through the fixed boundary — +// including immediate-success plans — before beginPendingCallEffect. A denial +// settles only that original call with status denied and no metadata, no +// concrete effect and no tool active intent; later calls still run. An allowed +// executor commits intent, executes once behind the cancellation-before-start +// gate, and commits one validated outcome — a returned real outcome wins a +// cancellation race. func (h *Harness) toolEffect(c *coordinator, operationID string, exec Execution, capture ExecutionCapture) agent.ToolEffect { prepared := exec.Tool - advertised := advertisedToolNames(capture) - return func(ctx context.Context, call model.ToolCall) (model.ToolResult, error) { - pending, record, err := pendingToolCallRecord(c, operationID, call.ID) + return func(ctx context.Context, call model.ToolCall, _ []string) (model.ToolResult, error) { + pending, record, advertised, err := pendingToolCallRecord(c, operationID, call.ID) if err != nil { return model.ToolResult{}, err } @@ -1149,7 +1167,7 @@ func (h *Harness) toolEffect(c *coordinator, operationID string, exec Execution, if ctx.Err() != nil { // execution cancellation prevents later preparation: interrupted-before-execution return h.commitToolResult(settleCtx, c, operationID, pending, ToolOutcome{Result: interruptedToolResult(call.ID)}, false) } - if !advertised[record.Name] { // the one advertisement gate, ahead of every hook and preparer + if !advertised[record.Name] { // the one advertisement gate, ahead of every hook and preparer: the committed producing record is the authority return h.commitToolResult(settleCtx, c, operationID, pending, ToolOutcome{Result: unavailableToolResult(call.ID, record.Name)}, false) } // The selected normalized value feeding preparation: the final @@ -1803,8 +1821,7 @@ func (h *Harness) execute(c *coordinator, operationID string, prepared PreparedE } res, runErr := agent.Run(execCtx, agent.Invocation{ ExpectedModel: agentCapture.Model, - Tools: agentCapture.Tools, - Context: h.contextSource(c, operationID), + Request: h.requestSource(c, operationID, agentCapture), ModelEffect: h.modelEffect(c, operationID, exec, agentCapture), ToolEffect: h.toolEffect(c, operationID, exec, agentCapture), }) diff --git a/harness/effect_test.go b/harness/effect_test.go index b17234e7..9da4d9c1 100644 --- a/harness/effect_test.go +++ b/harness/effect_test.go @@ -74,6 +74,20 @@ func invokeModelEffect(t *testing.T, me agent.ModelEffect, req model.Request, as return invokeModelEffectCtx(t, me, context.Background(), req, assemble) } +// fixtureProjection wraps one Operation's request source with the test +// capture, exposing only the projected messages for fixtures that read the +// boundary directly. +func fixtureProjection(h *Harness, c *coordinator) func(context.Context) ([]model.Message, error) { + source := h.requestSource(c, testOpID, testCapture()) + return func(ctx context.Context) ([]model.Message, error) { + req, err := source(ctx) + if err != nil { + return nil, err + } + return req.Messages, nil + } +} + func testToolCall(id string) model.ToolCall { return model.ToolCall{ID: id, Name: "echo", Arguments: json.RawMessage(`{"x":1}`)} } @@ -189,14 +203,16 @@ func erroredTurnStream() *scriptStream { } // TestModelEffectReadyRemainsRunning proves the ready transition: the -// committed assistant consumes the effect's reserved identity, completed calls -// receive their reservations, usage lands on the entry and both totals, and -// the Operation stays running with the effect cleared. +// committed assistant consumes the effect's reserved identity, carries the +// intent's request identity and the producing request's advertised names, +// completed calls receive their reservations, usage lands on the entry and +// both totals, and the Operation stays running with the effect cleared. func TestModelEffectReadyRemainsRunning(t *testing.T) { var ( store *graphStorage sessionID string reservedID string + requestID string ) modelFn := func(context.Context, model.Request) (model.Stream, error) { reg, err := store.ReadRegister(context.Background(), RegisterKey{SessionID: sessionID, Kind: RegisterOperation, OperationID: testOpID}) @@ -211,6 +227,7 @@ func TestModelEffectReadyRemainsRunning(t *testing.T) { return nil, errors.New("no model effect intent is durable while the effect runs") } reservedID = op.State.ActiveEffect.ResultEntryID + requestID = op.State.ActiveEffect.RequestID return completedTurnStream(testToolCall("call-1"), testToolCall("call-2")), nil } h, st, c, sid := newEffectHarness(t, modelFn) @@ -234,6 +251,8 @@ func TestModelEffectReadyRemainsRunning(t *testing.T) { } } testassert.Require(t, assistant != nil, "ready settlement published no assistant entry") + testassert.Require(t, assistant.RequestID == requestID && requestID != "", "assistant request id = %q, want the committed intent's identity %q", assistant.RequestID, requestID) + testassert.Require(t, len(assistant.Advertised) == 1 && assistant.Advertised[0] == "echo", "assistant advertised names = %v, want the producing request's [echo]", assistant.Advertised) testassert.Require(t, assistant.EntryID == reservedID && (assistant.Status == model.OutputCompleted && len(assistant.ToolCalls) == 2), "got %v, want %v; assistant entry = %+v, want completed output with two calls", assistant.EntryID, reservedID, assistant) testassert.Require(t, assistant.Usage != nil && *assistant.Usage == (UsageCount{InputTokens: 3, CachedInputTokens: 1, OutputTokens: 2}), "assistant usage = %+v, want the reported counts", assistant.Usage) for i, call := range assistant.ToolCalls { @@ -256,7 +275,7 @@ func TestModelEffectReadyRemainsRunning(t *testing.T) { testassert.Require(t, view.Header.CurrentOperationID == testOpID && usageTotalsEqual(view.TotalUsage, want), "got %v, want %v; session usage = %+v, want %+v", view.Header.CurrentOperationID, testOpID, view.TotalUsage, want) // The second context projection reads the entry the first effect committed. - source := h.contextSource(c, testOpID) + source := fixtureProjection(h, c) msgs, err := source(context.Background()) testassert.NoError(t, err) // system prompt, admitted input, committed assistant @@ -578,13 +597,13 @@ func projectionTestGraph() *testGraph { return fixture } -// TestContextSourceProjectsFullHistory proves the one kind-to-message mapping +// TestRequestSourceProjectsFullHistory proves the one kind-to-message mapping // over the full committed history plus the captured system prompt. -func TestContextSourceProjectsFullHistory(t *testing.T) { +func TestRequestSourceProjectsFullHistory(t *testing.T) { store := projectionTestGraph().storage(t) h := newTestHarness(t, store, nil) c := leaseCoordinator(t, h, testSessionID) - msgs, err := h.contextSource(c, testOpID)(context.Background()) + msgs, err := fixtureProjection(h, c)(context.Background()) testassert.NoError(t, err) testassert.Equal(t, len(msgs), 5) testassert.Require(t, msgs[0].Role == model.RoleSystem && msgs[0].TextContent() == "system", "first message = %+v, want the captured system prompt", msgs[0]) @@ -609,7 +628,7 @@ func TestContextSourceProjectsFullHistory(t *testing.T) { testassert.NoError(t, err) } { - _, err := h.toolEffect(c, testOpID, exec, testCapture())(context.Background(), testToolCall("call-1")) + _, err := h.toolEffect(c, testOpID, exec, testCapture())(context.Background(), testToolCall("call-1"), nil) testassert.NoError(t, err) } } @@ -623,7 +642,7 @@ func TestContextSourceProjectsFullHistory(t *testing.T) { _, err := submitText(t, h, sessionID, "op-2", MessageModeRegular, "s1") testassert.NoError(t, err) } - first, err := h.contextSource(c, testOpID)(context.Background()) + first, err := fixtureProjection(h, c)(context.Background()) testassert.NoError(t, err) testassert.Require(t, len(first) == 2 && first[1].TextContent() == "hello", "first projection = %+v, want the admitted input only despite the pending head", first) }) @@ -641,7 +660,7 @@ func TestContextSourceProjectsFullHistory(t *testing.T) { c.mu.Lock() c.run = run // the live execution whose context selection must leave alone c.mu.Unlock() - source := h.contextSource(c, testOpID) + source := fixtureProjection(h, c) first, err := source(context.Background()) // steering is empty through the first projection testassert.Require(t, err == nil && len(first) == 2 && first[1].TextContent() == "hello", "first projection = (%+v, %v), want the admitted input with empty steering", first, err) runEffects(t, h, c, modelFn) // the real model and tool callbacks return before the head arrives @@ -1283,7 +1302,7 @@ func TestToolEffectPlansAndOutcomes(t *testing.T) { } return inner } - got, err := h.toolEffect(c, testOpID, effectExecution(nil, plan), testCapture())(context.Background(), testToolCall("call-1")) + got, err := h.toolEffect(c, testOpID, effectExecution(nil, plan), testCapture())(context.Background(), testToolCall("call-1"), nil) testassert.NoError(t, err) testassert.Equal(t, got, tc.want) testassert.Equal(t, executions, tc.wantExecutions) @@ -1562,7 +1581,7 @@ func TestToolEffectPermissionBoundary(t *testing.T) { } return inner } - got, err := h.toolEffect(c, testOpID, Execution{Tool: plan, NormalizeTool: objectNormalize, Permissions: tc.policy}, capture)(context.Background(), testToolCall("call-1")) + got, err := h.toolEffect(c, testOpID, Execution{Tool: plan, NormalizeTool: objectNormalize, Permissions: tc.policy}, capture)(context.Background(), testToolCall("call-1"), nil) testassert.NoError(t, err) wantStatus := model.ResultSuccess if tc.wantDenied { @@ -1608,7 +1627,7 @@ func TestToolEffectDeniedSettlesOnlyItsCall(t *testing.T) { } return nil } - got, err := h.toolEffect(c, testOpID, effectExecution(nil, plan), testCapture())(context.Background(), testToolCall("call-1")) + got, err := h.toolEffect(c, testOpID, effectExecution(nil, plan), testCapture())(context.Background(), testToolCall("call-1"), nil) testassert.NoError(t, err) testassert.Require(t, got.Status == model.ResultDenied && got.Content == PermissionDeniedContent, "call-1 result = %+v, want the fixed denial", got) testassert.Equal(t, executions, 0) @@ -1618,7 +1637,7 @@ func TestToolEffectDeniedSettlesOnlyItsCall(t *testing.T) { testassert.NoError(t, err) testassert.Require(t, rec.State.Status == OperationRunning && rec.State.ActiveEffect == nil && (len(rec.State.PendingToolCalls) == 1 && rec.State.PendingToolCalls[0].CallID == "call-2"), "operation after the denial = %+v, want running with no active effect; pending calls = %+v, want the later call untouched", rec.State, rec.State.PendingToolCalls) { - _, err := h.toolEffect(c, testOpID, effectExecution(nil, plan), testCapture())(context.Background(), testToolCall("call-2")) + _, err := h.toolEffect(c, testOpID, effectExecution(nil, plan), testCapture())(context.Background(), testToolCall("call-2"), nil) testassert.NoError(t, err) } testassert.Equal(t, executions, 1) @@ -1642,9 +1661,12 @@ func TestToolEffectDeniedSettlesOnlyItsCall(t *testing.T) { } // TestToolEffectAdvertisementGate proves the one advertisement gate ahead of -// every preparer: a completed call whose committed record names a tool -// outside the capture set settles the ordinary unavailable-tool error with no -// preparation and no normalization, while an advertised sibling still runs. +// every preparer, read from the producing assistant record: a completed call +// whose committed record names a tool outside the request's advertised set +// settles the ordinary unavailable-tool error with no preparation and no +// normalization, a later capture advertising that name grants no retroactive +// authority, a forged names argument grants none either, while a call the +// producing request advertised still runs. func TestToolEffectAdvertisementGate(t *testing.T) { h, store, c, sessionID := newEffectHarness(t, nil) normalized := 0 @@ -1656,6 +1678,7 @@ func TestToolEffectAdvertisementGate(t *testing.T) { return completedTurnStream( model.ToolCall{ID: "call-1", Name: "ghost", Arguments: json.RawMessage(`{"x":1}`)}, testToolCall("call-2"), + model.ToolCall{ID: "call-3", Name: "ghost", Arguments: json.RawMessage(`{"x":1}`)}, ), nil } { @@ -1668,13 +1691,22 @@ func TestToolEffectAdvertisementGate(t *testing.T) { prepared++ return PreparedTool{Permissions: fixturePermission, Immediate: &ToolOutcome{Result: model.ToolResult{CallID: call.ID, Status: model.ResultSuccess, Content: "done"}}} } - got, err := h.toolEffect(c, testOpID, Execution{Tool: plan, NormalizeTool: normalize}, testCapture())(context.Background(), testToolCall("call-1")) + got, err := h.toolEffect(c, testOpID, Execution{Tool: plan, NormalizeTool: normalize}, testCapture())(context.Background(), testToolCall("call-1"), nil) testassert.NoError(t, err) testassert.Require(t, got.Status == model.ResultError && got.Content == `Tool "ghost" is not available.`, "unadvertised result = %+v, want the ordinary unavailable-tool error", got) testassert.Equal(t, normalized, 1) testassert.Equal(t, prepared, 0) + // A later capture that advertises the name grants the committed call no + // authority: the gate reads the producing record, never the capture, and a + // forged names argument is never consulted either. + ghostCapture := testCapture() + ghostCapture.Tools = append(ghostCapture.Tools, model.ToolDefinition{Name: "ghost", Description: "ghost tool", Parameters: json.RawMessage(`{"type":"object"}`)}) + got, err = h.toolEffect(c, testOpID, Execution{Tool: plan, NormalizeTool: normalize}, ghostCapture)(context.Background(), testToolCall("call-3"), []string{"ghost"}) + testassert.NoError(t, err) + testassert.Require(t, got.Status == model.ResultError && got.Content == `Tool "ghost" is not available.`, "gate under a retroactive capture = %+v, want the ordinary unavailable-tool error", got) + testassert.Equal(t, prepared, 0) { - _, err := h.toolEffect(c, testOpID, Execution{Tool: plan, NormalizeTool: normalize}, testCapture())(context.Background(), testToolCall("call-2")) + _, err := h.toolEffect(c, testOpID, Execution{Tool: plan, NormalizeTool: normalize}, testCapture())(context.Background(), testToolCall("call-2"), nil) testassert.NoError(t, err) } testassert.Equal(t, prepared, 1) @@ -1745,7 +1777,7 @@ func TestToolEffectConsumesCommittedNormalization(t *testing.T) { // preparer the committed record's name and normalized bytes. A forged // unadvertised name would trip the advertisement gate if it were used. forged := model.ToolCall{ID: "call-1", Name: "forged", Arguments: json.RawMessage(`{"y":99}`)} - got, err := h.toolEffect(c, testOpID, Execution{Tool: plan, NormalizeTool: normalize}, testCapture())(context.Background(), forged) + got, err := h.toolEffect(c, testOpID, Execution{Tool: plan, NormalizeTool: normalize}, testCapture())(context.Background(), forged, nil) testassert.NoError(t, err) testassert.Require(t, got.Status == model.ResultSuccess && got.Content == "done", "result = %+v, want the committed call to execute, not a forged-name rejection", got) testassert.Equal(t, normalized, 1) @@ -1782,7 +1814,7 @@ func TestToolEffectConsumesCommittedNormalization(t *testing.T) { } return nil } - got, err := h.toolEffect(c, testOpID, Execution{Tool: plan, NormalizeTool: normalize}, testCapture())(context.Background(), testToolCall("call-1")) + got, err := h.toolEffect(c, testOpID, Execution{Tool: plan, NormalizeTool: normalize}, testCapture())(context.Background(), testToolCall("call-1"), nil) testassert.NoError(t, err) testassert.Require(t, got.Status == model.ResultSuccess && got.Content == "ran", "result = %+v, want the executed outcome", got) // Operation + Session + the tool intent @@ -1816,7 +1848,7 @@ func TestToolEffectConsumesCommittedNormalization(t *testing.T) { prepared++ return PreparedTool{Permissions: fixturePermission, Immediate: &ToolOutcome{Result: model.ToolResult{CallID: "call-1", Status: model.ResultSuccess, Content: "done"}}} } - got, err := h.toolEffect(c, testOpID, Execution{Tool: plan, NormalizeTool: normalize}, testCapture())(context.Background(), testToolCall("call-1")) + got, err := h.toolEffect(c, testOpID, Execution{Tool: plan, NormalizeTool: normalize}, testCapture())(context.Background(), testToolCall("call-1"), nil) testassert.NoError(t, err) testassert.Equal(t, normalized, 2) testassert.Require(t, got.Status == model.ResultError && got.Content == "arguments must be one non-null JSON object", "result = %+v, want the callback's useful validation diagnostic", got) @@ -1850,7 +1882,7 @@ func TestToolEffectConsumesCommittedNormalization(t *testing.T) { got, err := h.toolEffect(c, testOpID, Execution{Tool: func(context.Context, model.ToolCall) PreparedTool { t.Fatalf("preparer must not run for the invalid original") return PreparedTool{} - }, NormalizeTool: normalize}, testCapture())(context.Background(), testToolCall("call-1")) + }, NormalizeTool: normalize}, testCapture())(context.Background(), testToolCall("call-1"), nil) testassert.NoError(t, err) testassert.Require(t, got.Status == model.ResultError, "result = %+v, want the invalid original's validation error", got) testassert.Equal(t, seenAtBoundary, `{"n":[1,1.0,9007199254740993]}`) @@ -1866,7 +1898,7 @@ func TestToolEffectConsumesCommittedNormalization(t *testing.T) { got, err := h.toolEffect(c, testOpID, Execution{Tool: func(context.Context, model.ToolCall) PreparedTool { t.Fatalf("preparer must not run for an invalid original") return PreparedTool{} - }, NormalizeTool: normalize}, testCapture())(context.Background(), testToolCall("call-1")) + }, NormalizeTool: normalize}, testCapture())(context.Background(), testToolCall("call-1"), nil) testassert.NoError(t, err) testassert.Require(t, got.Status == model.ResultError && len(got.Content) <= maxToolDiagnosticBytes && strings.HasPrefix(got.Content, "xyyyyy"), "result = %+v, want the diagnostic truncated within %d bytes", got, maxToolDiagnosticBytes) }) @@ -1886,7 +1918,7 @@ func TestToolEffectConsumesCommittedNormalization(t *testing.T) { got, err := h.toolEffect(c, testOpID, Execution{Tool: func(context.Context, model.ToolCall) PreparedTool { t.Fatalf("preparer must not run for a malformed callback outcome") return PreparedTool{} - }, NormalizeTool: boundaryNormalize}, testCapture())(context.Background(), testToolCall("call-1")) + }, NormalizeTool: boundaryNormalize}, testCapture())(context.Background(), testToolCall("call-1"), nil) testassert.NoError(t, err) testassert.Equal(t, normalized, 2) testassert.Require(t, got.Status == model.ResultError && got.Content == invalidToolResultContent, "result = %+v, want the fixed internal-validation error", got) @@ -1936,7 +1968,7 @@ func TestToolOutcomeMetadataRules(t *testing.T) { } return PreparedTool{Permissions: fixturePermission, Execute: func(context.Context) ToolOutcome { return outcome }} } - got, err := h.toolEffect(c, testOpID, effectExecution(nil, plan), testCapture())(context.Background(), testToolCall("call-1")) + got, err := h.toolEffect(c, testOpID, effectExecution(nil, plan), testCapture())(context.Background(), testToolCall("call-1"), nil) testassert.NoError(t, err) want := model.ToolResult{CallID: "call-1", Status: model.ResultSuccess, Content: "ran"} testassert.Equal(t, got, want) @@ -2231,18 +2263,18 @@ func (s *cancelOnCloseStream) Close() error { } // TestToolResultAdoptsIntoView proves a committed tool result adopts into the -// coordinator view: the next contextSource projection carries it as its +// coordinator view: the next request-source projection carries it as its // model.RoleTool message, and a second tool effect in the same Operation sees // the first one committed. func TestToolResultAdoptsIntoView(t *testing.T) { h, _, c, sessionID := newEffectHarness(t, nil) publishCalls(t, h, c, sessionID, testToolCall("call-1"), testToolCall("call-2")) - source := h.contextSource(c, testOpID) + source := fixtureProjection(h, c) immediate := func(_ context.Context, call model.ToolCall) PreparedTool { return PreparedTool{Permissions: fixturePermission, Immediate: &ToolOutcome{Result: model.ToolResult{CallID: call.ID, Status: model.ResultSuccess, Content: "ran " + call.ID}}} } { - _, err := h.toolEffect(c, testOpID, effectExecution(nil, immediate), testCapture())(context.Background(), testToolCall("call-1")) + _, err := h.toolEffect(c, testOpID, effectExecution(nil, immediate), testCapture())(context.Background(), testToolCall("call-1"), nil) testassert.NoError(t, err) } msgs, err := source(context.Background()) @@ -2252,7 +2284,7 @@ func TestToolResultAdoptsIntoView(t *testing.T) { tool := msgs[len(msgs)-1] testassert.Require(t, tool.Role == model.RoleTool && tool.ToolCallID == "call-1" && tool.TextContent() == "ran call-1", "projected tool message = %+v, want the committed call-1 result", tool) { - _, err := h.toolEffect(c, testOpID, effectExecution(nil, immediate), testCapture())(context.Background(), testToolCall("call-2")) + _, err := h.toolEffect(c, testOpID, effectExecution(nil, immediate), testCapture())(context.Background(), testToolCall("call-2"), nil) testassert.NoError(t, err) } msgs, err = source(context.Background()) @@ -2502,7 +2534,7 @@ func TestSettleAgentTerminalClassifiesRunError(t *testing.T) { if err != nil { return err } - op.State.ActiveEffect = &ActiveEffect{Kind: EffectModel, ResultEntryID: hexID(7)} + op.State.ActiveEffect = &ActiveEffect{Kind: EffectModel, ResultEntryID: hexID(7), RequestID: hexID(99)} payload, err := encodeOperationRegister(op) if err != nil { return err @@ -2606,7 +2638,7 @@ func TestToolEffectGateBeforePreparation(t *testing.T) { } return nil } - got, err := h.toolEffect(c, testOpID, effectExecution(nil, spy.tool), testCapture())(ctx, testToolCall("call-1")) + got, err := h.toolEffect(c, testOpID, effectExecution(nil, spy.tool), testCapture())(ctx, testToolCall("call-1"), nil) testassert.NoError(t, err) { dispatched := spy.dispatched() diff --git a/harness/graph_test.go b/harness/graph_test.go index 40b8e133..8aea7827 100644 --- a/harness/graph_test.go +++ b/harness/graph_test.go @@ -446,7 +446,7 @@ func TestGraphValidatorAcceptsCoherentSessions(t *testing.T) { t.Run("running model effect", func(t *testing.T) { fixture := validTestGraph() - fixture.ops[0].State.ActiveEffect = &ActiveEffect{Kind: EffectModel, ResultEntryID: hexID(3)} + fixture.ops[0].State.ActiveEffect = &ActiveEffect{Kind: EffectModel, ResultEntryID: hexID(3), RequestID: hexID(99)} view, err := validateFixture(t, fixture.storage(t), testSessionID) testassert.NoError(t, err) testassert.Require(t, view.Operations[0].State.ActiveEffect != nil && view.Operations[0].State.ActiveEffect.Kind == EffectModel, "typed view lost the active model effect") @@ -669,6 +669,15 @@ func TestGraphValidatorSurfacesCorruption(t *testing.T) { testassert.NoError(t, err) fixture.entries[0].rawOverride = setKey(raw, "bogus", json.RawMessage(`1`)) }}, + {"assistant payload without its request id", func(t *testing.T, fixture *testGraph) { + raw, err := encodeAssistantEntry(*fixture.entries[1].assistant) + testassert.NoError(t, err) + obj := wireObject(t, raw) + delete(obj, "request_id") + patched, err := json.Marshal(obj) + testassert.NoError(t, err) + fixture.entries[1].rawOverride = patched + }}, {"corrupt session register payload", func(t *testing.T, fixture *testGraph) { raw, err := encodeSessionRegister(fixture.session) testassert.NoError(t, err) @@ -1044,7 +1053,7 @@ func TestGraphValidatorSurfacesCorruption(t *testing.T) { } }}, {"model effect reserves a committed entry", func(t *testing.T, fixture *testGraph) { - fixture.ops[0].State.ActiveEffect = &ActiveEffect{Kind: EffectModel, ResultEntryID: hexID(1)} + fixture.ops[0].State.ActiveEffect = &ActiveEffect{Kind: EffectModel, ResultEntryID: hexID(1), RequestID: hexID(99)} }}, {"model effect reserves a tool call reservation", func(t *testing.T, fixture *testGraph) { assistant := validAssistantEntry(testOpID) @@ -1058,7 +1067,7 @@ func TestGraphValidatorSurfacesCorruption(t *testing.T) { CallID: "call-1", ResultEntryID: hexID(3), }} - fixture.ops[0].State.ActiveEffect = &ActiveEffect{Kind: EffectModel, ResultEntryID: hexID(3)} + fixture.ops[0].State.ActiveEffect = &ActiveEffect{Kind: EffectModel, ResultEntryID: hexID(3), RequestID: hexID(99)} }}, {"terminal op carries a second settlement entry", func(t *testing.T, fixture *testGraph) { fixture.session.State.CurrentOperationID = "" diff --git a/harness/hooks_test.go b/harness/hooks_test.go index 92dfa150..7a353054 100644 --- a/harness/hooks_test.go +++ b/harness/hooks_test.go @@ -142,7 +142,7 @@ func TestToolArgumentHookChainSettlement(t *testing.T) { publishHookCalls(t, h, c, exec, model.ToolCall{ID: "call-1", Name: "echo", Arguments: json.RawMessage(` {"x": 1} `)}) te := h.toolEffect(c, testOpID, exec, testCapture()) - got, err := te(context.Background(), testToolCall("call-1")) + got, err := te(context.Background(), testToolCall("call-1"), nil) testassert.NoError(t, err) testassert.Equal(t, got, (model.ToolResult{CallID: "call-1", Status: model.ResultSuccess, Content: "done"})) // Callback counts: one normalization for the valid original at the @@ -182,7 +182,7 @@ func TestToolArgumentHookChainSettlement(t *testing.T) { rec, err := h.ReadOperation(context.Background(), sessionID, testOpID) testassert.NoError(t, err) testassert.Require(t, rec.State.Status == OperationRunning && rec.State.ActiveEffect == nil && len(rec.State.PendingToolCalls) == 0, "operation state = %+v, want a quiet running Operation with the call settled", rec.State) - messages, err := h.contextSource(c, testOpID)(context.Background()) + messages, err := fixtureProjection(h, c)(context.Background()) testassert.NoError(t, err) for _, msg := range messages { for _, part := range msg.Content { @@ -207,7 +207,7 @@ func TestToolArgumentHookInterruptionRules(t *testing.T) { return nil, ctx.Err() }} exec.ToolHooks = []ToolArgumentsHook{hook} - got, err := h.toolEffect(c, testOpID, exec, testCapture())(ctx, testToolCall("call-1")) + got, err := h.toolEffect(c, testOpID, exec, testCapture())(ctx, testToolCall("call-1"), nil) testassert.NoError(t, err) testassert.Equal(t, got, (model.ToolResult{CallID: "call-1", Status: model.ResultInterrupted, Content: interruptedToolResultContent})) committed := hookGraphHookResults(t, store, sessionID) @@ -240,7 +240,7 @@ func TestToolArgumentHookInterruptionRules(t *testing.T) { return nil } baselineReplaces = replaces - got, err := h.toolEffect(c, testOpID, exec, testCapture())(ctx, testToolCall("call-1")) + got, err := h.toolEffect(c, testOpID, exec, testCapture())(ctx, testToolCall("call-1"), nil) testassert.NoError(t, err) testassert.Equal(t, ran, 0) testassert.Equal(t, got, (model.ToolResult{CallID: "call-1", Status: model.ResultInterrupted, Content: interruptedToolResultContent})) @@ -268,7 +268,7 @@ func TestToolArgumentHookInterruptionRules(t *testing.T) { return json.RawMessage(`{}`), nil }}, } - got, err := h.toolEffect(c, testOpID, exec, testCapture())(ctx, testToolCall("call-1")) + got, err := h.toolEffect(c, testOpID, exec, testCapture())(ctx, testToolCall("call-1"), nil) testassert.NoError(t, err) testassert.Equal(t, got, (model.ToolResult{CallID: "call-1", Status: model.ResultInterrupted, Content: interruptedToolResultContent})) testassert.Equal(t, secondRan, 0) @@ -287,7 +287,7 @@ func TestToolArgumentHookInterruptionRules(t *testing.T) { exec := Execution{Tool: func(context.Context, model.ToolCall) PreparedTool { return PreparedTool{} }, NormalizeTool: objectNormalize, ToolHooks: []ToolArgumentsHook{hook}} h, store, c, sessionID := newHookHarness(t, exec) publishHookCalls(t, h, c, exec, testToolCall("call-1")) - got, err := h.toolEffect(c, testOpID, exec, testCapture())(context.Background(), testToolCall("call-1")) + got, err := h.toolEffect(c, testOpID, exec, testCapture())(context.Background(), testToolCall("call-1"), nil) testassert.NoError(t, err) testassert.Require(t, got.Status == model.ResultError && got.Content == "context canceled", "tool result = %+v, want the validation error with the hook's diagnostic", got) committed := hookGraphHookResults(t, store, sessionID) @@ -308,7 +308,7 @@ func TestToolArgumentHookInterruptionRules(t *testing.T) { return json.RawMessage(`{"fixed":true}`), nil }} exec.ToolHooks = []ToolArgumentsHook{hook} - got, err := h.toolEffect(c, testOpID, exec, testCapture())(ctx, testToolCall("call-1")) + got, err := h.toolEffect(c, testOpID, exec, testCapture())(ctx, testToolCall("call-1"), nil) testassert.NoError(t, err) // The successful replacement committed as a real result; the // interruption appears at the next boundary (the tool intent). @@ -404,7 +404,7 @@ func TestToolArgumentHookFailureRules(t *testing.T) { h, store, c, sessionID := newHookHarness(t, exec) publishHookCalls(t, h, c, exec, testToolCall("call-1"), testToolCall("call-2")) - got, err := h.toolEffect(c, testOpID, exec, testCapture())(context.Background(), testToolCall("call-1")) + got, err := h.toolEffect(c, testOpID, exec, testCapture())(context.Background(), testToolCall("call-1"), nil) testassert.NoError(t, err) testassert.Equal(t, got, tc.want) testassert.Equal(t, later, 0) @@ -412,7 +412,7 @@ func TestToolArgumentHookFailureRules(t *testing.T) { testassert.Require(t, len(committed) == 1 && committed[0].Status == tc.wantStatus && committed[0].HookID == "cap.hook.one" && len(committed[0].Arguments) == 0 && committed[0].Error != "", "hook results = %+v, want one %s result with a non-empty diagnostic", committed, tc.wantStatus) testassert.Require(t, tc.wantError == "" || committed[0].Error == tc.wantError, "hook result error = %q, want %q", committed[0].Error, tc.wantError) // Later calls still run: call-2 executes after the failed chain. - got, err = h.toolEffect(c, testOpID, exec, testCapture())(context.Background(), testToolCall("call-2")) + got, err = h.toolEffect(c, testOpID, exec, testCapture())(context.Background(), testToolCall("call-2"), nil) testassert.NoError(t, err) testassert.Require(t, got.Status == model.ResultSuccess, "later call result = %+v, want success", got) prepareCalls := spy.received() @@ -464,7 +464,7 @@ func TestToolArgumentHookFailedResultCommitLeavesRecovery(t *testing.T) { return nil } { - _, err := h.toolEffect(c, testOpID, exec, testCapture())(context.Background(), testToolCall("call-1")) + _, err := h.toolEffect(c, testOpID, exec, testCapture())(context.Background(), testToolCall("call-1"), nil) testassert.Require(t, errors.Is(err, ErrStorage), "tool effect = %v, want the injected storage failure", err) } mu.Lock() @@ -543,7 +543,7 @@ func TestToolArgumentHookDenialSettlesAfterHooks(t *testing.T) { }) h, store, c, sessionID := newHookHarness(t, exec) publishHookCalls(t, h, c, exec, testToolCall("call-1")) - got, err := h.toolEffect(c, testOpID, exec, testCapture())(context.Background(), testToolCall("call-1")) + got, err := h.toolEffect(c, testOpID, exec, testCapture())(context.Background(), testToolCall("call-1"), nil) testassert.NoError(t, err) testassert.Equal(t, got, (model.ToolResult{CallID: "call-1", Status: model.ResultDenied, Content: PermissionDeniedContent})) testassert.Equal(t, executed, 0) @@ -556,7 +556,7 @@ func TestToolArgumentHookDenialSettlesAfterHooks(t *testing.T) { exec := buildExec(spy.tool) h, _, c, _ := newHookHarness(t, exec) publishHookCalls(t, h, c, exec, testToolCall("call-1")) - got, err := h.toolEffect(c, testOpID, exec, testCapture())(context.Background(), testToolCall("call-1")) + got, err := h.toolEffect(c, testOpID, exec, testCapture())(context.Background(), testToolCall("call-1"), nil) testassert.NoError(t, err) testassert.Require(t, got.Status == model.ResultSuccess, "tool result = %+v, want success", got) prepared := spy.received() @@ -566,21 +566,37 @@ func TestToolArgumentHookDenialSettlesAfterHooks(t *testing.T) { // TestToolArgumentHookAdvertisementGate proves the one advertisement gate // precedes every hook: a committed call whose tool name is outside the -// advertised set settles the unavailable-tool error and never runs a hook. +// producing request's advertised set settles the unavailable-tool error and +// never runs a hook — neither a later capture advertising that name nor a +// forged names argument grants the committed call retroactive authority. func TestToolArgumentHookAdvertisementGate(t *testing.T) { ran := 0 hook := ToolArgumentsHook{ID: "cap.hook.one", Run: func(context.Context, model.ToolCall) (json.RawMessage, error) { ran++ return json.RawMessage(`{}`), nil }} - exec := Execution{Tool: func(context.Context, model.ToolCall) PreparedTool { return PreparedTool{} }, NormalizeTool: objectNormalize, ToolHooks: []ToolArgumentsHook{hook}} + prepared := 0 + exec := Execution{Tool: func(context.Context, model.ToolCall) PreparedTool { prepared++; return PreparedTool{} }, NormalizeTool: objectNormalize, ToolHooks: []ToolArgumentsHook{hook}} h, store, c, sessionID := newHookHarness(t, exec) - publishHookCalls(t, h, c, exec, model.ToolCall{ID: "call-1", Name: "ghost", Arguments: json.RawMessage(`{}`)}) + publishHookCalls(t, h, c, exec, + model.ToolCall{ID: "call-1", Name: "ghost", Arguments: json.RawMessage(`{}`)}, + model.ToolCall{ID: "call-2", Name: "ghost", Arguments: json.RawMessage(`{}`)}, + ) - got, err := h.toolEffect(c, testOpID, exec, testCapture())(context.Background(), model.ToolCall{ID: "call-1", Name: "ghost"}) + got, err := h.toolEffect(c, testOpID, exec, testCapture())(context.Background(), model.ToolCall{ID: "call-1", Name: "ghost"}, nil) testassert.NoError(t, err) testassert.Equal(t, got, (model.ToolResult{CallID: "call-1", Status: model.ResultError, Content: `Tool "ghost" is not available.`})) testassert.Equal(t, ran, 0) + testassert.Equal(t, prepared, 0) + { + ghostCapture := testCapture() + ghostCapture.Tools = append(ghostCapture.Tools, model.ToolDefinition{Name: "ghost", Description: "ghost tool", Parameters: json.RawMessage(`{"type":"object"}`)}) + got, err := h.toolEffect(c, testOpID, exec, ghostCapture)(context.Background(), model.ToolCall{ID: "call-2", Name: "ghost"}, []string{"ghost"}) + testassert.NoError(t, err) + testassert.Require(t, got.CallID == "call-2" && got.Status == model.ResultError && got.Content == `Tool "ghost" is not available.`, "gate under a retroactive capture and forged names = %+v, want the ordinary unavailable-tool error", got) + } + testassert.Equal(t, ran, 0) + testassert.Equal(t, prepared, 0) { got := hookGraphHookResults(t, store, sessionID) testassert.Require(t, len(got) == 0, "hook results = %+v, want none", got) @@ -654,7 +670,7 @@ func TestToolArgumentHookIntentFailureLeavesRecovery(t *testing.T) { return nil } { - _, err := h.toolEffect(c, testOpID, exec, testCapture())(context.Background(), testToolCall("call-1")) + _, err := h.toolEffect(c, testOpID, exec, testCapture())(context.Background(), testToolCall("call-1"), nil) testassert.Require(t, errors.Is(err, ErrStorage), "tool effect = %v, want the injected storage failure", err) } testassert.Equal(t, ran, 0) diff --git a/harness/model_effect_test.go b/harness/model_effect_test.go index 97772d87..874136c2 100644 --- a/harness/model_effect_test.go +++ b/harness/model_effect_test.go @@ -401,7 +401,7 @@ func TestModelEffectAcceptedStreamNeverRetries(t *testing.T) { testassert.Require(t, rec.State.Status == OperationRunning && rec.State.ActiveEffect == nil, "operation state = %+v, want running with the effect cleared", rec.State) // Partial continuation: the next context projection reads the committed // partial from history. - msgs, err := h.contextSource(c, testOpID)(context.Background()) + msgs, err := fixtureProjection(h, c)(context.Background()) testassert.NoError(t, err) // system, input, committed partial, continuation signal testassert.Equal(t, len(msgs), 4) diff --git a/harness/observe_test.go b/harness/observe_test.go index a1c54188..5439ae5e 100644 --- a/harness/observe_test.go +++ b/harness/observe_test.go @@ -120,7 +120,7 @@ func TestObserveFactKinds(t *testing.T) { return PreparedTool{Permissions: fixturePermission, Immediate: &ToolOutcome{Result: model.ToolResult{CallID: call.ID, Status: model.ResultSuccess, Content: "done"}}} } { - _, err := h.toolEffect(c, testOpID, effectExecution(nil, plan), testCapture())(context.Background(), testToolCall("call-1")) + _, err := h.toolEffect(c, testOpID, effectExecution(nil, plan), testCapture())(context.Background(), testToolCall("call-1"), nil) testassert.NoError(t, err) } @@ -733,7 +733,7 @@ func TestObserveIntentOnly(t *testing.T) { go func() { defer close(done) { - _, err := h.toolEffect(c, testOpID, effectExecution(nil, plan), testCapture())(context.Background(), testToolCall("call-1")) + _, err := h.toolEffect(c, testOpID, effectExecution(nil, plan), testCapture())(context.Background(), testToolCall("call-1"), nil) testassert.Check(t, err == nil, "tool effect: %v", err) } }() @@ -1333,7 +1333,7 @@ func TestObserveToolLifecycle(t *testing.T) { cancel() ctx = canceled } - got, err := h.toolEffect(c, testOpID, effectExecution(nil, tc.plan), testCapture())(ctx, testToolCall("call-1")) + got, err := h.toolEffect(c, testOpID, effectExecution(nil, tc.plan), testCapture())(ctx, testToolCall("call-1"), nil) testassert.NoError(t, err) testassert.Equal(t, got.Status, tc.status) facts := col.snapshot() @@ -1374,7 +1374,7 @@ func TestObserveToolLifecycle(t *testing.T) { }, usageDelta(model.Usage{InputTokens: 1, OutputTokens: 1})) publishStream(t, h, c, stream) col := observeHarness(h) - got, err := h.toolEffect(c, testOpID, effectExecution(nil, nil), testCapture())(context.Background(), testToolCall("call-1")) + got, err := h.toolEffect(c, testOpID, effectExecution(nil, nil), testCapture())(context.Background(), testToolCall("call-1"), nil) testassert.Require(t, err == nil && got.Status == model.ResultError, "unavailable tool = %+v err %v, want the error result", got, err) facts := col.snapshot() testassert.Require(t, len(facts) == 3 && facts[0].Kind == FactToolStarted && facts[1].Kind == FactInvalidation && facts[2].Kind == FactToolFinished, "advertisement-gate facts = %+v, want started, the result commit, then finished", facts) @@ -1387,7 +1387,7 @@ func TestObserveToolLifecycle(t *testing.T) { return PreparedTool{Permissions: fixturePermission, Immediate: &ToolOutcome{Result: model.ToolResult{CallID: call.ID, Status: model.ResultSuccess, Content: "done"}}} } { - _, err := h.toolEffect(c, testOpID, effectExecution(nil, successPlan), testCapture())(context.Background(), testToolCall("call-1")) + _, err := h.toolEffect(c, testOpID, effectExecution(nil, successPlan), testCapture())(context.Background(), testToolCall("call-1"), nil) testassert.NoError(t, err) } col := observeHarness(h) @@ -1419,7 +1419,7 @@ func TestObserveToolLifecycle(t *testing.T) { return nil, errors.New("hook boom") }}} col := observeHarness(h) - got, err := h.toolEffect(c, testOpID, exec, testCapture())(context.Background(), testToolCall("call-1")) + got, err := h.toolEffect(c, testOpID, exec, testCapture())(context.Background(), testToolCall("call-1"), nil) testassert.Require(t, err == nil && got.Status == model.ResultError, "hook-settled tool = %+v err %v, want the error result", got, err) var started, finished, invalidations int for _, fact := range col.snapshot() { diff --git a/harness/payload.go b/harness/payload.go index 1d799f69..07f958ab 100644 --- a/harness/payload.go +++ b/harness/payload.go @@ -340,6 +340,8 @@ func encodeAssistantEntry(v assistantEntry) (json.RawMessage, error) { SessionID string `json:"session_id"` EntryID string `json:"entry_id"` OperationID string `json:"operation_id,omitempty"` + RequestID string `json:"request_id"` + Advertised []string `json:"advertised,omitempty"` Status string `json:"status"` Source json.RawMessage `json:"source"` Content []json.RawMessage `json:"content"` @@ -351,6 +353,8 @@ func encodeAssistantEntry(v assistantEntry) (json.RawMessage, error) { SessionID: v.SessionID, EntryID: v.EntryID, OperationID: v.OperationID, + RequestID: v.RequestID, + Advertised: v.Advertised, Status: string(v.Status), Source: source, Content: content, @@ -376,7 +380,7 @@ func decodeAssistantEntry(env Entry) (assistantEntry, error) { if err != nil { return assistantEntry{}, err } - if err := rejectUnknownMembers(obj, "session_id", "entry_id", "operation_id", "status", "source", "content", "refusal", "extra", "tool_calls", "usage"); err != nil { + if err := rejectUnknownMembers(obj, "session_id", "entry_id", "operation_id", "request_id", "advertised", "status", "source", "content", "refusal", "extra", "tool_calls", "usage"); err != nil { return assistantEntry{}, err } sessionID, err := stringMember(obj, "session_id", true) @@ -391,6 +395,10 @@ func decodeAssistantEntry(env Entry) (assistantEntry, error) { if err != nil { return assistantEntry{}, err } + requestID, err := stringMember(obj, "request_id", true) + if err != nil { + return assistantEntry{}, err + } status, err := stringMember(obj, "status", true) if err != nil { return assistantEntry{}, err @@ -444,12 +452,26 @@ func decodeAssistantEntry(env Entry) (assistantEntry, error) { SessionID: sessionID, EntryID: entryID, OperationID: operationID, + RequestID: requestID, Status: model.OutputStatus(status), Source: source, Refusal: refusal, Extra: extra, Usage: usage, } + if _, present := obj["advertised"]; present { + advertisedRaw, err := arrayMember(obj, "advertised", true) + if err != nil { + return assistantEntry{}, err + } + for i, raw := range advertisedRaw { + var name string + if err := json.Unmarshal(raw, &name); err != nil { + return assistantEntry{}, fmt.Errorf("advertised[%d]: must be a JSON string: %w", i, err) + } + v.Advertised = append(v.Advertised, name) + } + } for i, raw := range contentRaw { part, err := decodeContentPart(raw) if err != nil { @@ -474,9 +496,10 @@ func decodeAssistantEntry(env Entry) (assistantEntry, error) { } // validateAssistantEntry enforces the closed assistant-entry shape: closed -// output status, complete source identity, unique tool calls with zero-based -// ordinals and unique reserved results, no calls on errored or interrupted -// output, and an eligible model-visible payload. +// output status, complete source identity, the producing request's non-empty +// identity with unique non-empty advertised tool names, unique tool calls with +// zero-based ordinals and unique reserved results, no calls on errored or +// interrupted output, and an eligible model-visible payload. func validateAssistantEntry(v assistantEntry) error { if err := validateHexID(v.SessionID, "session id"); err != nil { return err @@ -491,6 +514,19 @@ func validateAssistantEntry(v assistantEntry) error { } else if v.Usage != nil { return errors.New("independently copied fork-prefix assistant entry carries no source usage") } + if v.RequestID == "" { + return errors.New("assistant entry requires its producing request id") + } + seenAdvertised := make(map[string]bool, len(v.Advertised)) + for i, name := range v.Advertised { + if name == "" { + return fmt.Errorf("advertised[%d]: tool name must be non-empty", i) + } + if seenAdvertised[name] { + return fmt.Errorf("advertised[%d]: tool name %q is not unique", i, name) + } + seenAdvertised[name] = true + } if !validOutputStatus(v.Status) { return fmt.Errorf("assistant status %q is not one of completed, errored or interrupted", v.Status) } @@ -2288,7 +2324,7 @@ func decodeOperationState(obj map[string]json.RawMessage) (OperationCurrentState // decodeActiveEffect reads one active effect with exact keys. func decodeActiveEffect(obj map[string]json.RawMessage) (ActiveEffect, error) { - if err := rejectUnknownMembers(obj, "kind", "result_entry_id", "tool_call_id", "hook_id"); err != nil { + if err := rejectUnknownMembers(obj, "kind", "result_entry_id", "request_id", "tool_call_id", "hook_id"); err != nil { return ActiveEffect{}, err } kind, err := stringMember(obj, "kind", true) @@ -2299,6 +2335,10 @@ func decodeActiveEffect(obj map[string]json.RawMessage) (ActiveEffect, error) { if err != nil { return ActiveEffect{}, err } + requestID, err := optionalNonEmptyString(obj, "request_id") + if err != nil { + return ActiveEffect{}, err + } toolCallID, err := optionalNonEmptyString(obj, "tool_call_id") if err != nil { return ActiveEffect{}, err @@ -2307,7 +2347,7 @@ func decodeActiveEffect(obj map[string]json.RawMessage) (ActiveEffect, error) { if err != nil { return ActiveEffect{}, err } - v := ActiveEffect{Kind: EffectKind(kind), ResultEntryID: resultEntryID, ToolCallID: toolCallID, HookID: hookID} + v := ActiveEffect{Kind: EffectKind(kind), ResultEntryID: resultEntryID, RequestID: requestID, ToolCallID: toolCallID, HookID: hookID} if err := validateHexID(v.ResultEntryID, "active effect result entry id"); err != nil { return ActiveEffect{}, err } @@ -2374,8 +2414,9 @@ func decodeOperationTerminal(obj map[string]json.RawMessage) (OperationTerminal, // active and pending effects, carry the contract detail rule, and name a // durable settlement entry; a tool active effect addresses the matching first // pending call under its reserved result identity; active effects carry -// durable result identities; pending calls have unique identities and -// reserved results. +// durable result identities and the request id rule is closed by kind — model +// requires one, tool and hook forbid it; pending calls have unique identities +// and reserved results. func validateOperationState(v OperationCurrentState) error { switch v.Status { case OperationRunning, OperationSuccess, OperationFailure, OperationInterruption: @@ -2425,6 +2466,9 @@ func validateOperationState(v OperationCurrentState) error { } switch v.ActiveEffect.Kind { case EffectModel: + if v.ActiveEffect.RequestID == "" { + return errors.New("model active effect requires its request id") + } if v.ActiveEffect.ToolCallID != "" { return errors.New("model active effect omits the tool call id") } @@ -2432,6 +2476,9 @@ func validateOperationState(v OperationCurrentState) error { return errors.New("model active effect omits the hook id") } case EffectTool: + if v.ActiveEffect.RequestID != "" { + return errors.New("tool active effect omits the request id") + } if v.ActiveEffect.ToolCallID == "" { return errors.New("tool active effect requires its tool call id") } @@ -2445,6 +2492,9 @@ func validateOperationState(v OperationCurrentState) error { return errors.New("tool active effect must reserve the first pending call's result identity") } case EffectHook: + if v.ActiveEffect.RequestID != "" { + return errors.New("hook active effect omits the request id") + } if v.ActiveEffect.ToolCallID == "" { return errors.New("hook active effect requires its tool call id") } diff --git a/harness/public_test.go b/harness/public_test.go index 59945d4c..53111246 100644 --- a/harness/public_test.go +++ b/harness/public_test.go @@ -3124,7 +3124,7 @@ func rawAssistantEntryPayload(sessionID, entryID, operationID string, calls [][2 call[0], i, args, call[1])) } return fmt.Sprintf( - `{"session_id":%q,"entry_id":%q,"operation_id":%q,"status":"completed",`+ + `{"session_id":%q,"entry_id":%q,"operation_id":%q,"request_id":"raw-request-1","status":"completed",`+ `"source":{"provider":"prov","model":"gpt-x"},"content":[{"kind":"text","text":"done"}],"tool_calls":[%s]}`, sessionID, entryID, operationID, strings.Join(parts, ",")) } @@ -3153,7 +3153,7 @@ func seedRunningOperationShape(t *testing.T, store harness.Storage, activeEffect switch activeEffect { case "model": modelReserved = newRawSessionID(t) - activeEffectJSON = fmt.Sprintf(`,"active_effect":{"kind":"model","result_entry_id":%q}`, modelReserved) + activeEffectJSON = fmt.Sprintf(`,"active_effect":{"kind":"model","result_entry_id":%q,"request_id":"raw-request-1"}`, modelReserved) case "tool": activeEffectJSON = fmt.Sprintf(`,"active_effect":{"kind":"tool","result_entry_id":%q,"tool_call_id":%q}`, reserved[0], calls[0]) case "hook": @@ -3502,7 +3502,7 @@ func rawCopiedAssistantPayload(sessionID, entryID string, calls [][2]string) str call[0], i, args, call[1])) } return fmt.Sprintf( - `{"session_id":%q,"entry_id":%q,"status":"completed",`+ + `{"session_id":%q,"entry_id":%q,"request_id":"raw-request-1","status":"completed",`+ `"source":{"provider":"prov","model":"gpt-x"},"content":[{"kind":"text","text":"done"}],"tool_calls":[%s]}`, sessionID, entryID, strings.Join(parts, ",")) } @@ -4352,7 +4352,7 @@ func TestPublicRecoverCorruptSibling(t *testing.T) { input := snapEntryOf(snap, harness.EntryInput) reg := snapRegisterOf(snap, harness.RegisterOperation, "op-1") reg.Payload = editStateObject(t, reg.Payload, func(state map[string]json.RawMessage) { - state["active_effect"] = json.RawMessage(fmt.Sprintf(`{"kind":"model","result_entry_id":%q}`, input.ID)) + state["active_effect"] = json.RawMessage(fmt.Sprintf(`{"kind":"model","result_entry_id":%q,"request_id":"raw-request-1"}`, input.ID)) }) }, }, @@ -4363,7 +4363,7 @@ func TestPublicRecoverCorruptSibling(t *testing.T) { reserved := snapReservedResultID(t, snap) reg := snapRegisterOf(snap, harness.RegisterOperation, "op-1") reg.Payload = editStateObject(t, reg.Payload, func(state map[string]json.RawMessage) { - state["active_effect"] = json.RawMessage(fmt.Sprintf(`{"kind":"model","result_entry_id":%q}`, reserved)) + state["active_effect"] = json.RawMessage(fmt.Sprintf(`{"kind":"model","result_entry_id":%q,"request_id":"raw-request-1"}`, reserved)) }) }, }, @@ -4958,7 +4958,9 @@ func TestPublicForkCopiesPrefixAndAdmits(t *testing.T) { } testassert.Equal(t, copiedInput.Origin, "user") var copiedAssistant struct { - ToolCalls []struct { + RequestID string `json:"request_id"` + Advertised []string `json:"advertised"` + ToolCalls []struct { ID string `json:"id"` ResultEntryID string `json:"result_entry_id"` NormalizedArguments json.RawMessage `json:"normalized_arguments"` @@ -4972,6 +4974,24 @@ func TestPublicForkCopiesPrefixAndAdmits(t *testing.T) { testassert.Require(t, copiedAssistant.Usage == nil, "copied assistant carries source usage") testassert.Require(t, len(copiedAssistant.ToolCalls) == 1 && copiedAssistant.ToolCalls[0].ID == "call-1" && copiedAssistant.ToolCalls[0].ResultEntryID == entries[2].ID, "copied call reservation = %+v, want it rewritten to the copied result entry %s", copiedAssistant.ToolCalls, entries[2].ID) testassert.Require(t, string(copiedAssistant.ToolCalls[0].NormalizedArguments) == `{"x":1}`, "copied assistant normalized arguments = %s, want the source's committed normalization preserved verbatim", copiedAssistant.ToolCalls[0].NormalizedArguments) + sourceAssistant := snapEntryOf(&before, harness.EntryAssistant) + testassert.Require(t, sourceAssistant != nil, "the source snapshot carries no assistant entry") + var sourceAssistantWire struct { + RequestID string `json:"request_id"` + Advertised []string `json:"advertised"` + ToolCalls []struct { + ID string `json:"id"` + ResultEntryID string `json:"result_entry_id"` + NormalizedArguments json.RawMessage `json:"normalized_arguments"` + } `json:"tool_calls"` + Usage *struct{} `json:"usage"` + } + { + err := json.Unmarshal(sourceAssistant.Payload, &sourceAssistantWire) + testassert.NoError(t, err) + } + testassert.Require(t, copiedAssistant.RequestID != "" && copiedAssistant.RequestID == sourceAssistantWire.RequestID, "copied assistant request id = %q, want the source assistant's %q", copiedAssistant.RequestID, sourceAssistantWire.RequestID) + testassert.Require(t, reflect.DeepEqual(copiedAssistant.Advertised, sourceAssistantWire.Advertised) && reflect.DeepEqual(copiedAssistant.Advertised, []string{"echo"}), "copied assistant advertised = %v, want the source's advertised set [echo] (source: %v)", copiedAssistant.Advertised, sourceAssistantWire.Advertised) var copiedResult struct { AssistantEntry struct { SessionID string `json:"session_id"` @@ -6010,7 +6030,7 @@ func rawSettledSuccessOperation(t *testing.T, store harness.Storage, sessionID, fmt.Sprintf(`{"session_id":%q,"entry_id":%q,"operation_id":%q,"origin":"user","content":[{"kind":"text","text":%q}]}`, sessionID, inputID, operationID, inputText)) insertRawEntry(t, store, sessionID, assistantID, operationID, harness.EntryAssistant, - fmt.Sprintf(`{"session_id":%q,"entry_id":%q,"operation_id":%q,"status":"completed",`+ + fmt.Sprintf(`{"session_id":%q,"entry_id":%q,"operation_id":%q,"request_id":"raw-request-1","status":"completed",`+ `"source":{"provider":"prov","model":"gpt-x"},"content":[{"kind":"text","text":%q}],"tool_calls":[]}`, sessionID, assistantID, operationID, assistantText)) insertRawEntry(t, store, sessionID, settlementID, operationID, harness.EntryOperationSettlement, @@ -6045,7 +6065,7 @@ func TestPublicRecoverRunningCompactOperation(t *testing.T) { now := time.Now().UTC().Format(time.RFC3339Nano) insertRawRegister(t, store, harness.RegisterKey{SessionID: sessionID, Kind: harness.RegisterOperation, OperationID: "compact-op"}, fmt.Sprintf(`{"admission":%s,"state":{"status":"running","started_at":%q,`+ - `"active_effect":{"kind":"model","result_entry_id":%q},"pending_tool_calls":[],"usage":{"by_model":[]}}}`, + `"active_effect":{"kind":"model","result_entry_id":%q,"request_id":"raw-request-1"},"pending_tool_calls":[],"usage":{"by_model":[]}}}`, rawCompactAdmission(sessionID, "compact-op"), now, reserved)) // The seeded shape: a running compact request with a live model intent. diff --git a/harness/schema.go b/harness/schema.go index 5c201ab7..d36d0bae 100644 --- a/harness/schema.go +++ b/harness/schema.go @@ -213,13 +213,16 @@ type OperationAdmission struct { } // ActiveEffect names the one in-flight effect of a running Operation and the -// entry reserved for its result. A model effect omits the tool-call and hook -// IDs; a tool effect requires the tool-call ID and addresses the matching -// first pending call; a hook effect requires both the hook ID and the -// tool-call ID of the first pending call it runs for. +// entry reserved for its result. A model effect carries the request identity +// its intent consumed and omits the tool-call and hook IDs; a tool effect +// requires the tool-call ID, addresses the matching first pending call, and +// carries no request identity; a hook effect requires both the hook ID and the +// tool-call ID of the first pending call it runs for and carries no request +// identity. type ActiveEffect struct { Kind EffectKind `json:"kind"` ResultEntryID string `json:"result_entry_id"` + RequestID string `json:"request_id,omitempty"` ToolCallID string `json:"tool_call_id,omitempty"` HookID string `json:"hook_id,omitempty"` } @@ -292,11 +295,14 @@ type toolCallRecord struct { } // assistantEntry is one finalized model output: status, complete source -// identity, eligible model-visible payload, and reported usage when present. +// identity, the producing request's identity and ordered advertised tool +// names, eligible model-visible payload, and reported usage when present. type assistantEntry struct { SessionID string `json:"session_id"` EntryID string `json:"entry_id"` OperationID string `json:"operation_id,omitempty"` + RequestID string `json:"request_id"` + Advertised []string `json:"advertised,omitempty"` Status model.OutputStatus `json:"status"` Source model.ModelRef `json:"source"` Content []model.ContentPart `json:"content"` diff --git a/harness/snapshot.go b/harness/snapshot.go index c5c6c965..9856d4ab 100644 --- a/harness/snapshot.go +++ b/harness/snapshot.go @@ -543,6 +543,9 @@ func ownInputPayload(v inputEntry) *inputEntry { func ownAssistantPayload(v assistantEntry) *assistantEntry { out := v + if len(v.Advertised) > 0 { + out.Advertised = append([]string(nil), v.Advertised...) + } out.Content = ownContentParts(v.Content) out.Extra = v.Extra.Clone() if len(v.ToolCalls) == 0 { diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index 121a6942..18aaa11a 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -30,9 +30,9 @@ const ( // sqliteSchemaVersion is the only accepted schema version; it is stamped // into PRAGMA user_version at initialization and checked at open. Version - // 7 is the changed capture and settlement payloads; the SQL tables are - // unchanged. - sqliteSchemaVersion = 7 + // 8 is the changed assistant-entry and operation-register payload shapes; + // the SQL tables are unchanged. + sqliteSchemaVersion = 8 // sqlitePoolDSN configures every physical connection of the long-lived // pool: write-ahead logging, full synchronous durability, immediate diff --git a/internal/storage/sqlite_test.go b/internal/storage/sqlite_test.go index fe729aea..451491b3 100644 --- a/internal/storage/sqlite_test.go +++ b/internal/storage/sqlite_test.go @@ -186,7 +186,7 @@ func sqliteFixtureDatabase(t *testing.T, path string, ddl []string, version int) } // TestSQLiteSchemaInitialization proves a missing path is initialized with the -// complete canonical schema in one shot: version 7, exactly the two canonical +// complete canonical schema in one shot: version 8, exactly the two canonical // tables and the one explicit index, only the autoindexes implied by those // tables, a well-formed image, no sidecars after close, and no partial file // when initialization fails. @@ -202,7 +202,7 @@ func TestSQLiteSchemaInitialization(t *testing.T) { var version int { err := conn.QueryRowContext(ctx, `PRAGMA user_version`).Scan(&version) - testassert.Require(t, err == nil && version == 7, "PRAGMA user_version = %d (error %v), want 7", version, err) + testassert.Require(t, err == nil && version == 8, "PRAGMA user_version = %d (error %v), want 8", version, err) } objects := map[string]string{} @@ -263,7 +263,7 @@ func TestSQLiteSchemaInitialization(t *testing.T) { var emptyVersion int { err := emptyStore.db.QueryRowContext(ctx, `PRAGMA user_version`).Scan(&emptyVersion) - testassert.Check(t, err == nil && emptyVersion == 7, "zero-length file user_version = %d (error %v), want 7", emptyVersion, err) + testassert.Check(t, err == nil && emptyVersion == 8, "zero-length file user_version = %d (error %v), want 8", emptyVersion, err) } testassert.NoError(t, emptyStore.Close()) sqliteAssertNoSidecars(t, empty) @@ -276,7 +276,7 @@ func TestSQLiteSchemaInitialization(t *testing.T) { var reservedVersion int { err := reservedStore.db.QueryRowContext(ctx, `PRAGMA user_version`).Scan(&reservedVersion) - testassert.Check(t, err == nil && reservedVersion == 7, "reserved-path user_version = %d (error %v), want 7", reservedVersion, err) + testassert.Check(t, err == nil && reservedVersion == 8, "reserved-path user_version = %d (error %v), want 8", reservedVersion, err) } testassert.NoError(t, reservedStore.Close()) { @@ -395,7 +395,7 @@ func TestSQLiteRejectsNonEmptyVersionZero(t *testing.T) { testassert.Require(t, errors.Is(err, harness.ErrIncompatible), "error = %v, want ErrIncompatible", err) var incompatible *harness.IncompatibleSchemaError testassert.Require(t, errors.As(err, &incompatible), "error = %T, want *harness.IncompatibleSchemaError", err) - testassert.Check(t, incompatible.Found == 0 && incompatible.Supported == 7, "recovered %+v, want found 0 supported 7", incompatible) + testassert.Check(t, incompatible.Found == 0 && incompatible.Supported == 8, "recovered %+v, want found 0 supported 8", incompatible) testassert.Check(t, store == nil, "rejected database returned a store") { after := sqliteFileDigest(t, path) @@ -405,19 +405,19 @@ func TestSQLiteRejectsNonEmptyVersionZero(t *testing.T) { } // TestSQLiteRejectsPriorVersion proves a database left at the prior schema -// version 6 — the child-lineage and background-completion payload shape with +// version 7 — the capture and settlement payload shape with // the unchanged canonical tables — is rejected through the read-only open // phase with the incompatible class before recovery ever sees the store, // leaving the file byte-identical with no sidecars. func TestSQLiteRejectsPriorVersion(t *testing.T) { path := filepath.Join(t.TempDir(), "prior.db") - sqliteFixtureDatabase(t, path, []string{sqliteEntriesDDL, sqliteRegistersDDL, sqliteOperationIndexDDL}, 6) + sqliteFixtureDatabase(t, path, []string{sqliteEntriesDDL, sqliteRegistersDDL, sqliteOperationIndexDDL}, 7) before := sqliteFileDigest(t, path) store, err := OpenSQLite(path) testassert.Require(t, errors.Is(err, harness.ErrIncompatible), "error = %v, want ErrIncompatible", err) var incompatible *harness.IncompatibleSchemaError - testassert.Check(t, errors.As(err, &incompatible) && incompatible.Found == 6 && incompatible.Supported == 7, "recovered %+v, want found 6 supported 7", incompatible) + testassert.Check(t, errors.As(err, &incompatible) && incompatible.Found == 7 && incompatible.Supported == 8, "recovered %+v, want found 7 supported 8", incompatible) testassert.Check(t, store == nil, "rejected prior-version database returned a store") { after := sqliteFileDigest(t, path) @@ -427,25 +427,25 @@ func TestSQLiteRejectsPriorVersion(t *testing.T) { } // TestSQLiteRejectsUnsupportedVersion proves a database with a future schema -// version beyond the accepted 7 is rejected without any file or sidecar +// version beyond the accepted 8 is rejected without any file or sidecar // change. func TestSQLiteRejectsUnsupportedVersion(t *testing.T) { path := filepath.Join(t.TempDir(), "future.db") - sqliteFixtureDatabase(t, path, []string{sqliteEntriesDDL, sqliteRegistersDDL, sqliteOperationIndexDDL}, 8) + sqliteFixtureDatabase(t, path, []string{sqliteEntriesDDL, sqliteRegistersDDL, sqliteOperationIndexDDL}, 9) before := sqliteFileDigest(t, path) _, err := OpenSQLite(path) testassert.Require(t, errors.Is(err, harness.ErrIncompatible), "error = %v, want ErrIncompatible", err) var incompatible *harness.IncompatibleSchemaError - testassert.Check(t, errors.As(err, &incompatible) && incompatible.Found == 8 && incompatible.Supported == 7, "recovered %+v, want found 8 supported 7", incompatible) + testassert.Check(t, errors.As(err, &incompatible) && incompatible.Found == 9 && incompatible.Supported == 8, "recovered %+v, want found 9 supported 8", incompatible) { after := sqliteFileDigest(t, path) - testassert.Check(t, after == before, "rejected version-8 database file changed") + testassert.Check(t, after == before, "rejected version-9 database file changed") } sqliteAssertNoSidecars(t, path) - // Unsupported hot-WAL sibling: version 8 lives only in an uncheckpointed - // WAL beside a checkpointed version 7 main file, with no SHM file — the + // Unsupported hot-WAL sibling: version 9 lives only in an uncheckpointed + // WAL beside a checkpointed version 8 main file, with no SHM file — the // crashed-writer state. Read-only validation must see the WAL-carried // version and reject the copy while leaving the database and WAL bytes // unchanged; a SQLite-owned -shm WAL index may appear and remain. @@ -453,7 +453,7 @@ func TestSQLiteRejectsUnsupportedVersion(t *testing.T) { live, err := sql.Open(sqliteDriverName, sqliteDSN(source, "_journal_mode=WAL&_synchronous=FULL")) testassert.NoError(t, err) { - _, err := live.Exec(`PRAGMA user_version = 7`) + _, err := live.Exec(`PRAGMA user_version = 8`) testassert.NoError(t, err) } { @@ -461,7 +461,7 @@ func TestSQLiteRejectsUnsupportedVersion(t *testing.T) { testassert.NoError(t, err) } { - _, err := live.Exec(`PRAGMA user_version = 8`) + _, err := live.Exec(`PRAGMA user_version = 9`) testassert.NoError(t, err) } mainBytes, err := os.ReadFile(source) @@ -478,7 +478,7 @@ func TestSQLiteRejectsUnsupportedVersion(t *testing.T) { crashedStore, err := OpenSQLite(target) testassert.Require(t, errors.Is(err, harness.ErrIncompatible), "error = %v, want ErrIncompatible", err) var walIncompatible *harness.IncompatibleSchemaError - testassert.Check(t, errors.As(err, &walIncompatible) && walIncompatible.Found == 8 && walIncompatible.Supported == 7, "recovered %+v, want the WAL-carried version found 8 supported 7", walIncompatible) + testassert.Check(t, errors.As(err, &walIncompatible) && walIncompatible.Found == 9 && walIncompatible.Supported == 8, "recovered %+v, want the WAL-carried version found 9 supported 8", walIncompatible) testassert.Check(t, crashedStore == nil, "rejected WAL-mode database returned a store") { after := sqliteFileDigest(t, target) @@ -520,12 +520,12 @@ ON registers(operation_id) WHERE kind = 'operation'` } { t.Run(name, func(t *testing.T) { path := filepath.Join(t.TempDir(), "schema.db") - sqliteFixtureDatabase(t, path, ddl, 7) + sqliteFixtureDatabase(t, path, ddl, 8) store, err := OpenSQLite(path) testassert.Require(t, errors.Is(err, harness.ErrIncompatible), "error = %v, want ErrIncompatible", err) var incompatible *harness.IncompatibleSchemaError testassert.Require(t, errors.As(err, &incompatible), "error = %T, want *harness.IncompatibleSchemaError", err) - testassert.Check(t, incompatible.Found == 7 && incompatible.Supported == 7, "recovered %+v, want found 7 supported 7", incompatible) + testassert.Check(t, incompatible.Found == 8 && incompatible.Supported == 8, "recovered %+v, want found 8 supported 8", incompatible) testassert.Check(t, store == nil, "rejected database returned a store") }) } @@ -585,7 +585,7 @@ func TestSQLitePersistenceAcrossCloseReopen(t *testing.T) { sqliteEntriesDDL, sqliteRegistersDDL, sqliteOperationIndexDDL, - `PRAGMA user_version = 7`, + `PRAGMA user_version = 8`, `INSERT INTO registers (session_id, kind, operation_id, revision, payload) VALUES ('s1', 'session', '', 1, '{"session":"s1"}')`, `INSERT INTO entries (session_id, sequence, entry_id, operation_id, kind, committed_at_ns, payload) VALUES ('s1', 1, 'e1', '', 'input', 1700000000000000000, '{"entry":"e1"}')`, } { diff --git a/runtime/compact_lifecycle_test.go b/runtime/compact_lifecycle_test.go index c56324d9..d9d67d9e 100644 --- a/runtime/compact_lifecycle_test.go +++ b/runtime/compact_lifecycle_test.go @@ -260,8 +260,8 @@ func compactRawOperationRegister(admission, state string) string { func compactRawRunningModelState(now, reserved string) string { return fmt.Sprintf( - `{"status":"running","started_at":%q,"active_effect":{"kind":"model","result_entry_id":%q},"pending_tool_calls":[],"usage":{"by_model":[]}}`, - now, reserved) + `{"status":"running","started_at":%q,"active_effect":{"kind":"model","result_entry_id":%q,"request_id":"req-%s"},"pending_tool_calls":[],"usage":{"by_model":[]}}`, + now, reserved, reserved) } func compactRawSuccessState(now, settlementEntry, sessionID, usage string) string { @@ -278,9 +278,9 @@ func compactRawInputEntry(sessionID, entryID, operationID, origin, text string) func compactRawAssistantEntry(sessionID, entryID, operationID, text string) string { return fmt.Sprintf( - `{"session_id":%q,"entry_id":%q,"operation_id":%q,"status":"completed",`+ + `{"session_id":%q,"entry_id":%q,"operation_id":%q,"request_id":"req-%s","status":"completed",`+ `"source":{"provider":"prov","model":"m"},"content":[{"kind":"text","text":%q}],"tool_calls":[]}`, - sessionID, entryID, operationID, text) + sessionID, entryID, operationID, operationID, text) } func compactRawSettlementEntry(sessionID, entryID, operationID, extra string) string { diff --git a/runtime/protocol_paging_test.go b/runtime/protocol_paging_test.go index 32d5fa4b..e9e50ae6 100644 --- a/runtime/protocol_paging_test.go +++ b/runtime/protocol_paging_test.go @@ -408,13 +408,14 @@ func usageSeedMessageOperation(t *testing.T, store harness.Storage, sessionID, o SessionID string `json:"session_id"` EntryID string `json:"entry_id"` OperationID string `json:"operation_id"` + RequestID string `json:"request_id"` Status string `json:"status"` Source usageWireRef `json:"source"` Content json.RawMessage `json:"content"` ToolCalls []harness.ToolCallRecord `json:"tool_calls"` Usage *harness.UsageCount `json:"usage,omitempty"` }{ - SessionID: sessionID, EntryID: assistantID, OperationID: operationID, + SessionID: sessionID, EntryID: assistantID, OperationID: operationID, RequestID: "req-" + operationID, Status: "completed", Source: usageWireRef{Provider: "prov", Model: "m"}, Content: json.RawMessage(`[{"kind":"text","text":"answer ` + operationID + `"}]`), ToolCalls: []harness.ToolCallRecord{}, From 327b749d0d8dbbd5c49689d50ae36f543f3034f3 Mon Sep 17 00:00:00 2001 From: MMinasyan Date: Sun, 25 Oct 2026 00:11:28 +0400 Subject: [PATCH 2/3] feat(engine): add standalone model and tool effect adapters --- agent/adapter.go | 65 ++++++ agent/adapter_test.go | 323 +++++++++++++++++++++++++++ agent/contract.go | 19 +- agent/foundation_composition_test.go | 121 ++++++++-- model/model_ref.go | 3 + 5 files changed, 501 insertions(+), 30 deletions(-) create mode 100644 agent/adapter.go create mode 100644 agent/adapter_test.go diff --git a/agent/adapter.go b/agent/adapter.go new file mode 100644 index 00000000..d6070899 --- /dev/null +++ b/agent/adapter.go @@ -0,0 +1,65 @@ +package agent + +import ( + "context" + "fmt" + + "github.com/MMinasyan/lightcode/model" +) + +// ToolFunc is one tool implementation in its plain standalone shape: the started call goes in, its settled result and error come out. AdaptTools is the only route a ToolFunc reaches a run through; the loop itself never sees this type. +type ToolFunc func(context.Context, model.ToolCall) (model.ToolResult, error) + +// AdaptTools builds the ordinary standalone ToolEffect over a name-registered tool map. The map is copied at construction, so later caller mutations of the input map cannot reach the adapter; a nil or empty map is valid for a zero-tool run. Per started call the adapted effect consults, in order, the owned advertised-name copy of the producing request that Run hands it, then the copied registry. A call whose name is unadvertised or unregistered settles the ordinary unavailable-tool error result — the call never reaches any implementation, and that error settlement leaves every later call of the batch eligible. A registered nil ToolFunc returns the typed boundary-protocol violation naming the "tool" boundary before anything is invoked, never a panic. Every other call forwards the registered function's result and error unchanged: settled-result validation belongs to Run's existing validator, not to the adapter. +func AdaptTools(tools map[string]ToolFunc) ToolEffect { + registered := make(map[string]ToolFunc, len(tools)) + for name, fn := range tools { + registered[name] = fn + } + return func(ctx context.Context, call model.ToolCall, names []string) (model.ToolResult, error) { + advertised := false + for _, name := range names { + if name == call.Name { + advertised = true + break + } + } + fn, ok := registered[call.Name] + if !advertised || !ok { // one gate covers both halves: the call never reaches an implementation, and the ordinary unavailable settlement answers it. + return model.ToolResult{CallID: call.ID, Status: model.ResultError, Content: fmt.Sprintf("Tool %q is not available.", call.Name)}, nil + } + if fn == nil { // a registered nil function is a caller protocol breach, reported before any invocation instead of panicking. + return model.ToolResult{}, newBoundaryViolation("tool", fmt.Sprintf("registered tool %q is a nil function", call.Name)) + } + return fn(ctx, call) // result and error travel verbatim to Run's validator. + } +} + +// AdaptModelEffect builds the ordinary standalone ModelEffect over one model.Transport: exactly one physical Stream attempt with the request Run hands it and no per-call runtime extras, assembled with exactly the given source identity through Run's assembly callback. Before any dispatch, a nil transport or an incomplete source identity returns the typed boundary-protocol violation naming the "model" boundary. A pre-acceptance transport failure that is cancellation-shaped under a genuinely done run context settles the interruption disposition with the shared interruption detail — an active model effect never reports cancellation as a Go error — while every other transport failure settles the failure disposition carrying that error's text with no output. On an accepted stream the assembler's error is returned verbatim as a Go error, and the finalized output maps through the existing settlement algebra: completed becomes ready, errored becomes failure retaining the partial output and its detail, interrupted becomes interruption carrying its output and detail. The transport's protocol-warning diagnostics are discarded here because ModelEffect has no diagnostics channel; direct transport callers retain that API. The adapter owns no retry: one attempt per model effect. +func AdaptModelEffect(source model.ModelRef, t *model.Transport) ModelEffect { + return func(ctx context.Context, req model.Request, cb AssemblyCallback) (ModelSettlement, error) { + if t == nil || !nonzeroSource(source) { + return ModelSettlement{}, newBoundaryViolation("model", "model effect adapter requires a non-nil transport and a complete source model identity") + } + stream, _, err := t.Stream(ctx, req, nil) // warnings discarded: they are diagnostics, not a ModelEffect channel. + if err != nil { + if isCancellation(err) && ctx.Err() != nil { // pre-acceptance cancellation is never ordinary provider failure. + return ModelSettlement{Disposition: DispoInterruption, Detail: detailInterrupted}, nil + } + return ModelSettlement{Disposition: DispoFailure, Detail: err.Error()}, nil + } + out, err := cb(source, stream) + if err != nil { + _ = stream.Close() // cleanup-only: a callback that rejects the stream never takes ownership, and the exactly-once idempotent Close makes this safe whether or not the assembler already released it. + return ModelSettlement{}, err // assembler failures are preserved verbatim. + } + switch out.Status { + case model.OutputCompleted: + return ModelSettlement{Disposition: DispoReady, Output: &out}, nil + case model.OutputErrored: + return ModelSettlement{Disposition: DispoFailure, Output: &out, Detail: out.Detail}, nil + default: // OutputInterrupted: the only remaining closed status. + return ModelSettlement{Disposition: DispoInterruption, Output: &out, Detail: out.Detail}, nil + } + } +} diff --git a/agent/adapter_test.go b/agent/adapter_test.go new file mode 100644 index 00000000..c3832868 --- /dev/null +++ b/agent/adapter_test.go @@ -0,0 +1,323 @@ +package agent_test + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/MMinasyan/lightcode/agent" + "github.com/MMinasyan/lightcode/internal/testassert" + "github.com/MMinasyan/lightcode/model" +) + +func requireBoundaryViolation(t *testing.T, err error, want string) { + t.Helper() + var pe *agent.ProtocolError + testassert.Require(t, errors.Is(err, agent.ErrBoundaryProtocol) && errors.As(err, &pe) && pe.Boundary == want && pe.Detail != "", "err = %v, want a typed boundary-protocol violation naming %q with non-empty detail", err, want) +} + +func unavailableResult(call model.ToolCall) model.ToolResult { + return model.ToolResult{CallID: call.ID, Status: model.ResultError, Content: fmt.Sprintf("Tool %q is not available.", call.Name)} +} + +func echoCall(id, name string) model.ToolCall { + return model.ToolCall{ID: id, Name: name, Arguments: json.RawMessage(`{}`)} +} + +// rejectCallback fails the test if an adapter dispatches model work it should never have accepted. +func rejectCallback(t *testing.T) agent.AssemblyCallback { + return func(model.ModelRef, model.Stream) (model.Output, error) { + t.Errorf("assembly callback ran unexpectedly") + return model.Output{}, errors.New("unexpected assembly") + } +} + +// TestAdaptToolsCopiesMapAtConstruction pins the construction barrier: replacing a registered entry and adding a new one after AdaptTools returns cannot reach the adapter — the original implementation still runs and the late name settles unavailable as if never registered. +func TestAdaptToolsCopiesMapAtConstruction(t *testing.T) { + ranOriginal, ranReplacement := 0, 0 + tools := map[string]agent.ToolFunc{"echo": func(_ context.Context, c model.ToolCall) (model.ToolResult, error) { + ranOriginal++ + return model.ToolResult{CallID: c.ID, Status: model.ResultSuccess, Content: "original"}, nil + }} + effect := agent.AdaptTools(tools) + tools["echo"] = func(_ context.Context, c model.ToolCall) (model.ToolResult, error) { + ranReplacement++ + return model.ToolResult{CallID: c.ID, Status: model.ResultSuccess, Content: "replacement"}, nil + } + tools["late"] = func(_ context.Context, c model.ToolCall) (model.ToolResult, error) { + t.Errorf("tool registered after construction ran") + return model.ToolResult{CallID: c.ID, Status: model.ResultSuccess}, nil + } + + res, err := effect(context.Background(), echoCall("c1", "echo"), []string{"echo"}) + testassert.NoError(t, err) + testassert.Require(t, ranOriginal == 1 && ranReplacement == 0 && res.Content == "original", "dispatch after map mutation = %+v (original ran %d, replacement ran %d), want the construction-time implementation only", res, ranOriginal, ranReplacement) + + res, err = effect(context.Background(), echoCall("c2", "late"), []string{"late"}) + testassert.NoError(t, err) + testassert.Equal(t, res, unavailableResult(echoCall("c2", "late"))) +} + +// TestAdaptToolsUnavailableGate pins both halves of the gate: a registered name absent from the advertised names, and a call name missing from the copied registry, each settle the ordinary unavailable-tool error result without invoking any registered implementation. +func TestAdaptToolsUnavailableGate(t *testing.T) { + ran := 0 + tools := map[string]agent.ToolFunc{"echo": func(_ context.Context, c model.ToolCall) (model.ToolResult, error) { + ran++ + return model.ToolResult{CallID: c.ID, Status: model.ResultSuccess}, nil + }} + effect := agent.AdaptTools(tools) + + rows := []struct { + name string + call model.ToolCall + names []string + }{ + {"registered-but-unadvertised", echoCall("c1", "echo"), []string{"other"}}, + {"unregistered", echoCall("c2", "ghost"), []string{"echo"}}, + } + for _, row := range rows { + t.Run(row.name, func(t *testing.T) { + res, err := effect(context.Background(), row.call, row.names) + testassert.NoError(t, err) + testassert.Equal(t, res, unavailableResult(row.call)) + testassert.Require(t, ran == 0, "registered implementation ran %d times for a gated call, want 0", ran) + }) + } +} + +// TestAdaptToolsNilRegisteredFunc pins that a selected nil ToolFunc is a typed boundary-protocol violation naming the "tool" boundary with a zero result, reported before any invocation instead of panicking. +func TestAdaptToolsNilRegisteredFunc(t *testing.T) { + effect := agent.AdaptTools(map[string]agent.ToolFunc{"x": nil}) + res, err := effect(context.Background(), echoCall("c1", "x"), []string{"x"}) + requireBoundaryViolation(t, err, "tool") + testassert.Equal(t, res, model.ToolResult{}) +} + +// TestAdaptToolsForwardsUnchanged pins that an invoked implementation's result and error come back verbatim: the adapter validates and reinterprets nothing on either value. +func TestAdaptToolsForwardsUnchanged(t *testing.T) { + errBoom := errors.New("implementation blew up") + want := model.ToolResult{CallID: "c1", Status: model.ResultDenied, Content: "no"} + effect := agent.AdaptTools(map[string]agent.ToolFunc{"x": func(_ context.Context, c model.ToolCall) (model.ToolResult, error) { + return want, errBoom + }}) + res, err := effect(context.Background(), echoCall("c1", "x"), []string{"x"}) + testassert.Equal(t, res, want) + testassert.Check(t, err == errBoom, "error = %v, want the implementation error forwarded verbatim", err) +} + +// TestAdaptToolsEmptyMapZeroToolRun pins that an empty (here nil) tool map is a valid effect for a run whose request advertises no tools and whose response carries none: the composition reaches terminal success without any dispatch. +func TestAdaptToolsEmptyMapZeroToolRun(t *testing.T) { + ref := model.ModelRef{Provider: "acme", Model: "m-1"} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprint(w, finalTextSSE) + })) + defer srv.Close() + transport, err := model.NewTransport(model.ResolvedTransport{Model: ref, BaseURL: srv.URL}) + testassert.NoError(t, err) + + requestSource := func(context.Context) (model.Request, error) { + return model.Request{Messages: []model.Message{mustMessage(t, model.Message{ + Role: model.RoleUser, + Content: []model.ContentPart{{Kind: model.PartText, Text: "no tools here"}}, + })}}, nil + } + + res, err := agent.Run(context.Background(), agent.Invocation{ + ExpectedModel: ref, + Request: requestSource, + ModelEffect: agent.AdaptModelEffect(ref, transport), + ToolEffect: agent.AdaptTools(nil), + }) + testassert.Require(t, err == nil && res.Status == agent.TerminalSuccess, "run = %+v/%v, want terminal success", res, err) +} + +// TestAdaptModelEffectRejectsNilTransportAndIncompleteSource pins both pre-dispatch guards as typed boundary-protocol violations naming the "model" boundary with a zero settlement, before any transport attempt runs — the incomplete-source row proves it with a recording server that saw nothing. +func TestAdaptModelEffectRejectsNilTransportAndIncompleteSource(t *testing.T) { + complete := model.ModelRef{Provider: "acme", Model: "m-1"} + attempts := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + attempts++ + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + transport, err := model.NewTransport(model.ResolvedTransport{Model: complete, BaseURL: srv.URL}) + testassert.NoError(t, err) + + request := model.Request{Messages: []model.Message{mustMessage(t, model.Message{ + Role: model.RoleUser, + Content: []model.ContentPart{{Kind: model.PartText, Text: "x"}}, + })}} + + rows := []struct { + name string + source model.ModelRef + t *model.Transport + }{ + {"nil-transport", complete, nil}, + {"incomplete-source", model.ModelRef{Provider: "acme"}, transport}, + } + for _, row := range rows { + t.Run(row.name, func(t *testing.T) { + before := attempts + set, err := agent.AdaptModelEffect(row.source, row.t)(context.Background(), request, rejectCallback(t)) + requireBoundaryViolation(t, err, "model") + testassert.Equal(t, set, agent.ModelSettlement{}) + testassert.Check(t, attempts == before, "rejected adapter attempted %d transport dispatches, want none", attempts-before) + }) + } +} + +// TestAdaptModelEffectTransportFailureSettlesFailure pins the pre-acceptance failure mapping: a non-2xx attempt on a live run context settles the failure disposition with the transport error's text as detail and no output, never a Go error. +func TestAdaptModelEffectTransportFailureSettlesFailure(t *testing.T) { + ref := model.ModelRef{Provider: "acme", Model: "m-1"} + attempts := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + attempts++ + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, `{"error":{"message":"provider said no"}}`) + })) + defer srv.Close() + transport, err := model.NewTransport(model.ResolvedTransport{Model: ref, BaseURL: srv.URL}) + testassert.NoError(t, err) + + request := model.Request{Messages: []model.Message{mustMessage(t, model.Message{ + Role: model.RoleUser, + Content: []model.ContentPart{{Kind: model.PartText, Text: "x"}}, + })}} + set, err := agent.AdaptModelEffect(ref, transport)(context.Background(), request, rejectCallback(t)) + testassert.NoError(t, err) + testassert.Require(t, set.Disposition == agent.DispoFailure && set.Output == nil && set.Detail != "", "settlement = %+v, want failure with no output and non-empty detail", set) + testassert.Check(t, strings.Contains(set.Detail, "500"), "failure detail = %q, want it to carry the transport status failure", set.Detail) + testassert.Equal(t, attempts, 1) // no hidden retry: the failure mapping makes exactly one physical attempt. + _, verr := agent.ValidateModelSettlement(ref, set) + testassert.NoError(t, verr) +} + +// TestAdaptModelEffectCancellationSettlesInterruption pins that a cancellation-shaped transport failure under a genuinely done run context settles the interruption disposition with the shared interruption detail and no output — an active model effect never reports cancellation as a Go error. +func TestAdaptModelEffectCancellationSettlesInterruption(t *testing.T) { + ref := model.ModelRef{Provider: "acme", Model: "m-1"} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprint(w, finalTextSSE) + })) + defer srv.Close() + transport, err := model.NewTransport(model.ResolvedTransport{Model: ref, BaseURL: srv.URL}) + testassert.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // the run context is already done before the attempt starts. + + request := model.Request{Messages: []model.Message{mustMessage(t, model.Message{ + Role: model.RoleUser, + Content: []model.ContentPart{{Kind: model.PartText, Text: "x"}}, + })}} + set, err := agent.AdaptModelEffect(ref, transport)(ctx, request, rejectCallback(t)) + testassert.NoError(t, err) + testassert.Require(t, set.Disposition == agent.DispoInterruption && set.Output == nil && set.Detail == "agent interrupted", "settlement = %+v, want interruption with no output and the shared interruption detail", set) + _, verr := agent.ValidateModelSettlement(ref, set) + testassert.NoError(t, verr) +} + +// TestAdaptModelEffectPreservesCallbackError pins the assembler-error route: when the assembly callback fails over an accepted stream, the adapter returns that exact error verbatim with a zero settlement — never reinterpreted as one — and releases the callback-rejected stream, which the follow-up Recv reporting io.EOF proves. +func TestAdaptModelEffectPreservesCallbackError(t *testing.T) { + ref := model.ModelRef{Provider: "acme", Model: "m-1"} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprint(w, finalTextSSE) + })) + defer srv.Close() + transport, err := model.NewTransport(model.ResolvedTransport{Model: ref, BaseURL: srv.URL}) + testassert.NoError(t, err) + + errScript := errors.New("scripted assembler failure") + var accepted model.Stream + cb := func(_ model.ModelRef, stream model.Stream) (model.Output, error) { + accepted = stream // capture the accepted stream so the assertion below can prove the adapter released it. + return model.Output{}, errScript + } + + request := model.Request{Messages: []model.Message{mustMessage(t, model.Message{ + Role: model.RoleUser, + Content: []model.ContentPart{{Kind: model.PartText, Text: "x"}}, + })}} + set, err := agent.AdaptModelEffect(ref, transport)(context.Background(), request, cb) + testassert.Check(t, err == errScript, "err = %v, want the callback error returned verbatim", err) + testassert.Equal(t, set, agent.ModelSettlement{}) + _, rerr := accepted.Recv() + testassert.Require(t, errors.Is(rerr, io.EOF), "Recv after the callback failure = %v, want io.EOF: the adapter must have closed the callback-rejected stream instead of leaving it open", rerr) +} + +// erroredSSE is a streaming body with a partial payload under an unsupported finish reason: assembly finalizes it as an errored output that retains the partial assistant text. +const erroredSSE = `data: {"choices":[{"index":0,"delta":{"role":"assistant","content":"partial"}}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"length"}]} + +data: [DONE] + +` + +// TestAdaptModelEffectOutputMapping pins the finalized-output side of the settlement algebra over live streams: an errored assembly maps to failure retaining the partial output and its own detail, and an interrupted assembly maps to interruption carrying its output and detail. +func TestAdaptModelEffectOutputMapping(t *testing.T) { + ref := model.ModelRef{Provider: "acme", Model: "m-1"} + assemble := func(ctx context.Context) agent.AssemblyCallback { + return func(source model.ModelRef, stream model.Stream) (model.Output, error) { + return agent.Assemble(ctx, source, stream) + } + } + request := model.Request{Messages: []model.Message{mustMessage(t, model.Message{ + Role: model.RoleUser, + Content: []model.ContentPart{{Kind: model.PartText, Text: "x"}}, + })}} + + t.Run("errored-output-settles-failure-retaining-partial", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprint(w, erroredSSE) + })) + defer srv.Close() + transport, err := model.NewTransport(model.ResolvedTransport{Model: ref, BaseURL: srv.URL}) + testassert.NoError(t, err) + + ctx := context.Background() + set, err := agent.AdaptModelEffect(ref, transport)(ctx, request, assemble(ctx)) + testassert.NoError(t, err) + testassert.Require(t, set.Disposition == agent.DispoFailure && set.Output != nil && set.Output.Status == model.OutputErrored && set.Detail != "", "settlement = %+v, want failure carrying the errored output with non-empty detail", set) + testassert.Check(t, set.Detail == set.Output.Detail, "failure detail = %q, want the output's own detail preserved", set.Detail) + testassert.Check(t, set.Output.Message != nil && set.Output.Message.TextContent() == "partial", "retained partial = %+v, want the errored output to carry its assistant payload", set.Output.Message) + _, verr := agent.ValidateModelSettlement(ref, set) + testassert.NoError(t, verr) + }) + + t.Run("interrupted-output-settles-interruption", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // accepts the attempt, then carries the response open with no body until the client cancels. + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + w.(http.Flusher).Flush() + <-r.Context().Done() + })) + defer srv.Close() + transport, err := model.NewTransport(model.ResolvedTransport{Model: ref, BaseURL: srv.URL}) + testassert.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + cb := func(source model.ModelRef, stream model.Stream) (model.Output, error) { + cancel() // cancellation arrives after acceptance, while the assembly is reading the live stream. + return agent.Assemble(ctx, source, stream) + } + + set, err := agent.AdaptModelEffect(ref, transport)(ctx, request, cb) + testassert.NoError(t, err) + testassert.Require(t, set.Disposition == agent.DispoInterruption && set.Output != nil && set.Output.Status == model.OutputInterrupted && set.Detail != "", "settlement = %+v, want interruption carrying the interrupted output with non-empty detail", set) + testassert.Check(t, set.Detail == set.Output.Detail, "interruption detail = %q, want the output's own detail preserved", set.Detail) + _, verr := agent.ValidateModelSettlement(ref, set) + testassert.NoError(t, verr) + }) +} diff --git a/agent/contract.go b/agent/contract.go index 84193fcd..415e8fd9 100644 --- a/agent/contract.go +++ b/agent/contract.go @@ -1,12 +1,15 @@ // Package agent defines the caller-driven model/tool effect contracts of the -// fixed Lightcode Agent, its one stream assembler, and its execution loop. A -// later Harness layer supplies plain functions for requests, model effects, and -// tool effects; this package owns turning an accepted model.Stream into exactly -// one finalized model.Output, sequencing those caller boundaries under one -// cancellation invariant, and validating what those -// caller boundaries return. It imports only the standard library and -// lightcode/model; transport, retry policy, persistence, events, and adaptation -// all belong to layers above these contracts. +// fixed Lightcode Agent, its one stream assembler, its execution loop, and the +// ordinary standalone adapters — AdaptModelEffect over a model.Transport and +// AdaptTools over a registered tool map — that make the model client plus loop +// independently usable on their own (the melta-engine surface) without private +// wiring. A later Harness layer supplies its own durable request, model-effect, +// and tool-effect functions instead of these adapters; this package owns turning +// an accepted model.Stream into exactly one finalized model.Output, sequencing +// those caller boundaries under one cancellation invariant, validating what +// those caller boundaries return, and the adapters' lookup and settlement +// mapping. Retry policy, persistence, and events belong to layers above these +// contracts. It imports only the standard library and lightcode/model. package agent import ( diff --git a/agent/foundation_composition_test.go b/agent/foundation_composition_test.go index ac164a7b..ae58b224 100644 --- a/agent/foundation_composition_test.go +++ b/agent/foundation_composition_test.go @@ -36,7 +36,7 @@ data: [DONE] ` -// TestFoundationCompositionOverRealTransport composes the two public packages exactly as a future caller will own them, from outside both packages and with no production adapter or test-only production API: a real model.Transport speaking to an httptest OpenAI-compatible SSE server, the normal Agent assembly callback invoked from inside a test model effect, a fake tool effect, a request source whose snapshot changes between the two model requests, and the complete Agent run loop over one tool round and a final text response. +// TestFoundationCompositionOverRealTransport composes the two public packages exactly as a future caller will own them, from outside both packages and with only the shipped adapters: a real model.Transport speaking to an httptest OpenAI-compatible SSE server, agent.AdaptModelEffect supplying the model effect, agent.AdaptTools supplying the tool effect from a registered function map, a request source whose snapshot changes between the two model requests, and the complete Agent run loop over one tool round and a final text response. func TestFoundationCompositionOverRealTransport(t *testing.T) { ref := model.ModelRef{Provider: "acme", Model: "m-1"} @@ -75,36 +75,26 @@ func TestFoundationCompositionOverRealTransport(t *testing.T) { advertised := []model.ToolDefinition{{Name: "echo", Description: "echo its argument", Parameters: json.RawMessage(`{"type":"object"}`)}} sourceCalls := 0 + var reqs []model.Request requestSource := func(context.Context) (model.Request, error) { sourceCalls++ if sourceCalls > len(requests) { return model.Request{}, fmt.Errorf("request source ran out of snapshots after %d calls", sourceCalls) } - return model.Request{Messages: requests[sourceCalls-1], Tools: advertised}, nil + req := model.Request{Messages: requests[sourceCalls-1], Tools: advertised} + reqs = append(reqs, req) // each round's fresh snapshot as the Engine receives it. + return req, nil } - var reqs []model.Request - modelEffect := func(ctx context.Context, req model.Request, cb agent.AssemblyCallback) (agent.ModelSettlement, error) { - reqs = append(reqs, req) - stream, _, err := transport.Stream(ctx, req, nil) // real public transport: one physical attempt, SSE body owned by the returned stream. - if err != nil { - return agent.ModelSettlement{}, err - } - out, err := cb(ref, stream) // the normal Agent assembly callback over the live stream. - if err != nil { - return agent.ModelSettlement{}, err - } - return agent.ModelSettlement{Disposition: agent.DispoReady, Output: &out}, nil - } + modelEffect := agent.AdaptModelEffect(ref, transport) // the shipped standalone adapter: one physical Stream attempt per round, assembled with the supplied source. var dispatched []model.ToolCall - toolEffect := func(_ context.Context, call model.ToolCall, names []string) (model.ToolResult, error) { - dispatched = append(dispatched, call) - if len(names) != 1 || names[0] != "echo" { - return model.ToolResult{}, fmt.Errorf("dispatch names = %v, want the producing request's [echo]", names) - } - return model.ToolResult{CallID: call.ID, Status: model.ResultSuccess, Content: "echoed"}, nil - } + toolEffect := agent.AdaptTools(map[string]agent.ToolFunc{ // the shipped standalone adapter: dispatch is the registered implementation, gated by each round's advertised names. + "echo": func(_ context.Context, call model.ToolCall) (model.ToolResult, error) { + dispatched = append(dispatched, call) + return model.ToolResult{CallID: call.ID, Status: model.ResultSuccess, Content: "echoed"}, nil + }, + }) res, err := agent.Run(context.Background(), agent.Invocation{ ExpectedModel: ref, @@ -137,6 +127,93 @@ func TestFoundationCompositionOverRealTransport(t *testing.T) { testassert.Check(t, strings.Contains(bodies[1], `"role":"tool"`), "second request body does not carry the tool round trip: %s", bodies[1]) } +// unadvertisedCallSSE is a streaming body whose completed response calls "b" first and "echo" second: the first call's name is registered in the tool map but never advertised by the request. +const unadvertisedCallSSE = `data: {"choices":[{"index":0,"delta":{"role":"assistant","content":"mixed "}}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call-1","type":"function","function":{"name":"b","arguments":"{}"}}]}}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"id":"call-2","type":"function","function":{"name":"echo","arguments":"{\"x\":1}"}}]}}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]} + +data: [DONE] + +` + +// TestFoundationCompositionUnadvertisedToolCall pins the advertisement gate at the full standalone composition: the tool map registers implementations for "echo" AND "b" while the request advertises only [echo], and the model calls registered-but-unadvertised "b" first. The "b" implementation never runs; the call answers the ordinary unavailable-tool error settlement (Run validated it and the batch continued — a stopped batch or a protocol rejection would end the run instead), the later "echo" call reaches its implementation, and the run still reaches terminal success. +func TestFoundationCompositionUnadvertisedToolCall(t *testing.T) { + ref := model.ModelRef{Provider: "acme", Model: "m-1"} + + var bodies []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + bodies = append(bodies, string(body)) + w.Header().Set("Content-Type", "text/event-stream") + if len(bodies) == 1 { + io.WriteString(w, unadvertisedCallSSE) + } else { + io.WriteString(w, finalTextSSE) + } + })) + defer srv.Close() + + transport, err := model.NewTransport(model.ResolvedTransport{Model: ref, BaseURL: srv.URL}) + testassert.NoError(t, err) + + base := []model.Message{mustMessage(t, model.Message{ + Role: model.RoleUser, + Content: []model.ContentPart{{Kind: model.PartText, Text: "call a tool that is not advertised"}}, + })} + roundTrip := append(base, //nolint:gocritic // append result is never assigned back to base; the second snapshot is owned separately. + mustMessage(t, model.Message{ + Role: model.RoleAssistant, + Source: ref, + ToolCalls: []model.ToolCall{ + {ID: "call-1", Name: "b", Arguments: json.RawMessage(`{}`)}, + {ID: "call-2", Name: "echo", Arguments: json.RawMessage(`{"x":1}`)}, + }, + }), + mustMessage(t, model.Message{ + Role: model.RoleTool, + ToolCallID: "call-1", + Content: []model.ContentPart{{Kind: model.PartText, Text: `Tool "b" is not available.`}}, + })) + requests := [][]model.Message{base, roundTrip} + advertised := []model.ToolDefinition{{Name: "echo", Description: "echo its argument", Parameters: json.RawMessage(`{"type":"object"}`)}} + + sourceCalls := 0 + requestSource := func(context.Context) (model.Request, error) { + sourceCalls++ + if sourceCalls > len(requests) { + return model.Request{}, fmt.Errorf("request source ran out of snapshots after %d calls", sourceCalls) + } + return model.Request{Messages: requests[sourceCalls-1], Tools: advertised}, nil + } + + var ran []string + toolEffect := agent.AdaptTools(map[string]agent.ToolFunc{ + "echo": func(_ context.Context, call model.ToolCall) (model.ToolResult, error) { + ran = append(ran, "echo") + return model.ToolResult{CallID: call.ID, Status: model.ResultSuccess, Content: "echoed"}, nil + }, + "b": func(_ context.Context, call model.ToolCall) (model.ToolResult, error) { + ran = append(ran, "b") + return model.ToolResult{CallID: call.ID, Status: model.ResultSuccess, Content: "b ran"}, nil + }, + }) + + res, err := agent.Run(context.Background(), agent.Invocation{ + ExpectedModel: ref, + Request: requestSource, + ModelEffect: agent.AdaptModelEffect(ref, transport), + ToolEffect: toolEffect, + }) + testassert.NoError(t, err) + testassert.Require(t, res.Status == agent.TerminalSuccess && res.Detail == "" && res.LastOutput != nil && res.LastOutput.Status == model.OutputCompleted, "terminal = %+v, want success with empty detail; last output = %+v, want a completed output", res, res.LastOutput) + testassert.Require(t, len(ran) == 1 && ran[0] == "echo", "tool implementations that ran = %v, want only echo: registered-but-unadvertised b never reached its implementation and the batch continued past its unavailable settlement", ran) + testassert.Require(t, sourceCalls == 2 && len(bodies) == 2, "loop ran %d requests and %d transport attempts, want 2 and 2", sourceCalls, len(bodies)) +} + func mustMessage(t *testing.T, m model.Message) model.Message { t.Helper() out, err := model.NewMessage(m) diff --git a/model/model_ref.go b/model/model_ref.go index d730fd2c..4d6502fe 100644 --- a/model/model_ref.go +++ b/model/model_ref.go @@ -8,6 +8,9 @@ // Messages have no general JSON wire or persistence contract: provider wire // encoding is produced by the OpenAI-compatible encoder in this package's // transport layer, not by these values. +// +// This package's Transport composed with the agent package's Run loop and +// ordinary adapters is the independently usable engine surface (melta-engine). package model import ( From d843c88760d336c87feca63f5352d1a54ea35e07 Mon Sep 17 00:00:00 2001 From: MMinasyan Date: Sun, 25 Oct 2026 05:23:53 +0400 Subject: [PATCH 3/3] fix(engine): own tool dispatch advertisement per call --- agent/adapter.go | 7 +- agent/adapter_test.go | 83 ++++++++++++++++++++- agent/contract.go | 8 +- agent/run.go | 4 +- harness/admission_test.go | 2 +- harness/codec_test.go | 48 ++---------- harness/effect.go | 118 ++++++++++++++---------------- harness/effect_test.go | 81 ++++++++++---------- harness/graph_test.go | 15 +--- harness/hooks_test.go | 58 ++++++++------- harness/observe_test.go | 12 +-- harness/payload.go | 66 ++--------------- harness/public_test.go | 36 ++------- harness/schema.go | 16 ++-- harness/snapshot.go | 3 - internal/storage/sqlite.go | 6 +- internal/storage/sqlite_test.go | 40 +++++----- runtime/compact_lifecycle_test.go | 8 +- runtime/protocol_paging_test.go | 3 +- 19 files changed, 276 insertions(+), 338 deletions(-) diff --git a/agent/adapter.go b/agent/adapter.go index d6070899..421bdd90 100644 --- a/agent/adapter.go +++ b/agent/adapter.go @@ -10,7 +10,7 @@ import ( // ToolFunc is one tool implementation in its plain standalone shape: the started call goes in, its settled result and error come out. AdaptTools is the only route a ToolFunc reaches a run through; the loop itself never sees this type. type ToolFunc func(context.Context, model.ToolCall) (model.ToolResult, error) -// AdaptTools builds the ordinary standalone ToolEffect over a name-registered tool map. The map is copied at construction, so later caller mutations of the input map cannot reach the adapter; a nil or empty map is valid for a zero-tool run. Per started call the adapted effect consults, in order, the owned advertised-name copy of the producing request that Run hands it, then the copied registry. A call whose name is unadvertised or unregistered settles the ordinary unavailable-tool error result — the call never reaches any implementation, and that error settlement leaves every later call of the batch eligible. A registered nil ToolFunc returns the typed boundary-protocol violation naming the "tool" boundary before anything is invoked, never a panic. Every other call forwards the registered function's result and error unchanged: settled-result validation belongs to Run's existing validator, not to the adapter. +// AdaptTools builds the ordinary standalone ToolEffect over a name-registered tool map. The map is copied at construction, so later caller mutations of the input map cannot reach the adapter; a nil or empty map is valid for a zero-tool run. Per started call the adapted effect consults, in order, the owned per-call advertised-name copy of the producing request that Run hands it, then the copied registry. One rule covers every miss: a call whose name is unadvertised, absent from the registry, or registered as a nil ToolFunc settles the ordinary unavailable-tool error result — the call never reaches any implementation, no nil function is ever invoked, and that error settlement leaves every later call of the batch eligible. Every other call forwards the registered function's result and error unchanged: settled-result validation belongs to Run's existing validator, not to the adapter. func AdaptTools(tools map[string]ToolFunc) ToolEffect { registered := make(map[string]ToolFunc, len(tools)) for name, fn := range tools { @@ -25,12 +25,9 @@ func AdaptTools(tools map[string]ToolFunc) ToolEffect { } } fn, ok := registered[call.Name] - if !advertised || !ok { // one gate covers both halves: the call never reaches an implementation, and the ordinary unavailable settlement answers it. + if !advertised || !ok || fn == nil { // one gate covers every miss — unadvertised, unregistered, or a nil registration: the call never reaches an implementation, and the ordinary unavailable settlement answers it. return model.ToolResult{CallID: call.ID, Status: model.ResultError, Content: fmt.Sprintf("Tool %q is not available.", call.Name)}, nil } - if fn == nil { // a registered nil function is a caller protocol breach, reported before any invocation instead of panicking. - return model.ToolResult{}, newBoundaryViolation("tool", fmt.Sprintf("registered tool %q is a nil function", call.Name)) - } return fn(ctx, call) // result and error travel verbatim to Run's validator. } } diff --git a/agent/adapter_test.go b/agent/adapter_test.go index c3832868..16b1c8f2 100644 --- a/agent/adapter_test.go +++ b/agent/adapter_test.go @@ -91,12 +91,12 @@ func TestAdaptToolsUnavailableGate(t *testing.T) { } } -// TestAdaptToolsNilRegisteredFunc pins that a selected nil ToolFunc is a typed boundary-protocol violation naming the "tool" boundary with a zero result, reported before any invocation instead of panicking. +// TestAdaptToolsNilRegisteredFunc pins that a selected nil ToolFunc is answered by the ordinary unavailable-tool error result under the same one gate rule as an unadvertised or unregistered name: no implementation is ever invoked. func TestAdaptToolsNilRegisteredFunc(t *testing.T) { effect := agent.AdaptTools(map[string]agent.ToolFunc{"x": nil}) res, err := effect(context.Background(), echoCall("c1", "x"), []string{"x"}) - requireBoundaryViolation(t, err, "tool") - testassert.Equal(t, res, model.ToolResult{}) + testassert.NoError(t, err) + testassert.Equal(t, res, unavailableResult(echoCall("c1", "x"))) } // TestAdaptToolsForwardsUnchanged pins that an invoked implementation's result and error come back verbatim: the adapter validates and reinterprets nothing on either value. @@ -111,6 +111,83 @@ func TestAdaptToolsForwardsUnchanged(t *testing.T) { testassert.Check(t, err == errBoom, "error = %v, want the implementation error forwarded verbatim", err) } +// twoToolCallSSE is a streaming body publishing two completed tool calls in one turn: the first name the fixture advertises, the second a registered name the request never advertised. +const twoToolCallSSE = `data: {"choices":[{"index":0,"delta":{"role":"assistant","content":"working"}}]} + +data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"c1","type":"function","function":{"name":"echo","arguments":"{}"}},{"index":1,"id":"c2","type":"function","function":{"name":"ghost","arguments":"{}"}}]}}]} + +data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]} + +data: [DONE] + +` + +// TestRunToolEffectNamesArePerCallOwned proves the loop's per-call ownership of the advertised names: a wrapper ToolEffect that mutates the names slice it received in place after the first sibling's dispatch cannot hand the next sibling a different advertisement — the unadvertised registered name stays gated out, its implementation never runs, the unavailable settlement keeps the batch eligible, and the run still settles successfully. +func TestRunToolEffectNamesArePerCallOwned(t *testing.T) { + ref := model.ModelRef{Provider: "acme", Model: "m-1"} + attempts := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + attempts++ + w.Header().Set("Content-Type", "text/event-stream") + if attempts == 1 { + fmt.Fprint(w, twoToolCallSSE) + } else { + fmt.Fprint(w, finalTextSSE) + } + })) + defer srv.Close() + transport, err := model.NewTransport(model.ResolvedTransport{Model: ref, BaseURL: srv.URL}) + testassert.NoError(t, err) + + echoRan, ghostRan := 0, 0 + inner := agent.AdaptTools(map[string]agent.ToolFunc{ + "echo": func(_ context.Context, c model.ToolCall) (model.ToolResult, error) { + echoRan++ + return model.ToolResult{CallID: c.ID, Status: model.ResultSuccess, Content: "echoed"}, nil + }, + "ghost": func(_ context.Context, c model.ToolCall) (model.ToolResult, error) { + ghostRan++ + return model.ToolResult{CallID: c.ID, Status: model.ResultSuccess, Content: "ghost implementation ran"}, nil + }, + }) + var secondCallNames []string + var secondCallResult model.ToolResult + mutatedTo := "" + toolEffect := func(ctx context.Context, call model.ToolCall, names []string) (model.ToolResult, error) { + res, err := inner(ctx, call, names) // the adapter gate consults the names exactly as handed. + if call.ID == "c1" && len(names) > 0 { // after the first sibling settles, mutate the received names slice in place. + names[0] = "ghost" + mutatedTo = names[0] + } + if call.ID == "c2" { + secondCallNames = append([]string(nil), names...) + secondCallResult = res + } + return res, err + } + + res, err := agent.Run(context.Background(), agent.Invocation{ + ExpectedModel: ref, + Request: func(context.Context) (model.Request, error) { + return model.Request{Messages: []model.Message{mustMessage(t, model.Message{ + Role: model.RoleUser, + Content: []model.ContentPart{{Kind: model.PartText, Text: "run the tools"}}, + })}, Tools: []model.ToolDefinition{{Name: "echo", Description: "echo", Parameters: json.RawMessage(`{"type":"object"}`)}}}, nil + }, + ModelEffect: agent.AdaptModelEffect(ref, transport), + ToolEffect: toolEffect, + }) + testassert.NoError(t, err) + testassert.Require(t, res.Status == agent.TerminalSuccess, "terminal = %+v, want success: the unavailable settlement never stops the batch", res) + testassert.Require(t, mutatedTo == "ghost", "the wrapper's in-place mutation of the received names did not happen: %q", mutatedTo) + testassert.Require(t, echoRan == 1, "echo ran %d times, want the advertised call dispatched once", echoRan) + testassert.Require(t, ghostRan == 0, "the unadvertised registered tool ran %d times, want 0: a sibling's names mutation granted it no authority", ghostRan) + testassert.Require(t, len(secondCallNames) == 1 && secondCallNames[0] == "echo", "second call names = %v, want its own pristine [echo] copy", secondCallNames) + testassert.Require(t, secondCallResult.Status == model.ResultError && secondCallResult.CallID == "c2" && secondCallResult.Content == `Tool "ghost" is not available.`, "second call settled %+v, want the ordinary unavailable-tool error", secondCallResult) + testassert.Require(t, res.LastOutput != nil && res.LastOutput.Message.TextContent() == "all done", "last output = %+v, want the final text after the continued batch", res.LastOutput) + testassert.Require(t, attempts == 2, "transport attempts = %d, want the batch to continue to a second model round", attempts) +} + // TestAdaptToolsEmptyMapZeroToolRun pins that an empty (here nil) tool map is a valid effect for a run whose request advertises no tools and whose response carries none: the composition reaches terminal success without any dispatch. func TestAdaptToolsEmptyMapZeroToolRun(t *testing.T) { ref := model.ModelRef{Provider: "acme", Model: "m-1"} diff --git a/agent/contract.go b/agent/contract.go index 415e8fd9..7b1ae314 100644 --- a/agent/contract.go +++ b/agent/contract.go @@ -29,9 +29,9 @@ type ( ModelEffect func(context.Context, model.Request, AssemblyCallback) (ModelSettlement, error) - // ToolEffect runs one started call. The names are an owned copy of the - // producing request's advertised tool names, independent of the mutable - // request handed to the model effect. + // ToolEffect runs one started call. The names argument is an owned + // per-call copy of the producing request's advertised tool names, + // independent of the mutable request handed to the model effect. ToolEffect func(context.Context, model.ToolCall, []string) (model.ToolResult, error) ) @@ -75,7 +75,7 @@ type Invocation struct { ExpectedModel model.ModelRef // complete identity every callback-produced source must equal for the whole run. Request RequestSource // supplies one complete request per round; nil is a protocol violation at invocation entry — never normalized to any other request. ModelEffect ModelEffect // exactly one per invocation; its settlements are validated against the closed disposition table before anything proceeds from them. - ToolEffect ToolEffect // invoked once per started call in assembled order, carrying the producing request's advertised names; nil is likewise rejected at entry. + ToolEffect ToolEffect // invoked once per started call in assembled order, carrying an owned per-call copy of the producing request's advertised names; nil is likewise rejected at entry. } type ModelSettlement struct { diff --git a/agent/run.go b/agent/run.go index c44bc8e2..6cc42002 100644 --- a/agent/run.go +++ b/agent/run.go @@ -91,9 +91,9 @@ func Run(ctx context.Context, inv Invocation) (TerminalResult, error) { if ctx.Err() != nil { // no later effect starts: this call and everything after it remain unstarted. return interruptionTerminal(detailInterrupted, set.Output, calls[i:]) } - call.Arguments = model.CloneRaw(call.Arguments) // the tool boundary receives owned containers cloned from the retained settled output, so its mutations cannot rewrite it. + call.Arguments = model.CloneRaw(call.Arguments) // per dispatched call the tool boundary receives owned argument bytes, extras, and advertised names, all cloned from the retained settled output and the round's advertisement: one call's mutations cannot alter what a later sibling call or the retained output sees. call.Extra = call.Extra.Clone() - res, err := inv.ToolEffect(ctx, call, names) + res, err := inv.ToolEffect(ctx, call, append([]string(nil), names...)) if err != nil { if isCancellation(err) { // an active tool effect settles cancellation through an interrupted result, never through a Go error. return TerminalResult{}, newBoundaryViolation("tool", "tool effect reported cancellation as an error instead of an interrupted result") diff --git a/harness/admission_test.go b/harness/admission_test.go index c6714dae..7cd5a7dd 100644 --- a/harness/admission_test.go +++ b/harness/admission_test.go @@ -1165,7 +1165,7 @@ func TestHarnessCancellationGatesPublication(t *testing.T) { func TestReadOperationReturnsOwnedRecords(t *testing.T) { t.Run("active effect pointer", func(t *testing.T) { fixture := validTestGraph() - fixture.ops[0].State.ActiveEffect = &ActiveEffect{Kind: EffectModel, ResultEntryID: hexID(3), RequestID: hexID(99)} + fixture.ops[0].State.ActiveEffect = &ActiveEffect{Kind: EffectModel, ResultEntryID: hexID(3)} h := newTestHarness(t, fixture.storage(t), nil) rec, err := h.ReadOperation(context.Background(), testSessionID, testOpID) testassert.NoError(t, err) diff --git a/harness/codec_test.go b/harness/codec_test.go index 5cfb73b5..47a2766e 100644 --- a/harness/codec_test.go +++ b/harness/codec_test.go @@ -120,8 +120,6 @@ func validAssistantEntry(operationID string) assistantEntry { SessionID: testSessionID, EntryID: testEntryID, OperationID: operationID, - RequestID: hexID(7), - Advertised: []string{"echo"}, Status: model.OutputCompleted, Source: testModelRef(), Content: []model.ContentPart{model.ContentPart{Kind: model.PartText, Text: "hi"}}, @@ -282,19 +280,9 @@ func TestEntryPayloadRoundTrip(t *testing.T) { encode: func() (json.RawMessage, error) { v := validAssistantEntry(testOpID) v.Usage = testUsage(3) - v.Advertised = []string{"echo", "extra"} return encodeAssistantEntry(v) }, - decode: func(env Entry) error { - v, err := decodeAssistantEntry(env) - if err != nil { - return err - } - if v.RequestID != hexID(7) || len(v.Advertised) != 2 || v.Advertised[0] != "echo" || v.Advertised[1] != "extra" { - return fmt.Errorf("request_id/advertised round-trip lost: %q %v", v.RequestID, v.Advertised) - } - return nil - }, + decode: func(env Entry) error { _, err := decodeAssistantEntry(env); return err }, }, { name: "assistant operationless fork copy", @@ -610,8 +598,7 @@ func TestHookResultStatusRules(t *testing.T) { // TestHookActiveEffectCodecRules proves the active-effect codec rules: a hook // effect requires its hook ID and the matching first pending call, while the -// model and tool effects forbid a hook ID; the request id rule is closed by -// kind — a model effect requires one, tool and hook effects forbid it. +// model and tool effects forbid a hook ID. func TestHookActiveEffectCodecRules(t *testing.T) { op := validOperationRecord() assistant := validAssistantEntry(testOpID) @@ -639,10 +626,7 @@ func TestHookActiveEffectCodecRules(t *testing.T) { {"hook effect without a hook id", ActiveEffect{Kind: EffectHook, ResultEntryID: hexID(9), ToolCallID: "call-1"}, "hook active effect requires its hook id"}, {"hook effect without a tool call id", ActiveEffect{Kind: EffectHook, ResultEntryID: hexID(9), HookID: "cap.hook.one"}, "hook active effect requires its tool call id"}, {"hook effect on a non-pending call", ActiveEffect{Kind: EffectHook, ResultEntryID: hexID(9), ToolCallID: "call-9", HookID: "cap.hook.one"}, "hook active effect must address the matching first pending call"}, - {"hook effect with a request id", ActiveEffect{Kind: EffectHook, ResultEntryID: hexID(9), RequestID: hexID(8), ToolCallID: "call-1", HookID: "cap.hook.one"}, "hook active effect omits the request id"}, - {"model effect without a request id", ActiveEffect{Kind: EffectModel, ResultEntryID: hexID(9)}, "model active effect requires its request id"}, - {"model effect with a hook id", ActiveEffect{Kind: EffectModel, ResultEntryID: hexID(9), RequestID: hexID(8), HookID: "cap.hook.one"}, "model active effect omits the hook id"}, - {"tool effect with a request id", ActiveEffect{Kind: EffectTool, ResultEntryID: testResultID, RequestID: hexID(8), ToolCallID: "call-1"}, "tool active effect omits the request id"}, + {"model effect with a hook id", ActiveEffect{Kind: EffectModel, ResultEntryID: hexID(9), HookID: "cap.hook.one"}, "model active effect omits the hook id"}, {"tool effect with a hook id", ActiveEffect{Kind: EffectTool, ResultEntryID: testResultID, ToolCallID: "call-1", HookID: "cap.hook.one"}, "tool active effect omits the hook id"}, {"unknown kind", ActiveEffect{Kind: "widget", ResultEntryID: hexID(9), ToolCallID: "call-1", HookID: "cap.hook.one"}, `active effect kind "widget" is not one of model, tool or hook`}, } { @@ -724,7 +708,7 @@ func TestOperationStateEncodingShape(t *testing.T) { } effect := validOperationRecord() - effect.State.ActiveEffect = &ActiveEffect{Kind: EffectModel, ResultEntryID: hexID(3), RequestID: hexID(8)} // model effect: carries its request id, no tool call id + effect.State.ActiveEffect = &ActiveEffect{Kind: EffectModel, ResultEntryID: hexID(3)} // model effect: no tool call id raw, err = encodeOperationRegister(effect) testassert.NoError(t, err) stateObj, err = objectMember(wireObject(t, raw), "state", true) @@ -735,10 +719,6 @@ func TestOperationStateEncodingShape(t *testing.T) { _, present := effectObj["tool_call_id"] testassert.Require(t, !present, "model active effect carries tool_call_id: %s", raw) } - { - requestID, err := stringMember(effectObj, "request_id", true) - testassert.Require(t, err == nil && requestID == hexID(8), "model active effect request_id = %q (%v), want %s", requestID, err, hexID(8)) - } } // TestRegisterPayloadRejectsInvalidWire proves the register payloads enforce @@ -1133,24 +1113,6 @@ func TestCodecRejectsInvalidValues(t *testing.T) { _, err := encodeAssistantEntry(v) return err }}, - {"assistant missing request id", func() error { - v := validAssistantEntry(testOpID) - v.RequestID = "" - _, err := encodeAssistantEntry(v) - return err - }}, - {"assistant duplicate advertised names", func() error { - v := validAssistantEntry(testOpID) - v.Advertised = []string{"echo", "echo"} - _, err := encodeAssistantEntry(v) - return err - }}, - {"assistant empty advertised name", func() error { - v := validAssistantEntry(testOpID) - v.Advertised = []string{""} - _, err := encodeAssistantEntry(v) - return err - }}, {"tool result denied without content", func() error { v := validToolResultEntry(testOpID) v.Status = model.ResultDenied @@ -1244,7 +1206,7 @@ func TestCodecRejectsInvalidValues(t *testing.T) { }}, {"operation model effect with call id", func() error { v := validOperationRecord() - v.State.ActiveEffect = &ActiveEffect{Kind: EffectModel, ResultEntryID: hexID(3), RequestID: hexID(8), ToolCallID: "call-1"} + v.State.ActiveEffect = &ActiveEffect{Kind: EffectModel, ResultEntryID: hexID(3), ToolCallID: "call-1"} _, err := encodeOperationRegister(v) return err }}, diff --git a/harness/effect.go b/harness/effect.go index e5150eb2..912fb713 100644 --- a/harness/effect.go +++ b/harness/effect.go @@ -46,8 +46,7 @@ type modelEffectIntent struct { sessionID string expected model.ModelRef resultID string - requestID string // identity of the prepared request this intent's effect consumed - compaction bool // true for the compact effect's intent: its usage is summary usage + compaction bool // true for the compact effect's intent: its usage is summary usage } // modelResult is the settlement content of one logical model effect: the @@ -131,8 +130,8 @@ func (h *Harness) modelEffect(c *coordinator, operationID string, exec Execution req.Messages = messages } // The advertisement of this logical effect is the validated request's - // own ordered tool names: the record it produces is gated by them, - // never by a later capture. + // own ordered tool names: the Engine derives them from it before the + // effect runs and hands the dispatch gate a per-call copy. names := make([]string, len(req.Tools)) for i, def := range req.Tools { names[i] = def.Name @@ -213,7 +212,7 @@ func (h *Harness) modelEffect(c *coordinator, operationID string, exec Execution } } } - assistant, reported, err := newAssistantEntry(intent.sessionID, operationID, intent.resultID, intent.requestID, owned.Output, normalize, names) + assistant, reported, err := newAssistantEntry(intent.sessionID, operationID, intent.resultID, owned.Output, normalize, names) if err != nil { return agent.ModelSettlement{}, err } @@ -332,10 +331,9 @@ func publishedCallIDs(c *coordinator, operationID string) map[string]bool { } // beginModelEffect commits the one active-effect intent of a logical model -// effect: it reserves the effect's result entry identity and a fresh request -// identity for the prepared request the effect consumes, and records both in -// the Operation register. The reserved identity stays free until the model -// result or the consuming settlement commits under it. +// effect: it reserves the effect's result entry identity and records it in the +// Operation register. The reserved identity stays free until the model result +// or the consuming settlement commits under it. func (h *Harness) beginModelEffect(ctx context.Context, c *coordinator, operationID string) (modelEffectIntent, error) { c.mu.Lock() op, ok := c.graph.Operation(operationID) @@ -357,11 +355,6 @@ func (h *Harness) beginModelEffect(ctx context.Context, c *coordinator, operatio c.mu.Unlock() return modelEffectIntent{}, fmt.Errorf("%w: %v", ErrStorage, err) } - requestID, err := newHexID() - if err != nil { - c.mu.Unlock() - return modelEffectIntent{}, fmt.Errorf("%w: %v", ErrStorage, err) - } var updated OperationRecord err = h.deps.Storage.Transact(ctx, func(tx Transaction) error { key := RegisterKey{SessionID: op.Admission.SessionID, Kind: RegisterOperation, OperationID: operationID} @@ -381,7 +374,7 @@ func (h *Harness) beginModelEffect(ctx context.Context, c *coordinator, operatio return fmt.Errorf("%w: operation %q revision %d changed concurrently to %d", errRevisionRace, operationID, op.Revision, reg.Revision) } next := current - next.State.ActiveEffect = &ActiveEffect{Kind: EffectModel, ResultEntryID: resultID, RequestID: requestID} + next.State.ActiveEffect = &ActiveEffect{Kind: EffectModel, ResultEntryID: resultID} payload, err := encodeOperationRegister(next) if err != nil { return err @@ -410,7 +403,6 @@ func (h *Harness) beginModelEffect(ctx context.Context, c *coordinator, operatio sessionID: op.Admission.SessionID, expected: op.Admission.Execution.Model, resultID: resultID, - requestID: requestID, }, nil } @@ -895,17 +887,16 @@ func normalizeCallArguments(normalize func(model.ToolCall) (json.RawMessage, err var errMalformedNormalization = errors.New("malformed normalization") // newAssistantEntry builds one assistant entry from one validated model -// output under the given entry and request identity, reserving one result -// identity per completed call and recording the producing request's ordered -// advertised names. An output without an eligible model-visible payload writes +// output under the given entry identity, reserving one result identity per +// completed call. An output without an eligible model-visible payload writes // no assistant entry and returns its reported usage for the consuming // settlement entry instead. Before the entry commits, every completed call -// whose name the producing request advertised is normalized through the -// execution's pure callback: its successful owned object populates the nested -// call's normalized_arguments member; a failed or malformed normalization, and -// every unknown or unadvertised name, leave the member absent while the -// original raw argument bytes are always retained. -func newAssistantEntry(sessionID, operationID, entryID, requestID string, out *model.Output, normalize func(model.ToolCall) (json.RawMessage, error), advertised []string) (*assistantEntry, *UsageCount, error) { +// whose name is in the producing request's ordered advertised names is +// normalized through the execution's pure callback: its successful owned +// object populates the nested call's normalized_arguments member; a failed or +// malformed normalization, and every unknown or unadvertised name, leave the +// member absent while the original raw argument bytes are always retained. +func newAssistantEntry(sessionID, operationID, entryID string, out *model.Output, normalize func(model.ToolCall) (json.RawMessage, error), advertised []string) (*assistantEntry, *UsageCount, error) { if out == nil { return nil, nil, nil } @@ -913,14 +904,9 @@ func newAssistantEntry(sessionID, operationID, entryID, requestID string, out *m SessionID: sessionID, EntryID: entryID, OperationID: operationID, - RequestID: requestID, - Advertised: advertised, Status: out.Status, Source: out.Source, } - if len(entry.Advertised) == 0 { - entry.Advertised = nil - } advertisedNames := make(map[string]bool, len(advertised)) for _, name := range advertised { advertisedNames[name] = true @@ -1052,16 +1038,16 @@ func interruptedToolResult(callID string) model.ToolResult { return model.ToolResult{CallID: callID, Status: model.ResultInterrupted, Content: interruptedToolResultContent} } -// pendingToolCallRecord resolves one published call's pending reservation, its -// committed assistant tool-call record, and the producing assistant entry's -// advertised-name set from the coordinator's validated view: the boundary -// consumes the durable records, never call data from model JSON. -func pendingToolCallRecord(c *coordinator, operationID, callID string) (PendingToolCall, toolCallRecord, map[string]bool, error) { +// pendingToolCallRecord resolves one published call's pending reservation and +// its committed assistant tool-call record from the coordinator's validated +// view: the boundary consumes the durable record, never call data from model +// JSON. +func pendingToolCallRecord(c *coordinator, operationID, callID string) (PendingToolCall, toolCallRecord, error) { c.mu.Lock() defer c.mu.Unlock() op, ok := c.graph.Operation(operationID) if !ok { - return PendingToolCall{}, toolCallRecord{}, nil, fmt.Errorf("%w: operation %q in session %q", ErrNotFound, operationID, c.graph.Session.Identity.SessionID) + return PendingToolCall{}, toolCallRecord{}, fmt.Errorf("%w: operation %q in session %q", ErrNotFound, operationID, c.graph.Session.Identity.SessionID) } var pending PendingToolCall found := false @@ -1072,7 +1058,7 @@ func pendingToolCallRecord(c *coordinator, operationID, callID string) (PendingT } } if !found { - return PendingToolCall{}, toolCallRecord{}, nil, invalidInput("operation %q has no pending call %q; every call settles exactly once", operationID, callID) + return PendingToolCall{}, toolCallRecord{}, invalidInput("operation %q has no pending call %q; every call settles exactly once", operationID, callID) } for _, entry := range c.graph.Entries { if entry.Envelope.ID != pending.AssistantEntry.EntryID || entry.Assistant == nil { @@ -1080,19 +1066,16 @@ func pendingToolCallRecord(c *coordinator, operationID, callID string) (PendingT } for _, record := range entry.Assistant.ToolCalls { if record.ID == callID { - advertised := make(map[string]bool, len(entry.Assistant.Advertised)) - for _, name := range entry.Assistant.Advertised { - advertised[name] = true - } - return pending, record, advertised, nil + return pending, record, nil } } } - return PendingToolCall{}, toolCallRecord{}, nil, invalidInput("operation %q pending call %q has no committed assistant record", operationID, callID) + return PendingToolCall{}, toolCallRecord{}, invalidInput("operation %q pending call %q has no committed assistant record", operationID, callID) } // unavailableToolResult is the ordinary unavailable-tool error result the -// advertisement gate settles for a name outside the committed advertised set. +// advertisement gate settles for a name outside the producing request's +// advertised set. func unavailableToolResult(callID, name string) model.ToolResult { return model.ToolResult{CallID: callID, Status: model.ResultError, Content: fmt.Sprintf("Tool %q is not available.", name)} } @@ -1127,28 +1110,29 @@ func boundedToolDiagnostic(cause error) string { // toolEffect encloses one concrete tool execution in the shared Harness tool // boundary. In order: it resolves the call's pending reservation and committed -// assistant record together with the producing assistant entry's advertised -// set, and pre-start cancellation wins before anything else; the advertisement -// gate rejects names outside that committed set with the ordinary -// unavailable-tool error before any hook or preparer runs — the Engine-passed -// names are never consulted as authority; the selected argument hooks then run -// in configured order, each as its own settled effect, and a settled hook -// chain hands the final committed value on; without hooks (or for an unhooked -// call) the original assistant's committed normalized arguments are the -// selected input, and only an invalid unhooked original is normalized again to -// obtain its useful validation diagnostic. It then prepares the plan outside -// locks, rejects invalid plan shapes with the existing internal-validation -// error, and evaluates every declaration through the fixed boundary — -// including immediate-success plans — before beginPendingCallEffect. A denial -// settles only that original call with status denied and no metadata, no -// concrete effect and no tool active intent; later calls still run. An allowed -// executor commits intent, executes once behind the cancellation-before-start -// gate, and commits one validated outcome — a returned real outcome wins a -// cancellation race. +// assistant record, and pre-start cancellation wins before anything else; the +// advertisement gate rejects names outside the engine-supplied advertisement +// of the producing request — the per-call owned copy the Engine derived from +// the validated request before its model effect — with the ordinary +// unavailable-tool error before any hook or preparer runs, while the canonical +// pending-call record stays the authority for the call's own data; the +// selected argument hooks then run in configured order, each as its own +// settled effect, and a settled hook chain hands the final committed value on; +// without hooks (or for an unhooked call) the original assistant's committed +// normalized arguments are the selected input, and only an invalid unhooked +// original is normalized again to obtain its useful validation diagnostic. It +// then prepares the plan outside locks, rejects invalid plan shapes with the +// existing internal-validation error, and evaluates every declaration through +// the fixed boundary — including immediate-success plans — before +// beginPendingCallEffect. A denial settles only that original call with status +// denied and no metadata, no concrete effect and no tool active intent; later +// calls still run. An allowed executor commits intent, executes once behind +// the cancellation-before-start gate, and commits one validated outcome — a +// returned real outcome wins a cancellation race. func (h *Harness) toolEffect(c *coordinator, operationID string, exec Execution, capture ExecutionCapture) agent.ToolEffect { prepared := exec.Tool - return func(ctx context.Context, call model.ToolCall, _ []string) (model.ToolResult, error) { - pending, record, advertised, err := pendingToolCallRecord(c, operationID, call.ID) + return func(ctx context.Context, call model.ToolCall, names []string) (model.ToolResult, error) { + pending, record, err := pendingToolCallRecord(c, operationID, call.ID) if err != nil { return model.ToolResult{}, err } @@ -1167,7 +1151,11 @@ func (h *Harness) toolEffect(c *coordinator, operationID string, exec Execution, if ctx.Err() != nil { // execution cancellation prevents later preparation: interrupted-before-execution return h.commitToolResult(settleCtx, c, operationID, pending, ToolOutcome{Result: interruptedToolResult(call.ID)}, false) } - if !advertised[record.Name] { // the one advertisement gate, ahead of every hook and preparer: the committed producing record is the authority + advertised := make(map[string]bool, len(names)) + for _, name := range names { + advertised[name] = true + } + if !advertised[record.Name] { // the one advertisement gate, ahead of every hook and preparer: the engine-supplied per-call advertisement of the producing request is the authority return h.commitToolResult(settleCtx, c, operationID, pending, ToolOutcome{Result: unavailableToolResult(call.ID, record.Name)}, false) } // The selected normalized value feeding preparation: the final diff --git a/harness/effect_test.go b/harness/effect_test.go index 9da4d9c1..c49ac12b 100644 --- a/harness/effect_test.go +++ b/harness/effect_test.go @@ -203,16 +203,14 @@ func erroredTurnStream() *scriptStream { } // TestModelEffectReadyRemainsRunning proves the ready transition: the -// committed assistant consumes the effect's reserved identity, carries the -// intent's request identity and the producing request's advertised names, -// completed calls receive their reservations, usage lands on the entry and -// both totals, and the Operation stays running with the effect cleared. +// committed assistant consumes the effect's reserved identity, completed calls +// receive their reservations, usage lands on the entry and both totals, and +// the Operation stays running with the effect cleared. func TestModelEffectReadyRemainsRunning(t *testing.T) { var ( store *graphStorage sessionID string reservedID string - requestID string ) modelFn := func(context.Context, model.Request) (model.Stream, error) { reg, err := store.ReadRegister(context.Background(), RegisterKey{SessionID: sessionID, Kind: RegisterOperation, OperationID: testOpID}) @@ -227,7 +225,6 @@ func TestModelEffectReadyRemainsRunning(t *testing.T) { return nil, errors.New("no model effect intent is durable while the effect runs") } reservedID = op.State.ActiveEffect.ResultEntryID - requestID = op.State.ActiveEffect.RequestID return completedTurnStream(testToolCall("call-1"), testToolCall("call-2")), nil } h, st, c, sid := newEffectHarness(t, modelFn) @@ -251,8 +248,6 @@ func TestModelEffectReadyRemainsRunning(t *testing.T) { } } testassert.Require(t, assistant != nil, "ready settlement published no assistant entry") - testassert.Require(t, assistant.RequestID == requestID && requestID != "", "assistant request id = %q, want the committed intent's identity %q", assistant.RequestID, requestID) - testassert.Require(t, len(assistant.Advertised) == 1 && assistant.Advertised[0] == "echo", "assistant advertised names = %v, want the producing request's [echo]", assistant.Advertised) testassert.Require(t, assistant.EntryID == reservedID && (assistant.Status == model.OutputCompleted && len(assistant.ToolCalls) == 2), "got %v, want %v; assistant entry = %+v, want completed output with two calls", assistant.EntryID, reservedID, assistant) testassert.Require(t, assistant.Usage != nil && *assistant.Usage == (UsageCount{InputTokens: 3, CachedInputTokens: 1, OutputTokens: 2}), "assistant usage = %+v, want the reported counts", assistant.Usage) for i, call := range assistant.ToolCalls { @@ -628,7 +623,7 @@ func TestRequestSourceProjectsFullHistory(t *testing.T) { testassert.NoError(t, err) } { - _, err := h.toolEffect(c, testOpID, exec, testCapture())(context.Background(), testToolCall("call-1"), nil) + _, err := h.toolEffect(c, testOpID, exec, testCapture())(context.Background(), testToolCall("call-1"), []string{"echo"}) testassert.NoError(t, err) } } @@ -1302,7 +1297,7 @@ func TestToolEffectPlansAndOutcomes(t *testing.T) { } return inner } - got, err := h.toolEffect(c, testOpID, effectExecution(nil, plan), testCapture())(context.Background(), testToolCall("call-1"), nil) + got, err := h.toolEffect(c, testOpID, effectExecution(nil, plan), testCapture())(context.Background(), testToolCall("call-1"), []string{"echo"}) testassert.NoError(t, err) testassert.Equal(t, got, tc.want) testassert.Equal(t, executions, tc.wantExecutions) @@ -1581,7 +1576,7 @@ func TestToolEffectPermissionBoundary(t *testing.T) { } return inner } - got, err := h.toolEffect(c, testOpID, Execution{Tool: plan, NormalizeTool: objectNormalize, Permissions: tc.policy}, capture)(context.Background(), testToolCall("call-1"), nil) + got, err := h.toolEffect(c, testOpID, Execution{Tool: plan, NormalizeTool: objectNormalize, Permissions: tc.policy}, capture)(context.Background(), testToolCall("call-1"), []string{"echo"}) testassert.NoError(t, err) wantStatus := model.ResultSuccess if tc.wantDenied { @@ -1627,7 +1622,7 @@ func TestToolEffectDeniedSettlesOnlyItsCall(t *testing.T) { } return nil } - got, err := h.toolEffect(c, testOpID, effectExecution(nil, plan), testCapture())(context.Background(), testToolCall("call-1"), nil) + got, err := h.toolEffect(c, testOpID, effectExecution(nil, plan), testCapture())(context.Background(), testToolCall("call-1"), []string{"echo"}) testassert.NoError(t, err) testassert.Require(t, got.Status == model.ResultDenied && got.Content == PermissionDeniedContent, "call-1 result = %+v, want the fixed denial", got) testassert.Equal(t, executions, 0) @@ -1637,7 +1632,7 @@ func TestToolEffectDeniedSettlesOnlyItsCall(t *testing.T) { testassert.NoError(t, err) testassert.Require(t, rec.State.Status == OperationRunning && rec.State.ActiveEffect == nil && (len(rec.State.PendingToolCalls) == 1 && rec.State.PendingToolCalls[0].CallID == "call-2"), "operation after the denial = %+v, want running with no active effect; pending calls = %+v, want the later call untouched", rec.State, rec.State.PendingToolCalls) { - _, err := h.toolEffect(c, testOpID, effectExecution(nil, plan), testCapture())(context.Background(), testToolCall("call-2"), nil) + _, err := h.toolEffect(c, testOpID, effectExecution(nil, plan), testCapture())(context.Background(), testToolCall("call-2"), []string{"echo"}) testassert.NoError(t, err) } testassert.Equal(t, executions, 1) @@ -1661,12 +1656,13 @@ func TestToolEffectDeniedSettlesOnlyItsCall(t *testing.T) { } // TestToolEffectAdvertisementGate proves the one advertisement gate ahead of -// every preparer, read from the producing assistant record: a completed call -// whose committed record names a tool outside the request's advertised set +// every preparer, consulted through the Engine-supplied per-call names of the +// producing request: a committed call whose name is absent from the names // settles the ordinary unavailable-tool error with no preparation and no -// normalization, a later capture advertising that name grants no retroactive -// authority, a forged names argument grants none either, while a call the -// producing request advertised still runs. +// normalization even when a capture advertises that name — a call produced by +// an earlier request gains no retroactive authority from a later +// advertisement — a names-complete call proceeds to its plan, and a captured, +// registered name the names omit is still gated out. func TestToolEffectAdvertisementGate(t *testing.T) { h, store, c, sessionID := newEffectHarness(t, nil) normalized := 0 @@ -1678,38 +1674,39 @@ func TestToolEffectAdvertisementGate(t *testing.T) { return completedTurnStream( model.ToolCall{ID: "call-1", Name: "ghost", Arguments: json.RawMessage(`{"x":1}`)}, testToolCall("call-2"), - model.ToolCall{ID: "call-3", Name: "ghost", Arguments: json.RawMessage(`{"x":1}`)}, + testToolCall("call-3"), ), nil } { _, err := invokeModelEffect(t, h.modelEffect(c, testOpID, Execution{Model: modelFn, NormalizeTool: normalize}, testCapture()), defaultModelRequest(t), nil) testassert.NoError(t, err) } - testassert.Equal(t, normalized, 1) + testassert.Equal(t, normalized, 2) prepared := 0 plan := func(_ context.Context, call model.ToolCall) PreparedTool { prepared++ return PreparedTool{Permissions: fixturePermission, Immediate: &ToolOutcome{Result: model.ToolResult{CallID: call.ID, Status: model.ResultSuccess, Content: "done"}}} } - got, err := h.toolEffect(c, testOpID, Execution{Tool: plan, NormalizeTool: normalize}, testCapture())(context.Background(), testToolCall("call-1"), nil) - testassert.NoError(t, err) - testassert.Require(t, got.Status == model.ResultError && got.Content == `Tool "ghost" is not available.`, "unadvertised result = %+v, want the ordinary unavailable-tool error", got) - testassert.Equal(t, normalized, 1) - testassert.Equal(t, prepared, 0) - // A later capture that advertises the name grants the committed call no - // authority: the gate reads the producing record, never the capture, and a - // forged names argument is never consulted either. + // The producing request advertised only echo: the ghost call settles the + // ordinary unavailable-tool error even under a capture that now also + // advertises ghost — the capture grants it no retroactive authority. ghostCapture := testCapture() ghostCapture.Tools = append(ghostCapture.Tools, model.ToolDefinition{Name: "ghost", Description: "ghost tool", Parameters: json.RawMessage(`{"type":"object"}`)}) - got, err = h.toolEffect(c, testOpID, Execution{Tool: plan, NormalizeTool: normalize}, ghostCapture)(context.Background(), testToolCall("call-3"), []string{"ghost"}) + got, err := h.toolEffect(c, testOpID, Execution{Tool: plan, NormalizeTool: normalize}, ghostCapture)(context.Background(), testToolCall("call-1"), []string{"echo"}) testassert.NoError(t, err) - testassert.Require(t, got.Status == model.ResultError && got.Content == `Tool "ghost" is not available.`, "gate under a retroactive capture = %+v, want the ordinary unavailable-tool error", got) + testassert.Require(t, got.Status == model.ResultError && got.Content == `Tool "ghost" is not available.`, "names-absent result = %+v, want the ordinary unavailable-tool error", got) + testassert.Equal(t, normalized, 2) testassert.Equal(t, prepared, 0) { - _, err := h.toolEffect(c, testOpID, Execution{Tool: plan, NormalizeTool: normalize}, testCapture())(context.Background(), testToolCall("call-2"), nil) + _, err := h.toolEffect(c, testOpID, Execution{Tool: plan, NormalizeTool: normalize}, testCapture())(context.Background(), testToolCall("call-2"), []string{"echo"}) testassert.NoError(t, err) } testassert.Equal(t, prepared, 1) + // A captured, registered name the names omit is gated out too. + got, err = h.toolEffect(c, testOpID, Execution{Tool: plan, NormalizeTool: normalize}, testCapture())(context.Background(), testToolCall("call-3"), nil) + testassert.NoError(t, err) + testassert.Require(t, got.Status == model.ResultError && got.Content == `Tool "echo" is not available.`, "names-omitted result = %+v, want the ordinary unavailable-tool error for the captured echo name", got) + testassert.Equal(t, prepared, 1) { _, err := validateFixture(t, store, sessionID) testassert.NoError(t, err) @@ -1777,7 +1774,7 @@ func TestToolEffectConsumesCommittedNormalization(t *testing.T) { // preparer the committed record's name and normalized bytes. A forged // unadvertised name would trip the advertisement gate if it were used. forged := model.ToolCall{ID: "call-1", Name: "forged", Arguments: json.RawMessage(`{"y":99}`)} - got, err := h.toolEffect(c, testOpID, Execution{Tool: plan, NormalizeTool: normalize}, testCapture())(context.Background(), forged, nil) + got, err := h.toolEffect(c, testOpID, Execution{Tool: plan, NormalizeTool: normalize}, testCapture())(context.Background(), forged, []string{"echo"}) testassert.NoError(t, err) testassert.Require(t, got.Status == model.ResultSuccess && got.Content == "done", "result = %+v, want the committed call to execute, not a forged-name rejection", got) testassert.Equal(t, normalized, 1) @@ -1814,7 +1811,7 @@ func TestToolEffectConsumesCommittedNormalization(t *testing.T) { } return nil } - got, err := h.toolEffect(c, testOpID, Execution{Tool: plan, NormalizeTool: normalize}, testCapture())(context.Background(), testToolCall("call-1"), nil) + got, err := h.toolEffect(c, testOpID, Execution{Tool: plan, NormalizeTool: normalize}, testCapture())(context.Background(), testToolCall("call-1"), []string{"echo"}) testassert.NoError(t, err) testassert.Require(t, got.Status == model.ResultSuccess && got.Content == "ran", "result = %+v, want the executed outcome", got) // Operation + Session + the tool intent @@ -1848,7 +1845,7 @@ func TestToolEffectConsumesCommittedNormalization(t *testing.T) { prepared++ return PreparedTool{Permissions: fixturePermission, Immediate: &ToolOutcome{Result: model.ToolResult{CallID: "call-1", Status: model.ResultSuccess, Content: "done"}}} } - got, err := h.toolEffect(c, testOpID, Execution{Tool: plan, NormalizeTool: normalize}, testCapture())(context.Background(), testToolCall("call-1"), nil) + got, err := h.toolEffect(c, testOpID, Execution{Tool: plan, NormalizeTool: normalize}, testCapture())(context.Background(), testToolCall("call-1"), []string{"echo"}) testassert.NoError(t, err) testassert.Equal(t, normalized, 2) testassert.Require(t, got.Status == model.ResultError && got.Content == "arguments must be one non-null JSON object", "result = %+v, want the callback's useful validation diagnostic", got) @@ -1882,7 +1879,7 @@ func TestToolEffectConsumesCommittedNormalization(t *testing.T) { got, err := h.toolEffect(c, testOpID, Execution{Tool: func(context.Context, model.ToolCall) PreparedTool { t.Fatalf("preparer must not run for the invalid original") return PreparedTool{} - }, NormalizeTool: normalize}, testCapture())(context.Background(), testToolCall("call-1"), nil) + }, NormalizeTool: normalize}, testCapture())(context.Background(), testToolCall("call-1"), []string{"echo"}) testassert.NoError(t, err) testassert.Require(t, got.Status == model.ResultError, "result = %+v, want the invalid original's validation error", got) testassert.Equal(t, seenAtBoundary, `{"n":[1,1.0,9007199254740993]}`) @@ -1898,7 +1895,7 @@ func TestToolEffectConsumesCommittedNormalization(t *testing.T) { got, err := h.toolEffect(c, testOpID, Execution{Tool: func(context.Context, model.ToolCall) PreparedTool { t.Fatalf("preparer must not run for an invalid original") return PreparedTool{} - }, NormalizeTool: normalize}, testCapture())(context.Background(), testToolCall("call-1"), nil) + }, NormalizeTool: normalize}, testCapture())(context.Background(), testToolCall("call-1"), []string{"echo"}) testassert.NoError(t, err) testassert.Require(t, got.Status == model.ResultError && len(got.Content) <= maxToolDiagnosticBytes && strings.HasPrefix(got.Content, "xyyyyy"), "result = %+v, want the diagnostic truncated within %d bytes", got, maxToolDiagnosticBytes) }) @@ -1918,7 +1915,7 @@ func TestToolEffectConsumesCommittedNormalization(t *testing.T) { got, err := h.toolEffect(c, testOpID, Execution{Tool: func(context.Context, model.ToolCall) PreparedTool { t.Fatalf("preparer must not run for a malformed callback outcome") return PreparedTool{} - }, NormalizeTool: boundaryNormalize}, testCapture())(context.Background(), testToolCall("call-1"), nil) + }, NormalizeTool: boundaryNormalize}, testCapture())(context.Background(), testToolCall("call-1"), []string{"echo"}) testassert.NoError(t, err) testassert.Equal(t, normalized, 2) testassert.Require(t, got.Status == model.ResultError && got.Content == invalidToolResultContent, "result = %+v, want the fixed internal-validation error", got) @@ -1968,7 +1965,7 @@ func TestToolOutcomeMetadataRules(t *testing.T) { } return PreparedTool{Permissions: fixturePermission, Execute: func(context.Context) ToolOutcome { return outcome }} } - got, err := h.toolEffect(c, testOpID, effectExecution(nil, plan), testCapture())(context.Background(), testToolCall("call-1"), nil) + got, err := h.toolEffect(c, testOpID, effectExecution(nil, plan), testCapture())(context.Background(), testToolCall("call-1"), []string{"echo"}) testassert.NoError(t, err) want := model.ToolResult{CallID: "call-1", Status: model.ResultSuccess, Content: "ran"} testassert.Equal(t, got, want) @@ -2274,7 +2271,7 @@ func TestToolResultAdoptsIntoView(t *testing.T) { return PreparedTool{Permissions: fixturePermission, Immediate: &ToolOutcome{Result: model.ToolResult{CallID: call.ID, Status: model.ResultSuccess, Content: "ran " + call.ID}}} } { - _, err := h.toolEffect(c, testOpID, effectExecution(nil, immediate), testCapture())(context.Background(), testToolCall("call-1"), nil) + _, err := h.toolEffect(c, testOpID, effectExecution(nil, immediate), testCapture())(context.Background(), testToolCall("call-1"), []string{"echo"}) testassert.NoError(t, err) } msgs, err := source(context.Background()) @@ -2284,7 +2281,7 @@ func TestToolResultAdoptsIntoView(t *testing.T) { tool := msgs[len(msgs)-1] testassert.Require(t, tool.Role == model.RoleTool && tool.ToolCallID == "call-1" && tool.TextContent() == "ran call-1", "projected tool message = %+v, want the committed call-1 result", tool) { - _, err := h.toolEffect(c, testOpID, effectExecution(nil, immediate), testCapture())(context.Background(), testToolCall("call-2"), nil) + _, err := h.toolEffect(c, testOpID, effectExecution(nil, immediate), testCapture())(context.Background(), testToolCall("call-2"), []string{"echo"}) testassert.NoError(t, err) } msgs, err = source(context.Background()) @@ -2534,7 +2531,7 @@ func TestSettleAgentTerminalClassifiesRunError(t *testing.T) { if err != nil { return err } - op.State.ActiveEffect = &ActiveEffect{Kind: EffectModel, ResultEntryID: hexID(7), RequestID: hexID(99)} + op.State.ActiveEffect = &ActiveEffect{Kind: EffectModel, ResultEntryID: hexID(7)} payload, err := encodeOperationRegister(op) if err != nil { return err @@ -2638,7 +2635,7 @@ func TestToolEffectGateBeforePreparation(t *testing.T) { } return nil } - got, err := h.toolEffect(c, testOpID, effectExecution(nil, spy.tool), testCapture())(ctx, testToolCall("call-1"), nil) + got, err := h.toolEffect(c, testOpID, effectExecution(nil, spy.tool), testCapture())(ctx, testToolCall("call-1"), []string{"echo"}) testassert.NoError(t, err) { dispatched := spy.dispatched() diff --git a/harness/graph_test.go b/harness/graph_test.go index 8aea7827..40b8e133 100644 --- a/harness/graph_test.go +++ b/harness/graph_test.go @@ -446,7 +446,7 @@ func TestGraphValidatorAcceptsCoherentSessions(t *testing.T) { t.Run("running model effect", func(t *testing.T) { fixture := validTestGraph() - fixture.ops[0].State.ActiveEffect = &ActiveEffect{Kind: EffectModel, ResultEntryID: hexID(3), RequestID: hexID(99)} + fixture.ops[0].State.ActiveEffect = &ActiveEffect{Kind: EffectModel, ResultEntryID: hexID(3)} view, err := validateFixture(t, fixture.storage(t), testSessionID) testassert.NoError(t, err) testassert.Require(t, view.Operations[0].State.ActiveEffect != nil && view.Operations[0].State.ActiveEffect.Kind == EffectModel, "typed view lost the active model effect") @@ -669,15 +669,6 @@ func TestGraphValidatorSurfacesCorruption(t *testing.T) { testassert.NoError(t, err) fixture.entries[0].rawOverride = setKey(raw, "bogus", json.RawMessage(`1`)) }}, - {"assistant payload without its request id", func(t *testing.T, fixture *testGraph) { - raw, err := encodeAssistantEntry(*fixture.entries[1].assistant) - testassert.NoError(t, err) - obj := wireObject(t, raw) - delete(obj, "request_id") - patched, err := json.Marshal(obj) - testassert.NoError(t, err) - fixture.entries[1].rawOverride = patched - }}, {"corrupt session register payload", func(t *testing.T, fixture *testGraph) { raw, err := encodeSessionRegister(fixture.session) testassert.NoError(t, err) @@ -1053,7 +1044,7 @@ func TestGraphValidatorSurfacesCorruption(t *testing.T) { } }}, {"model effect reserves a committed entry", func(t *testing.T, fixture *testGraph) { - fixture.ops[0].State.ActiveEffect = &ActiveEffect{Kind: EffectModel, ResultEntryID: hexID(1), RequestID: hexID(99)} + fixture.ops[0].State.ActiveEffect = &ActiveEffect{Kind: EffectModel, ResultEntryID: hexID(1)} }}, {"model effect reserves a tool call reservation", func(t *testing.T, fixture *testGraph) { assistant := validAssistantEntry(testOpID) @@ -1067,7 +1058,7 @@ func TestGraphValidatorSurfacesCorruption(t *testing.T) { CallID: "call-1", ResultEntryID: hexID(3), }} - fixture.ops[0].State.ActiveEffect = &ActiveEffect{Kind: EffectModel, ResultEntryID: hexID(3), RequestID: hexID(99)} + fixture.ops[0].State.ActiveEffect = &ActiveEffect{Kind: EffectModel, ResultEntryID: hexID(3)} }}, {"terminal op carries a second settlement entry", func(t *testing.T, fixture *testGraph) { fixture.session.State.CurrentOperationID = "" diff --git a/harness/hooks_test.go b/harness/hooks_test.go index 7a353054..8246943c 100644 --- a/harness/hooks_test.go +++ b/harness/hooks_test.go @@ -142,7 +142,7 @@ func TestToolArgumentHookChainSettlement(t *testing.T) { publishHookCalls(t, h, c, exec, model.ToolCall{ID: "call-1", Name: "echo", Arguments: json.RawMessage(` {"x": 1} `)}) te := h.toolEffect(c, testOpID, exec, testCapture()) - got, err := te(context.Background(), testToolCall("call-1"), nil) + got, err := te(context.Background(), testToolCall("call-1"), []string{"echo"}) testassert.NoError(t, err) testassert.Equal(t, got, (model.ToolResult{CallID: "call-1", Status: model.ResultSuccess, Content: "done"})) // Callback counts: one normalization for the valid original at the @@ -207,7 +207,7 @@ func TestToolArgumentHookInterruptionRules(t *testing.T) { return nil, ctx.Err() }} exec.ToolHooks = []ToolArgumentsHook{hook} - got, err := h.toolEffect(c, testOpID, exec, testCapture())(ctx, testToolCall("call-1"), nil) + got, err := h.toolEffect(c, testOpID, exec, testCapture())(ctx, testToolCall("call-1"), []string{"echo"}) testassert.NoError(t, err) testassert.Equal(t, got, (model.ToolResult{CallID: "call-1", Status: model.ResultInterrupted, Content: interruptedToolResultContent})) committed := hookGraphHookResults(t, store, sessionID) @@ -240,7 +240,7 @@ func TestToolArgumentHookInterruptionRules(t *testing.T) { return nil } baselineReplaces = replaces - got, err := h.toolEffect(c, testOpID, exec, testCapture())(ctx, testToolCall("call-1"), nil) + got, err := h.toolEffect(c, testOpID, exec, testCapture())(ctx, testToolCall("call-1"), []string{"echo"}) testassert.NoError(t, err) testassert.Equal(t, ran, 0) testassert.Equal(t, got, (model.ToolResult{CallID: "call-1", Status: model.ResultInterrupted, Content: interruptedToolResultContent})) @@ -268,7 +268,7 @@ func TestToolArgumentHookInterruptionRules(t *testing.T) { return json.RawMessage(`{}`), nil }}, } - got, err := h.toolEffect(c, testOpID, exec, testCapture())(ctx, testToolCall("call-1"), nil) + got, err := h.toolEffect(c, testOpID, exec, testCapture())(ctx, testToolCall("call-1"), []string{"echo"}) testassert.NoError(t, err) testassert.Equal(t, got, (model.ToolResult{CallID: "call-1", Status: model.ResultInterrupted, Content: interruptedToolResultContent})) testassert.Equal(t, secondRan, 0) @@ -287,7 +287,7 @@ func TestToolArgumentHookInterruptionRules(t *testing.T) { exec := Execution{Tool: func(context.Context, model.ToolCall) PreparedTool { return PreparedTool{} }, NormalizeTool: objectNormalize, ToolHooks: []ToolArgumentsHook{hook}} h, store, c, sessionID := newHookHarness(t, exec) publishHookCalls(t, h, c, exec, testToolCall("call-1")) - got, err := h.toolEffect(c, testOpID, exec, testCapture())(context.Background(), testToolCall("call-1"), nil) + got, err := h.toolEffect(c, testOpID, exec, testCapture())(context.Background(), testToolCall("call-1"), []string{"echo"}) testassert.NoError(t, err) testassert.Require(t, got.Status == model.ResultError && got.Content == "context canceled", "tool result = %+v, want the validation error with the hook's diagnostic", got) committed := hookGraphHookResults(t, store, sessionID) @@ -308,7 +308,7 @@ func TestToolArgumentHookInterruptionRules(t *testing.T) { return json.RawMessage(`{"fixed":true}`), nil }} exec.ToolHooks = []ToolArgumentsHook{hook} - got, err := h.toolEffect(c, testOpID, exec, testCapture())(ctx, testToolCall("call-1"), nil) + got, err := h.toolEffect(c, testOpID, exec, testCapture())(ctx, testToolCall("call-1"), []string{"echo"}) testassert.NoError(t, err) // The successful replacement committed as a real result; the // interruption appears at the next boundary (the tool intent). @@ -404,7 +404,7 @@ func TestToolArgumentHookFailureRules(t *testing.T) { h, store, c, sessionID := newHookHarness(t, exec) publishHookCalls(t, h, c, exec, testToolCall("call-1"), testToolCall("call-2")) - got, err := h.toolEffect(c, testOpID, exec, testCapture())(context.Background(), testToolCall("call-1"), nil) + got, err := h.toolEffect(c, testOpID, exec, testCapture())(context.Background(), testToolCall("call-1"), []string{"echo"}) testassert.NoError(t, err) testassert.Equal(t, got, tc.want) testassert.Equal(t, later, 0) @@ -412,7 +412,7 @@ func TestToolArgumentHookFailureRules(t *testing.T) { testassert.Require(t, len(committed) == 1 && committed[0].Status == tc.wantStatus && committed[0].HookID == "cap.hook.one" && len(committed[0].Arguments) == 0 && committed[0].Error != "", "hook results = %+v, want one %s result with a non-empty diagnostic", committed, tc.wantStatus) testassert.Require(t, tc.wantError == "" || committed[0].Error == tc.wantError, "hook result error = %q, want %q", committed[0].Error, tc.wantError) // Later calls still run: call-2 executes after the failed chain. - got, err = h.toolEffect(c, testOpID, exec, testCapture())(context.Background(), testToolCall("call-2"), nil) + got, err = h.toolEffect(c, testOpID, exec, testCapture())(context.Background(), testToolCall("call-2"), []string{"echo"}) testassert.NoError(t, err) testassert.Require(t, got.Status == model.ResultSuccess, "later call result = %+v, want success", got) prepareCalls := spy.received() @@ -464,7 +464,7 @@ func TestToolArgumentHookFailedResultCommitLeavesRecovery(t *testing.T) { return nil } { - _, err := h.toolEffect(c, testOpID, exec, testCapture())(context.Background(), testToolCall("call-1"), nil) + _, err := h.toolEffect(c, testOpID, exec, testCapture())(context.Background(), testToolCall("call-1"), []string{"echo"}) testassert.Require(t, errors.Is(err, ErrStorage), "tool effect = %v, want the injected storage failure", err) } mu.Lock() @@ -543,7 +543,7 @@ func TestToolArgumentHookDenialSettlesAfterHooks(t *testing.T) { }) h, store, c, sessionID := newHookHarness(t, exec) publishHookCalls(t, h, c, exec, testToolCall("call-1")) - got, err := h.toolEffect(c, testOpID, exec, testCapture())(context.Background(), testToolCall("call-1"), nil) + got, err := h.toolEffect(c, testOpID, exec, testCapture())(context.Background(), testToolCall("call-1"), []string{"echo"}) testassert.NoError(t, err) testassert.Equal(t, got, (model.ToolResult{CallID: "call-1", Status: model.ResultDenied, Content: PermissionDeniedContent})) testassert.Equal(t, executed, 0) @@ -556,7 +556,7 @@ func TestToolArgumentHookDenialSettlesAfterHooks(t *testing.T) { exec := buildExec(spy.tool) h, _, c, _ := newHookHarness(t, exec) publishHookCalls(t, h, c, exec, testToolCall("call-1")) - got, err := h.toolEffect(c, testOpID, exec, testCapture())(context.Background(), testToolCall("call-1"), nil) + got, err := h.toolEffect(c, testOpID, exec, testCapture())(context.Background(), testToolCall("call-1"), []string{"echo"}) testassert.NoError(t, err) testassert.Require(t, got.Status == model.ResultSuccess, "tool result = %+v, want success", got) prepared := spy.received() @@ -565,10 +565,11 @@ func TestToolArgumentHookDenialSettlesAfterHooks(t *testing.T) { } // TestToolArgumentHookAdvertisementGate proves the one advertisement gate -// precedes every hook: a committed call whose tool name is outside the -// producing request's advertised set settles the unavailable-tool error and -// never runs a hook — neither a later capture advertising that name nor a -// forged names argument grants the committed call retroactive authority. +// precedes every hook: a committed call whose tool name is absent from the +// Engine-supplied per-call names of the producing request settles the +// unavailable-tool error and never runs a hook — a capture advertising that +// name grants it nothing — while a names-complete call proceeds past the gate +// into its hook chain and plan. func TestToolArgumentHookAdvertisementGate(t *testing.T) { ran := 0 hook := ToolArgumentsHook{ID: "cap.hook.one", Run: func(context.Context, model.ToolCall) (json.RawMessage, error) { @@ -576,30 +577,35 @@ func TestToolArgumentHookAdvertisementGate(t *testing.T) { return json.RawMessage(`{}`), nil }} prepared := 0 - exec := Execution{Tool: func(context.Context, model.ToolCall) PreparedTool { prepared++; return PreparedTool{} }, NormalizeTool: objectNormalize, ToolHooks: []ToolArgumentsHook{hook}} + exec := Execution{Tool: func(_ context.Context, call model.ToolCall) PreparedTool { + prepared++ + return PreparedTool{Permissions: fixturePermission, Immediate: &ToolOutcome{Result: model.ToolResult{CallID: call.ID, Status: model.ResultSuccess, Content: "ran"}}} + }, NormalizeTool: objectNormalize, ToolHooks: []ToolArgumentsHook{hook}} h, store, c, sessionID := newHookHarness(t, exec) publishHookCalls(t, h, c, exec, model.ToolCall{ID: "call-1", Name: "ghost", Arguments: json.RawMessage(`{}`)}, model.ToolCall{ID: "call-2", Name: "ghost", Arguments: json.RawMessage(`{}`)}, ) - got, err := h.toolEffect(c, testOpID, exec, testCapture())(context.Background(), model.ToolCall{ID: "call-1", Name: "ghost"}, nil) + ghostCapture := testCapture() + ghostCapture.Tools = append(ghostCapture.Tools, model.ToolDefinition{Name: "ghost", Description: "ghost tool", Parameters: json.RawMessage(`{"type":"object"}`)}) + got, err := h.toolEffect(c, testOpID, exec, ghostCapture)(context.Background(), model.ToolCall{ID: "call-1", Name: "ghost"}, []string{"echo"}) testassert.NoError(t, err) testassert.Equal(t, got, (model.ToolResult{CallID: "call-1", Status: model.ResultError, Content: `Tool "ghost" is not available.`})) testassert.Equal(t, ran, 0) testassert.Equal(t, prepared, 0) { - ghostCapture := testCapture() - ghostCapture.Tools = append(ghostCapture.Tools, model.ToolDefinition{Name: "ghost", Description: "ghost tool", Parameters: json.RawMessage(`{"type":"object"}`)}) - got, err := h.toolEffect(c, testOpID, exec, ghostCapture)(context.Background(), model.ToolCall{ID: "call-2", Name: "ghost"}, []string{"ghost"}) - testassert.NoError(t, err) - testassert.Require(t, got.CallID == "call-2" && got.Status == model.ResultError && got.Content == `Tool "ghost" is not available.`, "gate under a retroactive capture and forged names = %+v, want the ordinary unavailable-tool error", got) + got := hookGraphHookResults(t, store, sessionID) + testassert.Require(t, len(got) == 0, "hook results after the gated call = %+v, want none", got) } - testassert.Equal(t, ran, 0) - testassert.Equal(t, prepared, 0) + got, err = h.toolEffect(c, testOpID, exec, testCapture())(context.Background(), model.ToolCall{ID: "call-2", Name: "ghost"}, []string{"ghost"}) + testassert.NoError(t, err) + testassert.Require(t, got.CallID == "call-2" && got.Status == model.ResultSuccess && got.Content == "ran", "names-complete call = %+v, want it past the gate into its hook chain and plan", got) + testassert.Equal(t, ran, 1) + testassert.Equal(t, prepared, 1) { got := hookGraphHookResults(t, store, sessionID) - testassert.Require(t, len(got) == 0, "hook results = %+v, want none", got) + testassert.Require(t, len(got) == 1 && got[0].HookID == "cap.hook.one", "hook results after the admitted call = %+v, want the one settled hook execution", got) } } @@ -670,7 +676,7 @@ func TestToolArgumentHookIntentFailureLeavesRecovery(t *testing.T) { return nil } { - _, err := h.toolEffect(c, testOpID, exec, testCapture())(context.Background(), testToolCall("call-1"), nil) + _, err := h.toolEffect(c, testOpID, exec, testCapture())(context.Background(), testToolCall("call-1"), []string{"echo"}) testassert.Require(t, errors.Is(err, ErrStorage), "tool effect = %v, want the injected storage failure", err) } testassert.Equal(t, ran, 0) diff --git a/harness/observe_test.go b/harness/observe_test.go index 5439ae5e..818a96f2 100644 --- a/harness/observe_test.go +++ b/harness/observe_test.go @@ -120,7 +120,7 @@ func TestObserveFactKinds(t *testing.T) { return PreparedTool{Permissions: fixturePermission, Immediate: &ToolOutcome{Result: model.ToolResult{CallID: call.ID, Status: model.ResultSuccess, Content: "done"}}} } { - _, err := h.toolEffect(c, testOpID, effectExecution(nil, plan), testCapture())(context.Background(), testToolCall("call-1"), nil) + _, err := h.toolEffect(c, testOpID, effectExecution(nil, plan), testCapture())(context.Background(), testToolCall("call-1"), []string{"echo"}) testassert.NoError(t, err) } @@ -733,7 +733,7 @@ func TestObserveIntentOnly(t *testing.T) { go func() { defer close(done) { - _, err := h.toolEffect(c, testOpID, effectExecution(nil, plan), testCapture())(context.Background(), testToolCall("call-1"), nil) + _, err := h.toolEffect(c, testOpID, effectExecution(nil, plan), testCapture())(context.Background(), testToolCall("call-1"), []string{"echo"}) testassert.Check(t, err == nil, "tool effect: %v", err) } }() @@ -1333,7 +1333,7 @@ func TestObserveToolLifecycle(t *testing.T) { cancel() ctx = canceled } - got, err := h.toolEffect(c, testOpID, effectExecution(nil, tc.plan), testCapture())(ctx, testToolCall("call-1"), nil) + got, err := h.toolEffect(c, testOpID, effectExecution(nil, tc.plan), testCapture())(ctx, testToolCall("call-1"), []string{"echo"}) testassert.NoError(t, err) testassert.Equal(t, got.Status, tc.status) facts := col.snapshot() @@ -1374,7 +1374,7 @@ func TestObserveToolLifecycle(t *testing.T) { }, usageDelta(model.Usage{InputTokens: 1, OutputTokens: 1})) publishStream(t, h, c, stream) col := observeHarness(h) - got, err := h.toolEffect(c, testOpID, effectExecution(nil, nil), testCapture())(context.Background(), testToolCall("call-1"), nil) + got, err := h.toolEffect(c, testOpID, effectExecution(nil, nil), testCapture())(context.Background(), testToolCall("call-1"), []string{"echo"}) testassert.Require(t, err == nil && got.Status == model.ResultError, "unavailable tool = %+v err %v, want the error result", got, err) facts := col.snapshot() testassert.Require(t, len(facts) == 3 && facts[0].Kind == FactToolStarted && facts[1].Kind == FactInvalidation && facts[2].Kind == FactToolFinished, "advertisement-gate facts = %+v, want started, the result commit, then finished", facts) @@ -1387,7 +1387,7 @@ func TestObserveToolLifecycle(t *testing.T) { return PreparedTool{Permissions: fixturePermission, Immediate: &ToolOutcome{Result: model.ToolResult{CallID: call.ID, Status: model.ResultSuccess, Content: "done"}}} } { - _, err := h.toolEffect(c, testOpID, effectExecution(nil, successPlan), testCapture())(context.Background(), testToolCall("call-1"), nil) + _, err := h.toolEffect(c, testOpID, effectExecution(nil, successPlan), testCapture())(context.Background(), testToolCall("call-1"), []string{"echo"}) testassert.NoError(t, err) } col := observeHarness(h) @@ -1419,7 +1419,7 @@ func TestObserveToolLifecycle(t *testing.T) { return nil, errors.New("hook boom") }}} col := observeHarness(h) - got, err := h.toolEffect(c, testOpID, exec, testCapture())(context.Background(), testToolCall("call-1"), nil) + got, err := h.toolEffect(c, testOpID, exec, testCapture())(context.Background(), testToolCall("call-1"), []string{"echo"}) testassert.Require(t, err == nil && got.Status == model.ResultError, "hook-settled tool = %+v err %v, want the error result", got, err) var started, finished, invalidations int for _, fact := range col.snapshot() { diff --git a/harness/payload.go b/harness/payload.go index 07f958ab..1d799f69 100644 --- a/harness/payload.go +++ b/harness/payload.go @@ -340,8 +340,6 @@ func encodeAssistantEntry(v assistantEntry) (json.RawMessage, error) { SessionID string `json:"session_id"` EntryID string `json:"entry_id"` OperationID string `json:"operation_id,omitempty"` - RequestID string `json:"request_id"` - Advertised []string `json:"advertised,omitempty"` Status string `json:"status"` Source json.RawMessage `json:"source"` Content []json.RawMessage `json:"content"` @@ -353,8 +351,6 @@ func encodeAssistantEntry(v assistantEntry) (json.RawMessage, error) { SessionID: v.SessionID, EntryID: v.EntryID, OperationID: v.OperationID, - RequestID: v.RequestID, - Advertised: v.Advertised, Status: string(v.Status), Source: source, Content: content, @@ -380,7 +376,7 @@ func decodeAssistantEntry(env Entry) (assistantEntry, error) { if err != nil { return assistantEntry{}, err } - if err := rejectUnknownMembers(obj, "session_id", "entry_id", "operation_id", "request_id", "advertised", "status", "source", "content", "refusal", "extra", "tool_calls", "usage"); err != nil { + if err := rejectUnknownMembers(obj, "session_id", "entry_id", "operation_id", "status", "source", "content", "refusal", "extra", "tool_calls", "usage"); err != nil { return assistantEntry{}, err } sessionID, err := stringMember(obj, "session_id", true) @@ -395,10 +391,6 @@ func decodeAssistantEntry(env Entry) (assistantEntry, error) { if err != nil { return assistantEntry{}, err } - requestID, err := stringMember(obj, "request_id", true) - if err != nil { - return assistantEntry{}, err - } status, err := stringMember(obj, "status", true) if err != nil { return assistantEntry{}, err @@ -452,26 +444,12 @@ func decodeAssistantEntry(env Entry) (assistantEntry, error) { SessionID: sessionID, EntryID: entryID, OperationID: operationID, - RequestID: requestID, Status: model.OutputStatus(status), Source: source, Refusal: refusal, Extra: extra, Usage: usage, } - if _, present := obj["advertised"]; present { - advertisedRaw, err := arrayMember(obj, "advertised", true) - if err != nil { - return assistantEntry{}, err - } - for i, raw := range advertisedRaw { - var name string - if err := json.Unmarshal(raw, &name); err != nil { - return assistantEntry{}, fmt.Errorf("advertised[%d]: must be a JSON string: %w", i, err) - } - v.Advertised = append(v.Advertised, name) - } - } for i, raw := range contentRaw { part, err := decodeContentPart(raw) if err != nil { @@ -496,10 +474,9 @@ func decodeAssistantEntry(env Entry) (assistantEntry, error) { } // validateAssistantEntry enforces the closed assistant-entry shape: closed -// output status, complete source identity, the producing request's non-empty -// identity with unique non-empty advertised tool names, unique tool calls with -// zero-based ordinals and unique reserved results, no calls on errored or -// interrupted output, and an eligible model-visible payload. +// output status, complete source identity, unique tool calls with zero-based +// ordinals and unique reserved results, no calls on errored or interrupted +// output, and an eligible model-visible payload. func validateAssistantEntry(v assistantEntry) error { if err := validateHexID(v.SessionID, "session id"); err != nil { return err @@ -514,19 +491,6 @@ func validateAssistantEntry(v assistantEntry) error { } else if v.Usage != nil { return errors.New("independently copied fork-prefix assistant entry carries no source usage") } - if v.RequestID == "" { - return errors.New("assistant entry requires its producing request id") - } - seenAdvertised := make(map[string]bool, len(v.Advertised)) - for i, name := range v.Advertised { - if name == "" { - return fmt.Errorf("advertised[%d]: tool name must be non-empty", i) - } - if seenAdvertised[name] { - return fmt.Errorf("advertised[%d]: tool name %q is not unique", i, name) - } - seenAdvertised[name] = true - } if !validOutputStatus(v.Status) { return fmt.Errorf("assistant status %q is not one of completed, errored or interrupted", v.Status) } @@ -2324,7 +2288,7 @@ func decodeOperationState(obj map[string]json.RawMessage) (OperationCurrentState // decodeActiveEffect reads one active effect with exact keys. func decodeActiveEffect(obj map[string]json.RawMessage) (ActiveEffect, error) { - if err := rejectUnknownMembers(obj, "kind", "result_entry_id", "request_id", "tool_call_id", "hook_id"); err != nil { + if err := rejectUnknownMembers(obj, "kind", "result_entry_id", "tool_call_id", "hook_id"); err != nil { return ActiveEffect{}, err } kind, err := stringMember(obj, "kind", true) @@ -2335,10 +2299,6 @@ func decodeActiveEffect(obj map[string]json.RawMessage) (ActiveEffect, error) { if err != nil { return ActiveEffect{}, err } - requestID, err := optionalNonEmptyString(obj, "request_id") - if err != nil { - return ActiveEffect{}, err - } toolCallID, err := optionalNonEmptyString(obj, "tool_call_id") if err != nil { return ActiveEffect{}, err @@ -2347,7 +2307,7 @@ func decodeActiveEffect(obj map[string]json.RawMessage) (ActiveEffect, error) { if err != nil { return ActiveEffect{}, err } - v := ActiveEffect{Kind: EffectKind(kind), ResultEntryID: resultEntryID, RequestID: requestID, ToolCallID: toolCallID, HookID: hookID} + v := ActiveEffect{Kind: EffectKind(kind), ResultEntryID: resultEntryID, ToolCallID: toolCallID, HookID: hookID} if err := validateHexID(v.ResultEntryID, "active effect result entry id"); err != nil { return ActiveEffect{}, err } @@ -2414,9 +2374,8 @@ func decodeOperationTerminal(obj map[string]json.RawMessage) (OperationTerminal, // active and pending effects, carry the contract detail rule, and name a // durable settlement entry; a tool active effect addresses the matching first // pending call under its reserved result identity; active effects carry -// durable result identities and the request id rule is closed by kind — model -// requires one, tool and hook forbid it; pending calls have unique identities -// and reserved results. +// durable result identities; pending calls have unique identities and +// reserved results. func validateOperationState(v OperationCurrentState) error { switch v.Status { case OperationRunning, OperationSuccess, OperationFailure, OperationInterruption: @@ -2466,9 +2425,6 @@ func validateOperationState(v OperationCurrentState) error { } switch v.ActiveEffect.Kind { case EffectModel: - if v.ActiveEffect.RequestID == "" { - return errors.New("model active effect requires its request id") - } if v.ActiveEffect.ToolCallID != "" { return errors.New("model active effect omits the tool call id") } @@ -2476,9 +2432,6 @@ func validateOperationState(v OperationCurrentState) error { return errors.New("model active effect omits the hook id") } case EffectTool: - if v.ActiveEffect.RequestID != "" { - return errors.New("tool active effect omits the request id") - } if v.ActiveEffect.ToolCallID == "" { return errors.New("tool active effect requires its tool call id") } @@ -2492,9 +2445,6 @@ func validateOperationState(v OperationCurrentState) error { return errors.New("tool active effect must reserve the first pending call's result identity") } case EffectHook: - if v.ActiveEffect.RequestID != "" { - return errors.New("hook active effect omits the request id") - } if v.ActiveEffect.ToolCallID == "" { return errors.New("hook active effect requires its tool call id") } diff --git a/harness/public_test.go b/harness/public_test.go index 53111246..59945d4c 100644 --- a/harness/public_test.go +++ b/harness/public_test.go @@ -3124,7 +3124,7 @@ func rawAssistantEntryPayload(sessionID, entryID, operationID string, calls [][2 call[0], i, args, call[1])) } return fmt.Sprintf( - `{"session_id":%q,"entry_id":%q,"operation_id":%q,"request_id":"raw-request-1","status":"completed",`+ + `{"session_id":%q,"entry_id":%q,"operation_id":%q,"status":"completed",`+ `"source":{"provider":"prov","model":"gpt-x"},"content":[{"kind":"text","text":"done"}],"tool_calls":[%s]}`, sessionID, entryID, operationID, strings.Join(parts, ",")) } @@ -3153,7 +3153,7 @@ func seedRunningOperationShape(t *testing.T, store harness.Storage, activeEffect switch activeEffect { case "model": modelReserved = newRawSessionID(t) - activeEffectJSON = fmt.Sprintf(`,"active_effect":{"kind":"model","result_entry_id":%q,"request_id":"raw-request-1"}`, modelReserved) + activeEffectJSON = fmt.Sprintf(`,"active_effect":{"kind":"model","result_entry_id":%q}`, modelReserved) case "tool": activeEffectJSON = fmt.Sprintf(`,"active_effect":{"kind":"tool","result_entry_id":%q,"tool_call_id":%q}`, reserved[0], calls[0]) case "hook": @@ -3502,7 +3502,7 @@ func rawCopiedAssistantPayload(sessionID, entryID string, calls [][2]string) str call[0], i, args, call[1])) } return fmt.Sprintf( - `{"session_id":%q,"entry_id":%q,"request_id":"raw-request-1","status":"completed",`+ + `{"session_id":%q,"entry_id":%q,"status":"completed",`+ `"source":{"provider":"prov","model":"gpt-x"},"content":[{"kind":"text","text":"done"}],"tool_calls":[%s]}`, sessionID, entryID, strings.Join(parts, ",")) } @@ -4352,7 +4352,7 @@ func TestPublicRecoverCorruptSibling(t *testing.T) { input := snapEntryOf(snap, harness.EntryInput) reg := snapRegisterOf(snap, harness.RegisterOperation, "op-1") reg.Payload = editStateObject(t, reg.Payload, func(state map[string]json.RawMessage) { - state["active_effect"] = json.RawMessage(fmt.Sprintf(`{"kind":"model","result_entry_id":%q,"request_id":"raw-request-1"}`, input.ID)) + state["active_effect"] = json.RawMessage(fmt.Sprintf(`{"kind":"model","result_entry_id":%q}`, input.ID)) }) }, }, @@ -4363,7 +4363,7 @@ func TestPublicRecoverCorruptSibling(t *testing.T) { reserved := snapReservedResultID(t, snap) reg := snapRegisterOf(snap, harness.RegisterOperation, "op-1") reg.Payload = editStateObject(t, reg.Payload, func(state map[string]json.RawMessage) { - state["active_effect"] = json.RawMessage(fmt.Sprintf(`{"kind":"model","result_entry_id":%q,"request_id":"raw-request-1"}`, reserved)) + state["active_effect"] = json.RawMessage(fmt.Sprintf(`{"kind":"model","result_entry_id":%q}`, reserved)) }) }, }, @@ -4958,9 +4958,7 @@ func TestPublicForkCopiesPrefixAndAdmits(t *testing.T) { } testassert.Equal(t, copiedInput.Origin, "user") var copiedAssistant struct { - RequestID string `json:"request_id"` - Advertised []string `json:"advertised"` - ToolCalls []struct { + ToolCalls []struct { ID string `json:"id"` ResultEntryID string `json:"result_entry_id"` NormalizedArguments json.RawMessage `json:"normalized_arguments"` @@ -4974,24 +4972,6 @@ func TestPublicForkCopiesPrefixAndAdmits(t *testing.T) { testassert.Require(t, copiedAssistant.Usage == nil, "copied assistant carries source usage") testassert.Require(t, len(copiedAssistant.ToolCalls) == 1 && copiedAssistant.ToolCalls[0].ID == "call-1" && copiedAssistant.ToolCalls[0].ResultEntryID == entries[2].ID, "copied call reservation = %+v, want it rewritten to the copied result entry %s", copiedAssistant.ToolCalls, entries[2].ID) testassert.Require(t, string(copiedAssistant.ToolCalls[0].NormalizedArguments) == `{"x":1}`, "copied assistant normalized arguments = %s, want the source's committed normalization preserved verbatim", copiedAssistant.ToolCalls[0].NormalizedArguments) - sourceAssistant := snapEntryOf(&before, harness.EntryAssistant) - testassert.Require(t, sourceAssistant != nil, "the source snapshot carries no assistant entry") - var sourceAssistantWire struct { - RequestID string `json:"request_id"` - Advertised []string `json:"advertised"` - ToolCalls []struct { - ID string `json:"id"` - ResultEntryID string `json:"result_entry_id"` - NormalizedArguments json.RawMessage `json:"normalized_arguments"` - } `json:"tool_calls"` - Usage *struct{} `json:"usage"` - } - { - err := json.Unmarshal(sourceAssistant.Payload, &sourceAssistantWire) - testassert.NoError(t, err) - } - testassert.Require(t, copiedAssistant.RequestID != "" && copiedAssistant.RequestID == sourceAssistantWire.RequestID, "copied assistant request id = %q, want the source assistant's %q", copiedAssistant.RequestID, sourceAssistantWire.RequestID) - testassert.Require(t, reflect.DeepEqual(copiedAssistant.Advertised, sourceAssistantWire.Advertised) && reflect.DeepEqual(copiedAssistant.Advertised, []string{"echo"}), "copied assistant advertised = %v, want the source's advertised set [echo] (source: %v)", copiedAssistant.Advertised, sourceAssistantWire.Advertised) var copiedResult struct { AssistantEntry struct { SessionID string `json:"session_id"` @@ -6030,7 +6010,7 @@ func rawSettledSuccessOperation(t *testing.T, store harness.Storage, sessionID, fmt.Sprintf(`{"session_id":%q,"entry_id":%q,"operation_id":%q,"origin":"user","content":[{"kind":"text","text":%q}]}`, sessionID, inputID, operationID, inputText)) insertRawEntry(t, store, sessionID, assistantID, operationID, harness.EntryAssistant, - fmt.Sprintf(`{"session_id":%q,"entry_id":%q,"operation_id":%q,"request_id":"raw-request-1","status":"completed",`+ + fmt.Sprintf(`{"session_id":%q,"entry_id":%q,"operation_id":%q,"status":"completed",`+ `"source":{"provider":"prov","model":"gpt-x"},"content":[{"kind":"text","text":%q}],"tool_calls":[]}`, sessionID, assistantID, operationID, assistantText)) insertRawEntry(t, store, sessionID, settlementID, operationID, harness.EntryOperationSettlement, @@ -6065,7 +6045,7 @@ func TestPublicRecoverRunningCompactOperation(t *testing.T) { now := time.Now().UTC().Format(time.RFC3339Nano) insertRawRegister(t, store, harness.RegisterKey{SessionID: sessionID, Kind: harness.RegisterOperation, OperationID: "compact-op"}, fmt.Sprintf(`{"admission":%s,"state":{"status":"running","started_at":%q,`+ - `"active_effect":{"kind":"model","result_entry_id":%q,"request_id":"raw-request-1"},"pending_tool_calls":[],"usage":{"by_model":[]}}}`, + `"active_effect":{"kind":"model","result_entry_id":%q},"pending_tool_calls":[],"usage":{"by_model":[]}}}`, rawCompactAdmission(sessionID, "compact-op"), now, reserved)) // The seeded shape: a running compact request with a live model intent. diff --git a/harness/schema.go b/harness/schema.go index d36d0bae..5c201ab7 100644 --- a/harness/schema.go +++ b/harness/schema.go @@ -213,16 +213,13 @@ type OperationAdmission struct { } // ActiveEffect names the one in-flight effect of a running Operation and the -// entry reserved for its result. A model effect carries the request identity -// its intent consumed and omits the tool-call and hook IDs; a tool effect -// requires the tool-call ID, addresses the matching first pending call, and -// carries no request identity; a hook effect requires both the hook ID and the -// tool-call ID of the first pending call it runs for and carries no request -// identity. +// entry reserved for its result. A model effect omits the tool-call and hook +// IDs; a tool effect requires the tool-call ID and addresses the matching +// first pending call; a hook effect requires both the hook ID and the +// tool-call ID of the first pending call it runs for. type ActiveEffect struct { Kind EffectKind `json:"kind"` ResultEntryID string `json:"result_entry_id"` - RequestID string `json:"request_id,omitempty"` ToolCallID string `json:"tool_call_id,omitempty"` HookID string `json:"hook_id,omitempty"` } @@ -295,14 +292,11 @@ type toolCallRecord struct { } // assistantEntry is one finalized model output: status, complete source -// identity, the producing request's identity and ordered advertised tool -// names, eligible model-visible payload, and reported usage when present. +// identity, eligible model-visible payload, and reported usage when present. type assistantEntry struct { SessionID string `json:"session_id"` EntryID string `json:"entry_id"` OperationID string `json:"operation_id,omitempty"` - RequestID string `json:"request_id"` - Advertised []string `json:"advertised,omitempty"` Status model.OutputStatus `json:"status"` Source model.ModelRef `json:"source"` Content []model.ContentPart `json:"content"` diff --git a/harness/snapshot.go b/harness/snapshot.go index 9856d4ab..c5c6c965 100644 --- a/harness/snapshot.go +++ b/harness/snapshot.go @@ -543,9 +543,6 @@ func ownInputPayload(v inputEntry) *inputEntry { func ownAssistantPayload(v assistantEntry) *assistantEntry { out := v - if len(v.Advertised) > 0 { - out.Advertised = append([]string(nil), v.Advertised...) - } out.Content = ownContentParts(v.Content) out.Extra = v.Extra.Clone() if len(v.ToolCalls) == 0 { diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index 18aaa11a..121a6942 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -30,9 +30,9 @@ const ( // sqliteSchemaVersion is the only accepted schema version; it is stamped // into PRAGMA user_version at initialization and checked at open. Version - // 8 is the changed assistant-entry and operation-register payload shapes; - // the SQL tables are unchanged. - sqliteSchemaVersion = 8 + // 7 is the changed capture and settlement payloads; the SQL tables are + // unchanged. + sqliteSchemaVersion = 7 // sqlitePoolDSN configures every physical connection of the long-lived // pool: write-ahead logging, full synchronous durability, immediate diff --git a/internal/storage/sqlite_test.go b/internal/storage/sqlite_test.go index 451491b3..fe729aea 100644 --- a/internal/storage/sqlite_test.go +++ b/internal/storage/sqlite_test.go @@ -186,7 +186,7 @@ func sqliteFixtureDatabase(t *testing.T, path string, ddl []string, version int) } // TestSQLiteSchemaInitialization proves a missing path is initialized with the -// complete canonical schema in one shot: version 8, exactly the two canonical +// complete canonical schema in one shot: version 7, exactly the two canonical // tables and the one explicit index, only the autoindexes implied by those // tables, a well-formed image, no sidecars after close, and no partial file // when initialization fails. @@ -202,7 +202,7 @@ func TestSQLiteSchemaInitialization(t *testing.T) { var version int { err := conn.QueryRowContext(ctx, `PRAGMA user_version`).Scan(&version) - testassert.Require(t, err == nil && version == 8, "PRAGMA user_version = %d (error %v), want 8", version, err) + testassert.Require(t, err == nil && version == 7, "PRAGMA user_version = %d (error %v), want 7", version, err) } objects := map[string]string{} @@ -263,7 +263,7 @@ func TestSQLiteSchemaInitialization(t *testing.T) { var emptyVersion int { err := emptyStore.db.QueryRowContext(ctx, `PRAGMA user_version`).Scan(&emptyVersion) - testassert.Check(t, err == nil && emptyVersion == 8, "zero-length file user_version = %d (error %v), want 8", emptyVersion, err) + testassert.Check(t, err == nil && emptyVersion == 7, "zero-length file user_version = %d (error %v), want 7", emptyVersion, err) } testassert.NoError(t, emptyStore.Close()) sqliteAssertNoSidecars(t, empty) @@ -276,7 +276,7 @@ func TestSQLiteSchemaInitialization(t *testing.T) { var reservedVersion int { err := reservedStore.db.QueryRowContext(ctx, `PRAGMA user_version`).Scan(&reservedVersion) - testassert.Check(t, err == nil && reservedVersion == 8, "reserved-path user_version = %d (error %v), want 8", reservedVersion, err) + testassert.Check(t, err == nil && reservedVersion == 7, "reserved-path user_version = %d (error %v), want 7", reservedVersion, err) } testassert.NoError(t, reservedStore.Close()) { @@ -395,7 +395,7 @@ func TestSQLiteRejectsNonEmptyVersionZero(t *testing.T) { testassert.Require(t, errors.Is(err, harness.ErrIncompatible), "error = %v, want ErrIncompatible", err) var incompatible *harness.IncompatibleSchemaError testassert.Require(t, errors.As(err, &incompatible), "error = %T, want *harness.IncompatibleSchemaError", err) - testassert.Check(t, incompatible.Found == 0 && incompatible.Supported == 8, "recovered %+v, want found 0 supported 8", incompatible) + testassert.Check(t, incompatible.Found == 0 && incompatible.Supported == 7, "recovered %+v, want found 0 supported 7", incompatible) testassert.Check(t, store == nil, "rejected database returned a store") { after := sqliteFileDigest(t, path) @@ -405,19 +405,19 @@ func TestSQLiteRejectsNonEmptyVersionZero(t *testing.T) { } // TestSQLiteRejectsPriorVersion proves a database left at the prior schema -// version 7 — the capture and settlement payload shape with +// version 6 — the child-lineage and background-completion payload shape with // the unchanged canonical tables — is rejected through the read-only open // phase with the incompatible class before recovery ever sees the store, // leaving the file byte-identical with no sidecars. func TestSQLiteRejectsPriorVersion(t *testing.T) { path := filepath.Join(t.TempDir(), "prior.db") - sqliteFixtureDatabase(t, path, []string{sqliteEntriesDDL, sqliteRegistersDDL, sqliteOperationIndexDDL}, 7) + sqliteFixtureDatabase(t, path, []string{sqliteEntriesDDL, sqliteRegistersDDL, sqliteOperationIndexDDL}, 6) before := sqliteFileDigest(t, path) store, err := OpenSQLite(path) testassert.Require(t, errors.Is(err, harness.ErrIncompatible), "error = %v, want ErrIncompatible", err) var incompatible *harness.IncompatibleSchemaError - testassert.Check(t, errors.As(err, &incompatible) && incompatible.Found == 7 && incompatible.Supported == 8, "recovered %+v, want found 7 supported 8", incompatible) + testassert.Check(t, errors.As(err, &incompatible) && incompatible.Found == 6 && incompatible.Supported == 7, "recovered %+v, want found 6 supported 7", incompatible) testassert.Check(t, store == nil, "rejected prior-version database returned a store") { after := sqliteFileDigest(t, path) @@ -427,25 +427,25 @@ func TestSQLiteRejectsPriorVersion(t *testing.T) { } // TestSQLiteRejectsUnsupportedVersion proves a database with a future schema -// version beyond the accepted 8 is rejected without any file or sidecar +// version beyond the accepted 7 is rejected without any file or sidecar // change. func TestSQLiteRejectsUnsupportedVersion(t *testing.T) { path := filepath.Join(t.TempDir(), "future.db") - sqliteFixtureDatabase(t, path, []string{sqliteEntriesDDL, sqliteRegistersDDL, sqliteOperationIndexDDL}, 9) + sqliteFixtureDatabase(t, path, []string{sqliteEntriesDDL, sqliteRegistersDDL, sqliteOperationIndexDDL}, 8) before := sqliteFileDigest(t, path) _, err := OpenSQLite(path) testassert.Require(t, errors.Is(err, harness.ErrIncompatible), "error = %v, want ErrIncompatible", err) var incompatible *harness.IncompatibleSchemaError - testassert.Check(t, errors.As(err, &incompatible) && incompatible.Found == 9 && incompatible.Supported == 8, "recovered %+v, want found 9 supported 8", incompatible) + testassert.Check(t, errors.As(err, &incompatible) && incompatible.Found == 8 && incompatible.Supported == 7, "recovered %+v, want found 8 supported 7", incompatible) { after := sqliteFileDigest(t, path) - testassert.Check(t, after == before, "rejected version-9 database file changed") + testassert.Check(t, after == before, "rejected version-8 database file changed") } sqliteAssertNoSidecars(t, path) - // Unsupported hot-WAL sibling: version 9 lives only in an uncheckpointed - // WAL beside a checkpointed version 8 main file, with no SHM file — the + // Unsupported hot-WAL sibling: version 8 lives only in an uncheckpointed + // WAL beside a checkpointed version 7 main file, with no SHM file — the // crashed-writer state. Read-only validation must see the WAL-carried // version and reject the copy while leaving the database and WAL bytes // unchanged; a SQLite-owned -shm WAL index may appear and remain. @@ -453,7 +453,7 @@ func TestSQLiteRejectsUnsupportedVersion(t *testing.T) { live, err := sql.Open(sqliteDriverName, sqliteDSN(source, "_journal_mode=WAL&_synchronous=FULL")) testassert.NoError(t, err) { - _, err := live.Exec(`PRAGMA user_version = 8`) + _, err := live.Exec(`PRAGMA user_version = 7`) testassert.NoError(t, err) } { @@ -461,7 +461,7 @@ func TestSQLiteRejectsUnsupportedVersion(t *testing.T) { testassert.NoError(t, err) } { - _, err := live.Exec(`PRAGMA user_version = 9`) + _, err := live.Exec(`PRAGMA user_version = 8`) testassert.NoError(t, err) } mainBytes, err := os.ReadFile(source) @@ -478,7 +478,7 @@ func TestSQLiteRejectsUnsupportedVersion(t *testing.T) { crashedStore, err := OpenSQLite(target) testassert.Require(t, errors.Is(err, harness.ErrIncompatible), "error = %v, want ErrIncompatible", err) var walIncompatible *harness.IncompatibleSchemaError - testassert.Check(t, errors.As(err, &walIncompatible) && walIncompatible.Found == 9 && walIncompatible.Supported == 8, "recovered %+v, want the WAL-carried version found 9 supported 8", walIncompatible) + testassert.Check(t, errors.As(err, &walIncompatible) && walIncompatible.Found == 8 && walIncompatible.Supported == 7, "recovered %+v, want the WAL-carried version found 8 supported 7", walIncompatible) testassert.Check(t, crashedStore == nil, "rejected WAL-mode database returned a store") { after := sqliteFileDigest(t, target) @@ -520,12 +520,12 @@ ON registers(operation_id) WHERE kind = 'operation'` } { t.Run(name, func(t *testing.T) { path := filepath.Join(t.TempDir(), "schema.db") - sqliteFixtureDatabase(t, path, ddl, 8) + sqliteFixtureDatabase(t, path, ddl, 7) store, err := OpenSQLite(path) testassert.Require(t, errors.Is(err, harness.ErrIncompatible), "error = %v, want ErrIncompatible", err) var incompatible *harness.IncompatibleSchemaError testassert.Require(t, errors.As(err, &incompatible), "error = %T, want *harness.IncompatibleSchemaError", err) - testassert.Check(t, incompatible.Found == 8 && incompatible.Supported == 8, "recovered %+v, want found 8 supported 8", incompatible) + testassert.Check(t, incompatible.Found == 7 && incompatible.Supported == 7, "recovered %+v, want found 7 supported 7", incompatible) testassert.Check(t, store == nil, "rejected database returned a store") }) } @@ -585,7 +585,7 @@ func TestSQLitePersistenceAcrossCloseReopen(t *testing.T) { sqliteEntriesDDL, sqliteRegistersDDL, sqliteOperationIndexDDL, - `PRAGMA user_version = 8`, + `PRAGMA user_version = 7`, `INSERT INTO registers (session_id, kind, operation_id, revision, payload) VALUES ('s1', 'session', '', 1, '{"session":"s1"}')`, `INSERT INTO entries (session_id, sequence, entry_id, operation_id, kind, committed_at_ns, payload) VALUES ('s1', 1, 'e1', '', 'input', 1700000000000000000, '{"entry":"e1"}')`, } { diff --git a/runtime/compact_lifecycle_test.go b/runtime/compact_lifecycle_test.go index d9d67d9e..c56324d9 100644 --- a/runtime/compact_lifecycle_test.go +++ b/runtime/compact_lifecycle_test.go @@ -260,8 +260,8 @@ func compactRawOperationRegister(admission, state string) string { func compactRawRunningModelState(now, reserved string) string { return fmt.Sprintf( - `{"status":"running","started_at":%q,"active_effect":{"kind":"model","result_entry_id":%q,"request_id":"req-%s"},"pending_tool_calls":[],"usage":{"by_model":[]}}`, - now, reserved, reserved) + `{"status":"running","started_at":%q,"active_effect":{"kind":"model","result_entry_id":%q},"pending_tool_calls":[],"usage":{"by_model":[]}}`, + now, reserved) } func compactRawSuccessState(now, settlementEntry, sessionID, usage string) string { @@ -278,9 +278,9 @@ func compactRawInputEntry(sessionID, entryID, operationID, origin, text string) func compactRawAssistantEntry(sessionID, entryID, operationID, text string) string { return fmt.Sprintf( - `{"session_id":%q,"entry_id":%q,"operation_id":%q,"request_id":"req-%s","status":"completed",`+ + `{"session_id":%q,"entry_id":%q,"operation_id":%q,"status":"completed",`+ `"source":{"provider":"prov","model":"m"},"content":[{"kind":"text","text":%q}],"tool_calls":[]}`, - sessionID, entryID, operationID, operationID, text) + sessionID, entryID, operationID, text) } func compactRawSettlementEntry(sessionID, entryID, operationID, extra string) string { diff --git a/runtime/protocol_paging_test.go b/runtime/protocol_paging_test.go index e9e50ae6..32d5fa4b 100644 --- a/runtime/protocol_paging_test.go +++ b/runtime/protocol_paging_test.go @@ -408,14 +408,13 @@ func usageSeedMessageOperation(t *testing.T, store harness.Storage, sessionID, o SessionID string `json:"session_id"` EntryID string `json:"entry_id"` OperationID string `json:"operation_id"` - RequestID string `json:"request_id"` Status string `json:"status"` Source usageWireRef `json:"source"` Content json.RawMessage `json:"content"` ToolCalls []harness.ToolCallRecord `json:"tool_calls"` Usage *harness.UsageCount `json:"usage,omitempty"` }{ - SessionID: sessionID, EntryID: assistantID, OperationID: operationID, RequestID: "req-" + operationID, + SessionID: sessionID, EntryID: assistantID, OperationID: operationID, Status: "completed", Source: usageWireRef{Provider: "prov", Model: "m"}, Content: json.RawMessage(`[{"kind":"text","text":"answer ` + operationID + `"}]`), ToolCalls: []harness.ToolCallRecord{},