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.
Files changed (45) hide show
  1. quick_agent/__init__.py +6 -0
  2. quick_agent/agent_call_tool.py +44 -0
  3. quick_agent/agent_registry.py +75 -0
  4. quick_agent/agent_tools.py +41 -0
  5. quick_agent/cli.py +44 -0
  6. quick_agent/directory_permissions.py +49 -0
  7. quick_agent/io_utils.py +36 -0
  8. quick_agent/json_utils.py +37 -0
  9. quick_agent/models/__init__.py +23 -0
  10. quick_agent/models/agent_spec.py +22 -0
  11. quick_agent/models/chain_step_spec.py +14 -0
  12. quick_agent/models/handoff_spec.py +13 -0
  13. quick_agent/models/loaded_agent_file.py +14 -0
  14. quick_agent/models/model_spec.py +14 -0
  15. quick_agent/models/output_spec.py +10 -0
  16. quick_agent/models/run_input.py +14 -0
  17. quick_agent/models/tool_impl_spec.py +11 -0
  18. quick_agent/models/tool_json.py +14 -0
  19. quick_agent/orchestrator.py +35 -0
  20. quick_agent/prompting.py +28 -0
  21. quick_agent/quick_agent.py +313 -0
  22. quick_agent/schemas/outputs.py +55 -0
  23. quick_agent/tools/__init__.py +0 -0
  24. quick_agent/tools/filesystem/__init__.py +0 -0
  25. quick_agent/tools/filesystem/adapter.py +26 -0
  26. quick_agent/tools/filesystem/read_text.py +16 -0
  27. quick_agent/tools/filesystem/write_text.py +19 -0
  28. quick_agent/tools/filesystem.read_text/tool.json +10 -0
  29. quick_agent/tools/filesystem.write_text/tool.json +10 -0
  30. quick_agent/tools_loader.py +76 -0
  31. quick_agent-0.1.1.data/data/quick_agent/agents/function-spec-validator.md +109 -0
  32. quick_agent-0.1.1.data/data/quick_agent/agents/subagent-validate-eval-list.md +122 -0
  33. quick_agent-0.1.1.data/data/quick_agent/agents/subagent-validator-contains.md +115 -0
  34. quick_agent-0.1.1.data/data/quick_agent/agents/template.md +88 -0
  35. quick_agent-0.1.1.dist-info/METADATA +918 -0
  36. quick_agent-0.1.1.dist-info/RECORD +45 -0
  37. quick_agent-0.1.1.dist-info/WHEEL +5 -0
  38. quick_agent-0.1.1.dist-info/entry_points.txt +2 -0
  39. quick_agent-0.1.1.dist-info/licenses/LICENSE +674 -0
  40. quick_agent-0.1.1.dist-info/top_level.txt +2 -0
  41. tests/test_agent.py +196 -0
  42. tests/test_directory_permissions.py +89 -0
  43. tests/test_integration.py +221 -0
  44. tests/test_orchestrator.py +797 -0
  45. 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": {}}]