quick-agent 0.1.1__py3-none-any.whl → 0.1.3__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 (35) hide show
  1. quick_agent/__init__.py +4 -1
  2. quick_agent/agent_call_tool.py +22 -5
  3. quick_agent/agent_registry.py +7 -27
  4. quick_agent/agent_tools.py +3 -2
  5. quick_agent/cli.py +19 -5
  6. quick_agent/directory_permissions.py +7 -3
  7. quick_agent/input_adaptors.py +30 -0
  8. quick_agent/llms.txt +239 -0
  9. quick_agent/models/agent_spec.py +3 -0
  10. quick_agent/models/loaded_agent_file.py +136 -1
  11. quick_agent/models/output_spec.py +1 -1
  12. quick_agent/orchestrator.py +15 -8
  13. quick_agent/prompting.py +34 -16
  14. quick_agent/py.typed +1 -0
  15. quick_agent/quick_agent.py +171 -155
  16. quick_agent/schemas/outputs.py +6 -0
  17. quick_agent-0.1.3.data/data/quick_agent/agents/business-extract-structured.md +49 -0
  18. quick_agent-0.1.3.data/data/quick_agent/agents/business-extract.md +42 -0
  19. {quick_agent-0.1.1.data → quick_agent-0.1.3.data}/data/quick_agent/agents/function-spec-validator.md +1 -1
  20. {quick_agent-0.1.1.data → quick_agent-0.1.3.data}/data/quick_agent/agents/subagent-validate-eval-list.md +1 -1
  21. {quick_agent-0.1.1.data → quick_agent-0.1.3.data}/data/quick_agent/agents/subagent-validator-contains.md +8 -1
  22. {quick_agent-0.1.1.data → quick_agent-0.1.3.data}/data/quick_agent/agents/template.md +12 -1
  23. {quick_agent-0.1.1.dist-info → quick_agent-0.1.3.dist-info}/METADATA +21 -4
  24. quick_agent-0.1.3.dist-info/RECORD +52 -0
  25. tests/test_agent.py +273 -9
  26. tests/test_directory_permissions.py +10 -0
  27. tests/test_httpx_tools.py +295 -0
  28. tests/test_input_adaptors.py +31 -0
  29. tests/test_integration.py +134 -1
  30. tests/test_orchestrator.py +525 -111
  31. quick_agent-0.1.1.dist-info/RECORD +0 -45
  32. {quick_agent-0.1.1.dist-info → quick_agent-0.1.3.dist-info}/WHEEL +0 -0
  33. {quick_agent-0.1.1.dist-info → quick_agent-0.1.3.dist-info}/entry_points.txt +0 -0
  34. {quick_agent-0.1.1.dist-info → quick_agent-0.1.3.dist-info}/licenses/LICENSE +0 -0
  35. {quick_agent-0.1.1.dist-info → quick_agent-0.1.3.dist-info}/top_level.txt +0 -0
@@ -1,7 +1,7 @@
1
1
  import sys
2
2
  import types
3
3
  from pathlib import Path
4
- from typing import Any, cast
4
+ from typing import Any, Literal, cast
5
5
 
6
6
  import pytest
7
7
  from pydantic import BaseModel
@@ -11,6 +11,7 @@ from pydantic_ai.toolsets import FunctionToolset
11
11
 
12
12
  from quick_agent import quick_agent as qa_module
13
13
  from quick_agent import agent_tools as tools_module
14
+ from quick_agent import input_adaptors as input_adaptors_module
14
15
  from quick_agent.agent_call_tool import AgentCallTool
15
16
  from quick_agent.agent_registry import AgentRegistry
16
17
  from quick_agent.agent_tools import AgentTools
@@ -26,6 +27,7 @@ from quick_agent.orchestrator import Orchestrator
26
27
  from quick_agent.quick_agent import QuickAgent
27
28
  from quick_agent.quick_agent import build_model
28
29
  from quick_agent.quick_agent import resolve_schema
30
+ from quick_agent.prompting import make_user_prompt
29
31
 
30
32
 
31
33
  class DummyProvider:
