From 99797e7977eefdc3a3c0e52c336c8192767e9bf0 Mon Sep 17 00:00:00 2001 From: MMinasyan Date: Fri, 23 Oct 2026 15:15:10 +0400 Subject: [PATCH] fix(agent): remove model request limit --- agent/contract.go | 2 +- agent/run.go | 19 +++---------- agent/run_cancel_test.go | 17 +++++------ agent/run_loop_test.go | 55 ++++++++++++++++++++---------------- agent/run_settlement_test.go | 6 ++-- harness/effect_test.go | 27 +++++++++--------- 6 files changed, 58 insertions(+), 68 deletions(-) diff --git a/agent/contract.go b/agent/contract.go index 8de120dd..b76d08ae 100644 --- a/agent/contract.go +++ b/agent/contract.go @@ -3,7 +3,7 @@ // later Harness layer supplies plain functions for context, 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 a fixed model-effect cap, and validating what those +// 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. diff --git a/agent/run.go b/agent/run.go index db79902b..99698e5b 100644 --- a/agent/run.go +++ b/agent/run.go @@ -8,16 +8,10 @@ import ( "github.com/MMinasyan/lightcode/model" ) -// maxModelEffects is the fixed cap on logical model-effect invocations per run; tool effects never consume it. -const maxModelEffects = 25 +// Agent-observed run-context cancellation and interrupted tool results share this detail. +const detailInterrupted = "agent interrupted" -// The two contract-fixed terminal details: Agent-observed run-context cancellation and interrupted tool results interrupt under detailInterrupted, and model-effect cap exhaustion fails under detailCap. Every other diagnostic wording stays free. -const ( - detailInterrupted = "agent interrupted" - detailCap = "agent exceeded 25 model effects" -) - -// 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 counted 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 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. 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") @@ -34,15 +28,11 @@ func Run(ctx context.Context, inv Invocation) (TerminalResult, error) { } var lastSettled *model.Output // the most recent authoritative model settlement; only a completed one may be carried by a later interruption. - effects := 0 // logical model effects already run, counted against the fixed cap. for { - if ctx.Err() != nil { // cancellation dominates every other loop-top outcome, the cap included. + if ctx.Err() != nil { return retainedInterruption(lastSettled, nil) } - if effects == maxModelEffects { // a settlement would otherwise continue: fail before a 26th request is ever constructed. - return finishTerminal(TerminalResult{Status: TerminalFailure, LastOutput: lastSettled, Detail: detailCap}) - } msgs, err := inv.Context(ctx) if err != nil { @@ -66,7 +56,6 @@ func Run(ctx context.Context, inv Invocation) (TerminalResult, error) { } 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) - effects++ 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") diff --git a/agent/run_cancel_test.go b/agent/run_cancel_test.go index 20544526..b1b92b2b 100644 --- a/agent/run_cancel_test.go +++ b/agent/run_cancel_test.go @@ -259,24 +259,21 @@ func TestRunInfraErrorsWinOverCancellation(t *testing.T) { }) } -// TestRunCancellationDominatesCap pins the cap-vs-cancellation order: when the run context is done at the exact point the cap would otherwise fire, the result is an interruption, not the cap failure. -func TestRunCancellationDominatesCap(t *testing.T) { +// TestRunCancelledAfterManyContinuations proves a long run still stops before the next effect. +func TestRunCancelledAfterManyContinuations(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) + defer cancel() f := &runFakes{} - sets := make([]ModelSettlement, maxModelEffects) - for i := range sets[:maxModelEffects-1] { - sets[i] = erroredContinue(i) - } f.modelSet = func(call int, req model.Request, cb AssemblyCallback) (ModelSettlement, error) { - if call == maxModelEffects { - return cancelThen(cancel, erroredContinue(call)) + if call == 30 { + cancel() } - return setScript(sets...)(call, req, cb) + return erroredContinue(call), nil } res, err := Run(ctx, f.invocation()) requireRunInterrupted(t, res, err, "agent interrupted") // the last settlement was errored, which no interruption terminal may carry. testassert.Require(t, res.LastOutput == nil, "interruption carried %#v, want no output", res.LastOutput) - testassert.Require(t, f.modelCalls == maxModelEffects && f.sourceCalls == maxModelEffects, "model=%d source=%d, want 25/25", f.modelCalls, f.sourceCalls) + testassert.Require(t, f.modelCalls == 30 && f.sourceCalls == 30, "model=%d source=%d, want 30/30", f.modelCalls, f.sourceCalls) } diff --git a/agent/run_loop_test.go b/agent/run_loop_test.go index 89fb32a5..a3bd1e89 100644 --- a/agent/run_loop_test.go +++ b/agent/run_loop_test.go @@ -114,61 +114,66 @@ func TestRunDeniedAndErrorResultsNeverStopTheBatch(t *testing.T) { testassert.Require(t, f.toolCalls == 3 && f.modelCalls == 2, "tool=%d model=%d, want 3/2", f.toolCalls, f.modelCalls) } -// TestRunCapExhaustedAfterContinue pins the cap failure shape: after 25 continues, Agent fails with the last errored output and the fixed detail before a 26th request — and before another snapshot fetch. -func TestRunCapExhaustedAfterContinue(t *testing.T) { +// TestRunContinuesPast25ModelEffects proves continuation length cannot fail a run. +func TestRunContinuesPast25ModelEffects(t *testing.T) { f := &runFakes{} - sets := make([]ModelSettlement, maxModelEffects) - for i := range sets { + sets := make([]ModelSettlement, 51) + for i := range sets[:50] { sets[i] = erroredContinue(i) } + sets[50] = ModelSettlement{Disposition: DispoReady, Output: mkOutput(model.OutputCompleted, "", nil)} f.modelSet = setScript(sets...) res, err := Run(context.Background(), f.invocation()) testassert.NoError(t, err) - testassert.Require(t, res.Status == TerminalFailure && res.Detail == "agent exceeded 25 model effects", "terminal = %s/%q, want failure with the cap detail", res.Status, res.Detail) - testassert.Equal(t, res.LastOutput, sets[maxModelEffects-1].Output) - testassert.Require(t, f.modelCalls == maxModelEffects && f.sourceCalls == maxModelEffects, "model=%d source=%d, want 25/25 — the cap ends the run before a 26th request", f.modelCalls, f.sourceCalls) + testassert.Require(t, res.Status == TerminalSuccess, "terminal = %s/%q, want success after 50 continuations", res.Status, res.Detail) + testassert.Equal(t, res.LastOutput, sets[50].Output) + testassert.Require(t, f.modelCalls == 51 && f.sourceCalls == 51, "model=%d source=%d, want 51/51", f.modelCalls, f.sourceCalls) } -// TestRunFinalTextFromEffect25Succeeds pins that the 25th logical model effect may still finish the run cleanly: its call-free completed output succeeds instead of tripping the cap. +// TestRunFinalTextFromEffect25Succeeds pins success at the former request boundary. func TestRunFinalTextFromEffect25Succeeds(t *testing.T) { f := &runFakes{} - sets := make([]ModelSettlement, maxModelEffects) - for i := range sets[:maxModelEffects-1] { + sets := make([]ModelSettlement, 25) + for i := range sets[:24] { sets[i] = erroredContinue(i) } - sets[maxModelEffects-1] = ModelSettlement{Disposition: DispoReady, Output: mkOutput(model.OutputCompleted, "", nil)} + sets[24] = ModelSettlement{Disposition: DispoReady, Output: mkOutput(model.OutputCompleted, "", nil)} f.modelSet = setScript(sets...) res, err := Run(context.Background(), f.invocation()) testassert.Require(t, err == nil && res.Status == TerminalSuccess, "run = %#v, %v; want success from the 25th effect", res, err) - testassert.Equal(t, f.modelCalls, maxModelEffects) + testassert.Equal(t, f.modelCalls, 25) } -// TestRunEffect25SettlesBatchBeforeCapFailure pins that a completed tool batch from the 25th effect fully settles — every call dispatched — and only then fails with that last completed output under the cap detail. -func TestRunEffect25SettlesBatchBeforeCapFailure(t *testing.T) { +// TestRunToolBatchesContinuePast25ModelEffects proves ordered tool dispatch continues until final text. +func TestRunToolBatchesContinuePast25ModelEffects(t *testing.T) { f := &runFakes{} - sets := make([]ModelSettlement, maxModelEffects) - for i := range sets[:maxModelEffects-1] { - sets[i] = erroredContinue(i) - } + sets := make([]ModelSettlement, 31) callsOut := mkCallsTerminalOutput() - sets[maxModelEffects-1] = ModelSettlement{Disposition: DispoReady, Output: callsOut} + for i := range sets[:30] { + sets[i] = ModelSettlement{Disposition: DispoReady, Output: callsOut} + } + final := mkOutput(model.OutputCompleted, "", nil) + sets[30] = ModelSettlement{Disposition: DispoReady, Output: final} f.modelSet = setScript(sets...) res, err := Run(context.Background(), f.invocation()) testassert.NoError(t, err) - testassert.Require(t, res.Status == TerminalFailure && res.Detail == "agent exceeded 25 model effects" && res.LastOutput == callsOut, "terminal = %s/%q, want cap failure; got %v, want %v", res.Status, res.Detail, res.LastOutput, callsOut) - testassert.Require(t, f.toolCalls == 3 && f.modelCalls == maxModelEffects && f.sourceCalls == maxModelEffects, "tool=%d model=%d source=%d, want 3/25/25 — the batch settles before the cap ends the run", f.toolCalls, f.modelCalls, f.sourceCalls) + testassert.Require(t, res.Status == TerminalSuccess && res.LastOutput == final, "terminal = %s/%q, want success with final text", res.Status, res.Detail) + testassert.Require(t, f.toolCalls == 90 && f.modelCalls == 31 && f.sourceCalls == 31, "tool=%d model=%d source=%d, want 90/31/31", f.toolCalls, f.modelCalls, f.sourceCalls) + for i, call := range f.calls { + testassert.Equal(t, call.ID, callsOut.Message.ToolCalls[i%3].ID) + } } -// TestRunToolEffectsDoNotConsumeModelCap pins that tool calls beyond the numeric model cap are irrelevant to it: one batch of 30 calls followed by a final text response still succeeds. -func TestRunToolEffectsDoNotConsumeModelCap(t *testing.T) { +// TestRunLargeToolBatchSucceeds pins that every call in a large batch settles. +func TestRunLargeToolBatchSucceeds(t *testing.T) { f := &runFakes{} - bigOut := mkOutput(model.OutputCompleted, "", callArgs(maxModelEffects+5, json.RawMessage(`{}`))) + bigOut := mkOutput(model.OutputCompleted, "", callArgs(30, json.RawMessage(`{}`))) f.modelSet = readyOutputs(bigOut, mkOutput(model.OutputCompleted, "", nil)) 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, f.toolCalls == maxModelEffects+5 && f.modelCalls == 2, "tool=%d model=%d, want 30/2", f.toolCalls, f.modelCalls) + testassert.Require(t, f.toolCalls == 30 && f.modelCalls == 2, "tool=%d model=%d, want 30/2", f.toolCalls, f.modelCalls) } diff --git a/agent/run_settlement_test.go b/agent/run_settlement_test.go index 32dd5e6b..a724a0a8 100644 --- a/agent/run_settlement_test.go +++ b/agent/run_settlement_test.go @@ -130,8 +130,8 @@ func TestRunDetailMismatchDispositionsRejectedThroughRun(t *testing.T) { func TestRunInfraErrorAfterPriorContinuationVerbatim(t *testing.T) { f := &runFakes{} f.modelSet = func(call int, req model.Request, cb AssemblyCallback) (ModelSettlement, error) { - if call == 1 { - return erroredContinue(1), nil + if call <= 30 { + return erroredContinue(call), nil } return ModelSettlement{}, errInfra } @@ -139,7 +139,7 @@ func TestRunInfraErrorAfterPriorContinuationVerbatim(t *testing.T) { res, err := Run(context.Background(), f.invocation()) requireZeroTerminal(t, res) testassert.Require(t, errors.Is(err, errInfra), "error = %v, want the infrastructure error verbatim", err) - testassert.Require(t, f.modelCalls == 2 && f.sourceCalls == 2 && f.toolCalls == 0, "model=%d source=%d tool=%d, want 2/2/0", f.modelCalls, f.sourceCalls, f.toolCalls) + testassert.Require(t, f.modelCalls == 31 && f.sourceCalls == 31 && f.toolCalls == 0, "model=%d source=%d tool=%d, want 31/31/0", f.modelCalls, f.sourceCalls, f.toolCalls) } // TestDuplicateCallIDSharedValidator pins the one identity rule under both shared validators: a completed output whose tool calls repeat an ID is rejected naming the "model" boundary from the settlement validator and the "agent" boundary from the terminal validator, while distinct IDs pass both — the nearest allowed sibling. diff --git a/harness/effect_test.go b/harness/effect_test.go index 7aa7cc1d..b17234e7 100644 --- a/harness/effect_test.go +++ b/harness/effect_test.go @@ -2155,26 +2155,25 @@ func TestExecuteBetweenEffectCancellationSettlesInterruption(t *testing.T) { } } -// TestExecuteCapSettlesFailure proves the model-effect cap exhausts through -// the outer path: the Operation settles failure with the Agent's cap detail -// after the last settled continuation. -func TestExecuteCapSettlesFailure(t *testing.T) { +// TestExecutePast25ModelEffectsSettlesSuccessAndDrainsSteering proves a long +// Operation commits its continuations, then succeeds and drains buffered input. +func TestExecutePast25ModelEffectsSettlesSuccessAndDrainsSteering(t *testing.T) { var ( mu sync.Mutex modelRuns int - arrived25 = make(chan struct{}) - gate25 = make(chan struct{}) + arrived30 = make(chan struct{}) + gate30 = make(chan struct{}) ) modelFn := func(context.Context, model.Request) (model.Stream, error) { mu.Lock() modelRuns++ n := modelRuns mu.Unlock() - if n == 25 { // pending steering arrives at the last effect, before the cap check - close(arrived25) - <-gate25 + if n == 30 { + close(arrived30) + <-gate30 } - if n > 25 { + if n > 30 { return completedTurnStream(), nil // the drained successor's own turn } return erroredTurnStream(), nil @@ -2184,19 +2183,19 @@ func TestExecuteCapSettlesFailure(t *testing.T) { }) done := make(chan error, 1) go func() { done <- executeDirect(h, c, h.ctx, testOpID, prepared) }() - receiveBounded(t, arrived25, "the 25th model effect") + receiveBounded(t, arrived30, "the 30th model effect") { _, err := submitText(t, h, sessionID, "op-2", MessageModeRegular, "s1") testassert.NoError(t, err) } - close(gate25) + close(gate30) { err := <-done testassert.NoError(t, err) } rec, err := h.ReadOperation(context.Background(), sessionID, testOpID) testassert.NoError(t, err) - testassert.Require(t, rec.State.Status == OperationFailure && rec.State.Terminal != nil && rec.State.Terminal.Detail == "agent exceeded 25 model effects", "operation state = %+v, want terminal failure at the cap", rec.State) + testassert.Require(t, rec.State.Status == OperationSuccess && rec.State.Terminal != nil && rec.State.Terminal.Detail == "", "operation state = %+v, want terminal success without a synthetic failure", rec.State) awaitOperationTerminal(t, store, sessionID, "op-2", OperationSuccess) // the pending head drains as its own Operation awaitHarnessQuiet(t, h, sessionID) graph, err := validateFixture(t, store, sessionID) @@ -2210,7 +2209,7 @@ func TestExecuteCapSettlesFailure(t *testing.T) { signals++ } } - testassert.Require(t, assistants == 25 && signals == 25, "%d assistants and %d signals committed, want 25 of each", assistants, signals) + testassert.Require(t, assistants == 30 && signals == 30, "%d assistants and %d signals committed, want 30 of each", assistants, signals) { owner, _ := steeringInputOwner(t, store, sessionID, "s1") testassert.Equal(t, owner, "op-2")