quick-agent 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- quick_agent/__init__.py +6 -0
- quick_agent/agent_call_tool.py +44 -0
- quick_agent/agent_registry.py +75 -0
- quick_agent/agent_tools.py +41 -0
- quick_agent/cli.py +44 -0
- quick_agent/directory_permissions.py +49 -0
- quick_agent/io_utils.py +36 -0
- quick_agent/json_utils.py +37 -0
- quick_agent/models/__init__.py +23 -0
- quick_agent/models/agent_spec.py +22 -0
- quick_agent/models/chain_step_spec.py +14 -0
- quick_agent/models/handoff_spec.py +13 -0
- quick_agent/models/loaded_agent_file.py +14 -0
- quick_agent/models/model_spec.py +14 -0
- quick_agent/models/output_spec.py +10 -0
- quick_agent/models/run_input.py +14 -0
- quick_agent/models/tool_impl_spec.py +11 -0
- quick_agent/models/tool_json.py +14 -0
- quick_agent/orchestrator.py +35 -0
- quick_agent/prompting.py +28 -0
- quick_agent/quick_agent.py +313 -0
- quick_agent/schemas/outputs.py +55 -0
- quick_agent/tools/__init__.py +0 -0
- quick_agent/tools/filesystem/__init__.py +0 -0
- quick_agent/tools/filesystem/adapter.py +26 -0
- quick_agent/tools/filesystem/read_text.py +16 -0
- quick_agent/tools/filesystem/write_text.py +19 -0
- quick_agent/tools/filesystem.read_text/tool.json +10 -0
- quick_agent/tools/filesystem.write_text/tool.json +10 -0
- quick_agent/tools_loader.py +76 -0
- quick_agent-0.1.1.data/data/quick_agent/agents/function-spec-validator.md +109 -0
- quick_agent-0.1.1.data/data/quick_agent/agents/subagent-validate-eval-list.md +122 -0
- quick_agent-0.1.1.data/data/quick_agent/agents/subagent-validator-contains.md +115 -0
- quick_agent-0.1.1.data/data/quick_agent/agents/template.md +88 -0
- quick_agent-0.1.1.dist-info/METADATA +918 -0
- quick_agent-0.1.1.dist-info/RECORD +45 -0
- quick_agent-0.1.1.dist-info/WHEEL +5 -0
- quick_agent-0.1.1.dist-info/entry_points.txt +2 -0
- quick_agent-0.1.1.dist-info/licenses/LICENSE +674 -0
- quick_agent-0.1.1.dist-info/top_level.txt +2 -0
- tests/test_agent.py +196 -0
- tests/test_directory_permissions.py +89 -0
- tests/test_integration.py +221 -0
- tests/test_orchestrator.py +797 -0
- tests/test_tools.py +25 -0
|
@@ -0,0 +1,797 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import types
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, cast
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
from pydantic_ai.models.openai import OpenAIChatModel
|
|
9
|
+
from pydantic_ai.settings import ModelSettings
|
|
10
|
+
from pydantic_ai.toolsets import FunctionToolset
|
|
11
|
+
|
|
12
|
+
from quick_agent import quick_agent as qa_module
|
|
13
|
+
from quick_agent import agent_tools as tools_module
|
|
14
|
+
from quick_agent.agent_call_tool import AgentCallTool
|
|
15
|
+
from quick_agent.agent_registry import AgentRegistry
|
|
16
|
+
from quick_agent.agent_tools import AgentTools
|
|
17
|
+
from quick_agent.directory_permissions import DirectoryPermissions
|
|
18
|
+
from quick_agent.models import AgentSpec
|
|
19
|
+
from quick_agent.models import ChainStepSpec
|
|
20
|
+
from quick_agent.models import LoadedAgentFile
|
|
21
|
+
from quick_agent.models import ModelSpec
|
|
22
|
+
from quick_agent.models.handoff_spec import HandoffSpec
|
|
23
|
+
from quick_agent.models.output_spec import OutputSpec
|
|
24
|
+
from quick_agent.models.run_input import RunInput
|
|
25
|
+
from quick_agent.orchestrator import Orchestrator
|
|
26
|
+
from quick_agent.quick_agent import QuickAgent
|
|
27
|
+
from quick_agent.quick_agent import build_model
|
|
28
|
+
from quick_agent.quick_agent import resolve_schema
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class DummyProvider:
|
|
32
|
+
def __init__(self, base_url: str, api_key: str) -> None:
|
|
33
|
+
self.base_url = base_url
|
|
34
|
+
self.api_key = api_key
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class DummyModel:
|
|
38
|
+
def __init__(self, model_name: str, provider: DummyProvider) -> None:
|
|
39
|
+
self.model_name = model_name
|
|
40
|
+
self.provider = provider
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class DummyOpenAIProvider:
|
|
44
|
+
def __init__(self, base_url: str) -> None:
|
|
45
|
+
self.base_url = base_url
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class DummyOpenAIModel:
|
|
49
|
+
def __init__(self, base_url: str) -> None:
|
|
50
|
+
self.provider = DummyOpenAIProvider(base_url)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class RecordingToolset(FunctionToolset[Any]):
|
|
54
|
+
def __init__(self) -> None:
|
|
55
|
+
super().__init__()
|
|
56
|
+
self.add_calls: list[tuple[Any, str, str]] = []
|
|
57
|
+
|
|
58
|
+
def add_function(self, *args: Any, **kwargs: Any) -> None:
|
|
59
|
+
func = kwargs.get("func")
|
|
60
|
+
name = kwargs.get("name")
|
|
61
|
+
description = kwargs.get("description")
|
|
62
|
+
if func is not None and name is not None and description is not None:
|
|
63
|
+
self.add_calls.append((func, name, description))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class FakeAgentResult:
|
|
67
|
+
def __init__(self, output: str) -> None:
|
|
68
|
+
self.output = output
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class FakeAgent:
|
|
72
|
+
next_output = ""
|
|
73
|
+
last_init: dict[str, Any] | None = None
|
|
74
|
+
last_prompt: str | None = None
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
model: Any,
|
|
79
|
+
instructions: str,
|
|
80
|
+
toolsets: list[Any],
|
|
81
|
+
output_type: Any,
|
|
82
|
+
model_settings: Any | None = None,
|
|
83
|
+
) -> None:
|
|
84
|
+
FakeAgent.last_init = {
|
|
85
|
+
"model": model,
|
|
86
|
+
"instructions": instructions,
|
|
87
|
+
"toolsets": toolsets,
|
|
88
|
+
"output_type": output_type,
|
|
89
|
+
"model_settings": model_settings,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async def run(self, user_prompt: str) -> FakeAgentResult:
|
|
93
|
+
FakeAgent.last_prompt = user_prompt
|
|
94
|
+
return FakeAgentResult(FakeAgent.next_output)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class LoadToolsRecorder:
|
|
98
|
+
def __init__(self, toolset: Any) -> None:
|
|
99
|
+
self.toolset = toolset
|
|
100
|
+
self.calls: list[tuple[list[Path], list[str], DirectoryPermissions]] = []
|
|
101
|
+
|
|
102
|
+
def __call__(
|
|
103
|
+
self,
|
|
104
|
+
tool_roots: list[Path],
|
|
105
|
+
tool_ids: list[str],
|
|
106
|
+
permissions: DirectoryPermissions,
|
|
107
|
+
) -> Any:
|
|
108
|
+
self.calls.append((tool_roots, tool_ids, permissions))
|
|
109
|
+
return self.toolset
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class SyncCallRecorder:
|
|
113
|
+
def __init__(self, return_value: Any = None) -> None:
|
|
114
|
+
self.return_value = return_value
|
|
115
|
+
self.calls: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
|
|
116
|
+
|
|
117
|
+
def __call__(self, *args: Any, **kwargs: Any) -> Any:
|
|
118
|
+
self.calls.append((args, kwargs))
|
|
119
|
+
return self.return_value
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class AsyncCallRecorder:
|
|
123
|
+
def __init__(self, return_value: Any = None) -> None:
|
|
124
|
+
self.return_value = return_value
|
|
125
|
+
self.calls: list[dict[str, Any]] = []
|
|
126
|
+
|
|
127
|
+
async def __call__(self, *args: Any, **kwargs: Any) -> Any:
|
|
128
|
+
self.calls.append({"args": args, "kwargs": kwargs})
|
|
129
|
+
return self.return_value
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class FakeRegistry(AgentRegistry):
|
|
133
|
+
def __init__(self, loaded: LoadedAgentFile) -> None:
|
|
134
|
+
super().__init__(agent_roots=[])
|
|
135
|
+
self.loaded = loaded
|
|
136
|
+
self.calls: list[str] = []
|
|
137
|
+
|
|
138
|
+
def get(self, agent_id: str) -> LoadedAgentFile:
|
|
139
|
+
self.calls.append(agent_id)
|
|
140
|
+
return self.loaded
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class RecordingQuickAgent(QuickAgent):
|
|
144
|
+
def __init__(self, outputs: list[tuple[Any, Any]]) -> None:
|
|
145
|
+
self.outputs = outputs
|
|
146
|
+
self.calls: list[str] = []
|
|
147
|
+
self.index = 0
|
|
148
|
+
|
|
149
|
+
async def _run_step(self, **kwargs: Any) -> tuple[Any, Any]:
|
|
150
|
+
step = kwargs.get("step")
|
|
151
|
+
if step is not None:
|
|
152
|
+
self.calls.append(step.id)
|
|
153
|
+
output = self.outputs[self.index]
|
|
154
|
+
self.index += 1
|
|
155
|
+
return output
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class HandoffQuickAgent(QuickAgent):
|
|
159
|
+
def __init__(self) -> None:
|
|
160
|
+
self.calls: list[tuple[str, Path]] = []
|
|
161
|
+
|
|
162
|
+
async def _run_nested_agent(self, agent_id: str, input_path: Path) -> str:
|
|
163
|
+
self.calls.append((agent_id, input_path))
|
|
164
|
+
return "ok"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class ExampleSchema(BaseModel):
|
|
168
|
+
x: int
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class OutputSchema(BaseModel):
|
|
172
|
+
msg: str
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _make_loaded_with_chain(
|
|
176
|
+
chain: list[ChainStepSpec],
|
|
177
|
+
*,
|
|
178
|
+
schemas: dict[str, str] | None = None,
|
|
179
|
+
output: OutputSpec | None = None,
|
|
180
|
+
handoff: HandoffSpec | None = None,
|
|
181
|
+
) -> LoadedAgentFile:
|
|
182
|
+
spec = AgentSpec(
|
|
183
|
+
name="test",
|
|
184
|
+
model=ModelSpec(base_url="http://x", model_name="m"),
|
|
185
|
+
chain=chain,
|
|
186
|
+
schemas=schemas or {},
|
|
187
|
+
output=output or OutputSpec(file="out/result.json"),
|
|
188
|
+
handoff=handoff or HandoffSpec(),
|
|
189
|
+
)
|
|
190
|
+
return LoadedAgentFile(spec=spec, body="system", step_prompts={"step:one": "do thing"})
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _permissions(tmp_path: Path | None = None) -> DirectoryPermissions:
|
|
194
|
+
root = Path("safe") if tmp_path is None else tmp_path / "safe"
|
|
195
|
+
return DirectoryPermissions(root)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_init_sets_registry_and_tool_roots(tmp_path: Path) -> None:
|
|
199
|
+
orch = Orchestrator([tmp_path], [tmp_path / "tools"], safe_dir=_permissions(tmp_path).root)
|
|
200
|
+
|
|
201
|
+
assert isinstance(orch.registry, AgentRegistry)
|
|
202
|
+
assert isinstance(orch.tools, AgentTools)
|
|
203
|
+
assert orch.tools._tool_roots == [tmp_path / "tools"]
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def test_build_model_uses_env_api_key(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
207
|
+
monkeypatch.setenv("TEST_KEY", "abc")
|
|
208
|
+
monkeypatch.setattr(qa_module, "OpenAIProvider", DummyProvider)
|
|
209
|
+
monkeypatch.setattr(qa_module, "OpenAIChatModel", DummyModel)
|
|
210
|
+
|
|
211
|
+
spec = ModelSpec(base_url="http://base", model_name="gpt-test", api_key_env="TEST_KEY")
|
|
212
|
+
model = build_model(spec)
|
|
213
|
+
|
|
214
|
+
assert isinstance(model, DummyModel)
|
|
215
|
+
assert model.model_name == "gpt-test"
|
|
216
|
+
assert model.provider.base_url == "http://base"
|
|
217
|
+
assert model.provider.api_key == "abc"
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def test_resolve_schema_valid_missing_and_invalid() -> None:
|
|
221
|
+
schema_module = types.ModuleType("schemas.orch")
|
|
222
|
+
|
|
223
|
+
class GoodSchema(BaseModel):
|
|
224
|
+
x: int
|
|
225
|
+
|
|
226
|
+
class NotSchema:
|
|
227
|
+
pass
|
|
228
|
+
|
|
229
|
+
schema_module.__dict__["GoodSchema"] = GoodSchema
|
|
230
|
+
schema_module.__dict__["NotSchema"] = NotSchema
|
|
231
|
+
sys.modules["schemas.orch"] = schema_module
|
|
232
|
+
|
|
233
|
+
spec = AgentSpec(
|
|
234
|
+
name="test",
|
|
235
|
+
model=ModelSpec(base_url="http://x", model_name="m"),
|
|
236
|
+
chain=[ChainStepSpec(id="s1", kind="text", prompt_section="step:one")],
|
|
237
|
+
schemas={"Good": "schemas.orch:GoodSchema", "Bad": "schemas.orch:NotSchema"},
|
|
238
|
+
)
|
|
239
|
+
loaded = LoadedAgentFile(spec=spec, body="", step_prompts={})
|
|
240
|
+
|
|
241
|
+
try:
|
|
242
|
+
assert resolve_schema(loaded, "Good") is GoodSchema
|
|
243
|
+
with pytest.raises(KeyError):
|
|
244
|
+
resolve_schema(loaded, "Missing")
|
|
245
|
+
with pytest.raises(TypeError):
|
|
246
|
+
resolve_schema(loaded, "Bad")
|
|
247
|
+
finally:
|
|
248
|
+
sys.modules.pop("schemas.orch", None)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def test_build_toolset_filters_agent_call(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
252
|
+
sentinel_toolset = RecordingToolset()
|
|
253
|
+
recorder = LoadToolsRecorder(sentinel_toolset)
|
|
254
|
+
monkeypatch.setattr(tools_module, "load_tools", recorder)
|
|
255
|
+
tools = AgentTools([tmp_path])
|
|
256
|
+
toolset = tools.build_toolset(["tool.a", "agent.call", "tool.b"], _permissions(tmp_path))
|
|
257
|
+
|
|
258
|
+
assert toolset is sentinel_toolset
|
|
259
|
+
assert len(recorder.calls) == 1
|
|
260
|
+
roots, tool_ids, perms = recorder.calls[0]
|
|
261
|
+
assert roots == [tmp_path]
|
|
262
|
+
assert tool_ids == ["tool.a", "tool.b"]
|
|
263
|
+
assert perms.root == _permissions(tmp_path).root
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def test_build_toolset_returns_empty_for_agent_call_only(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
267
|
+
recorder = LoadToolsRecorder(RecordingToolset())
|
|
268
|
+
monkeypatch.setattr(tools_module, "load_tools", recorder)
|
|
269
|
+
tools = AgentTools([tmp_path])
|
|
270
|
+
toolset = tools.build_toolset(["agent.call"], _permissions(tmp_path))
|
|
271
|
+
|
|
272
|
+
assert isinstance(toolset, FunctionToolset)
|
|
273
|
+
assert recorder.calls == []
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def test_maybe_inject_agent_call_tool_adds_when_requested() -> None:
|
|
277
|
+
tools = AgentTools([])
|
|
278
|
+
toolset = RecordingToolset()
|
|
279
|
+
|
|
280
|
+
tools.maybe_inject_agent_call(
|
|
281
|
+
["agent.call"],
|
|
282
|
+
toolset,
|
|
283
|
+
"run/input.json",
|
|
284
|
+
AsyncCallRecorder(return_value={"text": "ok"}),
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
assert len(toolset.add_calls) == 1
|
|
288
|
+
func, name, description = toolset.add_calls[0]
|
|
289
|
+
assert hasattr(func, "__self__")
|
|
290
|
+
assert isinstance(func.__self__, AgentCallTool)
|
|
291
|
+
assert name == "agent_call"
|
|
292
|
+
assert "another agent" in description
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def test_maybe_inject_agent_call_tool_skips_when_missing() -> None:
|
|
296
|
+
tools = AgentTools([])
|
|
297
|
+
toolset = RecordingToolset()
|
|
298
|
+
|
|
299
|
+
tools.maybe_inject_agent_call(
|
|
300
|
+
[],
|
|
301
|
+
toolset,
|
|
302
|
+
"run/input.json",
|
|
303
|
+
AsyncCallRecorder(return_value={"text": "ok"}),
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
assert toolset.add_calls == []
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def test_init_state_contains_agent_id_and_steps() -> None:
|
|
310
|
+
qa = object.__new__(QuickAgent)
|
|
311
|
+
|
|
312
|
+
state = qa._init_state("agent-1")
|
|
313
|
+
|
|
314
|
+
assert state == {"agent_id": "agent-1", "steps": {}}
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def test_build_model_settings_openai_compatible() -> None:
|
|
318
|
+
qa = object.__new__(QuickAgent)
|
|
319
|
+
spec = ModelSpec(base_url="http://x", model_name="m", provider="openai-compatible")
|
|
320
|
+
|
|
321
|
+
settings = qa._build_model_settings(spec)
|
|
322
|
+
|
|
323
|
+
assert settings == {"extra_body": {"format": "json"}}
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def test_build_model_settings_openai_endpoint_skips_format() -> None:
|
|
327
|
+
qa = object.__new__(QuickAgent)
|
|
328
|
+
spec = ModelSpec(
|
|
329
|
+
base_url="https://api.openai.com/v1",
|
|
330
|
+
model_name="m",
|
|
331
|
+
provider="openai-compatible",
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
settings = qa._build_model_settings(spec)
|
|
335
|
+
|
|
336
|
+
assert settings is None
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def test_build_model_settings_other_provider() -> None:
|
|
340
|
+
qa = object.__new__(QuickAgent)
|
|
341
|
+
spec = ModelSpec(base_url="http://x", model_name="m", provider="other")
|
|
342
|
+
|
|
343
|
+
settings = qa._build_model_settings(spec)
|
|
344
|
+
|
|
345
|
+
assert settings is None
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def test_build_structured_model_settings_non_openai_passthrough() -> None:
|
|
349
|
+
qa = object.__new__(QuickAgent)
|
|
350
|
+
schema = ExampleSchema
|
|
351
|
+
model = cast(OpenAIChatModel, DummyOpenAIModel("http://localhost"))
|
|
352
|
+
settings: ModelSettings = {"extra_body": {"format": "json"}}
|
|
353
|
+
|
|
354
|
+
result = qa._build_structured_model_settings(
|
|
355
|
+
model=model,
|
|
356
|
+
model_settings_json=settings,
|
|
357
|
+
schema_cls=schema,
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
assert result == settings
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def test_build_structured_model_settings_openai_injects_schema() -> None:
|
|
364
|
+
qa = object.__new__(QuickAgent)
|
|
365
|
+
schema = ExampleSchema
|
|
366
|
+
model = cast(OpenAIChatModel, DummyOpenAIModel("https://api.openai.com/v1"))
|
|
367
|
+
|
|
368
|
+
result = qa._build_structured_model_settings(
|
|
369
|
+
model=model,
|
|
370
|
+
model_settings_json=None,
|
|
371
|
+
schema_cls=schema,
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
assert result is not None
|
|
375
|
+
extra_body_obj = result.get("extra_body")
|
|
376
|
+
assert extra_body_obj is not None
|
|
377
|
+
assert isinstance(extra_body_obj, dict)
|
|
378
|
+
response_format_obj = extra_body_obj["response_format"]
|
|
379
|
+
assert isinstance(response_format_obj, dict)
|
|
380
|
+
assert response_format_obj["type"] == "json_schema"
|
|
381
|
+
json_schema_obj = response_format_obj["json_schema"]
|
|
382
|
+
assert isinstance(json_schema_obj, dict)
|
|
383
|
+
assert json_schema_obj["name"] == "ExampleSchema"
|
|
384
|
+
assert json_schema_obj["strict"] is True
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def test_build_user_prompt_raises_for_missing_section() -> None:
|
|
388
|
+
step = ChainStepSpec(id="s1", kind="text", prompt_section="step:missing")
|
|
389
|
+
loaded = LoadedAgentFile(spec=_make_loaded_with_chain([step]).spec, body="body", step_prompts={})
|
|
390
|
+
run_input = RunInput(source_path="in.txt", kind="text", text="hi", data=None)
|
|
391
|
+
|
|
392
|
+
qa = object.__new__(QuickAgent)
|
|
393
|
+
with pytest.raises(KeyError):
|
|
394
|
+
qa._build_user_prompt(step=step, loaded=loaded, run_input=run_input, state={"steps": {}})
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def test_build_user_prompt_uses_prompting(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
398
|
+
step = ChainStepSpec(id="s1", kind="text", prompt_section="step:one")
|
|
399
|
+
loaded = _make_loaded_with_chain([step])
|
|
400
|
+
run_input = RunInput(source_path="in.txt", kind="text", text="hi", data=None)
|
|
401
|
+
recorder = SyncCallRecorder(return_value="prompt")
|
|
402
|
+
monkeypatch.setattr(qa_module, "make_user_prompt", recorder)
|
|
403
|
+
|
|
404
|
+
qa = object.__new__(QuickAgent)
|
|
405
|
+
result = qa._build_user_prompt(step=step, loaded=loaded, run_input=run_input, state={"steps": {}})
|
|
406
|
+
|
|
407
|
+
assert result == "prompt"
|
|
408
|
+
assert recorder.calls == [((loaded.step_prompts["step:one"], run_input, {"steps": {}}), {})]
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
@pytest.mark.anyio
|
|
412
|
+
async def test_run_step_text_returns_output(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
413
|
+
monkeypatch.setattr(qa_module, "Agent", FakeAgent)
|
|
414
|
+
FakeAgent.next_output = "hello"
|
|
415
|
+
|
|
416
|
+
step = ChainStepSpec(id="s1", kind="text", prompt_section="step:one")
|
|
417
|
+
loaded = _make_loaded_with_chain([step])
|
|
418
|
+
run_input = RunInput(source_path="in.txt", kind="text", text="hi", data=None)
|
|
419
|
+
|
|
420
|
+
qa = object.__new__(QuickAgent)
|
|
421
|
+
output, final = await qa._run_step(
|
|
422
|
+
step=step,
|
|
423
|
+
loaded=loaded,
|
|
424
|
+
model=cast(OpenAIChatModel, object()),
|
|
425
|
+
model_settings_json=None,
|
|
426
|
+
toolset=RecordingToolset(),
|
|
427
|
+
run_input=run_input,
|
|
428
|
+
state={"agent_id": "a", "steps": {}},
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
assert output == "hello"
|
|
432
|
+
assert final == "hello"
|
|
433
|
+
assert FakeAgent.last_init is not None
|
|
434
|
+
assert FakeAgent.last_init["instructions"] == "system"
|
|
435
|
+
assert FakeAgent.last_init["output_type"] is str
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
@pytest.mark.anyio
|
|
439
|
+
async def test_run_step_structured_parses_json_with_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
440
|
+
monkeypatch.setattr(qa_module, "Agent", FakeAgent)
|
|
441
|
+
FakeAgent.next_output = "preface {\"x\": 7} trailing"
|
|
442
|
+
|
|
443
|
+
schema_module = types.ModuleType("schemas.struct")
|
|
444
|
+
schema_module.__dict__["ExampleSchema"] = ExampleSchema
|
|
445
|
+
sys.modules["schemas.struct"] = schema_module
|
|
446
|
+
|
|
447
|
+
step = ChainStepSpec(id="s1", kind="structured", prompt_section="step:one", output_schema="Example")
|
|
448
|
+
spec = AgentSpec(
|
|
449
|
+
name="test",
|
|
450
|
+
model=ModelSpec(base_url="http://x", model_name="m"),
|
|
451
|
+
chain=[step],
|
|
452
|
+
schemas={"Example": "schemas.struct:ExampleSchema"},
|
|
453
|
+
)
|
|
454
|
+
loaded = LoadedAgentFile(spec=spec, body="system", step_prompts={"step:one": "do thing"})
|
|
455
|
+
run_input = RunInput(source_path="in.json", kind="json", text="{}", data={})
|
|
456
|
+
|
|
457
|
+
try:
|
|
458
|
+
qa = object.__new__(QuickAgent)
|
|
459
|
+
output, final = await qa._run_step(
|
|
460
|
+
step=step,
|
|
461
|
+
loaded=loaded,
|
|
462
|
+
model=cast(OpenAIChatModel, object()),
|
|
463
|
+
model_settings_json={"extra_body": {"format": "json"}},
|
|
464
|
+
toolset=RecordingToolset(),
|
|
465
|
+
run_input=run_input,
|
|
466
|
+
state={"agent_id": "a", "steps": {}},
|
|
467
|
+
)
|
|
468
|
+
finally:
|
|
469
|
+
sys.modules.pop("schemas.struct", None)
|
|
470
|
+
|
|
471
|
+
assert output == {"x": 7}
|
|
472
|
+
assert isinstance(final, ExampleSchema)
|
|
473
|
+
assert final.x == 7
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
@pytest.mark.anyio
|
|
477
|
+
async def test_run_step_unknown_kind_raises(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
478
|
+
monkeypatch.setattr(qa_module, "Agent", FakeAgent)
|
|
479
|
+
step = ChainStepSpec(id="s1", kind="mystery", prompt_section="step:one")
|
|
480
|
+
loaded = _make_loaded_with_chain([step])
|
|
481
|
+
run_input = RunInput(source_path="in.txt", kind="text", text="hi", data=None)
|
|
482
|
+
|
|
483
|
+
qa = object.__new__(QuickAgent)
|
|
484
|
+
with pytest.raises(NotImplementedError):
|
|
485
|
+
await qa._run_step(
|
|
486
|
+
step=step,
|
|
487
|
+
loaded=loaded,
|
|
488
|
+
model=cast(OpenAIChatModel, object()),
|
|
489
|
+
model_settings_json=None,
|
|
490
|
+
toolset=RecordingToolset(),
|
|
491
|
+
run_input=run_input,
|
|
492
|
+
state={"agent_id": "a", "steps": {}},
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
@pytest.mark.anyio
|
|
497
|
+
async def test_run_text_step_uses_build_user_prompt(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
498
|
+
monkeypatch.setattr(qa_module, "Agent", FakeAgent)
|
|
499
|
+
FakeAgent.next_output = "ok"
|
|
500
|
+
step = ChainStepSpec(id="s1", kind="text", prompt_section="step:one")
|
|
501
|
+
loaded = _make_loaded_with_chain([step])
|
|
502
|
+
run_input = RunInput(source_path="in.txt", kind="text", text="hi", data=None)
|
|
503
|
+
|
|
504
|
+
qa = object.__new__(QuickAgent)
|
|
505
|
+
monkeypatch.setattr(qa, "_build_user_prompt", SyncCallRecorder(return_value="prompt"))
|
|
506
|
+
|
|
507
|
+
output, final = await qa._run_text_step(
|
|
508
|
+
step=step,
|
|
509
|
+
loaded=loaded,
|
|
510
|
+
model=cast(OpenAIChatModel, object()),
|
|
511
|
+
toolset=RecordingToolset(),
|
|
512
|
+
run_input=run_input,
|
|
513
|
+
state={"agent_id": "a", "steps": {}},
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
assert output == "ok"
|
|
517
|
+
assert final == "ok"
|
|
518
|
+
assert FakeAgent.last_prompt == "prompt"
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
@pytest.mark.anyio
|
|
522
|
+
async def test_run_structured_step_missing_schema_raises() -> None:
|
|
523
|
+
step = ChainStepSpec(id="s1", kind="structured", prompt_section="step:one", output_schema=None)
|
|
524
|
+
loaded = _make_loaded_with_chain([step])
|
|
525
|
+
run_input = RunInput(source_path="in.json", kind="json", text="{}", data={})
|
|
526
|
+
|
|
527
|
+
qa = object.__new__(QuickAgent)
|
|
528
|
+
with pytest.raises(ValueError):
|
|
529
|
+
await qa._run_structured_step(
|
|
530
|
+
step=step,
|
|
531
|
+
loaded=loaded,
|
|
532
|
+
model=cast(OpenAIChatModel, object()),
|
|
533
|
+
model_settings_json=None,
|
|
534
|
+
toolset=RecordingToolset(),
|
|
535
|
+
run_input=run_input,
|
|
536
|
+
state={"agent_id": "a", "steps": {}},
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
@pytest.mark.anyio
|
|
541
|
+
async def test_run_structured_step_parses_json(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
542
|
+
monkeypatch.setattr(qa_module, "Agent", FakeAgent)
|
|
543
|
+
FakeAgent.next_output = "{\"x\": 3}"
|
|
544
|
+
|
|
545
|
+
schema_module = types.ModuleType("schemas.struct2")
|
|
546
|
+
schema_module.__dict__["ExampleSchema"] = ExampleSchema
|
|
547
|
+
sys.modules["schemas.struct2"] = schema_module
|
|
548
|
+
|
|
549
|
+
step = ChainStepSpec(id="s1", kind="structured", prompt_section="step:one", output_schema="Example")
|
|
550
|
+
spec = AgentSpec(
|
|
551
|
+
name="test",
|
|
552
|
+
model=ModelSpec(base_url="http://x", model_name="m"),
|
|
553
|
+
chain=[step],
|
|
554
|
+
schemas={"Example": "schemas.struct2:ExampleSchema"},
|
|
555
|
+
)
|
|
556
|
+
loaded = LoadedAgentFile(spec=spec, body="system", step_prompts={"step:one": "do thing"})
|
|
557
|
+
run_input = RunInput(source_path="in.json", kind="json", text="{}", data={})
|
|
558
|
+
|
|
559
|
+
try:
|
|
560
|
+
qa = object.__new__(QuickAgent)
|
|
561
|
+
output, final = await qa._run_structured_step(
|
|
562
|
+
step=step,
|
|
563
|
+
loaded=loaded,
|
|
564
|
+
model=cast(OpenAIChatModel, object()),
|
|
565
|
+
model_settings_json=None,
|
|
566
|
+
toolset=RecordingToolset(),
|
|
567
|
+
run_input=run_input,
|
|
568
|
+
state={"agent_id": "a", "steps": {}},
|
|
569
|
+
)
|
|
570
|
+
finally:
|
|
571
|
+
sys.modules.pop("schemas.struct2", None)
|
|
572
|
+
|
|
573
|
+
assert output == {"x": 3}
|
|
574
|
+
assert isinstance(final, ExampleSchema)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
@pytest.mark.anyio
|
|
578
|
+
async def test_run_structured_step_adds_json_schema_for_openai(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
579
|
+
monkeypatch.setattr(qa_module, "Agent", FakeAgent)
|
|
580
|
+
FakeAgent.next_output = "{\"x\": 9}"
|
|
581
|
+
|
|
582
|
+
schema_module = types.ModuleType("schemas.struct3")
|
|
583
|
+
schema_module.__dict__["ExampleSchema"] = ExampleSchema
|
|
584
|
+
sys.modules["schemas.struct3"] = schema_module
|
|
585
|
+
|
|
586
|
+
step = ChainStepSpec(id="s1", kind="structured", prompt_section="step:one", output_schema="Example")
|
|
587
|
+
spec = AgentSpec(
|
|
588
|
+
name="test",
|
|
589
|
+
model=ModelSpec(base_url="https://api.openai.com/v1", model_name="m"),
|
|
590
|
+
chain=[step],
|
|
591
|
+
schemas={"Example": "schemas.struct3:ExampleSchema"},
|
|
592
|
+
)
|
|
593
|
+
loaded = LoadedAgentFile(spec=spec, body="system", step_prompts={"step:one": "do thing"})
|
|
594
|
+
run_input = RunInput(source_path="in.json", kind="json", text="{}", data={})
|
|
595
|
+
|
|
596
|
+
try:
|
|
597
|
+
qa = object.__new__(QuickAgent)
|
|
598
|
+
await qa._run_structured_step(
|
|
599
|
+
step=step,
|
|
600
|
+
loaded=loaded,
|
|
601
|
+
model=cast(OpenAIChatModel, DummyOpenAIModel("https://api.openai.com/v1")),
|
|
602
|
+
model_settings_json=None,
|
|
603
|
+
toolset=RecordingToolset(),
|
|
604
|
+
run_input=run_input,
|
|
605
|
+
state={"agent_id": "a", "steps": {}},
|
|
606
|
+
)
|
|
607
|
+
finally:
|
|
608
|
+
sys.modules.pop("schemas.struct3", None)
|
|
609
|
+
|
|
610
|
+
assert FakeAgent.last_init is not None
|
|
611
|
+
settings = FakeAgent.last_init["model_settings"]
|
|
612
|
+
assert isinstance(settings, dict)
|
|
613
|
+
extra_body = settings["extra_body"]
|
|
614
|
+
assert extra_body["response_format"]["type"] == "json_schema"
|
|
615
|
+
assert extra_body["response_format"]["json_schema"]["name"] == "ExampleSchema"
|
|
616
|
+
assert extra_body["response_format"]["json_schema"]["strict"] is True
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
@pytest.mark.anyio
|
|
620
|
+
async def test_run_chain_updates_state_and_returns_last() -> None:
|
|
621
|
+
step1 = ChainStepSpec(id="s1", kind="text", prompt_section="step:one")
|
|
622
|
+
step2 = ChainStepSpec(id="s2", kind="text", prompt_section="step:one")
|
|
623
|
+
loaded = _make_loaded_with_chain([step1, step2])
|
|
624
|
+
|
|
625
|
+
qa = RecordingQuickAgent(outputs=[({"a": 1}, "first"), ("b", "second")])
|
|
626
|
+
state = {"agent_id": "a", "steps": {}}
|
|
627
|
+
|
|
628
|
+
final = await qa._run_chain(
|
|
629
|
+
loaded=loaded,
|
|
630
|
+
model=cast(OpenAIChatModel, object()),
|
|
631
|
+
model_settings_json=None,
|
|
632
|
+
toolset=RecordingToolset(),
|
|
633
|
+
run_input=RunInput(source_path="in.txt", kind="text", text="hi", data=None),
|
|
634
|
+
state=state,
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
assert final == "second"
|
|
638
|
+
assert state["steps"] == {"s1": {"a": 1}, "s2": "b"}
|
|
639
|
+
assert qa.calls == ["s1", "s2"]
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def test_write_final_output_serializes_model(tmp_path: Path) -> None:
|
|
643
|
+
safe_root = tmp_path / "safe"
|
|
644
|
+
out_path = safe_root / "out.json"
|
|
645
|
+
output = OutputSpec(file=str(out_path), format="json")
|
|
646
|
+
step = ChainStepSpec(id="s1", kind="text", prompt_section="step:one")
|
|
647
|
+
loaded = _make_loaded_with_chain([step], output=output)
|
|
648
|
+
|
|
649
|
+
permissions = DirectoryPermissions(safe_root)
|
|
650
|
+
qa = object.__new__(QuickAgent)
|
|
651
|
+
result_path = qa._write_final_output(loaded, OutputSchema(msg="hi"), permissions)
|
|
652
|
+
|
|
653
|
+
assert result_path == out_path
|
|
654
|
+
assert "\"msg\": \"hi\"" in out_path.read_text(encoding="utf-8")
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def test_write_final_output_writes_text(tmp_path: Path) -> None:
|
|
658
|
+
safe_root = tmp_path / "safe"
|
|
659
|
+
out_path = safe_root / "out.txt"
|
|
660
|
+
output = OutputSpec(file=str(out_path), format="markdown")
|
|
661
|
+
step = ChainStepSpec(id="s1", kind="text", prompt_section="step:one")
|
|
662
|
+
loaded = _make_loaded_with_chain([step], output=output)
|
|
663
|
+
|
|
664
|
+
permissions = DirectoryPermissions(safe_root)
|
|
665
|
+
qa = object.__new__(QuickAgent)
|
|
666
|
+
result_path = qa._write_final_output(loaded, "hello", permissions)
|
|
667
|
+
|
|
668
|
+
assert result_path == out_path
|
|
669
|
+
assert out_path.read_text(encoding="utf-8") == "hello"
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
@pytest.mark.anyio
|
|
673
|
+
async def test_handle_handoff_runs_followup() -> None:
|
|
674
|
+
out_path = Path("/tmp/out.json")
|
|
675
|
+
handoff = HandoffSpec(enabled=True, agent_id="next")
|
|
676
|
+
step = ChainStepSpec(id="s1", kind="text", prompt_section="step:one")
|
|
677
|
+
loaded = _make_loaded_with_chain([step], handoff=handoff)
|
|
678
|
+
|
|
679
|
+
qa = HandoffQuickAgent()
|
|
680
|
+
await qa._handle_handoff(loaded, out_path)
|
|
681
|
+
|
|
682
|
+
assert qa.calls == [("next", out_path)]
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
@pytest.mark.anyio
|
|
686
|
+
async def test_handle_handoff_skips_when_disabled() -> None:
|
|
687
|
+
out_path = Path("/tmp/out.json")
|
|
688
|
+
handoff = HandoffSpec(enabled=False, agent_id="next")
|
|
689
|
+
step = ChainStepSpec(id="s1", kind="text", prompt_section="step:one")
|
|
690
|
+
loaded = _make_loaded_with_chain([step], handoff=handoff)
|
|
691
|
+
|
|
692
|
+
qa = HandoffQuickAgent()
|
|
693
|
+
await qa._handle_handoff(loaded, out_path)
|
|
694
|
+
|
|
695
|
+
assert qa.calls == []
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
@pytest.mark.anyio
|
|
699
|
+
async def test_run_agent_wires_dependencies(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
700
|
+
step = ChainStepSpec(id="s1", kind="text", prompt_section="step:one")
|
|
701
|
+
spec = AgentSpec(
|
|
702
|
+
name="test",
|
|
703
|
+
model=ModelSpec(base_url="http://x", model_name="m"),
|
|
704
|
+
chain=[step],
|
|
705
|
+
tools=["tool.a", "agent.call", "tool.a"],
|
|
706
|
+
output=OutputSpec(file=str(tmp_path / "out.json")),
|
|
707
|
+
)
|
|
708
|
+
loaded = LoadedAgentFile(spec=spec, body="system", step_prompts={"step:one": "do thing"})
|
|
709
|
+
|
|
710
|
+
run_input = RunInput(source_path=str(tmp_path / "input.json"), kind="json", text="{}", data={})
|
|
711
|
+
toolset = RecordingToolset()
|
|
712
|
+
model = object()
|
|
713
|
+
settings = {"extra_body": {"format": "json"}}
|
|
714
|
+
out_path = tmp_path / "out.json"
|
|
715
|
+
|
|
716
|
+
load_input_recorder = SyncCallRecorder(return_value=run_input)
|
|
717
|
+
build_model_recorder = SyncCallRecorder(return_value=model)
|
|
718
|
+
build_toolset_recorder = SyncCallRecorder(return_value=toolset)
|
|
719
|
+
build_settings_recorder = SyncCallRecorder(return_value=settings)
|
|
720
|
+
maybe_inject_recorder = SyncCallRecorder(return_value=None)
|
|
721
|
+
run_chain_recorder = AsyncCallRecorder(return_value="final")
|
|
722
|
+
write_output_recorder = SyncCallRecorder(return_value=out_path)
|
|
723
|
+
handoff_recorder = AsyncCallRecorder(return_value=None)
|
|
724
|
+
|
|
725
|
+
monkeypatch.setattr(qa_module, "load_input", load_input_recorder)
|
|
726
|
+
monkeypatch.setattr(qa_module, "build_model", build_model_recorder)
|
|
727
|
+
monkeypatch.setattr(QuickAgent, "_build_model_settings", build_settings_recorder)
|
|
728
|
+
monkeypatch.setattr(QuickAgent, "_run_chain", run_chain_recorder)
|
|
729
|
+
monkeypatch.setattr(QuickAgent, "_write_final_output", write_output_recorder)
|
|
730
|
+
monkeypatch.setattr(QuickAgent, "_handle_handoff", handoff_recorder)
|
|
731
|
+
|
|
732
|
+
tools = AgentTools([tmp_path])
|
|
733
|
+
monkeypatch.setattr(tools, "build_toolset", build_toolset_recorder)
|
|
734
|
+
monkeypatch.setattr(tools, "maybe_inject_agent_call", maybe_inject_recorder)
|
|
735
|
+
fake_registry = FakeRegistry(loaded)
|
|
736
|
+
|
|
737
|
+
agent = QuickAgent(
|
|
738
|
+
registry=fake_registry,
|
|
739
|
+
tools=tools,
|
|
740
|
+
directory_permissions=_permissions(tmp_path),
|
|
741
|
+
agent_id="agent-1",
|
|
742
|
+
input_path=tmp_path / "input.json",
|
|
743
|
+
extra_tools=["tool.b"],
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
result = await agent.run()
|
|
747
|
+
|
|
748
|
+
assert result == "final"
|
|
749
|
+
assert fake_registry.calls == ["agent-1"]
|
|
750
|
+
|
|
751
|
+
assert load_input_recorder.calls
|
|
752
|
+
load_args, load_kwargs = load_input_recorder.calls[0]
|
|
753
|
+
assert load_kwargs == {}
|
|
754
|
+
assert load_args[0] == tmp_path / "input.json"
|
|
755
|
+
assert isinstance(load_args[1], DirectoryPermissions)
|
|
756
|
+
assert load_args[1].root == _permissions(tmp_path).root
|
|
757
|
+
assert build_model_recorder.calls == [((loaded.spec.model,), {})]
|
|
758
|
+
|
|
759
|
+
assert build_toolset_recorder.calls
|
|
760
|
+
args, kwargs = build_toolset_recorder.calls[0]
|
|
761
|
+
assert kwargs == {}
|
|
762
|
+
assert args[0] == [
|
|
763
|
+
"tool.a",
|
|
764
|
+
"agent.call",
|
|
765
|
+
"tool.b",
|
|
766
|
+
]
|
|
767
|
+
assert isinstance(args[1], DirectoryPermissions)
|
|
768
|
+
|
|
769
|
+
assert build_settings_recorder.calls == [((loaded.spec.model,), {})]
|
|
770
|
+
maybe_args, maybe_kwargs = maybe_inject_recorder.calls[0]
|
|
771
|
+
assert maybe_kwargs == {}
|
|
772
|
+
assert maybe_args[0] == [
|
|
773
|
+
"tool.a",
|
|
774
|
+
"agent.call",
|
|
775
|
+
"tool.b",
|
|
776
|
+
]
|
|
777
|
+
assert maybe_args[1] is toolset
|
|
778
|
+
assert maybe_args[2] == run_input.source_path
|
|
779
|
+
assert callable(maybe_args[3])
|
|
780
|
+
|
|
781
|
+
assert run_chain_recorder.calls
|
|
782
|
+
run_chain_kwargs = run_chain_recorder.calls[0]["kwargs"]
|
|
783
|
+
assert run_chain_kwargs["loaded"] is loaded
|
|
784
|
+
assert run_chain_kwargs["model"] is model
|
|
785
|
+
assert run_chain_kwargs["model_settings_json"] is settings
|
|
786
|
+
assert run_chain_kwargs["toolset"] is toolset
|
|
787
|
+
assert run_chain_kwargs["run_input"] is run_input
|
|
788
|
+
assert run_chain_kwargs["state"]["agent_id"] == "agent-1"
|
|
789
|
+
|
|
790
|
+
assert write_output_recorder.calls
|
|
791
|
+
write_args, write_kwargs = write_output_recorder.calls[0]
|
|
792
|
+
assert write_kwargs == {}
|
|
793
|
+
assert write_args[0] is loaded
|
|
794
|
+
assert write_args[1] == "final"
|
|
795
|
+
assert isinstance(write_args[2], DirectoryPermissions)
|
|
796
|
+
assert write_args[2].root == _permissions(tmp_path).root
|
|
797
|
+
assert handoff_recorder.calls == [{"args": (loaded, out_path), "kwargs": {}}]
|