Skip to content

Commit a0fae18

Browse files
committed
Add Phase.extra_init_kwargs and agent/build.py tests
1 parent bbc9048 commit a0fae18

6 files changed

Lines changed: 122 additions & 15 deletions

File tree

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

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from typing import Any
99

1010
from ddev.ai.agent.base import BaseAgent
11-
from ddev.ai.agent.build import AgentBuilder
11+
from ddev.ai.agent.build import AgentBuilder, make_agent_builder
1212
from ddev.ai.callbacks.callbacks import Callbacks
1313
from ddev.ai.phases.base import Phase, PhaseOutcome
1414
from ddev.ai.phases.checkpoint import CheckpointManager
@@ -90,6 +90,25 @@ def validate_config(
9090
if not config.tasks:
9191
raise FlowConfigError(f"Phase {phase_id!r} (AgenticPhase) must have at least one task")
9292

93+
@classmethod
94+
def extra_init_kwargs(
95+
cls,
96+
phase_id: str,
97+
phase_config: PhaseConfig,
98+
agents: dict[str, AgentConfig],
99+
agent_clients: dict[str, Any],
100+
file_registry: FileRegistry,
101+
) -> dict[str, Any]:
102+
if phase_config.agent is None:
103+
raise FlowConfigError(f"Phase {phase_id!r} (AgenticPhase) requires 'agent'")
104+
return {
105+
"agent_builder": make_agent_builder(
106+
agent_config=agents[phase_config.agent],
107+
agent_clients=agent_clients,
108+
file_registry=file_registry,
109+
)
110+
}
111+
93112
def before_react(self) -> None:
94113
"""Called once before agent/tools are created. Override for phase-specific setup."""
95114

@@ -152,9 +171,6 @@ async def _run_memory_step(
152171
return response.text, response.usage.input_tokens, response.usage.output_tokens
153172

154173
async def execute(self, context: dict[str, Any]) -> PhaseOutcome:
155-
if self._config.agent is None:
156-
raise FlowConfigError(f"Phase '{self._phase_id}': agent must be set before execute()")
157-
158174
self.before_react()
159175
agent, process = self._build_agent_and_process(context)
160176
total_input, total_output = await self.run_tasks(process, context)

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

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,18 @@ def validate_config(
108108
"""Override to enforce per-subclass config invariants. Raise FlowConfigError on mismatch."""
109109
return None
110110

111+
@classmethod
112+
def extra_init_kwargs(
113+
cls,
114+
phase_id: str,
115+
phase_config: PhaseConfig,
116+
agents: dict[str, AgentConfig],
117+
agent_clients: dict[str, Any],
118+
file_registry: FileRegistry,
119+
) -> dict[str, Any]:
120+
"""Override to inject subclass-specific kwargs into __init__ at construction time."""
121+
return {}
122+
111123
@abstractmethod
112124
async def execute(self, context: dict[str, Any]) -> PhaseOutcome: ...
113125

@@ -126,8 +138,6 @@ async def process_message(self, message: PhaseTrigger) -> None:
126138

127139
outcome = await self.execute(context)
128140

129-
self._checkpoint_manager.write_memory(self._phase_id, outcome.memory_text)
130-
131141
checkpoint_payload: dict[str, Any] = {
132142
"status": "success",
133143
"started_at": self._started_at.isoformat(),
@@ -145,6 +155,7 @@ async def process_message(self, message: PhaseTrigger) -> None:
145155
)
146156
checkpoint_payload.update(outcome.extra_checkpoint)
147157

158+
self._checkpoint_manager.write_memory(self._phase_id, outcome.memory_text)
148159
self._checkpoint_manager.write_phase_checkpoint(self._phase_id, checkpoint_payload)
149160
await self._callbacks.fire_phase_finish(self._phase_id)
150161

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

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

11-
from ddev.ai.agent.build import make_agent_builder
1211
from ddev.ai.callbacks.callbacks import Callbacks
13-
from ddev.ai.phases.agentic_phase import AgenticPhase
1412
from ddev.ai.phases.base import Phase, PhaseRegistry
1513
from ddev.ai.phases.checkpoint import CheckpointManager
1614
from ddev.ai.phases.config import FlowConfig, FlowConfigError
@@ -114,14 +112,15 @@ async def on_initialize(self) -> None:
114112
"callbacks": self._callbacks,
115113
"logger": self._logger,
116114
}
117-
if issubclass(phase_cls, AgenticPhase):
118-
if phase_config.agent is None:
119-
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],
115+
phase_kwargs.update(
116+
phase_cls.extra_init_kwargs(
117+
phase_id=phase_id,
118+
phase_config=phase_config,
119+
agents=config.agents,
122120
agent_clients=self._agent_clients,
123121
file_registry=self._file_registry,
124122
)
123+
)
125124

126125
phase = phase_cls(**phase_kwargs)
127126

ddev/tests/ai/agent/test_build.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# (C) Datadog, Inc. 2026-present
2+
# All rights reserved
3+
# Licensed under a 3-clause BSD style license (see LICENSE)
4+
5+
from unittest.mock import MagicMock
6+
7+
import pytest
8+
9+
from ddev.ai.agent.anthropic_client import AnthropicAgent
10+
from ddev.ai.agent.build import build_agent, make_agent_builder
11+
from ddev.ai.phases.config import AgentConfig
12+
from ddev.ai.tools.fs.file_access_policy import FileAccessPolicy
13+
from ddev.ai.tools.fs.file_registry import FileRegistry
14+
from ddev.ai.tools.registry import ToolRegistry
15+
16+
17+
@pytest.fixture
18+
def file_registry(tmp_path) -> FileRegistry:
19+
return FileRegistry(policy=FileAccessPolicy(write_root=tmp_path))
20+
21+
22+
def test_build_agent_anthropic_returns_agent_and_registry(file_registry):
23+
agent_config = AgentConfig(provider="anthropic", model="claude-test", max_tokens=1024, tools=[])
24+
agent_clients = {"anthropic": MagicMock()}
25+
26+
agent, registry = build_agent(
27+
agent_config=agent_config,
28+
agent_clients=agent_clients,
29+
system_prompt="hello",
30+
owner_id="p1",
31+
file_registry=file_registry,
32+
)
33+
34+
assert isinstance(agent, AnthropicAgent)
35+
assert isinstance(registry, ToolRegistry)
36+
assert agent.name == "p1"
37+
38+
39+
def test_build_agent_missing_client_raises(file_registry):
40+
agent_config = AgentConfig(provider="anthropic", tools=[])
41+
with pytest.raises(ValueError, match="No client provided for agent provider 'anthropic'"):
42+
build_agent(
43+
agent_config=agent_config,
44+
agent_clients={},
45+
system_prompt="hello",
46+
owner_id="p1",
47+
file_registry=file_registry,
48+
)
49+
50+
51+
def test_build_agent_unknown_provider_raises(file_registry):
52+
agent_config = AgentConfig(provider="openai", tools=[])
53+
with pytest.raises(ValueError, match="Unknown agent provider: 'openai'"):
54+
build_agent(
55+
agent_config=agent_config,
56+
agent_clients={"openai": MagicMock()},
57+
system_prompt="hello",
58+
owner_id="p1",
59+
file_registry=file_registry,
60+
)
61+
62+
63+
def test_make_agent_builder_returns_callable_that_delegates_to_build_agent(file_registry):
64+
agent_config = AgentConfig(provider="anthropic", tools=[])
65+
agent_clients = {"anthropic": MagicMock()}
66+
67+
builder = make_agent_builder(
68+
agent_config=agent_config,
69+
agent_clients=agent_clients,
70+
file_registry=file_registry,
71+
)
72+
73+
agent, registry = builder("system prompt", "p2")
74+
assert isinstance(agent, AnthropicAgent)
75+
assert isinstance(registry, ToolRegistry)
76+
assert agent.name == "p2"

ddev/tests/ai/phases/test_agentic_phase.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -298,7 +298,7 @@ async def test_runtime_variables_override_flow_variables(flow_dir, monkeypatch,
298298

299299
async def test_before_react_raises_propagates(flow_dir, monkeypatch, message_queue):
300300
mock_agent = MockAgent([])
301-
phase, _ = make_agent_phase(flow_dir, mock_agent, monkeypatch, message_queue)
301+
phase, mgr = make_agent_phase(flow_dir, mock_agent, monkeypatch, message_queue)
302302

303303
def failing_hook():
304304
raise RuntimeError("setup failed")
@@ -308,13 +308,15 @@ def failing_hook():
308308
with pytest.raises(RuntimeError, match="setup failed"):
309309
await phase.process_message(PhaseTrigger(id="start", phase_id=None))
310310

311+
assert mgr.read() == {}
312+
311313

312314
async def test_after_react_raises_propagates(flow_dir, monkeypatch, message_queue):
313315
responses = [
314316
make_response("done", 100, 50),
315317
]
316318
mock_agent = MockAgent(responses)
317-
phase, _ = make_agent_phase(flow_dir, mock_agent, monkeypatch, message_queue)
319+
phase, mgr = make_agent_phase(flow_dir, mock_agent, monkeypatch, message_queue)
318320

319321
def failing_hook():
320322
raise RuntimeError("teardown failed")
@@ -324,6 +326,8 @@ def failing_hook():
324326
with pytest.raises(RuntimeError, match="teardown failed"):
325327
await phase.process_message(PhaseTrigger(id="start", phase_id=None))
326328

329+
assert mgr.read() == {}
330+
327331

328332
# ---------------------------------------------------------------------------
329333
# AgenticPhase.process_message — resolver integration with memory files

ddev/tests/ai/phases/test_base.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ async def test_extra_checkpoint_cannot_override_reserved_keys(flow_dir, message_
213213
await phase.process_message(PhaseTrigger(id="start", phase_id=None))
214214

215215
assert mgr.read() == {}
216+
assert not mgr.memory_path("p1").exists()
216217

217218

218219
async def test_failed_phase_omits_memory_path(flow_dir, message_queue):

0 commit comments

Comments
 (0)