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
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"