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
3 changes: 0 additions & 3 deletions ddev/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,3 @@ ban-relative-imports = "parents"
[tool.ruff.lint.per-file-ignores]
#Tests can use assertions and relative imports
"**/tests/**/*" = ["I252"]

[tool.pytest.ini_options]
asyncio_mode = "auto"
100 changes: 92 additions & 8 deletions ddev/src/ddev/ai/agent/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from __future__ import annotations

from collections.abc import Callable
from pathlib import Path
from typing import TYPE_CHECKING, Any

from ddev.ai.agent.anthropic_client import AnthropicAgent
Expand All @@ -15,7 +16,14 @@
if TYPE_CHECKING:
from ddev.ai.phases.config import AgentConfig

AgentBuilder = Callable[[str, str], tuple[BaseAgent[Any], ToolRegistry]]
SubagentBuilder = Callable[
[str, str, list[str]], # (system_prompt, owner_id, tool_names)
tuple[BaseAgent[Any], ToolRegistry],
]
AgentBuilder = Callable[
[str, str, SubagentBuilder | None, Path | None], # system_prompt, owner_id, subagent_builder, log_dir
tuple[BaseAgent[Any], ToolRegistry],
]


def _resolve_client(agent_clients: dict[str, Any], provider: str) -> Any:
Expand All @@ -25,19 +33,22 @@ def _resolve_client(agent_clients: dict[str, Any], provider: str) -> Any:
return client


