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
tests/test_agent.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import sys
|
|
3
|
+
import types
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from quick_agent import agent_registry
|
|
11
|
+
from quick_agent import io_utils
|
|
12
|
+
from quick_agent import prompting
|
|
13
|
+
from quick_agent import tools_loader
|
|
14
|
+
from quick_agent.directory_permissions import DirectoryPermissions
|
|
15
|
+
from quick_agent.models import AgentSpec
|
|
16
|
+
from quick_agent.models import ChainStepSpec
|
|
17
|
+
from quick_agent.models import LoadedAgentFile
|
|
18
|
+
from quick_agent.models import ModelSpec
|
|
19
|
+
from quick_agent.models.run_input import RunInput
|
|
20
|
+
from quick_agent.quick_agent import resolve_schema
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_import_symbol_valid_and_invalid() -> None:
|
|
24
|
+
tmp_module = types.ModuleType("tmpmod")
|
|
25
|
+
tmp_module.__dict__["Value"] = 123
|
|
26
|
+
sys.modules["tmpmod"] = tmp_module
|
|
27
|
+
try:
|
|
28
|
+
assert tools_loader.import_symbol("tmpmod:Value") == 123
|
|
29
|
+
with pytest.raises(ValueError):
|
|
30
|
+
tools_loader.import_symbol("tmpmod.Value")
|
|
31
|
+
finally:
|
|
32
|
+
sys.modules.pop("tmpmod", None)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_split_step_sections_extracts_content() -> None:
|
|
36
|
+
body = """
|
|
37
|
+
# Title
|
|
38
|
+
|
|
39
|
+
## step:one
|
|
40
|
+
|
|
41
|
+
Hello one.
|
|
42
|
+
|
|
43
|
+
## step:two
|
|
44
|
+
|
|
45
|
+
Hello two.
|
|
46
|
+
"""
|
|
47
|
+
sections = agent_registry.split_step_sections(body)
|
|
48
|
+
assert sections["step:one"] == "Hello one."
|
|
49
|
+
assert sections["step:two"] == "Hello two."
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_load_input_json_and_text(tmp_path: Path) -> None:
|
|
53
|
+
safe_root = tmp_path / "safe"
|
|
54
|
+
safe_root.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
permissions = DirectoryPermissions(safe_root)
|
|
56
|
+
json_path = safe_root / "in.json"
|
|
57
|
+
json_path.write_text(json.dumps({"a": 1}), encoding="utf-8")
|
|
58
|
+
run_input = io_utils.load_input(json_path, permissions)
|
|
59
|
+
assert run_input.kind == "json"
|
|
60
|
+
assert run_input.data == {"a": 1}
|
|
61
|
+
assert "\n" in run_input.text
|
|
62
|
+
|
|
63
|
+
txt_path = safe_root / "in.txt"
|
|
64
|
+
txt_path.write_text("hello", encoding="utf-8")
|
|
65
|
+
run_input = io_utils.load_input(txt_path, permissions)
|
|
66
|
+
assert run_input.kind == "text"
|
|
67
|
+
assert run_input.text == "hello"
|
|
68
|
+
assert run_input.data is None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_write_output_creates_parent(tmp_path: Path) -> None:
|
|
72
|
+
safe_root = tmp_path / "safe"
|
|
73
|
+
safe_root.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
permissions = DirectoryPermissions(safe_root)
|
|
75
|
+
out_path = safe_root / "nested" / "out.txt"
|
|
76
|
+
io_utils.write_output(out_path, "data", permissions)
|
|
77
|
+
assert out_path.read_text(encoding="utf-8") == "data"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_make_user_prompt_contains_sections() -> None:
|
|
81
|
+
run_input = RunInput(source_path="file.txt", kind="text", text="hi", data=None)
|
|
82
|
+
prompt = prompting.make_user_prompt("do thing", run_input, {"x": 1})
|
|
83
|
+
|
|
84
|
+
assert "# Task Input" in prompt
|
|
85
|
+
assert "source_path: file.txt" in prompt
|
|
86
|
+
assert "## Input Content" in prompt
|
|
87
|
+
assert "hi" in prompt
|
|
88
|
+
assert "## Chain State (JSON)" in prompt
|
|
89
|
+
assert '"x": 1' in prompt
|
|
90
|
+
assert "## Step Instructions" in prompt
|
|
91
|
+
assert "do thing" in prompt
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_resolve_schema_valid_missing_and_invalid() -> None:
|
|
95
|
+
schema_module = types.ModuleType("schemas.tmp")
|
|
96
|
+
|
|
97
|
+
class GoodSchema(BaseModel):
|
|
98
|
+
x: int
|
|
99
|
+
|
|
100
|
+
class NotSchema:
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
schema_module.__dict__["GoodSchema"] = GoodSchema
|
|
104
|
+
schema_module.__dict__["NotSchema"] = NotSchema
|
|
105
|
+
sys.modules["schemas.tmp"] = schema_module
|
|
106
|
+
|
|
107
|
+
spec = AgentSpec(
|
|
108
|
+
name="test",
|
|
109
|
+
model=ModelSpec(base_url="http://x", model_name="y"),
|
|
110
|
+
chain=[ChainStepSpec(id="s1", kind="text", prompt_section="step:one")],
|
|
111
|
+
schemas={"Good": "schemas.tmp:GoodSchema", "Bad": "schemas.tmp:NotSchema"},
|
|
112
|
+
)
|
|
113
|
+
loaded = LoadedAgentFile(spec=spec, body="", step_prompts={})
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
assert resolve_schema(loaded, "Good") is GoodSchema
|
|
117
|
+
|
|
118
|
+
with pytest.raises(KeyError):
|
|
119
|
+
resolve_schema(loaded, "Missing")
|
|
120
|
+
|
|
121
|
+
with pytest.raises(TypeError):
|
|
122
|
+
resolve_schema(loaded, "Bad")
|
|
123
|
+
finally:
|
|
124
|
+
sys.modules.pop("schemas.tmp", None)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_load_agent_file_parses_frontmatter_and_steps(tmp_path: Path) -> None:
|
|
128
|
+
md = """---
|
|
129
|
+
name: Test
|
|
130
|
+
description: Hello
|
|
131
|
+
model:
|
|
132
|
+
base_url: http://localhost
|
|
133
|
+
model_name: test
|
|
134
|
+
chain:
|
|
135
|
+
- id: one
|
|
136
|
+
kind: text
|
|
137
|
+
prompt_section: step:one
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## step:one
|
|
141
|
+
|
|
142
|
+
body.
|
|
143
|
+
"""
|
|
144
|
+
md_path = tmp_path / "agent.md"
|
|
145
|
+
md_path.write_text(md, encoding="utf-8")
|
|
146
|
+
|
|
147
|
+
loaded = agent_registry.load_agent_file(md_path)
|
|
148
|
+
assert loaded.spec.name == "Test"
|
|
149
|
+
assert loaded.step_prompts["step:one"] == "body."
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_load_agent_file_model_defaults_when_missing(tmp_path: Path) -> None:
|
|
153
|
+
md = """---
|
|
154
|
+
name: Defaults
|
|
155
|
+
model: {}
|
|
156
|
+
chain:
|
|
157
|
+
- id: one
|
|
158
|
+
kind: text
|
|
159
|
+
prompt_section: step:one
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
## step:one
|
|
163
|
+
|
|
164
|
+
body.
|
|
165
|
+
"""
|
|
166
|
+
md_path = tmp_path / "agent.md"
|
|
167
|
+
md_path.write_text(md, encoding="utf-8")
|
|
168
|
+
|
|
169
|
+
loaded = agent_registry.load_agent_file(md_path)
|
|
170
|
+
assert loaded.spec.model.base_url == "https://api.openai.com/v1"
|
|
171
|
+
assert loaded.spec.model.api_key_env == "OPENAI_API_KEY"
|
|
172
|
+
assert loaded.spec.model.model_name == "gpt-5.2"
|
|
173
|
+
assert loaded.spec.model.provider == "openai-compatible"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_load_agent_file_allows_missing_model_block(tmp_path: Path) -> None:
|
|
177
|
+
md = """---
|
|
178
|
+
name: Defaults
|
|
179
|
+
chain:
|
|
180
|
+
- id: one
|
|
181
|
+
kind: text
|
|
182
|
+
prompt_section: step:one
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## step:one
|
|
186
|
+
|
|
187
|
+
body.
|
|
188
|
+
"""
|
|
189
|
+
md_path = tmp_path / "agent.md"
|
|
190
|
+
md_path.write_text(md, encoding="utf-8")
|
|
191
|
+
|
|
192
|
+
loaded = agent_registry.load_agent_file(md_path)
|
|
193
|
+
assert loaded.spec.model.base_url == "https://api.openai.com/v1"
|
|
194
|
+
assert loaded.spec.model.api_key_env == "OPENAI_API_KEY"
|
|
195
|
+
assert loaded.spec.model.model_name == "gpt-5.2"
|
|
196
|
+
assert loaded.spec.model.provider == "openai-compatible"
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from quick_agent.directory_permissions import DirectoryPermissions
|
|
6
|
+
from quick_agent.orchestrator import Orchestrator
|
|
7
|
+
from quick_agent.quick_agent import QuickAgent
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AsyncReturner:
|
|
11
|
+
def __init__(self, value: object) -> None:
|
|
12
|
+
self.value = value
|
|
13
|
+
|
|
14
|
+
async def __call__(self, *args: object, **kwargs: object) -> object:
|
|
15
|
+
return self.value
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_directory_permissions_resolve_allows_within_root(tmp_path: Path) -> None:
|
|
19
|
+
safe_root = tmp_path / "safe"
|
|
20
|
+
safe_root.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
perms = DirectoryPermissions(safe_root)
|
|
22
|
+
|
|
23
|
+
resolved = perms.resolve(Path("nested/file.txt"), for_write=False)
|
|
24
|
+
|
|
25
|
+
assert resolved == safe_root / "nested" / "file.txt"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_directory_permissions_resolve_blocks_escape(tmp_path: Path) -> None:
|
|
29
|
+
safe_root = tmp_path / "safe"
|
|
30
|
+
safe_root.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
perms = DirectoryPermissions(safe_root)
|
|
32
|
+
|
|
33
|
+
with pytest.raises(PermissionError):
|
|
34
|
+
perms.resolve(Path("../outside.txt"), for_write=False)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_directory_permissions_can_read_write(tmp_path: Path) -> None:
|
|
38
|
+
safe_root = tmp_path / "safe"
|
|
39
|
+
safe_root.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
perms = DirectoryPermissions(safe_root)
|
|
41
|
+
|
|
42
|
+
assert perms.can_read(Path("ok.txt")) is True
|
|
43
|
+
assert perms.can_write(Path("ok.txt")) is True
|
|
44
|
+
assert perms.can_read(Path("../nope.txt")) is False
|
|
45
|
+
assert perms.can_write(Path("../nope.txt")) is False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_agent_cannot_write_outside_scoped_directory(
|
|
49
|
+
tmp_path: Path,
|
|
50
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
51
|
+
) -> None:
|
|
52
|
+
safe_root = tmp_path / "safe"
|
|
53
|
+
safe_root.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
agents_dir = tmp_path / "agents"
|
|
55
|
+
agents_dir.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
|
|
57
|
+
agent_md = """---
|
|
58
|
+
name: "Scoped Agent"
|
|
59
|
+
safe_dir: "agent"
|
|
60
|
+
chain:
|
|
61
|
+
- id: one
|
|
62
|
+
kind: text
|
|
63
|
+
prompt_section: step:one
|
|
64
|
+
output:
|
|
65
|
+
format: json
|
|
66
|
+
file: ../out.json
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## step:one
|
|
70
|
+
|
|
71
|
+
Say ok.
|
|
72
|
+
"""
|
|
73
|
+
(agents_dir / "scoped.md").write_text(agent_md, encoding="utf-8")
|
|
74
|
+
|
|
75
|
+
input_path = safe_root / "agent" / "input.txt"
|
|
76
|
+
input_path.parent.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
input_path.write_text("hi", encoding="utf-8")
|
|
78
|
+
|
|
79
|
+
orch = Orchestrator(
|
|
80
|
+
[agents_dir],
|
|
81
|
+
[tmp_path / "tools"],
|
|
82
|
+
safe_dir=safe_root,
|
|
83
|
+
)
|
|
84
|
+
monkeypatch.setattr(QuickAgent, "_run_chain", AsyncReturner("ok"))
|
|
85
|
+
|
|
86
|
+
with pytest.raises(PermissionError):
|
|
87
|
+
import anyio
|
|
88
|
+
|
|
89
|
+
anyio.run(orch.run, "scoped", input_path)
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
from quick_agent.orchestrator import Orchestrator
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def _run_agent(orchestrator: Orchestrator, agent_id: str, input_path: Path) -> str:
|
|
10
|
+
result = await orchestrator.run(agent_id, input_path)
|
|
11
|
+
assert isinstance(result, str)
|
|
12
|
+
return result
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def _run_agent_any(orchestrator: Orchestrator, agent_id: str, input_path: Path) -> BaseModel | str:
|
|
16
|
+
return await orchestrator.run(agent_id, input_path)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _require_env(name: str) -> str:
|
|
20
|
+
value = os.environ.get(name)
|
|
21
|
+
if not value:
|
|
22
|
+
pytest.skip(f"Missing required env var: {name}")
|
|
23
|
+
raise RuntimeError(f"Missing required env var: {name}")
|
|
24
|
+
return value
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ContactInfo(BaseModel):
|
|
28
|
+
name: str
|
|
29
|
+
company: str
|
|
30
|
+
email: str
|
|
31
|
+
phone: str
|
|
32
|
+
role: str | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ContactSummary(BaseModel):
|
|
36
|
+
contact: ContactInfo
|
|
37
|
+
summary: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_orchestrator_runs_agent_end_to_end(tmp_path: Path) -> None:
|
|
41
|
+
_require_env("OPENAI_API_KEY")
|
|
42
|
+
safe_root = tmp_path / "safe"
|
|
43
|
+
safe_root.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
|
|
45
|
+
from quick_agent.orchestrator import Orchestrator
|
|
46
|
+
|
|
47
|
+
agents_dir = tmp_path / "agents"
|
|
48
|
+
agents_dir.mkdir(parents=True)
|
|
49
|
+
|
|
50
|
+
base_url = os.environ.get("OPENAI_BASE_URL") or "https://api.openai.com/v1"
|
|
51
|
+
model_name = os.environ.get("OPENAI_MODEL") or "gpt-5.2"
|
|
52
|
+
|
|
53
|
+
output_path = safe_root / "out" / "result.json"
|
|
54
|
+
agent_md = f"""---
|
|
55
|
+
name: Test Agent
|
|
56
|
+
model:
|
|
57
|
+
provider: openai-compatible
|
|
58
|
+
base_url: {base_url}
|
|
59
|
+
api_key_env: OPENAI_API_KEY
|
|
60
|
+
model_name: {model_name}
|
|
61
|
+
chain:
|
|
62
|
+
- id: one
|
|
63
|
+
kind: text
|
|
64
|
+
prompt_section: step:one
|
|
65
|
+
output:
|
|
66
|
+
format: json
|
|
67
|
+
file: {output_path}
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## step:one
|
|
71
|
+
|
|
72
|
+
Say ok.
|
|
73
|
+
"""
|
|
74
|
+
(agents_dir / "example.md").write_text(agent_md, encoding="utf-8")
|
|
75
|
+
|
|
76
|
+
input_path = safe_root / "input.txt"
|
|
77
|
+
input_path.write_text("hello", encoding="utf-8")
|
|
78
|
+
|
|
79
|
+
orchestrator = Orchestrator(
|
|
80
|
+
[agents_dir],
|
|
81
|
+
[tmp_path / "tools"],
|
|
82
|
+
safe_dir=safe_root,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
import anyio
|
|
86
|
+
|
|
87
|
+
output = anyio.run(_run_agent, orchestrator, "example", input_path)
|
|
88
|
+
assert output == "ok"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_orchestrator_runs_multi_step_contact_extraction(tmp_path: Path) -> None:
|
|
92
|
+
_require_env("OPENAI_API_KEY")
|
|
93
|
+
safe_root = tmp_path / "safe"
|
|
94
|
+
safe_root.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
|
|
96
|
+
agents_dir = tmp_path / "agents"
|
|
97
|
+
agents_dir.mkdir(parents=True)
|
|
98
|
+
|
|
99
|
+
output_path = safe_root / "out" / "result.json"
|
|
100
|
+
agent_md = f"""---
|
|
101
|
+
name: Contact Extractor
|
|
102
|
+
schemas:
|
|
103
|
+
ContactInfo: test_integration:ContactInfo
|
|
104
|
+
ContactSummary: test_integration:ContactSummary
|
|
105
|
+
chain:
|
|
106
|
+
- id: extract
|
|
107
|
+
kind: structured
|
|
108
|
+
prompt_section: step:extract
|
|
109
|
+
output_schema: ContactInfo
|
|
110
|
+
- id: summary
|
|
111
|
+
kind: structured
|
|
112
|
+
prompt_section: step:summary
|
|
113
|
+
output_schema: ContactSummary
|
|
114
|
+
output:
|
|
115
|
+
format: json
|
|
116
|
+
file: {output_path}
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## step:extract
|
|
120
|
+
|
|
121
|
+
Extract the primary business contact from the conversation as JSON that matches the ContactInfo schema.
|
|
122
|
+
The \"name\" field should include the person's full name.
|
|
123
|
+
If the role is not explicitly stated, set \"role\" to null.
|
|
124
|
+
|
|
125
|
+
## step:summary
|
|
126
|
+
|
|
127
|
+
Produce JSON matching ContactSummary. The summary must be a single sentence and include the contact name and company.
|
|
128
|
+
Use the extracted JSON from the chain state as the ContactInfo object.
|
|
129
|
+
"""
|
|
130
|
+
(agents_dir / "contact.md").write_text(agent_md, encoding="utf-8")
|
|
131
|
+
|
|
132
|
+
conversation = (
|
|
133
|
+
"Alex: Thanks for chatting today. The right point of contact is Avery Chen, our "
|
|
134
|
+
"Head of Partnerships at Acme Robotics. You can reach Avery at avery.chen@acmerobotics.com "
|
|
135
|
+
"or call +1-415-555-0199. Let's follow up next week."
|
|
136
|
+
)
|
|
137
|
+
input_path = safe_root / "input.txt"
|
|
138
|
+
input_path.write_text(conversation, encoding="utf-8")
|
|
139
|
+
|
|
140
|
+
orchestrator = Orchestrator(
|
|
141
|
+
[agents_dir],
|
|
142
|
+
[tmp_path / "tools"],
|
|
143
|
+
safe_dir=safe_root,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
import anyio
|
|
147
|
+
|
|
148
|
+
output = anyio.run(_run_agent_any, orchestrator, "contact", input_path)
|
|
149
|
+
assert isinstance(output, ContactSummary)
|
|
150
|
+
assert output.contact.name == "Avery Chen"
|
|
151
|
+
assert output.contact.company == "Acme Robotics"
|
|
152
|
+
assert output.contact.email == "avery.chen@acmerobotics.com"
|
|
153
|
+
assert output.contact.phone == "+1-415-555-0199"
|
|
154
|
+
assert output.summary
|
|
155
|
+
assert "Avery" in output.summary
|
|
156
|
+
assert "Acme" in output.summary
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_orchestrator_allows_agent_call_tool(tmp_path: Path) -> None:
|
|
160
|
+
_require_env("OPENAI_API_KEY")
|
|
161
|
+
safe_root = tmp_path / "safe"
|
|
162
|
+
safe_root.mkdir(parents=True, exist_ok=True)
|
|
163
|
+
|
|
164
|
+
agents_dir = tmp_path / "agents"
|
|
165
|
+
agents_dir.mkdir(parents=True)
|
|
166
|
+
|
|
167
|
+
child_output = safe_root / "out" / "child.json"
|
|
168
|
+
child_md = f"""---
|
|
169
|
+
name: Child Agent
|
|
170
|
+
chain:
|
|
171
|
+
- id: respond
|
|
172
|
+
kind: text
|
|
173
|
+
prompt_section: step:respond
|
|
174
|
+
output:
|
|
175
|
+
format: json
|
|
176
|
+
file: {child_output}
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
## step:respond
|
|
180
|
+
|
|
181
|
+
Reply with exactly: pong
|
|
182
|
+
"""
|
|
183
|
+
(agents_dir / "child.md").write_text(child_md, encoding="utf-8")
|
|
184
|
+
|
|
185
|
+
parent_output = safe_root / "out" / "parent.json"
|
|
186
|
+
parent_md = f"""---
|
|
187
|
+
name: Parent Agent
|
|
188
|
+
tools:
|
|
189
|
+
- "agent.call"
|
|
190
|
+
chain:
|
|
191
|
+
- id: invoke
|
|
192
|
+
kind: text
|
|
193
|
+
prompt_section: step:invoke
|
|
194
|
+
output:
|
|
195
|
+
format: json
|
|
196
|
+
file: {parent_output}
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
## step:invoke
|
|
200
|
+
|
|
201
|
+
Call agent_call with agent "child" and input_file "{{base_directory}}/child_input.txt".
|
|
202
|
+
Then respond with only the returned text value.
|
|
203
|
+
"""
|
|
204
|
+
(agents_dir / "parent.md").write_text(parent_md, encoding="utf-8")
|
|
205
|
+
|
|
206
|
+
child_input = safe_root / "child_input.txt"
|
|
207
|
+
child_input.write_text("ignored", encoding="utf-8")
|
|
208
|
+
|
|
209
|
+
parent_input = safe_root / "parent_input.txt"
|
|
210
|
+
parent_input.write_text("call child", encoding="utf-8")
|
|
211
|
+
|
|
212
|
+
orchestrator = Orchestrator(
|
|
213
|
+
[agents_dir],
|
|
214
|
+
[tmp_path / "tools"],
|
|
215
|
+
safe_dir=safe_root,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
import anyio
|
|
219
|
+
|
|
220
|
+
output = anyio.run(_run_agent, orchestrator, "parent", parent_input)
|
|
221
|
+
assert output == "pong"
|