Skip to content

emrg: delegating to a child session is a tool - #2454

Merged
argszero merged 1 commit into
masterfrom
feature/subagent-tool
Oct 21, 2026
Merged

argszero merged 1 commit into
masterfrom
feature/subagent-tool

Conversation

@argszero

Copy link
Copy Markdown
Owner

Closes #2451

What this adds

subagent(prompt, timeout_s=600, intent) — one tool call runs one child session to completion and hands the parent its final answer.

Delegation already worked through the daemon's own protocol, but only as a page of instructions in a user-level skill: the model assembled the frames by hand, picked its own session name, and decided alone whether a slot was free. Every step was a place to be wrong, and being wrong cost a whole round. The tool loop already knows how to create a session and drive one round, so this exposes that instead.

The mechanism

  • emrg/tools/subagent_tool.py (new) — the tool. The schema carries only what a model may decide: prompt, timeout_s, intent. The parent's session id and cwd are injected by the daemon into every call it dispatches (_inject_tool_arguments), unconditionally: the parent of a delegation cannot be the delegated. A call without them is refused, which is what a call from outside a daemon turn is.
  • emrg/server/daemon.py — the runner and the slot table:
    • nine slots per parent, and the one handed out is the one closed longest ago; a tenth call waits, bounded only by its own timeout_s;
    • a slot is emptied through Session.delete(child_id, slot_cwd) before reuse, which also drops the global session index entry — deleting the directory by hand leaves a dangling entry;
    • the child runs at its parent's sandbox tier and declares none of its own, so a fenced session cannot widen its fence by delegating;
    • the slot is stamped closed only after the child's memory write has landed, so the next child of that slot cannot clear a directory still being written into;
    • a child may delegate again, up to depth 9, where the tool refuses and says so.
  • emrg/session.py — a session's delegation depth, stored in meta.json and written only when non-zero (the same rule the sandbox tier follows), because the child is the one that has to know how deep it is.

The slot directory is the child's cwd, <parent cwd>/.emrg/sessions/<parent sid>/sub-agent/sub<N>; the child's own session directory is built inside it like any other session's. That is what keeps a child's memory inside the parent's session tree instead of planting a second project root.

The timeout, and why it is not wait_for

The tool loop absorbs the cancellation it receives: it broadcasts the turn's cancelled frame and returns, which is what a user's Esc does. A cancelled child therefore ends normally, and asyncio.wait_for cannot tell a timeout from a finished turn — measured, it handed back a success report reading "the child produced no final answer". The bound is measured by the clock instead: asyncio.wait(..., timeout=timeout_s) decides whether the child finished, and on either exit the child is cancelled and awaited, so its finally (slot, busy flag, memory write) has run before the parent is told anything.

Verification

  • Whole suite on this head: 5515 passed, 34 skipped. from emrg.client.app import run_client imports; python -m emrg --help prints.
  • tests/test_subagent_tool.py — 15 tests: one per acceptance item, plus the tool's own contract (no injected parent, an impossible timeout, the default passthrough). No daemon is started, stopped or restarted; the LLM is a stub and everything lives under tmp_path.
  • Each guard was armed, eight mutations of the mechanism, each expected to redden exactly one named test: the answer ceiling, the LRU slot choice, the depth cap, sandbox inheritance, the slot released on cancel, the close waiting for the memory write, the parent-bearing child id, and emptying a slot before reuse. Seven went red on the first run.
  • The eighth did not, and the fix went into the test: the ceiling test derived its subject's length from _SUBAGENT_ANSWER_CHARS, so raising the constant moved both sides of the comparison and it stayed green. It now uses a fixed 50,000-character answer, and the arm reddens.
  • Two regressions the suite caught, fixed here: the memory-task table is now created on first use (twelve test_memory_reflection rows build a server with __new__ and raised AttributeError), and the new tool is named in tests/test_tool_argument_domains.py's roster and its domain table, so the schema states the bound the tool enforces rather than only the code knowing it.

CI is pending on this head; it has not been read as green.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20261021-204645

Read at head cc7c4456, pushed 2026-10-21T12:43:51Z, mergeable CLEAN.

  • Fresh: scripts/check-merge-freshness.py 2454 answers FRESH — base bb133428 is master's own tip (ahead, behind 0), so the green CI describes the tree a merge would produce.
  • CI green on both legs: test 2m36s and test-windows 3m52s, run 39601937701.
  • Landing tree passes the suite: scripts/check-merge-plan-suite.py 2454 measured final tree c65da71a50fc — 5514 passed, 35 skipped in 247.67 s. That count is the plan's tree, not the checkout's and not #2453's.

The change itself is the subagent tool: emrg/tools/subagent_tool.py, the depth accessor in emrg/session.py, argument injection plus the slot/busy tables and the child runner in emrg/server/daemon.py, with tests/test_subagent_tool.py and the registry entry in tests/test_tool_argument_domains.py. Its parents are in the body (Closes #2451), and the two defects the tests found — asyncio.wait_for being blind to a swallowed CancelledError, and a ceiling test whose two sides moved together — are fixed rather than papered over.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20261021-211145

Head cc7c4456, mergeable CLEAN. This is the second vote; the first was cast by a different cycle.

  • Fresh: check-merge-freshness.py 2454 answers FRESH — base bb133428 is master's own tip, so the green CI describes the tree a merge would produce.
  • CI green on both legs: test and test-windows, run 39601937701.
  • Landing tree passes the suite: check-merge-plan-suite.py 2454 measured final tree c65da71a50fc — 5514 passed, 35 skipped in 228.23 s.

The head has not moved since the earlier review, so the code under this vote is the code that was read then: the subagent tool in emrg/tools/subagent_tool.py, the depth accessor on Session, and in emrg/server/daemon.py the argument injection, the slot/busy tables and the child single-turn runner — with tests/test_subagent_tool.py and the registry entry the tool-domain guard demands. The two defects its tests found (a wait_for blind to a swallowed CancelledError, and a ceiling test whose two sides moved together) are fixed rather than worked around.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20261021-212902

Third vote on head cc7c4456; the first two came from two other cycles. This cycle pushed nothing into it.

  • Fresh: check-merge-freshness.py 2454 answers FRESH — base bb133428 is master's own tip (ahead, behind 0), so the green CI describes the tree a merge would produce.
  • CI green on both legs: test 2m36s and test-windows 3m52s, run 39601937701.
  • Landing tree re-measured now, not quoted: check-merge-plan-suite.py 2454 → final tree c65da71a50fc, 5514 passed, 35 skipped in 225.41 s.

What it adds: a subagent tool that delegates one self-contained job to a child session and returns its answer, with the child's cwd built inside the parent's session tree, the sandbox tier inherited from the parent rather than chosen by the child, and the delegation depth capped. The two defects its own tests found are fixed rather than worked around — a wait_for that could not see a CancelledError the tool loop swallows, and a ceiling test whose two sides moved together so that raising the bound kept the arm green.

I found no reason to hold it: the tenancy of the slot table is keyed by parent session id, the child id carries the parent's so the busy/cancel tables cannot collide across parents, and the failure paths return a named refusal instead of a silent empty answer.

@argszero
argszero merged commit 3c6beff into master Oct 21, 2026
2 checks passed
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.

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

1 participant