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
make_agent/parser.py
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
"""Makefile parser.
|
|
2
|
+
|
|
3
|
+
Parses a subset of GNU Make syntax into structured data:
|
|
4
|
+
- Variables (=, :=, ?=, +=, and ``define``/``endef`` blocks)
|
|
5
|
+
- Rules (target: prerequisites + tab-indented recipes)
|
|
6
|
+
- .PHONY declarations
|
|
7
|
+
- Special comment blocks:
|
|
8
|
+
# <system> … # </system> — system prompt for the agent (legacy)
|
|
9
|
+
# <tool> … # </tool> — tool description + @param declarations
|
|
10
|
+
- ``define SYSTEM_PROMPT … endef`` — system prompt (preferred; takes
|
|
11
|
+
precedence over ``# <system>`` blocks)
|
|
12
|
+
|
|
13
|
+
Inside a ``# <tool>`` block:
|
|
14
|
+
- Lines starting with ``# @param NAME type description`` declare tool
|
|
15
|
+
parameters.
|
|
16
|
+
- All other lines form the human-readable tool description sent to the LLM.
|
|
17
|
+
|
|
18
|
+
Param types
|
|
19
|
+
-----------
|
|
20
|
+
``string``, ``number``, ``integer``, ``boolean``
|
|
21
|
+
Standard JSON Schema primitives. Values are injected via a temporary
|
|
22
|
+
``params.mk`` file using ``define``/``endef`` blocks, so the LLM can pass
|
|
23
|
+
any text — including ``$``, quotes, and newlines — without escaping.
|
|
24
|
+
|
|
25
|
+
Recipes reference parameters as ``$(PARAM)`` (Make-expanded) or
|
|
26
|
+
``$(value PARAM)`` (raw literal, preserves ``$`` signs and special
|
|
27
|
+
characters).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import re
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from enum import Enum, auto
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
_FLAVOR_MAP = {
|
|
38
|
+
"=": "recursive",
|
|
39
|
+
":=": "simple",
|
|
40
|
+
"::=": "simple",
|
|
41
|
+
"?=": "conditional",
|
|
42
|
+
"+=": "append",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
# Matches: NAME = value / NAME := value / NAME ?= value / NAME += value
|
|
46
|
+
_VARIABLE_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\s*(::?=|\?=|\+=|=)\s*(.*)")
|
|
47
|
+
|
|
48
|
+
# Matches: target(s): prerequisites
|
|
49
|
+
# [^\t#] — first char is not a tab or comment marker
|
|
50
|
+
# [^:=]*? — non-greedy, no colons or equals in the target portion
|
|
51
|
+
# :(?![:=]) — a single colon NOT followed by : or =
|
|
52
|
+
_TARGET_RE = re.compile(r"^([^\t#][^:=]*?)\s*:(?![:=])(.*)")
|
|
53
|
+
|
|
54
|
+
# Matches: .PHONY: target …
|
|
55
|
+
_PHONY_RE = re.compile(r"^\.PHONY\s*:(.*)")
|
|
56
|
+
|
|
57
|
+
# Matches: $(VAR), ${VAR}, or $X variable references
|
|
58
|
+
_VAR_REF_RE = re.compile(r"\$(?:\(([^)]+)\)|\{([^}]+)\}|([A-Za-z_]))")
|
|
59
|
+
|
|
60
|
+
# Matches: @param NAME type description (inside a <tool> block)
|
|
61
|
+
_PARAM_RE = re.compile(r"^@param\s+(\w+)\s+(\w+)\s+(.+)")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class Variable:
|
|
66
|
+
name: str
|
|
67
|
+
value: str
|
|
68
|
+
flavor: str
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class Param:
|
|
73
|
+
name: str
|
|
74
|
+
type: str # JSON Schema primitive: string, number, integer, boolean
|
|
75
|
+
description: str
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class Rule:
|
|
80
|
+
target: str
|
|
81
|
+
prerequisites: list[str] = field(default_factory=list)
|
|
82
|
+
recipes: list[str] = field(default_factory=list)
|
|
83
|
+
is_phony: bool = False
|
|
84
|
+
description: str | None = None
|
|
85
|
+
params: list[Param] = field(default_factory=list)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class Makefile:
|
|
90
|
+
system_prompt: str | None = None
|
|
91
|
+
variables: dict[str, Variable] = field(default_factory=dict)
|
|
92
|
+
rules: list[Rule] = field(default_factory=list)
|
|
93
|
+
default_target: str | None = None # first non-special target
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _expand_vars(s: str, variables: dict[str, Variable]) -> str:
|
|
97
|
+
"""Expand $(VAR), ${VAR}, and $X references using already-seen variables."""
|
|
98
|
+
|
|
99
|
+
def replace(m: re.Match) -> str: # type: ignore[type-arg]
|
|
100
|
+
name = m.group(1) or m.group(2) or m.group(3)
|
|
101
|
+
return variables[name].value if name in variables else m.group(0)
|
|
102
|
+
|
|
103
|
+
return _VAR_REF_RE.sub(replace, s)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _strip_comment(s: str) -> str:
|
|
107
|
+
"""Strip an inline # comment (not preceded by a backslash)."""
|
|
108
|
+
i = 0
|
|
109
|
+
while i < len(s):
|
|
110
|
+
if s[i] == "#" and (i == 0 or s[i - 1] != "\\"):
|
|
111
|
+
return s[:i].rstrip()
|
|
112
|
+
i += 1
|
|
113
|
+
return s.rstrip()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
_DEFINE_RE = re.compile(r"^define\s+([A-Za-z_][A-Za-z0-9_]*)\s*$")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class _State(Enum):
|
|
120
|
+
NORMAL = auto()
|
|
121
|
+
RECIPE = auto()
|
|
122
|
+
SYSTEM_BLOCK = auto()
|
|
123
|
+
TOOL_BLOCK = auto()
|
|
124
|
+
DEFINE = auto()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def parse(text: str) -> Makefile:
|
|
128
|
+
"""Parse Makefile text and return a structured :class:`Makefile` object."""
|
|
129
|
+
result = Makefile()
|
|
130
|
+
state = _State.NORMAL
|
|
131
|
+
current_recipes: list[str] | None = None
|
|
132
|
+
pending_description: str | None = None
|
|
133
|
+
pending_params: list[Param] = []
|
|
134
|
+
system_lines: list[str] = []
|
|
135
|
+
tool_lines: list[str] = []
|
|
136
|
+
tool_params: list[Param] = []
|
|
137
|
+
phony_targets: set[str] = set()
|
|
138
|
+
define_name: str = ""
|
|
139
|
+
define_lines: list[str] = []
|
|
140
|
+
|
|
141
|
+
# Join line continuations (backslash-newline → space), but only outside
|
|
142
|
+
# define blocks (define block content is literal).
|
|
143
|
+
raw_lines = text.splitlines()
|
|
144
|
+
logical_lines: list[str] = []
|
|
145
|
+
buf = ""
|
|
146
|
+
in_define = False
|
|
147
|
+
for raw in raw_lines:
|
|
148
|
+
stripped_raw = raw.strip()
|
|
149
|
+
if not in_define and _DEFINE_RE.match(stripped_raw):
|
|
150
|
+
if buf:
|
|
151
|
+
logical_lines.append(buf)
|
|
152
|
+
buf = ""
|
|
153
|
+
logical_lines.append(raw)
|
|
154
|
+
in_define = True
|
|
155
|
+
elif in_define:
|
|
156
|
+
logical_lines.append(raw)
|
|
157
|
+
if stripped_raw == "endef":
|
|
158
|
+
in_define = False
|
|
159
|
+
elif raw.endswith("\\"):
|
|
160
|
+
buf += raw[:-1] + " "
|
|
161
|
+
else:
|
|
162
|
+
logical_lines.append(buf + raw)
|
|
163
|
+
buf = ""
|
|
164
|
+
if buf:
|
|
165
|
+
logical_lines.append(buf)
|
|
166
|
+
|
|
167
|
+
for line in logical_lines:
|
|
168
|
+
# ── Inside a define block ────────────────────────────────────────────
|
|
169
|
+
if state == _State.DEFINE:
|
|
170
|
+
if line.strip() == "endef":
|
|
171
|
+
value = "\n".join(define_lines)
|
|
172
|
+
result.variables[define_name] = Variable(name=define_name, value=value, flavor="define")
|
|
173
|
+
if define_name == "SYSTEM_PROMPT":
|
|
174
|
+
result.system_prompt = value.strip() or None
|
|
175
|
+
state = _State.NORMAL
|
|
176
|
+
else:
|
|
177
|
+
define_lines.append(line)
|
|
178
|
+
continue
|
|
179
|
+
|
|
180
|
+
# Recipe lines must start with a tab
|
|
181
|
+
if line.startswith("\t"):
|
|
182
|
+
if state == _State.RECIPE and current_recipes is not None:
|
|
183
|
+
current_recipes.append(line[1:]) # strip the leading tab
|
|
184
|
+
continue
|
|
185
|
+
|
|
186
|
+
# A non-tab line ends recipe collection.
|
|
187
|
+
# Exception: plain comment lines (not special block tags) are ignored by
|
|
188
|
+
# GNU Make inside a rule body and should not end recipe collection.
|
|
189
|
+
if state == _State.RECIPE:
|
|
190
|
+
stripped_peek = line.strip()
|
|
191
|
+
if stripped_peek.startswith("#") and stripped_peek not in ("# <tool>", "# <system>", "# </tool>", "# </system>"):
|
|
192
|
+
continue
|
|
193
|
+
state = _State.NORMAL
|
|
194
|
+
current_recipes = None
|
|
195
|
+
|
|
196
|
+
stripped = line.strip()
|
|
197
|
+
|
|
198
|
+
# ── Inside a special comment block ──────────────────────────────────
|
|
199
|
+
if state == _State.SYSTEM_BLOCK:
|
|
200
|
+
if stripped == "# </system>":
|
|
201
|
+
# Only set system_prompt from # <system> if not already set by define
|
|
202
|
+
if result.system_prompt is None:
|
|
203
|
+
result.system_prompt = "\n".join(system_lines).strip() or None
|
|
204
|
+
state = _State.NORMAL
|
|
205
|
+
elif stripped.startswith("# "):
|
|
206
|
+
system_lines.append(stripped[2:])
|
|
207
|
+
elif stripped == "#":
|
|
208
|
+
system_lines.append("")
|
|
209
|
+
continue
|
|
210
|
+
|
|
211
|
+
if state == _State.TOOL_BLOCK:
|
|
212
|
+
if stripped == "# </tool>":
|
|
213
|
+
pending_description = "\n".join(tool_lines).strip() or None
|
|
214
|
+
pending_params = tool_params
|
|
215
|
+
tool_lines = []
|
|
216
|
+
tool_params = []
|
|
217
|
+
state = _State.NORMAL
|
|
218
|
+
elif stripped.startswith("# "):
|
|
219
|
+
content = stripped[2:]
|
|
220
|
+
param_m = _PARAM_RE.match(content)
|
|
221
|
+
if param_m:
|
|
222
|
+
tool_params.append(Param(
|
|
223
|
+
name=param_m.group(1),
|
|
224
|
+
type=param_m.group(2),
|
|
225
|
+
description=param_m.group(3).strip(),
|
|
226
|
+
))
|
|
227
|
+
else:
|
|
228
|
+
tool_lines.append(content)
|
|
229
|
+
elif stripped == "#":
|
|
230
|
+
tool_lines.append("")
|
|
231
|
+
continue
|
|
232
|
+
|
|
233
|
+
# ── Block-opening tags ───────────────────────────────────────────────
|
|
234
|
+
if stripped == "# <system>":
|
|
235
|
+
system_lines = []
|
|
236
|
+
state = _State.SYSTEM_BLOCK
|
|
237
|
+
continue
|
|
238
|
+
|
|
239
|
+
if stripped == "# <tool>":
|
|
240
|
+
tool_lines = []
|
|
241
|
+
tool_params = []
|
|
242
|
+
state = _State.TOOL_BLOCK
|
|
243
|
+
continue
|
|
244
|
+
|
|
245
|
+
# ── Skip regular comments and blank lines ────────────────────────────
|
|
246
|
+
if not stripped or stripped.startswith("#"):
|
|
247
|
+
continue
|
|
248
|
+
|
|
249
|
+
# ── define/endef block ───────────────────────────────────────────────
|
|
250
|
+
define_m = _DEFINE_RE.match(stripped)
|
|
251
|
+
if define_m:
|
|
252
|
+
define_name = define_m.group(1)
|
|
253
|
+
define_lines = []
|
|
254
|
+
state = _State.DEFINE
|
|
255
|
+
continue
|
|
256
|
+
|
|
257
|
+
# ── .PHONY declaration ───────────────────────────────────────────────
|
|
258
|
+
phony_m = _PHONY_RE.match(stripped)
|
|
259
|
+
if phony_m:
|
|
260
|
+
new_phonies = phony_m.group(1).split()
|
|
261
|
+
phony_targets.update(new_phonies)
|
|
262
|
+
for rule in result.rules:
|
|
263
|
+
if rule.target in phony_targets:
|
|
264
|
+
rule.is_phony = True
|
|
265
|
+
continue
|
|
266
|
+
|
|
267
|
+
# ── Variable assignment ──────────────────────────────────────────────
|
|
268
|
+
var_m = _VARIABLE_RE.match(line.rstrip())
|
|
269
|
+
if var_m:
|
|
270
|
+
name = var_m.group(1)
|
|
271
|
+
op = var_m.group(2).strip()
|
|
272
|
+
raw_val = _strip_comment(var_m.group(3))
|
|
273
|
+
expanded = _expand_vars(raw_val, result.variables)
|
|
274
|
+
flavor = _FLAVOR_MAP.get(op, "recursive")
|
|
275
|
+
if op == "+=" and name in result.variables:
|
|
276
|
+
result.variables[name].value = (result.variables[name].value + " " + expanded).strip()
|
|
277
|
+
else:
|
|
278
|
+
result.variables[name] = Variable(name=name, value=expanded, flavor=flavor)
|
|
279
|
+
continue
|
|
280
|
+
|
|
281
|
+
# ── Target rule ──────────────────────────────────────────────────────
|
|
282
|
+
target_m = _TARGET_RE.match(line.rstrip())
|
|
283
|
+
if target_m:
|
|
284
|
+
targets_str = target_m.group(1).strip()
|
|
285
|
+
prereqs_str = _strip_comment(target_m.group(2) or "")
|
|
286
|
+
targets = [_expand_vars(t, result.variables) for t in targets_str.split()]
|
|
287
|
+
prereqs = [_expand_vars(p, result.variables) for p in prereqs_str.split()]
|
|
288
|
+
shared_recipes: list[str] = []
|
|
289
|
+
for i, target in enumerate(targets):
|
|
290
|
+
rule = Rule(
|
|
291
|
+
target=target,
|
|
292
|
+
prerequisites=prereqs,
|
|
293
|
+
recipes=shared_recipes,
|
|
294
|
+
is_phony=target in phony_targets,
|
|
295
|
+
description=pending_description if i == 0 else None,
|
|
296
|
+
params=pending_params if i == 0 else [],
|
|
297
|
+
)
|
|
298
|
+
result.rules.append(rule)
|
|
299
|
+
if result.default_target is None and not target.startswith("."):
|
|
300
|
+
result.default_target = target
|
|
301
|
+
pending_description = None
|
|
302
|
+
pending_params = []
|
|
303
|
+
state = _State.RECIPE
|
|
304
|
+
current_recipes = shared_recipes
|
|
305
|
+
|
|
306
|
+
return result
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def parse_file(path: str | Path) -> Makefile:
|
|
310
|
+
"""Parse a Makefile from disk."""
|
|
311
|
+
return parse(Path(path).read_text(encoding="utf-8"))
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
# Matches $(NAME), ${NAME}, or $$NAME in recipe text.
|
|
315
|
+
# The optional ``value `` prefix handles the ``$(value NAME)`` raw-literal form.
|
|
316
|
+
_RECIPE_VAR_RE = re.compile(r"\$\((?:value\s+)?([^)]+)\)|\$\{(?:value\s+)?([^}]+)\}|\$\$(\w+)")
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def validate(makefile: Makefile) -> list[str]:
|
|
320
|
+
"""Check that every ``@param NAME`` is referenced in the rule's recipe body.
|
|
321
|
+
|
|
322
|
+
Accepts ``$(NAME)``, ``${NAME}``, ``$$NAME``, or ``$(NAME_FILE)`` /
|
|
323
|
+
``${NAME_FILE}`` / ``$$NAME_FILE`` as valid references. The ``_FILE``
|
|
324
|
+
form is always available at runtime (a temp file is written for every
|
|
325
|
+
parameter) so either convention is valid in a recipe.
|
|
326
|
+
|
|
327
|
+
Returns a list of human-readable error strings (empty list means valid).
|
|
328
|
+
"""
|
|
329
|
+
errors: list[str] = []
|
|
330
|
+
for rule in makefile.rules:
|
|
331
|
+
if not rule.params:
|
|
332
|
+
continue
|
|
333
|
+
recipe_text = "\n".join(rule.recipes)
|
|
334
|
+
used_vars = {m.group(1) or m.group(2) or m.group(3) for m in _RECIPE_VAR_RE.finditer(recipe_text)}
|
|
335
|
+
for param in rule.params:
|
|
336
|
+
file_var = f"{param.name}_FILE"
|
|
337
|
+
if param.name not in used_vars and file_var not in used_vars:
|
|
338
|
+
errors.append(
|
|
339
|
+
f"Tool '{rule.target}': @param {param.name} declared but never "
|
|
340
|
+
f"referenced in recipe.\n"
|
|
341
|
+
f" Expected $({param.name}), ${{{param.name}}}, $${param.name}, "
|
|
342
|
+
f"or $({file_var}) in the recipe body."
|
|
343
|
+
)
|
|
344
|
+
return errors
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def validate_or_raise(makefile: Makefile) -> None:
|
|
348
|
+
"""Like :func:`validate` but raises :exc:`ValueError` if any errors are found."""
|
|
349
|
+
errors = validate(makefile)
|
|
350
|
+
if errors:
|
|
351
|
+
raise ValueError("\n".join(errors))
|
make_agent/settings.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Per-project settings stored in ``~/.make-agent/<project>/settings.yaml``.
|
|
2
|
+
|
|
3
|
+
Supported fields::
|
|
4
|
+
|
|
5
|
+
model: anthropic/claude-haiku-4-5-20251001
|
|
6
|
+
makefile: ./Makefile
|
|
7
|
+
|
|
8
|
+
Fields present in the file act as defaults; CLI flags always take precedence.
|
|
9
|
+
Missing fields are simply ignored — settings are always partial.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import yaml
|
|
18
|
+
|
|
19
|
+
from make_agent.app_dirs import default_agents_dir, settings_file
|
|
20
|
+
|
|
21
|
+
_DEFAULT_MODEL = "anthropic/claude-haiku-4-5-20251001"
|
|
22
|
+
_DEFAULT_MAKEFILE = "Makefile"
|
|
23
|
+
_ORCHESTRA_TEMPLATE = Path(__file__).parent / "templates" / "orchestra.mk"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def load_settings(cwd: str | None = None) -> dict[str, Any] | None:
|
|
27
|
+
"""Load settings from ``~/.make-agent/<project>/settings.yaml``.
|
|
28
|
+
|
|
29
|
+
Returns the parsed dict, or ``None`` if the file does not exist.
|
|
30
|
+
Unknown keys are silently ignored.
|
|
31
|
+
"""
|
|
32
|
+
path = settings_file(cwd)
|
|
33
|
+
if not path.exists():
|
|
34
|
+
return None
|
|
35
|
+
text = path.read_text(encoding="utf-8")
|
|
36
|
+
data = yaml.safe_load(text)
|
|
37
|
+
if data is None:
|
|
38
|
+
return {}
|
|
39
|
+
if not isinstance(data, dict):
|
|
40
|
+
raise ValueError(
|
|
41
|
+
f"Invalid settings file {path}: expected a YAML mapping, "
|
|
42
|
+
f"got {type(data).__name__}. Please check your settings.yaml."
|
|
43
|
+
)
|
|
44
|
+
return {k: v for k, v in data.items() if k in ("model", "makefile", "memory", "agent_model")}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def save_settings(data: dict[str, Any], cwd: str | None = None) -> None:
|
|
48
|
+
"""Write *data* to ``~/.make-agent/<project>/settings.yaml``."""
|
|
49
|
+
path = settings_file(cwd)
|
|
50
|
+
path.write_text(yaml.dump(data, default_flow_style=False, allow_unicode=True), encoding="utf-8")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def run_setup_wizard() -> dict[str, Any]:
|
|
54
|
+
"""Interactively ask the user for project settings, save and return them.
|
|
55
|
+
|
|
56
|
+
If the agents directory is empty, copies the bundled ``orchestra.mk``
|
|
57
|
+
template there and uses it as the makefile (no prompt for the path).
|
|
58
|
+
Otherwise prompts for a Makefile path.
|
|
59
|
+
|
|
60
|
+
Always prompts for the model string.
|
|
61
|
+
"""
|
|
62
|
+
print("\nNo settings.yaml found for this project.")
|
|
63
|
+
print("Let's create one. Press Enter to accept the default shown in brackets.\n")
|
|
64
|
+
|
|
65
|
+
agents_dir = Path(default_agents_dir())
|
|
66
|
+
existing_agents = list(agents_dir.glob("*.mk"))
|
|
67
|
+
|
|
68
|
+
if not existing_agents:
|
|
69
|
+
dest = agents_dir / "orchestra.mk"
|
|
70
|
+
dest.write_bytes(_ORCHESTRA_TEMPLATE.read_bytes())
|
|
71
|
+
makefile = str(dest)
|
|
72
|
+
print(f" Created {dest}")
|
|
73
|
+
else:
|
|
74
|
+
print(" Available agents:")
|
|
75
|
+
for i, agent in enumerate(existing_agents, 1):
|
|
76
|
+
print(f" {i}) {agent.name}")
|
|
77
|
+
while True:
|
|
78
|
+
raw = input(f" Choose an agent [1-{len(existing_agents)}]: ").strip()
|
|
79
|
+
if raw.isdigit() and 1 <= int(raw) <= len(existing_agents):
|
|
80
|
+
makefile = str(existing_agents[int(raw) - 1])
|
|
81
|
+
break
|
|
82
|
+
print(f" Please enter a number between 1 and {len(existing_agents)}.")
|
|
83
|
+
|
|
84
|
+
raw_model = input(f" Model [{_DEFAULT_MODEL}]: ").strip()
|
|
85
|
+
model = raw_model or _DEFAULT_MODEL
|
|
86
|
+
|
|
87
|
+
settings: dict[str, Any] = {"makefile": makefile, "model": model}
|
|
88
|
+
save_settings(settings)
|
|
89
|
+
|
|
90
|
+
path = settings_file()
|
|
91
|
+
print(f"\nSaved settings to {path}\n")
|
|
92
|
+
return settings
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
define SYSTEM_PROMPT
|
|
2
|
+
You are an orchestrator agent that manages a library of specialist agents.
|
|
3
|
+
Specialist agents live as Makefile files in the agents directory.
|
|
4
|
+
Each agent has a focused set of tools for a specific domain.
|
|
5
|
+
|
|
6
|
+
You have three built-in tools available at all times:
|
|
7
|
+
|
|
8
|
+
- list_agent — discover available specialist agents and their descriptions
|
|
9
|
+
- create_agent — create or overwrite a specialist agent from a YAML spec
|
|
10
|
+
- run_agent — delegate a task to a specialist agent and get its output
|
|
11
|
+
|
|
12
|
+
Your workflow for every task:
|
|
13
|
+
1. Call list_agent to discover available specialists.
|
|
14
|
+
2. If a suitable agent exists, call run_agent to delegate the task.
|
|
15
|
+
3. If no suitable agent exists, design a new specialist and call
|
|
16
|
+
create_agent to save it, then run_agent to execute it.
|
|
17
|
+
4. To improve an existing agent, call create_agent with the same name —
|
|
18
|
+
this overwrites the previous version.
|
|
19
|
+
|
|
20
|
+
When creating a new agent, pass a YAML spec with this structure:
|
|
21
|
+
|
|
22
|
+
system_prompt: "You are a specialist that ..."
|
|
23
|
+
tools:
|
|
24
|
+
- name: tool-name
|
|
25
|
+
description: What this tool does.
|
|
26
|
+
params:
|
|
27
|
+
- name: PARAM
|
|
28
|
+
type: string
|
|
29
|
+
description: The param purpose
|
|
30
|
+
recipe:
|
|
31
|
+
- "@shell command $(PARAM)"
|
|
32
|
+
|
|
33
|
+
"params" may be omitted for tools that take no arguments.
|
|
34
|
+
"type" must be one of: string, number, integer, boolean.
|
|
35
|
+
Each "recipe" entry becomes one shell line in the Makefile target.
|
|
36
|
+
For multiline values or values with shell metacharacters, use $(PARAM_FILE)
|
|
37
|
+
instead of $(PARAM) in the recipe — a temp file with the full value is
|
|
38
|
+
always available as $(PARAM_FILE) for every declared parameter.
|
|
39
|
+
|
|
40
|
+
CRITICAL: every param listed in "params" MUST be referenced as $(PARAM_NAME)
|
|
41
|
+
or $(PARAM_NAME_FILE) in the recipe. A param declared but absent from the
|
|
42
|
+
recipe will cause an error.
|
|
43
|
+
|
|
44
|
+
Example of a correct two-param tool:
|
|
45
|
+
|
|
46
|
+
system_prompt: "You are a search specialist."
|
|
47
|
+
tools:
|
|
48
|
+
- name: search-files
|
|
49
|
+
description: Search files for a pattern in a directory.
|
|
50
|
+
params:
|
|
51
|
+
- name: PATTERN
|
|
52
|
+
type: string
|
|
53
|
+
description: Search pattern (regex)
|
|
54
|
+
- name: DIR
|
|
55
|
+
type: string
|
|
56
|
+
description: Directory to search in
|
|
57
|
+
recipe:
|
|
58
|
+
- '@grep -rn "$(PATTERN)" "$(DIR)" || echo "No matches found"'
|
|
59
|
+
|
|
60
|
+
Each agent should report errors by echoing a message that starts with "ERROR:" — this is how you detect failure. Include this in system prompts and encourage agents to use it for error handling.
|
|
61
|
+
Each agent should always ask you for help if they are unsure about how to complete a task, rather than making assumptions or taking random actions. Include this in system prompts to encourage it.
|
|
62
|
+
Always return useful information from the agent, even in case of errors. The orchestrator will relay this back to the user.
|
|
63
|
+
Always delegate work to specialist agents rather than attempting tasks directly.
|
|
64
|
+
Always check if a suitable specialist exists before creating a new one.
|
|
65
|
+
Always create a plan for completing the task and provide it to the user to confirm before executing any steps. The plan should include which agents you intend to use and how.
|
|
66
|
+
|
|
67
|
+
## Memory tools (available when --with-memory is enabled)
|
|
68
|
+
|
|
69
|
+
- get_recent_messages(limit, from_date, to_date) — fetch the N most recent messages; use this first to orient yourself at the start of a session
|
|
70
|
+
- search_user_memory(query, limit, from_date, to_date) — FTS5 keyword search over past user messages
|
|
71
|
+
- search_agent_memory(query, limit, from_date, to_date) — FTS5 keyword search over past agent replies
|
|
72
|
+
|
|
73
|
+
All date parameters accept ISO 8601 strings (e.g. '2026-03-01'). All parameters are optional.
|
|
74
|
+
FTS5 tips:
|
|
75
|
+
- Use short keywords, not full sentences: "goal project" not "what is the goal of this project"
|
|
76
|
+
- Use OR for broader recall: "goal OR objective OR purpose"
|
|
77
|
+
- Stop words (the, of, is, a) are not indexed — omit them
|
|
78
|
+
- If a search returns nothing, retry with broader or alternative keywords
|
|
79
|
+
endef
|
|
80
|
+
|
|
81
|
+
.PHONY: current-dir os-info current-date
|
|
82
|
+
|
|
83
|
+
# <tool>
|
|
84
|
+
# Return the current working directory path.
|
|
85
|
+
# </tool>
|
|
86
|
+
current-dir:
|
|
87
|
+
@pwd
|
|
88
|
+
|
|
89
|
+
# <tool>
|
|
90
|
+
# Return operating system and kernel information.
|
|
91
|
+
# </tool>
|
|
92
|
+
os-info:
|
|
93
|
+
@uname -a
|
|
94
|
+
|
|
95
|
+
# <tool>
|
|
96
|
+
# Return the current date and time.
|
|
97
|
+
# </tool>
|
|
98
|
+
current-date:
|
|
99
|
+
@date
|