Skip to content

fix(mcpl): report tool calls made by code_execution scripts (RFC-007) - #237

Merged
antra-tess merged 4 commits into
mainfrom
fix/lifecycle-script-tool-calls
Oct 7, 2026
Merged

antra-tess merged 4 commits into
mainfrom
fix/lifecycle-script-tool-calls

Conversation

@antra-tess

@antra-tess antra-tess commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator

Why

Tool-lifecycle observers (MCPL RFC-007) could not see tool calls made by code_execution scripts. The emitter registered calls only where the model's calls are dispatched. Script calls went from dispatchScriptToolCall straight to dispatchToolCall, and their results returned at the script-waiter intercept before onResult. Observers saw only the outer code_execution call, which has class shell. So an observer that filtered on the inner tool's name or class (for example comms) 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:

    • inferenceId is the inference that issued the code_execution call.
    • The parent call's toolCallId goes in _meta["agent-framework/parentToolCallId"]. RFC-007 has no field for a call's origin, and RFC-008 §3 reserves the mcpl/ prefix for keys the spec defines. That leaves MCP's _meta slot 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: runCodeExecution reads it while its own call is being dispatched (ToolLifecycleEmitter.scriptOrigin). It then passes through the runners' onToolCall closures:

    • a background runner captures it;
    • the per-agent foreground runner reads it from a per-agent map that is set for the duration of the exec.

    For the foreground map to be correct, the runner has to be claimed synchronously; see the next item.

  • py-runner.ts race fix (9 lines): exec() checked pending and then awaited the interpreter before setting it, so two code_execution calls in one model response both got through.

    • On main, the interpreter refuses the second script, and that refusal settles the second call's pending entry, which had overwritten the first's. The first script runs, but its exec_result is 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.
    • The runner now marks itself busy (starting) before the await. The second call is refused at once with "already running", and the first runs normally.
    • Another open PR (fix/code-exec-blocking-deadline) edits py-runner.ts in the deadline path. This diff touches only the class fields, busy, and the claim around ensureChild(), and git merge-tree merges 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 (sleep with a huge seconds sets the gate, then throws formatting until). So such a call is reported started then failed, 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 (or failed) when it finishes. If no result arrives, the waiter's safety timeout ends it as failed.

  • 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/ _meta key that would replace the host-specific one used here.

Tests

  • Emitter (test/mcpl-tool-lifecycle-emitter.test.ts), 5 new tests:

    • inner tool, inner class, parent inference and _meta;
    • the inner class decides filters and the comms input exclusion;
    • an error result is completed+isError, a refusal sends nothing, a lost result is failed;
    • abortOpen leaves script calls open while model calls are aborted as before;
    • a parent nothing observed still gives its inner calls the parent's inference.
  • End to end (test/mcpl-tool-lifecycle-script.test.ts): real python, one MCPL provider, four observers.

    • A script's calls are reported started → completed with the right tool names. A 250 ms call reports a durationMs that covers it, and the error result is completed+isError. A model call in the same round is unchanged, with no _meta.
    • The class filter (comms) and the name filter (serverTool: run) each see only their inner call. A filter that matches nothing sees nothing.
    • A dispatch that throws after doing work is reported started → failed, to both the unfiltered observer and the comms observer. This test fails if the throw is treated as a refusal.
    • A call still running when its script hits time_limit_ms is reported completed once 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.
    • The first two end-to-end tests fail against main's framework. The orphan test also fails if the abortOpen skip is removed.
  • Code execution (test/code-execution.test.ts), 2 new tests:

    • two same-tick exec()s on one runner, both cold and warm: the second is refused and the first completes;
    • two code_execution calls 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_ms when a call's arguments carry it, or once the file named by hold exists.

  • Full suite: typecheck and build are clean. On the latest commit, npm test gave 1225 pass, 3 fail. All 3 failures are mcpl-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

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>
@greptile-apps

greptile-apps Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5 Tier: apex

[Medium risk] Adds script-inner tool call reporting to the lifecycle system.

The PR appears safe to merge; the previous finding is fixed and no new actionable issues were found.

What we checked:

  • Tests wait for observer logs: The tests now wait for the expected events before checking the filtered logs or releasing the held reply.

Summary

This PR reports tool calls made inside code_execution scripts to MCPL observers.

  • Inner calls carry their own tool name and class, plus the parent call's origin.
  • The runner claims itself before waiting for Python, preventing overlapping scripts.
  • The latest changes wait for observer logs before checking them. The previous finding is fixed.

Review used code inspection; tests were not run.

Reviews (3) · Last reviewed commit: "test: wait for each observer's log befor..." · Reviewed by Greptile

Comment thread src/framework.ts Resolved
Comment thread src/framework.ts Resolved
Comment thread test/mcpl-tool-lifecycle-script.test.ts Resolved
…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>
@antra-tess

Copy link
Copy Markdown
Collaborator Author

@greptileai please re-review at the current head (037afd8).

Comment thread test/mcpl-tool-lifecycle-script.test.ts Resolved
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>
@antra-tess

Copy link
Copy Markdown
Collaborator Author

@greptileai please re-review at the current head (0d5f770).

…ool-calls

Resolves framework.ts against #242's per-script deferred end-turn and
#238's pattern check: a foreground script now carries both its RFC-007
origin and its end-turn mark, both cleared in one finally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	src/framework.ts

@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.

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

  1. Repo harness (tsc, pretest, node --test over dist): the lifecycle emitter tests, the lifecycle
    end-to-end tests and code-execution all pass, 67/67.
  2. Red without the fix, on the base (ec04465):
    • The two new same-tick race tests in code-execution fail.
    • 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 abortOpen stop 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.
  3. Together with #240, which also edits py-runner.ts and test/code-execution.test.ts. They merge cleanly,
    the merged tree builds with no type errors, and both fixes are present (_ScriptStop and the starting
    claim). Both PRs' code-execution and lifecycle tests pass on it, 76/76.
  4. 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 call started, then completed after the outer script is killed at its limit, with
    _meta["agent-framework/parentToolCallId"] on its events.
    • #237 alone: both F4 judges pass. The only failures are A4.2a and A4.6, which are #240's fix and fail
      exactly as on main.
    • #237 merged with #240: 12/12 ×3.
    • main: both F4 judges fail with the observer seeing nothing ([]), which is F4 as originally found.

Read closely, no change requested

  • The starting claim. exec() is synchronous up to its first await, so the claim is set before a
    same-tick second caller can check busy. The finally clears it, and pending is assigned inside the
    Promise executor, which runs synchronously. So there's no gap between the claim ending and pending being
    set.
  • The foreground origin map. ownsOrigin is 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 the finally can'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. The sleep example 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.

@antra-tess
antra-tess merged commit 02adf27 into main Oct 7, 2026
5 checks passed
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 8, 2026
…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>
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 12, 2026
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.
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 13, 2026
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>
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.

2 participants