Repository navigation
code_execution scripts outlive their ephemeral agent (detached tasks, watchdog disposal) #231
Description
Found while reviewing #220. Present on main (e457abd).
What happens. A code_execution interpreter is keyed by agent name and its tool callback captures only that name:
agent-framework/src/framework.ts
Line 11524 in e457abd
| onToolCall: (toolName, args) => this.handleScriptToolCall(agentName, toolName, args), |
runEphemeralToCompletion's teardown cancels the stream and deregisters the agent but leaves the runner in codeExecutionRunners and does not abort it:
agent-framework/src/framework.ts
Lines 4218 to 4238 in e457abd
| if (startupWatchdog) clearTimeout(startupWatchdog); | |
| if (completionWatchdog) clearInterval(completionWatchdog); | |
| // Mark before cancellation: an adapter may synchronously settle its | |
| // iterator, and this physical frame still owns terminal typing/outgoing | |
| // closure even though the ephemeral name is about to be deregistered. | |
| this.disposedEphemeralFrames.add(agent); | |
| // Cancel before deregistration. Adapters may still yield a queued event; | |
| // driveStream's Agent-identity check above discards it before dispatch. | |
| agent.cancelStream(); | |
| if (this.ephemeralRuns.get(agent.name) === run) this.ephemeralRuns.delete(agent.name); | |
| if (this.agents.get(agent.name) === agent) { | |
| // Exact-generation disposal owns these name-keyed entries. Remove them | |
| // before deregistration; the late driveStream finally will see the | |
| // generation mismatch and leave any future owner untouched. | |
| this.activeStreams.delete(agent.name); | |
| this.pendingAssistantBlocks.delete(agent.name); | |
| // The late stream finalizer intentionally refuses name-keyed cleanup once | |
| // deregistered, so disposal must release EventGate liveness itself. | |
| this.eventGate?.onInferenceEnded(agent.name); | |
| this.agents.delete(agent.name); | |
| } |
Two ways a script keeps running after its owner is gone:
- Detached tasks. A script that schedules
asyncio.get_event_loop().create_task(...)and returns: the execution ends, the tool result is delivered, the ephemeral finishes normally — and the task keeps running in the idle interpreter until it is reclaimed.PyRunneracceptstool_calllines whether or not an execution is active (src/code-execution/py-runner.tscase 'tool_call'), so the task's tool calls are dispatched under the retired name. - Watchdog disposal. Script-inner results resolve their waiter before
touchEphemeralRun, so a long script does not count as activity; with a script ceiling above the idle threshold (15 min default vs 10 min default script limit, both configurable) the run is rejected as "stalled" while the script continues.
Repro (1), default config. Ephemeral agent, one code_execution call whose script creates a task that sleeps 1.5 s and then calls a module tool; runEphemeralToCompletion resolves, getAgent(name) is undefined, and 1.5 s later the module's handleToolCall runs.
Why it matters. The call executes on behalf of an agent that no longer exists (results go to a waiter nobody reads). With #220, per-agent allowedTools is looked up by name at dispatch and unregistered names keep the full surface, so a restricted ephemeral's deferred call is unrestricted.
Direction. In the ephemeral teardown (and wherever conversation agents are retired), abort and dispose the name's runner before deregistering; have the runner drop tool_call lines when no execution is active (or cancel leftover tasks when an execution finishes); count a running script as ephemeral activity.
Activity