Skip to content

Replace tool iteration limits with exact recurrence guards - #2420

Draft
Aaronontheweb wants to merge 45 commits into
devfrom
feature/tool-recurrence
Draft

Aaronontheweb wants to merge 45 commits into
devfrom
feature/tool-recurrence

Conversation

@Aaronontheweb

@Aaronontheweb Aaronontheweb commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

Hard tool-iteration limits stop useful long tasks. This change removes those limits and detects exact recurrence across feedback rounds.

The detector compares prepared call identities and trusted outcomes. Two equal rounds trigger correction. A further prohibited recurrence stops the batch before dispatch. Changed outcomes and authorized pending-job checks can continue. Approval prompts, cancellation, operation health checks, and the adjacent-cycle guard remain.

Durable task evidence survives compaction, restart, approval redrive, and attributed job results. Recovery preserves committed sibling results and closes unanswered calls with truthful non-success results. The model decides further work from recorded facts. The framework does not replay uncertain effects or request another model response for terminal settlement.

Subscriber death also remains safe across synchronous journal writes. Subscribers use raw death notifications because persistence can lose custom death-watch messages. Explicit leave removes the watch. Tests stop real subscribers during a held journal write and verify durable turn completion and idle passivation.

This is the first PR in native GitHub stack #2423. Its base is dev. Dependent drafts are #2421 and #2422. The delivery plan records contracts and evidence limits.

Why this is safe: Trusted prepared identities and actual outcomes govern recurrence. Durable task adoption retains the original authority. Terminal settlement requires no additional model response. Private replay and productive holdout gates remain mandatory.

Validation:

  • The stack includes dev at ba37133e.
  • All three stronger subscriber tests pass on the repair. The original code fails the held-journal case with DeathPactException.
  • Omission of Unwatch fails both explicit-leave controls. Independent reviews confirm the persistence and actor-message cause.
  • The original SignalR admission tests pass 12/12 on the repair.
  • Retained detector controls cover one million call sequences, 10,000 checkpoints, eighteen named faults, authority, persistence failures, and cold recovery.
  • Earlier five-trial compaction and changed-result batches pass at fff88fc47 and 0120c5b53. These remain revision-scoped evidence.
  • Slopwatch, copyright headers, and diff checks pass. All 29 hosted checks pass at 5fe92fe2.
  • The combined cycle batch at 3dd2c1a3 records 34 original passes from 35 trials. The stricter JSON contract yields 33 passes. One compaction trial adds a forbidden effect; another uses fenced JSON.
  • All ten framework terminal trials use zero upstream inference requests, including sidecars. Independent review confirms actual records and cleanup.
  • Broader model acceptance remains open. Original verdicts and failed trials remain unchanged.
  • The original combined stack has a productive false block after child delivery. Two earlier missing-report reads retain detector suspicion after child completion. PR Define the background child run lifecycle contract #2421 now starts a fresh detector window at first durable canonical child-result adoption. Task identity, authority, receipt failures, and duplicate checks remain intact. The repair passes 177 actor cases and six authority harness cases, with independent review. One fresh scripted live regression now passes at combined head f6685d5f, with independent review. Two Missing reads precede child completion. Two later reads return the actual file; the next identical read receives a guard correction. This proves the specific regression, not general model compliance. Private replay, shadow review, and the productive holdout still require separate acceptance.

The actual SQLite plugin rejects new tta-v1 records through the original reader before effects. A disposable backup restoration retains the original events, snapshot, and job artifact. This proof excludes live rollback and cannot undo external effects.

This PR remains a draft. Private replay, shadow, and the 3,000-turn independent productive holdout remain open. No faithful replay or independently labeled productive holdout turn is certified. Exact recurrence does not detect every semantic loop or guarantee finite total spend.

No merge, deployment, or skill publication is included.

