Skip to content

Commit bbc9048

Browse files
committed
Make Phase and Orchestrator model-agnostic
1 parent 1f068d0 commit bbc9048

10 files changed

Lines changed: 196 additions & 258 deletions

File tree

ddev/src/ddev/ai/agent/build.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# (C) Datadog, Inc. 2026-present
2+
# All rights reserved
3+
# Licensed under a 3-clause BSD style license (see LICENSE)
4+
5+
from __future__ import annotations
6+
7+
from collections.abc import Callable
8+
from typing import TYPE_CHECKING, Any
9+
10+
from ddev.ai.agent.anthropic_client import AnthropicAgent
11+
from ddev.ai.agent.base import BaseAgent
12+
from ddev.ai.tools.fs.file_registry import FileRegistry
13+
from ddev.ai.tools.registry import ToolRegistry
14+
15+
if TYPE_CHECKING:
16+
from ddev.ai.phases.config import AgentConfig
17+
18+
AgentBuilder = Callable[[str, str], tuple[BaseAgent[Any], ToolRegistry]]
19+
20+
21+
def _resolve_client(agent_clients: dict[str, Any], provider: str) -> Any:
22+
client = agent_clients.get(provider)
23+
if client is None:
24+
raise ValueError(f"No client provided for agent provider {provider!r}")
25+
return client
26+
27+
28+
def build_agent(
29+
agent_config: AgentConfig,
30+
agent_clients: dict[str, Any],
31+
system_prompt: str,
32+
owner_id: str,
33+
file_registry: FileRegistry,
34+
) -> tuple[BaseAgent[Any], ToolRegistry]:
35+
"""Construct a provider-specific BaseAgent and its ToolRegistry from an AgentConfig."""
36+
37+
tool_registry = ToolRegistry.from_names(
38+
agent_config.tools,
39+
owner_id=owner_id,
40+
file_registry=file_registry,
41+
)
42+
43+
if agent_config.provider == "anthropic":
44+
kwargs: dict[str, Any] = {}
45+
if agent_config.model is not None:
46+
kwargs["model"] = agent_config.model
47+
if agent_config.max_tokens is not None:
48+
kwargs["max_tokens"] = agent_config.max_tokens
49+
agent: BaseAgent[Any] = AnthropicAgent(
50+
client=_resolve_client(agent_clients, "anthropic"),
51+
tools=tool_registry,
52+
system_prompt=system_prompt,
53+
name=owner_id,
54+
**kwargs,
55+
)
56+
return agent, tool_registry
57+
58+
raise ValueError(f"Unknown agent provider: {agent_config.provider!r}")
59+
60+
61+
def make_agent_builder(
62+
agent_config: AgentConfig,
63+
agent_clients: dict[str, Any],
64+
file_registry: FileRegistry,
65+
) -> AgentBuilder:
66+
"""Return a closure that builds an agent+registry given a rendered system_prompt and owner_id."""
67+
68+
def builder(system_prompt: str, owner_id: str) -> tuple[BaseAgent[Any], ToolRegistry]:
69+
return build_agent(
70+
agent_config=agent_config,
71+
agent_clients=agent_clients,
72+
system_prompt=system_prompt,
73+
owner_id=owner_id,
74+
file_registry=file_registry,
75+
)
76+
77+
return builder

ddev/src/ddev/ai/phases/agentic_phase.py

Lines changed: 7 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,15 @@
77
from pathlib import Path
88
from typing import Any
99

10-
import anthropic
11-
12-
from ddev.ai.agent.anthropic_client import AnthropicAgent
10+
from ddev.ai.agent.base import BaseAgent
11+
from ddev.ai.agent.build import AgentBuilder
1312
from ddev.ai.callbacks.callbacks import Callbacks
1413
from ddev.ai.phases.base import Phase, PhaseOutcome
1514
from ddev.ai.phases.checkpoint import CheckpointManager
1615
from ddev.ai.phases.config import AgentConfig, CheckpointConfig, FlowConfigError, PhaseConfig, TaskConfig
1716
from ddev.ai.phases.template import render_inline, render_prompt
1817
from ddev.ai.react.process import ReActProcess
1918
from ddev.ai.tools.fs.file_registry import FileRegistry
20-
from ddev.ai.tools.registry import ToolRegistry
2119

