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
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""Agent execution logic."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Type
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ValidationError
|
|
10
|
+
from pydantic_ai import Agent
|
|
11
|
+
from pydantic_ai.models.openai import OpenAIChatModel
|
|
12
|
+
from pydantic_ai.providers.openai import OpenAIProvider
|
|
13
|
+
from pydantic_ai.settings import ModelSettings
|
|
14
|
+
from pydantic_ai.toolsets import FunctionToolset
|
|
15
|
+
|
|
16
|
+
from quick_agent.agent_registry import AgentRegistry
|
|
17
|
+
from quick_agent.agent_tools import AgentTools
|
|
18
|
+
from quick_agent.directory_permissions import DirectoryPermissions
|
|
19
|
+
from quick_agent.io_utils import load_input, write_output
|
|
20
|
+
from quick_agent.json_utils import extract_first_json_object
|
|
21
|
+
from quick_agent.models.loaded_agent_file import LoadedAgentFile
|
|
22
|
+
from quick_agent.models.model_spec import ModelSpec
|
|
23
|
+
from quick_agent.prompting import make_user_prompt
|
|
24
|
+
from quick_agent.tools_loader import import_symbol
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class QuickAgent:
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
*,
|
|
31
|
+
registry: AgentRegistry,
|
|
32
|
+
tools: AgentTools,
|
|
33
|
+
directory_permissions: DirectoryPermissions,
|
|
34
|
+
agent_id: str,
|
|
35
|
+
input_path: Path,
|
|
36
|
+
extra_tools: list[str] | None,
|
|
37
|
+
) -> None:
|
|
38
|
+
self._registry = registry
|
|
39
|
+
self._tools = tools
|
|
40
|
+
self._directory_permissions = directory_permissions
|
|
41
|
+
self._agent_id = agent_id
|
|
42
|
+
self._input_path = input_path
|
|
43
|
+
self._extra_tools = extra_tools
|
|
44
|
+
|
|
45
|
+
self.loaded = self._registry.get(self._agent_id)
|
|
46
|
+
safe_dir = self.loaded.spec.safe_dir
|
|
47
|
+
if safe_dir is not None and Path(safe_dir).is_absolute():
|
|
48
|
+
raise ValueError("safe_dir must be a relative path.")
|
|
49
|
+
self.permissions = self._directory_permissions.scoped(safe_dir)
|
|
50
|
+
self.run_input = load_input(self._input_path, self.permissions)
|
|
51
|
+
|
|
52
|
+
self.tool_ids = list(dict.fromkeys((self.loaded.spec.tools or []) + (self._extra_tools or [])))
|
|
53
|
+
self.toolset = self._tools.build_toolset(self.tool_ids, self.permissions)
|
|
54
|
+
|
|
55
|
+
self.model = build_model(self.loaded.spec.model)
|
|
56
|
+
self.model_settings_json = self._build_model_settings(self.loaded.spec.model)
|
|
57
|
+
self.state = self._init_state(self._agent_id)
|
|
58
|
+
|
|
59
|
+
async def run(self) -> BaseModel | str:
|
|
60
|
+
self._tools.maybe_inject_agent_call(
|
|
61
|
+
self.tool_ids,
|
|
62
|
+
self.toolset,
|
|
63
|
+
self.run_input.source_path,
|
|
64
|
+
self._run_nested_agent,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
final_output = await self._run_chain(
|
|
68
|
+
loaded=self.loaded,
|
|
69
|
+
model=self.model,
|
|
70
|
+
model_settings_json=self.model_settings_json,
|
|
71
|
+
toolset=self.toolset,
|
|
72
|
+
run_input=self.run_input,
|
|
73
|
+
state=self.state,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
out_path = self._write_final_output(self.loaded, final_output, self.permissions)
|
|
77
|
+
|
|
78
|
+
await self._handle_handoff(self.loaded, out_path)
|
|
79
|
+
|
|
80
|
+
return final_output
|
|
81
|
+
|
|
82
|
+
async def _run_nested_agent(self, agent_id: str, input_path: Path) -> BaseModel | str:
|
|
83
|
+
agent = QuickAgent(
|
|
84
|
+
registry=self._registry,
|
|
85
|
+
tools=self._tools,
|
|
86
|
+
directory_permissions=self._directory_permissions,
|
|
87
|
+
agent_id=agent_id,
|
|
88
|
+
input_path=input_path,
|
|
89
|
+
extra_tools=None,
|
|
90
|
+
)
|
|
91
|
+
return await agent.run()
|
|
92
|
+
|
|
93
|
+
def _init_state(self, agent_id: str) -> dict[str, Any]:
|
|
94
|
+
return {
|
|
95
|
+
"agent_id": agent_id,
|
|
96
|
+
"steps": {},
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
def _build_model_settings(self, model_spec: ModelSpec) -> ModelSettings | None:
|
|
100
|
+
if model_spec.provider == "openai-compatible":
|
|
101
|
+
# Ollama OpenAI-compatible API uses "format": "json" to force JSON output.
|
|
102
|
+
if model_spec.base_url != "https://api.openai.com/v1":
|
|
103
|
+
return {"extra_body": {"format": "json"}}
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
def _build_structured_model_settings(
|
|
107
|
+
self,
|
|
108
|
+
*,
|
|
109
|
+
model: OpenAIChatModel,
|
|
110
|
+
model_settings_json: ModelSettings | None,
|
|
111
|
+
schema_cls: Type[BaseModel],
|
|
112
|
+
) -> ModelSettings | None:
|
|
113
|
+
model_settings: ModelSettings | None = model_settings_json
|
|
114
|
+
provider = getattr(model, "provider", None)
|
|
115
|
+
base_url = getattr(provider, "base_url", None)
|
|
116
|
+
if base_url == "https://api.openai.com/v1":
|
|
117
|
+
if model_settings_json is None:
|
|
118
|
+
model_settings_dict: ModelSettings = {}
|
|
119
|
+
else:
|
|
120
|
+
model_settings_dict = model_settings_json
|
|
121
|
+
extra_body_obj = model_settings_dict.get("extra_body")
|
|
122
|
+
extra_body: dict[str, Any] = {}
|
|
123
|
+
if isinstance(extra_body_obj, dict):
|
|
124
|
+
extra_body = dict(extra_body_obj)
|
|
125
|
+
if "response_format" not in extra_body:
|
|
126
|
+
extra_body["response_format"] = {
|
|
127
|
+
"type": "json_schema",
|
|
128
|
+
"json_schema": {
|
|
129
|
+
"name": schema_cls.__name__,
|
|
130
|
+
"schema": schema_cls.model_json_schema(),
|
|
131
|
+
"strict": True,
|
|
132
|
+
},
|
|
133
|
+
}
|
|
134
|
+
model_settings_dict["extra_body"] = extra_body
|
|
135
|
+
model_settings = model_settings_dict
|
|
136
|
+
return model_settings
|
|
137
|
+
|
|
138
|
+
async def _run_step(
|
|
139
|
+
self,
|
|
140
|
+
*,
|
|
141
|
+
step: Any,
|
|
142
|
+
loaded: LoadedAgentFile,
|
|
143
|
+
model: OpenAIChatModel,
|
|
144
|
+
model_settings_json: ModelSettings | None,
|
|
145
|
+
toolset: FunctionToolset[Any],
|
|
146
|
+
run_input: Any,
|
|
147
|
+
state: dict[str, Any],
|
|
148
|
+
) -> tuple[Any, BaseModel | str]:
|
|
149
|
+
if step.kind == "text":
|
|
150
|
+
return await self._run_text_step(
|
|
151
|
+
step=step,
|
|
152
|
+
loaded=loaded,
|
|
153
|
+
model=model,
|
|
154
|
+
toolset=toolset,
|
|
155
|
+
run_input=run_input,
|
|
156
|
+
state=state,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
if step.kind == "structured":
|
|
160
|
+
return await self._run_structured_step(
|
|
161
|
+
step=step,
|
|
162
|
+
loaded=loaded,
|
|
163
|
+
model=model,
|
|
164
|
+
model_settings_json=model_settings_json,
|
|
165
|
+
toolset=toolset,
|
|
166
|
+
run_input=run_input,
|
|
167
|
+
state=state,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
raise NotImplementedError(f"Unknown step kind: {step.kind}")
|
|
171
|
+
|
|
172
|
+
def _build_user_prompt(
|
|
173
|
+
self,
|
|
174
|
+
*,
|
|
175
|
+
step: Any,
|
|
176
|
+
loaded: LoadedAgentFile,
|
|
177
|
+
run_input: Any,
|
|
178
|
+
state: dict[str, Any],
|
|
179
|
+
) -> str:
|
|
180
|
+
if step.prompt_section not in loaded.step_prompts:
|
|
181
|
+
raise KeyError(f"Missing step section {step.prompt_section!r} in agent.md body.")
|
|
182
|
+
|
|
183
|
+
step_prompt = loaded.step_prompts[step.prompt_section]
|
|
184
|
+
return make_user_prompt(step_prompt, run_input, state)
|
|
185
|
+
|
|
186
|
+
async def _run_text_step(
|
|
187
|
+
self,
|
|
188
|
+
*,
|
|
189
|
+
step: Any,
|
|
190
|
+
loaded: LoadedAgentFile,
|
|
191
|
+
model: OpenAIChatModel,
|
|
192
|
+
toolset: FunctionToolset[Any],
|
|
193
|
+
run_input: Any,
|
|
194
|
+
state: dict[str, Any],
|
|
195
|
+
) -> tuple[Any, BaseModel | str]:
|
|
196
|
+
user_prompt = self._build_user_prompt(
|
|
197
|
+
step=step,
|
|
198
|
+
loaded=loaded,
|
|
199
|
+
run_input=run_input,
|
|
200
|
+
state=state,
|
|
201
|
+
)
|
|
202
|
+
agent = Agent(
|
|
203
|
+
model,
|
|
204
|
+
instructions=loaded.body,
|
|
205
|
+
toolsets=[toolset],
|
|
206
|
+
output_type=str,
|
|
207
|
+
)
|
|
208
|
+
result = await agent.run(user_prompt)
|
|
209
|
+
return result.output, result.output
|
|
210
|
+
|
|
211
|
+
async def _run_structured_step(
|
|
212
|
+
self,
|
|
213
|
+
*,
|
|
214
|
+
step: Any,
|
|
215
|
+
loaded: LoadedAgentFile,
|
|
216
|
+
model: OpenAIChatModel,
|
|
217
|
+
model_settings_json: ModelSettings | None,
|
|
218
|
+
toolset: FunctionToolset[Any],
|
|
219
|
+
run_input: Any,
|
|
220
|
+
state: dict[str, Any],
|
|
221
|
+
) -> tuple[Any, BaseModel | str]:
|
|
222
|
+
if not step.output_schema:
|
|
223
|
+
raise ValueError(f"Step {step.id} is structured but missing output_schema.")
|
|
224
|
+
schema_cls = resolve_schema(loaded, step.output_schema)
|
|
225
|
+
|
|
226
|
+
model_settings = self._build_structured_model_settings(
|
|
227
|
+
model=model,
|
|
228
|
+
model_settings_json=model_settings_json,
|
|
229
|
+
schema_cls=schema_cls,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
user_prompt = self._build_user_prompt(
|
|
233
|
+
step=step,
|
|
234
|
+
loaded=loaded,
|
|
235
|
+
run_input=run_input,
|
|
236
|
+
state=state,
|
|
237
|
+
)
|
|
238
|
+
agent = Agent(
|
|
239
|
+
model,
|
|
240
|
+
instructions=loaded.body,
|
|
241
|
+
toolsets=[toolset],
|
|
242
|
+
output_type=str,
|
|
243
|
+
model_settings=model_settings,
|
|
244
|
+
)
|
|
245
|
+
result = await agent.run(user_prompt)
|
|
246
|
+
raw_output = result.output
|
|
247
|
+
try:
|
|
248
|
+
parsed = schema_cls.model_validate_json(raw_output)
|
|
249
|
+
except ValidationError:
|
|
250
|
+
extracted = extract_first_json_object(raw_output)
|
|
251
|
+
parsed = schema_cls.model_validate_json(extracted)
|
|
252
|
+
return parsed.model_dump(), parsed
|
|
253
|
+
|
|
254
|
+
async def _run_chain(
|
|
255
|
+
self,
|
|
256
|
+
*,
|
|
257
|
+
loaded: LoadedAgentFile,
|
|
258
|
+
model: OpenAIChatModel,
|
|
259
|
+
model_settings_json: ModelSettings | None,
|
|
260
|
+
toolset: FunctionToolset[Any],
|
|
261
|
+
run_input: Any,
|
|
262
|
+
state: dict[str, Any],
|
|
263
|
+
) -> BaseModel | str:
|
|
264
|
+
final_output: BaseModel | str = ""
|
|
265
|
+
for step in loaded.spec.chain:
|
|
266
|
+
step_out, step_final = await self._run_step(
|
|
267
|
+
step=step,
|
|
268
|
+
loaded=loaded,
|
|
269
|
+
model=model,
|
|
270
|
+
model_settings_json=model_settings_json,
|
|
271
|
+
toolset=toolset,
|
|
272
|
+
run_input=run_input,
|
|
273
|
+
state=state,
|
|
274
|
+
)
|
|
275
|
+
state["steps"][step.id] = step_out
|
|
276
|
+
final_output = step_final
|
|
277
|
+
return final_output
|
|
278
|
+
|
|
279
|
+
def _write_final_output(
|
|
280
|
+
self,
|
|
281
|
+
loaded: LoadedAgentFile,
|
|
282
|
+
final_output: BaseModel | str,
|
|
283
|
+
permissions: DirectoryPermissions,
|
|
284
|
+
) -> Path:
|
|
285
|
+
out_path = Path(loaded.spec.output.file)
|
|
286
|
+
if isinstance(final_output, BaseModel):
|
|
287
|
+
if loaded.spec.output.format == "json":
|
|
288
|
+
write_output(out_path, final_output.model_dump_json(indent=2), permissions)
|
|
289
|
+
else:
|
|
290
|
+
write_output(out_path, final_output.model_dump_json(indent=2), permissions)
|
|
291
|
+
else:
|
|
292
|
+
write_output(out_path, str(final_output), permissions)
|
|
293
|
+
return out_path
|
|
294
|
+
|
|
295
|
+
async def _handle_handoff(self, loaded: LoadedAgentFile, out_path: Path) -> None:
|
|
296
|
+
if loaded.spec.handoff.enabled and loaded.spec.handoff.agent_id:
|
|
297
|
+
# For a more robust version, generate an intermediate file for handoff input.
|
|
298
|
+
await self._run_nested_agent(loaded.spec.handoff.agent_id, out_path)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def resolve_schema(loaded: LoadedAgentFile, schema_name: str) -> Type[BaseModel]:
|
|
302
|
+
if schema_name not in loaded.spec.schemas:
|
|
303
|
+
raise KeyError(f"Schema {schema_name!r} not registered in agent.md schemas.")
|
|
304
|
+
cls = import_symbol(loaded.spec.schemas[schema_name])
|
|
305
|
+
if not isinstance(cls, type) or not issubclass(cls, BaseModel):
|
|
306
|
+
raise TypeError(f"Schema {schema_name!r} must be a Pydantic BaseModel subclass.")
|
|
307
|
+
return cls
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def build_model(model_spec: ModelSpec) -> OpenAIChatModel:
|
|
311
|
+
api_key = os.environ.get(model_spec.api_key_env, "noop")
|
|
312
|
+
provider = OpenAIProvider(base_url=model_spec.base_url, api_key=api_key)
|
|
313
|
+
return OpenAIChatModel(model_spec.model_name, provider=provider)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field, AliasChoices
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Plan(BaseModel):
|
|
9
|
+
# LLMs sometimes emit structured steps; allow both strings and dicts.
|
|
10
|
+
steps: list[str | dict[str, Any]] = Field(default_factory=list)
|
|
11
|
+
tool_calls: list[str | dict[str, Any]] = Field(default_factory=list)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ValidationIssue(BaseModel):
|
|
15
|
+
field: str
|
|
16
|
+
reason: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ValidationResult(BaseModel):
|
|
20
|
+
valid: bool
|
|
21
|
+
function_name: str | None = None
|
|
22
|
+
parameters_count: int | None = None
|
|
23
|
+
return_type: str | None = None
|
|
24
|
+
target_path: str | None = None
|
|
25
|
+
issues: list[ValidationIssue] = Field(default_factory=list)
|
|
26
|
+
summary: str | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class EvalRowResult(BaseModel):
|
|
30
|
+
index: int = 0
|
|
31
|
+
agent: str
|
|
32
|
+
command_file: str
|
|
33
|
+
result: str = Field(default="", validation_alias=AliasChoices("result", "results")) # "PASS" or "FAIL"
|
|
34
|
+
note: str | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class EvalListResult(BaseModel):
|
|
38
|
+
total: int
|
|
39
|
+
passed: int
|
|
40
|
+
failed: int
|
|
41
|
+
results_path: str
|
|
42
|
+
rows: list[EvalRowResult] = Field(default_factory=list)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ContainsCheck(BaseModel):
|
|
46
|
+
text: str
|
|
47
|
+
found: bool
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ContainsValidationResult(BaseModel):
|
|
51
|
+
status: str # "PASS" or "FAIL"
|
|
52
|
+
checks: list[ContainsCheck] = Field(default_factory=list)
|
|
53
|
+
missing: list[str] = Field(default_factory=list)
|
|
54
|
+
eval_path: str | None = None
|
|
55
|
+
response_path: str | None = None
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Filesystem tool adapter with directory permissions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from quick_agent.directory_permissions import DirectoryPermissions
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FilesystemToolAdapter:
|
|
11
|
+
def __init__(self, permissions: DirectoryPermissions) -> None:
|
|
12
|
+
self._permissions = permissions
|
|
13
|
+
|
|
14
|
+
def read_text(self, path: str) -> str:
|
|
15
|
+
if not self._permissions.can_read(Path(path)):
|
|
16
|
+
raise PermissionError(f"Read access denied for {path}.")
|
|
17
|
+
safe_path = self._permissions.resolve(Path(path), for_write=False)
|
|
18
|
+
return safe_path.read_text(encoding="utf-8")
|
|
19
|
+
|
|
20
|
+
def write_text(self, path: str, content: str) -> str:
|
|
21
|
+
if not self._permissions.can_write(Path(path)):
|
|
22
|
+
raise PermissionError(f"Write access denied for {path}.")
|
|
23
|
+
safe_path = self._permissions.resolve(Path(path), for_write=True)
|
|
24
|
+
safe_path.parent.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
safe_path.write_text(content, encoding="utf-8")
|
|
26
|
+
return str(safe_path)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from quick_agent.directory_permissions import DirectoryPermissions
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def read_text(
|
|
9
|
+
path: str,
|
|
10
|
+
permissions: DirectoryPermissions,
|
|
11
|
+
) -> str:
|
|
12
|
+
"""Read UTF-8 text from a file path."""
|
|
13
|
+
if not permissions.can_read(Path(path)):
|
|
14
|
+
raise PermissionError(f"Read access denied for {path}.")
|
|
15
|
+
safe_path = permissions.resolve(Path(path), for_write=False)
|
|
16
|
+
return safe_path.read_text(encoding="utf-8")
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from quick_agent.directory_permissions import DirectoryPermissions
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def write_text(
|
|
9
|
+
path: str,
|
|
10
|
+
content: str,
|
|
11
|
+
permissions: DirectoryPermissions,
|
|
12
|
+
) -> str:
|
|
13
|
+
"""Write UTF-8 text to a file path. Returns the written path."""
|
|
14
|
+
if not permissions.can_write(Path(path)):
|
|
15
|
+
raise PermissionError(f"Write access denied for {path}.")
|
|
16
|
+
out = permissions.resolve(Path(path), for_write=True)
|
|
17
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
18
|
+
out.write_text(content, encoding="utf-8")
|
|
19
|
+
return str(out)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "filesystem.write_text",
|
|
3
|
+
"name": "filesystem.write_text",
|
|
4
|
+
"description": "Write a UTF-8 text file to disk. Returns the written path.",
|
|
5
|
+
"impl": {
|
|
6
|
+
"kind": "python",
|
|
7
|
+
"module": "quick_agent.tools.filesystem.write_text",
|
|
8
|
+
"function": "write_text"
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Tool discovery and loading."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pydantic_ai.toolsets import FunctionToolset
|
|
11
|
+
|
|
12
|
+
from quick_agent.directory_permissions import DirectoryPermissions
|
|
13
|
+
from quick_agent.models.tool_json import ToolJson
|
|
14
|
+
from quick_agent.tools.filesystem.adapter import FilesystemToolAdapter
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def import_symbol(path: str) -> Any:
|
|
18
|
+
"""
|
|
19
|
+
Imports a symbol given "package.module:SymbolName".
|
|
20
|
+
"""
|
|
21
|
+
if ":" not in path:
|
|
22
|
+
raise ValueError(f"Expected import path 'module:Symbol', got {path!r}")
|
|
23
|
+
mod, sym = path.split(":", 1)
|
|
24
|
+
module = importlib.import_module(mod)
|
|
25
|
+
return getattr(module, sym)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _discover_tool_index(tool_roots: list[Path]) -> dict[str, Path]:
|
|
29
|
+
index: dict[str, Path] = {}
|
|
30
|
+
for root in tool_roots:
|
|
31
|
+
if not root.exists():
|
|
32
|
+
continue
|
|
33
|
+
for tool_json_path in root.rglob("tool.json"):
|
|
34
|
+
tool_obj = ToolJson.model_validate_json(tool_json_path.read_text(encoding="utf-8"))
|
|
35
|
+
if tool_obj.id in index:
|
|
36
|
+
continue
|
|
37
|
+
index[tool_obj.id] = tool_json_path
|
|
38
|
+
return index
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def load_tools(
|
|
42
|
+
tool_roots: list[Path],
|
|
43
|
+
tool_ids: list[str],
|
|
44
|
+
permissions: DirectoryPermissions,
|
|
45
|
+
) -> FunctionToolset[Any]:
|
|
46
|
+
"""
|
|
47
|
+
Minimal approach: load local python functions and register them into a FunctionToolset.
|
|
48
|
+
"""
|
|
49
|
+
toolset = FunctionToolset()
|
|
50
|
+
|
|
51
|
+
tool_index = _discover_tool_index(tool_roots)
|
|
52
|
+
fs_adapter = FilesystemToolAdapter(permissions)
|
|
53
|
+
|
|
54
|
+
for tool_id in tool_ids:
|
|
55
|
+
tool_json_path = tool_index.get(tool_id)
|
|
56
|
+
if tool_json_path is None:
|
|
57
|
+
raise FileNotFoundError(f"Missing tool.json for tool {tool_id} in roots: {tool_roots}")
|
|
58
|
+
|
|
59
|
+
tool_obj = ToolJson.model_validate_json(tool_json_path.read_text(encoding="utf-8"))
|
|
60
|
+
if tool_obj.impl.kind != "python":
|
|
61
|
+
raise NotImplementedError("Skeleton supports python tools only. Add MCP support next.")
|
|
62
|
+
|
|
63
|
+
func: Callable[..., Any]
|
|
64
|
+
if tool_id == "filesystem.read_text":
|
|
65
|
+
func = fs_adapter.read_text
|
|
66
|
+
elif tool_id == "filesystem.write_text":
|
|
67
|
+
func = fs_adapter.write_text
|
|
68
|
+
else:
|
|
69
|
+
func = import_symbol(f"{tool_obj.impl.module}:{tool_obj.impl.function}")
|
|
70
|
+
|
|
71
|
+
# Register function as a tool.
|
|
72
|
+
# The FunctionToolset will derive schema from type hints / docstring.
|
|
73
|
+
# You can enforce consistency with tool.json by adding checks here.
|
|
74
|
+
toolset.add_function(func=func, name=tool_obj.name, description=tool_obj.description)
|
|
75
|
+
|
|
76
|
+
return toolset
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
---
|
|
2
|
+
# Agent identity
|
|
3
|
+
name: "function_spec_validator"
|
|
4
|
+
description: "Validates function specifications meet required standards before creation."
|
|
5
|
+
|
|
6
|
+
# Model configuration: OpenAI-compatible endpoint (e.g., SGLang server)
|
|
7
|
+
model:
|
|
8
|
+
provider: "openai-compatible"
|
|
9
|
+
base_url: "http://localhost:11434/v1"
|
|
10
|
+
api_key_env: "OLLAMA_API_KEY"
|
|
11
|
+
model_name: "gpt-oss:20b"
|
|
12
|
+
temperature: 0.2
|
|
13
|
+
max_tokens: 2048
|
|
14
|
+
|
|
15
|
+
# Tools available to this agent (tool IDs resolved by the orchestrator)
|
|
16
|
+
tools: []
|
|
17
|
+
|
|
18
|
+
# Schema registry: symbolic names -> import paths
|
|
19
|
+
schemas:
|
|
20
|
+
Plan: "simple_agent.schemas.outputs:Plan"
|
|
21
|
+
ValidationResult: "simple_agent.schemas.outputs:ValidationResult"
|
|
22
|
+
|
|
23
|
+
# Prompt-chaining steps (ordered). Each step references a markdown section below.
|
|
24
|
+
chain:
|
|
25
|
+
- id: "execute"
|
|
26
|
+
kind: "text"
|
|
27
|
+
prompt_section: "step:execute"
|
|
28
|
+
|
|
29
|
+
- id: "finalize"
|
|
30
|
+
kind: "structured"
|
|
31
|
+
output_schema: "ValidationResult"
|
|
32
|
+
prompt_section: "step:finalize"
|
|
33
|
+
|
|
34
|
+
# Output settings
|
|
35
|
+
output:
|
|
36
|
+
format: "json"
|
|
37
|
+
file: "out/function_spec_validation.json"
|
|
38
|
+
|
|
39
|
+
# Optional: handoff to another agent after producing final output
|
|
40
|
+
handoff:
|
|
41
|
+
enabled: false
|
|
42
|
+
agent_id: null
|
|
43
|
+
input_mode: "final_output_json"
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
# function_spec_validator
|
|
47
|
+
|
|
48
|
+
You are a validation agent that checks a provided function specification.
|
|
49
|
+
Return a JSON valid result that includes a boolean `valid` field.
|
|
50
|
+
Follow the chain steps in order.
|
|
51
|
+
Do not create comments outside of the structured output.
|
|
52
|
+
|
|
53
|
+
## Required Fields
|
|
54
|
+
|
|
55
|
+
1. Function name - must be provided, snake_case format
|
|
56
|
+
2. Parameters with types - each parameter must have a type annotation
|
|
57
|
+
3. Return type - must be specified (use `None` if no return)
|
|
58
|
+
4. Behavior description - must describe what the function does
|
|
59
|
+
5. Target file path - must be a valid Python file path (.py extension)
|
|
60
|
+
|
|
61
|
+
## Validation Rules
|
|
62
|
+
|
|
63
|
+
### Function Name
|
|
64
|
+
- Must be non-empty
|
|
65
|
+
- Must use snake_case (lowercase with underscores)
|
|
66
|
+
- Must not start with a number
|
|
67
|
+
- Must not be a Python reserved keyword
|
|
68
|
+
|
|
69
|
+
### Parameters with Types
|
|
70
|
+
- Each parameter must follow format: `name: Type`
|
|
71
|
+
- Type must be a valid Python type or imported type
|
|
72
|
+
- Optional parameters must specify default values
|
|
73
|
+
- If there are no parameters, the following is accepted: None, an empty list, or no content
|
|
74
|
+
|
|
75
|
+
### Return Type
|
|
76
|
+
- Must be explicitly stated
|
|
77
|
+
- Use `None` for functions with no return value
|
|
78
|
+
- Complex types must be properly annotated (e.g., `List[str]`, `Dict[str, int]`)
|
|
79
|
+
|
|
80
|
+
### Behavior Description
|
|
81
|
+
- Minimum 10 characters
|
|
82
|
+
- Must describe the function's purpose
|
|
83
|
+
- Should mention expected inputs and outputs
|
|
84
|
+
|
|
85
|
+
### Target File Path
|
|
86
|
+
- Must end with `.py`
|
|
87
|
+
- Must be an relative path
|
|
88
|
+
- Project Root level files or within subdirectories are acceptable
|
|
89
|
+
- Project Root level files will not have a leading slash
|
|
90
|
+
|
|
91
|
+
## step:execute
|
|
92
|
+
|
|
93
|
+
Goal:
|
|
94
|
+
- Perform the validation checks on all required fields.
|
|
95
|
+
- Summarize findings in plain text and list any missing or invalid fields.
|
|
96
|
+
|
|
97
|
+
Constraints:
|
|
98
|
+
- Do not output JSON in this step.
|
|
99
|
+
|
|
100
|
+
## step:finalize
|
|
101
|
+
|
|
102
|
+
Goal:
|
|
103
|
+
- Return a `ValidationResult` JSON object.
|
|
104
|
+
- The object must include `valid` and should include any issues.
|
|
105
|
+
- If invalid, include the list of issues with `field` and `reason`.
|
|
106
|
+
- No comments on the json code is needed
|
|
107
|
+
- Remove any comments on the json output
|
|
108
|
+
|
|
109
|
+
Return only the structured object required by the schema (no additional commentary).
|