From c062ba1700afe8d2488204cd2e54f20d5ce268c1 Mon Sep 17 00:00:00 2001 From: divo12 Date: Sun, 9 Aug 2026 22:22:00 +0530 Subject: [PATCH 1/2] feat(subagents): lean Hermes+Claude catalog with wired isolation Ship explore/plan/verify builtins, wire model/overlay/spawned_by/isolation on the live path, add delegation poll/stop for background spawns, and keep sync as the beat default with a host tool blocklist. Co-authored-by: Cursor --- .../2026-08-09-lean-subagent-redesign.md | 58 ++++++ src/dream/_factory.py | 34 +++- src/dream/subagents/__init__.py | 42 ++++- src/dream/subagents/_async_delegation.py | 61 +++++- src/dream/subagents/_builtins.py | 145 +++++++++++++++ src/dream/subagents/_declaration.py | 174 ++++++++++-------- src/dream/subagents/_delegate.py | 14 +- src/dream/subagents/_host_blocklist.py | 95 ++++++++++ src/dream/subagents/_inline_executor.py | 159 ++++++++-------- src/dream/subagents/_isolation.py | 19 ++ src/dream/subagents/_overlay_gate.py | 46 +++++ src/dream/subagents/_worktree.py | 70 +++++++ src/dream/tools/builtin/delegation_get.py | 65 +++++++ src/dream/tools/builtin/delegation_stop.py | 47 +++++ src/dream/tools/builtin/spawn_subagent.py | 59 ++++-- tests/test_subagents/test_async_delegation.py | 4 +- tests/test_subagents/test_builtins.py | 84 +++++++++ tests/test_subagents/test_declaration.py | 3 + tests/test_subagents/test_spawn_enum.py | 20 +- 19 files changed, 1004 insertions(+), 195 deletions(-) create mode 100644 docs/designs/2026-08-09-lean-subagent-redesign.md create mode 100644 src/dream/subagents/_builtins.py create mode 100644 src/dream/subagents/_host_blocklist.py create mode 100644 src/dream/subagents/_isolation.py create mode 100644 src/dream/subagents/_overlay_gate.py create mode 100644 src/dream/subagents/_worktree.py create mode 100644 src/dream/tools/builtin/delegation_get.py create mode 100644 src/dream/tools/builtin/delegation_stop.py create mode 100644 tests/test_subagents/test_builtins.py diff --git a/docs/designs/2026-08-09-lean-subagent-redesign.md b/docs/designs/2026-08-09-lean-subagent-redesign.md new file mode 100644 index 00000000..1eeed288 --- /dev/null +++ b/docs/designs/2026-08-09-lean-subagent-redesign.md @@ -0,0 +1,58 @@ +# Lean Subagent Redesign (2026-08-09) + +Hermes + Claude typed-catalog hybrid. Mid-beat spawn stays separate from org +delegation. + +## Live path + +``` +spawn_subagent → run_subagent_delegate → run_subagent_session → run_role +``` + +## Builtins (always when spawn enabled) + +| Type | Posture | +|------|---------| +| `explore` | Read-only map / evidence | +| `plan` | Read-only implementation plan | +| `verify` | Strict PASS/FAIL/PARTIAL JSON | +| `generalPurpose` | Parent ∩ minus nest tools | + +Role specialists **add** names; they do not remove builtins. Unknown types refuse. + +## Wired declaration fields + +- `model` → `SessionOptions.model` +- `permission_overlay` → tighten-only child gate wrapper +- `spawned_by` → fail-closed at resolve +- `isolation` → `shared` | `worktree` (ephemeral git worktree under scratch) + +## Host blocklist (Hermes) + +Children never receive clarify / memory write / cron / task_create / worktree +enter-exit. Leaves also lose `spawn_subagent`. + +## Async + +`background=true` returns a handle. Poll/stop via `delegation_get` / +`delegation_stop` (not the shell `task_*` tools). Sync remains the beat default. + +## Depth + +`MAX_INLINE_NESTING = 2`. Flat by default; depth-2 only when a specialist +declares `spawnable`. + +## Chorus lean roster + +Keep: `web_research`, DoD graders (`test_author`, `api_verifier`, +`code_reviewer`), critics (`critic`, `brand_critic`, `design_critic`). + +Kill from manifests: craft middlemen (researcher wrappers, strategist, creative, +explorer, ux_researcher, analyst personas, ceo advisor/researcher, ui_tester). + +## Vendor steals + +- OpenHarness: Explore/Plan/verification denylists, worktree isolation, task poll +- Hermes: host blocklist, summary budget + spill, sync default +- OpenCode: explore allowlist posture, filterCompacted mindset +- qm: fail-closed named types only diff --git a/src/dream/_factory.py b/src/dream/_factory.py index e0641ca2..52dda726 100644 --- a/src/dream/_factory.py +++ b/src/dream/_factory.py @@ -268,8 +268,16 @@ def build_harness( register_task_memory_tools(tool_registry) # Subagents: register spawn when a set is provided (including empty — generalPurpose only). # ``subagents is None`` keeps the tool surface byte-identical (default off). - if subagents is not None and tool_registry.get("spawn_subagent") is None: - tool_registry.register(SpawnSubagentTool(), source=ToolSource.DEFAULT) + if subagents is not None: + from dream.tools.builtin.delegation_get import DelegationGetTool + from dream.tools.builtin.delegation_stop import DelegationStopTool + + if tool_registry.get("spawn_subagent") is None: + tool_registry.register(SpawnSubagentTool(), source=ToolSource.DEFAULT) + if tool_registry.get("delegation_get") is None: + tool_registry.register(DelegationGetTool(), source=ToolSource.DEFAULT) + if tool_registry.get("delegation_stop") is None: + tool_registry.register(DelegationStopTool(), source=ToolSource.DEFAULT) # Spec 05: discover per-repo tools after all default registrations so a # declared per-repo tool can intentionally shadow any built-in. try: @@ -792,7 +800,23 @@ def _build_session_engine( # the session has no subagent set (top-level role without spawn capability). if role_allowed is not None: context_metadata[PARENT_TOOLS_KEY] = role_allowed - context_metadata[PARENT_PERMISSIONS_KEY] = permission_gate + + from dream.subagents._inline_executor import ( + SUBAGENT_OVERLAY_METADATA_KEY, + SUBAGENT_WORKING_DIR_METADATA_KEY, + ) + from dream.subagents._overlay_gate import wrap_permission_gate + + session_working_dir = working_dir + override_cwd = options.metadata.get(SUBAGENT_WORKING_DIR_METADATA_KEY) + if isinstance(override_cwd, str) and override_cwd: + session_working_dir = Path(override_cwd) + + overlay = options.metadata.get(SUBAGENT_OVERLAY_METADATA_KEY) + child_gate = permission_gate + if isinstance(overlay, tuple) and overlay: + child_gate = wrap_permission_gate(permission_gate, overlay) + context_metadata[PARENT_PERMISSIONS_KEY] = child_gate # The run_role observer (when present) rides into the tool context, so the spawn tool can # forward it into a child session — nested spawns then surface on the same observer/bus. @@ -834,10 +858,10 @@ def _build_session_engine( streamer=streamer, registry=tool_registry, session_id=session_id, - working_dir=working_dir, + working_dir=session_working_dir, scratch_dir=paths.sidecar(session_id) / "scratch", max_turns=options.max_turns or max_turns, - permission_gate=permission_gate, + permission_gate=child_gate, role_allowed_tools=role_allowed, limits=SessionLimits(), context_metadata=context_metadata, diff --git a/src/dream/subagents/__init__.py b/src/dream/subagents/__init__.py index 7d2d0da0..f62966fd 100644 --- a/src/dream/subagents/__init__.py +++ b/src/dream/subagents/__init__.py @@ -1,28 +1,51 @@ -"""Subagent layer — chorus-side declaration, registry, and projection. +"""Subagent layer — declarations, builtins, inline delegate, async manager. -A subagent is a capability-minimized, ephemeral teammate a beat spawns to do -bounded work, then dissolves. This package defines: - -- ``Subagent``: the frozen declaration (on a role / shared registry). -- ``SubagentSet``: the resolved set of subagents available to a beat. -- ``SubagentRegistry``: the Tier-2 shared-capability agent registry. -- ``project_subagent``: the chorus→dream projection (Subagent → TeammateSpawnConfig). +Live path: ``spawn_subagent`` → ``run_subagent_delegate`` → ``run_role``. """ +from dream.subagents._async_delegation import ( + AsyncDelegationManager, + DelegationCompletion, + DelegationHandle, + DelegationSnapshot, + DelegationStatus, +) +from dream.subagents._builtins import ( + EXPLORE, + GENERAL_PURPOSE, + PLAN, + VERIFY, + builtin_agents, + merge_builtins, +) from dream.subagents._catalogue import SubagentCatalogue, SubagentCatalogueEntry from dream.subagents._declaration import ( GENERAL_PURPOSE_DESCRIPTION, GENERAL_PURPOSE_NAME, + MAX_INLINE_NESTING, + MAX_SUBAGENT_DEPTH, PermissionDelta, Subagent, SubagentSet, ) +from dream.subagents._isolation import IsolationMode from dream.subagents._projection import SubagentResult, project_subagent from dream.subagents._registry import SubagentRegistry __all__ = [ "GENERAL_PURPOSE_DESCRIPTION", "GENERAL_PURPOSE_NAME", + "MAX_INLINE_NESTING", + "MAX_SUBAGENT_DEPTH", + "AsyncDelegationManager", + "DelegationCompletion", + "DelegationHandle", + "DelegationSnapshot", + "DelegationStatus", + "EXPLORE", + "GENERAL_PURPOSE", + "IsolationMode", + "PLAN", "PermissionDelta", "Subagent", "SubagentCatalogue", @@ -30,5 +53,8 @@ "SubagentRegistry", "SubagentResult", "SubagentSet", + "VERIFY", + "builtin_agents", + "merge_builtins", "project_subagent", ] diff --git a/src/dream/subagents/_async_delegation.py b/src/dream/subagents/_async_delegation.py index c5d4afd4..b0a56609 100644 --- a/src/dream/subagents/_async_delegation.py +++ b/src/dream/subagents/_async_delegation.py @@ -17,6 +17,7 @@ class DelegationStatus(StrEnum): COMPLETED = "completed" FAILED = "failed" TIMED_OUT = "timed_out" + STOPPED = "stopped" @dataclass(frozen=True) @@ -45,13 +46,26 @@ def render(self) -> str: return "\n".join(lines) +@dataclass(frozen=True) +class DelegationSnapshot: + """Pollable view of one delegation (active or completed).""" + + delegation_id: str + session_id: str + status: DelegationStatus + subagent_names: tuple[str, ...] + results: tuple[SubagentResult, ...] = () + error: str | None = None + + _DelegationWork = Callable[[], Awaitable[tuple[SubagentResult, ...]]] -@dataclass(frozen=True) +@dataclass class _ActiveDelegation: session_id: str task: asyncio.Task[None] + subagent_names: tuple[str, ...] class AsyncDelegationManager: @@ -66,6 +80,7 @@ def __init__(self, *, max_active: int = 3, timeout_seconds: float = 300.0) -> No self._timeout_seconds = timeout_seconds self._active: dict[str, _ActiveDelegation] = {} self._completed: defaultdict[str, deque[DelegationCompletion]] = defaultdict(deque) + self._history: dict[str, DelegationSnapshot] = {} self._ready: defaultdict[str, asyncio.Event] = defaultdict(asyncio.Event) def start( @@ -78,7 +93,17 @@ def start( return None delegation_id = uuid4().hex[:12] task = asyncio.create_task(self._run(delegation_id, session_id, work)) - self._active[delegation_id] = _ActiveDelegation(session_id=session_id, task=task) + self._active[delegation_id] = _ActiveDelegation( + session_id=session_id, + task=task, + subagent_names=subagent_names, + ) + self._history[delegation_id] = DelegationSnapshot( + delegation_id=delegation_id, + session_id=session_id, + status=DelegationStatus.DISPATCHED, + subagent_names=subagent_names, + ) return DelegationHandle( delegation_id=delegation_id, status=DelegationStatus.DISPATCHED, @@ -88,6 +113,22 @@ def start( def active(self, session_id: str) -> int: return sum(item.session_id == session_id for item in self._active.values()) + def get(self, delegation_id: str) -> DelegationSnapshot | None: + return self._history.get(delegation_id) + + def list_for_session(self, session_id: str) -> tuple[DelegationSnapshot, ...]: + return tuple( + snap for snap in self._history.values() if snap.session_id == session_id + ) + + async def stop(self, delegation_id: str) -> DelegationSnapshot | None: + active = self._active.get(delegation_id) + if active is None: + return self._history.get(delegation_id) + active.task.cancel() + await asyncio.gather(active.task, return_exceptions=True) + return self._history.get(delegation_id) + def drain(self, session_id: str) -> tuple[DelegationCompletion, ...]: queue = self._completed[session_id] items = tuple(queue) @@ -127,6 +168,7 @@ async def _run( session_id: str, work: _DelegationWork, ) -> None: + names = self._active[delegation_id].subagent_names if delegation_id in self._active else () try: async with asyncio.timeout(self._timeout_seconds): results = await work() @@ -142,9 +184,9 @@ async def _run( except asyncio.CancelledError: completion = DelegationCompletion( delegation_id=delegation_id, - status=DelegationStatus.FAILED, + status=DelegationStatus.STOPPED, results=(), - error="background delegation cancelled", + error="background delegation stopped", ) except TimeoutError: completion = DelegationCompletion( @@ -153,7 +195,7 @@ async def _run( results=(), error=f"background delegation exceeded {self._timeout_seconds:g}s", ) - except Exception as exc: # the parent receives failure; the task never leaks it + except Exception as exc: completion = DelegationCompletion( delegation_id=delegation_id, status=DelegationStatus.FAILED, @@ -163,6 +205,14 @@ async def _run( finally: self._active.pop(delegation_id, None) self._ready[session_id].set() + self._history[delegation_id] = DelegationSnapshot( + delegation_id=delegation_id, + session_id=session_id, + status=completion.status, + subagent_names=names, + results=completion.results, + error=completion.error, + ) self._completed[session_id].append(completion) self._ready[session_id].set() @@ -171,5 +221,6 @@ async def _run( "AsyncDelegationManager", "DelegationCompletion", "DelegationHandle", + "DelegationSnapshot", "DelegationStatus", ] diff --git a/src/dream/subagents/_builtins.py b/src/dream/subagents/_builtins.py new file mode 100644 index 00000000..4e66f3ec --- /dev/null +++ b/src/dream/subagents/_builtins.py @@ -0,0 +1,145 @@ +"""Harness-builtin subagent templates (OpenHarness Explore / Plan / verification). + +Merged into every beat that enables ``spawn_subagent``. Role specialists add +names; they do not remove these builtins. Fail-closed enum = builtins ∪ role set. +""" + +from __future__ import annotations + +from dream.api.response_format import JsonSchema +from dream.subagents._declaration import ( + GENERAL_PURPOSE_NAME, + Subagent, + SubagentSet, +) +from dream.subagents._host_blocklist import EXPLORE_TOOLS, PLAN_TOOLS, VERIFY_TOOLS +from dream.subagents._isolation import IsolationMode + +EXPLORE = "explore" +PLAN = "plan" +VERIFY = "verify" +GENERAL_PURPOSE = GENERAL_PURPOSE_NAME + +_VERIFY_SCHEMA = JsonSchema.of( + { + "type": "object", + "additionalProperties": False, + "required": ["verdict", "summary", "findings"], + "properties": { + "verdict": { + "type": "string", + "enum": ["PASS", "FAIL", "PARTIAL"], + "description": "Machine-readable verification outcome.", + }, + "summary": { + "type": "string", + "description": "One-paragraph rationale.", + }, + "findings": { + "type": "array", + "items": {"type": "string"}, + "description": "Concrete defects or evidence lines.", + }, + }, + } +) + + +def explore_agent() -> Subagent: + """Read-only mapper — OpenHarness Explore denylist as Dream allowlist.""" + return Subagent( + name=EXPLORE, + description=( + "Read-only explore: map the codebase or gather evidence. " + "Cannot edit files, run mutating shell, or spawn children. " + "Return Critical Files, findings, and open questions." + ), + tools=EXPLORE_TOOLS, + max_turns=12, + isolation=IsolationMode.SHARED, + system_prompt=( + "You are explore, a read-only research subagent.\n" + "Map only what the goal asks for. Do not edit files or mutate state.\n" + "End with: Critical Files, Findings, Open Questions." + ), + ) + + +def plan_agent() -> Subagent: + """Read-only planner — no mutations (OpenHarness Plan).""" + return Subagent( + name=PLAN, + description=( + "Read-only planner: produce a concrete implementation plan. " + "Cannot edit files or mutate the repo. Return ordered steps and risks." + ), + tools=PLAN_TOOLS, + max_turns=10, + isolation=IsolationMode.SHARED, + system_prompt=( + "You are plan, a read-only planning subagent.\n" + "Produce an actionable plan with ordered steps, files touched, and risks.\n" + "Do not edit files or run mutating commands." + ), + ) + + +def verify_agent() -> Subagent: + """Strict PASS/FAIL verifier — OpenHarness verification verdict contract.""" + return Subagent( + name=VERIFY, + description=( + "Blind verifier: judge evidence against a contract. " + "Returns strict JSON with verdict PASS|FAIL|PARTIAL. Prefer when isolation " + "from the author's context matters." + ), + tools=VERIFY_TOOLS, + max_turns=10, + isolation=IsolationMode.SHARED, + output_schema=_VERIFY_SCHEMA, + strict=True, + system_prompt=( + "You are verify, an adversarial grader.\n" + "Judge only against the stated contract and evidence you can observe.\n" + "Final message MUST be JSON matching the schema with " + "verdict PASS|FAIL|PARTIAL." + ), + ) + + +def builtin_agents() -> tuple[Subagent, ...]: + """The harness catalog always offered when spawn is enabled.""" + return (explore_agent(), plan_agent(), verify_agent()) + + +def merge_builtins(role_set: SubagentSet | None) -> SubagentSet: + """Role agents overlay builtins; role wins on name collision.""" + agents: dict[str, Subagent] = {agent.name: agent for agent in builtin_agents()} + if role_set is not None: + for name, agent in role_set.agents.items(): + agents[name] = agent + return SubagentSet(agents=agents) + + +def spawn_catalog_names(role_set: SubagentSet | None) -> tuple[str, ...]: + """Enum values: generalPurpose, builtins, then remaining role names.""" + merged = merge_builtins(role_set) + names = [GENERAL_PURPOSE, EXPLORE, PLAN, VERIFY] + for name in merged.names(): + if name not in names: + names.append(name) + return tuple(names) + + +__all__ = [ + "EXPLORE", + "GENERAL_PURPOSE", + "PLAN", + "VERIFY", + "builtin_agents", + "explore_agent", + "merge_builtins", + "plan_agent", + "spawn_catalog_names", + "verify_agent", +] diff --git a/src/dream/subagents/_declaration.py b/src/dream/subagents/_declaration.py index cd7dd002..532dd3c3 100644 --- a/src/dream/subagents/_declaration.py +++ b/src/dream/subagents/_declaration.py @@ -1,28 +1,32 @@ """Role-agnostic subagent declarations. -A ``Subagent`` is a thin overlay declaration projected onto Dream's existing -``TeammateSpawnConfig`` at beat-build time. The declaration is durable (lives on -the role / in the registry); the spawn config is ephemeral (minted per dispatch). - -The declaring application owns role policy; Dream only executes the typed shape. +A ``Subagent`` is a frozen capability-minimized template. The live path is +``spawn_subagent`` → ``run_subagent_delegate`` → ``run_subagent_session`` → +``run_role``. The declaring application owns role policy; Dream executes the +typed shape. """ from __future__ import annotations -from collections.abc import Iterator, Mapping +from collections.abc import Iterator, Mapping, Sequence from dataclasses import dataclass, field -from typing import Any +from typing import cast from dream.api.response_format import JsonSchema +from dream.subagents._isolation import IsolationMode PermissionDelta = tuple[str, ...] -"""Tighten-only permission overlay — a tuple of permission tokens to *remove* -from the parent's set. Never widens.""" +"""Tighten-only permission overlay — tokens to *remove* from the parent. Never widens.""" + +MAX_INLINE_NESTING = 2 +"""Hard cap on mid-beat subagent nesting (Hermes-flat default; depth-2 for rare orchestrators). + +A subagent at ``depth < MAX_INLINE_NESTING`` may dispatch its declared ``spawnable`` +children; at the cap it is always a leaf. +""" -MAX_SUBAGENT_DEPTH = 2 -"""Hard cap on subagent nesting. A subagent at ``depth < MAX_SUBAGENT_DEPTH`` may dispatch its -declared ``spawnable`` children; at the cap it is always a leaf. V1 was flat (1); depth-2 lets a -Tier-1 specialist spawn a Tier-2 orchestrator, bounded by construction.""" +# Back-compat alias — prefer ``MAX_INLINE_NESTING``. +MAX_SUBAGENT_DEPTH = MAX_INLINE_NESTING GENERAL_PURPOSE_NAME = "generalPurpose" """Built-in ad-hoc worker type always offered when spawn is enabled.""" @@ -35,58 +39,25 @@ @dataclass(frozen=True) class Subagent: - """Harness-side subagent declaration. - - Declared on the role (Tier-1) or in the shared SubagentRegistry (Tier-2). - Projected onto dream's TeammateSpawnConfig at dispatch time. - """ + """Harness-side subagent declaration (Tier-1 role or Tier-2 registry).""" name: str - """Sanitized identifier — 'reviewer', 'query_orchestrator', etc.""" - description: str """One-line discovery copy for :class:`SubagentCatalogue`.""" tools: tuple[str, ...] - """Capability-minimized allow-list — must be a subset of the parent's tools.""" - skills: tuple[str, ...] = () - """Authored know-how the subagent consults (skill names).""" - permission_overlay: PermissionDelta = () - """Tighten-only (never widen) — permissions to *drop* from the parent.""" - depth: int = 1 - """Dream depth slot; must be > parent.depth. V1 is flat: always 1.""" - model: str | None = None - """Optional cheaper model for the subagent. None → parent model.""" - spawned_by: tuple[str, ...] = () - """Which parents may dispatch it (Tier-2 gating). Empty → any parent.""" - system_prompt: str | None = None - """Optional custom system prompt. None → generated from name+description.""" - max_turns: int = 8 - """Maximum turn budget for the subagent before forced termination.""" - spawnable: tuple[Subagent, ...] = () - """The Tier-2 subagents THIS subagent may itself dispatch (depth-2). Empty (default) = a leaf, - unchanged from v1. Non-empty + ``depth < MAX_SUBAGENT_DEPTH`` makes the child spawn-eligible: it - keeps ``spawn_subagent`` and is handed a scoped set of exactly these agents — never the parent's - full roster. Each is still tool-intersected with the child, so a grandchild can only narrow.""" - output_schema: JsonSchema | Mapping[str, object] | None = None - """Optional JSON-schema the subagent's final message is validated against at runtime. ``None`` = - no enforcement (free-text return, unchanged). When set, the inline executor coerces + validates the - output, runs a bounded reformat loop on failure, and fails open with a warning (``_output_guard``) - unless ``strict`` is True.""" - strict: bool = False - """When True with ``output_schema``, exhausted repairs raise - :class:`~dream.subagents._output_guard.OutputSchemaError` instead of fail-open. - Use for DoD graders (api_verifier, test_author).""" + isolation: IsolationMode = IsolationMode.SHARED + """``SHARED`` = parent worktree; ``WORKTREE`` = short-lived git worktree.""" def __post_init__(self) -> None: if not self.name: @@ -97,8 +68,19 @@ def __post_init__(self) -> None: raise TypeError("Subagent.tools must be a sequence of strings, not a bare string") if self.depth < 1: raise ValueError(f"Subagent.depth must be >= 1; got {self.depth}") + if not isinstance(self.isolation, IsolationMode): + raise TypeError( + f"Subagent.isolation must be IsolationMode; got {type(self.isolation)}" + ) - def to_dict(self) -> dict[str, Any]: + def to_dict(self) -> dict[str, object]: + schema_doc: dict[str, object] | None + if self.output_schema is None: + schema_doc = None + elif isinstance(self.output_schema, JsonSchema): + schema_doc = dict(self.output_schema.document) + else: + schema_doc = dict(self.output_schema) return { "name": self.name, "description": self.description, @@ -110,57 +92,81 @@ def to_dict(self) -> dict[str, Any]: "spawned_by": list(self.spawned_by), "system_prompt": self.system_prompt, "max_turns": self.max_turns, - "output_schema": ( - dict(self.output_schema.document) - if isinstance(self.output_schema, JsonSchema) - else (dict(self.output_schema) if self.output_schema is not None else None) - ), + "output_schema": schema_doc, "strict": self.strict, + "isolation": self.isolation.value, "spawnable": [child.to_dict() for child in self.spawnable], } @classmethod - def from_dict(cls, data: dict[str, Any]) -> Subagent: - raw_schema = data.get("output_schema") + def from_dict(cls, data: Mapping[str, object]) -> Subagent: + raw_schema = data["output_schema"] if "output_schema" in data else None output_schema: JsonSchema | None if raw_schema is None: output_schema = None elif isinstance(raw_schema, JsonSchema): output_schema = raw_schema elif isinstance(raw_schema, Mapping): - output_schema = JsonSchema.of(raw_schema) + output_schema = JsonSchema.of(cast(Mapping[str, object], raw_schema)) else: raise TypeError( f"Subagent.output_schema must be a mapping or JsonSchema; got {type(raw_schema)}" ) + + tools_raw = data["tools"] + if not isinstance(tools_raw, Sequence) or isinstance(tools_raw, (str, bytes)): + raise TypeError("Subagent.tools must be a sequence of strings") + + spawnable_raw = data["spawnable"] if "spawnable" in data else () + if spawnable_raw is None: + spawnable_raw = () + if not isinstance(spawnable_raw, Sequence) or isinstance(spawnable_raw, (str, bytes)): + raise TypeError("Subagent.spawnable must be a sequence") + + isolation_raw = data["isolation"] if "isolation" in data else IsolationMode.SHARED.value + isolation = ( + isolation_raw + if isinstance(isolation_raw, IsolationMode) + else IsolationMode(str(isolation_raw)) + ) + return cls( - name=data["name"], - description=data["description"], - tools=tuple(data["tools"]), - skills=tuple(data.get("skills") or ()), - permission_overlay=tuple(data.get("permission_overlay") or ()), - depth=data.get("depth", 1), - model=data.get("model"), - spawned_by=tuple(data.get("spawned_by") or ()), - system_prompt=data.get("system_prompt"), - max_turns=data.get("max_turns", 8), + name=str(data["name"]), + description=str(data["description"]), + tools=tuple(str(item) for item in tools_raw), + skills=_string_tuple(data, "skills"), + permission_overlay=_string_tuple(data, "permission_overlay"), + depth=int(data["depth"]) if "depth" in data else 1, + model=str(data["model"]) if data.get("model") is not None else None, + spawned_by=_string_tuple(data, "spawned_by"), + system_prompt=( + str(data["system_prompt"]) if data.get("system_prompt") is not None else None + ), + max_turns=int(data["max_turns"]) if "max_turns" in data else 8, output_schema=output_schema, - strict=bool(data.get("strict", False)), - spawnable=tuple(cls.from_dict(child) for child in (data.get("spawnable") or ())), + strict=bool(data["strict"]) if "strict" in data else False, + isolation=isolation, + spawnable=tuple( + cls.from_dict(cast(Mapping[str, object], child)) + for child in spawnable_raw + ), ) +def _string_tuple(data: Mapping[str, object], key: str) -> tuple[str, ...]: + if key not in data or data[key] is None: + return () + raw = data[key] + if not isinstance(raw, Sequence) or isinstance(raw, (str, bytes)): + raise TypeError(f"Subagent.{key} must be a sequence of strings") + return tuple(str(item) for item in raw) + + @dataclass(frozen=True) class SubagentSet: - """The resolved set of subagents available to one beat. - - Built by the harness factory: merges Tier-1 (role-owned) and Tier-2 - (shared registry) subagents, intersects each with the parent's live - toolset/permissions, and freezes the result. - """ + """Resolved set of subagents available to one beat.""" agents: dict[str, Subagent] = field(default_factory=dict) - """name → Subagent mapping. Immutable after construction.""" def get(self, name: str) -> Subagent | None: return self.agents.get(name) @@ -168,6 +174,9 @@ def get(self, name: str) -> Subagent | None: def names(self) -> list[str]: return list(self.agents.keys()) + def descriptions(self) -> dict[str, str]: + return {name: agent.description for name, agent in self.agents.items()} + def __iter__(self) -> Iterator[Subagent]: return iter(self.agents.values()) @@ -179,3 +188,14 @@ def __len__(self) -> int: def __bool__(self) -> bool: return bool(self.agents) + + +__all__ = [ + "GENERAL_PURPOSE_DESCRIPTION", + "GENERAL_PURPOSE_NAME", + "MAX_INLINE_NESTING", + "MAX_SUBAGENT_DEPTH", + "PermissionDelta", + "Subagent", + "SubagentSet", +] diff --git a/src/dream/subagents/_delegate.py b/src/dream/subagents/_delegate.py index 12af1e29..538fa1be 100644 --- a/src/dream/subagents/_delegate.py +++ b/src/dream/subagents/_delegate.py @@ -116,7 +116,15 @@ async def run_subagent_delegate( there, never into the caller's worktree. Without it the summary is truncated with no spill. """ - workspace = str(working_dir) if working_dir is not None else None + parent_cwd = Path(working_dir) if working_dir is not None else None + scratch = Path(spill_dir) if spill_dir is not None else None + # Shared isolation: advertise the parent cwd. WORKTREE isolation rebuilds + # the prompt inside the executor once the ephemeral checkout exists. + workspace = ( + None + if agent.isolation.value == "worktree" + else (str(parent_cwd) if parent_cwd is not None else None) + ) prompt = build_child_prompt(goal, context, workspace_path=workspace) result = await run_subagent_session( agent, @@ -126,6 +134,10 @@ async def run_subagent_delegate( spawn_counter=spawn_counter, tracer=tracer, observer=observer, + working_dir=parent_cwd, + spill_dir=scratch, + goal=goal, + context=context, ) if not result.success: return result diff --git a/src/dream/subagents/_host_blocklist.py b/src/dream/subagents/_host_blocklist.py new file mode 100644 index 00000000..f1a8b98b --- /dev/null +++ b/src/dream/subagents/_host_blocklist.py @@ -0,0 +1,95 @@ +"""Host-side tool strip for subagents (Hermes DELEGATE_BLOCKED_TOOLS). + +Children inherit ``tools ∩ parent`` then lose these host-forbidden names. +The model cannot widen past this strip. +""" + +from __future__ import annotations + +# Never available on leaf children. Orchestrators that declare ``spawnable`` keep +# ``spawn_subagent`` via the inline executor; everything else here is absolute. +HOST_BLOCKED_TOOLS: frozenset[str] = frozenset( + { + "clarify", + "memory_search", + "memory_get", + "memory_propose", + "working_memory_read", + "working_memory_write", + "working_memory_append", + "send_message", + "cron_list", + "cron_show", + "cron_create", + "cron_delete", + "cron_toggle", + "remote_trigger", + "task_create", + "task_update", + # Background shell tasks stay on the parent; children poll via + # delegation_* only when the parent opted into background spawn. + "enter_worktree", + "exit_worktree", + } +) + +# Mutating file tools denied for Explore / Plan / Verify builtins. +READONLY_DENIED_TOOLS: frozenset[str] = frozenset( + { + "write_file", + "apply_patch", + "edit_file", + "bash", # can mutate; explore uses read/search only + "git", + "todo_write", + "execute_code", + "browser_run", + "spawn_subagent", + "plan_show", + } +) + +EXPLORE_TOOLS: tuple[str, ...] = ( + "read_file", + "grep", + "glob", + "web_fetch", + "web_search", + "read_offloaded", +) + +PLAN_TOOLS: tuple[str, ...] = ( + "read_file", + "grep", + "glob", + "web_fetch", + "read_offloaded", +) + +VERIFY_TOOLS: tuple[str, ...] = ( + "read_file", + "grep", + "glob", + "bash", + "run_command", + "read_offloaded", + "web_fetch", +) + + +def strip_host_blocked(tools: tuple[str, ...], *, keep_spawn: bool) -> tuple[str, ...]: + """Drop host-forbidden tools; optionally keep ``spawn_subagent``.""" + blocked = HOST_BLOCKED_TOOLS + if not keep_spawn: + blocked = blocked | frozenset({"spawn_subagent"}) + return tuple(name for name in tools if name not in blocked) + + +__all__ = [ + "EXPLORE_TOOLS", + "HOST_BLOCKED_TOOLS", + "PLAN_TOOLS", + "READONLY_DENIED_TOOLS", + "VERIFY_TOOLS", + "strip_host_blocked", +] diff --git a/src/dream/subagents/_inline_executor.py b/src/dream/subagents/_inline_executor.py index 01b6a716..adf0c9cc 100644 --- a/src/dream/subagents/_inline_executor.py +++ b/src/dream/subagents/_inline_executor.py @@ -1,26 +1,27 @@ """Delegated subagent session executor — runs a subagent as a real bounded session. -The subagent gets its own ``Harness.run_role`` session with capability- -minimized tools and runs to completion bounded by ``max_turns``. It is a -real agent that can call ``read_file``, ``grep``, ``bash``, etc. — not a -single-shot LLM call. - -Spec §09 v1: in-process, shared worktree, serial join, flat depth. +Live path: capability-minimized ``Harness.run_role`` session bounded by +``max_turns``. Shared worktree by default; optional short-lived git worktree +when ``IsolationMode.WORKTREE``. """ from __future__ import annotations import asyncio from dataclasses import replace -from typing import TYPE_CHECKING, Any +from pathlib import Path +from typing import TYPE_CHECKING from dream.api.response_format import resolve_structured_output from dream.events import ToolUseResult, ToolUseStart from dream.roles._manifest import RoleManifest from dream.session import SessionOptions -from dream.subagents._declaration import MAX_SUBAGENT_DEPTH, Subagent, SubagentSet +from dream.subagents._declaration import MAX_INLINE_NESTING, Subagent, SubagentSet +from dream.subagents._host_blocklist import strip_host_blocked +from dream.subagents._isolation import IsolationMode from dream.subagents._output_guard import enforce_output_schema from dream.subagents._projection import SubagentResult, intersect_tools +from dream.subagents._worktree import SubagentWorktree, SubagentWorktreeFactory if TYPE_CHECKING: from dream.harness import Harness @@ -28,6 +29,8 @@ SUBAGENT_NAME_METADATA_KEY = "dream.subagent_name" +SUBAGENT_OVERLAY_METADATA_KEY = "dream.subagent_permission_overlay" +SUBAGENT_WORKING_DIR_METADATA_KEY = "dream.subagent_working_dir" async def run_subagent_session( @@ -39,57 +42,77 @@ async def run_subagent_session( spawn_counter: list[int] | None = None, tracer: object | None = None, observer: RunTaskObserver | None = None, + working_dir: Path | None = None, + spill_dir: Path | None = None, + goal: str | None = None, + context: str | None = None, ) -> SubagentResult: - """Execute a subagent as a real bounded session with tools. - - Creates a synthetic ``RoleManifest`` scoped to the subagent's - capability-minimized tools and runs it through ``harness.run_role()``. The - subagent: - - - Gets a real engine session with actual tool dispatch - - Has capability-minimized tools — ``agent.tools ∩ parent_tools`` (§05: - narrower-wins; can only drop, never widen past the parent's allow-list). - ``parent_tools is None`` means the parent had no role restriction, so the - agent keeps its declared tools. - - Cannot spawn sub-subagents (``spawn_subagent`` is disallowed) - - Is bounded by ``agent.max_turns`` - - Returns plain text (concatenation of all assistant text deltas) - """ - manifest = _build_subagent_manifest(agent, parent_tools=parent_tools) - # Depth-2: an eligible spawner's child session carries a scoped set + the shared spawn counter - # so it can dispatch its declared ``spawnable`` (the factory prefers these incoming keys). A leaf - # gets ``{}`` → unchanged. - child_metadata = build_child_spawn_metadata( - agent, - counter=spawn_counter if spawn_counter is not None else [0], - harness=harness, - tracer=tracer, - parent_tools=parent_tools, - ) - child_metadata[SUBAGENT_NAME_METADATA_KEY] = agent.name - response_format = None - if agent.output_schema is not None: - response_format = resolve_structured_output( - schema=agent.output_schema, - name=f"{agent.name}_output", - strict=agent.strict, - ) - options = SessionOptions( - max_turns=agent.max_turns, - response_format=response_format, - metadata=child_metadata, - ) + """Execute a subagent as a real bounded session with tools.""" + from dream.subagents._delegate import build_child_prompt + worktree: SubagentWorktree | None = None + child_cwd = working_dir + effective_prompt = prompt try: + if agent.isolation is IsolationMode.WORKTREE: + if spill_dir is None or working_dir is None: + return SubagentResult( + name=agent.name, + output="", + success=False, + error=( + "IsolationMode.WORKTREE requires parent working_dir and " + "session scratch_dir" + ), + turns_used=0, + ) + factory = SubagentWorktreeFactory( + scratch_dir=spill_dir, + parent_cwd=working_dir, + ) + worktree = factory.create(agent.name) + child_cwd = worktree.path + if goal is not None: + effective_prompt = build_child_prompt( + goal, + context, + workspace_path=str(child_cwd), + ) + + manifest = _build_subagent_manifest(agent, parent_tools=parent_tools) + child_metadata = build_child_spawn_metadata( + agent, + counter=spawn_counter if spawn_counter is not None else [0], + harness=harness, + tracer=tracer, + parent_tools=parent_tools, + ) + child_metadata[SUBAGENT_NAME_METADATA_KEY] = agent.name + if agent.permission_overlay: + child_metadata[SUBAGENT_OVERLAY_METADATA_KEY] = agent.permission_overlay + if child_cwd is not None: + child_metadata[SUBAGENT_WORKING_DIR_METADATA_KEY] = str(child_cwd) + + response_format = None + if agent.output_schema is not None: + response_format = resolve_structured_output( + schema=agent.output_schema, + name=f"{agent.name}_output", + strict=agent.strict, + ) + options = SessionOptions( + model=agent.model, + max_turns=agent.max_turns, + response_format=response_format, + metadata=child_metadata, + ) + result = await harness.run_role( manifest, - prompt, + effective_prompt, options=options, - # Forward the parent observer so this child's events (including any nested spawn) reach - # the same observer/bus — depth-2 visibility. ``None`` keeps the child stream isolated. observer=observer, ) - # Count tool calls from the event stream for observability tool_calls = sum(1 for ev in result.events if isinstance(ev, ToolUseStart)) tool_errors = sum( 1 for ev in result.events if isinstance(ev, ToolUseResult) and ev.is_error @@ -118,6 +141,9 @@ async def run_subagent_session( error=f"{type(exc).__name__}: {exc}", turns_used=0, ) + finally: + if worktree is not None: + worktree.remove() def build_child_spawn_metadata( @@ -127,15 +153,8 @@ def build_child_spawn_metadata( harness: Harness | object, tracer: object | None, parent_tools: frozenset[str] | None, -) -> dict[str, Any]: - """The ``SessionOptions.metadata`` a spawn-eligible child is handed (depth-2). - - Returns ``{}`` for a leaf (no seeding, unchanged). For an eligible spawner it carries a *scoped* - subagent set (its declared ``spawnable``, one depth deeper, each tool-intersected with the - spawner's own effective tools so a grandchild can only narrow) plus the *parent's* spawn - ``counter`` (same object → the per-beat cap spans the whole tree), harness, tracer, and the - spawner's effective tools as the grandchild's parent allow-list. - """ +) -> dict[str, object]: + """Session metadata for a spawn-eligible child (depth-2). Leaves get ``{}``.""" from dream.tools.builtin.spawn_subagent import ( HARNESS_KEY, PARENT_TOOLS_KEY, @@ -159,7 +178,7 @@ def build_child_spawn_metadata( for child in agent.spawnable } ) - metadata: dict[str, Any] = { + metadata: dict[str, object] = { SUBAGENT_SET_CONTEXT_KEY: scoped, SPAWN_COUNT_KEY: counter, HARNESS_KEY: harness, @@ -171,28 +190,16 @@ def build_child_spawn_metadata( def _can_spawn(agent: Subagent) -> bool: - """Whether this subagent may itself dispatch children — depth-2, bounded. - - Eligible = it declares ``spawnable`` children AND sits below the depth cap. A leaf (no - ``spawnable``) or a grandchild at the cap is never eligible: ``spawn_subagent`` stays disallowed - exactly as v1. - """ - return bool(agent.spawnable) and agent.depth < MAX_SUBAGENT_DEPTH + return bool(agent.spawnable) and agent.depth < MAX_INLINE_NESTING def _build_subagent_manifest( agent: Subagent, *, parent_tools: frozenset[str] | None = None ) -> RoleManifest: - """Build a synthetic RoleManifest for the subagent. - - Uses the ``generator`` role name (it needs tools) with the subagent's - capability-minimized tool allow-list — ``agent.tools ∩ parent_tools`` (§05: - narrower-wins, can only drop, never widen past the parent). ``spawn_subagent`` - is disallowed for a leaf; a spawn-eligible child (:func:`_can_spawn`) keeps it so it can - dispatch its declared ``spawnable`` set (depth-2, bounded). - """ + """Synthetic RoleManifest: tools ∩ parent, host blocklist, spawn allow/deny.""" effective_tools = intersect_tools(agent.tools, parent_tools) can_spawn = _can_spawn(agent) + effective_tools = strip_host_blocked(effective_tools, keep_spawn=can_spawn) spawn_note = ( "You may dispatch your declared subagent(s) with spawn_subagent when it helps." diff --git a/src/dream/subagents/_isolation.py b/src/dream/subagents/_isolation.py new file mode 100644 index 00000000..a34f3dc6 --- /dev/null +++ b/src/dream/subagents/_isolation.py @@ -0,0 +1,19 @@ +"""Filesystem isolation mode for a subagent session.""" + +from __future__ import annotations + +from enum import StrEnum + + +class IsolationMode(StrEnum): + """Where a child session's tools run. + + ``SHARED`` — same worktree as the parent (Hermes default; cheap). + ``WORKTREE`` — short-lived git worktree under scratch; torn down after join. + """ + + SHARED = "shared" + WORKTREE = "worktree" + + +__all__ = ["IsolationMode"] diff --git a/src/dream/subagents/_overlay_gate.py b/src/dream/subagents/_overlay_gate.py new file mode 100644 index 00000000..d11f64a6 --- /dev/null +++ b/src/dream/subagents/_overlay_gate.py @@ -0,0 +1,46 @@ +"""Tighten-only permission overlay applied to a child session gate.""" + +from __future__ import annotations + +from dream.engine._tool_dispatch import PermissionGate +from dream.permissions import Outcome, PermissionDecision, PermissionRequest +from dream.subagents._declaration import PermissionDelta + +# Capability tokens understood by the overlay (see dream.permissions PermissionEffect). +_WRITE_TOKENS: frozenset[str] = frozenset({"write", "repo-write", "repo-write+net-allowlist"}) + + +def wrap_permission_gate( + parent_gate: PermissionGate, + overlay: PermissionDelta, +) -> PermissionGate: + """Return a gate that denies overlay tokens, then consults ``parent_gate``. + + Overlay entries that look like tool names deny those tools. Entries in + ``_WRITE_TOKENS`` deny any non-read-only request. + """ + if not overlay: + return parent_gate + + deny_tools = frozenset(token for token in overlay if token not in _WRITE_TOKENS) + deny_writes = bool(_WRITE_TOKENS.intersection(overlay)) + + def gate(request: PermissionRequest) -> PermissionDecision: + if request.tool_name in deny_tools: + return PermissionDecision( + outcome=Outcome.DENY, + reason=f"subagent permission_overlay denies tool {request.tool_name!r}", + rule="subagent_permission_overlay", + ) + if deny_writes and not request.is_read_only: + return PermissionDecision( + outcome=Outcome.DENY, + reason="subagent permission_overlay denies write effects", + rule="subagent_permission_overlay", + ) + return parent_gate(request) + + return gate + + +__all__ = ["wrap_permission_gate"] diff --git a/src/dream/subagents/_worktree.py b/src/dream/subagents/_worktree.py new file mode 100644 index 00000000..c20dd92a --- /dev/null +++ b/src/dream/subagents/_worktree.py @@ -0,0 +1,70 @@ +"""Short-lived git worktrees for ``IsolationMode.WORKTREE`` subagents. + +Adapted from OpenHarness ``WorktreeManager`` / Dream ``enter_worktree``: one +create + remove pair per child session, paths confined under the session scratch +dir so they never land in the parent's durable worktree. +""" + +from __future__ import annotations + +import re +import uuid +from dataclasses import dataclass +from pathlib import Path + +from dream.utils.git import run_git + + +@dataclass(frozen=True) +class SubagentWorktree: + """One isolated checkout owned by a single subagent run.""" + + path: Path + branch: str + repo_root: Path + + def remove(self) -> None: + """Force-remove the worktree and delete its ephemeral branch.""" + run_git( + ["worktree", "remove", "--force", str(self.path)], + cwd=self.repo_root, + ) + run_git(["branch", "-D", self.branch], cwd=self.repo_root) + + +@dataclass(frozen=True) +class SubagentWorktreeFactory: + """Mint worktrees under ``scratch_dir / subagent-worktrees``.""" + + scratch_dir: Path + parent_cwd: Path + + def create(self, agent_name: str) -> SubagentWorktree: + rc, top_level, err = run_git( + ["rev-parse", "--show-toplevel"], + cwd=self.parent_cwd, + ) + if rc != 0 or not top_level: + raise RuntimeError( + f"worktree isolation requires a git repository: {err or 'rev-parse failed'}" + ) + repo_root = Path(top_level) + slug = _safe_slug(agent_name) + branch = f"dream-subagent/{slug}-{uuid.uuid4().hex[:8]}" + path = (self.scratch_dir / "subagent-worktrees" / branch.replace("/", "+")).resolve() + path.parent.mkdir(parents=True, exist_ok=True) + rc, out, err = run_git( + ["worktree", "add", "-b", branch, str(path), "HEAD"], + cwd=repo_root, + ) + if rc != 0: + raise RuntimeError(f"git worktree add failed: {err or out}") + return SubagentWorktree(path=path, branch=branch, repo_root=repo_root) + + +def _safe_slug(name: str) -> str: + slug = re.sub(r"[^\w\-]+", "-", name.strip().lower()).strip("-") + return (slug or "agent")[:48] + + +__all__ = ["SubagentWorktree", "SubagentWorktreeFactory"] diff --git a/src/dream/tools/builtin/delegation_get.py b/src/dream/tools/builtin/delegation_get.py new file mode 100644 index 00000000..75d8614b --- /dev/null +++ b/src/dream/tools/builtin/delegation_get.py @@ -0,0 +1,65 @@ +"""``delegation_get`` — poll a background ``spawn_subagent`` handle. + +OpenHarness ``task_output`` / Hermes lifecycle ``status`` adapted to Dream's +``AsyncDelegationManager``. Only useful after ``background=true``. +""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, Field + +from dream.contracts.tool import ToolResult +from dream.tools._base import BaseTool, ToolDeclaration +from dream.tools._context import ToolExecutionContext + + +class DelegationGetInput(BaseModel): + delegation_id: str = Field(description="Id returned by background spawn_subagent.") + + +class DelegationGetTool(BaseTool): + name = "delegation_get" + description = ( + "Poll a background spawn_subagent delegation by id. " + "Returns status and any completed summary." + ) + declaration = ToolDeclaration(risk="safe", tier_required=0, timeout_seconds=5.0) + input_model = DelegationGetInput + + async def execute(self, input: dict[str, Any], ctx: ToolExecutionContext) -> ToolResult: + args = DelegationGetInput.model_validate(input) + if ctx.delegations is None: + return ToolResult( + content="No async delegation manager on this session.", + is_error=True, + ) + snap = ctx.delegations.get(args.delegation_id) + if snap is None: + return ToolResult( + content=f"Unknown delegation_id {args.delegation_id!r}.", + is_error=True, + ) + lines = [ + f"delegation_id={snap.delegation_id}", + f"status={snap.status.value}", + f"subagents={','.join(snap.subagent_names)}", + ] + if snap.error: + lines.append(f"error={snap.error}") + for result in snap.results: + state = "ok" if result.success else "failed" + body = result.output if result.success else (result.error or "") + lines.append(f"- {result.name}: {state}\n{body}") + return ToolResult( + content="\n".join(lines), + metadata={ + "delegation_id": snap.delegation_id, + "status": snap.status.value, + "summary": f"delegation {snap.delegation_id} is {snap.status.value}", + }, + ) + + +__all__ = ["DelegationGetInput", "DelegationGetTool"] diff --git a/src/dream/tools/builtin/delegation_stop.py b/src/dream/tools/builtin/delegation_stop.py new file mode 100644 index 00000000..59ad3c76 --- /dev/null +++ b/src/dream/tools/builtin/delegation_stop.py @@ -0,0 +1,47 @@ +"""``delegation_stop`` — cancel a background ``spawn_subagent`` handle.""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, Field + +from dream.contracts.tool import ToolResult +from dream.tools._base import BaseTool, ToolDeclaration +from dream.tools._context import ToolExecutionContext + + +class DelegationStopInput(BaseModel): + delegation_id: str = Field(description="Id returned by background spawn_subagent.") + + +class DelegationStopTool(BaseTool): + name = "delegation_stop" + description = "Stop a running background spawn_subagent delegation by id." + declaration = ToolDeclaration(risk="mutating", tier_required=0, timeout_seconds=10.0) + input_model = DelegationStopInput + + async def execute(self, input: dict[str, Any], ctx: ToolExecutionContext) -> ToolResult: + args = DelegationStopInput.model_validate(input) + if ctx.delegations is None: + return ToolResult( + content="No async delegation manager on this session.", + is_error=True, + ) + snap = await ctx.delegations.stop(args.delegation_id) + if snap is None: + return ToolResult( + content=f"Unknown delegation_id {args.delegation_id!r}.", + is_error=True, + ) + return ToolResult( + content=f"delegation {snap.delegation_id} status={snap.status.value}", + metadata={ + "delegation_id": snap.delegation_id, + "status": snap.status.value, + "summary": f"stopped delegation {snap.delegation_id}", + }, + ) + + +__all__ = ["DelegationStopInput", "DelegationStopTool"] diff --git a/src/dream/tools/builtin/spawn_subagent.py b/src/dream/tools/builtin/spawn_subagent.py index 8a5b4168..a2c0bfce 100644 --- a/src/dream/tools/builtin/spawn_subagent.py +++ b/src/dream/tools/builtin/spawn_subagent.py @@ -21,12 +21,20 @@ from dream.contracts.hook import SubagentJoinMode from dream.contracts.tool import ToolResult from dream.observability._tracer import Tracer +from dream.subagents._builtins import ( + EXPLORE, + PLAN, + VERIFY, + merge_builtins, + spawn_catalog_names, +) from dream.subagents._declaration import ( GENERAL_PURPOSE_DESCRIPTION, GENERAL_PURPOSE_NAME, Subagent, SubagentSet, ) +from dream.subagents._inline_executor import SUBAGENT_NAME_METADATA_KEY from dream.subagents._projection import SubagentResult from dream.tools._base import BaseTool, ToolDeclaration from dream.tools._context import ToolExecutionContext @@ -53,10 +61,6 @@ GENERAL_PURPOSE = GENERAL_PURPOSE_NAME -class SpawnJoinMode(StrEnum): - DELEGATE = "delegate" - - class SpawnDispatchStatus(StrEnum): DISPATCHED = "dispatched" COMPLETED = "completed" @@ -74,9 +78,30 @@ class SpawnDispatchStatus(StrEnum): def spawn_type_names(subagent_set: SubagentSet | None) -> tuple[str, ...]: - """Enum values for this beat: generalPurpose first, then Spec names.""" - names = tuple(subagent_set.names()) if subagent_set is not None else () - return (GENERAL_PURPOSE_NAME, *names) + """Enum values: generalPurpose, builtins, then remaining role names.""" + return spawn_catalog_names(subagent_set) + + +def resolve_agent( + type_name: str, + *, + subagent_set: SubagentSet | None, + parent_tools: frozenset[str] | None, + parent_name: str | None, +) -> Subagent | None: + """Resolve a fail-closed catalog entry; enforce ``spawned_by`` when set. + + Role overrides of ``explore`` / ``plan`` / ``verify`` win: the merged + catalogue is consulted first so advertised names match the live resolver. + """ + if type_name == GENERAL_PURPOSE_NAME: + return general_purpose_agent(parent_tools) + agent = merge_builtins(subagent_set).get(type_name) + if agent is None: + return None + if agent.spawned_by and (parent_name is None or parent_name not in agent.spawned_by): + return None + return agent def spawn_label_from_input(tool_input: Mapping[str, Any]) -> str: @@ -296,6 +321,8 @@ async def execute(self, input: dict[str, Any], ctx: ToolExecutionContext) -> Too subagent_set: SubagentSet | None = ctx.metadata.get(SUBAGENT_SET_CONTEXT_KEY) available = spawn_type_names(subagent_set) parent_tools: frozenset[str] | None = ctx.metadata.get(PARENT_TOOLS_KEY) + parent_name_raw = ctx.metadata.get(SUBAGENT_NAME_METADATA_KEY) + parent_name = parent_name_raw if isinstance(parent_name_raw, str) else None requested = ( args.tasks if args.tasks is not None @@ -310,11 +337,12 @@ async def execute(self, input: dict[str, Any], ctx: ToolExecutionContext) -> Too resolved: list[_ResolvedTask] = [] for task in requested: type_name = task.subagent_type.strip() - agent: Subagent | None - if type_name == GENERAL_PURPOSE: - agent = general_purpose_agent(parent_tools) - else: - agent = subagent_set.get(type_name) if subagent_set is not None else None + agent = resolve_agent( + type_name, + subagent_set=subagent_set, + parent_tools=parent_tools, + parent_name=parent_name, + ) if agent is None: return unknown_subagent_result(type_name, available) resolved.append( @@ -355,8 +383,9 @@ async def execute(self, input: dict[str, Any], ctx: ToolExecutionContext) -> Too ledger = SpawnLedger() ctx.metadata[SPAWN_LEDGER_KEY] = ledger names = tuple(task.agent.name for task in resolved) - # generalPurpose is repeatable ad-hoc work; evidence specialists remain one-shot. - ledger_names = tuple(name for name in names if name != GENERAL_PURPOSE) + # Builtins + generalPurpose are repeatable; role specialists remain one-shot. + _repeatable = frozenset({GENERAL_PURPOSE, EXPLORE, PLAN, VERIFY}) + ledger_names = tuple(name for name in names if name not in _repeatable) duplicate = ledger.claim(ledger_names) if duplicate is not None: return ToolResult( @@ -509,7 +538,7 @@ async def run_all() -> tuple[SubagentResult, ...]: "turns_used": result.turns_used, "tool_calls": result.tool_calls, "tool_errors": result.tool_errors, - "mode": SpawnJoinMode.DELEGATE.value, + "mode": SubagentJoinMode.SYNC.value, "join_mode": SubagentJoinMode.SYNC.value, "background_forced_sync": bool(forced_sync_note), }, diff --git a/tests/test_subagents/test_async_delegation.py b/tests/test_subagents/test_async_delegation.py index ba7e514b..0bcd3546 100644 --- a/tests/test_subagents/test_async_delegation.py +++ b/tests/test_subagents/test_async_delegation.py @@ -86,8 +86,8 @@ async def work() -> tuple[SubagentResult, ...]: await manager.cancel_session("parent") completion = await waiter - assert completion.status is DelegationStatus.FAILED - assert completion.error == "background delegation cancelled" + assert completion.status is DelegationStatus.STOPPED + assert completion.error == "background delegation stopped" await manager.close() diff --git a/tests/test_subagents/test_builtins.py b/tests/test_subagents/test_builtins.py new file mode 100644 index 00000000..9825ed92 --- /dev/null +++ b/tests/test_subagents/test_builtins.py @@ -0,0 +1,84 @@ +"""Harness builtin catalog: explore / plan / verify.""" + +from __future__ import annotations + +from dream.subagents import ( + EXPLORE, + GENERAL_PURPOSE, + IsolationMode, + PLAN, + Subagent, + SubagentSet, + VERIFY, + builtin_agents, + merge_builtins, +) +from dream.subagents._builtins import spawn_catalog_names +from dream.subagents._host_blocklist import READONLY_DENIED_TOOLS +from dream.tools.builtin.spawn_subagent import resolve_agent, spawn_type_names + + +class TestBuiltins: + def test_builtin_names(self) -> None: + names = {agent.name for agent in builtin_agents()} + assert names == {EXPLORE, PLAN, VERIFY} + + def test_explore_is_read_only(self) -> None: + explore = next(agent for agent in builtin_agents() if agent.name == EXPLORE) + assert explore.isolation is IsolationMode.SHARED + assert not READONLY_DENIED_TOOLS.intersection(explore.tools) + + def test_verify_is_strict(self) -> None: + verify = next(agent for agent in builtin_agents() if agent.name == VERIFY) + assert verify.strict is True + assert verify.output_schema is not None + + def test_merge_role_wins_on_collision(self) -> None: + custom = Subagent( + name=EXPLORE, + description="role override", + tools=("read_file",), + ) + merged = merge_builtins(SubagentSet(agents={EXPLORE: custom})) + assert merged.get(EXPLORE) is not None + assert merged.get(EXPLORE).description == "role override" # type: ignore[union-attr] + + def test_catalog_enum_order(self) -> None: + names = spawn_catalog_names(None) + assert names[:4] == (GENERAL_PURPOSE, EXPLORE, PLAN, VERIFY) + + def test_spawn_type_names_includes_builtins(self) -> None: + assert EXPLORE in spawn_type_names(None) + assert PLAN in spawn_type_names(SubagentSet()) + + def test_resolve_explore(self) -> None: + agent = resolve_agent( + EXPLORE, + subagent_set=None, + parent_tools=frozenset({"read_file", "grep", "write_file"}), + parent_name=None, + ) + assert agent is not None + assert agent.name == EXPLORE + + def test_spawned_by_enforced(self) -> None: + gated = Subagent( + name="nested_only", + description="tier-2", + tools=("read_file",), + spawned_by=("orchestrator",), + ) + agent = resolve_agent( + "nested_only", + subagent_set=SubagentSet(agents={"nested_only": gated}), + parent_tools=frozenset({"read_file"}), + parent_name=None, + ) + assert agent is None + agent = resolve_agent( + "nested_only", + subagent_set=SubagentSet(agents={"nested_only": gated}), + parent_tools=frozenset({"read_file"}), + parent_name="orchestrator", + ) + assert agent is not None diff --git a/tests/test_subagents/test_declaration.py b/tests/test_subagents/test_declaration.py index 9fc68092..a4f999b1 100644 --- a/tests/test_subagents/test_declaration.py +++ b/tests/test_subagents/test_declaration.py @@ -4,6 +4,7 @@ import pytest +from dream.subagents import IsolationMode from dream.subagents._declaration import Subagent, SubagentSet @@ -56,10 +57,12 @@ def test_round_trip_dict(self) -> None: spawned_by=("analyst",), system_prompt="You are a SQL expert.", max_turns=4, + isolation=IsolationMode.WORKTREE, ) d = agent.to_dict() restored = Subagent.from_dict(d) assert restored == agent + assert restored.isolation is IsolationMode.WORKTREE def test_from_dict_defaults(self) -> None: d = {"name": "test", "description": "test", "tools": ["x"]} diff --git a/tests/test_subagents/test_spawn_enum.py b/tests/test_subagents/test_spawn_enum.py index b6ce6380..9fa8ce31 100644 --- a/tests/test_subagents/test_spawn_enum.py +++ b/tests/test_subagents/test_spawn_enum.py @@ -6,6 +6,7 @@ from typing import Any from unittest.mock import AsyncMock, patch +from dream.subagents import EXPLORE, PLAN, VERIFY from dream.subagents._declaration import Subagent, SubagentSet from dream.subagents._projection import SubagentResult from dream.tools._context import ToolExecutionContext @@ -45,22 +46,29 @@ def _set() -> SubagentSet: def test_spawn_type_names_includes_general_purpose_first() -> None: - assert spawn_type_names(_set()) == (GENERAL_PURPOSE, "reviewer") - assert spawn_type_names(SubagentSet()) == (GENERAL_PURPOSE,) - assert spawn_type_names(None) == (GENERAL_PURPOSE,) + assert spawn_type_names(_set()) == ( + GENERAL_PURPOSE, + EXPLORE, + PLAN, + VERIFY, + "reviewer", + ) + assert spawn_type_names(SubagentSet()) == (GENERAL_PURPOSE, EXPLORE, PLAN, VERIFY) + assert spawn_type_names(None) == (GENERAL_PURPOSE, EXPLORE, PLAN, VERIFY) def test_build_spawn_parameters_sets_enum_only() -> None: base = SpawnSubagentTool().input_schema() patched = build_spawn_parameters(base, _set()) prop = patched["properties"]["subagent_type"] - assert prop["enum"] == [GENERAL_PURPOSE, "reviewer"] + expected = [GENERAL_PURPOSE, EXPLORE, PLAN, VERIFY, "reviewer"] + assert prop["enum"] == expected assert prop["description"] == "Name from Subagent definitions." assert "WHEN TO USE" not in prop["description"] assert "Reviews code" not in prop["description"] assert patched["$defs"]["SpawnTaskInput"]["properties"]["subagent_type"][ "enum" - ] == [GENERAL_PURPOSE, "reviewer"] + ] == expected async def test_unknown_type_fails_with_available_enum() -> None: @@ -110,7 +118,7 @@ async def test_general_purpose_uses_delegate_path() -> None: ) assert not result.is_error assert "summary" in result.content - assert result.metadata["mode"] == "delegate" + assert result.metadata["mode"] == "sync" assert result.metadata["subagent_name"] == GENERAL_PURPOSE mock_delegate.assert_awaited_once() agent = mock_delegate.await_args.args[0] From 985530b9b5a22a108749655ce87805a8437e2c00 Mon Sep 17 00:00:00 2001 From: divo12 Date: Sun, 16 Aug 2026 20:30:04 +0530 Subject: [PATCH 2/2] fix(subagents): enforce typed isolation boundaries Scope asynchronous delegation to its owning session and make overlays and worktree execution fail closed so the lean subagent catalog is safe to expose. Co-authored-by: Cursor --- consumer-facing-api/HARNESS.md | 4 +- .../2026-08-09-lean-subagent-redesign.md | 4 +- src/dream/_factory.py | 25 +-- src/dream/subagents/__init__.py | 10 +- src/dream/subagents/_async_delegation.py | 92 ++++++++--- src/dream/subagents/_builtins.py | 2 +- src/dream/subagents/_catalogue.py | 18 +- src/dream/subagents/_declaration.py | 38 +++-- src/dream/subagents/_delegate.py | 13 +- src/dream/subagents/_host_blocklist.py | 8 + src/dream/subagents/_inline_executor.py | 14 +- src/dream/subagents/_isolation.py | 7 +- src/dream/subagents/_overlay.py | 119 +++++++++++++ src/dream/subagents/_overlay_gate.py | 75 +++++++-- src/dream/subagents/_worktree.py | 35 +++- src/dream/tools/builtin/delegation_get.py | 2 +- src/dream/tools/builtin/delegation_stop.py | 2 +- src/dream/tools/builtin/spawn_subagent.py | 13 +- tests/test_subagents/test_builtins.py | 20 ++- tests/test_subagents/test_catalogue.py | 37 ++++- .../test_child_spawn_metadata.py | 16 +- tests/test_subagents/test_declaration.py | 2 +- .../test_delegation_ownership.py | 121 ++++++++++++++ tests/test_subagents/test_inline_executor.py | 4 +- tests/test_subagents/test_overlay_gate.py | 102 ++++++++++++ tests/test_subagents/test_spawn_enum.py | 4 +- tests/test_subagents/test_worktree.py | 156 ++++++++++++++++++ 27 files changed, 825 insertions(+), 118 deletions(-) create mode 100644 src/dream/subagents/_overlay.py create mode 100644 tests/test_subagents/test_delegation_ownership.py create mode 100644 tests/test_subagents/test_overlay_gate.py create mode 100644 tests/test_subagents/test_worktree.py diff --git a/consumer-facing-api/HARNESS.md b/consumer-facing-api/HARNESS.md index 48b72f33..d2de7de2 100644 --- a/consumer-facing-api/HARNESS.md +++ b/consumer-facing-api/HARNESS.md @@ -179,7 +179,9 @@ teammates dispatched mid-beat. **Data model** (`src/dream/subagents/`): - `Subagent` — frozen declaration: name, description, tools (⊆ parent), skills, - permission_overlay (tighten-only), depth (v1: always 1), model override, max_turns. + permission_overlay (typed tighten-only capability/tool removals), depth + (v1: always 1), model override, max_turns, isolation (`shared` or ephemeral + `worktree` — child cwd confined; edits discarded on join). - `SubagentSet` — resolved {name → Subagent} for one beat, built from Tier-1 (role-owned) + Tier-2 (shared `SubagentRegistry`) agents. - `SubagentRegistry` — kernel-level registry for Tier-2 shared capability agents. diff --git a/docs/designs/2026-08-09-lean-subagent-redesign.md b/docs/designs/2026-08-09-lean-subagent-redesign.md index 1eeed288..3f8bb4a0 100644 --- a/docs/designs/2026-08-09-lean-subagent-redesign.md +++ b/docs/designs/2026-08-09-lean-subagent-redesign.md @@ -25,7 +25,9 @@ Role specialists **add** names; they do not remove builtins. Unknown types refus - `model` → `SessionOptions.model` - `permission_overlay` → tighten-only child gate wrapper - `spawned_by` → fail-closed at resolve -- `isolation` → `shared` | `worktree` (ephemeral git worktree under scratch) +- `isolation` → `shared` | `worktree` (ephemeral git worktree under scratch; + child permission cwd is the worktree; edits are discarded on join and never + merge back to the parent) ## Host blocklist (Hermes) diff --git a/src/dream/_factory.py b/src/dream/_factory.py index 52dda726..41ba3788 100644 --- a/src/dream/_factory.py +++ b/src/dream/_factory.py @@ -689,9 +689,7 @@ def _build_session_engine( memory_catalogue=memory_catalogue, agents_md=load_agents_md(working_dir), tool_catalogue=tool_catalogue.render() if tool_catalogue is not None else "", - subagent_catalogue=( - subagent_catalogue.render() if subagent_catalogue is not None else "" - ), + subagent_catalogue=(subagent_catalogue.render() if subagent_catalogue is not None else ""), ) prompt_surfaces = PromptSurfaces( stable=stable_block, @@ -805,17 +803,24 @@ def _build_session_engine( SUBAGENT_OVERLAY_METADATA_KEY, SUBAGENT_WORKING_DIR_METADATA_KEY, ) - from dream.subagents._overlay_gate import wrap_permission_gate + from dream.subagents._overlay import PermissionOverlay + from dream.subagents._overlay_gate import confine_permission_gate, wrap_permission_gate session_working_dir = working_dir override_cwd = options.metadata.get(SUBAGENT_WORKING_DIR_METADATA_KEY) - if isinstance(override_cwd, str) and override_cwd: + if isinstance(override_cwd, Path): + session_working_dir = override_cwd + elif isinstance(override_cwd, str) and override_cwd: session_working_dir = Path(override_cwd) - overlay = options.metadata.get(SUBAGENT_OVERLAY_METADATA_KEY) child_gate = permission_gate - if isinstance(overlay, tuple) and overlay: - child_gate = wrap_permission_gate(permission_gate, overlay) + if session_working_dir != working_dir: + child_gate, _ = make_permission_gate(tool_registry, paths=paths, cwd=session_working_dir) + child_gate = confine_permission_gate(child_gate, session_working_dir) + + overlay = options.metadata.get(SUBAGENT_OVERLAY_METADATA_KEY) + if isinstance(overlay, PermissionOverlay) and overlay: + child_gate = wrap_permission_gate(child_gate, overlay) context_metadata[PARENT_PERMISSIONS_KEY] = child_gate # The run_role observer (when present) rides into the tool context, so the spawn tool can @@ -824,9 +829,7 @@ def _build_session_engine( context_metadata[OBSERVER_KEY] = options.metadata[OBSERVER_KEY] inherited_set = options.metadata.get(SUBAGENT_SET_CONTEXT_KEY) - effective_subagents = ( - inherited_set if isinstance(inherited_set, SubagentSet) else subagents - ) + effective_subagents = inherited_set if isinstance(inherited_set, SubagentSet) else subagents # Wire even when empty so generalPurpose can run without Spec templates. if effective_subagents is not None: context_metadata[SUBAGENT_SET_CONTEXT_KEY] = effective_subagents diff --git a/src/dream/subagents/__init__.py b/src/dream/subagents/__init__.py index f62966fd..2cd9401a 100644 --- a/src/dream/subagents/__init__.py +++ b/src/dream/subagents/__init__.py @@ -25,6 +25,7 @@ MAX_INLINE_NESTING, MAX_SUBAGENT_DEPTH, PermissionDelta, + PermissionOverlay, Subagent, SubagentSet, ) @@ -33,27 +34,28 @@ from dream.subagents._registry import SubagentRegistry __all__ = [ + "EXPLORE", + "GENERAL_PURPOSE", "GENERAL_PURPOSE_DESCRIPTION", "GENERAL_PURPOSE_NAME", "MAX_INLINE_NESTING", "MAX_SUBAGENT_DEPTH", + "PLAN", + "VERIFY", "AsyncDelegationManager", "DelegationCompletion", "DelegationHandle", "DelegationSnapshot", "DelegationStatus", - "EXPLORE", - "GENERAL_PURPOSE", "IsolationMode", - "PLAN", "PermissionDelta", + "PermissionOverlay", "Subagent", "SubagentCatalogue", "SubagentCatalogueEntry", "SubagentRegistry", "SubagentResult", "SubagentSet", - "VERIFY", "builtin_agents", "merge_builtins", "project_subagent", diff --git a/src/dream/subagents/_async_delegation.py b/src/dream/subagents/_async_delegation.py index b0a56609..79080921 100644 --- a/src/dream/subagents/_async_delegation.py +++ b/src/dream/subagents/_async_delegation.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import contextlib from collections import defaultdict, deque from collections.abc import Awaitable, Callable from dataclasses import dataclass @@ -11,6 +12,8 @@ from dream.subagents._projection import SubagentResult +DEFAULT_MAX_HISTORY = 64 + class DelegationStatus(StrEnum): DISPATCHED = "dispatched" @@ -71,16 +74,26 @@ class _ActiveDelegation: class AsyncDelegationManager: """Own background work and queue its completion for the parent session.""" - def __init__(self, *, max_active: int = 3, timeout_seconds: float = 300.0) -> None: + def __init__( + self, + *, + max_active: int = 3, + timeout_seconds: float = 300.0, + max_history: int = DEFAULT_MAX_HISTORY, + ) -> None: if max_active < 1: raise ValueError("max_active must be >= 1") if timeout_seconds <= 0: raise ValueError("timeout_seconds must be > 0") + if max_history < 1: + raise ValueError("max_history must be >= 1") self._max_active = max_active self._timeout_seconds = timeout_seconds + self._max_history = max_history self._active: dict[str, _ActiveDelegation] = {} self._completed: defaultdict[str, deque[DelegationCompletion]] = defaultdict(deque) self._history: dict[str, DelegationSnapshot] = {} + self._history_order: defaultdict[str, deque[str]] = defaultdict(deque) self._ready: defaultdict[str, asyncio.Event] = defaultdict(asyncio.Event) def start( @@ -98,11 +111,13 @@ def start( task=task, subagent_names=subagent_names, ) - self._history[delegation_id] = DelegationSnapshot( - delegation_id=delegation_id, - session_id=session_id, - status=DelegationStatus.DISPATCHED, - subagent_names=subagent_names, + self._record_snapshot( + DelegationSnapshot( + delegation_id=delegation_id, + session_id=session_id, + status=DelegationStatus.DISPATCHED, + subagent_names=subagent_names, + ) ) return DelegationHandle( delegation_id=delegation_id, @@ -113,21 +128,25 @@ def start( def active(self, session_id: str) -> int: return sum(item.session_id == session_id for item in self._active.values()) - def get(self, delegation_id: str) -> DelegationSnapshot | None: - return self._history.get(delegation_id) + def get(self, delegation_id: str, *, session_id: str) -> DelegationSnapshot | None: + snap = self._history.get(delegation_id) + if snap is None or snap.session_id != session_id: + return None + return snap def list_for_session(self, session_id: str) -> tuple[DelegationSnapshot, ...]: - return tuple( - snap for snap in self._history.values() if snap.session_id == session_id - ) + return tuple(snap for snap in self._history.values() if snap.session_id == session_id) - async def stop(self, delegation_id: str) -> DelegationSnapshot | None: + async def stop(self, delegation_id: str, *, session_id: str) -> DelegationSnapshot | None: + snap = self.get(delegation_id, session_id=session_id) + if snap is None: + return None active = self._active.get(delegation_id) if active is None: - return self._history.get(delegation_id) + return snap active.task.cancel() await asyncio.gather(active.task, return_exceptions=True) - return self._history.get(delegation_id) + return self.get(delegation_id, session_id=session_id) def drain(self, session_id: str) -> tuple[DelegationCompletion, ...]: queue = self._completed[session_id] @@ -147,9 +166,7 @@ async def wait_next(self, session_id: str) -> DelegationCompletion: await ready.wait() async def cancel_session(self, session_id: str) -> None: - tasks = [ - item.task for item in self._active.values() if item.session_id == session_id - ] + tasks = [item.task for item in self._active.values() if item.session_id == session_id] for task in tasks: task.cancel() if tasks: @@ -205,19 +222,46 @@ async def _run( finally: self._active.pop(delegation_id, None) self._ready[session_id].set() - self._history[delegation_id] = DelegationSnapshot( - delegation_id=delegation_id, - session_id=session_id, - status=completion.status, - subagent_names=names, - results=completion.results, - error=completion.error, + self._record_snapshot( + DelegationSnapshot( + delegation_id=delegation_id, + session_id=session_id, + status=completion.status, + subagent_names=names, + results=completion.results, + error=completion.error, + ) ) self._completed[session_id].append(completion) self._ready[session_id].set() + def _record_snapshot(self, snap: DelegationSnapshot) -> None: + if snap.delegation_id not in self._history: + self._history_order[snap.session_id].append(snap.delegation_id) + self._history[snap.delegation_id] = snap + self._evict_history(snap.session_id) + + def _evict_history(self, session_id: str) -> None: + order = self._history_order[session_id] + while True: + retained = [did for did in order if did in self._history] + if len(retained) <= self._max_history: + break + evicted = False + for delegation_id in list(order): + if delegation_id in self._active: + continue + with contextlib.suppress(ValueError): + order.remove(delegation_id) + self._history.pop(delegation_id, None) + evicted = True + break + if not evicted: + break + __all__ = [ + "DEFAULT_MAX_HISTORY", "AsyncDelegationManager", "DelegationCompletion", "DelegationHandle", diff --git a/src/dream/subagents/_builtins.py b/src/dream/subagents/_builtins.py index 4e66f3ec..798bd0dd 100644 --- a/src/dream/subagents/_builtins.py +++ b/src/dream/subagents/_builtins.py @@ -1,7 +1,7 @@ """Harness-builtin subagent templates (OpenHarness Explore / Plan / verification). Merged into every beat that enables ``spawn_subagent``. Role specialists add -names; they do not remove these builtins. Fail-closed enum = builtins ∪ role set. +names; they do not remove these builtins. Fail-closed enum = builtins | role set. """ from __future__ import annotations diff --git a/src/dream/subagents/_catalogue.py b/src/dream/subagents/_catalogue.py index b963aa08..7fac810c 100644 --- a/src/dream/subagents/_catalogue.py +++ b/src/dream/subagents/_catalogue.py @@ -9,6 +9,7 @@ from collections.abc import Iterator from dataclasses import dataclass +from dream.subagents._builtins import EXPLORE, PLAN, VERIFY, merge_builtins from dream.subagents._declaration import ( GENERAL_PURPOSE_DESCRIPTION, GENERAL_PURPOSE_NAME, @@ -44,15 +45,26 @@ def __iter__(self) -> Iterator[SubagentCatalogueEntry]: @classmethod def for_set(cls, subagent_set: SubagentSet | None) -> SubagentCatalogue | None: - """Build a catalogue, or ``None`` when spawn is not wired on the harness.""" + """Build a catalogue, or ``None`` when spawn is not wired on the harness. + + Entries match the live resolver: ``generalPurpose``, then ``explore`` / + ``plan`` / ``verify`` (role overrides win), then remaining role names. + """ if subagent_set is None: return None + merged = merge_builtins(subagent_set) general = SubagentCatalogueEntry( name=GENERAL_PURPOSE_NAME, description=GENERAL_PURPOSE_DESCRIPTION, ) - specialists = tuple(_entry_for(agent) for agent in subagent_set) - return cls(entries=(general, *specialists)) + reserved = {GENERAL_PURPOSE_NAME, EXPLORE, PLAN, VERIFY} + ordered: list[SubagentCatalogueEntry] = [] + for name in (EXPLORE, PLAN, VERIFY): + agent = merged.get(name) + if agent is not None: + ordered.append(_entry_for(agent)) + ordered.extend(_entry_for(agent) for agent in merged if agent.name not in reserved) + return cls(entries=(general, *ordered)) def render(self) -> str: lines = ["# Subagent definitions", ""] diff --git a/src/dream/subagents/_declaration.py b/src/dream/subagents/_declaration.py index 532dd3c3..2ab9d569 100644 --- a/src/dream/subagents/_declaration.py +++ b/src/dream/subagents/_declaration.py @@ -14,9 +14,10 @@ from dream.api.response_format import JsonSchema from dream.subagents._isolation import IsolationMode +from dream.subagents._overlay import PermissionOverlay -PermissionDelta = tuple[str, ...] -"""Tighten-only permission overlay — tokens to *remove* from the parent. Never widens.""" +PermissionDelta = PermissionOverlay +"""Tighten-only permission overlay. Never widens the parent.""" MAX_INLINE_NESTING = 2 """Hard cap on mid-beat subagent nesting (Hermes-flat default; depth-2 for rare orchestrators). @@ -47,7 +48,7 @@ class Subagent: tools: tuple[str, ...] skills: tuple[str, ...] = () - permission_overlay: PermissionDelta = () + permission_overlay: PermissionOverlay = field(default_factory=PermissionOverlay) depth: int = 1 model: str | None = None spawned_by: tuple[str, ...] = () @@ -69,9 +70,10 @@ def __post_init__(self) -> None: if self.depth < 1: raise ValueError(f"Subagent.depth must be >= 1; got {self.depth}") if not isinstance(self.isolation, IsolationMode): - raise TypeError( - f"Subagent.isolation must be IsolationMode; got {type(self.isolation)}" - ) + raise TypeError(f"Subagent.isolation must be IsolationMode; got {type(self.isolation)}") + object.__setattr__( + self, "permission_overlay", PermissionOverlay.parse(self.permission_overlay) + ) def to_dict(self) -> dict[str, object]: schema_doc: dict[str, object] | None @@ -86,7 +88,7 @@ def to_dict(self) -> dict[str, object]: "description": self.description, "tools": list(self.tools), "skills": list(self.skills), - "permission_overlay": list(self.permission_overlay), + "permission_overlay": list(self.permission_overlay.as_tokens()), "depth": self.depth, "model": self.model, "spawned_by": list(self.spawned_by), @@ -100,7 +102,7 @@ def to_dict(self) -> dict[str, object]: @classmethod def from_dict(cls, data: Mapping[str, object]) -> Subagent: - raw_schema = data["output_schema"] if "output_schema" in data else None + raw_schema = data.get("output_schema") output_schema: JsonSchema | None if raw_schema is None: output_schema = None @@ -117,13 +119,13 @@ def from_dict(cls, data: Mapping[str, object]) -> Subagent: if not isinstance(tools_raw, Sequence) or isinstance(tools_raw, (str, bytes)): raise TypeError("Subagent.tools must be a sequence of strings") - spawnable_raw = data["spawnable"] if "spawnable" in data else () + spawnable_raw = data.get("spawnable", ()) if spawnable_raw is None: spawnable_raw = () if not isinstance(spawnable_raw, Sequence) or isinstance(spawnable_raw, (str, bytes)): raise TypeError("Subagent.spawnable must be a sequence") - isolation_raw = data["isolation"] if "isolation" in data else IsolationMode.SHARED.value + isolation_raw = data.get("isolation", IsolationMode.SHARED.value) isolation = ( isolation_raw if isinstance(isolation_raw, IsolationMode) @@ -135,24 +137,29 @@ def from_dict(cls, data: Mapping[str, object]) -> Subagent: description=str(data["description"]), tools=tuple(str(item) for item in tools_raw), skills=_string_tuple(data, "skills"), - permission_overlay=_string_tuple(data, "permission_overlay"), - depth=int(data["depth"]) if "depth" in data else 1, + permission_overlay=PermissionOverlay.parse(_string_tuple(data, "permission_overlay")), + depth=_int_field(data, "depth", 1), model=str(data["model"]) if data.get("model") is not None else None, spawned_by=_string_tuple(data, "spawned_by"), system_prompt=( str(data["system_prompt"]) if data.get("system_prompt") is not None else None ), - max_turns=int(data["max_turns"]) if "max_turns" in data else 8, + max_turns=_int_field(data, "max_turns", 8), output_schema=output_schema, strict=bool(data["strict"]) if "strict" in data else False, isolation=isolation, spawnable=tuple( - cls.from_dict(cast(Mapping[str, object], child)) - for child in spawnable_raw + cls.from_dict(cast(Mapping[str, object], child)) for child in spawnable_raw ), ) +def _int_field(data: Mapping[str, object], key: str, default: int) -> int: + if key not in data: + return default + return int(str(data[key])) + + def _string_tuple(data: Mapping[str, object], key: str) -> tuple[str, ...]: if key not in data or data[key] is None: return () @@ -196,6 +203,7 @@ def __bool__(self) -> bool: "MAX_INLINE_NESTING", "MAX_SUBAGENT_DEPTH", "PermissionDelta", + "PermissionOverlay", "Subagent", "SubagentSet", ] diff --git a/src/dream/subagents/_delegate.py b/src/dream/subagents/_delegate.py index 538fa1be..ecf4f9db 100644 --- a/src/dream/subagents/_delegate.py +++ b/src/dream/subagents/_delegate.py @@ -12,6 +12,7 @@ from typing import TYPE_CHECKING from dream.subagents._inline_executor import run_subagent_session +from dream.subagents._isolation import IsolationMode from dream.subagents._projection import SubagentResult from dream.utils.fs import atomic_write_text @@ -35,6 +36,7 @@ def build_child_prompt( context: str | None = None, *, workspace_path: str | None = None, + ephemeral_workspace: bool = False, ) -> str: """Hermes child inlet: goal + optional packed context — never parent history.""" parts = [ @@ -47,6 +49,15 @@ def build_child_prompt( parts.extend(["", "CONTEXT:", context.strip()]) if workspace_path: parts.extend(["", f"WORKSPACE PATH: {workspace_path}"]) + if ephemeral_workspace: + parts.extend( + [ + "", + "This workspace is an ephemeral git worktree. Edits here are discarded", + "when you finish and do not persist in the parent tree. Report findings", + "in your summary; do not rely on leftover files.", + ] + ) parts.extend( [ "", @@ -122,7 +133,7 @@ async def run_subagent_delegate( # the prompt inside the executor once the ephemeral checkout exists. workspace = ( None - if agent.isolation.value == "worktree" + if agent.isolation is IsolationMode.WORKTREE else (str(parent_cwd) if parent_cwd is not None else None) ) prompt = build_child_prompt(goal, context, workspace_path=workspace) diff --git a/src/dream/subagents/_host_blocklist.py b/src/dream/subagents/_host_blocklist.py index f1a8b98b..a8315cff 100644 --- a/src/dream/subagents/_host_blocklist.py +++ b/src/dream/subagents/_host_blocklist.py @@ -6,6 +6,8 @@ from __future__ import annotations +from dream.subagents._overlay import EXECUTE_TOOLS + # Never available on leaf children. Orchestrators that declare ``spawnable`` keep # ``spawn_subagent`` via the inline executor; everything else here is absolute. HOST_BLOCKED_TOOLS: frozenset[str] = frozenset( @@ -85,6 +87,11 @@ def strip_host_blocked(tools: tuple[str, ...], *, keep_spawn: bool) -> tuple[str return tuple(name for name in tools if name not in blocked) +def strip_unconfinable_commands(tools: tuple[str, ...]) -> tuple[str, ...]: + """Drop shell/code tools whose writes cannot be confined to a worktree cwd.""" + return tuple(name for name in tools if name not in EXECUTE_TOOLS) + + __all__ = [ "EXPLORE_TOOLS", "HOST_BLOCKED_TOOLS", @@ -92,4 +99,5 @@ def strip_host_blocked(tools: tuple[str, ...], *, keep_spawn: bool) -> tuple[str "READONLY_DENIED_TOOLS", "VERIFY_TOOLS", "strip_host_blocked", + "strip_unconfinable_commands", ] diff --git a/src/dream/subagents/_inline_executor.py b/src/dream/subagents/_inline_executor.py index adf0c9cc..f3d29449 100644 --- a/src/dream/subagents/_inline_executor.py +++ b/src/dream/subagents/_inline_executor.py @@ -8,6 +8,7 @@ from __future__ import annotations import asyncio +import contextlib from dataclasses import replace from pathlib import Path from typing import TYPE_CHECKING @@ -17,7 +18,7 @@ from dream.roles._manifest import RoleManifest from dream.session import SessionOptions from dream.subagents._declaration import MAX_INLINE_NESTING, Subagent, SubagentSet -from dream.subagents._host_blocklist import strip_host_blocked +from dream.subagents._host_blocklist import strip_host_blocked, strip_unconfinable_commands from dream.subagents._isolation import IsolationMode from dream.subagents._output_guard import enforce_output_schema from dream.subagents._projection import SubagentResult, intersect_tools @@ -61,8 +62,7 @@ async def run_subagent_session( output="", success=False, error=( - "IsolationMode.WORKTREE requires parent working_dir and " - "session scratch_dir" + "IsolationMode.WORKTREE requires parent working_dir and session scratch_dir" ), turns_used=0, ) @@ -77,6 +77,7 @@ async def run_subagent_session( goal, context, workspace_path=str(child_cwd), + ephemeral_workspace=True, ) manifest = _build_subagent_manifest(agent, parent_tools=parent_tools) @@ -91,7 +92,7 @@ async def run_subagent_session( if agent.permission_overlay: child_metadata[SUBAGENT_OVERLAY_METADATA_KEY] = agent.permission_overlay if child_cwd is not None: - child_metadata[SUBAGENT_WORKING_DIR_METADATA_KEY] = str(child_cwd) + child_metadata[SUBAGENT_WORKING_DIR_METADATA_KEY] = child_cwd response_format = None if agent.output_schema is not None: @@ -143,7 +144,8 @@ async def run_subagent_session( ) finally: if worktree is not None: - worktree.remove() + with contextlib.suppress(Exception): + worktree.remove() def build_child_spawn_metadata( @@ -200,6 +202,8 @@ def _build_subagent_manifest( effective_tools = intersect_tools(agent.tools, parent_tools) can_spawn = _can_spawn(agent) effective_tools = strip_host_blocked(effective_tools, keep_spawn=can_spawn) + if agent.isolation is IsolationMode.WORKTREE: + effective_tools = strip_unconfinable_commands(effective_tools) spawn_note = ( "You may dispatch your declared subagent(s) with spawn_subagent when it helps." diff --git a/src/dream/subagents/_isolation.py b/src/dream/subagents/_isolation.py index a34f3dc6..10932679 100644 --- a/src/dream/subagents/_isolation.py +++ b/src/dream/subagents/_isolation.py @@ -9,7 +9,12 @@ class IsolationMode(StrEnum): """Where a child session's tools run. ``SHARED`` — same worktree as the parent (Hermes default; cheap). - ``WORKTREE`` — short-lived git worktree under scratch; torn down after join. + ``WORKTREE`` — short-lived git worktree under the session scratch dir. + The child's permission cwd is the worktree, so writes cannot escape into + the parent tree. Command-bearing tools (bash / execute_code) are dropped + because shell redirects cannot be confined. The checkout is force-removed + after join: edits are ephemeral and never merge back. Use WORKTREE to + confine side effects, not to land durable patches. """ SHARED = "shared" diff --git a/src/dream/subagents/_overlay.py b/src/dream/subagents/_overlay.py new file mode 100644 index 00000000..eb65f655 --- /dev/null +++ b/src/dream/subagents/_overlay.py @@ -0,0 +1,119 @@ +"""Tighten-only permission overlay for child subagent sessions. + +An overlay only *removes* capabilities. It cannot grant write, network, +execute, or tools the parent gate would deny. +""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from dataclasses import dataclass +from enum import StrEnum + +__all__ = [ + "EXECUTE_TOOLS", + "OverlayCapability", + "PermissionOverlay", +] + + +class OverlayCapability(StrEnum): + """Named capabilities an overlay may strip from the parent.""" + + WRITE = "write" + NETWORK = "network" + EXECUTE = "execute" + + +_WRITE_ALIASES: frozenset[str] = frozenset( + { + OverlayCapability.WRITE.value, + "repo-write", + "repo-write+net-allowlist", + } +) +_NETWORK_ALIASES: frozenset[str] = frozenset( + { + OverlayCapability.NETWORK.value, + "net", + "net-allowlist", + } +) +_EXECUTE_ALIASES: frozenset[str] = frozenset( + { + OverlayCapability.EXECUTE.value, + "exec", + } +) + +EXECUTE_TOOLS: frozenset[str] = frozenset({"bash", "execute_code", "run_command"}) + + +@dataclass(frozen=True, slots=True) +class PermissionOverlay: + """Capabilities and tools to remove from the parent. Never grants.""" + + write: bool = False + network: bool = False + execute: bool = False + tools: frozenset[str] = frozenset() + + @classmethod + def parse(cls, raw: object) -> PermissionOverlay: + """Build an overlay from tokens or return ``raw`` unchanged. + + Known capability tokens (``write`` / ``network`` / ``execute`` and + sandbox-tier aliases) set flags. Every other token is a tool name to + deny. Unknown tokens never become grants. + """ + if raw is None: + return cls() + if isinstance(raw, PermissionOverlay): + return raw + if isinstance(raw, (str, bytes)) or not isinstance(raw, Sequence): + raise TypeError("permission overlay must be a sequence of tokens, not a bare string") + write = False + network = False + execute = False + tools: set[str] = set() + for token in raw: + name = str(token).strip() + if not name: + continue + if name in _WRITE_ALIASES: + write = True + elif name in _NETWORK_ALIASES: + network = True + elif name in _EXECUTE_ALIASES: + execute = True + else: + tools.add(name) + return cls(write=write, network=network, execute=execute, tools=frozenset(tools)) + + def as_tokens(self) -> tuple[str, ...]: + tokens: list[str] = [] + if self.write: + tokens.append(OverlayCapability.WRITE.value) + if self.network: + tokens.append(OverlayCapability.NETWORK.value) + if self.execute: + tokens.append(OverlayCapability.EXECUTE.value) + tokens.extend(sorted(self.tools)) + return tuple(tokens) + + def __iter__(self) -> Iterator[str]: + return iter(self.as_tokens()) + + def __contains__(self, token: object) -> bool: + if not isinstance(token, str): + return False + if token in _WRITE_ALIASES: + return self.write + if token in _NETWORK_ALIASES: + return self.network + if token in _EXECUTE_ALIASES: + return self.execute + return token in self.tools + + def __bool__(self) -> bool: + return self.write or self.network or self.execute or bool(self.tools) diff --git a/src/dream/subagents/_overlay_gate.py b/src/dream/subagents/_overlay_gate.py index d11f64a6..dd2f292e 100644 --- a/src/dream/subagents/_overlay_gate.py +++ b/src/dream/subagents/_overlay_gate.py @@ -1,46 +1,91 @@ -"""Tighten-only permission overlay applied to a child session gate.""" +"""Tighten-only permission overlay and worktree confinement wrappers.""" from __future__ import annotations +from pathlib import Path + from dream.engine._tool_dispatch import PermissionGate from dream.permissions import Outcome, PermissionDecision, PermissionRequest -from dream.subagents._declaration import PermissionDelta - -# Capability tokens understood by the overlay (see dream.permissions PermissionEffect). -_WRITE_TOKENS: frozenset[str] = frozenset({"write", "repo-write", "repo-write+net-allowlist"}) +from dream.permissions._path_validator import validate_repo_write +from dream.subagents._overlay import EXECUTE_TOOLS, PermissionOverlay def wrap_permission_gate( parent_gate: PermissionGate, - overlay: PermissionDelta, + overlay: PermissionOverlay, ) -> PermissionGate: - """Return a gate that denies overlay tokens, then consults ``parent_gate``. + """Return a gate that applies overlay denies, then consults ``parent_gate``. - Overlay entries that look like tool names deny those tools. Entries in - ``_WRITE_TOKENS`` deny any non-read-only request. + Overlay flags only deny. The parent decision is the sole allow path, so + the child cannot widen past the parent. """ if not overlay: return parent_gate - deny_tools = frozenset(token for token in overlay if token not in _WRITE_TOKENS) - deny_writes = bool(_WRITE_TOKENS.intersection(overlay)) - def gate(request: PermissionRequest) -> PermissionDecision: - if request.tool_name in deny_tools: + if request.tool_name in overlay.tools: return PermissionDecision( outcome=Outcome.DENY, reason=f"subagent permission_overlay denies tool {request.tool_name!r}", rule="subagent_permission_overlay", ) - if deny_writes and not request.is_read_only: + if overlay.write and (not request.is_read_only or _is_execute(request)): return PermissionDecision( outcome=Outcome.DENY, reason="subagent permission_overlay denies write effects", rule="subagent_permission_overlay", ) + if overlay.network and (request.network_host is not None or _is_execute(request)): + return PermissionDecision( + outcome=Outcome.DENY, + reason="subagent permission_overlay denies network effects", + rule="subagent_permission_overlay", + ) + if overlay.execute and _is_execute(request): + return PermissionDecision( + outcome=Outcome.DENY, + reason="subagent permission_overlay denies execute effects", + rule="subagent_permission_overlay", + ) return parent_gate(request) return gate -__all__ = ["wrap_permission_gate"] +def confine_permission_gate(parent_gate: PermissionGate, cwd: Path) -> PermissionGate: + """Deny mutating paths that resolve outside ``cwd``. + + Used for ``IsolationMode.WORKTREE`` so the child cannot write the parent + tree even when the parent policy lists extra-allowed roots. + """ + root = cwd.resolve() + + def gate(request: PermissionRequest) -> PermissionDecision: + if _is_execute(request): + return PermissionDecision( + outcome=Outcome.DENY, + reason=( + "worktree isolation denies unconfinable command execution " + f"({request.tool_name!r})" + ), + rule="subagent_worktree_confine", + ) + if not request.is_read_only: + for path in request.target_paths: + ok, reason = validate_repo_write(path, root) + if not ok: + return PermissionDecision( + outcome=Outcome.DENY, + reason=reason, + rule="subagent_worktree_confine", + ) + return parent_gate(request) + + return gate + + +def _is_execute(request: PermissionRequest) -> bool: + return request.tool_name in EXECUTE_TOOLS or request.command is not None + + +__all__ = ["confine_permission_gate", "wrap_permission_gate"] diff --git a/src/dream/subagents/_worktree.py b/src/dream/subagents/_worktree.py index c20dd92a..41a719b1 100644 --- a/src/dream/subagents/_worktree.py +++ b/src/dream/subagents/_worktree.py @@ -3,11 +3,16 @@ Adapted from OpenHarness ``WorktreeManager`` / Dream ``enter_worktree``: one create + remove pair per child session, paths confined under the session scratch dir so they never land in the parent's durable worktree. + +Edits in the child worktree are ephemeral. ``remove`` force-deletes the +checkout and its branch; nothing is merged back to the parent. """ from __future__ import annotations +import contextlib import re +import shutil import uuid from dataclasses import dataclass from pathlib import Path @@ -24,12 +29,8 @@ class SubagentWorktree: repo_root: Path def remove(self) -> None: - """Force-remove the worktree and delete its ephemeral branch.""" - run_git( - ["worktree", "remove", "--force", str(self.path)], - cwd=self.repo_root, - ) - run_git(["branch", "-D", self.branch], cwd=self.repo_root) + """Best-effort teardown: drop the worktree, then the ephemeral branch.""" + forget_worktree(self.repo_root, self.path, branch=self.branch) @dataclass(frozen=True) @@ -58,13 +59,33 @@ def create(self, agent_name: str) -> SubagentWorktree: cwd=repo_root, ) if rc != 0: + forget_worktree(repo_root, path, branch=branch) raise RuntimeError(f"git worktree add failed: {err or out}") return SubagentWorktree(path=path, branch=branch, repo_root=repo_root) +def forget_worktree(repo_root: Path, path: Path, *, branch: str | None = None) -> None: + """Drop git worktree metadata, then the filesystem path. + + Order is fail-closed: unregister (``worktree remove``), prune stale + admin files, delete the ephemeral branch, then ``rmtree``. Used both + for normal teardown and a failed ``worktree add`` that may have + registered metadata before returning an error. + """ + with contextlib.suppress(Exception): + run_git(["worktree", "remove", "--force", str(path)], cwd=repo_root) + with contextlib.suppress(Exception): + run_git(["worktree", "prune"], cwd=repo_root) + if branch: + with contextlib.suppress(Exception): + run_git(["branch", "-D", branch], cwd=repo_root) + if path.exists(): + shutil.rmtree(path, ignore_errors=True) + + def _safe_slug(name: str) -> str: slug = re.sub(r"[^\w\-]+", "-", name.strip().lower()).strip("-") return (slug or "agent")[:48] -__all__ = ["SubagentWorktree", "SubagentWorktreeFactory"] +__all__ = ["SubagentWorktree", "SubagentWorktreeFactory", "forget_worktree"] diff --git a/src/dream/tools/builtin/delegation_get.py b/src/dream/tools/builtin/delegation_get.py index 75d8614b..1a160bd8 100644 --- a/src/dream/tools/builtin/delegation_get.py +++ b/src/dream/tools/builtin/delegation_get.py @@ -35,7 +35,7 @@ async def execute(self, input: dict[str, Any], ctx: ToolExecutionContext) -> Too content="No async delegation manager on this session.", is_error=True, ) - snap = ctx.delegations.get(args.delegation_id) + snap = ctx.delegations.get(args.delegation_id, session_id=ctx.session_id) if snap is None: return ToolResult( content=f"Unknown delegation_id {args.delegation_id!r}.", diff --git a/src/dream/tools/builtin/delegation_stop.py b/src/dream/tools/builtin/delegation_stop.py index 59ad3c76..d014e773 100644 --- a/src/dream/tools/builtin/delegation_stop.py +++ b/src/dream/tools/builtin/delegation_stop.py @@ -28,7 +28,7 @@ async def execute(self, input: dict[str, Any], ctx: ToolExecutionContext) -> Too content="No async delegation manager on this session.", is_error=True, ) - snap = await ctx.delegations.stop(args.delegation_id) + snap = await ctx.delegations.stop(args.delegation_id, session_id=ctx.session_id) if snap is None: return ToolResult( content=f"Unknown delegation_id {args.delegation_id!r}.", diff --git a/src/dream/tools/builtin/spawn_subagent.py b/src/dream/tools/builtin/spawn_subagent.py index a2c0bfce..f907f0ec 100644 --- a/src/dream/tools/builtin/spawn_subagent.py +++ b/src/dream/tools/builtin/spawn_subagent.py @@ -169,9 +169,7 @@ def build_spawn_parameters( enum_property = _SpawnTypeEnumProperty(names=type_names) schema: dict[str, object] = dict(base_schema) properties_raw = schema.get("properties") - properties: dict[str, object] = ( - dict(properties_raw) if isinstance(properties_raw, dict) else {} - ) + properties: dict[str, object] = dict(properties_raw) if isinstance(properties_raw, dict) else {} properties["subagent_type"] = enum_property.as_mapping() schema["properties"] = properties required_raw = schema.get("required") @@ -180,9 +178,7 @@ def build_spawn_parameters( if isinstance(required_raw, list) else [] ) - schema["required"] = [ - name for name in required_names if name not in ("name", "subagent_type") - ] + schema["required"] = [name for name in required_names if name not in ("name", "subagent_type")] defs_raw = schema.get("$defs") if isinstance(defs_raw, dict): defs: dict[str, object] = dict(defs_raw) @@ -447,10 +443,7 @@ async def run_one(task: _ResolvedTask) -> SubagentResult: async def run_all() -> tuple[SubagentResult, ...]: return tuple(await asyncio.gather(*(run_one(task) for task in resolved))) - background_supported = ( - args.background - and ctx.delegations is not None - ) + background_supported = args.background and ctx.delegations is not None forced_sync_note = "" if background_supported: assert ctx.delegations is not None diff --git a/tests/test_subagents/test_builtins.py b/tests/test_subagents/test_builtins.py index 9825ed92..a81c4329 100644 --- a/tests/test_subagents/test_builtins.py +++ b/tests/test_subagents/test_builtins.py @@ -5,11 +5,11 @@ from dream.subagents import ( EXPLORE, GENERAL_PURPOSE, - IsolationMode, PLAN, + VERIFY, + IsolationMode, Subagent, SubagentSet, - VERIFY, builtin_agents, merge_builtins, ) @@ -61,6 +61,22 @@ def test_resolve_explore(self) -> None: assert agent is not None assert agent.name == EXPLORE + def test_resolve_uses_role_override_not_builtin(self) -> None: + custom = Subagent( + name=EXPLORE, + description="role override", + tools=("read_file", "grep"), + ) + agent = resolve_agent( + EXPLORE, + subagent_set=SubagentSet(agents={EXPLORE: custom}), + parent_tools=frozenset({"read_file", "grep"}), + parent_name=None, + ) + assert agent is not None + assert agent.description == "role override" + assert agent.tools == ("read_file", "grep") + def test_spawned_by_enforced(self) -> None: gated = Subagent( name="nested_only", diff --git a/tests/test_subagents/test_catalogue.py b/tests/test_subagents/test_catalogue.py index dbf63a73..5d203613 100644 --- a/tests/test_subagents/test_catalogue.py +++ b/tests/test_subagents/test_catalogue.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dream.subagents import EXPLORE, PLAN, VERIFY from dream.subagents._catalogue import SubagentCatalogue from dream.subagents._declaration import ( GENERAL_PURPOSE_DESCRIPTION, @@ -15,10 +16,15 @@ def test_for_set_none_means_spawn_disabled() -> None: assert SubagentCatalogue.for_set(None) is None -def test_empty_set_still_lists_general_purpose() -> None: +def test_empty_set_lists_general_purpose_and_builtins() -> None: catalogue = SubagentCatalogue.for_set(SubagentSet()) assert catalogue is not None - assert [entry.name for entry in catalogue] == [GENERAL_PURPOSE_NAME] + assert [entry.name for entry in catalogue] == [ + GENERAL_PURPOSE_NAME, + EXPLORE, + PLAN, + VERIFY, + ] rendered = catalogue.render() assert "# Subagent definitions" in rendered assert GENERAL_PURPOSE_NAME in rendered @@ -26,7 +32,7 @@ def test_empty_set_still_lists_general_purpose() -> None: assert "spawn_subagent" not in rendered -def test_specialists_follow_general_purpose() -> None: +def test_specialists_follow_builtins() -> None: catalogue = SubagentCatalogue.for_set( SubagentSet( agents={ @@ -39,5 +45,28 @@ def test_specialists_follow_general_purpose() -> None: ) ) assert catalogue is not None - assert [entry.name for entry in catalogue] == [GENERAL_PURPOSE_NAME, "reviewer"] + assert [entry.name for entry in catalogue] == [ + GENERAL_PURPOSE_NAME, + EXPLORE, + PLAN, + VERIFY, + "reviewer", + ] assert "- **reviewer** — Reviews code" in catalogue.render() + + +def test_role_override_of_explore_is_advertised() -> None: + catalogue = SubagentCatalogue.for_set( + SubagentSet( + agents={ + EXPLORE: Subagent( + name=EXPLORE, + description="Role explore override", + tools=("read_file",), + ), + } + ) + ) + assert catalogue is not None + by_name = {entry.name: entry.description for entry in catalogue} + assert by_name[EXPLORE] == "Role explore override" diff --git a/tests/test_subagents/test_child_spawn_metadata.py b/tests/test_subagents/test_child_spawn_metadata.py index bf4314a1..bb7fd123 100644 --- a/tests/test_subagents/test_child_spawn_metadata.py +++ b/tests/test_subagents/test_child_spawn_metadata.py @@ -57,12 +57,17 @@ def test_grandchild_tools_intersected_with_child(self) -> None: """A spawnable child can only narrow: its tools ∩ the spawner's effective tools.""" greedy = Subagent(name="web_research", description="d", tools=("web_search", "bash")) spawner = Subagent( - name="strategist", description="d", + name="strategist", + description="d", tools=("read_file", "spawn_subagent", "web_search"), # no bash - spawnable=(greedy,), depth=1, + spawnable=(greedy,), + depth=1, ) meta = build_child_spawn_metadata( - spawner, counter=[0], harness=object(), tracer=None, + spawner, + counter=[0], + harness=object(), + tracer=None, parent_tools=frozenset({"read_file", "spawn_subagent", "web_search"}), ) scoped: SubagentSet = meta[SUBAGENT_SET_CONTEXT_KEY] @@ -71,7 +76,10 @@ def test_grandchild_tools_intersected_with_child(self) -> None: def test_harness_and_parent_tools_wired(self) -> None: sentinel = object() meta = build_child_spawn_metadata( - _spawner(), counter=[0], harness=sentinel, tracer=None, + _spawner(), + counter=[0], + harness=sentinel, + tracer=None, parent_tools=frozenset({"read_file", "spawn_subagent"}), ) assert meta[HARNESS_KEY] is sentinel diff --git a/tests/test_subagents/test_declaration.py b/tests/test_subagents/test_declaration.py index a4f999b1..6f78f72d 100644 --- a/tests/test_subagents/test_declaration.py +++ b/tests/test_subagents/test_declaration.py @@ -21,7 +21,7 @@ def test_basic_construction(self) -> None: assert agent.depth == 1 assert agent.model is None assert agent.skills == () - assert agent.permission_overlay == () + assert not agent.permission_overlay assert agent.max_turns == 8 def test_empty_name_raises(self) -> None: diff --git a/tests/test_subagents/test_delegation_ownership.py b/tests/test_subagents/test_delegation_ownership.py new file mode 100644 index 00000000..207d03b6 --- /dev/null +++ b/tests/test_subagents/test_delegation_ownership.py @@ -0,0 +1,121 @@ +"""Session ownership for delegation_get / delegation_stop, plus history bound.""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +from dream.subagents._async_delegation import AsyncDelegationManager, DelegationStatus +from dream.subagents._projection import SubagentResult +from dream.tools._context import ToolExecutionContext +from dream.tools.builtin.delegation_get import DelegationGetTool +from dream.tools.builtin.delegation_stop import DelegationStopTool + + +async def _quick() -> tuple[SubagentResult, ...]: + return (SubagentResult(name="reviewer", output="ok"),) + + +def _ctx(session_id: str, manager: AsyncDelegationManager) -> ToolExecutionContext: + return ToolExecutionContext( + working_dir=Path("/tmp/test"), + session_id=session_id, + delegations=manager, + ) + + +async def test_get_and_stop_require_owning_session() -> None: + manager = AsyncDelegationManager(max_active=1) + release = asyncio.Event() + + async def work() -> tuple[SubagentResult, ...]: + await release.wait() + return (SubagentResult(name="reviewer", output="secret"),) + + handle = manager.start("owner", ("reviewer",), work) + assert handle is not None + assert manager.get(handle.delegation_id, session_id="intruder") is None + assert await manager.stop(handle.delegation_id, session_id="intruder") is None + assert manager.active("owner") == 1 + + snap = manager.get(handle.delegation_id, session_id="owner") + assert snap is not None + assert snap.session_id == "owner" + + release.set() + await manager.wait_next("owner") + await manager.close() + + +async def test_delegation_tools_hide_foreign_session() -> None: + manager = AsyncDelegationManager(max_active=1) + handle = manager.start("owner", ("reviewer",), _quick) + assert handle is not None + await manager.wait_next("owner") + + get_tool = DelegationGetTool() + stop_tool = DelegationStopTool() + foreign = _ctx("intruder", manager) + got = await get_tool.execute({"delegation_id": handle.delegation_id}, foreign) + assert got.is_error + assert "Unknown delegation_id" in got.content + + stopped = await stop_tool.execute({"delegation_id": handle.delegation_id}, foreign) + assert stopped.is_error + assert "Unknown delegation_id" in stopped.content + + owned = await get_tool.execute({"delegation_id": handle.delegation_id}, _ctx("owner", manager)) + assert not owned.is_error + assert "secret" not in owned.content + assert "ok" in owned.content + await manager.close() + + +async def test_history_is_bounded() -> None: + manager = AsyncDelegationManager(max_active=1, max_history=2) + for index in range(3): + handle = manager.start("owner", (f"agent-{index}",), _quick) + assert handle is not None + await manager.wait_next("owner") + + snaps = manager.list_for_session("owner") + assert len(snaps) == 2 + names = {snap.subagent_names[0] for snap in snaps} + assert names == {"agent-1", "agent-2"} + await manager.close() + + +async def test_history_cap_is_per_session() -> None: + manager = AsyncDelegationManager(max_active=1, max_history=2) + other = manager.start("other", ("keep-me",), _quick) + assert other is not None + await manager.wait_next("other") + + for index in range(3): + handle = manager.start("owner", (f"agent-{index}",), _quick) + assert handle is not None + await manager.wait_next("owner") + + owner = manager.list_for_session("owner") + assert len(owner) == 2 + assert {snap.subagent_names[0] for snap in owner} == {"agent-1", "agent-2"} + kept = manager.get(other.delegation_id, session_id="other") + assert kept is not None + assert kept.subagent_names == ("keep-me",) + await manager.close() + + +async def test_stop_owned_active_delegation() -> None: + manager = AsyncDelegationManager(max_active=1) + + async def hang() -> tuple[SubagentResult, ...]: + await asyncio.Future() + + handle = manager.start("owner", ("reviewer",), hang) + assert handle is not None + await asyncio.sleep(0) + snap = await manager.stop(handle.delegation_id, session_id="owner") + assert snap is not None + assert snap.status is DelegationStatus.STOPPED + assert manager.active("owner") == 0 + await manager.close() diff --git a/tests/test_subagents/test_inline_executor.py b/tests/test_subagents/test_inline_executor.py index 1e3f7aa2..627c23bf 100644 --- a/tests/test_subagents/test_inline_executor.py +++ b/tests/test_subagents/test_inline_executor.py @@ -38,9 +38,7 @@ def test_manifest_tools_intersected_with_parent(self) -> None: description="declares more than the parent has", tools=("read_file", "grep", "bash"), ) - manifest = _build_subagent_manifest( - agent, parent_tools=frozenset({"read_file", "grep"}) - ) + manifest = _build_subagent_manifest(agent, parent_tools=frozenset({"read_file", "grep"})) assert manifest.tools == ("read_file", "grep") assert "bash" not in manifest.tools diff --git a/tests/test_subagents/test_overlay_gate.py b/tests/test_subagents/test_overlay_gate.py new file mode 100644 index 00000000..dc2f41be --- /dev/null +++ b/tests/test_subagents/test_overlay_gate.py @@ -0,0 +1,102 @@ +"""Typed permission overlay: write/network/execute/tool denies; never widens.""" + +from __future__ import annotations + +from pathlib import Path + +from dream.permissions import Outcome, PermissionDecision, PermissionRequest +from dream.subagents._overlay import PermissionOverlay +from dream.subagents._overlay_gate import wrap_permission_gate + + +def _allow(_request: PermissionRequest) -> PermissionDecision: + return PermissionDecision(outcome=Outcome.ALLOW, reason="parent allow", rule="test") + + +def _deny(_request: PermissionRequest) -> PermissionDecision: + return PermissionDecision(outcome=Outcome.DENY, reason="parent deny", rule="test") + + +def test_write_token_denies_mutating_request() -> None: + gate = wrap_permission_gate(_allow, PermissionOverlay.parse(("write",))) + decision = gate( + PermissionRequest(tool_name="write_file", is_read_only=False, target_paths=(Path("a"),)) + ) + assert decision.outcome is Outcome.DENY + assert "write" in decision.reason + + +def test_network_token_denies_network_host() -> None: + gate = wrap_permission_gate(_allow, PermissionOverlay.parse(("network",))) + decision = gate( + PermissionRequest(tool_name="web_fetch", is_read_only=True, network_host="example.com") + ) + assert decision.outcome is Outcome.DENY + assert "network" in decision.reason + allowed = gate(PermissionRequest(tool_name="read_file", is_read_only=True)) + assert allowed.outcome is Outcome.ALLOW + + +def test_execute_token_denies_bash_and_command() -> None: + gate = wrap_permission_gate(_allow, PermissionOverlay.parse(("execute",))) + bash = gate(PermissionRequest(tool_name="bash", is_read_only=False, command="ls")) + assert bash.outcome is Outcome.DENY + assert "execute" in bash.reason + named = gate(PermissionRequest(tool_name="execute_code", is_read_only=False)) + assert named.outcome is Outcome.DENY + + +def test_unknown_token_is_a_tool_deny_not_a_grant() -> None: + overlay = PermissionOverlay.parse(("web_search",)) + assert overlay.tools == frozenset({"web_search"}) + assert not overlay.write + gate = wrap_permission_gate(_allow, overlay) + denied = gate(PermissionRequest(tool_name="web_search", is_read_only=True)) + assert denied.outcome is Outcome.DENY + other = gate(PermissionRequest(tool_name="read_file", is_read_only=True)) + assert other.outcome is Outcome.ALLOW + + +def test_overlay_cannot_widen_parent_deny() -> None: + gate = wrap_permission_gate(_deny, PermissionOverlay()) + decision = gate(PermissionRequest(tool_name="write_file", is_read_only=False)) + assert decision.outcome is Outcome.DENY + assert decision.reason == "parent deny" + + +def test_repo_write_alias_is_write_capability() -> None: + overlay = PermissionOverlay.parse(("repo-write+net-allowlist",)) + assert overlay.write + assert "write" in overlay + assert not overlay.network + + +def test_write_overlay_allows_read_only_file_tools() -> None: + gate = wrap_permission_gate(_allow, PermissionOverlay.parse(("write",))) + decision = gate(PermissionRequest(tool_name="read_file", is_read_only=True)) + assert decision.outcome is Outcome.ALLOW + + +def test_write_overlay_denies_read_only_classified_shell() -> None: + """``echo`` / ``cat`` can still redirect; write overlay is fail-closed.""" + gate = wrap_permission_gate(_allow, PermissionOverlay.parse(("write",))) + decision = gate( + PermissionRequest( + tool_name="bash", + is_read_only=True, + command="echo leaked > /tmp/escape.txt", + ) + ) + assert decision.outcome is Outcome.DENY + assert "write" in decision.reason + + +def test_network_overlay_denies_command_based_network() -> None: + gate = wrap_permission_gate(_allow, PermissionOverlay.parse(("network",))) + curl = gate( + PermissionRequest(tool_name="bash", is_read_only=False, command="curl https://example.com") + ) + assert curl.outcome is Outcome.DENY + assert "network" in curl.reason + code = gate(PermissionRequest(tool_name="execute_code", is_read_only=False)) + assert code.outcome is Outcome.DENY diff --git a/tests/test_subagents/test_spawn_enum.py b/tests/test_subagents/test_spawn_enum.py index 9fa8ce31..7516a3b8 100644 --- a/tests/test_subagents/test_spawn_enum.py +++ b/tests/test_subagents/test_spawn_enum.py @@ -66,9 +66,7 @@ def test_build_spawn_parameters_sets_enum_only() -> None: assert prop["description"] == "Name from Subagent definitions." assert "WHEN TO USE" not in prop["description"] assert "Reviews code" not in prop["description"] - assert patched["$defs"]["SpawnTaskInput"]["properties"]["subagent_type"][ - "enum" - ] == expected + assert patched["$defs"]["SpawnTaskInput"]["properties"]["subagent_type"]["enum"] == expected async def test_unknown_type_fails_with_available_enum() -> None: diff --git a/tests/test_subagents/test_worktree.py b/tests/test_subagents/test_worktree.py new file mode 100644 index 00000000..44cf0087 --- /dev/null +++ b/tests/test_subagents/test_worktree.py @@ -0,0 +1,156 @@ +"""Worktree isolation: child cwd confinement and ephemeral cleanup.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from dream.permissions import Outcome, PermissionDecision, PermissionRequest +from dream.subagents._declaration import Subagent +from dream.subagents._delegate import build_child_prompt +from dream.subagents._inline_executor import _build_subagent_manifest +from dream.subagents._isolation import IsolationMode +from dream.subagents._overlay_gate import confine_permission_gate +from dream.subagents._worktree import SubagentWorktreeFactory, forget_worktree +from dream.utils.git import run_git + + +def _allow(_request: PermissionRequest) -> PermissionDecision: + return PermissionDecision(outcome=Outcome.ALLOW, reason="parent allow", rule="test") + + +def _init_repo(repo: Path) -> Path: + repo.mkdir() + run_git(["init", "-b", "main"], cwd=repo) + run_git(["config", "user.email", "test@example.com"], cwd=repo) + run_git(["config", "user.name", "test"], cwd=repo) + run_git(["config", "commit.gpgsign", "false"], cwd=repo) + (repo / "README.md").write_text("hi\n", encoding="utf-8") + run_git(["add", "README.md"], cwd=repo) + run_git(["commit", "-m", "init"], cwd=repo) + return repo + + +def test_confine_allows_write_inside_child_cwd(tmp_path: Path) -> None: + child = tmp_path / "child" + child.mkdir() + target = child / "note.txt" + gate = confine_permission_gate(_allow, child) + decision = gate( + PermissionRequest(tool_name="write_file", is_read_only=False, target_paths=(target,)) + ) + assert decision.outcome is Outcome.ALLOW + + +def test_confine_denies_write_to_parent_tree(tmp_path: Path) -> None: + parent = tmp_path / "parent" + child = tmp_path / "child" + parent.mkdir() + child.mkdir() + outside = parent / "secret.txt" + gate = confine_permission_gate(_allow, child) + decision = gate( + PermissionRequest(tool_name="write_file", is_read_only=False, target_paths=(outside,)) + ) + assert decision.outcome is Outcome.DENY + assert decision.rule == "subagent_worktree_confine" + + +def test_worktree_create_and_safe_remove(tmp_path: Path) -> None: + repo = _init_repo(tmp_path / "repo") + scratch = tmp_path / "scratch" + factory = SubagentWorktreeFactory(scratch_dir=scratch, parent_cwd=repo) + worktree = factory.create("explore") + assert worktree.path.is_dir() + assert (worktree.path / "README.md").is_file() + worktree.remove() + assert not worktree.path.exists() + rc, branches, _ = run_git(["branch", "--list", worktree.branch], cwd=repo) + assert rc == 0 + assert worktree.branch not in branches + + +def test_confine_denies_absolute_path_shell_write(tmp_path: Path) -> None: + child = tmp_path / "child" + child.mkdir() + gate = confine_permission_gate(_allow, child) + decision = gate( + PermissionRequest( + tool_name="bash", + is_read_only=True, + command=f"echo leaked > {tmp_path / 'escape.txt'}", + ) + ) + assert decision.outcome is Outcome.DENY + assert decision.rule == "subagent_worktree_confine" + assert "unconfinable" in decision.reason + + +def test_worktree_manifest_drops_unconfinable_commands() -> None: + agent = Subagent( + name="isolated", + description="writes in a scratch tree", + tools=("read_file", "bash", "execute_code", "write_file"), + isolation=IsolationMode.WORKTREE, + ) + manifest = _build_subagent_manifest(agent, parent_tools=None) + assert "bash" not in manifest.tools + assert "execute_code" not in manifest.tools + assert "read_file" in manifest.tools + assert "write_file" in manifest.tools + + +def test_forget_worktree_prunes_git_metadata(tmp_path: Path) -> None: + repo = _init_repo(tmp_path / "repo") + scratch = tmp_path / "scratch" + factory = SubagentWorktreeFactory(scratch_dir=scratch, parent_cwd=repo) + worktree = factory.create("explore") + listed_before = run_git(["worktree", "list", "--porcelain"], cwd=repo)[1] + assert str(worktree.path) in listed_before + + forget_worktree(worktree.repo_root, worktree.path, branch=worktree.branch) + assert not worktree.path.exists() + listed_after = run_git(["worktree", "list", "--porcelain"], cwd=repo)[1] + assert str(worktree.path) not in listed_after + rc, branches, _ = run_git(["branch", "--list", worktree.branch], cwd=repo) + assert rc == 0 + assert worktree.branch not in branches + + +def test_failed_add_prunes_before_rmtree(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + repo = _init_repo(tmp_path / "repo") + factory = SubagentWorktreeFactory(scratch_dir=tmp_path / "scratch", parent_cwd=repo) + calls: list[list[str]] = [] + original = run_git + + def tracking_run_git( + args: list[str], *, cwd: Path, env: object = None, timeout: float | None = None + ) -> tuple[int, str, str]: + calls.append(list(args)) + if args[:2] == ["worktree", "add"]: + return 1, "", "simulated add failure" + if args[0] == "rev-parse": + return original(args, cwd=cwd) + return 0, "", "" + + from dream.subagents import _worktree as worktree_mod + + monkeypatch.setattr(worktree_mod, "run_git", tracking_run_git) + with pytest.raises(RuntimeError, match="git worktree add failed"): + factory.create("explore") + assert any(item[:2] == ["worktree", "remove"] for item in calls) + assert any(item[:2] == ["worktree", "prune"] for item in calls) + remove_at = next(i for i, item in enumerate(calls) if item[:2] == ["worktree", "remove"]) + prune_at = next(i for i, item in enumerate(calls) if item[:2] == ["worktree", "prune"]) + assert remove_at < prune_at + + +def test_ephemeral_workspace_is_documented_in_prompt() -> None: + prompt = build_child_prompt( + "map src", + workspace_path="/tmp/child", + ephemeral_workspace=True, + ) + assert "ephemeral git worktree" in prompt + assert IsolationMode.WORKTREE.value == "worktree"