Skip to content

Bug: recursive_agent child sessions share live Skillbook/operations; failed children leak mutations #134

Description

@Fr3ya

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

  1. pip install pydantic pydantic_ai
  2. Clone ACE at SHA 96f7c9cfea1d7cae74994c391ad7791e6cbf7f6a.
  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions