Skip to content

code_execution scripts outlive their ephemeral agent (detached tasks, watchdog disposal) #231

Open

Description

@Anarchid

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:

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:

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:

  1. 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. PyRunner accepts tool_call lines whether or not an execution is active (src/code-execution/py-runner.ts case 'tool_call'), so the task's tool calls are dispatched under the retired name.
  2. 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions