makefile-agent 0.3.0__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.
- make_agent/__init__.py +0 -0
- make_agent/agent.py +190 -0
- make_agent/agent_shell.py +93 -0
- make_agent/app_dirs.py +51 -0
- make_agent/builtin_tools.py +380 -0
- make_agent/create_agent.py +228 -0
- make_agent/main.py +210 -0
- make_agent/memory.py +170 -0
- make_agent/parser.py +351 -0
- make_agent/settings.py +92 -0
- make_agent/templates/orchestra.mk +99 -0
- make_agent/tools.py +193 -0
- makefile_agent-0.3.0.dist-info/METADATA +265 -0
- makefile_agent-0.3.0.dist-info/RECORD +18 -0
- makefile_agent-0.3.0.dist-info/WHEEL +5 -0
- makefile_agent-0.3.0.dist-info/entry_points.txt +3 -0
- makefile_agent-0.3.0.dist-info/licenses/LICENSE +21 -0
- makefile_agent-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
"""Built-in agent management tools always available to every agent.
|
|
2
|
+
|
|
3
|
+
These three tools are injected into every agent's tool schema alongside any
|
|
4
|
+
Makefile-defined tools, without requiring a Makefile declaration.
|
|
5
|
+
|
|
6
|
+
- ``list_agent`` — discover available specialist agents
|
|
7
|
+
- ``validate_agent`` — validate a specialist agent's Makefile
|
|
8
|
+
- ``run_agent`` — delegate a task to a specialist agent via subprocess
|
|
9
|
+
- ``search_user_memory`` — FTS5 search over past user messages (when memory enabled)
|
|
10
|
+
- ``search_agent_memory`` — FTS5 search over past agent replies (when memory enabled)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
import yaml
|
|
22
|
+
|
|
23
|
+
from make_agent.create_agent import render, _write_output_no_symlink
|
|
24
|
+
from make_agent.parser import parse_file, validate
|
|
25
|
+
|
|
26
|
+
_VALID_AGENT_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
|
27
|
+
|
|
28
|
+
BUILTIN_TOOL_NAMES: frozenset[str] = frozenset({
|
|
29
|
+
"list_agent", "validate_agent", "create_agent", "run_agent",
|
|
30
|
+
"search_user_memory", "search_agent_memory", "get_recent_messages",
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
_MEMORY_SEARCH_PARAMS = {
|
|
34
|
+
"type": "object",
|
|
35
|
+
"properties": {
|
|
36
|
+
"query": {
|
|
37
|
+
"type": "string",
|
|
38
|
+
"description": (
|
|
39
|
+
"FTS5 keyword query. Use individual keywords rather than full sentences — "
|
|
40
|
+
"FTS5 matches on exact tokens, not phrases or semantics. "
|
|
41
|
+
"For example, to find 'the goal of this project', use 'goal project' or 'goal'. "
|
|
42
|
+
"Combine keywords with OR for broader recall: 'goal OR objective OR purpose'. "
|
|
43
|
+
"Avoid stop words (the, of, is, a) as they are not indexed."
|
|
44
|
+
),
|
|
45
|
+
},
|
|
46
|
+
"limit": {
|
|
47
|
+
"type": "integer",
|
|
48
|
+
"description": "Maximum number of results to return (default: 10).",
|
|
49
|
+
},
|
|
50
|
+
"from_date": {
|
|
51
|
+
"type": "string",
|
|
52
|
+
"description": "ISO 8601 date string to filter results on or after (e.g. '2026-03-01').",
|
|
53
|
+
},
|
|
54
|
+
"to_date": {
|
|
55
|
+
"type": "string",
|
|
56
|
+
"description": "ISO 8601 date string to filter results on or before (e.g. '2026-03-31').",
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
"required": ["query"],
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _valid_agent_name(name: str) -> bool:
|
|
64
|
+
return bool(_VALID_AGENT_NAME_RE.fullmatch(name))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _agent_summary(mk_path: Path) -> str:
|
|
68
|
+
"""Return a multi-line summary of an agent: system prompt + tool list."""
|
|
69
|
+
try:
|
|
70
|
+
mf = parse_file(mk_path)
|
|
71
|
+
except Exception:
|
|
72
|
+
return " (could not parse)"
|
|
73
|
+
|
|
74
|
+
lines: list[str] = []
|
|
75
|
+
|
|
76
|
+
if mf.system_prompt:
|
|
77
|
+
# First non-empty line of the system prompt as the headline.
|
|
78
|
+
for line in mf.system_prompt.splitlines():
|
|
79
|
+
if line.strip():
|
|
80
|
+
lines.append(f" {line.strip()}")
|
|
81
|
+
break
|
|
82
|
+
else:
|
|
83
|
+
lines.append(" (no description)")
|
|
84
|
+
|
|
85
|
+
tools = [r for r in mf.rules if r.description is not None]
|
|
86
|
+
if tools:
|
|
87
|
+
lines.append(" tools:")
|
|
88
|
+
for rule in tools:
|
|
89
|
+
desc = rule.description.splitlines()[0].strip() if rule.description else ""
|
|
90
|
+
params = ", ".join(p.name for p in rule.params)
|
|
91
|
+
param_str = f"({params})" if params else "()"
|
|
92
|
+
lines.append(f" - {rule.target}{param_str}: {desc}")
|
|
93
|
+
|
|
94
|
+
return "\n".join(lines)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def list_agent(agents_dir: str) -> str:
|
|
98
|
+
"""List all available specialist agents with their system prompt and tools."""
|
|
99
|
+
path = Path(agents_dir)
|
|
100
|
+
if not path.exists():
|
|
101
|
+
return "No agents found (directory does not exist)"
|
|
102
|
+
mk_files = sorted(path.glob("*.mk"))
|
|
103
|
+
if not mk_files:
|
|
104
|
+
return "No agents found"
|
|
105
|
+
entries = [f"{mk.stem}:\n{_agent_summary(mk)}" for mk in mk_files]
|
|
106
|
+
return "\n\n".join(entries)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def validate_agent(name: str, agents_dir: str) -> str:
|
|
110
|
+
"""Validate a specialist agent's Makefile and report any errors."""
|
|
111
|
+
if not _valid_agent_name(name):
|
|
112
|
+
return f"Error: invalid agent name {name!r}. Use letters, numbers, hyphens, underscores, and dots only."
|
|
113
|
+
|
|
114
|
+
mk_path = Path(agents_dir) / f"{name}.mk"
|
|
115
|
+
if not mk_path.exists():
|
|
116
|
+
return f"Agent '{name}' not found in {agents_dir}"
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
mf = parse_file(mk_path)
|
|
120
|
+
except OSError as e:
|
|
121
|
+
return f"Error: could not read {mk_path}: {e}"
|
|
122
|
+
|
|
123
|
+
errors = validate(mf)
|
|
124
|
+
if errors:
|
|
125
|
+
return "Validation errors:\n" + "\n".join(f" - {e}" for e in errors)
|
|
126
|
+
|
|
127
|
+
tool_count = sum(1 for r in mf.rules if r.params or r.description)
|
|
128
|
+
return f"OK — {mk_path} ({tool_count} tool(s) valid)"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def create_agent(name: str, spec: str, agents_dir: str) -> str:
|
|
132
|
+
"""Create a new specialist agent Makefile from a YAML spec string."""
|
|
133
|
+
if not _valid_agent_name(name):
|
|
134
|
+
return f"Error: invalid agent name {name!r}. Use letters, numbers, hyphens, underscores, and dots only."
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
parsed_spec = yaml.safe_load(spec)
|
|
138
|
+
except yaml.YAMLError as e:
|
|
139
|
+
return f"Error: invalid YAML spec: {e}"
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
makefile_content = render(parsed_spec)
|
|
143
|
+
except KeyError as e:
|
|
144
|
+
return f"Error: missing required field in spec: {e}"
|
|
145
|
+
except TypeError as e:
|
|
146
|
+
return f"Error: invalid spec structure: {e}"
|
|
147
|
+
except ValueError as e:
|
|
148
|
+
return f"Error: {e}"
|
|
149
|
+
|
|
150
|
+
mk_path = Path(agents_dir) / f"{name}.mk"
|
|
151
|
+
try:
|
|
152
|
+
_write_output_no_symlink(mk_path, makefile_content)
|
|
153
|
+
except OSError as e:
|
|
154
|
+
return f"Error: could not write agent file: {e}"
|
|
155
|
+
except ValueError as e:
|
|
156
|
+
return f"Error: {e}"
|
|
157
|
+
|
|
158
|
+
tool_count = sum(1 for t in parsed_spec.get("tools", []))
|
|
159
|
+
return f"Created agent '{name}' at {mk_path} ({tool_count} tool(s))"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def run_agent(name: str, prompt: str, agents_dir: str, model: str, debug: bool = False, timeout: int = 600) -> str:
|
|
163
|
+
"""Run a specialist agent as a subprocess and return its output."""
|
|
164
|
+
if not _valid_agent_name(name):
|
|
165
|
+
return f"Error: invalid agent name {name!r}."
|
|
166
|
+
|
|
167
|
+
mk_path = Path(agents_dir) / f"{name}.mk"
|
|
168
|
+
if not mk_path.exists():
|
|
169
|
+
return f"Agent '{name}' not found in {agents_dir}"
|
|
170
|
+
|
|
171
|
+
cmd = [
|
|
172
|
+
sys.executable, "-m", "make_agent.main",
|
|
173
|
+
"-f", str(mk_path),
|
|
174
|
+
"--prompt", prompt,
|
|
175
|
+
"--model", model,
|
|
176
|
+
"--agents-dir", agents_dir,
|
|
177
|
+
]
|
|
178
|
+
if debug:
|
|
179
|
+
cmd.append("--debug")
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
|
183
|
+
except subprocess.TimeoutExpired:
|
|
184
|
+
return f"Error: agent '{name}' exceeded {timeout}s time limit"
|
|
185
|
+
except OSError as e:
|
|
186
|
+
return f"Error: failed to run agent: {e}"
|
|
187
|
+
|
|
188
|
+
if result.returncode != 0:
|
|
189
|
+
parts = [p for p in [result.stdout.strip(), result.stderr.strip()] if p]
|
|
190
|
+
body = "\n".join(parts)
|
|
191
|
+
return f"Error (exit {result.returncode}):\n{body}" if body else f"Error (exit {result.returncode})"
|
|
192
|
+
|
|
193
|
+
return result.stdout
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ── OpenAI tool schemas ────────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
BUILTIN_SCHEMAS: list[dict[str, Any]] = [
|
|
199
|
+
{
|
|
200
|
+
"type": "function",
|
|
201
|
+
"function": {
|
|
202
|
+
"name": "list_agent",
|
|
203
|
+
"description": (
|
|
204
|
+
"List all available specialist agents in the library. "
|
|
205
|
+
"Returns each agent name and a short description of its purpose."
|
|
206
|
+
),
|
|
207
|
+
"parameters": {
|
|
208
|
+
"type": "object",
|
|
209
|
+
"properties": {},
|
|
210
|
+
"required": [],
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
"type": "function",
|
|
216
|
+
"function": {
|
|
217
|
+
"name": "validate_agent",
|
|
218
|
+
"description": (
|
|
219
|
+
"Validate a specialist agent's Makefile. "
|
|
220
|
+
"Checks that every declared @param is referenced in its recipe. "
|
|
221
|
+
"Returns 'OK' with the tool count, or a list of validation errors."
|
|
222
|
+
),
|
|
223
|
+
"parameters": {
|
|
224
|
+
"type": "object",
|
|
225
|
+
"properties": {
|
|
226
|
+
"name": {
|
|
227
|
+
"type": "string",
|
|
228
|
+
"description": (
|
|
229
|
+
"The agent name (without .mk extension, e.g. 'file-search'). "
|
|
230
|
+
"Use letters, numbers, hyphens, underscores, and dots only."
|
|
231
|
+
),
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
"required": ["name"],
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
"type": "function",
|
|
240
|
+
"function": {
|
|
241
|
+
"name": "create_agent",
|
|
242
|
+
"description": (
|
|
243
|
+
"Create a new specialist agent by writing a Makefile to the agents library. "
|
|
244
|
+
"Accepts a YAML spec with a system_prompt and a list of tools (each with a "
|
|
245
|
+
"name, description, optional params, and recipe). "
|
|
246
|
+
"Returns 'Created agent ...' on success or an error message."
|
|
247
|
+
),
|
|
248
|
+
"parameters": {
|
|
249
|
+
"type": "object",
|
|
250
|
+
"properties": {
|
|
251
|
+
"name": {
|
|
252
|
+
"type": "string",
|
|
253
|
+
"description": (
|
|
254
|
+
"The agent name (without .mk extension, e.g. 'file-search'). "
|
|
255
|
+
"Use letters, numbers, hyphens, underscores, and dots only."
|
|
256
|
+
),
|
|
257
|
+
},
|
|
258
|
+
"spec": {
|
|
259
|
+
"type": "string",
|
|
260
|
+
"description": (
|
|
261
|
+
"YAML string defining the agent. Required fields: "
|
|
262
|
+
"'system_prompt' (string) and 'tools' (list). "
|
|
263
|
+
"Each tool needs 'name', 'description', 'recipe' (list of shell commands), "
|
|
264
|
+
"and optional 'params' (list of {name, type, description})."
|
|
265
|
+
),
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
"required": ["name", "spec"],
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
"type": "function",
|
|
274
|
+
"function": {
|
|
275
|
+
"name": "run_agent",
|
|
276
|
+
"description": (
|
|
277
|
+
"Run a specialist agent with a single task prompt and return its output. "
|
|
278
|
+
"The agent will use its own tools to complete the task."
|
|
279
|
+
),
|
|
280
|
+
"parameters": {
|
|
281
|
+
"type": "object",
|
|
282
|
+
"properties": {
|
|
283
|
+
"name": {
|
|
284
|
+
"type": "string",
|
|
285
|
+
"description": "The agent name (without .mk extension).",
|
|
286
|
+
},
|
|
287
|
+
"prompt": {
|
|
288
|
+
"type": "string",
|
|
289
|
+
"description": "The task or question to send to the agent.",
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
"required": ["name", "prompt"],
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
]
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def get_builtin_tools(agents_dir: str, model: str, debug: bool = False, memory: Any = None, disabled: frozenset[str] = frozenset(), tool_timeout: int = 600) -> dict[str, Any]:
|
|
300
|
+
"""Return a name → callable mapping for all built-in tools.
|
|
301
|
+
|
|
302
|
+
Each callable accepts only the LLM-provided arguments; ``agents_dir``,
|
|
303
|
+
``model``, and ``memory`` are pre-bound via closure. Tools whose names
|
|
304
|
+
appear in *disabled* are omitted.
|
|
305
|
+
"""
|
|
306
|
+
tools: dict[str, Any] = {
|
|
307
|
+
"list_agent": lambda **_kw: list_agent(agents_dir),
|
|
308
|
+
"validate_agent": lambda name, **_kw: validate_agent(name, agents_dir),
|
|
309
|
+
"create_agent": lambda name, spec, **_kw: create_agent(name, spec, agents_dir),
|
|
310
|
+
"run_agent": lambda name, prompt, **_kw: run_agent(name, prompt, agents_dir, model, debug, tool_timeout),
|
|
311
|
+
}
|
|
312
|
+
if memory is not None:
|
|
313
|
+
tools["search_user_memory"] = lambda query, limit=10, from_date=None, to_date=None, **_kw: memory.search_user(query, limit, from_date, to_date)
|
|
314
|
+
tools["search_agent_memory"] = lambda query, limit=10, from_date=None, to_date=None, **_kw: memory.search_agent(query, limit, from_date, to_date)
|
|
315
|
+
tools["get_recent_messages"] = lambda limit=10, from_date=None, to_date=None, **_kw: memory.recent(limit, from_date, to_date)
|
|
316
|
+
return {name: fn for name, fn in tools.items() if name not in disabled}
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def get_memory_schemas() -> list[dict[str, Any]]:
|
|
320
|
+
"""Return the tool schemas for memory search tools.
|
|
321
|
+
|
|
322
|
+
These are only injected when memory is enabled.
|
|
323
|
+
"""
|
|
324
|
+
return [
|
|
325
|
+
{
|
|
326
|
+
"type": "function",
|
|
327
|
+
"function": {
|
|
328
|
+
"name": "search_user_memory",
|
|
329
|
+
"description": (
|
|
330
|
+
"Search past user messages using keyword-based full-text search (FTS5). "
|
|
331
|
+
"Use this proactively to recall context from earlier in the conversation or past sessions. "
|
|
332
|
+
"Query with short keywords — FTS5 does not match full sentences. "
|
|
333
|
+
"If the first query returns no results, retry with broader or alternative keywords."
|
|
334
|
+
),
|
|
335
|
+
"parameters": _MEMORY_SEARCH_PARAMS,
|
|
336
|
+
},
|
|
337
|
+
},
|
|
338
|
+
{
|
|
339
|
+
"type": "function",
|
|
340
|
+
"function": {
|
|
341
|
+
"name": "search_agent_memory",
|
|
342
|
+
"description": (
|
|
343
|
+
"Search past agent replies using keyword-based full-text search (FTS5). "
|
|
344
|
+
"Use this to recall what you previously told the user or decisions you made. "
|
|
345
|
+
"Query with short keywords — FTS5 does not match full sentences. "
|
|
346
|
+
"If the first query returns no results, retry with broader or alternative keywords."
|
|
347
|
+
),
|
|
348
|
+
"parameters": _MEMORY_SEARCH_PARAMS,
|
|
349
|
+
},
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
"type": "function",
|
|
353
|
+
"function": {
|
|
354
|
+
"name": "get_recent_messages",
|
|
355
|
+
"description": (
|
|
356
|
+
"Fetch the N most recent messages from memory, in chronological order. "
|
|
357
|
+
"Each entry shows the timestamp, sender (user or agent), and message text. "
|
|
358
|
+
"Use this to quickly recall recent conversation context without needing keywords."
|
|
359
|
+
),
|
|
360
|
+
"parameters": {
|
|
361
|
+
"type": "object",
|
|
362
|
+
"properties": {
|
|
363
|
+
"limit": {
|
|
364
|
+
"type": "integer",
|
|
365
|
+
"description": "Number of recent messages to return (default: 10).",
|
|
366
|
+
},
|
|
367
|
+
"from_date": {
|
|
368
|
+
"type": "string",
|
|
369
|
+
"description": "ISO 8601 date string to filter results on or after (e.g. '2026-03-01').",
|
|
370
|
+
},
|
|
371
|
+
"to_date": {
|
|
372
|
+
"type": "string",
|
|
373
|
+
"description": "ISO 8601 date string to filter results on or before (e.g. '2026-03-31').",
|
|
374
|
+
},
|
|
375
|
+
},
|
|
376
|
+
"required": [],
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
]
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Generate a make-agent Makefile from a structured YAML agent spec.
|
|
2
|
+
|
|
3
|
+
Usage (stdin)::
|
|
4
|
+
|
|
5
|
+
echo '<YAML>' | make-agent-create [-o OUTPUT]
|
|
6
|
+
|
|
7
|
+
Usage (argument)::
|
|
8
|
+
|
|
9
|
+
make-agent-create --spec '<YAML>' [-o OUTPUT]
|
|
10
|
+
make-agent-create --file spec.yaml [-o OUTPUT]
|
|
11
|
+
|
|
12
|
+
YAML spec schema::
|
|
13
|
+
|
|
14
|
+
system_prompt: |
|
|
15
|
+
You are a specialist that ...
|
|
16
|
+
tools:
|
|
17
|
+
- name: tool-name
|
|
18
|
+
description: What this tool does.
|
|
19
|
+
params:
|
|
20
|
+
- name: PARAM
|
|
21
|
+
type: string
|
|
22
|
+
description: The param purpose
|
|
23
|
+
recipe:
|
|
24
|
+
- "@shell command $(value PARAM)"
|
|
25
|
+
|
|
26
|
+
``params`` may be omitted for tools that take no arguments.
|
|
27
|
+
``type`` must be one of: ``string``, ``number``, ``integer``, or ``boolean``.
|
|
28
|
+
|
|
29
|
+
The ``system_prompt`` is written as a ``define SYSTEM_PROMPT``/``endef`` block
|
|
30
|
+
so it can contain any text including ``$`` signs without escaping.
|
|
31
|
+
|
|
32
|
+
``recipe`` can be a list of shell command strings or a single multi-line
|
|
33
|
+
string. Each recipe line is tab-indented in the generated Makefile.
|
|
34
|
+
|
|
35
|
+
In recipes, reference parameters as ``$(PARAM)`` (Make-expanded) or
|
|
36
|
+
``$(value PARAM)`` (raw literal, preserves ``$`` and special characters).
|
|
37
|
+
Each ``recipe`` entry becomes one tab-indented line in the Makefile target.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import argparse
|
|
43
|
+
import logging
|
|
44
|
+
import re
|
|
45
|
+
import string
|
|
46
|
+
import sys
|
|
47
|
+
from pathlib import Path
|
|
48
|
+
|
|
49
|
+
import yaml
|
|
50
|
+
|
|
51
|
+
from make_agent.app_dirs import log_file
|
|
52
|
+
|
|
53
|
+
logger = logging.getLogger(__name__)
|
|
54
|
+
|
|
55
|
+
_PARAM_LINE = string.Template("# @param ${name} ${type} ${description}\n")
|
|
56
|
+
|
|
57
|
+
_TOOL_BLOCK = string.Template("# <tool>\n${description}${params}# </tool>\n${name}:\n${recipe}\n")
|
|
58
|
+
|
|
59
|
+
_MAKEFILE = string.Template("define SYSTEM_PROMPT\n${system_prompt}\nendef\n\n.PHONY: ${phony}\n\n${tools}")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _comment_lines(text: str) -> str:
|
|
63
|
+
"""Prefix every line of *text* with ``# ``, blank lines become bare ``#``."""
|
|
64
|
+
lines = []
|
|
65
|
+
for line in text.splitlines():
|
|
66
|
+
lines.append(f"# {line}" if line.strip() else "#")
|
|
67
|
+
return "\n".join(lines) + "\n"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _render_tool(tool: dict) -> str:
|
|
71
|
+
description = _comment_lines(tool["description"])
|
|
72
|
+
params = "".join(
|
|
73
|
+
_PARAM_LINE.substitute(
|
|
74
|
+
name=p["name"],
|
|
75
|
+
type=p["type"],
|
|
76
|
+
description=p["description"],
|
|
77
|
+
)
|
|
78
|
+
for p in tool.get("params", [])
|
|
79
|
+
)
|
|
80
|
+
raw_recipe = tool["recipe"]
|
|
81
|
+
if isinstance(raw_recipe, str):
|
|
82
|
+
lines = raw_recipe.splitlines()
|
|
83
|
+
else:
|
|
84
|
+
lines = list(raw_recipe)
|
|
85
|
+
recipe = "".join(f"\t{line}\n" for line in lines)
|
|
86
|
+
return _TOOL_BLOCK.substitute(
|
|
87
|
+
name=tool["name"],
|
|
88
|
+
description=description,
|
|
89
|
+
params=params,
|
|
90
|
+
recipe=recipe,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _validate_spec_params(spec: dict) -> None:
|
|
95
|
+
"""Raise ``ValueError`` if any tool spec declares a param not used in its recipe.
|
|
96
|
+
|
|
97
|
+
Accepts ``$(NAME)``, ``${NAME}``, ``$$NAME``, the ``$(NAME_FILE)`` form,
|
|
98
|
+
and the raw-literal ``$(value NAME)`` / ``$(value NAME_FILE)`` form.
|
|
99
|
+
"""
|
|
100
|
+
errors: list[str] = []
|
|
101
|
+
for tool in spec.get("tools", []):
|
|
102
|
+
name = tool.get("name", "<unnamed>")
|
|
103
|
+
raw_recipe = tool.get("recipe", [])
|
|
104
|
+
if isinstance(raw_recipe, str):
|
|
105
|
+
recipe_text = raw_recipe
|
|
106
|
+
else:
|
|
107
|
+
recipe_text = "\n".join(raw_recipe)
|
|
108
|
+
used = set(re.findall(r"\$\((?:value\s+)?([^)]+)\)|\$\{(?:value\s+)?([^}]+)\}|\$\$(\w+)", recipe_text))
|
|
109
|
+
used_flat = {g for pair in used for g in pair if g}
|
|
110
|
+
for param in tool.get("params", []):
|
|
111
|
+
pname = param["name"]
|
|
112
|
+
file_var = f"{pname}_FILE"
|
|
113
|
+
if pname not in used_flat and file_var not in used_flat:
|
|
114
|
+
errors.append(
|
|
115
|
+
f"Tool '{name}': @param {pname} declared but never referenced in recipe.\n"
|
|
116
|
+
f" Expected $({pname}), ${{{pname}}}, $${pname}, or $({file_var}) in the recipe body."
|
|
117
|
+
)
|
|
118
|
+
if errors:
|
|
119
|
+
raise ValueError("\n".join(errors))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _init_logging(level: int = logging.DEBUG) -> None:
|
|
123
|
+
handler = logging.FileHandler(log_file())
|
|
124
|
+
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
|
|
125
|
+
logger.addHandler(handler)
|
|
126
|
+
logger.setLevel(level)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _write_output_no_symlink(output_path: Path, content: str) -> None:
|
|
130
|
+
"""Write *content* to *output_path* while refusing symlink destinations."""
|
|
131
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
132
|
+
if output_path.is_symlink():
|
|
133
|
+
raise ValueError(f"refusing to overwrite symlink: {output_path}")
|
|
134
|
+
output_path.write_text(content, encoding="utf-8")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def render(spec: dict) -> str:
|
|
138
|
+
"""Return a Makefile string rendered from an agent *spec* dict.
|
|
139
|
+
|
|
140
|
+
Raises ``KeyError`` if required fields are missing, ``TypeError`` if
|
|
141
|
+
``tools`` is not a list, ``ValueError`` if any tool declares a param that
|
|
142
|
+
is not referenced in its recipe.
|
|
143
|
+
"""
|
|
144
|
+
_validate_spec_params(spec)
|
|
145
|
+
system_prompt: str = spec["system_prompt"]
|
|
146
|
+
tools_list: list[dict] = spec["tools"]
|
|
147
|
+
phony = " ".join(t["name"] for t in tools_list)
|
|
148
|
+
tools = "\n".join(_render_tool(t) for t in tools_list)
|
|
149
|
+
return _MAKEFILE.substitute(
|
|
150
|
+
system_prompt=system_prompt,
|
|
151
|
+
phony=phony,
|
|
152
|
+
tools=tools,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def main() -> None:
|
|
157
|
+
parser = argparse.ArgumentParser(
|
|
158
|
+
prog="make-agent-create",
|
|
159
|
+
description="Generate a make-agent Makefile from a YAML agent spec.",
|
|
160
|
+
)
|
|
161
|
+
src = parser.add_mutually_exclusive_group()
|
|
162
|
+
src.add_argument(
|
|
163
|
+
"--spec",
|
|
164
|
+
metavar="YAML",
|
|
165
|
+
help="Agent spec as a YAML string",
|
|
166
|
+
)
|
|
167
|
+
src.add_argument(
|
|
168
|
+
"--file",
|
|
169
|
+
metavar="FILE",
|
|
170
|
+
help="Path to a YAML file containing the agent spec",
|
|
171
|
+
)
|
|
172
|
+
parser.add_argument(
|
|
173
|
+
"-o",
|
|
174
|
+
"--output",
|
|
175
|
+
metavar="FILE",
|
|
176
|
+
help="Write output to FILE instead of stdout",
|
|
177
|
+
)
|
|
178
|
+
parser.add_argument(
|
|
179
|
+
"--log-level",
|
|
180
|
+
default="INFO",
|
|
181
|
+
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
|
|
182
|
+
help="Set the logging level (default: INFO)",
|
|
183
|
+
)
|
|
184
|
+
args = parser.parse_args()
|
|
185
|
+
|
|
186
|
+
_init_logging(getattr(logging, args.log_level))
|
|
187
|
+
|
|
188
|
+
logger.info("Starting make-agent-create...")
|
|
189
|
+
|
|
190
|
+
if args.spec:
|
|
191
|
+
raw = args.spec
|
|
192
|
+
elif args.file:
|
|
193
|
+
raw = Path(args.file).read_text(encoding="utf-8")
|
|
194
|
+
else:
|
|
195
|
+
raw = sys.stdin.read()
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
spec = yaml.safe_load(raw)
|
|
199
|
+
except yaml.YAMLError as e:
|
|
200
|
+
logger.error("Failed to parse YAML: %s", e)
|
|
201
|
+
sys.exit(f"make-agent-create: invalid YAML: {e}")
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
makefile = render(spec)
|
|
205
|
+
except (KeyError, TypeError) as e:
|
|
206
|
+
logger.error("Invalid spec: %s", e)
|
|
207
|
+
sys.exit(f"make-agent-create: invalid spec: {e}")
|
|
208
|
+
except ValueError as e:
|
|
209
|
+
logger.error("Spec validation error: %s", e)
|
|
210
|
+
sys.exit(f"make-agent-create: {e}")
|
|
211
|
+
|
|
212
|
+
if args.output:
|
|
213
|
+
try:
|
|
214
|
+
_write_output_no_symlink(Path(args.output), makefile)
|
|
215
|
+
except OSError as e:
|
|
216
|
+
logger.error("Failed to write output file: %s", e)
|
|
217
|
+
sys.exit(f"make-agent-create: failed to write output file: {e}")
|
|
218
|
+
except ValueError as e:
|
|
219
|
+
logger.error("Unsafe output path: %s", e)
|
|
220
|
+
sys.exit(f"make-agent-create: {e}")
|
|
221
|
+
else:
|
|
222
|
+
sys.stdout.write(makefile)
|
|
223
|
+
|
|
224
|
+
logger.info("make-agent-create completed successfully.")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
if __name__ == "__main__":
|
|
228
|
+
main()
|