Assert.Equal(1, _client.CompactionCount);
var compaction = Assert.Single(_compactions);
Assert.True(compaction.Summarized);
Assert.True(compaction.MessagesAfter < compaction.MessagesBefore);
var evt = new ToolTaskAdopted
{
SessionId = _sessionId, TurnContext = admitted.TurnContext, InputIds = inputIds.ToArray(), AdoptedAtMs = NowMs(),
ContinuedJobKey = admitted.SourceBackgroundJobId?.Value
{
_state = _state.Apply(closed);
_inFlightDedup.CompleteBackgroundJob(input.SourceBackgroundJobId);
_log.Error("Background job lineage contract failure job={JobKey}", input.SourceBackgroundJobId?.Value);
if (authority.SessionId != evt.SessionId || evt.LoopAdmission?.TaskId != authority.TurnId.Value
|| evt.LegacyTaskContext is null || !SessionState.SameCanonicalContext(authority.ToRecord(), evt.LegacyTaskContext))
throw new InvalidDataException("A legacy tool baseline differs from the original approval task.");
var firstCall = evt.LoopAdmission.Calls.FirstOrDefault()
if (cmd.Source?.BackgroundJobId is not null
&& (cmd.Source.Principal != PrincipalClassification.VerifiedAutomation
|| cmd.Source.Provenance.TransportAuthenticity != TransportAuthenticity.LocalProcess
|| cmd.Source.Provenance.SourceKind?.Value != BackgroundJobManagerActor.SourceKind))
Comment thread src/Netclaw.Actors/Sessions/Handlers/TurnStateTracker.cs Fixed
Comment thread src/Netclaw.Actors/Sessions/Handlers/TurnStateTracker.cs Fixed
Comment thread src/Netclaw.Actors.Tests/Jobs/BackgroundJobDefinitionStoreTests.cs Fixed
Comment thread src/Netclaw.Actors.Tests/Jobs/BackgroundJobManagerActorTests.cs Fixed
{
context?.AddModelInputFile(imagePath, "diagram.png", "image/png");
ArgumentNullException.ThrowIfNull(context);
context.AddModelInputFile(imagePath, "diagram.png", "image/png");
contexts.Add(adopted.TurnContext);
eventOrder.Add($"adopt:{adopted.TurnContext.TurnId}");
});
Recover<ToolCallRecorded>(result => eventOrder.Add($"result:{result.ToolResult.ToolCallId?.Value}"));
IReadOnlyList<SerializableChatMessage> history,
SerializableChatMessage assistantMessage,
string callId)
=> FindToolResultsFor(history, assistantMessage).Any(message => message.ToolCallId?.Value == callId);
Comment thread src/Netclaw.Security.Tests/RepositoryIdentityTests.cs Fixed
Comment on lines +242 to +307
catch (Exception failure)
{
var failures = new List<Exception>
{
operationDeadline.IsCancellationRequested && !cancellationToken.IsCancellationRequested
? new TimeoutException("The process exceeded the ten-second operation deadline.", failure)
: failure
};
if (process.StartInfo.RedirectStandardInput)
{
try
{
// EOF lets the Git wrapper wait for its child before the wrapper exits.
process.StandardInput.Close();
if (!operationDeadline.IsCancellationRequested)
await process.WaitForExitAsync(operationDeadline.Token);
}
catch (Exception inputTeardownFailure)
{
failures.Add(inputTeardownFailure);
}
}
try
{
if (!process.HasExited)
{
try
{
process.Kill(entireProcessTree: true);
}
catch (InvalidOperationException) when (process.HasExited)
{
// The process can exit between the ownership check and termination.
await process.WaitForExitAsync(CancellationToken.None);
}
}
await process.WaitForExitAsync(CancellationToken.None);
}
catch (Exception teardownFailure)
{
failures.Add(teardownFailure);
}

// Stop pipe reads after teardown. Their errors must not replace the operation failure.
try
{
await deadline.CancelAsync();
}
catch (Exception cancellationFailure)
{
failures.Add(cancellationFailure);
}
try
{
await drains;
}
catch (Exception drainFailure)
{
failures.Add(drains.Exception is { } drainErrors ? drainErrors : drainFailure);
}
var output = standardOutput.IsCompletedSuccessfully ? await standardOutput : "<output did not complete>";
var error = standardError.IsCompletedSuccessfully ? await standardError : "<error output did not complete>";
throw new AggregateException(
$"Process failed: {process.StartInfo.FileName} {string.Join(' ', process.StartInfo.ArgumentList)}\n" +
$"stdout: {output}\nstderr: {error}", failures);
}
@Aaronontheweb
Aaronontheweb force-pushed the feature/tool-recurrence branch 5 times, most recently from f985e4f to 98426eb Compare October 11, 2026 16:14
The recurrence guard replaces the hard MaxToolIterationsPerTurn budget. Remove
the demo AppHost env var, README prose, and the session-config-decomposition
requirement that still referenced the removed key.
@Aaronontheweb
Aaronontheweb force-pushed the feature/tool-recurrence branch from 98426eb to ee2f744 Compare October 12, 2026 02:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant