Repository navigation
emrg: delegating to a child session is a tool, not a page of instructions #2451
Description
A subagent is a tool, not a paragraph of instructions
What happens today
Delegating a self-contained job to a child session is possible, and the daemon already has every
primitive it needs: a session can be created, driven through one tool loop, and torn down. But
nothing exposes that as a tool. The only procedure is a user-level skill file — a page of
instructions telling the model to assemble an auth frame, a task frame, a delta stream and a
done frame by hand over the daemon's own WebSocket protocol, pick a session name, ask the daemon
whether it is busy, and decide whether to delete and reuse a slot.
A page of instructions is not a capability. Every step is a place to get it wrong, and getting it
wrong costs a whole round: a malformed frame hangs until the client's timeout, a reused name
collides with a session that is still finishing, and none of it is visible to the tool loop that
already knows how to open a session and run one round.
What is expected
One blocking tool:
subagent(prompt, timeout_s=600, intent)
- Blocking. The parent's round is suspended for the duration; the call returns when the child
finishes, times out, or is cancelled. One child per round — no parallelism, so the return value
is unambiguous. - The schema carries only what the model can decide.
cwdand the slot are computed inside the
tool. A model that picks its own slot picks a wrong one; a model that picks its owncwdplants
memory in a project it was not asked about. - The return value is the child's last answer, plus its session id, its
cwd, how many rounds
it ran and how long it took. An answer over the size cap is truncated and says so. The child's
intermediate narration is not returned: concatenated fragments are not a report. - Timeout cancels the child's round, it does not abandon it running, and the parent's own round
survives to report the failure.
Slots
- Nine of them, at
<parent session dir>/sub-agent/sub1..sub9— under the parent's own session
directory, so nothing is written into another project's memory root. - An in-process table records, per slot, whether it is occupied and when it last closed. At daemon
start every slot is free with a close time equal to the start instant, so the first choice is
deterministic (lowest index wins a tie). A daemon restart clears the table, which matches the
fact that children running at that moment die with it. - A slot is held for the whole round. The one chosen is the one whose close time is oldest
(least recently used). If none is free, the call waits — with no wait timeout of its own; the
only bound is thetimeout_sthe call carries. - Before reuse the slot is emptied through
Session.delete(which removes the whole session
directory and its entry in the global session index). Deleting the directory by hand leaves a
dangling index entry, so it is not an option.
Three things that must be exactly this, or the feature is wrong
- A slot's session id carries the parent's session id, e.g.
sub-<parent-sid>-1. The daemon's
busy map, turn-started map, cancel map and turn-task map are all keyed by session id and know
nothing about directories. A fixed id such assub1makes two parents delegating at once read
each other as busy and block each other. Log labels stay short ([sub-1]) so one delegation can
be grepped out of the daemon log. - "Closed" means after the whole teardown, memory write included. The
doneframe is
broadcast first and the memory reflection runs after it, fire-and-forget. Stamping the close at
the earlier point means the next delegation to that slot empties the directory while the previous
round is still writing into it, and the directory comes back. The busy and cancel maps are
cleared unconditionally in afinally. - The child inherits the parent's sandbox tier. It does not declare one. Otherwise a parent
fenced intoworkspace-writeescalates by delegating, and the authority to widen a sandbox would
be held by the party the sandbox constrains.
Depth
An ordinary session is depth 0; a child is parent + 1. At 9 the tool refuses and returns an
explicit error rather than failing silently (at most nine levels of nesting). Depth is recorded in
the child's session metadata, which is how a child's own subagent tool learns which level it is on.
Acceptance
Each of these must be a test that can go red:
- A child runs; the parent receives the answer, the child's id, its
cwd, the round count and the
elapsed time. An over-long answer is truncated and marked as truncated. timeout_sexpiring cancels the child's round (it does not run on), the parent receives an
error, and the parent's own round continues.- Cancelling the parent's round (Esc) cancels the child, and the slot is returned immediately.
- With all nine slots held, a tenth call waits; when one is released, the slot handed out is the
one that closed longest ago — changing that choice to "the first free slot" must make this
test fail. - When a slot's previous session directory is already gone and the global index carries no entry
for it, reuse still works — removing the emptying step must make this test fail. - The close instant lands after the memory write: a child writes a memory entry, and a second child
is delegated to the same slot immediately after; the first child'smemory/must not be written
again after its teardown — moving the close instant to before thedoneframe must make this
test fail. - At depth 9 the tool refuses with a clear error.
- Every delegation's log lines carry
[sub-n], so one delegation can be filtered out of the daemon
log by itself.
Out of scope
- The existing delegation skill is not modified here. Removing it is a separate change, made once
the tool has been verified. - No parallel children.
- No additional wait timeout beyond the call's own
timeout_s.
Activity
argszero commentedon Oct 21, 2026
Correction: the slot path is the child's cwd, and it does build
An earlier comment here reported this requirement as unbuildable, on the reading that
<parent cwd>/.emrg/sessions/<parent sid>/sub-agent/sub<N>was meant to be the child's own sessiondirectory. It is not: it is the child's project cwd, and a session's cwd is free-form.
Session.__init__does exactlyself._dir = cwd / ".emrg" / "sessions" / session_id(
emrg/session.py:349), andis_safe_session_id(:317) constrains only that last component — itexists because the id arrives in a client message and
Path(cwd)/".emrg"/"sessions"/idhands..andabsolute ids to the kernel. Nothing constrains the cwd. So the slot directory is the child's cwd, the
child's own session directory is
<slot>/.emrg/sessions/<child sid>, and its memory is<slot>/.emrg/sessions/<child sid>/memory/— inside the parent session's directory, which is what therequirement asks for. No deviation and no decision to raise.
Two consequences worth carrying into the implementation:
Session.delete(session_id, cwd)takes the cwd too, so a slot's table entry has to remember bothhalves of the pair (
cwd,sid) — the id alone does not locate the directory to empty.id alone across the whole daemon; the path nesting does not make two
sub1ids distinguishable there.The rest of the earlier comment stands:
_run_upgrade_sessionis the headless precedent,_maybe_reflect_memory's discarded task is the seam that lets the "memory is written before the closestamp" arm go red,
_run_tool_loop_lockedreturns nothing so the round count comes from the child'sown history, and depth has to live in the child's
meta.json.argszero commentedon Oct 21, 2026
Handled by #2454