Repository navigation
fix(mcpl): report tool calls made by code_execution scripts (RFC-007) - #237
Conversation
Lifecycle observers saw only the outer code_execution call (class shell): calls a script made were dispatched without registering, and their results returned before onResult. An observer filtering on the inner tool's name or class saw nothing. Script-inner calls now go through the emitter like model calls: register, dispatch with its refusal sites, open, and a terminal at the script-waiter intercept, under the inner tool's own name and class. They carry the inference that issued the code_execution call and, in _meta["agent-framework/parentToolCallId"], that call's toolCallId. A stream's end does not abort them: a call still running when its script is killed reaches completed/failed when it finishes, or failed when the waiter's safety timeout gives up on it. The parent's origin reaches the script's calls through the runners' onToolCall closures; py-runner is unchanged. Refs #235 (F4), #199. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
…thrown script calls Review follow-ups for the script-call lifecycle change: - PyRunner.exec() checked `pending` and then awaited the interpreter before setting it, so two code_execution calls in one model response both got through. The interpreter refused the second; its exec_result settled the second's pending entry, which had replaced the first's, so the first script's result was ignored and its call never answered — the turn hung. The runner now marks itself busy before the await. That also makes the framework's per-agent script origin correct by construction. - A script call whose dispatch throws synchronously was treated as never started. Some tools change state before throwing (sleep sets the gate, then fails formatting its reply), so it is now reported started, then failed, as for a dispatch that rejects. - The orphaned-call test holds the provider's reply on a file latch until the turn's stream has ended, instead of racing a timer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@greptileai please re-review at the current head (037afd8). |
Observers log from their own processes, so a log can lag the framework's send. Wait for the events each assertion reads (obs-all, obs-comms, obs-run) instead of relying on the stream's end or a fixed pause. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@greptileai please re-review at the current head (0d5f770). |
slimepriestess
left a comment
There was a problem hiding this comment.
Head 0d5f770 on base ec04465 (diff 8 files, +688/−12, matching GitHub).
This closes #235's F4. A script's inner calls now go through the model-call path under the inner tool's own name
and class, so filters, grants and the comms input exclusion behave as if the model had called the tool. The
parent goes in a host-prefixed _meta key until the spec defines a field, which follows RFC-008 §3 correctly. The
py-runner race fix is a real bug in its own right: on main, two code_execution calls in one response hang the
turn.
Verified first-hand
- Repo harness (
tsc, pretest,node --testover dist): the lifecycle emitter tests, the lifecycle
end-to-end tests andcode-executionall pass, 67/67. - Red without the fix, on the base (ec04465):
- The two new same-tick race tests in
code-executionfail. - All 3 end-to-end lifecycle tests fail, with the file's one new constant export
(SCRIPT_PARENT_META_KEY) added so it loads. - Mutation on the PR build: making
abortOpenstop skipping script calls turns exactly the orphan test
red ("a call still running when its script is killed is reported completed"). The other two stay green, as
the PR says.
- The two new same-tick race tests in
- Together with #240, which also edits
py-runner.tsandtest/code-execution.test.ts. They merge cleanly,
the merged tree builds with no type errors, and both fixes are present (_ScriptStopand thestarting
claim). Both PRs' code-execution and lifecycle tests pass on it, 76/76. - Full host on bun, scripted mock, zero spend. Our Campaign-A4 rig (where F4 was found) has its A4.3 judge
inverted from "observer blind" to this contract. The observer, granted observe+inputs on the slow tool, must
see the script's inner callstarted, thencompletedafter the outer script is killed at its limit, with
_meta["agent-framework/parentToolCallId"]on its events.
Read closely, no change requested
- The
startingclaim.exec()is synchronous up to its firstawait, so the claim is set before a
same-tick second caller can checkbusy. Thefinallyclears it, andpendingis assigned inside the
Promiseexecutor, which runs synchronously. So there's no gap between the claim ending andpendingbeing
set. - The foreground origin map.
ownsOriginis checked and the map is set synchronously right before
runner.exec(), which claims the runner in the same tick. So a refused second script can never overwrite the
running script's origin, and the token-matched delete in thefinallycan't clear a successor's entry.
Background runners capture their origin in the closure. - Throw vs refusal. Reporting a dispatch that throws as
started→failed, rather than as a refusal, is
right for tools that change state before throwing. Thesleepexample is a good one.
Agree with the spec note
RFC-007 should say that module-origin calls are covered, and how to attribute them: a defined field, or a blessed
mcpl/ _meta key that would replace agent-framework/parentToolCallId.
Validated two-panel (both Weft panels; receipts preserved per run).
Re-verified at f7d4a14 (main merged in)
The review above was done at 0d5f770. At f7d4a14 we re-ran tsc (clean), the code-execution, lifecycle, empty-content and silent-heartbeat suites (129/129), and the Campaign-A4 rig with the F4 judges inverted (12/12 ×3). The #237/#242 conflict resolution matches the union we had resolved independently for our #242 review: one busy-guarded ownership decision for both the end-turn mark and the script origin.
…onto current main GitHub reported the anima-research#247 → anima-research#252 → anima-research#256 → anima-research#257 stack DIRTY against main 02adf27 (June-1784 and Basil-1786, room-203 #51425): the conflict sits at the bottom, in anima-research#247. Integrated once here, by merge so every reviewed commit keeps its hash, and propagated upward the same way (Tessa-974 #51438; Basil-1007 reviews the resolutions, #51449). main since 64c480b brings anima-research#236 (reject visibly-empty push and channel content), anima-research#238 (zero-match pattern and feature-set diagnostics), anima-research#240 and anima-research#242 (code-execution deadlines and inner results), anima-research#241 (silent heartbeat turn rows) and anima-research#237 (lifecycle tool calls from code_execution scripts). Two textual conflicts, both additive: - src/mcpl/push-handler.ts imports: main's EmptyContentError and visible-content helpers beside anima-research#247's InboundSource type. - src/framework.ts: main's new silentTurnRowMetadata() sits before recordProseSuppression(); that method keeps anima-research#247's doc comment ("suppressed without drafts (proseRouting=disabled)"), which is what its body does since anima-research#247 holds explicit-send-suppressed prose as drafts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anima-research#240, anima-research#242, anima-research#237, anima-research#169, anima-research#221) into fix/workspace-truthful-listings Brings the branch up to main 956c6d2. GitHub reported the PR as conflicting after anima-research#169's merge (6c07b6a), so CI didn't run on 2ae3554; git's merge is clean, with no file conflicts.
Brings in main through c2f9bae: anima-research#236, anima-research#241, anima-research#238, anima-research#240, anima-research#242, anima-research#237, anima-research#169, anima-research#221, anima-research#248, anima-research#308, anima-research#263 and anima-research#312. One conflict, in src/framework.ts's four MCPL handlers (push/event, inference/request, channels/incoming and host/command). anima-research#263 wraps each handler in answerOnce and refuseServerItem, and refuses the item after the barrier wait once the queue has closed. This branch replaced each handler's single-barrier wait with awaitDiscordAwarenessBarriers(), called only while a generation is current. Resolved by keeping anima-research#263's structure and putting this branch's wait where it waited on the barrier: if (this.discordAwarenessBarrier) await this.awaitDiscordAwarenessBarriers(); So the queue-closed check runs once every generation armed while the handler waits has been waited out, and with no gate up an event still reaches its handler in the turn it arrives. Everything else merged without conflict. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Why
Tool-lifecycle observers (MCPL RFC-007) could not see tool calls made by
code_executionscripts. The emitter registered calls only where the model's calls are dispatched. Script calls went fromdispatchScriptToolCallstraight todispatchToolCall, and their results returned at the script-waiter intercept beforeonResult. Observers saw only the outercode_executioncall, which has classshell. So an observer that filtered on the inner tool's name or class (for examplecomms) saw nothing of what a script did. This is finding F4 in #235.What this does
Script calls follow the model-call path: they are registered, dispatched (the same refusal sites apply), opened, and get their terminal at the waiter intercept. Each event uses the inner tool's name, class and provider, so grants, narrowing, filters and the
comms/unclassed input exclusions apply as if the model had called the tool directly.Attribution:
inferenceIdis the inference that issued thecode_executioncall.toolCallIdgoes in_meta["agent-framework/parentToolCallId"]. RFC-007 has no field for a call's origin, and RFC-008 §3 reserves themcpl/prefix for keys the spec defines. That leaves MCP's_metaslot with a host-prefixed key. The parent id is omitted when nothing observed the parent, since it then has no id.How the origin reaches the script's calls:
runCodeExecutionreads it while its own call is being dispatched (ToolLifecycleEmitter.scriptOrigin). It then passes through the runners'onToolCallclosures:For the foreground map to be correct, the runner has to be claimed synchronously; see the next item.
py-runner.tsrace fix (9 lines):exec()checkedpendingand then awaited the interpreter before setting it, so twocode_executioncalls in one model response both got through.exec_resultis ignored, so its call never gets a result and the turn hangs. The deadline kill later reclaims the interpreter, but it still never settles the call.starting) before the await. The second call is refused at once with "already running", and the first runs normally.fix/code-exec-blocking-deadline) editspy-runner.tsin the deadline path. This diff touches only the class fields,busy, and the claim aroundensureChild(), andgit merge-treemerges the two cleanly.Script calls whose dispatch throws: a synchronous throw is not a refusal; refusal sites call
refuse()and answer. Some tools change state before throwing (sleepwith a hugesecondssets the gate, then throws formattinguntil). So such a call is reportedstartedthenfailed, as for a dispatch that rejects.Orphaned calls: a stream's end no longer aborts calls made by a script. A call that is still running when its script is killed or its turn ends reaches
completed(orfailed) when it finishes. If no result arrives, the waiter's safety timeout ends it asfailed.Spec: RFC-007 §15 lists script calls as an agent-framework known limit, and §7.1 ("calls the host executes") neither includes nor excludes them. The spec should say that module-origin calls are covered. It should also say how to attribute them: a defined field, or a blessed
mcpl/_metakey that would replace the host-specific one used here.Tests
Emitter (
test/mcpl-tool-lifecycle-emitter.test.ts), 5 new tests:_meta;commsinput exclusion;completed+isError, a refusal sends nothing, a lost result isfailed;abortOpenleaves script calls open while model calls are aborted as before;End to end (
test/mcpl-tool-lifecycle-script.test.ts): real python, one MCPL provider, four observers.started→completedwith the right tool names. A 250 ms call reports adurationMsthat covers it, and the error result iscompleted+isError. A model call in the same round is unchanged, with no_meta.comms) and the name filter (serverTool: run) each see only their inner call. A filter that matches nothing sees nothing.started→failed, to both the unfiltered observer and thecommsobserver. This test fails if the throw is treated as a refusal.time_limit_msis reportedcompletedonce it finishes. The provider holds its reply on a file latch until the turn's stream has ended, so the test has no timing window.abortOpenskip is removed.Code execution (
test/code-execution.test.ts), 2 new tests:exec()s on one runner, both cold and warm: the second is refused and the first completes;code_executioncalls in one model response: one runs, the other is refused, and the turn completes.With main's
py-runner.ts, the first test times out ("an exec never settled") and the second reports "the turn never completed".Fixture: the provider answers after
delay_mswhen a call's arguments carry it, or once the file named byholdexists.Full suite: typecheck and build are clean. On the latest commit,
npm testgave 1225 pass, 3 fail. All 3 failures aremcpl-awareness-barrier"startup globally gates …" tests, the known flake under full-suite load. The file passes 25/25 when run alone, two times out of two.Refs #235 (finding F4), #199.
🤖 Generated with Claude Code