@@ -76,7 +78,8 @@ class FakeAgent:
76
78
  def __init__(
77
79
  self,
78
80
  model: Any,
79
- instructions: str,
81
+ instructions: str | None,
82
+ system_prompt: str | list[str],
80
83
  toolsets: list[Any],
81
84
  output_type: Any,
82
85
  model_settings: Any | None = None,
@@ -84,6 +87,7 @@ class FakeAgent:
84
87
  FakeAgent.last_init = {
85
88
  "model": model,
86
89
  "instructions": instructions,
90
+ "system_prompt": system_prompt,
87
91
  "toolsets": toolsets,
88
92
  "output_type": output_type,
89
93
  "model_settings": model_settings,
@@ -157,10 +161,10 @@ class RecordingQuickAgent(QuickAgent):
157
161
 
158
162
  class HandoffQuickAgent(QuickAgent):
159
163
  def __init__(self) -> None:
160
- self.calls: list[tuple[str, Path]] = []
164
+ self.calls: list[tuple[str, input_adaptors_module.InputAdaptor | Path]] = []
161
165
 
162
- async def _run_nested_agent(self, agent_id: str, input_path: Path) -> str:
163
- self.calls.append((agent_id, input_path))
166
+ async def _run_nested_agent(self, agent_id: str, input_data: input_adaptors_module.InputAdaptor | Path) -> str:
167
+ self.calls.append((agent_id, input_data))
164
168
  return "ok"
165
169
 
166
170
 
@@ -187,7 +191,12 @@ def _make_loaded_with_chain(
187
191
  output=output or OutputSpec(file="out/result.json"),
188
192
  handoff=handoff or HandoffSpec(),
189
193
  )
190
- return LoadedAgentFile(spec=spec, body="system", step_prompts={"step:one": "do thing"})
194
+ return LoadedAgentFile.from_parts(
195
+ spec=spec,
196
+ instructions="system",
197
+ system_prompt="",
198
+ step_prompts={"step:one": "do thing"},
199
+ )
191
200
 
192
201
 
193
202
  def _permissions(tmp_path: Path | None = None) -> DirectoryPermissions:
@@ -236,7 +245,7 @@ def test_resolve_schema_valid_missing_and_invalid() -> None:
236
245
  chain=[ChainStepSpec(id="s1", kind="text", prompt_section="step:one")],
237
246
  schemas={"Good": "schemas.orch:GoodSchema", "Bad": "schemas.orch:NotSchema"},
238
247
  )
239
- loaded = LoadedAgentFile(spec=spec, body="", step_prompts={})
248
+ loaded = LoadedAgentFile.from_parts(spec=spec, instructions="", system_prompt="", step_prompts={})
240
249
 
241
250
  try:
242
251
  assert resolve_schema(loaded, "Good") is GoodSchema
@@ -306,12 +315,41 @@ def test_maybe_inject_agent_call_tool_skips_when_missing() -> None:
306
315
  assert toolset.add_calls == []
307
316
 
308
317
 
318
+ @pytest.mark.anyio
319
+ async def test_agent_call_tool_accepts_input_text() -> None:
320
+ recorder = AsyncCallRecorder(return_value="ok")
321
+ tool = AgentCallTool(recorder, "run/input.json")
322
+
323
+ result = await tool(agent="child", input_text="hello")
324
+
325
+ assert result == {"text": "ok"}
326
+ assert len(recorder.calls) == 1
327
+ args = recorder.calls[0]["args"]
328
+ assert args[0] == "child"
329
+ assert isinstance(args[1], input_adaptors_module.TextInput)
330
+ run_input = args[1].load()
331
+ assert run_input.kind == "text"
332
+ assert run_input.text == "hello"
333
+
334
+
335
+ @pytest.mark.anyio
336
+ async def test_agent_call_tool_rejects_missing_or_duplicate_input() -> None:
337
+ recorder = AsyncCallRecorder(return_value="ok")
338
+ tool = AgentCallTool(recorder, "run/input.json")
339
+
340
+ with pytest.raises(ValueError):
341
+ await tool(agent="child")
342
+ with pytest.raises(ValueError):
343
+ await tool(agent="child", input_file="a.txt", input_text="hi")
344
+
345
+
309
346
  def test_init_state_contains_agent_id_and_steps() -> None:
310
347
  qa = object.__new__(QuickAgent)
311
348
 
312
- state = qa._init_state("agent-1")
349
+ qa._agent_id = "agent-1"
350
+ state = qa._init_state()
313
351
 
314
- assert state == {"agent_id": "agent-1", "steps": {}}
352
+ assert state == {"agent_id": "agent-1", "steps": {}, "final_output": None}
315
353
 
316
354
 
317
355
  def test_build_model_settings_openai_compatible() -> None:
@@ -348,14 +386,11 @@ def test_build_model_settings_other_provider() -> None:
348
386
  def test_build_structured_model_settings_non_openai_passthrough() -> None:
349
387
  qa = object.__new__(QuickAgent)
350
388
  schema = ExampleSchema
351
- model = cast(OpenAIChatModel, DummyOpenAIModel("http://localhost"))
352
389
  settings: ModelSettings = {"extra_body": {"format": "json"}}
390
+ qa.model = cast(OpenAIChatModel, DummyOpenAIModel("http://localhost"))
391
+ qa.model_settings_json = settings
353
392
 
354
- result = qa._build_structured_model_settings(
355
- model=model,
356
- model_settings_json=settings,
357
- schema_cls=schema,
358
- )
393
+ result = qa._build_structured_model_settings(schema_cls=schema)
359
394
 
360
395
  assert result == settings
361
396
 
@@ -363,13 +398,10 @@ def test_build_structured_model_settings_non_openai_passthrough() -> None:
363
398
  def test_build_structured_model_settings_openai_injects_schema() -> None:
364
399
  qa = object.__new__(QuickAgent)
365
400
  schema = ExampleSchema
366
- model = cast(OpenAIChatModel, DummyOpenAIModel("https://api.openai.com/v1"))
401
+ qa.model = cast(OpenAIChatModel, DummyOpenAIModel("https://api.openai.com/v1"))
402
+ qa.model_settings_json = None
367
403
 
368
- result = qa._build_structured_model_settings(
369
- model=model,
370
- model_settings_json=None,
371
- schema_cls=schema,
372
- )
404
+ result = qa._build_structured_model_settings(schema_cls=schema)
373
405
 
374
406
  assert result is not None
375
407
  extra_body_obj = result.get("extra_body")
@@ -384,16 +416,6 @@ def test_build_structured_model_settings_openai_injects_schema() -> None:
384
416
  assert json_schema_obj["strict"] is True
385
417
 
386
418
 
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
419
  def test_build_user_prompt_uses_prompting(monkeypatch: pytest.MonkeyPatch) -> None:
398
420
  step = ChainStepSpec(id="s1", kind="text", prompt_section="step:one")
399
421
  loaded = _make_loaded_with_chain([step])
@@ -402,10 +424,41 @@ def test_build_user_prompt_uses_prompting(monkeypatch: pytest.MonkeyPatch) -> No
402
424
  monkeypatch.setattr(qa_module, "make_user_prompt", recorder)
403
425
 
404
426
  qa = object.__new__(QuickAgent)
405
- result = qa._build_user_prompt(step=step, loaded=loaded, run_input=run_input, state={"steps": {}})
427
+ qa.loaded = loaded
428
+ qa.run_input = run_input
429
+ qa.state = {"agent_id": "agent-1", "steps": {}, "final_output": None}
430
+ result = qa._build_user_prompt()
406
431
 
407
432
  assert result == "prompt"
408
- assert recorder.calls == [((loaded.step_prompts["step:one"], run_input, {"steps": {}}), {})]
433
+ assert recorder.calls == [
434
+ (
435
+ (run_input, {"agent_id": "agent-1", "steps": {}, "final_output": None}),
436
+ {},
437
+ )
438
+ ]
439
+
440
+
441
+ @pytest.mark.anyio
442
+ async def test_run_text_step_raises_for_missing_section(monkeypatch: pytest.MonkeyPatch) -> None:
443
+ monkeypatch.setattr(qa_module, "Agent", FakeAgent)
444
+
445
+ step = ChainStepSpec(id="s1", kind="text", prompt_section="step:missing")
446
+ loaded = _make_loaded_with_chain([step])
447
+ run_input = RunInput(source_path="in.txt", kind="text", text="hi", data=None)
448
+
449
+ qa = object.__new__(QuickAgent)
450
+ qa.loaded = loaded
451
+ qa.model = cast(OpenAIChatModel, object())
452
+ qa.model_settings_json = None
453
+ qa.toolset = RecordingToolset()
454
+ qa.tool_ids = []
455
+ qa.run_input = run_input
456
+ qa.state = {"agent_id": "a", "steps": {}, "final_output": None}
457
+
458
+ with pytest.raises(KeyError):
459
+ await qa._run_text_step(
460
+ step=step,
461
+ )
409
462
 
410
463
 
411
464
  @pytest.mark.anyio
@@ -418,23 +471,51 @@ async def test_run_step_text_returns_output(monkeypatch: pytest.MonkeyPatch) ->
418
471
  run_input = RunInput(source_path="in.txt", kind="text", text="hi", data=None)
419
472
 
420
473
  qa = object.__new__(QuickAgent)
474
+ qa.loaded = loaded
475
+ qa.model = cast(OpenAIChatModel, object())
476
+ qa.model_settings_json = None
477
+ qa.toolset = RecordingToolset()
478
+ qa.tool_ids = []
479
+ qa.run_input = run_input
480
+ qa.state = {"agent_id": "a", "steps": {}, "final_output": None}
421
481
  output, final = await qa._run_step(
422
482
  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
483
  )
430
484
 
431
485
  assert output == "hello"
432
486
  assert final == "hello"
433
487
  assert FakeAgent.last_init is not None
434
- assert FakeAgent.last_init["instructions"] == "system"
488
+ assert FakeAgent.last_init["instructions"] == "systemdo thing"
489
+ assert FakeAgent.last_init["system_prompt"] == []
435
490
  assert FakeAgent.last_init["output_type"] is str
436
491
 
437
492
 
493
+ @pytest.mark.anyio
494
+ async def test_run_text_step_omits_tools_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
495
+ monkeypatch.setattr(qa_module, "Agent", FakeAgent)
496
+ FakeAgent.next_output = "hello"
497
+
498
+ step = ChainStepSpec(id="s1", kind="text", prompt_section="step:one")
499
+ loaded = _make_loaded_with_chain([step])
500
+ run_input = RunInput(source_path="in.txt", kind="text", text="hi", data=None)
501
+
502
+ qa = object.__new__(QuickAgent)
503
+ qa.loaded = loaded
504
+ qa.model = cast(OpenAIChatModel, object())
505
+ qa.model_settings_json = None
506
+ qa.toolset = RecordingToolset()
507
+ qa.run_input = run_input
508
+ qa.state = {"agent_id": "a", "steps": {}, "final_output": None}
509
+ qa.tool_ids = []
510
+
511
+ await qa._run_text_step(
512
+ step=step,
513
+ )
514
+
515
+ assert FakeAgent.last_init is not None
516
+ assert FakeAgent.last_init["toolsets"] == []
517
+
518
+
438
519
  @pytest.mark.anyio
439
520
  async def test_run_step_structured_parses_json_with_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
440
521
  monkeypatch.setattr(qa_module, "Agent", FakeAgent)
@@ -451,19 +532,25 @@ async def test_run_step_structured_parses_json_with_fallback(monkeypatch: pytest
451
532
  chain=[step],
452
533
  schemas={"Example": "schemas.struct:ExampleSchema"},
453
534
  )
454
- loaded = LoadedAgentFile(spec=spec, body="system", step_prompts={"step:one": "do thing"})
535
+ loaded = LoadedAgentFile.from_parts(
536
+ spec=spec,
537
+ instructions="system",
538
+ system_prompt="",
539
+ step_prompts={"step:one": "do thing"},
540
+ )
455
541
  run_input = RunInput(source_path="in.json", kind="json", text="{}", data={})
456
542
 
457
543
  try:
458
544
  qa = object.__new__(QuickAgent)
545
+ qa.loaded = loaded
546
+ qa.model = cast(OpenAIChatModel, object())
547
+ qa.model_settings_json = {"extra_body": {"format": "json"}}
548
+ qa.toolset = RecordingToolset()
549
+ qa.tool_ids = []
550
+ qa.run_input = run_input
551
+ qa.state = {"agent_id": "a", "steps": {}, "final_output": None}
459
552
  output, final = await qa._run_step(
460
553
  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
554
  )
468
555
  finally:
469
556
  sys.modules.pop("schemas.struct", None)
@@ -471,6 +558,8 @@ async def test_run_step_structured_parses_json_with_fallback(monkeypatch: pytest
471
558
  assert output == {"x": 7}
472
559
  assert isinstance(final, ExampleSchema)
473
560
  assert final.x == 7
561
+ assert FakeAgent.last_init is not None
562
+ assert FakeAgent.last_init["output_type"] is ExampleSchema
474
563
 
475
564
 
476
565
  @pytest.mark.anyio
@@ -481,15 +570,15 @@ async def test_run_step_unknown_kind_raises(monkeypatch: pytest.MonkeyPatch) ->
481
570
  run_input = RunInput(source_path="in.txt", kind="text", text="hi", data=None)
482
571
 
483
572
  qa = object.__new__(QuickAgent)
573
+ qa.loaded = loaded
574
+ qa.model = cast(OpenAIChatModel, object())
575
+ qa.model_settings_json = None
576
+ qa.toolset = RecordingToolset()
577
+ qa.run_input = run_input
578
+ qa.state = {"agent_id": "a", "steps": {}, "final_output": None}
484
579
  with pytest.raises(NotImplementedError):
485
580
  await qa._run_step(
486
581
  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
582
  )
494
583
 
495
584
 
@@ -502,15 +591,15 @@ async def test_run_text_step_uses_build_user_prompt(monkeypatch: pytest.MonkeyPa
502
591
  run_input = RunInput(source_path="in.txt", kind="text", text="hi", data=None)
503
592
 
504
593
  qa = object.__new__(QuickAgent)
594
+ qa.loaded = loaded
595
+ qa.model = cast(OpenAIChatModel, object())
596
+ qa.toolset = RecordingToolset()
597
+ qa.tool_ids = []
598
+ qa.run_input = run_input
505
599
  monkeypatch.setattr(qa, "_build_user_prompt", SyncCallRecorder(return_value="prompt"))
506
600
 
507
601
  output, final = await qa._run_text_step(
508
602
  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
603
  )
515
604
 
516
605
  assert output == "ok"
@@ -518,6 +607,129 @@ async def test_run_text_step_uses_build_user_prompt(monkeypatch: pytest.MonkeyPa
518
607
  assert FakeAgent.last_prompt == "prompt"
519
608
 
520
609
 
610
+ @pytest.mark.anyio
611
+ async def test_run_text_step_no_instructions_or_system_prompt(monkeypatch: pytest.MonkeyPatch) -> None:
612
+ monkeypatch.setattr(qa_module, "Agent", FakeAgent)
613
+ FakeAgent.next_output = "ok"
614
+
615
+ step = ChainStepSpec(id="s1", kind="text", prompt_section="step:one")
616
+ spec = AgentSpec(
617
+ name="test",
618
+ model=ModelSpec(base_url="http://x", model_name="m"),
619
+ chain=[step],
620
+ output=OutputSpec(file=None),
621
+ )
622
+ loaded = LoadedAgentFile.from_parts(
623
+ spec=spec,
624
+ instructions="",
625
+ system_prompt="",
626
+ step_prompts={"step:one": "do thing"},
627
+ )
628
+ run_input = RunInput(source_path="in.txt", kind="text", text="hi", data=None)
629
+
630
+ qa = object.__new__(QuickAgent)
631
+ qa.loaded = loaded
632
+ qa.model = cast(OpenAIChatModel, object())
633
+ qa.model_settings_json = None
634
+ qa.toolset = RecordingToolset()
635
+ qa.tool_ids = []
636
+ qa.run_input = run_input
637
+ qa.state = {"agent_id": "a", "steps": {}, "final_output": None}
638
+
639
+ output, final = await qa._run_text_step(
640
+ step=step,
641
+ )
642
+
643
+ assert output == "ok"
644
+ assert final == "ok"
645
+ assert FakeAgent.last_init is not None
646
+ assert FakeAgent.last_init["instructions"] == "do thing"
647
+ assert FakeAgent.last_init["system_prompt"] == []
648
+ assert FakeAgent.last_prompt == make_user_prompt(run_input, qa.state)
649
+
650
+
651
+ @pytest.mark.anyio
652
+ async def test_run_text_step_system_prompt_only(monkeypatch: pytest.MonkeyPatch) -> None:
653
+ monkeypatch.setattr(qa_module, "Agent", FakeAgent)
654
+ FakeAgent.next_output = "ok"
655
+
656
+ step = ChainStepSpec(id="s1", kind="text", prompt_section="step:one")
657
+ spec = AgentSpec(
658
+ name="test",
659
+ model=ModelSpec(base_url="http://x", model_name="m"),
660
+ chain=[step],
661
+ output=OutputSpec(file=None),
662
+ )
663
+ loaded = LoadedAgentFile.from_parts(
664
+ spec=spec,
665
+ instructions="",
666
+ system_prompt="You are concise.",
667
+ step_prompts={"step:one": "do thing"},
668
+ )
669
+ run_input = RunInput(source_path="in.txt", kind="text", text="hi", data=None)
670
+
671
+ qa = object.__new__(QuickAgent)
672
+ qa.loaded = loaded
673
+ qa.model = cast(OpenAIChatModel, object())
674
+ qa.model_settings_json = None
675
+ qa.toolset = RecordingToolset()
676
+ qa.tool_ids = []
677
+ qa.run_input = run_input
678
+ qa.state = {"agent_id": "a", "steps": {}, "final_output": None}
679
+
680
+ output, final = await qa._run_text_step(
681
+ step=step,
682
+ )
683
+
684
+ assert output == "ok"
685
+ assert final == "ok"
686
+ assert FakeAgent.last_init is not None
687
+ assert FakeAgent.last_init["instructions"] == "do thing"
688
+ assert FakeAgent.last_init["system_prompt"] == "You are concise."
689
+ assert FakeAgent.last_prompt == make_user_prompt(run_input, qa.state)
690
+
691
+
692
+ @pytest.mark.anyio
693
+ async def test_run_text_step_instructions_only(monkeypatch: pytest.MonkeyPatch) -> None:
694
+ monkeypatch.setattr(qa_module, "Agent", FakeAgent)
695
+ FakeAgent.next_output = "ok"
696
+
697
+ step = ChainStepSpec(id="s1", kind="text", prompt_section="step:one")
698
+ spec = AgentSpec(
699
+ name="test",
700
+ model=ModelSpec(base_url="http://x", model_name="m"),
701
+ chain=[step],
702
+ output=OutputSpec(file=None),
703
+ )
704
+ loaded = LoadedAgentFile.from_parts(
705
+ spec=spec,
706
+ instructions="Use the tool.",
707
+ system_prompt="",
708
+ step_prompts={"step:one": "do thing"},
709
+ )
710
+ run_input = RunInput(source_path="in.txt", kind="text", text="hi", data=None)
711
+
712
+ qa = object.__new__(QuickAgent)
713
+ qa.loaded = loaded
714
+ qa.model = cast(OpenAIChatModel, object())
715
+ qa.model_settings_json = None
716
+ qa.toolset = RecordingToolset()
717
+ qa.tool_ids = []
718
+ qa.run_input = run_input
719
+ qa.state = {"agent_id": "a", "steps": {}, "final_output": None}
720
+
721
+ output, final = await qa._run_text_step(
722
+ step=step,
723
+ )
724
+
725
+ assert output == "ok"
726
+ assert final == "ok"
727
+ assert FakeAgent.last_init is not None
728
+ assert FakeAgent.last_init["instructions"] == "Use the tool.do thing"
729
+ assert FakeAgent.last_init["system_prompt"] == []
730
+ assert FakeAgent.last_prompt == make_user_prompt(run_input, qa.state)
731
+
732
+
521
733
  @pytest.mark.anyio
522
734
  async def test_run_structured_step_missing_schema_raises() -> None:
523
735
  step = ChainStepSpec(id="s1", kind="structured", prompt_section="step:one", output_schema=None)
@@ -525,15 +737,14 @@ async def test_run_structured_step_missing_schema_raises() -> None:
525
737
  run_input = RunInput(source_path="in.json", kind="json", text="{}", data={})
526
738
 
527
739
  qa = object.__new__(QuickAgent)
740
+ qa.loaded = loaded
741
+ qa.model = cast(OpenAIChatModel, object())
742
+ qa.model_settings_json = None
743
+ qa.toolset = RecordingToolset()
744
+ qa.run_input = run_input
528
745
  with pytest.raises(ValueError):
529
746
  await qa._run_structured_step(
530
747
  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
748
  )
538
749
 
539
750
 
@@ -553,19 +764,25 @@ async def test_run_structured_step_parses_json(monkeypatch: pytest.MonkeyPatch)
553
764
  chain=[step],
554
765
  schemas={"Example": "schemas.struct2:ExampleSchema"},
555
766
  )
556
- loaded = LoadedAgentFile(spec=spec, body="system", step_prompts={"step:one": "do thing"})
767
+ loaded = LoadedAgentFile.from_parts(
768
+ spec=spec,
769
+ instructions="system",
770
+ system_prompt="",
771
+ step_prompts={"step:one": "do thing"},
772
+ )
557
773
  run_input = RunInput(source_path="in.json", kind="json", text="{}", data={})
558
774
 
559
775
  try:
560
776
  qa = object.__new__(QuickAgent)
777
+ qa.loaded = loaded
778
+ qa.model = cast(OpenAIChatModel, object())
779
+ qa.model_settings_json = None
780
+ qa.toolset = RecordingToolset()
781
+ qa.tool_ids = []
782
+ qa.run_input = run_input
783
+ qa.state = {"agent_id": "a", "steps": {}, "final_output": None}
561
784
  output, final = await qa._run_structured_step(
562
785
  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
786
  )
570
787
  finally:
571
788
  sys.modules.pop("schemas.struct2", None)
@@ -590,19 +807,25 @@ async def test_run_structured_step_adds_json_schema_for_openai(monkeypatch: pyte
590
807
  chain=[step],
591
808
  schemas={"Example": "schemas.struct3:ExampleSchema"},
592
809
  )
593
- loaded = LoadedAgentFile(spec=spec, body="system", step_prompts={"step:one": "do thing"})
810
+ loaded = LoadedAgentFile.from_parts(
811
+ spec=spec,
812
+ instructions="system",
813
+ system_prompt="",
814
+ step_prompts={"step:one": "do thing"},
815
+ )
594
816
  run_input = RunInput(source_path="in.json", kind="json", text="{}", data={})
595
817
 
596
818
  try:
597
819
  qa = object.__new__(QuickAgent)
820
+ qa.loaded = loaded
821
+ qa.model = cast(OpenAIChatModel, DummyOpenAIModel("https://api.openai.com/v1"))
822
+ qa.model_settings_json = None
823
+ qa.toolset = RecordingToolset()
824
+ qa.tool_ids = []
825
+ qa.run_input = run_input
826
+ qa.state = {"agent_id": "a", "steps": {}, "final_output": None}
598
827
  await qa._run_structured_step(
599
828
  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
829
  )
607
830
  finally:
608
831
  sys.modules.pop("schemas.struct3", None)
@@ -623,22 +846,95 @@ async def test_run_chain_updates_state_and_returns_last() -> None:
623
846
  loaded = _make_loaded_with_chain([step1, step2])
624
847
 
625
848
  qa = RecordingQuickAgent(outputs=[({"a": 1}, "first"), ("b", "second")])
626
- state = {"agent_id": "a", "steps": {}}
849
+ qa.loaded = loaded
850
+ qa.model = cast(OpenAIChatModel, object())
851
+ qa.model_settings_json = None
852
+ qa.toolset = RecordingToolset()
853
+ qa.run_input = RunInput(source_path="in.txt", kind="text", text="hi", data=None)
854
+ qa.state = {"agent_id": "a", "steps": {}, "final_output": None}
627
855
 
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
- )
856
+ final = await qa._run_chain()
636
857
 
637
858
  assert final == "second"
638
- assert state["steps"] == {"s1": {"a": 1}, "s2": "b"}
859
+ assert qa.state["steps"] == {"s1": {"a": 1}, "s2": "b"}
860
+ assert qa.state["final_output"] == "b"
639
861
  assert qa.calls == ["s1", "s2"]
640
862
 
641
863
 
864
+ @pytest.mark.anyio
865
+ async def test_run_chain_single_shot_system_prompt_only(monkeypatch: pytest.MonkeyPatch) -> None:
866
+ monkeypatch.setattr(qa_module, "Agent", FakeAgent)
867
+ FakeAgent.next_output = "hello"
868
+
869
+ spec = AgentSpec(
870
+ name="test",
871
+ model=ModelSpec(base_url="http://x", model_name="m"),
872
+ chain=[],
873
+ output=OutputSpec(file=None),
874
+ )
875
+ loaded = LoadedAgentFile.from_parts(
876
+ spec=spec,
877
+ instructions="",
878
+ system_prompt="You are concise.",
879
+ step_prompts={},
880
+ )
881
+ run_input = RunInput(source_path="in.txt", kind="text", text="hi", data=None)
882
+
883
+ qa = object.__new__(QuickAgent)
884
+ qa.loaded = loaded
885
+ qa.model = cast(OpenAIChatModel, object())
886
+ qa.model_settings_json = None
887
+ qa.toolset = RecordingToolset()
888
+ qa.tool_ids = []
889
+ qa.run_input = run_input
890
+ qa.state = {"agent_id": "a", "steps": {}, "final_output": None}
891
+
892
+ output = await qa._run_chain()
893
+
894
+ assert output == "hello"
895
+ assert FakeAgent.last_init is not None
896
+ assert FakeAgent.last_init["instructions"] is None
897
+ assert FakeAgent.last_init["system_prompt"] == "You are concise."
898
+ assert FakeAgent.last_prompt == make_user_prompt(run_input, qa.state)
899
+
900
+
901
+ @pytest.mark.anyio
902
+ async def test_run_chain_single_shot_instructions_only(monkeypatch: pytest.MonkeyPatch) -> None:
903
+ monkeypatch.setattr(qa_module, "Agent", FakeAgent)
904
+ FakeAgent.next_output = "hello"
905
+
906
+ spec = AgentSpec(
907
+ name="test",
908
+ model=ModelSpec(base_url="http://x", model_name="m"),
909
+ chain=[],
910
+ output=OutputSpec(file=None),
911
+ )
912
+ loaded = LoadedAgentFile.from_parts(
913
+ spec=spec,
914
+ instructions="Use the tool.",
915
+ system_prompt="",
916
+ step_prompts={},
917
+ )
918
+ run_input = RunInput(source_path="in.txt", kind="text", text="hi", data=None)
919
+
920
+ qa = object.__new__(QuickAgent)
921
+ qa.loaded = loaded
922
+ qa.model = cast(OpenAIChatModel, object())
923
+ qa.model_settings_json = None
924
+ qa.toolset = RecordingToolset()
925
+ qa.tool_ids = []
926
+ qa.run_input = run_input
927
+ qa.state = {"agent_id": "a", "steps": {}, "final_output": None}
928
+
929
+ output = await qa._run_chain()
930
+
931
+ assert output == "hello"
932
+ assert FakeAgent.last_init is not None
933
+ assert FakeAgent.last_init["instructions"] == "Use the tool."
934
+ assert FakeAgent.last_init["system_prompt"] == []
935
+ assert FakeAgent.last_prompt == make_user_prompt(run_input, qa.state)
936
+
937
+
642
938
  def test_write_final_output_serializes_model(tmp_path: Path) -> None:
643
939
  safe_root = tmp_path / "safe"
644
940
  out_path = safe_root / "out.json"
@@ -648,7 +944,9 @@ def test_write_final_output_serializes_model(tmp_path: Path) -> None:
648
944
 
649
945
  permissions = DirectoryPermissions(safe_root)
650
946
  qa = object.__new__(QuickAgent)
651
- result_path = qa._write_final_output(loaded, OutputSchema(msg="hi"), permissions)
947
+ qa.loaded = loaded
948
+ qa.permissions = permissions
949
+ result_path = qa._write_final_output(OutputSchema(msg="hi"))
652
950
 
653
951
  assert result_path == out_path
654
952
  assert "\"msg\": \"hi\"" in out_path.read_text(encoding="utf-8")
@@ -663,7 +961,9 @@ def test_write_final_output_writes_text(tmp_path: Path) -> None:
663
961
 
664
962
  permissions = DirectoryPermissions(safe_root)
665
963
  qa = object.__new__(QuickAgent)
666
- result_path = qa._write_final_output(loaded, "hello", permissions)
964
+ qa.loaded = loaded
965
+ qa.permissions = permissions
966
+ result_path = qa._write_final_output("hello")
667
967
 
668
968
  assert result_path == out_path
669
969
  assert out_path.read_text(encoding="utf-8") == "hello"
@@ -671,26 +971,49 @@ def test_write_final_output_writes_text(tmp_path: Path) -> None:
671
971
 
672
972
  @pytest.mark.anyio
673
973
  async def test_handle_handoff_runs_followup() -> None:
674
- out_path = Path("/tmp/out.json")
675
974
  handoff = HandoffSpec(enabled=True, agent_id="next")
676
975
  step = ChainStepSpec(id="s1", kind="text", prompt_section="step:one")
677
976
  loaded = _make_loaded_with_chain([step], handoff=handoff)
678
977
 
679
978
  qa = HandoffQuickAgent()
680
- await qa._handle_handoff(loaded, out_path)
979
+ qa.loaded = loaded
980
+ await qa._handle_handoff("hello")
981
+
982
+ assert len(qa.calls) == 1
983
+ agent_id, input_data = qa.calls[0]
984
+ assert agent_id == "next"
985
+ assert isinstance(input_data, input_adaptors_module.TextInput)
986
+ run_input = input_data.load()
987
+ assert run_input.kind == "text"
988
+ assert run_input.text == "hello"
989
+
990
+
991
+ @pytest.mark.anyio
992
+ async def test_handle_handoff_serializes_structured_output() -> None:
993
+ handoff = HandoffSpec(enabled=True, agent_id="next")
994
+ step = ChainStepSpec(id="s1", kind="text", prompt_section="step:one")
995
+ loaded = _make_loaded_with_chain([step], handoff=handoff)
996
+
997
+ qa = HandoffQuickAgent()
998
+ qa.loaded = loaded
999
+ await qa._handle_handoff(OutputSchema(msg="hi"))
681
1000
 
682
- assert qa.calls == [("next", out_path)]
1001
+ assert len(qa.calls) == 1
1002
+ _, input_data = qa.calls[0]
1003
+ assert isinstance(input_data, input_adaptors_module.TextInput)
1004
+ run_input = input_data.load()
1005
+ assert "\"msg\": \"hi\"" in run_input.text
683
1006
 
684
1007
 
685
1008
  @pytest.mark.anyio
686
1009
  async def test_handle_handoff_skips_when_disabled() -> None:
687
- out_path = Path("/tmp/out.json")
688
1010
  handoff = HandoffSpec(enabled=False, agent_id="next")
689
1011
  step = ChainStepSpec(id="s1", kind="text", prompt_section="step:one")
690
1012
  loaded = _make_loaded_with_chain([step], handoff=handoff)
691
1013
 
692
1014
  qa = HandoffQuickAgent()
693
- await qa._handle_handoff(loaded, out_path)
1015
+ qa.loaded = loaded
1016
+ await qa._handle_handoff("ignored")
694
1017
 
695
1018
  assert qa.calls == []
696
1019
 
@@ -705,7 +1028,12 @@ async def test_run_agent_wires_dependencies(monkeypatch: pytest.MonkeyPatch, tmp
705
1028
  tools=["tool.a", "agent.call", "tool.a"],
706
1029
  output=OutputSpec(file=str(tmp_path / "out.json")),
707
1030
  )
708
- loaded = LoadedAgentFile(spec=spec, body="system", step_prompts={"step:one": "do thing"})
1031
+ loaded = LoadedAgentFile.from_parts(
1032
+ spec=spec,
1033
+ instructions="system",
1034
+ system_prompt="",
1035
+ step_prompts={"step:one": "do thing"},
1036
+ )
709
1037
 
710
1038
  run_input = RunInput(source_path=str(tmp_path / "input.json"), kind="json", text="{}", data={})
711
1039
  toolset = RecordingToolset()
@@ -722,7 +1050,7 @@ async def test_run_agent_wires_dependencies(monkeypatch: pytest.MonkeyPatch, tmp
722
1050
  write_output_recorder = SyncCallRecorder(return_value=out_path)
723
1051
  handoff_recorder = AsyncCallRecorder(return_value=None)
724
1052
 
725
- monkeypatch.setattr(qa_module, "load_input", load_input_recorder)
1053
+ monkeypatch.setattr(input_adaptors_module, "load_input", load_input_recorder)
726
1054
  monkeypatch.setattr(qa_module, "build_model", build_model_recorder)
727
1055
  monkeypatch.setattr(QuickAgent, "_build_model_settings", build_settings_recorder)
728
1056
  monkeypatch.setattr(QuickAgent, "_run_chain", run_chain_recorder)
@@ -739,7 +1067,7 @@ async def test_run_agent_wires_dependencies(monkeypatch: pytest.MonkeyPatch, tmp
739
1067
  tools=tools,
740
1068
  directory_permissions=_permissions(tmp_path),
741
1069
  agent_id="agent-1",
742
- input_path=tmp_path / "input.json",
1070
+ input_data=tmp_path / "input.json",
743
1071
  extra_tools=["tool.b"],
744
1072
  )
745
1073
 
@@ -756,6 +1084,8 @@ async def test_run_agent_wires_dependencies(monkeypatch: pytest.MonkeyPatch, tmp
756
1084
  assert load_args[1].root == _permissions(tmp_path).root
757
1085
  assert build_model_recorder.calls == [((loaded.spec.model,), {})]
758
1086
 
1087
+ assert build_settings_recorder.calls == [((loaded.spec.model,), {})]
1088
+
759
1089
  assert build_toolset_recorder.calls
760
1090
  args, kwargs = build_toolset_recorder.calls[0]
761
1091
  assert kwargs == {}
@@ -766,7 +1096,6 @@ async def test_run_agent_wires_dependencies(monkeypatch: pytest.MonkeyPatch, tmp
766
1096
  ]
767
1097
  assert isinstance(args[1], DirectoryPermissions)
768
1098
 
769
- assert build_settings_recorder.calls == [((loaded.spec.model,), {})]
770
1099
  maybe_args, maybe_kwargs = maybe_inject_recorder.calls[0]
771
1100
  assert maybe_kwargs == {}
772
1101
  assert maybe_args[0] == [
@@ -779,19 +1108,104 @@ async def test_run_agent_wires_dependencies(monkeypatch: pytest.MonkeyPatch, tmp
779
1108
  assert callable(maybe_args[3])
780
1109
 
781
1110
  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"
1111
+ assert run_chain_recorder.calls[0]["kwargs"] == {}
789
1112
 
790
1113
  assert write_output_recorder.calls
791
1114
  write_args, write_kwargs = write_output_recorder.calls[0]
792
1115
  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": {}}]
1116
+ assert write_args[0] == "final"
1117
+ assert handoff_recorder.calls == [{"args": ("final",), "kwargs": {}}]
1118
+
1119
+
1120
+ @pytest.mark.anyio
1121
+ async def test_run_skips_write_when_output_file_missing(
1122
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
1123
+ ) -> None:
1124
+ step = ChainStepSpec(id="s1", kind="text", prompt_section="step:one")
1125
+ spec = AgentSpec(
1126
+ name="test",
1127
+ model=ModelSpec(base_url="http://x", model_name="m"),
1128
+ chain=[step],
1129
+ output=OutputSpec(file=None),
1130
+ )
1131
+ loaded = LoadedAgentFile.from_parts(
1132
+ spec=spec,
1133
+ instructions="system",
1134
+ system_prompt="",
1135
+ step_prompts={"step:one": "do thing"},
1136
+ )
1137
+
1138
+ run_input = RunInput(source_path=str(tmp_path / "input.json"), kind="json", text="{}", data={})
1139
+ toolset = RecordingToolset()
1140
+ model = object()
1141
+
1142
+ load_input_recorder = SyncCallRecorder(return_value=run_input)
1143
+ build_model_recorder = SyncCallRecorder(return_value=model)
1144
+ build_toolset_recorder = SyncCallRecorder(return_value=toolset)
1145
+ build_settings_recorder = SyncCallRecorder(return_value=None)
1146
+ maybe_inject_recorder = SyncCallRecorder(return_value=None)
1147
+ run_chain_recorder = AsyncCallRecorder(return_value="final")
1148
+ write_output_recorder = SyncCallRecorder(return_value=tmp_path / "out.json")
1149
+ handoff_recorder = AsyncCallRecorder(return_value=None)
1150
+
1151
+ monkeypatch.setattr(input_adaptors_module, "load_input", load_input_recorder)
1152
+ monkeypatch.setattr(qa_module, "build_model", build_model_recorder)
1153
+ monkeypatch.setattr(QuickAgent, "_build_model_settings", build_settings_recorder)
1154
+ monkeypatch.setattr(QuickAgent, "_run_chain", run_chain_recorder)
1155
+ monkeypatch.setattr(QuickAgent, "_write_final_output", write_output_recorder)
1156
+ monkeypatch.setattr(QuickAgent, "_handle_handoff", handoff_recorder)
1157
+
1158
+ tools = AgentTools([tmp_path])
1159
+ monkeypatch.setattr(tools, "build_toolset", build_toolset_recorder)
1160
+ monkeypatch.setattr(tools, "maybe_inject_agent_call", maybe_inject_recorder)
1161
+ fake_registry = FakeRegistry(loaded)
1162
+
1163
+ agent = QuickAgent(
1164
+ registry=fake_registry,
1165
+ tools=tools,
1166
+ directory_permissions=_permissions(tmp_path),
1167
+ agent_id="agent-1",
1168
+ input_data=tmp_path / "input.json",
1169
+ extra_tools=None,
1170
+ )
1171
+
1172
+ result = await agent.run()
1173
+
1174
+ assert result == "final"
1175
+ assert write_output_recorder.calls == []
1176
+ assert handoff_recorder.calls == [{"args": ("final",), "kwargs": {}}]
1177
+
1178
+
1179
+ @pytest.mark.anyio
1180
+ @pytest.mark.parametrize(
1181
+ ("nested_output", "expected_write_output"),
1182
+ [
1183
+ ("inline", False),
1184
+ ("file", True),
1185
+ ],
1186
+ )
1187
+ async def test_run_nested_agent_respects_nested_output(
1188
+ monkeypatch: pytest.MonkeyPatch,
1189
+ nested_output: Literal["inline", "file"],
1190
+ expected_write_output: bool,
1191
+ ) -> None:
1192
+ qa = object.__new__(QuickAgent)
1193
+ qa._registry = cast(AgentRegistry, object())
1194
+ qa._tools = cast(AgentTools, object())
1195
+ qa._directory_permissions = cast(DirectoryPermissions, object())
1196
+
1197
+ step = ChainStepSpec(id="s1", kind="text", prompt_section="step:one")
1198
+ loaded = _make_loaded_with_chain([step])
1199
+ loaded.spec.nested_output = nested_output
1200
+ qa.loaded = loaded
1201
+
1202
+ init_recorder = SyncCallRecorder(return_value=None)
1203
+ run_recorder = AsyncCallRecorder(return_value="ok")
1204
+ monkeypatch.setattr(QuickAgent, "__init__", init_recorder)
1205
+ monkeypatch.setattr(QuickAgent, "run", run_recorder)
1206
+
1207
+ await qa._run_nested_agent("child", Path("input.txt"))
1208
+
1209
+ assert len(init_recorder.calls) == 1
1210
+ _, kwargs = init_recorder.calls[0]
1211
+ assert kwargs["write_output"] is expected_write_output