Skip to content

fix(code-execution): separate script execution from bounded observation - #155

Closed
Meganeuridae wants to merge 3 commits into
anima-research:mainfrom
Meganeuridae:codex/exec-lifecycle-fixes
Closed

Meganeuridae wants to merge 3 commits into
anima-research:mainfrom
Meganeuridae:codex/exec-lifecycle-fixes

Conversation

@Meganeuridae

@Meganeuridae Meganeuridae commented Sep 17, 2026 •

Copy link
Copy Markdown

Problem

A Python await currently holds the model-facing tool call open until the whole script exits. The runner also accepts overlapping cold starts, and an outstanding tool response from a killed interpreter can satisfy a replacement interpreter's reused call ID. Background wake requests can exceed the configured rate/cap when concurrent and falsely acknowledge a context delivery failure.

Changes

  • Reserve startup before awaiting readiness; make startup cancellation settle promptly; bind tool replies and wake acknowledgements to their originating interpreter/execution; preserve sub-second tool timeouts.
  • Separate script execution from bounded observation. A persistent agent's foreground calls return a running script_id after 10 seconds by default (wait_ms, maximum 60 seconds); action=wait retrieves or rejoins the same run. Ephemeral agents wait for the script to end, since they cannot receive a completion notice. Persistent Python state stays serial and busy responses identify the active run.
  • on_timeout=end_turn arms completion delivery before returning an end-turn result. An active observer consumes completion directly; otherwise a yielded execution sends a bounded completion notice and wake. A result a notice announced stays retrievable until the agent retrieves it (at most 20 per owner); other settled results, the five most recent. An inner end-turn ends only a turn still waiting on its script.
  • Preserve independent background interpreters and existing silent-clean-exit watcher behavior. Serialize explicit wakes, acknowledge delivery failures honestly, and scope inner-tool end-turn effects to their originating execution.
  • Host quiesce (Quiesce/maintenance mode: pause main thread + MCPLs, run maintenance through the live compression machinery #122) keeps its guarantee: the drain waits for foreground scripts that outlived their turn and abandon stops them; host status adds foregroundScripts. Background watchers stay exempt.
  • Add releaseCodeExecutionWait(agentName, scriptId?) for the host's admin-only command. This resolves observations through the normal tool-result boundary without killing Python. It is not an agent tool and is not exposed on the generic framework socket API.

The agent-facing description keeps its original guidance and adds the waiting behavior; docs/code-execution-lifecycle.md describes the exact behavior and the further design work. A breaking-change fragment calls out the new foreground return contract.

Tests

Current head (merged with main at e45c1a1, which includes #203):

  • Build passes; npm test: 1138 pass / 0 fail / 4 skipped (1142 total).
  • Focused code-execution, ScriptRun and quiesce tests: 98 pass / 0 fail.
  • Each review fix and each previously untested changelog fix has a regression test that fails when the fix is reverted (14 reverts, 14 failures).
  • Real terminal-sessions acceptance against this branch: 7/7. Paired host check with connectome-host#130 (recipe limits reach the tool, untrusted release refused, admin release keeps Python running, completion wakes once): 4/4.

Original submission: baseline 8b612f5 786 pass; branch 802 pass; three regressions replayed red against the original source.

Not verified / limits

No live provider, resident deployment, host restart recovery, or cross-platform run was performed. Scripts/results remain process-local. Cancelling Python does not cancel an already dispatched inner tool. A wake_agent queued behind a live turn is acknowledged once queued; a store rejection when it is later flushed is logged and dropped, as for every deferred write. Durable outcomes, paged journals, and delivery receipts are explicitly deferred in the design note.

Companion PRs

Companion: anima-research/connectome-host#130. It adds admin-only /release-wait [script_id] and recipe validation. Publish/install a framework release containing this primitive before expecting that command to work; the companion fails explicitly on older framework versions. No merge, package publication, dependency rollout, or deployment is included here.

Generated with OpenAI Codex.

Review fixes: 🤖 Generated with Claude Code

Reserve interpreter startup, isolate late replies, serialize wake delivery, retain execution results, and support ending an observation with completion wake armed.

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>

@slimepriestess slimepriestess left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed head 7130ac8, and as a trial merge onto main@67991cf (0.18.0). Second read by Opus 5.5 in a separate session; the ephemeral finding and the test-gap list are its catches, re-verified here.

Verdict: CHANGES REQUESTED. For primary agents the mechanism is right and the ordering in ScriptRun holds up. Three things stand in the way: ephemerals lose results and are told they won't, four of the changelog's fixes have no test that goes red on revert, and the branch doesn't typecheck against current main.

Blocking

  1. Ephemerals are promised a wake they can't receive, and their script is killed when they believe it. An ephemeral's foreground call yields after 10s with the note "Still running. Completion will notify you"; the tool description says the same. When the ephemeral's run ends, the finally in runEphemeralToCompletion calls disposeAgentScripts, which sets cancelled and so suppresses the completion notice in settleCodeExecution. An ephemeral that ends its turn on that note never sees its output. Before this PR a 30s script in an ephemeral blocked and returned. The PR already knows ephemerals can't be woken, which is why on_timeout=end_turn is refused for them (runCodeExecution); the default path breaks the same promise silently. No test touches ephemerals on the foreground path. Fix is either blocking semantics for ephemerals (observe until the script deadline) or an honest note ("you must keep waiting; this execution ends with your run") plus a test for whichever you pick.

  2. Four fixes in the changelog have no revert-goes-red test. Reverting any of these leaves the suite green:

    • Inner-tool endTurn scoped to its execution ("keep deferred end-turn effects scoped"). Background runners now pass no onEndTurn, so a background inner endTurn is dropped instead of landing on an unrelated foreground result. Only the pre-existing foreground case at test/code-execution.test.ts:508 touches endTurn.
    • abortAgentScript aborting only an observed run ("unrelated later stream failures leave yielded work alone"). Nothing exercises a stream error against an observed run, or against a yielded one. Item 4 below depends on this behavior, so it needs the test.
    • Stale wake_ack after interpreter replacement. The test at :299 covers tool_result; the wake branch has the same guard and no test.
    • wakeAbort cutting a rate-floor wait short when the run ends.
  3. Doesn't typecheck on main. getHostModeStatus (src/framework.ts:2741 on main, from #122) reads this.backgroundScripts, which this PR renames. Git merges everything but the first import line cleanly, then tsc fails with TS2551 on that property. [...this.codeExecutionScripts.values()].filter(r => r.status === 'running').length fixes it; see item 4 for what the field should mean now.

For antra to rule

  1. Yielded foreground runs change a guarantee of #122's quiesce. Before: a foreground script held a turn token, so drain waited for it and abandon killed it. Now: drained: true can be reported while model-authored foreground work is mid-flight (up to scriptTimeoutMs, 600s by default), and abandon no longer stops it, because abortAgentScript skips a run nobody is observing. Background daemons were already exempt by design; this extends the exemption to work that came out of a turn. Either abandon also aborts yielded foreground runs, or the quiesce doc says drained is not quiet. Whichever way, the status field should count both modes and probably stop being called backgroundScripts.

  2. The code_execution description is rewritten, and the guidance flips. tool-definition.ts drops the header comment antra wrote on 7/31 explaining that the tool's name, input shape and description deliberately track Anthropic's server-side programmatic tool calling so models' trained priors transfer. The new text replaces "call tools directly when a single call answers the question" with "choose direct calls or code as useful, including for a single call," drops the sanitized-name example, and drops the "arm a wake rule as backup" caution. It also never says that a detached foreground run still dies at scriptTimeoutMs, so an agent will start a 30-minute job in the foreground and get a DIED wake at ten minutes. The lifecycle sentences need adding, no question. The rest is a decision about how residents are steered, riding inside a lifecycle PR.

Notes

  1. A deferred inner-tool endTurn from a yielded run is applied to whichever later wait retrieves the result: the agent wakes on completion, calls wait, and the retrieval ends its turn. Defensible; say so in the doc, or clear the flag on yield.
  2. Script wakes are now system: true, so isConversationalInjection returns false for them: a wake no longer clears explicit-send suppression and never influences routing. Reads right for completion notices, less obviously right for wake_agent(payload), which is the script speaking to its owner. Intentional?
  3. ScriptRun drops the old belt-and-suspenders catch. With the new py-runner the completion can't reject by construction (the ensureChild chain settles its own failures), so this is defense in depth, not a bug. If it ever did reject, the record would sit at running with the context reporting busy; cancel recovers it.
  4. injectScriptWake's return value is ignored inside settleCodeExecution, so a completion whose notice fails to land is lost to an agent that already ended its turn. The doc admits this under limits; fine for now, worth a line in the notice-failure log at least.

Verified

  • runtime-py.ts on main already emits exec_id on tool_call and wake, so the new "reply must match the pending execution" filter is live-correct, not a silent drop.
  • wait_ms: integer 0–60000, default codeExecution.foregroundWaitMs then 10_000; end_turn refused for ephemerals and unregistered agents.
  • Busy foreground context returns the running id. cancel of a yielded run calls PyRunner.abort(), which reclaims the interpreter (the runner object survives, the Python child and its globals don't); the next run gets a fresh child. The test at :727 prints a literal, so it doesn't actually show freshness; asserting a prior global is gone would.
  • Startup reserve: pending is set before the first await; abort and dispose during startup settle; late tool_result from a replaced interpreter is dropped (tests).
  • Wake serialization under asyncio.gather respects floor and cap; delivery failure surfaces in Python as RuntimeError (tests).
  • Completion path: addMessage(..., { forAgent }) plus a pendingRequests entry with reason script:wake; parked during quiesce, admitted otherwise; not an EventGate event, so "bypasses ambient gating" is accurate. "Queued even if it arrives during turn teardown" is read, not tested: the release tests sleep 0.4s, so completion lands after teardown.
  • ScriptRun ordering: result set, waiters finished, onComplete run synchronously in the same .then; the observer's continuation runs after, so record.status is final when read. A timer firing before completion arms one notice; completion before the timer delivers to the observer; no path gives both.
  • Head: script-run + code-execution tests 42/42. Trial merge with item 3 patched: 974 tests, 970 pass, 0 fail, 4 skipped (workspace-mount re-attach cases, platform-gated, unrelated).
  • Companion connectome-host#130: calls releaseCodeExecutionWait behind a feature check, gates /release-wait on operator authority, and validates foregroundWaitMs as an integer ≤ 60000. Not reviewed beyond that.

For the #architecture thread: the ten seconds isn't hardcoded. Default 10_000, recipe codeExecution.foregroundWaitMs (host main already forwards the block whole), per-call wait_ms up to 60_000.

@greptile-apps

greptile-apps Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 3/5 Tier: apex

[Critical risk] Restructures code execution lifecycle and observation model.

The PR is not ready to merge until late inner-tool end-turn effects and deferred wake acknowledgments are correctly scoped and delivered.

Findings

  1. P1 Late end-turn affects another turn ▶
  2. P1 Deferred wake acknowledged too early ▶
  3. P2 Invalid default blocks script management ▶
  4. P2 Completion notice promises evicted result ▶
Fix with agent prompt
### Issue 1
src/framework.ts:8200-8201
When a foreground script outlasts its observation budget and later calls an inner tool such as `skip_reply`, that tool sets `record.endTurn` after the original tool result has returned. A later `action=wait`, even after the script finishes, consumes the flag and ends that unrelated turn prematurely.

### Issue 2
src/framework.ts:8488
When `wake_agent()` fires during an active turn, the envelope is deferred rather than stored, but this path immediately acknowledges delivery to Python. If the later flush fails, it discards the envelope while the queued wake can still start inference, so the agent never receives the payload Python was told it delivered.

### Issue 3
src/framework.ts:8095-8098
If the host sets `foregroundWaitMs` outside 0–60000, validation rejects every `code_execution` call without an explicit `wait_ms`. That includes `action=list`, `action=cancel`, and immediate background starts, none of which needs a foreground observation budget. An invalid default therefore prevents agents from managing existing scripts.

### Issue 4
src/framework.ts:8442
The notice tells the agent to retrieve the result by `script_id`, but if five other executions settle before the agent acts on the queued wake, this result is evicted. `action=wait` then returns `no script`, leaving a foreground script’s full output unavailable beyond the notice’s short tail.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

This PR separates Python script execution from bounded tool-call observation and adds retained results, waits, completion wakes, and an operator wait-release method. It also tightens interpreter startup, reply correlation, and background wake handling.

  • The new lifecycle has two turn/delivery boundary failures that need correction.
  • Configuration validation and result-eviction messaging need smaller follow-up fixes.

Reviews (1) · Last reviewed commit: "fix(code-execution): separate script lif..."

Comment thread src/framework.ts
Comment on lines +8200 to +8201
const endTurn = observed.endTurn || record.endTurn;
record.endTurn = false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Late end-turn affects another turn When a foreground script outlasts its observation budget and later calls an inner tool such as skip_reply, that tool sets record.endTurn after the original tool result has returned. A later action=wait, even after the script finishes, consumes the flag and ends that unrelated turn prematurely.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 8200-8201

Comment:
**Late end-turn affects another turn** When a foreground script outlasts its observation budget and later calls an inner tool such as `skip_reply`, that tool sets `record.endTurn` after the original tool result has returned. A later `action=wait`, even after the script finishes, consumes the flag and ends that unrelated turn prematurely.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment thread src/framework.ts
messageId,
source: `background-script:${record.id}`,
});
return null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Deferred wake acknowledged too early When wake_agent() fires during an active turn, the envelope is deferred rather than stored, but this path immediately acknowledges delivery to Python. If the later flush fails, it discards the envelope while the queued wake can still start inference, so the agent never receives the payload Python was told it delivered.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 8488

Comment:
**Deferred wake acknowledged too early** When `wake_agent()` fires during an active turn, the envelope is deferred rather than stored, but this path immediately acknowledges delivery to Python. If the later flush fails, it discards the envelope while the queued wake can still start inference, so the agent never receives the payload Python was told it delivered.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment thread src/framework.ts Outdated
Comment on lines +8095 to +8098
const waitMs = input.wait_ms ?? this.codeExecutionConfig?.foregroundWaitMs ?? 10_000;
if (typeof waitMs !== 'number' || !Number.isInteger(waitMs) || waitMs < 0 || waitMs > 60_000) {
return { success: false, error: 'wait_ms must be an integer from 0 to 60000', isError: true };
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Invalid default blocks script management If the host sets foregroundWaitMs outside 0–60000, validation rejects every code_execution call without an explicit wait_ms. That includes action=list, action=cancel, and immediate background starts, none of which needs a foreground observation budget. An invalid default therefore prevents agents from managing existing scripts.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 8095-8098

Comment:
**Invalid default blocks script management** If the host sets `foregroundWaitMs` outside 0–60000, validation rejects every `code_execution` call without an explicit `wait_ms`. That includes `action=list`, `action=cancel`, and immediate background starts, none of which needs a foreground observation budget. An invalid default therefore prevents agents from managing existing scripts.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment thread src/framework.ts Outdated
`[background script ${record.id}] Your background script DIED ${elapsedMin}m after start ` +
`(it did not call wake_agent and is no longer watching).\n` +
`[${record.mode} script ${record.id}] Script ${record.status === 'died' ? 'DIED' : 'finished'} ${elapsedMin}m after start ` +
`(return_code=${exec.returnCode}). Retrieve the retained result with code_execution action=wait, script_id=${record.id}.\n` +

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Completion notice promises evicted result The notice tells the agent to retrieve the result by script_id, but if five other executions settle before the agent acts on the queued wake, this result is evicted. action=wait then returns no script, leaving a foreground script’s full output unavailable beyond the notice’s short tail.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 8442

Comment:
**Completion notice promises evicted result** The notice tells the agent to retrieve the result by `script_id`, but if five other executions settle before the agent acts on the queued wake, this result is evicted. `action=wait` then returns `no script`, leaving a foreground script’s full output unavailable beyond the notice’s short tail.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Meganeuridae pushed a commit to Meganeuridae/agent-framework that referenced this pull request Oct 2, 2026
anima-research#155 separates how long the agent watches a script (wait_ms, on_timeout)
from how long the script may run. "timeout" there names the watching side,
so a per-call `timeout_ms` that stops the script would read as the same
thing. `time_limit_ms` pairs with `wait_ms` instead. The description also
says that stopping a script does not stop tools it already called.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UCEhG5Sap3gQCiaNzWEXqb
Aster and others added 2 commits October 1, 2026 23:01
… and the integration fixes

Brings the branch onto current main (0.19.0 plus anima-research#203's per-call
time_limit_ms). Conflicts in PyRunner.exec, the tool description and
runCodeExecution are resolved as on the local execution integration
branch: time_limit_ms bounds the Python run and wait_ms bounds the
observation, the time-limit cap note survives a yield and a later wait.

Also carries the fixes found while integrating (without anima-research#181's
structured results, which stay in their own PR):
- a script notification accepted into a live tool continuation retires
  its queued wake; a turn that ends keeps it armed
- shutdown resolves pending inner-tool waiters; late tool results after
  the queue closed are dropped instead of raising
- host status counts running background interpreters only, fixing the
  typecheck against anima-research#122's getHostModeStatus

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Greptile:
- An inner end-turn ends only a turn still waiting on its script; a late
  request after the call returned "running" is dropped instead of ending the
  turn that later retrieves the result.
- codeExecution.foregroundWaitMs is validated when the framework is created,
  so an invalid default can no longer refuse list/cancel.
- A result announced by a completion notice is kept until retrieved (at most
  20 per agent); other settled results stay five deep. The notice says so.
- wake_agent's ack contract is stated precisely: stored, or queued behind the
  agent's live turn like every deferred writer.

Review:
- Ephemeral agents wait for their foreground scripts to end instead of being
  promised a completion wake they can never receive.
- Quiesce keeps anima-research#122's guarantee: drain waits for foreground scripts that
  outlived their turn and abandon stops them; status adds foregroundScripts.
  Background watchers stay exempt.
- The code_execution description and header comment are restored, with only
  the waiting and retention sentences added.
- wake_agent messages are conversational again; completion notices stay system.
- ScriptRun turns a rejected completion into a failed result again; an
  undeliverable completion notice is logged.
- Revert-red tests for each fix and for the four untested changelog fixes
  (background end-turn, observed-only abort, stale wake_ack, wakeAbort).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Meganeuridae

Copy link
Copy Markdown
Author

Updated to 39f474c: merged current main (including #203's per-call time_limit_ms), then addressed both reviews. Full suite 1138 pass / 0 fail / 4 skipped. Every fix below has a test, and each test was checked by reverting its fix and seeing it fail (14 reverts, 14 failures).

Greptile

  1. Late end-turn affects another turn. Fixed. An inner end-turn now ends only a turn still waiting on the script (record.run.observing). Once the call has returned running, a late request is dropped instead of ending whichever later wait retrieves the result. Test: an inner end-turn ends only a turn that is still waiting on the script.
  2. Deferred wake acknowledged too early. Contract made explicit, not changed. A wake queued behind the agent's live turn is acknowledged once it is in the deferred queue: the same queue and guarantee every other writer gets, including module events and message.send. Holding the script until the turn's next boundary would stall watchers. It would also make an agent that observes its own background script with wait_ms always time out, because the wake can't land until that observation returns. What remains is a queued write the store rejects at flush, which the framework logs and drops for every writer. The design note said "only after context delivery", which overstated this; it now states what happens. A per-message delivery receipt is in the note's next steps and would serve every writer, not only scripts.
  3. Invalid default blocks script management. Fixed. codeExecution.foregroundWaitMs is validated in validateCodeExecutionConfig when the framework is created. Per call, only an explicit wait_ms is checked, so configuration can no longer block list or cancel. Test: an out-of-range foregroundWaitMs is refused when the framework is created.
  4. Completion notice promises evicted result. Fixed. A result announced by a completion notice is kept until the agent retrieves it, at most 20 per agent so an agent that never retrieves can't grow memory. Other settled results keep the five-deep rule. The notice now says "kept until you retrieve it", and an evicted id's error says it is no longer retained. Test: a result announced by a completion notice is kept until retrieved.

@slimepriestess

  1. Ephemerals. I chose blocking semantics. An ephemeral's foreground call always waits for its script to end (bounded by the script's time limit), whatever wait_ms says, so it is never promised a wake. Test: an ephemeral agent waits for its script to finish instead of being promised a wake.
  2. Revert-red tests for the four fixes, all added:
    • background inner end-turn: a background script's inner end-turn never ends a foreground call
    • observed-only abortAgentScript: a failed turn stops the script it is waiting on, never one it already left running
    • stale wake_ack: does not deliver a late wake acknowledgement to a replacement interpreter
    • wakeAbort: a pending rate-limited wake is released when its script ends
  3. Typecheck on main. Fixed in the merge. getHostModeStatus counts running background interpreters.
  4. Quiesce. Keeps Quiesce/maintenance mode: pause main thread + MCPLs, run maintenance through the live compression machinery #122's guarantee. drained is false while any foreground script runs, including one whose call already returned. The drain waits for those scripts, and abandon stops them; the owner gets the completion notice at resume. Background watchers stay exempt. backgroundScripts is unchanged, and status adds foregroundScripts. Test: quiesce drains scripts that outlived their turn; abandon stops them and spares watchers.
  5. Description. The header comment and the original description are restored verbatim. That includes "call tools directly when a single call answers the question", the sanitized-name examples and the wake-rule caution. Only the waiting sentences and the retention sentence are added. feat(code-execution): per-call time limit (time_limit_ms) #203's time-limit sentence already says a script is stopped at its limit, even after the call returns.
  6. Resolved by Greptile 1.
  7. Completion notices stay system. wake_agent(payload) messages are conversational again, as on main, since that is the script speaking to its agent. Both are asserted in tests.
  8. The rejection guard is back in ScriptRun: a rejected completion settles as a failed result. It uses a single then(ok, fail), so completion ordering is unchanged. Test added.
  9. A completion notice that can't be delivered is now logged with its script id.

The cancel test now also asserts that a prior global is gone after cancelling.

@antra-tess: items 4, 5 and 7 were yours to rule on. In each case I kept the old behaviour, and any of them can be changed.

#181 is separate and not included here.

nissa-seru pushed a commit that referenced this pull request Oct 14, 2026
…d carries no thinking

Context-manager 0.13's SelectOptions.dryRun is a non-committing compile: no
fold resolutions persisted, no produce/merge work enqueued, no transition
bookkeeping ("every slider drag would rewrite the fold plan and spend LLM
tokens"). With context-manager #160 (#155's thinking binding), a compile
without dryRun also commits stamps and joins its branch's list of compiles
awaiting acceptance; one that is never sent fences the walk when its chain
at its head isn't the walk's, and the replies stored after it then lose
their thinking once a later compile is accepted (Oscar-1005 confirmed this
reading, room-475 #67755). Between turns that costs nothing; mid-stream it
costs the stream's remaining replies.

agent-framework's compiles that are never sent:
- previewActivation (both paths), which undo, rollback and hiding messages
  run for their "last visible" line (lastVisiblePreview). It passes the new
  ActivationRequestOptions.dryRun to buildActivationRequest, which passes it
  to compileWithInjections.
- ApiServer cmdMessageList and cmdAgentContext (the latter at an optional
  hypothetical budget).
- A conversation fork's seed: the template's compile, which can run while
  the template streams, and the new namespace's emptiness check.

Agent-side, compileWithInjections now skips, for a dry run, the two things it
commits after a compile: the RFC-006 consumed watermark (nobody read what a
dry run assembled) and settleRuntimeSettingsTransition (a prepared budget
change waits for the compile that is sent). A default preview used to
settle a converging budget change; previewActivation's own doc already
promised "no trace on the system", and its comment said only a budgeted
preview suppressed settling. The doc claims that ContextManager.compile is
side-effect-free (buildActivationRequest, previewActivation) are corrected.
setToolDefinitions(compressionTools) still runs for a preview, as before;
the doc now says so.

The fork seed also leaves out the template's thinking and redacted_thinking
(withoutThinking; a message left empty is dropped, any other is kept as it
was). Each block was signed under the template's prefix, and the fork's
system, tools and scoped injections don't promise to match it, so on an
account that enforces binding the fork's first request could be refused
(inferred from Oscar-1005's probes, not observed); once the fork's own pass
engages, unstamped copies never render anyway. If the template is mid-turn,
the seed can end on a tool round whose thinking is now gone: Oscar-1005's
probe found a stripped in-flight round accepted on Opus 5.5 and Haiku 5.5
(not probed: Sonnet 5.5, Fable 5.1).

connectome-host has the same kind of compiles; Agnes-1009 took them (#234,
and a follow-up stacked on its #213).

Tests: test/unsent-compiles.test.ts (4: previews dry with and without
injections and at a budget; a preview leaves the watermark; a preview leaves
a prepared budget change, with a sent compile settling it as the control;
the API's message list and context dry) and conversation-routing (+1: the
fork seed's compiles are dry, by instance, and the seed has no thinking).
Red at 02adf27 with only these test files: 5 fail, 15 pass.
nissa-seru pushed a commit that referenced this pull request Oct 17, 2026
…messages are read

Context-manager #155 (thinking binding): a provider that binds signed thinking
to the prefix it was minted under refuses a reply whose stored prefix holds a
message its request didn't. Context-manager #160 stamps each reply from the
store as it stands when the reply is stored, and the host's side of that
contract (acceptRound's doc) is that what's stored after a compile, up to a
reply, is what the round's request carried. Three agent-framework paths broke
it.

1. Window-only deferred messages. At a tool boundary every message deferred
   for the agent was stored, and only the injectable ones were injected: one
   carrying tool blocks (the membrane's tool cycle) or named as the agent (a
   prefill) was stored between the turn's rounds without being injected.
   Sources: the built-in Discord module's backfill stores the bot's own
   messages under the agent's name; any module's addMessage as the agent or
   with tool blocks; the puppet pair's defensive queueing. Oscar-1005 showed
   the direction (room-203 #67671): the reply stored after such a message is
   stamped with it, later compiles render it, the stamp matches, and the
   thinking goes out bound to a request without it.
   Fix: drainDeferredFor takes an optional stopAt; the tool boundary drains
   the agent's queue only up to its first message that isInjectableMidTurn
   rejects. That message and everything queued behind it stay queued for the
   turn-end flush (driveStream's finally), where no reply follows them in the
   stream. A cut rather than a skip: the queue's order is kept everywhere
   else, the Discord backfill depends on chronology, and a pair must stay
   adjacent. Cost: messages queued behind a held one wait for the turn's end.
   A budget restart doesn't drain at its turn start (continuingTurn), so they
   wait through it too.

2. Re-deferral split a pair. addMessage's deferral skipped any write carrying
   tool_result. A turn-end flush can run after a successor turn has taken the
   agent (the 2026-07-31 interleave) and re-adds drained entries through
   addMessage, so a queued pair's tool_use re-deferred while its tool_result
   was stored alone. (1) sends every mid-turn pair through that flush. Now
   only a fresh tool_result write keeps the bypass; one coming back from the
   queue (deferredWriteId) re-defers like anything else. resume() clears
   quiesced before its flush and boot recovery runs with no turn alive, so
   neither changes. This also covers a flush while another agent's tool cycle
   is pending (the default path's global midToolCycle). The post-surgery
   flush (the surgery hold's release) was the one re-add from the queue that
   passed no deferredWriteId, so its re-adds counted as fresh, and boot
   recovery couldn't dedupe its writes after a crash; it now passes the id
   like the others (Petra-1012 found it reading the PR text).

3. RFC-006 watermark. The consumed watermark moved only at a compile (and
   boot, branch switch, a new fork). A message injected at a tool boundary is
   stored above the turn compile's watermark, so isUnreadStoredMessage called
   it unread and a coalesced replacement for its subject could remove it
   (push-coalescer acceptPlain/acceptDeferred/retract -> removePlacement ->
   cm.removeMessage), mid-turn or before the next compile: a message the agent
   heard and answered, and, under #160, either a stripped reply or a refused
   one depending on whether the removal came after or before the reply was
   stored (Oscar-1005, room-475 #67755). Fix: after provideToolResults hands
   injected messages to the stream, agent.markContextConsumed(). Store head,
   not the last injected sequence: conservative for other slots' messages
   stored since the compile (they read as read, so a replacement appends).
   Only when something was injected; endTurn, restart and non-streaming
   boundaries inject nothing and leave the watermark alone.

Tests: test/deferred-window-only.test.ts, 7. Red at 02adf27: 6 fail; the
control (a message stored at an endTurn boundary stays unread) passes before
and after. operator-surgery, +1: a write deferred behind a surgery lands with
its durable id (fails with the post-surgery flush passing none).
nissa-seru pushed a commit that referenced this pull request Oct 18, 2026
…them

Context-manager #155 (thinking binding): a provider that binds signed thinking
to the prefix it was minted under refuses that thinking once a later request
changes anything before it. Two agent-framework request-only constructs
changed the prefix between the request that minted a reply and every later
request.

1. Silent heartbeat: the tick's instruction rode an ephemeral system-position
   injection (InferenceRequest.ephemeralSystemPrompt -> ContextInjection
   'framework:silent-heartbeat'), so every heartbeat request's system differed
   from every other request's. That request missed the prompt cache from the
   system on, every retained thinking block failed its binding there, and the
   tick reply's own thinking failed at the next ordinary turn. Now the
   instruction is the separator's text (SILENT_HEARTBEAT_SEPARATOR), which
   every compile already renders byte-identically before the tick's first
   surviving row and which the tick's own request ends on. Every past tick
   reads it too, telling the agent those turns were private self-checks.
   Steady-state cost: 199 characters where the separator was 16, at each tick
   whose rows survive in the window (roughly 50 tokens a tick). One-time
   cost: the first compile after upgrading renders past ticks with the new
   text, one prompt-cache rewrite (and no strip if this ships before
   agent-framework engages context-manager #160's pass).
   ephemeralSystemPrompt is removed from InferenceRequest (types/agent.ts),
   the push-event wake, the batched wake, the budget restart and the system
   injection: the heartbeat was its only producer; connectome-host's main
   neither sets nor reads it (git grep), nor does anything else in the anima
   checkouts here. It is an exported interface, so the removal has its own
   breaking fragment naming who would need to act; the behaviour is in the
   changed fragment.

2. [Continue]: a compile that ends on the agent's own message gets a trailing
   request-only [Continue] turn, and later compiles didn't render it, so the
   reply it prompted sat straight after the previous assistant message: the
   formatter merged them (signed thinking from two responses in one message,
   the "cannot be modified" 400 when it is the latest), and the reply's
   binding failed from the next request. Context-manager's chain never sees
   request-only turns (it chains its own compiled messages), so it would
   send that thinking. Now the reply's first stored row is stamped
   metadata.promptedBy: 'continue' and request builds render [Continue]
   immediately before it, found the way tick rows are found (tool_use ids,
   thinking signatures; text never). Same known limit as ticks: a reply with
   neither isn't found, and carries no thinking to fail.

Mechanics:
- silent-heartbeat.ts is now the request-only-rows module.
  indexRequestOnlyRows returns ticks (identity key -> eventId, this agent's
  tick rows) and continued (the identity keys of stamped rows), independently,
  so a row can be both. renderRequestOnlyRows puts a tick's separator before
  the tick's first surviving row and [Continue] immediately before each
  stamped row, after any separator, as the reply's request had them. A
  stamped row is one stored row of the agent's own reply, which a compile
  never splits, so [Continue] needs no once-per-group check. (Basil-1007 asked about a row carrying both stamps: no path found
  today opens a stream inside a tick on [Continue] — every same-turn restart
  or retry compiles past stored tool results or an unchanged opening — but
  the first version would have rendered only the separator for such a row.)
  Internal names only; the public exports (SILENT_HEARTBEAT_SEPARATOR,
  silentHeartbeatOf, the tick types) keep their names.
- Agent.buildActivationRequest's body is now assembleActivationRequest
  (private), which also reports endsOnContinue; buildActivationRequest wraps
  it for previews. startStreamWithInjections sets continuePromptPending from
  it; addAssistantResponse stamps the first row it stores while that's set.
  Every stream start resets it (a budget restart, guard retry or error retry
  compiles afresh); the other addAssistantResponse callers store the stream's
  own rows. The direct Agent API stores through contextManager.addMessage and
  never adds [Continue].

Tests: silent-heartbeat-rows (+4: the tick's system is the agent's own; the
reply to [Continue] is re-rendered byte-stable; only its first row is marked;
a row with both stamps gets both turns, separator first, also when it is the
tick's first surviving row) and silent-heartbeat
(the instruction is the request's last turn and the system is unchanged).
The separator literal in the rows test is now the exported constant. Red at
02adf27 with only these test files: 4 of the integration tests fail (the
both-stamps unit test needs the new exports). Reviewed by Basil-1007.
@nissa-seru

Copy link
Copy Markdown
Contributor

@Meganeuridae #608 replaced the Python code_execution runner with JavaScript in a QuickJS isolate; if makes sense, it may be cleanest for us to close this PR and open a separate one if similar issues remain in the JS-based runner.

@nissa-seru nissa-seru closed this Nov 9, 2026
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.

3 participants