def build_agent(
def _build_agent_and_registry(
agent_config: AgentConfig,
agent_clients: dict[str, Any],
system_prompt: str,
owner_id: str,
tool_names: list[str],
file_registry: FileRegistry,
subagent_builder: SubagentBuilder | None = None,
log_dir: Path | None = None,
) -> tuple[BaseAgent[Any], ToolRegistry]:
"""Construct a provider-specific BaseAgent and its ToolRegistry from an AgentConfig."""

tool_registry = ToolRegistry.from_names(
agent_config.tools,
tool_names,
owner_id=owner_id,
file_registry=file_registry,
subagent_builder=subagent_builder,
log_dir=log_dir,
)

if agent_config.provider == "anthropic":
Expand All @@ -58,20 +69,93 @@ def build_agent(
raise ValueError(f"Unknown agent provider: {agent_config.provider!r}")


def build_agent(
agent_config: AgentConfig,
agent_clients: dict[str, Any],
system_prompt: str,
owner_id: str,
file_registry: FileRegistry,
subagent_builder: SubagentBuilder | None = None,
log_dir: Path | None = None,
) -> tuple[BaseAgent[Any], ToolRegistry]:
"""Construct a provider-specific BaseAgent and its ToolRegistry from an AgentConfig."""
return _build_agent_and_registry(
agent_config=agent_config,
agent_clients=agent_clients,
system_prompt=system_prompt,
owner_id=owner_id,
tool_names=agent_config.tools,
file_registry=file_registry,
subagent_builder=subagent_builder,
log_dir=log_dir,
)


def build_subagent(
parent_agent_config: AgentConfig,
agent_clients: dict[str, Any],
file_registry: FileRegistry,
system_prompt: str,
owner_id: str,
tool_names: list[str],
) -> tuple[BaseAgent[Any], ToolRegistry]:
"""Build a subagent + ToolRegistry using the shared FileRegistry.

Reuses the parent's provider/model/max_tokens. No subagent_builder or
log_dir is forwarded, so the subagent cannot recursively spawn subagents —
ToolRegistry.from_names will raise if spawn_subagent is in tool_names.
"""
return _build_agent_and_registry(
agent_config=parent_agent_config,
agent_clients=agent_clients,
system_prompt=system_prompt,
owner_id=owner_id,
tool_names=tool_names,
file_registry=file_registry,
)


def make_agent_builder(
agent_config: AgentConfig,
agent_clients: dict[str, Any],
file_registry: FileRegistry,
) -> AgentBuilder:
"""Return a closure that builds an agent+registry given a rendered system_prompt and owner_id."""

def builder(system_prompt: str, owner_id: str) -> tuple[BaseAgent[Any], ToolRegistry]:
"""Return a closure that builds an agent+registry given system_prompt, owner_id, subagent_builder, log_dir."""

def builder(
system_prompt: str,
owner_id: str,
subagent_builder: SubagentBuilder | None,
log_dir: Path | None,
) -> tuple[BaseAgent[Any], ToolRegistry]:
return build_agent(
agent_config=agent_config,
agent_clients=agent_clients,
system_prompt=system_prompt,
owner_id=owner_id,
file_registry=file_registry,
subagent_builder=subagent_builder,
log_dir=log_dir,
)

return builder


def make_subagent_builder(
parent_agent_config: AgentConfig,
agent_clients: dict[str, Any],
file_registry: FileRegistry,
) -> SubagentBuilder:
"""Return a closure that builds a subagent+registry given (system_prompt, owner_id, tool_names)."""

def builder(system_prompt: str, owner_id: str, tool_names: list[str]) -> tuple[BaseAgent[Any], ToolRegistry]:
return build_subagent(
parent_agent_config=parent_agent_config,
agent_clients=agent_clients,
file_registry=file_registry,
system_prompt=system_prompt,
owner_id=owner_id,
tool_names=tool_names,
)

return builder
39 changes: 34 additions & 5 deletions ddev/src/ddev/ai/phases/agentic_phase.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@
from typing import Any

from ddev.ai.agent.base import BaseAgent
from ddev.ai.agent.build import AgentBuilder, make_agent_builder
from ddev.ai.agent.build import AgentBuilder, SubagentBuilder, make_agent_builder, make_subagent_builder
from ddev.ai.callbacks.callbacks import Callbacks
from ddev.ai.phases.base import Phase, PhaseOutcome
from ddev.ai.phases.checkpoint import CheckpointManager
from ddev.ai.phases.config import AgentConfig, CheckpointConfig, FlowConfigError, PhaseConfig, TaskConfig
from ddev.ai.phases.template import render_inline, render_prompt
from ddev.ai.react.process import ReActProcess
from ddev.ai.tools.fs.file_registry import FileRegistry
from ddev.ai.tools.registry import TOOL_MANIFEST


def render_task_prompt(
Expand Down Expand Up @@ -59,6 +60,7 @@ def __init__(
flow_variables: dict[str, str],
config_dir: Path,
file_registry: FileRegistry,
subagent_builder: SubagentBuilder | None = None,
callbacks: Callbacks | None = None,
logger: logging.Logger | None = None,
) -> None:
Expand All @@ -75,6 +77,10 @@ def __init__(
logger=logger,
)
self._agent_builder = agent_builder
self._subagent_builder = subagent_builder
self._subagent_log_dir = (
checkpoint_manager.root / "subagents" / phase_id if subagent_builder is not None else None
)

@classmethod
def validate_config(
Expand All @@ -91,22 +97,40 @@ def validate_config(
raise FlowConfigError(f"Phase {phase_id!r} (AgenticPhase) must have at least one task")

@classmethod
def extra_init_kwargs(
def extra_init_kwargs( # type: ignore[override]
cls,
*,
phase_id: str,
phase_config: PhaseConfig,
agents: dict[str, AgentConfig],
agent_clients: dict[str, Any],
file_registry: FileRegistry,
**_: Any,
) -> dict[str, Any]:
if phase_config.agent is None:
raise FlowConfigError(f"Phase {phase_id!r} (AgenticPhase) requires 'agent'")
agent_config = agents[phase_config.agent]

subagent_builder = None
requires_subagent_builder = any(
spec.requires_subagent_builder
for name in agent_config.tools
if (spec := TOOL_MANIFEST.get(name)) is not None
)
if requires_subagent_builder:
subagent_builder = make_subagent_builder(
parent_agent_config=agent_config,
agent_clients=agent_clients,
file_registry=file_registry,
)

return {
"agent_builder": make_agent_builder(
agent_config=agents[phase_config.agent],
agent_config=agent_config,
agent_clients=agent_clients,
file_registry=file_registry,
)
),
"subagent_builder": subagent_builder,
}

def before_react(self) -> None:
Expand Down Expand Up @@ -146,7 +170,12 @@ def _build_agent_and_process(self, context: dict[str, Any]) -> tuple[BaseAgent[A
context,
self._resolver,
)
agent, tool_registry = self._agent_builder(system_prompt, self._phase_id)
agent, tool_registry = self._agent_builder(
system_prompt,
self._phase_id,
self._subagent_builder,
self._subagent_log_dir,
)
process = ReActProcess(
agent=agent,
tool_registry=tool_registry,
Expand Down
17 changes: 8 additions & 9 deletions ddev/src/ddev/ai/phases/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,15 +109,14 @@ def validate_config(
return None

@classmethod
def extra_init_kwargs(
cls,
phase_id: str,
phase_config: PhaseConfig,
agents: dict[str, AgentConfig],
agent_clients: dict[str, Any],
file_registry: FileRegistry,
) -> dict[str, Any]:
"""Override to inject subclass-specific kwargs into __init__ at construction time."""
def extra_init_kwargs(cls, **kwargs: Any) -> dict[str, Any]:
"""Override to inject subclass-specific kwargs into __init__ at construction time.

The orchestrator passes every framework-level dep (phase_id, phase_config, agents,
agent_clients, file_registry, checkpoint_manager, ...) as keyword arguments.
Subclasses pick the ones they need by declaring them explicitly and accept the
rest via **kwargs.
"""
return {}

@abstractmethod
Expand Down
9 changes: 7 additions & 2 deletions ddev/src/ddev/ai/phases/checkpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,13 @@ class CheckpointManager:
def __init__(self, path: Path) -> None:
self._path = path

@property
def root(self) -> Path:
"""Directory that holds checkpoints.yaml, per-phase memory files, and any side artifacts."""
return self._path.parent

def _ensure_dir(self) -> None:
self._path.parent.mkdir(parents=True, exist_ok=True)
self.root.mkdir(parents=True, exist_ok=True)

def read(self) -> dict[str, Any]:
"""Return full checkpoint data, keyed by phase_id. Empty dict if file absent."""
Expand All @@ -44,7 +49,7 @@ def build_memory_prompt(self, user_additions: str | None) -> str:

def memory_path(self, phase_id: str) -> Path:
"""Return the resolved path to a phase's memory file."""
return (self._path.parent / f"{phase_id}_memory.md").resolve()
return (self.root / f"{phase_id}_memory.md").resolve()

def write_memory(self, phase_id: str, text: str) -> None:
"""Write agent-authored text to this phase's memory file."""
Expand Down
2 changes: 2 additions & 0 deletions ddev/src/ddev/ai/react/process.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ async def start(self, prompt: str, allowed_tools: list[str] | None = None) -> Re
r if isinstance(r, ToolResult) else ToolResult(success=False, error=f"{type(r).__name__}: {r}")
for r in raw_results
]
total_input += sum(result.total_input_tokens for result in tool_results)
total_output += sum(result.total_output_tokens for result in tool_results)

tool_call_results = list(zip(response.tool_calls, tool_results, strict=True))

Expand Down
3 changes: 3 additions & 0 deletions ddev/src/ddev/ai/tools/agents/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# (C) Datadog, Inc. 2026-present
# All rights reserved
# Licensed under a 3-clause BSD style license (see LICENSE)
Loading
Loading