Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion agent/contract.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
19 changes: 4 additions & 15 deletions agent/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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 {
Expand All @@ -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")
Expand Down
17 changes: 7 additions & 10 deletions agent/run_cancel_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
55 changes: 30 additions & 25 deletions agent/run_loop_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
6 changes: 3 additions & 3 deletions agent/run_settlement_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,16 +130,16 @@ 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
}

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.
Expand Down
27 changes: 13 additions & 14 deletions harness/effect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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")
Expand Down