perf(delegation): finished delegate children no longer pin their transcripts in the parent heap
A parent that fanned out 1,320 subagents over 13h reached 2.6 GB RSS (1.9 GB anonymous heap). Every closed child AIAgent stayed reachable and still owned a copy of its full message history. gc.get_referrers on a finished child (30-child fan-out bench, evals/fanout_resource_bench.py) showed two retainers: 1. bind_subagent_parent() stored the agent strongly in the `hermes_subagent_lifecycle_parent` ContextVar. Each child binds ITSELF for its own turn, and every asyncio Handle/Future scheduled during that turn (LSP reader loops, kernel pipe transports) snapshots the Context — 56 live Contexts held 14 finished children after the bench. The ContextVar now holds a weakref (non-weakrefable doubles fall back to a closure); get_active_subagent_parent() dereferences it. 2. AIAgent.close() cleared _session_messages but not the _db_flush_scan_prefix snapshot (a `messages[:]` shallow copy taken on every successful DB flush) nor _streamed_assistant_text_parts, so the agent — kept alive by (1) — retained every message dict. close() now drops both. The delegate_task result entry never carried `messages`; a pin test confirms the per-child result JSON is unchanged. Bench (30 children / 10 worktrees, ~100 KB final replies so retention is visible): post-fan-out live child AIAgents 14 -> 0; RSS after fan-out 636 MB -> 556 MB. With the harness' tiny default replies both runs sit at ~192-194 MB (the children's transcripts were never the dominant cost there; the leaked objects were).
This commit is contained in:
@@ -17,6 +17,7 @@ import math
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
import weakref
|
||||
from contextlib import contextmanager
|
||||
from concurrent.futures import Future, TimeoutError
|
||||
from typing import Any, Callable, Mapping, Optional
|
||||
@@ -171,8 +172,19 @@ _ACTIVE_PARENT_AGENT: contextvars.ContextVar[Any] = contextvars.ContextVar(
|
||||
|
||||
@contextmanager
|
||||
def bind_subagent_parent(parent_agent: Any):
|
||||
"""Bind the host-owned parent for the current agent turn."""
|
||||
token = _ACTIVE_PARENT_AGENT.set(parent_agent)
|
||||
"""Bind the host-owned parent for the current agent turn.
|
||||
|
||||
Stored as a weakref: every asyncio Handle/Future scheduled from the turn
|
||||
(LSP reader loops, kernel pipes, ...) snapshots the Context, and those
|
||||
snapshots outlive the turn. A strong ref there pinned finished delegate
|
||||
children — each of which binds itself here for its own turn — in the
|
||||
parent process heap for the life of the background loop.
|
||||
"""
|
||||
try:
|
||||
ref = weakref.ref(parent_agent)
|
||||
except TypeError:
|
||||
ref = lambda: parent_agent # noqa: E731 — non-weakrefable test doubles
|
||||
token = _ACTIVE_PARENT_AGENT.set(ref)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
@@ -181,7 +193,8 @@ def bind_subagent_parent(parent_agent: Any):
|
||||
|
||||
def get_active_subagent_parent() -> Any:
|
||||
"""Return the parent bound to this execution context, if any."""
|
||||
return _ACTIVE_PARENT_AGENT.get()
|
||||
ref = _ACTIVE_PARENT_AGENT.get()
|
||||
return ref() if ref is not None else None
|
||||
|
||||
|
||||
class SubagentLifecycleService:
|
||||
|
||||
Reference in New Issue
Block a user