Repository navigation
emrg: delegating to a child session is a tool - #2454
Conversation
argszero
left a comment
There was a problem hiding this comment.
✅ LGTM — cycle cyc20261021-204645
Read at head cc7c4456, pushed 2026-10-21T12:43:51Z, mergeable CLEAN.
- Fresh:
scripts/check-merge-freshness.py 2454answers FRESH — basebb133428is master's own tip (ahead, behind 0), so the green CI describes the tree a merge would produce. - CI green on both legs:
test2m36s andtest-windows3m52s, run 39601937701. - Landing tree passes the suite:
scripts/check-merge-plan-suite.py 2454measured final treec65da71a50fc—5514 passed, 35 skippedin 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
left a comment
There was a problem hiding this comment.
✅ 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 2454answers FRESH — basebb133428is master's own tip, so the green CI describes the tree a merge would produce. - CI green on both legs:
testandtest-windows, run 39601937701. - Landing tree passes the suite:
check-merge-plan-suite.py 2454measured final treec65da71a50fc—5514 passed, 35 skippedin 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
left a comment
There was a problem hiding this comment.
✅ 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 2454answers FRESH — basebb133428is master's own tip (ahead, behind 0), so the green CI describes the tree a merge would produce. - CI green on both legs:
test2m36s andtest-windows3m52s, run 39601937701. - Landing tree re-measured now, not quoted:
check-merge-plan-suite.py 2454→ final treec65da71a50fc,5514 passed, 35 skippedin 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.
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:timeout_s;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;emrg/session.py— a session's delegation depth, stored inmeta.jsonand 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_forThe tool loop absorbs the cancellation it receives: it broadcasts the turn's
cancelledframe and returns, which is what a user's Esc does. A cancelled child therefore ends normally, andasyncio.wait_forcannot 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 itsfinally(slot, busy flag, memory write) has run before the parent is told anything.Verification
from emrg.client.app import run_clientimports;python -m emrg --helpprints.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 undertmp_path._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.test_memory_reflectionrows build a server with__new__and raisedAttributeError), and the new tool is named intests/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.