Skip to content

Commit 0b7b7f4

Browse files
committed
refactor(ai): drop AgentBinding, have fake()/record() return their fakes directly
Agent.fake()/record() now return AgentFake/AgentRecordFake directly instead of wrapping the latter in an AgentBinding indirection layer. AgentRecordFake gains the container-binding, cassette-resolution, and decorator behavior AgentBinding used to provide, so `with Agent.record(...) as agent:` and the `@Agent.record(...)` decorator form both keep working unchanged. AgentBinding is removed entirely, along with every reference to it (imports, __init__ exports, type hints, tests).
1 parent 62fc8a7 commit 0b7b7f4

7 files changed

Lines changed: 44 additions & 42 deletions

File tree

example/agents/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,6 @@ storage
77
node_modules
88
/public/build
99
/public/hot
10+
.ruff_cache
11+
.pytest_cache
12+
.ai
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"034751ef1322a12f3406dad43313f9cdb31f4ea85d9452c22bf7529ad8e92614": {
3+
"content": "Hello! How can I help you today?",
4+
"tool_calls": []
5+
}
6+
}

fastapi_startkit/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ exclude = [
129129
"**/__pycache__",
130130
"**/.venv",
131131
]
132-
typeCheckingMode = "basic"
132+
typeCheckingMode = "standard"
133133
pythonVersion = "3.12"
134134

135135
[tool.pytest.ini_options]

fastapi_startkit/src/fastapi_startkit/ai/__init__.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,21 +13,20 @@
1313
from .judge import JudgeAgent
1414
from .providers.ai_provider import AIProvider
1515
from .response import AgentResponse, AgentSnapshot
16-
from .testing import AgentBinding, AgentModelFake, RecordingAgent, ToolCallView
16+
from .testing import AgentFake, AgentRecordFake, ToolCallView
1717

