agentainer 0.1.0
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.
- package/README.md +603 -0
- package/agents.example.yaml +257 -0
- package/bin/agentainer.js +74 -0
- package/examples/bug-hunt.yaml +112 -0
- package/examples/existing-repo.yaml +72 -0
- package/examples/research-swarm.yaml +120 -0
- package/examples/software-company.yaml +152 -0
- package/hooks/claude_stop.sh +17 -0
- package/hooks/codex_notify.sh +16 -0
- package/lib/config.py +641 -0
- package/lib/minyaml.py +376 -0
- package/lib/swarm.py +2277 -0
- package/llms.txt +405 -0
- package/package.json +52 -0
- package/scripts/check-deps.js +76 -0
- package/swarm.sh +43 -0
package/lib/minyaml.py
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""A small YAML-subset parser.
|
|
2
|
+
|
|
3
|
+
Used only when PyYAML is not importable, so that AgentSwarm keeps working on a
|
|
4
|
+
bare Python 3 install. Supports the subset AgentSwarm configs actually need:
|
|
5
|
+
|
|
6
|
+
* nested block mappings and block sequences
|
|
7
|
+
* scalars: strings, ints, floats, booleans, null
|
|
8
|
+
* single/double quoted strings
|
|
9
|
+
* literal (``|``) and folded (``>``) block scalars, with ``-``/``+`` chomping
|
|
10
|
+
* flow collections: ``[a, b]`` and ``{a: 1, b: 2}``
|
|
11
|
+
* ``#`` comments (whole-line and trailing)
|
|
12
|
+
|
|
13
|
+
Anything fancier (anchors, aliases, tags, multi-doc, complex keys) raises
|
|
14
|
+
YAMLError telling the user to install PyYAML.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
|
|
21
|
+
__all__ = ["load", "YAMLError"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class YAMLError(Exception):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
_KEY_RE = re.compile(
|
|
29
|
+
r"""^(?P<key>
|
|
30
|
+
"(?:[^"\\]|\\.)*" # double quoted key
|
|
31
|
+
| '(?:[^']|'')*' # single quoted key
|
|
32
|
+
| [^\s:#][^:#]*? # plain key
|
|
33
|
+
)
|
|
34
|
+
\s*:(?:\s+(?P<rest>.*))?$""",
|
|
35
|
+
re.VERBOSE,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
_BLOCK_RE = re.compile(r"^([|>])([+-]?)(\d*)$")
|
|
39
|
+
|
|
40
|
+
_UNSUPPORTED = ("&", "*", "!!", "---", "...")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def load(text: str):
|
|
44
|
+
"""Parse *text* and return the corresponding Python object."""
|
|
45
|
+
lines = text.replace("\t", " ").splitlines()
|
|
46
|
+
for raw in lines:
|
|
47
|
+
stripped = raw.strip()
|
|
48
|
+
if stripped.startswith(("---", "...")) and stripped not in ("---", "..."):
|
|
49
|
+
continue
|
|
50
|
+
if stripped in ("---", "..."):
|
|
51
|
+
raise YAMLError(
|
|
52
|
+
"multi-document YAML is not supported by the builtin parser; "
|
|
53
|
+
"install PyYAML (pip install pyyaml)"
|
|
54
|
+
)
|
|
55
|
+
parser = _Parser(lines)
|
|
56
|
+
value = parser.parse()
|
|
57
|
+
parser.expect_eof()
|
|
58
|
+
return value if value is not None else {}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# --------------------------------------------------------------------------
|
|
62
|
+
# scalar handling
|
|
63
|
+
# --------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _strip_comment(s: str) -> str:
|
|
67
|
+
"""Remove a trailing ``# comment``, respecting quotes and brackets."""
|
|
68
|
+
out = []
|
|
69
|
+
quote = None
|
|
70
|
+
prev = ""
|
|
71
|
+
for ch in s:
|
|
72
|
+
if quote:
|
|
73
|
+
out.append(ch)
|
|
74
|
+
if ch == quote and prev != "\\":
|
|
75
|
+
quote = None
|
|
76
|
+
elif ch in "\"'":
|
|
77
|
+
quote = ch
|
|
78
|
+
out.append(ch)
|
|
79
|
+
elif ch == "#" and (not out or prev in " \t"):
|
|
80
|
+
break
|
|
81
|
+
else:
|
|
82
|
+
out.append(ch)
|
|
83
|
+
prev = ch
|
|
84
|
+
return "".join(out).rstrip()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _split_flow(body: str) -> list[str]:
|
|
88
|
+
"""Split ``a, b, [c, d]`` on top-level commas."""
|
|
89
|
+
parts, depth, quote, cur = [], 0, None, []
|
|
90
|
+
for ch in body:
|
|
91
|
+
if quote:
|
|
92
|
+
cur.append(ch)
|
|
93
|
+
if ch == quote:
|
|
94
|
+
quote = None
|
|
95
|
+
continue
|
|
96
|
+
if ch in "\"'":
|
|
97
|
+
quote = ch
|
|
98
|
+
elif ch in "[{":
|
|
99
|
+
depth += 1
|
|
100
|
+
elif ch in "]}":
|
|
101
|
+
depth -= 1
|
|
102
|
+
elif ch == "," and depth == 0:
|
|
103
|
+
parts.append("".join(cur))
|
|
104
|
+
cur = []
|
|
105
|
+
continue
|
|
106
|
+
cur.append(ch)
|
|
107
|
+
if "".join(cur).strip():
|
|
108
|
+
parts.append("".join(cur))
|
|
109
|
+
return [p.strip() for p in parts]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _is_balanced(s: str) -> bool:
|
|
113
|
+
"""True once every ``[``/``{`` opened in *s* has been closed."""
|
|
114
|
+
depth, quote = 0, None
|
|
115
|
+
for ch in s:
|
|
116
|
+
if quote:
|
|
117
|
+
if ch == quote:
|
|
118
|
+
quote = None
|
|
119
|
+
elif ch in "\"'":
|
|
120
|
+
quote = ch
|
|
121
|
+
elif ch in "[{":
|
|
122
|
+
depth += 1
|
|
123
|
+
elif ch in "]}":
|
|
124
|
+
depth -= 1
|
|
125
|
+
return depth <= 0
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _unquote(s: str) -> str:
|
|
129
|
+
if len(s) >= 2 and s[0] == s[-1] == '"':
|
|
130
|
+
return s[1:-1].encode("utf-8").decode("unicode_escape")
|
|
131
|
+
if len(s) >= 2 and s[0] == s[-1] == "'":
|
|
132
|
+
return s[1:-1].replace("''", "'")
|
|
133
|
+
return s
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _scalar(raw: str):
|
|
137
|
+
s = _strip_comment(raw).strip()
|
|
138
|
+
if not s:
|
|
139
|
+
return None
|
|
140
|
+
if s[0] in "\"'":
|
|
141
|
+
return _unquote(s)
|
|
142
|
+
if s.startswith("["):
|
|
143
|
+
if not s.endswith("]"):
|
|
144
|
+
raise YAMLError(f"unterminated flow sequence: {raw!r}")
|
|
145
|
+
return [_scalar(p) for p in _split_flow(s[1:-1])]
|
|
146
|
+
if s.startswith("{"):
|
|
147
|
+
if not s.endswith("}"):
|
|
148
|
+
raise YAMLError(f"unterminated flow mapping: {raw!r}")
|
|
149
|
+
out = {}
|
|
150
|
+
for part in _split_flow(s[1:-1]):
|
|
151
|
+
if ":" not in part:
|
|
152
|
+
raise YAMLError(f"bad flow mapping entry: {part!r}")
|
|
153
|
+
k, _, v = part.partition(":")
|
|
154
|
+
out[_unquote(k.strip())] = _scalar(v)
|
|
155
|
+
return out
|
|
156
|
+
if s.startswith(_UNSUPPORTED):
|
|
157
|
+
raise YAMLError(
|
|
158
|
+
f"unsupported YAML feature in {raw!r}; install PyYAML (pip install pyyaml)"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
low = s.lower()
|
|
162
|
+
if low in ("null", "~"):
|
|
163
|
+
return None
|
|
164
|
+
if low in ("true", "yes", "on"):
|
|
165
|
+
return True
|
|
166
|
+
if low in ("false", "no", "off"):
|
|
167
|
+
return False
|
|
168
|
+
try:
|
|
169
|
+
return int(s, 10)
|
|
170
|
+
except ValueError:
|
|
171
|
+
pass
|
|
172
|
+
try:
|
|
173
|
+
return float(s)
|
|
174
|
+
except ValueError:
|
|
175
|
+
pass
|
|
176
|
+
return s
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# --------------------------------------------------------------------------
|
|
180
|
+
# block parser
|
|
181
|
+
# --------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _indent_of(line: str) -> int:
|
|
185
|
+
return len(line) - len(line.lstrip(" "))
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _is_blank(line: str) -> bool:
|
|
189
|
+
s = line.strip()
|
|
190
|
+
return not s or s.startswith("#")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class _Parser:
|
|
194
|
+
def __init__(self, lines: list[str]):
|
|
195
|
+
self.lines = lines
|
|
196
|
+
self.i = 0
|
|
197
|
+
|
|
198
|
+
# -- cursor helpers ----------------------------------------------------
|
|
199
|
+
|
|
200
|
+
def peek(self):
|
|
201
|
+
"""Return (index, indent, stripped) of the next meaningful line."""
|
|
202
|
+
while self.i < len(self.lines) and _is_blank(self.lines[self.i]):
|
|
203
|
+
self.i += 1
|
|
204
|
+
if self.i >= len(self.lines):
|
|
205
|
+
return None
|
|
206
|
+
line = self.lines[self.i]
|
|
207
|
+
return self.i, _indent_of(line), line.strip()
|
|
208
|
+
|
|
209
|
+
def expect_eof(self):
|
|
210
|
+
if self.peek() is not None:
|
|
211
|
+
_, indent, text = self.peek()
|
|
212
|
+
raise YAMLError(f"unexpected content at indent {indent}: {text!r}")
|
|
213
|
+
|
|
214
|
+
def finish_flow(self, first: str):
|
|
215
|
+
"""Parse a flow collection, consuming continuation lines until balanced.
|
|
216
|
+
|
|
217
|
+
The cursor must already sit *after* the line ``first`` came from.
|
|
218
|
+
"""
|
|
219
|
+
buf = _strip_comment(first)
|
|
220
|
+
while not _is_balanced(buf):
|
|
221
|
+
if self.i >= len(self.lines):
|
|
222
|
+
raise YAMLError(f"unterminated flow collection: {first!r}")
|
|
223
|
+
line = self.lines[self.i]
|
|
224
|
+
self.i += 1
|
|
225
|
+
if _is_blank(line):
|
|
226
|
+
continue
|
|
227
|
+
buf += " " + _strip_comment(line.strip())
|
|
228
|
+
return _scalar(buf)
|
|
229
|
+
|
|
230
|
+
# -- grammar -----------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
def parse(self, indent: int | None = None):
|
|
233
|
+
head = self.peek()
|
|
234
|
+
if head is None:
|
|
235
|
+
return None
|
|
236
|
+
_, cur_indent, text = head
|
|
237
|
+
if indent is None:
|
|
238
|
+
indent = cur_indent
|
|
239
|
+
if cur_indent < indent:
|
|
240
|
+
return None
|
|
241
|
+
if text == "-" or text.startswith("- "):
|
|
242
|
+
return self.parse_seq(cur_indent)
|
|
243
|
+
# Check flow collections before the key regex: `{name: a, type: b}` would
|
|
244
|
+
# otherwise look like a block mapping with the key `{name`.
|
|
245
|
+
if text.startswith(("{", "[")):
|
|
246
|
+
self.i += 1
|
|
247
|
+
return self.finish_flow(text)
|
|
248
|
+
if _KEY_RE.match(text):
|
|
249
|
+
return self.parse_map(cur_indent)
|
|
250
|
+
# A lone scalar (only reachable for sequence item bodies).
|
|
251
|
+
self.i += 1
|
|
252
|
+
return _scalar(text)
|
|
253
|
+
|
|
254
|
+
def parse_map(self, indent: int) -> dict:
|
|
255
|
+
out: dict = {}
|
|
256
|
+
while True:
|
|
257
|
+
head = self.peek()
|
|
258
|
+
if head is None:
|
|
259
|
+
break
|
|
260
|
+
_, cur_indent, text = head
|
|
261
|
+
if cur_indent < indent:
|
|
262
|
+
break
|
|
263
|
+
if cur_indent > indent:
|
|
264
|
+
raise YAMLError(f"unexpected indentation before {text!r}")
|
|
265
|
+
match = _KEY_RE.match(text)
|
|
266
|
+
if not match:
|
|
267
|
+
break
|
|
268
|
+
key = _unquote(match.group("key").strip())
|
|
269
|
+
rest = (match.group("rest") or "").strip()
|
|
270
|
+
self.i += 1
|
|
271
|
+
|
|
272
|
+
block = _BLOCK_RE.match(_strip_comment(rest)) if rest else None
|
|
273
|
+
if block:
|
|
274
|
+
out[key] = self.parse_block_scalar(indent, block)
|
|
275
|
+
elif rest.startswith(("{", "[")) and not _is_balanced(_strip_comment(rest)):
|
|
276
|
+
out[key] = self.finish_flow(rest)
|
|
277
|
+
elif rest:
|
|
278
|
+
out[key] = _scalar(rest)
|
|
279
|
+
else:
|
|
280
|
+
nxt = self.peek()
|
|
281
|
+
if nxt and nxt[1] > indent:
|
|
282
|
+
out[key] = self.parse(nxt[1])
|
|
283
|
+
elif nxt and nxt[1] == indent and nxt[2].startswith(("-", "- ")):
|
|
284
|
+
# Sequences may sit at the same indent as their key.
|
|
285
|
+
out[key] = self.parse_seq(indent)
|
|
286
|
+
else:
|
|
287
|
+
out[key] = None
|
|
288
|
+
return out
|
|
289
|
+
|
|
290
|
+
def parse_seq(self, indent: int) -> list:
|
|
291
|
+
out: list = []
|
|
292
|
+
while True:
|
|
293
|
+
head = self.peek()
|
|
294
|
+
if head is None:
|
|
295
|
+
break
|
|
296
|
+
idx, cur_indent, text = head
|
|
297
|
+
if cur_indent != indent or not (text == "-" or text.startswith("- ")):
|
|
298
|
+
break
|
|
299
|
+
|
|
300
|
+
line = self.lines[idx]
|
|
301
|
+
# Rewrite "- foo" into " foo" so the item body is a normal block
|
|
302
|
+
# that starts at the column right after the dash.
|
|
303
|
+
body_first = line[:indent] + " " + line[indent + 1 :]
|
|
304
|
+
self.i += 1
|
|
305
|
+
|
|
306
|
+
if not body_first.strip():
|
|
307
|
+
nxt = self.peek()
|
|
308
|
+
out.append(self.parse(nxt[1]) if nxt and nxt[1] > indent else None)
|
|
309
|
+
continue
|
|
310
|
+
|
|
311
|
+
body = [body_first]
|
|
312
|
+
while self.i < len(self.lines):
|
|
313
|
+
nxt_line = self.lines[self.i]
|
|
314
|
+
if _is_blank(nxt_line):
|
|
315
|
+
body.append(nxt_line)
|
|
316
|
+
self.i += 1
|
|
317
|
+
continue
|
|
318
|
+
if _indent_of(nxt_line) > indent:
|
|
319
|
+
body.append(nxt_line)
|
|
320
|
+
self.i += 1
|
|
321
|
+
continue
|
|
322
|
+
break
|
|
323
|
+
|
|
324
|
+
sub = _Parser(body)
|
|
325
|
+
out.append(sub.parse())
|
|
326
|
+
sub.expect_eof()
|
|
327
|
+
return out
|
|
328
|
+
|
|
329
|
+
def parse_block_scalar(self, indent: int, match: re.Match) -> str:
|
|
330
|
+
style, chomp, explicit = match.group(1), match.group(2), match.group(3)
|
|
331
|
+
|
|
332
|
+
raw: list[str] = []
|
|
333
|
+
while self.i < len(self.lines):
|
|
334
|
+
line = self.lines[self.i]
|
|
335
|
+
if line.strip() and _indent_of(line) <= indent:
|
|
336
|
+
break
|
|
337
|
+
raw.append(line)
|
|
338
|
+
self.i += 1
|
|
339
|
+
|
|
340
|
+
while raw and not raw[-1].strip():
|
|
341
|
+
raw.pop()
|
|
342
|
+
if not raw:
|
|
343
|
+
return ""
|
|
344
|
+
|
|
345
|
+
if explicit:
|
|
346
|
+
block_indent = indent + int(explicit)
|
|
347
|
+
else:
|
|
348
|
+
block_indent = min(_indent_of(l) for l in raw if l.strip())
|
|
349
|
+
|
|
350
|
+
body = [l[block_indent:] if len(l) > block_indent else "" for l in raw]
|
|
351
|
+
|
|
352
|
+
if style == "|":
|
|
353
|
+
text = "\n".join(body)
|
|
354
|
+
else:
|
|
355
|
+
folded, buf = [], []
|
|
356
|
+
for line in body:
|
|
357
|
+
if not line.strip():
|
|
358
|
+
folded.append(" ".join(buf))
|
|
359
|
+
buf = []
|
|
360
|
+
folded.append("")
|
|
361
|
+
elif line.startswith(" "): # more-indented lines keep their breaks
|
|
362
|
+
if buf:
|
|
363
|
+
folded.append(" ".join(buf))
|
|
364
|
+
buf = []
|
|
365
|
+
folded.append(line)
|
|
366
|
+
else:
|
|
367
|
+
buf.append(line.strip())
|
|
368
|
+
if buf:
|
|
369
|
+
folded.append(" ".join(buf))
|
|
370
|
+
text = "\n".join(folded)
|
|
371
|
+
|
|
372
|
+
if chomp == "-":
|
|
373
|
+
return text.rstrip("\n")
|
|
374
|
+
if chomp == "+":
|
|
375
|
+
return text + "\n"
|
|
376
|
+
return text.rstrip("\n") + "\n"
|