Skip to content

emrg: delegating to a child session is a tool, not a page of instructions #2451

Closed

Description

@argszero

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. cwd and the slot are computed inside the
    tool. A model that picks its own slot picks a wrong one; a model that picks its own cwd plants
    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 the timeout_s the 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

  1. 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 as sub1 makes 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.
  2. "Closed" means after the whole teardown, memory write included. The done frame 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 a finally.
  3. The child inherits the parent's sandbox tier. It does not declare one. Otherwise a parent
    fenced into workspace-write escalates 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:

  1. 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.
  2. timeout_s expiring cancels the child's round (it does not run on), the parent receives an
    error, and the parent's own round continues.
  3. Cancelling the parent's round (Esc) cancels the child, and the slot is returned immediately.
  4. 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.
  5. 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.
  6. 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's memory/ must not be written
    again after its teardown — moving the close instant to before the done frame must make this
    test fail.
  7. At depth 9 the tool refuses with a clear error.
  8. 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

  1. argszero commented on Oct 21, 2026

    @argszero
    OwnerAuthor

    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 session
    directory. It is not: it is the child's project cwd, and a session's cwd is free-form.

    Session.__init__ does exactly self._dir = cwd / ".emrg" / "sessions" / session_id
    (emrg/session.py:349), and is_safe_session_id (:317) constrains only that last component — it
    exists because the id arrives in a client message and Path(cwd)/".emrg"/"sessions"/id hands .. and
    absolute 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 the
    requirement 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 both
      halves of the pair (cwd, sid) — the id alone does not locate the directory to empty.
    • The slot id still has to be unique per parent, because the busy/cancel/turn tables are keyed by
      id alone across the whole daemon; the path nesting does not make two sub1 ids distinguishable there.

    The rest of the earlier comment stands: _run_upgrade_session is the headless precedent,
    _maybe_reflect_memory's discarded task is the seam that lets the "memory is written before the close
    stamp" arm go red, _run_tool_loop_locked returns nothing so the round count comes from the child's
    own history, and depth has to live in the child's meta.json.

  2. argszero commented on Oct 21, 2026

    @argszero
    OwnerAuthor

    Handled by #2454

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

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions