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.
- quick_agent/__init__.py +4 -1
- quick_agent/agent_call_tool.py +22 -5
- quick_agent/agent_registry.py +7 -27
- quick_agent/agent_tools.py +3 -2
- quick_agent/cli.py +19 -5
- quick_agent/directory_permissions.py +7 -3
- quick_agent/input_adaptors.py +30 -0
- quick_agent/llms.txt +239 -0
- quick_agent/models/agent_spec.py +3 -0
- quick_agent/models/loaded_agent_file.py +136 -1
- quick_agent/models/output_spec.py +1 -1
- quick_agent/orchestrator.py +15 -8
- quick_agent/prompting.py +34 -16
- quick_agent/py.typed +1 -0
- quick_agent/quick_agent.py +171 -155
- quick_agent/schemas/outputs.py +6 -0
- quick_agent-0.1.3.data/data/quick_agent/agents/business-extract-structured.md +49 -0
- quick_agent-0.1.3.data/data/quick_agent/agents/business-extract.md +42 -0
- {quick_agent-0.1.1.data → quick_agent-0.1.3.data}/data/quick_agent/agents/function-spec-validator.md +1 -1
- {quick_agent-0.1.1.data → quick_agent-0.1.3.data}/data/quick_agent/agents/subagent-validate-eval-list.md +1 -1
- {quick_agent-0.1.1.data → quick_agent-0.1.3.data}/data/quick_agent/agents/subagent-validator-contains.md +8 -1
- {quick_agent-0.1.1.data → quick_agent-0.1.3.data}/data/quick_agent/agents/template.md +12 -1
- {quick_agent-0.1.1.dist-info → quick_agent-0.1.3.dist-info}/METADATA +21 -4
- quick_agent-0.1.3.dist-info/RECORD +52 -0
- tests/test_agent.py +273 -9
- tests/test_directory_permissions.py +10 -0
- tests/test_httpx_tools.py +295 -0
- tests/test_input_adaptors.py +31 -0
- tests/test_integration.py +134 -1
- tests/test_orchestrator.py +525 -111
- quick_agent-0.1.1.dist-info/RECORD +0 -45
- {quick_agent-0.1.1.dist-info → quick_agent-0.1.3.dist-info}/WHEEL +0 -0
- {quick_agent-0.1.1.dist-info → quick_agent-0.1.3.dist-info}/entry_points.txt +0 -0
- {quick_agent-0.1.1.dist-info → quick_agent-0.1.3.dist-info}/licenses/LICENSE +0 -0
- {quick_agent-0.1.1.dist-info → quick_agent-0.1.3.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
import pytest
|
|
7
|
+
from pydantic_ai.models.openai import OpenAIChatModel
|
|
8
|
+
from pydantic_ai.providers.openai import OpenAIProvider
|
|
9
|
+
from pydantic_ai.toolsets import FunctionToolset
|
|
10
|
+
|
|
11
|
+
from quick_agent.agent_registry import AgentRegistry
|
|
12
|
+
from quick_agent.agent_tools import AgentTools
|
|
13
|
+
from quick_agent.directory_permissions import DirectoryPermissions
|
|
14
|
+
from quick_agent.input_adaptors import TextInput
|
|
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.output_spec import OutputSpec
|
|
20
|
+
from quick_agent.quick_agent import QuickAgent
|
|
21
|
+
from quick_agent import quick_agent as qa_module
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def dummy_tool() -> str:
|
|
25
|
+
return "ok"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class HttpxRequestRecorder:
|
|
29
|
+
def __init__(self, response_json: dict[str, Any]) -> None:
|
|
30
|
+
self.response_json = response_json
|
|
31
|
+
self.requests: list[httpx.Request] = []
|
|
32
|
+
self.last_json: dict[str, Any] | None = None
|
|
33
|
+
|
|
34
|
+
def __call__(self, request: httpx.Request) -> httpx.Response:
|
|
35
|
+
self.requests.append(request)
|
|
36
|
+
self.last_json = json.loads(request.content.decode("utf-8"))
|
|
37
|
+
return httpx.Response(200, json=self.response_json)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class StaticRegistry(AgentRegistry):
|
|
41
|
+
def __init__(self, loaded: LoadedAgentFile) -> None:
|
|
42
|
+
super().__init__([])
|
|
43
|
+
self._loaded = loaded
|
|
44
|
+
|
|
45
|
+
def get(self, agent_id: str) -> LoadedAgentFile:
|
|
46
|
+
return self._loaded
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class BuildModelStub:
|
|
50
|
+
def __init__(self, model: OpenAIChatModel) -> None:
|
|
51
|
+
self.model = model
|
|
52
|
+
|
|
53
|
+
def __call__(self, _: ModelSpec) -> OpenAIChatModel:
|
|
54
|
+
return self.model
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _chat_completion_response(model_name: str) -> dict[str, Any]:
|
|
58
|
+
return {
|
|
59
|
+
"id": "chatcmpl-test",
|
|
60
|
+
"object": "chat.completion",
|
|
61
|
+
"created": 123,
|
|
62
|
+
"model": model_name,
|
|
63
|
+
"choices": [
|
|
64
|
+
{
|
|
65
|
+
"index": 0,
|
|
66
|
+
"message": {"role": "assistant", "content": "ok"},
|
|
67
|
+
"finish_reason": "stop",
|
|
68
|
+
}
|
|
69
|
+
],
|
|
70
|
+
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _messages_by_role(messages: list[dict[str, Any]], role: str) -> list[dict[str, Any]]:
|
|
75
|
+
return [message for message in messages if message.get("role") == role]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@pytest.mark.anyio
|
|
79
|
+
async def test_single_shot_without_tools_omits_tools_in_httpx_post(
|
|
80
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
81
|
+
) -> None:
|
|
82
|
+
response_json = _chat_completion_response("gpt-5")
|
|
83
|
+
recorder = HttpxRequestRecorder(response_json)
|
|
84
|
+
transport = httpx.MockTransport(recorder)
|
|
85
|
+
|
|
86
|
+
async with httpx.AsyncClient(transport=transport, base_url="https://example.test/v1") as client:
|
|
87
|
+
provider = OpenAIProvider(base_url="https://example.test/v1", api_key="test", http_client=client)
|
|
88
|
+
model = OpenAIChatModel("gpt-5", provider=provider)
|
|
89
|
+
monkeypatch.setattr(qa_module, "build_model", BuildModelStub(model))
|
|
90
|
+
|
|
91
|
+
step = ChainStepSpec(id="s1", kind="text", prompt_section="step:one")
|
|
92
|
+
spec = AgentSpec(
|
|
93
|
+
name="test",
|
|
94
|
+
model=ModelSpec(base_url="https://example.test/v1", model_name="gpt-5"),
|
|
95
|
+
chain=[step],
|
|
96
|
+
tools=[],
|
|
97
|
+
output=OutputSpec(file=None),
|
|
98
|
+
)
|
|
99
|
+
loaded = LoadedAgentFile.from_parts(
|
|
100
|
+
spec=spec,
|
|
101
|
+
instructions="system",
|
|
102
|
+
system_prompt="",
|
|
103
|
+
step_prompts={"step:one": "say hi"},
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
registry = StaticRegistry(loaded)
|
|
107
|
+
tools = AgentTools([])
|
|
108
|
+
permissions = DirectoryPermissions(tmp_path)
|
|
109
|
+
|
|
110
|
+
agent = QuickAgent(
|
|
111
|
+
registry=registry,
|
|
112
|
+
tools=tools,
|
|
113
|
+
directory_permissions=permissions,
|
|
114
|
+
agent_id="agent-1",
|
|
115
|
+
input_data=TextInput("hello"),
|
|
116
|
+
extra_tools=None,
|
|
117
|
+
write_output=False,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
result = await agent.run()
|
|
121
|
+
|
|
122
|
+
assert result == "ok"
|
|
123
|
+
assert len(recorder.requests) == 1
|
|
124
|
+
assert recorder.last_json is not None
|
|
125
|
+
assert recorder.last_json.get("tools") is None
|
|
126
|
+
assert recorder.last_json.get("tool_choice") is None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@pytest.mark.anyio
|
|
130
|
+
async def test_single_shot_with_tools_includes_tools_in_httpx_post(
|
|
131
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
132
|
+
) -> None:
|
|
133
|
+
response_json = _chat_completion_response("gpt-5")
|
|
134
|
+
recorder = HttpxRequestRecorder(response_json)
|
|
135
|
+
transport = httpx.MockTransport(recorder)
|
|
136
|
+
|
|
137
|
+
async with httpx.AsyncClient(transport=transport, base_url="https://example.test/v1") as client:
|
|
138
|
+
provider = OpenAIProvider(base_url="https://example.test/v1", api_key="test", http_client=client)
|
|
139
|
+
model = OpenAIChatModel("gpt-5", provider=provider)
|
|
140
|
+
monkeypatch.setattr(qa_module, "build_model", BuildModelStub(model))
|
|
141
|
+
|
|
142
|
+
step = ChainStepSpec(id="s1", kind="text", prompt_section="step:one")
|
|
143
|
+
spec = AgentSpec(
|
|
144
|
+
name="test",
|
|
145
|
+
model=ModelSpec(base_url="https://example.test/v1", model_name="gpt-5"),
|
|
146
|
+
chain=[step],
|
|
147
|
+
tools=["dummy.tool"],
|
|
148
|
+
output=OutputSpec(file=None),
|
|
149
|
+
)
|
|
150
|
+
loaded = LoadedAgentFile.from_parts(
|
|
151
|
+
spec=spec,
|
|
152
|
+
instructions="system",
|
|
153
|
+
system_prompt="",
|
|
154
|
+
step_prompts={"step:one": "say hi"},
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
registry = StaticRegistry(loaded)
|
|
158
|
+
tools = AgentTools([])
|
|
159
|
+
toolset = FunctionToolset[Any]()
|
|
160
|
+
toolset.add_function(func=dummy_tool, name="dummy_tool", description="dummy tool")
|
|
161
|
+
monkeypatch.setattr(tools, "build_toolset", lambda *_: toolset)
|
|
162
|
+
permissions = DirectoryPermissions(tmp_path)
|
|
163
|
+
|
|
164
|
+
agent = QuickAgent(
|
|
165
|
+
registry=registry,
|
|
166
|
+
tools=tools,
|
|
167
|
+
directory_permissions=permissions,
|
|
168
|
+
agent_id="agent-1",
|
|
169
|
+
input_data=TextInput("hello"),
|
|
170
|
+
extra_tools=None,
|
|
171
|
+
write_output=False,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
result = await agent.run()
|
|
175
|
+
|
|
176
|
+
assert result == "ok"
|
|
177
|
+
assert len(recorder.requests) == 1
|
|
178
|
+
assert recorder.last_json is not None
|
|
179
|
+
tools_json = recorder.last_json.get("tools")
|
|
180
|
+
assert isinstance(tools_json, list)
|
|
181
|
+
assert tools_json
|
|
182
|
+
assert tools_json[0]["function"]["name"] == "dummy_tool"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@pytest.mark.anyio
|
|
186
|
+
async def test_single_shot_no_steps_system_prompt_only_includes_system_prompt(
|
|
187
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
188
|
+
) -> None:
|
|
189
|
+
response_json = _chat_completion_response("gpt-5")
|
|
190
|
+
recorder = HttpxRequestRecorder(response_json)
|
|
191
|
+
transport = httpx.MockTransport(recorder)
|
|
192
|
+
|
|
193
|
+
async with httpx.AsyncClient(transport=transport, base_url="https://example.test/v1") as client:
|
|
194
|
+
provider = OpenAIProvider(base_url="https://example.test/v1", api_key="test", http_client=client)
|
|
195
|
+
model = OpenAIChatModel("gpt-5", provider=provider)
|
|
196
|
+
monkeypatch.setattr(qa_module, "build_model", BuildModelStub(model))
|
|
197
|
+
|
|
198
|
+
spec = AgentSpec(
|
|
199
|
+
name="test",
|
|
200
|
+
model=ModelSpec(base_url="https://example.test/v1", model_name="gpt-5"),
|
|
201
|
+
chain=[],
|
|
202
|
+
tools=[],
|
|
203
|
+
output=OutputSpec(file=None),
|
|
204
|
+
)
|
|
205
|
+
loaded = LoadedAgentFile.from_parts(
|
|
206
|
+
spec=spec,
|
|
207
|
+
instructions="",
|
|
208
|
+
system_prompt="You are concise.",
|
|
209
|
+
step_prompts={},
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
registry = StaticRegistry(loaded)
|
|
213
|
+
tools = AgentTools([])
|
|
214
|
+
permissions = DirectoryPermissions(tmp_path)
|
|
215
|
+
|
|
216
|
+
agent = QuickAgent(
|
|
217
|
+
registry=registry,
|
|
218
|
+
tools=tools,
|
|
219
|
+
directory_permissions=permissions,
|
|
220
|
+
agent_id="agent-1",
|
|
221
|
+
input_data=TextInput("hello"),
|
|
222
|
+
extra_tools=None,
|
|
223
|
+
write_output=False,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
result = await agent.run()
|
|
227
|
+
|
|
228
|
+
assert result == "ok"
|
|
229
|
+
assert recorder.last_json is not None
|
|
230
|
+
messages = recorder.last_json.get("messages")
|
|
231
|
+
assert isinstance(messages, list)
|
|
232
|
+
assert messages[0]["role"] == "system"
|
|
233
|
+
assert messages[0]["content"] == "You are concise."
|
|
234
|
+
assert messages[-1]["role"] == "user"
|
|
235
|
+
assert "# Task Input" not in messages[-1]["content"]
|
|
236
|
+
assert "## Input Content" not in messages[-1]["content"]
|
|
237
|
+
assert "## Chain State (YAML)" not in messages[-1]["content"]
|
|
238
|
+
assert "## Step Instructions" not in messages[-1]["content"]
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@pytest.mark.anyio
|
|
242
|
+
async def test_single_shot_no_steps_instructions_only_includes_instructions(
|
|
243
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
244
|
+
) -> None:
|
|
245
|
+
response_json = _chat_completion_response("gpt-5")
|
|
246
|
+
recorder = HttpxRequestRecorder(response_json)
|
|
247
|
+
transport = httpx.MockTransport(recorder)
|
|
248
|
+
|
|
249
|
+
async with httpx.AsyncClient(transport=transport, base_url="https://example.test/v1") as client:
|
|
250
|
+
provider = OpenAIProvider(base_url="https://example.test/v1", api_key="test", http_client=client)
|
|
251
|
+
model = OpenAIChatModel("gpt-5", provider=provider)
|
|
252
|
+
monkeypatch.setattr(qa_module, "build_model", BuildModelStub(model))
|
|
253
|
+
|
|
254
|
+
spec = AgentSpec(
|
|
255
|
+
name="test",
|
|
256
|
+
model=ModelSpec(base_url="https://example.test/v1", model_name="gpt-5"),
|
|
257
|
+
chain=[],
|
|
258
|
+
tools=[],
|
|
259
|
+
output=OutputSpec(file=None),
|
|
260
|
+
)
|
|
261
|
+
loaded = LoadedAgentFile.from_parts(
|
|
262
|
+
spec=spec,
|
|
263
|
+
instructions="Use the tool.",
|
|
264
|
+
system_prompt="",
|
|
265
|
+
step_prompts={},
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
registry = StaticRegistry(loaded)
|
|
269
|
+
tools = AgentTools([])
|
|
270
|
+
permissions = DirectoryPermissions(tmp_path)
|
|
271
|
+
|
|
272
|
+
agent = QuickAgent(
|
|
273
|
+
registry=registry,
|
|
274
|
+
tools=tools,
|
|
275
|
+
directory_permissions=permissions,
|
|
276
|
+
agent_id="agent-1",
|
|
277
|
+
input_data=TextInput("hello"),
|
|
278
|
+
extra_tools=None,
|
|
279
|
+
write_output=False,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
result = await agent.run()
|
|
283
|
+
|
|
284
|
+
assert result == "ok"
|
|
285
|
+
assert recorder.last_json is not None
|
|
286
|
+
messages = recorder.last_json.get("messages")
|
|
287
|
+
assert isinstance(messages, list)
|
|
288
|
+
system_messages = _messages_by_role(messages, "system")
|
|
289
|
+
system_contents = [message.get("content") for message in system_messages]
|
|
290
|
+
assert "Use the tool." in system_contents
|
|
291
|
+
assert messages[-1]["role"] == "user"
|
|
292
|
+
assert "# Task Input" not in messages[-1]["content"]
|
|
293
|
+
assert "## Input Content" not in messages[-1]["content"]
|
|
294
|
+
assert "## Chain State (YAML)" not in messages[-1]["content"]
|
|
295
|
+
assert "## Step Instructions" not in messages[-1]["content"]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from quick_agent.directory_permissions import DirectoryPermissions
|
|
6
|
+
from quick_agent.input_adaptors import FileInput
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_file_input_checks_permissions_at_creation(tmp_path: Path) -> None:
|
|
10
|
+
safe_root = tmp_path / "safe"
|
|
11
|
+
safe_root.mkdir(parents=True, exist_ok=True)
|
|
12
|
+
input_path = safe_root / "input.txt"
|
|
13
|
+
input_path.write_text("ok", encoding="utf-8")
|
|
14
|
+
|
|
15
|
+
permissions = DirectoryPermissions(safe_root)
|
|
16
|
+
|
|
17
|
+
adaptor = FileInput(input_path, permissions)
|
|
18
|
+
|
|
19
|
+
run_input = adaptor.load()
|
|
20
|
+
assert run_input.text == "ok"
|
|
21
|
+
assert run_input.source_path == str(input_path)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_file_input_denies_without_root(tmp_path: Path) -> None:
|
|
25
|
+
input_path = tmp_path / "input.txt"
|
|
26
|
+
input_path.write_text("nope", encoding="utf-8")
|
|
27
|
+
|
|
28
|
+
permissions = DirectoryPermissions(None)
|
|
29
|
+
|
|
30
|
+
with pytest.raises(PermissionError):
|
|
31
|
+
FileInput(input_path, permissions)
|
tests/test_integration.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
from pathlib import Path
|
|
2
1
|
import os
|
|
2
|
+
from pathlib import Path
|
|
3
3
|
|
|
4
4
|
import pytest
|
|
5
5
|
from quick_agent.orchestrator import Orchestrator
|
|
@@ -154,6 +154,9 @@ Use the extracted JSON from the chain state as the ContactInfo object.
|
|
|
154
154
|
assert output.summary
|
|
155
155
|
assert "Avery" in output.summary
|
|
156
156
|
assert "Acme" in output.summary
|
|
157
|
+
assert output_path.exists()
|
|
158
|
+
file_output = ContactSummary.model_validate_json(output_path.read_text(encoding="utf-8"))
|
|
159
|
+
assert file_output.model_dump() == output.model_dump()
|
|
157
160
|
|
|
158
161
|
|
|
159
162
|
def test_orchestrator_allows_agent_call_tool(tmp_path: Path) -> None:
|
|
@@ -187,6 +190,7 @@ Reply with exactly: pong
|
|
|
187
190
|
name: Parent Agent
|
|
188
191
|
tools:
|
|
189
192
|
- "agent.call"
|
|
193
|
+
nested_output: inline
|
|
190
194
|
chain:
|
|
191
195
|
- id: invoke
|
|
192
196
|
kind: text
|
|
@@ -219,3 +223,132 @@ Then respond with only the returned text value.
|
|
|
219
223
|
|
|
220
224
|
output = anyio.run(_run_agent, orchestrator, "parent", parent_input)
|
|
221
225
|
assert output == "pong"
|
|
226
|
+
assert not child_output.exists()
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def test_orchestrator_allows_agent_call_tool_with_inline_text(tmp_path: Path) -> None:
|
|
230
|
+
_require_env("OPENAI_API_KEY")
|
|
231
|
+
safe_root = tmp_path / "safe"
|
|
232
|
+
safe_root.mkdir(parents=True, exist_ok=True)
|
|
233
|
+
|
|
234
|
+
agents_dir = tmp_path / "agents"
|
|
235
|
+
agents_dir.mkdir(parents=True)
|
|
236
|
+
|
|
237
|
+
child_output = safe_root / "out" / "child.json"
|
|
238
|
+
child_md = f"""---
|
|
239
|
+
name: Child Agent
|
|
240
|
+
chain:
|
|
241
|
+
- id: respond
|
|
242
|
+
kind: text
|
|
243
|
+
prompt_section: step:respond
|
|
244
|
+
output:
|
|
245
|
+
format: json
|
|
246
|
+
file: {child_output}
|
|
247
|
+
---
|
|
248
|
+
|
|
249
|
+
## step:respond
|
|
250
|
+
|
|
251
|
+
Reply with exactly: pong
|
|
252
|
+
"""
|
|
253
|
+
(agents_dir / "child.md").write_text(child_md, encoding="utf-8")
|
|
254
|
+
|
|
255
|
+
parent_output = safe_root / "out" / "parent.json"
|
|
256
|
+
parent_md = f"""---
|
|
257
|
+
name: Parent Agent
|
|
258
|
+
tools:
|
|
259
|
+
- "agent.call"
|
|
260
|
+
nested_output: inline
|
|
261
|
+
chain:
|
|
262
|
+
- id: invoke
|
|
263
|
+
kind: text
|
|
264
|
+
prompt_section: step:invoke
|
|
265
|
+
output:
|
|
266
|
+
format: json
|
|
267
|
+
file: {parent_output}
|
|
268
|
+
---
|
|
269
|
+
|
|
270
|
+
## step:invoke
|
|
271
|
+
|
|
272
|
+
Call agent_call with agent "child" and input_text "hello from memory".
|
|
273
|
+
Then respond with only the returned text value.
|
|
274
|
+
"""
|
|
275
|
+
(agents_dir / "parent.md").write_text(parent_md, encoding="utf-8")
|
|
276
|
+
|
|
277
|
+
parent_input = safe_root / "parent_input.txt"
|
|
278
|
+
parent_input.write_text("call child", encoding="utf-8")
|
|
279
|
+
|
|
280
|
+
orchestrator = Orchestrator(
|
|
281
|
+
[agents_dir],
|
|
282
|
+
[tmp_path / "tools"],
|
|
283
|
+
safe_dir=safe_root,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
import anyio
|
|
287
|
+
|
|
288
|
+
output = anyio.run(_run_agent, orchestrator, "parent", parent_input)
|
|
289
|
+
assert output == "pong"
|
|
290
|
+
assert not child_output.exists()
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def test_orchestrator_allows_nested_output_file(tmp_path: Path) -> None:
|
|
294
|
+
_require_env("OPENAI_API_KEY")
|
|
295
|
+
safe_root = tmp_path / "safe"
|
|
296
|
+
safe_root.mkdir(parents=True, exist_ok=True)
|
|
297
|
+
|
|
298
|
+
agents_dir = tmp_path / "agents"
|
|
299
|
+
agents_dir.mkdir(parents=True)
|
|
300
|
+
|
|
301
|
+
child_output = safe_root / "out" / "child.json"
|
|
302
|
+
child_md = f"""---
|
|
303
|
+
name: Child Agent
|
|
304
|
+
chain:
|
|
305
|
+
- id: respond
|
|
306
|
+
kind: text
|
|
307
|
+
prompt_section: step:respond
|
|
308
|
+
output:
|
|
309
|
+
format: json
|
|
310
|
+
file: {child_output}
|
|
311
|
+
---
|
|
312
|
+
|
|
313
|
+
## step:respond
|
|
314
|
+
|
|
315
|
+
Reply with exactly: pong
|
|
316
|
+
"""
|
|
317
|
+
(agents_dir / "child.md").write_text(child_md, encoding="utf-8")
|
|
318
|
+
|
|
319
|
+
parent_output = safe_root / "out" / "parent.json"
|
|
320
|
+
parent_md = f"""---
|
|
321
|
+
name: Parent Agent
|
|
322
|
+
tools:
|
|
323
|
+
- "agent.call"
|
|
324
|
+
nested_output: file
|
|
325
|
+
chain:
|
|
326
|
+
- id: invoke
|
|
327
|
+
kind: text
|
|
328
|
+
prompt_section: step:invoke
|
|
329
|
+
output:
|
|
330
|
+
format: json
|
|
331
|
+
file: {parent_output}
|
|
332
|
+
---
|
|
333
|
+
|
|
334
|
+
## step:invoke
|
|
335
|
+
|
|
336
|
+
Call agent_call with agent "child" and input_text "hello from memory".
|
|
337
|
+
Then respond with only the returned text value.
|
|
338
|
+
"""
|
|
339
|
+
(agents_dir / "parent.md").write_text(parent_md, encoding="utf-8")
|
|
340
|
+
|
|
341
|
+
parent_input = safe_root / "parent_input.txt"
|
|
342
|
+
parent_input.write_text("call child", encoding="utf-8")
|
|
343
|
+
|
|
344
|
+
orchestrator = Orchestrator(
|
|
345
|
+
[agents_dir],
|
|
346
|
+
[tmp_path / "tools"],
|
|
347
|
+
safe_dir=safe_root,
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
import anyio
|
|
351
|
+
|
|
352
|
+
output = anyio.run(_run_agent, orchestrator, "parent", parent_input)
|
|
353
|
+
assert output == "pong"
|
|
354
|
+
assert child_output.exists()
|