2220

2321
def render_task_prompt(
@@ -55,8 +53,7 @@ def __init__(
5553
phase_id: str,
5654
dependencies: list[str],
5755
config: PhaseConfig,
58-
agent_config: AgentConfig,
59-
anthropic_client: anthropic.AsyncAnthropic,
56+
agent_builder: AgentBuilder,
6057
checkpoint_manager: CheckpointManager,
6158
runtime_variables: dict[str, str],
6259
flow_variables: dict[str, str],
@@ -77,8 +74,7 @@ def __init__(
7774
callbacks=callbacks,
7875
logger=logger,
7976
)
80-
self._agent_config = agent_config
81-
self._anthropic_client = anthropic_client
77+
self._agent_builder = agent_builder
8278

8379
@classmethod
8480
def validate_config(
@@ -124,33 +120,14 @@ async def run_tasks(
124120
total_output += last_result.total_output_tokens
125121
return total_input, total_output
126122

127-
def _build_agent_and_process(self, context: dict[str, Any]) -> tuple[AnthropicAgent, ReActProcess]:
123+
def _build_agent_and_process(self, context: dict[str, Any]) -> tuple[BaseAgent[Any], ReActProcess]:
128124
"""Build the agent and ReAct process used to drive task execution."""
129125
system_prompt = render_prompt(
130126
self._config_dir / "prompts" / f"{self._config.agent}.md",
131127
context,
132128
self._resolver,
133129
)
134-
tool_registry = ToolRegistry.from_names(
135-
self._agent_config.tools,
136-
owner_id=self._phase_id,
137-
file_registry=self._file_registry,
138-
)
139-
140-
agent_kwargs: dict[str, Any] = {}
141-
if self._agent_config.model is not None:
142-
agent_kwargs["model"] = self._agent_config.model
143-
if self._agent_config.max_tokens is not None:
144-
agent_kwargs["max_tokens"] = self._agent_config.max_tokens
145-
146-
agent = AnthropicAgent(
147-
client=self._anthropic_client,
148-
tools=tool_registry,
149-
system_prompt=system_prompt,
150-
name=self._phase_id,
151-
**agent_kwargs,
152-
)
153-
130+
agent, tool_registry = self._agent_builder(system_prompt, self._phase_id)
154131
process = ReActProcess(
155132
agent=agent,
156133
tool_registry=tool_registry,
@@ -160,7 +137,7 @@ def _build_agent_and_process(self, context: dict[str, Any]) -> tuple[AnthropicAg
160137

161138
async def _run_memory_step(
162139
self,
163-
agent: AnthropicAgent,
140+
agent: BaseAgent[Any],
164141
context: dict[str, Any],
165142
) -> tuple[str, int, int]:
166143
"""Run the final summary turn. Returns (memory_text, input_tokens, output_tokens)."""

ddev/src/ddev/ai/phases/base.py

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -43,17 +43,6 @@ def get(self, name: str) -> type["Phase"]:
4343
return self._registry[name]
4444

4545

46-
def _make_memory_resolver(checkpoint_manager: CheckpointManager) -> Callable[[str], str]:
47-
"""Build a resolver that reads phase memory files on demand for template substitution."""
48-
49-
def resolve(key: str) -> str:
50-
if key.endswith("_memory"):
51-
return checkpoint_manager.memory_content(key.removesuffix("_memory"))
52-
return f"<VARIABLE UNDEFINED: {key}>"
53-
54-
return resolve
55-
56-
5746
class Phase(AsyncProcessor[PhaseTrigger]):
5847
"""Lifecycle base for all phases.
5948
@@ -133,7 +122,7 @@ async def process_message(self, message: PhaseTrigger) -> None:
133122
"phase_name": self._phase_id,
134123
"checkpoints": self._checkpoint_manager.read(),
135124
}
136-
self._resolver = _make_memory_resolver(self._checkpoint_manager)
125+
self._resolver = self._checkpoint_manager.resolve_template_variable
137126

138127
outcome = await self.execute(context)
139128

ddev/src/ddev/ai/phases/checkpoint.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,9 @@ def memory_content(self, phase_id: str) -> str:
5555
"""Return the contents of a phase's memory file, or a NOT FOUND placeholder."""
5656
path = self.memory_path(phase_id)
5757
return path.read_text(encoding="utf-8") if path.exists() else f"<MEMORY NOT FOUND: {phase_id}>"
58+
59+
def resolve_template_variable(self, key: str) -> str:
60+
"""Resolve a template variable. ``<phase>_memory`` keys read the matching memory file."""
61+
if key.endswith("_memory"):
62+
return self.memory_content(key.removesuffix("_memory"))
63+
return f"<VARIABLE UNDEFINED: {key}>"

ddev/src/ddev/ai/phases/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ def exactly_one_source(self) -> CheckpointConfig:
8080

8181
class AgentConfig(BaseModel):
8282
model_config = ConfigDict(extra="forbid")
83+
provider: str = "anthropic"
8384
model: str | None = None
8485
max_tokens: int | None = None
8586
tools: list[str] = []

ddev/src/ddev/ai/phases/orchestrator.py

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,7 @@
88
from pathlib import Path
99
from typing import Any
1010

11-
import anthropic
12-
11+
from ddev.ai.agent.build import make_agent_builder
1312
from ddev.ai.callbacks.callbacks import Callbacks
1413
from ddev.ai.phases.agentic_phase import AgenticPhase
1514
from ddev.ai.phases.base import Phase, PhaseRegistry
@@ -48,22 +47,26 @@ def __init__(
4847
flow_yaml_path: Path,
4948
checkpoint_path: Path,
5049
runtime_variables: dict[str, str],
51-
anthropic_client: anthropic.AsyncAnthropic,
50+
agent_clients: dict[str, Any],
5251
file_access_policy: FileAccessPolicy,
5352
callbacks: Callbacks | None = None,
5453
grace_period: float = 10,
5554
logger: logging.Logger | None = None,
5655
) -> None:
5756
"""Initialize the orchestrator.
5857
58+
``agent_clients`` maps provider name (e.g. ``"anthropic"``) to a constructed
59+
provider client. ``build_agent`` looks up the right one based on each
60+
``AgentConfig.provider``.
61+
5962
``file_access_policy`` must have ``write_root`` set to the integration
6063
output directory so that agent writes are confined to that path.
6164
"""
6265
super().__init__(logger=logger or logging.getLogger(__name__), grace_period=grace_period)
6366
self._flow_yaml_path = flow_yaml_path
6467
self._checkpoint_path = checkpoint_path
6568
self._runtime_variables = runtime_variables
66-
self._anthropic_client = anthropic_client
69+
self._agent_clients = agent_clients
6770
self._callbacks: Callbacks = callbacks or Callbacks()
6871
self._phase_registry = PhaseRegistry()
6972
self._failed_phase: str | None = None
@@ -112,11 +115,13 @@ async def on_initialize(self) -> None:
112115
"logger": self._logger,
113116
}
114117
if issubclass(phase_cls, AgenticPhase):
115-
if phase_config.agent is not None:
116-
phase_kwargs["agent_config"] = config.agents[phase_config.agent]
117-
phase_kwargs["anthropic_client"] = self._anthropic_client
118-
else:
118+
if phase_config.agent is None:
119119
raise FlowConfigError(f"Phase '{phase_id}': agent must be set for AgenticPhase")
120+
phase_kwargs["agent_builder"] = make_agent_builder(
121+
agent_config=config.agents[phase_config.agent],
122+
agent_clients=self._agent_clients,
123+
file_registry=self._file_registry,
124+
)
120125

121126
phase = phase_cls(**phase_kwargs)
122127

ddev/tests/ai/phases/conftest.py

Lines changed: 14 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,13 @@
44

55
import asyncio
66
from typing import Any
7-
from unittest.mock import MagicMock
87

98
import pytest
109

1110
from ddev.ai.agent.types import AgentResponse, ContextUsage, StopReason, TokenUsage, ToolResultMessage
1211
from ddev.ai.phases.agentic_phase import AgenticPhase
1312
from ddev.ai.phases.checkpoint import CheckpointManager
14-
from ddev.ai.phases.config import AgentConfig, PhaseConfig, TaskConfig
13+
from ddev.ai.phases.config import PhaseConfig, TaskConfig
1514
from ddev.ai.tools.fs.file_access_policy import FileAccessPolicy
1615
from ddev.ai.tools.fs.file_registry import FileRegistry
1716
from ddev.ai.tools.registry import ToolRegistry
@@ -86,24 +85,21 @@ async def compact_preserving_last_turn(self) -> AgentResponse | None:
8685
return None
8786

8887

89-
def make_agent_factory(mock_agent: MockAgent, captured_kwargs: dict[str, Any] | None = None):
90-
"""Create a callable that replaces AnthropicAgent constructor, returning the given mock.
88+
def make_agent_builder(mock_agent: MockAgent, captured_kwargs: dict[str, Any] | None = None):
89+
"""Create an agent_builder that returns the given mock and an empty ToolRegistry.
9190
92-
If ``captured_kwargs`` is provided, every call updates it with the kwargs passed to
93-
the constructor — useful for asserting on system_prompt, tools, etc.
91+
If ``captured_kwargs`` is provided, every call records the system_prompt and
92+
owner_id passed in — useful for asserting on prompt rendering.
9493
"""
9594

96-
def factory(**kwargs: Any) -> MockAgent:
95+
def builder(system_prompt: str, owner_id: str) -> tuple[MockAgent, ToolRegistry]:
9796
if captured_kwargs is not None:
98-
captured_kwargs.update(kwargs)
99-
mock_agent.name = kwargs.get("name", "mock")
100-
return mock_agent
97+
captured_kwargs["system_prompt"] = system_prompt
98+
captured_kwargs["owner_id"] = owner_id
99+
mock_agent.name = owner_id
100+
return mock_agent, ToolRegistry([])
101101

102-
return factory
103-
104-
105-
def _empty_registry_from_names(cls, names, *, owner_id, file_registry):
106-
return ToolRegistry([])
102+
return builder
107103

108104

109105
def make_agent_phase(
@@ -116,7 +112,6 @@ def make_agent_phase(
116112
dependencies: list[str] | None = None,
117113
tasks: list[TaskConfig] | None = None,
118114
checkpoint=None,
119-
agent_tools: list[str] | None = None,
120115
flow_variables: dict[str, str] | None = None,
121116
runtime_variables: dict[str, str] | None = None,
122117
context_compact_threshold_pct: int = 80,
@@ -125,31 +120,22 @@ def make_agent_phase(
125120
) -> tuple[AgenticPhase, CheckpointManager]:
126121
"""Build an AgenticPhase ready for process_message-driven tests.
127122
128-
Patches ``AnthropicAgent`` and ``ToolRegistry.from_names`` so no real LLM or tools
129-
are constructed. Pass ``captured_agent_kwargs`` (a dict) to record AnthropicAgent
130-
constructor kwargs across calls (e.g. to inspect system_prompt rendering).
123+
Injects a mock agent_builder so no real LLM or tools are constructed. Pass
124+
``captured_agent_kwargs`` (a dict) to record the rendered system_prompt and owner_id.
131125
"""
132-
monkeypatch.setattr(
133-
"ddev.ai.phases.agentic_phase.AnthropicAgent",
134-
make_agent_factory(mock_agent, captured_agent_kwargs),
135-
)
136-
monkeypatch.setattr(ToolRegistry, "from_names", classmethod(_empty_registry_from_names))
137-
138126
config = PhaseConfig(
139127
agent="writer",
140128
tasks=tasks or [TaskConfig(name="t1", prompt="Do the work.")],
141129
checkpoint=checkpoint,
142130
context_compact_threshold_pct=context_compact_threshold_pct,
143131
)
144-
agent_config = AgentConfig(tools=agent_tools or [])
145132
checkpoint_manager = CheckpointManager(flow_dir / "checkpoints.yaml")
146133

147134
phase = AgenticPhase(
148135
phase_id=phase_id,
149136
dependencies=dependencies or [],
150137
config=config,
151-
agent_config=agent_config,
152-
anthropic_client=MagicMock(),
138+
agent_builder=make_agent_builder(mock_agent, captured_agent_kwargs),
153139
checkpoint_manager=checkpoint_manager,
154140
runtime_variables=runtime_variables or {},
155141
flow_variables=flow_variables or {},

0 commit comments

Comments
 (0)