1818
__all__ = [
1919
"Agent",
2020
"Ai",
2121
"Middleware",
22-
"AgentBinding",
23-
"AgentModelFake",
22+
"AgentFake",
2423
"AgentResponse",
2524
"AgentSnapshot",
2625
"AIConfig",
2726
"AIProvider",
2827
"AnthropicConfig",
2928
"JudgeAgent",
30-
"RecordingAgent",
29+
"AgentRecordFake",
3130
"ToolCallView",
3231
"Audio",
3332
"AudioResponse",

fastapi_startkit/src/fastapi_startkit/ai/agent.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,11 @@
44

55
from .document import Document
66
from .response import AgentResponse
7-
from .testing import AgentBinding
87

98
if TYPE_CHECKING:
109
from langchain_core.tools import BaseTool
1110

12-
from .testing import AgentModelFake
11+
from .testing import AgentFake, AgentRecordFake
1312

1413

1514
class Agent:
@@ -81,16 +80,16 @@ async def stream(
8180
yield chunk
8281

8382
@classmethod
84-
def fake(cls, responses: list) -> "AgentModelFake":
85-
from .testing import AgentModelFake
83+
def fake(cls, responses: list) -> "AgentFake":
84+
from .testing import AgentFake
8685

87-
return AgentModelFake(cls, responses)
86+
return AgentFake(cls, responses)
8887

8988
@classmethod
90-
def record(cls, cassette: str | None = None, messages: list | None = None) -> "AgentBinding":
91-
from .testing import AgentBinding, RecordingAgent
89+
def record(cls, cassette: str | None = None, messages: list | None = None) -> "AgentRecordFake":
90+
from .testing import AgentRecordFake
9291

93-
return AgentBinding(cls, RecordingAgent(cls(), cassette, messages))
92+
return AgentRecordFake(cls(), cassette, messages)
9493

9594
@classmethod
9695
def _binding(cls) -> Any:

fastapi_startkit/src/fastapi_startkit/ai/testing.py

Lines changed: 16 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,10 @@ def _joined(value: Any) -> str:
5555
return "".join(value) if isinstance(value, list) else value
5656

5757

58-
class AgentModelFake:
58+
class AgentFake:
5959
"""Registers a fixed, ordered list of replies as ``agent_cls``'s chat
6060
model for the duration of a ``with`` block (or a decorated function).
6161
62-
Unlike the old pattern-matching stand-in, this swaps only the model —
6362
``prompt()``/``stream()`` still run the real message-building, pipeline,
6463
and tool-execution path; see ``Ai.fake()``.
6564
"""
@@ -111,7 +110,7 @@ def __repr__(self) -> str:
111110
return f"ToolCallView(name={self.name!r}, args={self.args!r})"
112111

113112

114-
class RecordingAgent(_Recorder):
113+
class AgentRecordFake(_Recorder):
115114
"""Bound as ``agent`` by ``with Agent.record(cassette) as agent:``.
116115
117116
Fluent testing handle around a record-and-replay session: ``prompt()``
@@ -124,6 +123,11 @@ class RecordingAgent(_Recorder):
124123
cached to disk (keyed by the conversation history so far, plus the new
125124
message, so two sessions with different histories but the same latest
126125
message text don't collide). On a hit, it's replayed with no live call.
126+
127+
Entering the ``with`` block also binds this handle into the container
128+
under the agent class's name, so any other instance of that class
129+
created during the block (e.g. by application code under test) is
130+
routed through the same recording session.
127131
"""
128132

129133
def __init__(self, real: Agent, cassette: str | None = None, messages: list | None = None) -> None:
@@ -159,7 +163,7 @@ def _key(self, message: str, attachments: list[Document] | None) -> str:
159163

160164
def _load(self) -> tuple[Path, dict]:
161165
cassette = self.cassette
162-
assert cassette is not None, "RecordingAgent has no cassette resolved"
166+
assert cassette is not None, "AgentRecordFake has no cassette resolved"
163167
return cassette, (json.loads(cassette.read_text()) if cassette.exists() else {})
164168

165169
def _save(self, cassette: Path, store: dict, key: str, value: Any) -> None:
@@ -276,34 +280,25 @@ async def _judge_live(self, model: str, expectation: str, content: str, provider
276280
judge.provider = provider
277281
return await judge.judge(expectation, content)
278282

279-
280-
class AgentBinding:
281-
def __init__(self, agent_cls: type[Agent], stand_in: Any) -> None:
282-
self._agent_cls = agent_cls
283-
self._stand_in = stand_in
284-
285283
def _resolve_cassette(self, filename: str, qualname: str) -> None:
286-
stand_in = self._stand_in
287-
if not isinstance(stand_in, RecordingAgent):
288-
return
289284
here = Path(filename).parent
290-
if stand_in.cassette is None:
291-
stand_in.cassette = here / "cassettes" / f"{qualname.replace('.', '_')}.json"
292-
elif not stand_in.cassette.is_absolute():
293-
stand_in.cassette = here / stand_in.cassette
285+
if self.cassette is None:
286+
self.cassette = here / "cassettes" / f"{qualname.replace('.', '_')}.json"
287+
elif not self.cassette.is_absolute():
288+
self.cassette = here / self.cassette
294289

295-
def __enter__(self) -> Any:
290+
def __enter__(self) -> "AgentRecordFake":
296291
from fastapi_startkit.application import app
297292

298293
caller = sys._getframe(1).f_code
299294
self._resolve_cassette(caller.co_filename, caller.co_qualname)
300-
app().bind(self._agent_cls.__name__, self._stand_in)
301-
return self._stand_in
295+
app().bind(type(self._real).__name__, self)
296+
return self
302297

303298
def __exit__(self, *_exc: Any) -> bool:
304299
from fastapi_startkit.application import app
305300

306-
app().unbind(self._agent_cls.__name__)
301+
app().unbind(type(self._real).__name__)
307302
return False
308303

309304
def __call__(self, func: Callable) -> Callable:

fastapi_startkit/tests/ai/test_agent_record_fluent.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Tests for the fluent Agent.record() testing DSL.
22
3-
``with Agent.record(cassette) as agent:`` binds a ``RecordingAgent`` handle
3+
``with Agent.record(cassette) as agent:`` binds an ``AgentRecordFake`` handle
44
whose async ``prompt()`` and assertion methods judge the most recent turn —
55
mirroring how a browser-testing ``page`` object exposes assertions against
66
current page state:
@@ -24,7 +24,7 @@
2424

2525
from fastapi_startkit.ai.agent import Agent
2626
from fastapi_startkit.ai.response import AgentResponse
27-
from fastapi_startkit.ai.testing import RecordingAgent
27+
from fastapi_startkit.ai.testing import AgentRecordFake
2828

2929

3030
class SimpleAgent(Agent):
@@ -254,7 +254,7 @@ async def test_passes_when_judge_approves(self):
254254
self.setup_agent("Hello there, welcome!")
255255
with tempfile.TemporaryDirectory() as tmp:
256256
with mock.patch.object(
257-
RecordingAgent,
257+
AgentRecordFake,
258258
"_judge_live",
259259
mock.AsyncMock(return_value={"passed": True, "reasoning": "greets the user"}),
260260
):
@@ -268,7 +268,7 @@ async def test_fails_when_judge_rejects(self):
268268
self.setup_agent("Completely unrelated content")
269269
with tempfile.TemporaryDirectory() as tmp:
270270
with mock.patch.object(
271-
RecordingAgent,
271+
AgentRecordFake,
272272
"_judge_live",
273273
mock.AsyncMock(return_value={"passed": False, "reasoning": "not a greeting"}),
274274
):
@@ -284,7 +284,7 @@ async def test_verdict_is_cached_in_the_cassette_and_not_re_judged(self):
284284
judge = mock.AsyncMock(return_value={"passed": True, "reasoning": "ok"})
285285
with tempfile.TemporaryDirectory() as tmp:
286286
cassette = os.path.join(tmp, "c.json")
287-
with mock.patch.object(RecordingAgent, "_judge_live", judge):
287+
with mock.patch.object(AgentRecordFake, "_judge_live", judge):
288288
with SimpleAgent.record(cassette) as agent:
289289
await agent.prompt("hello")
290290
await agent.assert_response_judged(model="gpt-3.5-turbo", expectation="greet")
@@ -297,14 +297,14 @@ async def test_verdict_persists_to_disk_for_a_later_replay(self):
297297
with tempfile.TemporaryDirectory() as tmp:
298298
cassette = os.path.join(tmp, "c.json")
299299
with mock.patch.object(
300-
RecordingAgent, "_judge_live", mock.AsyncMock(return_value={"passed": True, "reasoning": "ok"})
300+
AgentRecordFake, "_judge_live", mock.AsyncMock(return_value={"passed": True, "reasoning": "ok"})
301301
):
302302
with SimpleAgent.record(cassette) as agent:
303303
await agent.prompt("hello")
304304
await agent.assert_response_judged(model="gpt-3.5-turbo", expectation="greet")
305305

306306
judge = mock.AsyncMock(side_effect=AssertionError("must not be called on replay"))
307-
with mock.patch.object(RecordingAgent, "_judge_live", judge):
307+
with mock.patch.object(AgentRecordFake, "_judge_live", judge):
308308
with SimpleAgent.record(cassette) as agent:
309309
await agent.prompt("hello")
310310
await agent.assert_response_judged(model="gpt-3.5-turbo", expectation="greet")
@@ -321,7 +321,7 @@ async def test_provider_is_forwarded_to_the_judge(self):
321321
self.setup_agent("Hello there!")
322322
judge = mock.AsyncMock(return_value={"passed": True, "reasoning": "ok"})
323323
with tempfile.TemporaryDirectory() as tmp:
324-
with mock.patch.object(RecordingAgent, "_judge_live", judge):
324+
with mock.patch.object(AgentRecordFake, "_judge_live", judge):
325325
with SimpleAgent.record(os.path.join(tmp, "c.json")) as agent:
326326
await agent.prompt("hello")
327327
await agent.assert_response_judged(model="gpt-3.5-turbo", provider="openai", expectation="greet")

0 commit comments

Comments
 (0)