Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion consumer-facing-api/HARNESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
60 changes: 60 additions & 0 deletions docs/designs/2026-08-09-lean-subagent-redesign.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# 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;
child permission cwd is the worktree; edits are discarded on join and never
merge back to the parent)

## 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
49 changes: 38 additions & 11 deletions src/dream/_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -681,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,
Expand Down Expand Up @@ -792,17 +798,38 @@ 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 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, Path):
session_working_dir = override_cwd
elif isinstance(override_cwd, str) and override_cwd:
session_working_dir = Path(override_cwd)

child_gate = permission_gate
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
Comment on lines +809 to +824

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: For a worktree-isolated child, session_working_dir points at the temporary worktree, but child_gate still wraps the permission policy created for the parent's working_dir. The permission checker therefore treats writes inside the child worktree as outside the allowed repository boundary and denies mutating tools, making IsolationMode.WORKTREE unusable for the operations it is intended to isolate. Build the child permission gate against the effective session working directory while preserving the parent's policy restrictions. [api mismatch]

Severity Level: Major ⚠️
- ❌ WORKTREE children cannot modify isolated checkout files.
- ⚠️ Isolated planning or verification workflows lose write-capable tools.
- ⚠️ Child sessions report permission denials instead of useful results.

Fix in Cursor Fix in VSCode Claude

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/dream/_factory.py
**Line:** 791:800
**Comment:**
	*Api Mismatch: For a worktree-isolated child, `session_working_dir` points at the temporary worktree, but `child_gate` still wraps the permission policy created for the parent's `working_dir`. The permission checker therefore treats writes inside the child worktree as outside the allowed repository boundary and denies mutating tools, making `IsolationMode.WORKTREE` unusable for the operations it is intended to isolate. Build the child permission gate against the effective session working directory while preserving the parent's policy restrictions.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎


# 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.
if OBSERVER_KEY in options.metadata:
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
Expand Down Expand Up @@ -834,10 +861,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,
Expand Down
44 changes: 36 additions & 8 deletions src/dream/subagents/__init__.py
Original file line number Diff line number Diff line change
@@ -1,34 +1,62 @@
"""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,
PermissionOverlay,
Subagent,
SubagentSet,
)
from dream.subagents._isolation import IsolationMode
from dream.subagents._projection import SubagentResult, project_subagent
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",
"IsolationMode",
"PermissionDelta",
"PermissionOverlay",
"Subagent",
"SubagentCatalogue",
"SubagentCatalogueEntry",
"SubagentRegistry",
"SubagentResult",
"SubagentSet",
"builtin_agents",
"merge_builtins",
"project_subagent",
]
Loading
Loading