RecursiveAgent.recurse (ace/core/recursive_agent.py#L223-L234) builds each child's Deps by iterating __dataclass_fields__ and calling deps.__class__(**{f.name: getattr(deps, f.name), ...}). This is a shallow copy: every dataclass field is passed by reference. For SMDeps (ace/implementations/sm_tools.py#L34-L40), this means the live Skillbook instance and the operations list are aliased between the parent and every child the parent spawns. The tool's own docstring at recursive_agent.py#L163-L164 tells the LLM to call recurse multiple times in one assistant turn so the children dispatch in parallel, so the supported execution shape is N sibling coroutines mutating the same Skillbook and appending to the same operations list under asyncio.gather. Compounding this, the try that wraps each child session (recursive_agent.py#L250-L251) is a bare except Exception as e: return f"(child session failed: {e})" — if a child raises after it has already invoked one or more mutation tools, the partial writes are already in the parent's Skillbook, but the parent only sees a benign string and has no signal that state is now inconsistent. The same swallow appears at the SkillManager level (ace/implementations/skill_manager.py#L191-L234): the except Exception at L226-229 returns raw={"error": str(e)} together with operations=list(deps.operations), i.e. the partial audit slice of whatever fired before the crash, with no exception propagated to the caller. End-user impact: in any multi-tool turn that uses recurse, the Skillbook accumulates skills from "failed" siblings, audit operations from different children interleave in one list, and an UpdateBatch can be persisted that mixes successful and partial work without any indication.
Steps to Reproduce
pip install pydantic pydantic_ai
- Clone ACE at SHA
96f7c9cfea1d7cae74994c391ad7791e6cbf7f6a.
- Save the script below as
repro.py next to (or inside) the ACE checkout and run python repro.py. The script uses the real SMDeps / Skillbook / sm_tools from the audited tree and replays the exact shallow-copy expression from recursive_agent.py:223-234, then wraps each child in the same bare-except as recursive_agent.py:250-251.
import asyncio, sys
from dataclasses import dataclass
from pathlib import Path
sys.path.insert(0, str(Path("/path/to/agentic-context-engine")))
from ace.core.recursive_agent import AgenticConfig
from ace.core.skillbook import Skillbook
from ace.implementations.sm_tools import (
SMDeps, register_add_skill, register_remove_skill, register_update_skill,
)
@dataclass
class StubCtx: deps: SMDeps
class _ToolCapture:
def __init__(self): self.tools = {}
def tool(self, fn=None, **_):
if fn is None:
def deco(f): self.tools[f.__name__] = f; return f
return deco
self.tools[fn.__name__] = fn; return fn
def shallow_clone_child(deps): # EXACT expression from recursive_agent.py:223-234
return deps.__class__(**{
**{f.name: getattr(deps, f.name) for f in deps.__dataclass_fields__.values()},
"sandbox": None, "depth": deps.depth + 1,
"iteration": 0, "parent_usage_tokens": 0,
})
async def run_child(child_deps, tools, program, label):
ctx = StubCtx(deps=child_deps); results = []
for tname, kw in program:
if tname == "__raise__": raise RuntimeError(kw["msg"])
results.append(tools[tname](ctx, **kw))
await asyncio.sleep(0)
return f"{label}: {results}"
async def recurse_emulated(parent, tools, programs):
async def one(label, prog):
child = shallow_clone_child(parent)
try: return await run_child(child, tools, prog, label)
except Exception as e: return f"(child session failed: {e})" # L250-251
return await asyncio.gather(*[one(l, p) for l, p in programs])
async def main():
sb = Skillbook()
cap = _ToolCapture()
register_add_skill(cap); register_update_skill(cap); register_remove_skill(cap)
parent = SMDeps(config=AgenticConfig(), skillbook=sb, depth=0)
seed = sb.add_skill(section="strategy", issue="seed",
keywords=["seed"], insight="seed insight")
parent.operations.clear()
programs = [
("ChildA", [
("add_skill", dict(section="strategy", issue="a1", keywords=["a"], insight="A1")),
("add_skill", dict(section="strategy", issue="a2", keywords=["a"], insight="A2")),
("update_skill", dict(skill_id=seed.id, issue="seed v2",
keywords=["seed"], insight="seed v2 insight")),
]),
("ChildB", [
("add_skill", dict(section="strategy", issue="b1-leaked",
keywords=["b"], insight="B1 leaked")),
("__raise__", dict(msg="simulated sandbox/tool crash")),
]),
]
results = await recurse_emulated(parent, cap.tools, programs)
for r in results: print(" ", r)
print("shared skillbook:", parent.skillbook is sb)
print("skills now:", sorted(sb._skills.keys()))
print("leaked from failed child:",
len([s for s in sb._skills.values() if s.issue == "b1-leaked"]))
print("ops (interleaved):", [(o.type, o.issue) for o in parent.operations])
asyncio.run(main())
Expected Behavior
Failed child sessions should not leave mutations in the parent's Skillbook. Either (a) each child gets a deep-copied / snapshot Skillbook and its own operations list, with the parent merging successful child results explicitly, or (b) the bare except Exception in recurse should re-raise (or surface a structured error to the caller) instead of returning a benign string while shared state has already been mutated.
Actual Behavior
ChildA: [{'ok': True, 'skill_id': 'context-00002'}, {'ok': True, 'skill_id': 'context-00004'}, {'ok': True, 'skill_id': 'context-00001'}]
(child session failed: simulated sandbox/tool crash)
shared skillbook: True
skills now: ['context-00001', 'context-00002', 'context-00003', 'context-00004']
leaked from failed child: 1
ops (interleaved): [('ADD', 'a1'), ('ADD', 'b1-leaked'), ('ADD', 'a2'), ('UPDATE', 'seed v2')]
ChildB raised, but its earlier add_skill("b1-leaked") is still in the shared Skillbook. The parent's operations list contains an interleaved sequence of both children's writes. The parent only sees the string "(child session failed: ...)".
Environment
- ACE source @ commit
96f7c9cfea1d7cae74994c391ad7791e6cbf7f6a (current main)
- Python 3.12.11
pydantic 2.x, pydantic_ai latest
- Ubuntu 22.04
RecursiveAgent.recurse(ace/core/recursive_agent.py#L223-L234) builds each child'sDepsby iterating__dataclass_fields__and callingdeps.__class__(**{f.name: getattr(deps, f.name), ...}). This is a shallow copy: every dataclass field is passed by reference. ForSMDeps(ace/implementations/sm_tools.py#L34-L40), this means the liveSkillbookinstance and theoperationslist are aliased between the parent and every child the parent spawns. The tool's own docstring atrecursive_agent.py#L163-L164tells the LLM to callrecursemultiple times in one assistant turn so the children dispatch in parallel, so the supported execution shape is N sibling coroutines mutating the sameSkillbookand appending to the sameoperationslist underasyncio.gather. Compounding this, thetrythat wraps each child session (recursive_agent.py#L250-L251) is a bareexcept Exception as e: return f"(child session failed: {e})"— if a child raises after it has already invoked one or more mutation tools, the partial writes are already in the parent'sSkillbook, but the parent only sees a benign string and has no signal that state is now inconsistent. The same swallow appears at the SkillManager level (ace/implementations/skill_manager.py#L191-L234): theexcept Exceptionat L226-229 returnsraw={"error": str(e)}together withoperations=list(deps.operations), i.e. the partial audit slice of whatever fired before the crash, with no exception propagated to the caller. End-user impact: in any multi-tool turn that usesrecurse, theSkillbookaccumulates skills from "failed" siblings, audit operations from different children interleave in one list, and anUpdateBatchcan be persisted that mixes successful and partial work without any indication.Steps to Reproduce
pip install pydantic pydantic_ai96f7c9cfea1d7cae74994c391ad7791e6cbf7f6a.repro.pynext to (or inside) the ACE checkout and runpython repro.py. The script uses the realSMDeps/Skillbook/sm_toolsfrom the audited tree and replays the exact shallow-copy expression fromrecursive_agent.py:223-234, then wraps each child in the same bare-except asrecursive_agent.py:250-251.Expected Behavior
Failed child sessions should not leave mutations in the parent's
Skillbook. Either (a) each child gets a deep-copied / snapshotSkillbookand its ownoperationslist, with the parent merging successful child results explicitly, or (b) the bareexcept Exceptioninrecurseshould re-raise (or surface a structured error to the caller) instead of returning a benign string while shared state has already been mutated.Actual Behavior
ChildB raised, but its earlier
add_skill("b1-leaked")is still in the sharedSkillbook. The parent'soperationslist contains an interleaved sequence of both children's writes. The parent only sees the string"(child session failed: ...)".Environment
96f7c9cfea1d7cae74994c391ad7791e6cbf7f6a(currentmain)pydantic2.x,pydantic_ailatest