Skip to content
59 changes: 54 additions & 5 deletions ddev/src/ddev/ai/agent/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,14 @@

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

from ddev.ai.agent.anthropic_client import AnthropicAgent
from ddev.ai.agent.base import BaseAgent
from ddev.ai.phases.config import AgentConfig
from ddev.ai.phases.goal import GOAL_REVIEWER_SYSTEM_PROMPT
from ddev.ai.tools.fs.file_registry import FileRegistry
from ddev.ai.tools.registry import ToolRegistry

if TYPE_CHECKING:
from ddev.ai.phases.config import AgentConfig
from ddev.ai.tools.registry import ToolRegistry, filter_read_only

SubagentBuilder = Callable[
[str, str, list[str]], # (system_prompt, owner_id, tool_names)
Expand All @@ -24,6 +23,10 @@
[str, str, SubagentBuilder | None, Path | None], # system_prompt, owner_id, subagent_builder, log_dir
tuple[BaseAgent[Any], ToolRegistry],
]
GoalAgentBuilder = Callable[
[str], # owner_id
tuple[BaseAgent[Any], ToolRegistry],
]


def _resolve_client(agent_clients: dict[str, Any], provider: str) -> Any:
Expand Down Expand Up @@ -159,3 +162,49 @@ def builder(system_prompt: str, owner_id: str, tool_names: list[str]) -> tuple[B
)

return builder


def build_goal_agent(
parent_agent_config: AgentConfig,
agent_clients: dict[str, Any],
file_registry: FileRegistry,
owner_id: str,
) -> tuple[BaseAgent[Any], ToolRegistry]:
"""Build the reviewer agent + its ToolRegistry.

Uses the same provider as the parent agent. Model and max_tokens are left at
provider defaults — the parent's overrides are intentionally not forwarded.
Tools are filtered to the read-only subset of the parent's tool list.
"""
read_only_tool_names = filter_read_only(parent_agent_config.tools)
goal_agent_config = AgentConfig(
provider=parent_agent_config.provider,
tools=read_only_tool_names,
)

return _build_agent_and_registry(
agent_config=goal_agent_config,
agent_clients=agent_clients,
system_prompt=GOAL_REVIEWER_SYSTEM_PROMPT,
owner_id=owner_id,
tool_names=read_only_tool_names,
file_registry=file_registry,
)


def make_goal_agent_builder(
parent_agent_config: AgentConfig,
agent_clients: dict[str, Any],
file_registry: FileRegistry,
) -> GoalAgentBuilder:
"""Return a closure that builds a (reviewer_agent, reviewer_registry) tuple."""

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

return builder
36 changes: 36 additions & 0 deletions ddev/src/ddev/ai/callbacks/callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,18 @@ class OnPhaseFinishCallback(Protocol):
async def __call__(self, phase_id: str) -> None: ...


class OnBeforeGoalCheckCallback(Protocol):
"""Called immediately before each reviewer agent run for a task with a goal."""

async def __call__(self, task_name: str, attempt: int) -> None: ...


class OnAfterGoalCheckCallback(Protocol):
"""Called after each reviewer agent run, with the parsed verdict."""

async def __call__(self, task_name: str, attempt: int, valid: bool, reason: str) -> None: ...


# ---------------------------------------------------------------------------
# CallbackSet and Callbacks
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -103,6 +115,8 @@ def __init__(self) -> None:
self._on_before_agent_send: list[OnBeforeAgentSendCallback] = []
self._on_phase_start: list[OnPhaseStartCallback] = []
self._on_phase_finish: list[OnPhaseFinishCallback] = []
self._on_before_goal_check: list[OnBeforeGoalCheckCallback] = []
self._on_after_goal_check: list[OnAfterGoalCheckCallback] = []

async def _fire(self, handlers: list[Any], *args: Any) -> None:
for handler in handlers:
Expand Down Expand Up @@ -174,6 +188,20 @@ def on_phase_finish(self, func: OnPhaseFinishCallback) -> OnPhaseFinishCallback:
async def fire_phase_finish(self, phase_id: str) -> None:
await self._fire(self._on_phase_finish, phase_id)

def on_before_goal_check(self, func: OnBeforeGoalCheckCallback) -> OnBeforeGoalCheckCallback:
self._on_before_goal_check.append(func)
return func

async def fire_before_goal_check(self, task_name: str, attempt: int) -> None:
await self._fire(self._on_before_goal_check, task_name, attempt)

def on_after_goal_check(self, func: OnAfterGoalCheckCallback) -> OnAfterGoalCheckCallback:
self._on_after_goal_check.append(func)
return func

async def fire_after_goal_check(self, task_name: str, attempt: int, valid: bool, reason: str) -> None:
await self._fire(self._on_after_goal_check, task_name, attempt, valid, reason)


class Callbacks:
"""Container of CallbackSet instances. Dispatches each fire_* to all contained sets."""
Expand Down Expand Up @@ -216,3 +244,11 @@ async def fire_phase_start(self, phase_id: str) -> None:
async def fire_phase_finish(self, phase_id: str) -> None:
for s in self._sets:
await s.fire_phase_finish(phase_id)

async def fire_before_goal_check(self, task_name: str, attempt: int) -> None:
for s in self._sets:
await s.fire_before_goal_check(task_name, attempt)

async def fire_after_goal_check(self, task_name: str, attempt: int, valid: bool, reason: str) -> None:
for s in self._sets:
await s.fire_after_goal_check(task_name, attempt, valid, reason)
Loading
Loading