@ssheleg/agent-stack 0.7.2 → 0.9.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/CHANGELOG.md +120 -0
- package/README.md +29 -4
- package/package.json +1 -1
- package/plugins/agent-stack/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-stack/skills/agent-harness/SKILL.md +163 -0
- package/plugins/agent-stack/skills/agent-harness/references/audit.md +141 -0
- package/plugins/agent-stack/skills/agent-harness/references/layers.md +104 -0
- package/plugins/agent-stack/skills/agent-harness/references/pi-sdk.md +318 -0
- package/plugins/agent-stack/skills/agent-harness/references/pi.md +241 -0
- package/plugins/agent-stack/skills/agent-harness/references/system-prompt.md +127 -0
- package/plugins/agent-stack/skills/agent-harness/references/techniques.md +115 -0
- package/plugins/agent-stack/skills/agent-harness/references/tools.md +154 -0
- package/plugins/agent-stack/skills/agent-harness/scripts/audit_agent.py +299 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Mechanical half of an agent-system audit.
|
|
3
|
+
|
|
4
|
+
Finds only what is visible WITHOUT understanding intent, and prints what it cannot see —
|
|
5
|
+
because a scanner that goes quiet is reporting its own blindness, and an audit that stops
|
|
6
|
+
at a silent scanner has audited the scanner.
|
|
7
|
+
|
|
8
|
+
python3 audit_agent.py <path> human-readable
|
|
9
|
+
python3 audit_agent.py <path> --json machine-readable
|
|
10
|
+
python3 audit_agent.py --self-test plant each defect, require each to be found
|
|
11
|
+
|
|
12
|
+
Zero dependencies. Python 3.9+.
|
|
13
|
+
|
|
14
|
+
Design rule, and the reason this file is short: every detector is CONSERVATIVE. A false
|
|
15
|
+
positive costs more than a miss here, because an audit report that cries wolf is discarded
|
|
16
|
+
whole — and the seven tracks in `references/audit.md` cover by hand everything this cannot
|
|
17
|
+
reach. Detectors therefore require corroboration (the file must look agent-related) and
|
|
18
|
+
each finding carries `file:line` so a human can disagree with it in one click.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
import sys
|
|
26
|
+
|
|
27
|
+
# A file is "agent-related" only if it shows two independent signs. One is a coincidence:
|
|
28
|
+
# plenty of code says "message" or "prompt" without being an agent loop.
|
|
29
|
+
AGENTISH = [
|
|
30
|
+
re.compile(r"\btool[_ ]?call", re.I),
|
|
31
|
+
re.compile(r"\btools\s*=|\"tools\"\s*:|'tools'\s*:"),
|
|
32
|
+
re.compile(r"\bsystem[_ ]?prompt", re.I),
|
|
33
|
+
re.compile(r"\b(anthropic|openai|litellm|langchain|langgraph|bedrock|mistral)\b", re.I),
|
|
34
|
+
re.compile(r"\bfunction[_ ]?call", re.I),
|
|
35
|
+
re.compile(r"\bmessages\s*=\s*\[|\"messages\"\s*:"),
|
|
36
|
+
]
|
|
37
|
+
CODE_EXT = {".py", ".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx"}
|
|
38
|
+
SKIP_DIRS = {".git", "node_modules", "venv", ".venv", "__pycache__", "dist", "build",
|
|
39
|
+
".next", "target", "vendor", ".tox", "site-packages"}
|
|
40
|
+
MAX_BYTES = 400_000 # a generated bundle is not worth reading, and skews everything
|
|
41
|
+
|
|
42
|
+
# Model ids that are usually hardcoded by accident. Deliberately not exhaustive: this is a
|
|
43
|
+
# smell detector, and the finding says "pin it deliberately", not "this id is wrong".
|
|
44
|
+
MODEL_LITERAL = re.compile(
|
|
45
|
+
r"[\"']((?:claude|gpt|gemini|llama|mistral|deepseek|qwen)[-\w.]*\d[\w.-]*)[\"']", re.I)
|
|
46
|
+
|
|
47
|
+
FINDINGS = []
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def add(kind, path, line, detail, fix):
|
|
51
|
+
FINDINGS.append({"check": kind, "file": path, "line": line, "detail": detail, "fix": fix})
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def agentish(text):
|
|
55
|
+
return sum(1 for p in AGENTISH if p.search(text)) >= 2
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def walk(root):
|
|
59
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
60
|
+
# Skip a virtualenv by its MARKER, not by its name. `venv`/`.venv` in SKIP_DIRS
|
|
61
|
+
# only catches the conventional names; a real repository met during testing used
|
|
62
|
+
# `myenv/`, holding 4249 of its 4261 code files, and was excluded only because
|
|
63
|
+
# `site-packages` happened to be listed too. Right by accident is not right.
|
|
64
|
+
dirnames[:] = [d for d in dirnames
|
|
65
|
+
if d not in SKIP_DIRS and not d.startswith(".")
|
|
66
|
+
and not os.path.exists(os.path.join(dirpath, d, "pyvenv.cfg"))]
|
|
67
|
+
for fn in filenames:
|
|
68
|
+
if os.path.splitext(fn)[1] not in CODE_EXT:
|
|
69
|
+
continue
|
|
70
|
+
full = os.path.join(dirpath, fn)
|
|
71
|
+
try:
|
|
72
|
+
if os.path.getsize(full) > MAX_BYTES:
|
|
73
|
+
continue
|
|
74
|
+
with open(full, encoding="utf-8", errors="replace") as fh:
|
|
75
|
+
text = fh.read()
|
|
76
|
+
except OSError:
|
|
77
|
+
continue
|
|
78
|
+
yield os.path.relpath(full, root), text
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ------------------------------------------------------------------ detectors
|
|
82
|
+
|
|
83
|
+
def check_unbounded_loop(rel, text, lines):
|
|
84
|
+
"""`while True` in an agent file with no visible iteration bound.
|
|
85
|
+
|
|
86
|
+
Conservative twice over: the file must be agent-related, AND the file must not mention
|
|
87
|
+
any bound at all. A loop with `max_iter` somewhere else in the file is left alone.
|
|
88
|
+
"""
|
|
89
|
+
if re.search(r"max[_ ]?iter|max[_ ]?steps|max[_ ]?turns|iteration_limit|for\s+\w+\s+in\s+range\(",
|
|
90
|
+
text, re.I):
|
|
91
|
+
return
|
|
92
|
+
for i, l in enumerate(lines, 1):
|
|
93
|
+
if re.search(r"^\s*while\s+(True|true|1)\s*[:)]|^\s*while\s*\(\s*true\s*\)", l):
|
|
94
|
+
add("unbounded-loop", rel, i,
|
|
95
|
+
"`while True` in an agent file with no iteration bound anywhere in it",
|
|
96
|
+
"Add a max-iteration guard that composes a partial answer at the bound, "
|
|
97
|
+
"rather than returning nothing")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def check_tool_without_description(rel, text, lines):
|
|
101
|
+
"""A tool declared with an empty or missing description.
|
|
102
|
+
|
|
103
|
+
Only fires on an explicit empty string — a missing key is too easy to get wrong across
|
|
104
|
+
frameworks, and a wrong finding here is worse than a missed one.
|
|
105
|
+
"""
|
|
106
|
+
for i, l in enumerate(lines, 1):
|
|
107
|
+
if re.search(r"[\"']description[\"']\s*:\s*[\"']\s*[\"']", l) or \
|
|
108
|
+
re.search(r"\bdescription\s*=\s*[\"']\s*[\"']", l):
|
|
109
|
+
add("tool-no-description", rel, i,
|
|
110
|
+
"a tool description is the empty string",
|
|
111
|
+
"Describe WHEN and WHY to use it, and name the neighbouring tool it is "
|
|
112
|
+
"confused with — the highest-leverage sentence in a tool definition")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def check_swallowed_error(rel, text, lines):
|
|
116
|
+
"""An exception caught and discarded inside an agent file."""
|
|
117
|
+
for i, l in enumerate(lines, 1):
|
|
118
|
+
nxt = lines[i] if i < len(lines) else ""
|
|
119
|
+
if re.search(r"^\s*except[^\n]*:\s*$", l) and re.search(r"^\s*pass\s*$", nxt):
|
|
120
|
+
add("swallowed-error", rel, i,
|
|
121
|
+
"`except: pass` — the failure is invisible to the loop and to the model",
|
|
122
|
+
"Return an error the agent can act on; a tool error is a turn in the "
|
|
123
|
+
"conversation, not a silence")
|
|
124
|
+
if re.search(r"catch\s*\([^)]*\)\s*\{\s*\}", l):
|
|
125
|
+
add("swallowed-error", rel, i,
|
|
126
|
+
"empty `catch` block — the failure is discarded",
|
|
127
|
+
"Surface it to the loop; an error that teaches the next attempt costs "
|
|
128
|
+
"nothing extra")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def check_no_timeout(rel, text, lines):
|
|
132
|
+
"""An outbound HTTP call with no timeout, in an agent file."""
|
|
133
|
+
for i, l in enumerate(lines, 1):
|
|
134
|
+
if re.search(r"\brequests\.(get|post|put|patch|delete)\s*\(", l) and "timeout" not in l:
|
|
135
|
+
add("no-timeout", rel, i,
|
|
136
|
+
"`requests` call with no `timeout=` — a hung provider hangs the agent",
|
|
137
|
+
"Set an explicit timeout on every external call, and decide what the loop "
|
|
138
|
+
"does when it fires")
|
|
139
|
+
if re.search(r"\burllib\.request\.urlopen\s*\(", l) and "timeout" not in l:
|
|
140
|
+
add("no-timeout", rel, i, "`urlopen` with no `timeout=`",
|
|
141
|
+
"Set an explicit timeout on every external call")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def check_hardcoded_model(rel, text, lines):
|
|
145
|
+
"""A model id as a literal, in more than one place — the smell is duplication."""
|
|
146
|
+
hits = []
|
|
147
|
+
for i, l in enumerate(lines, 1):
|
|
148
|
+
if l.lstrip().startswith(("#", "//", "*")):
|
|
149
|
+
continue
|
|
150
|
+
m = MODEL_LITERAL.search(l)
|
|
151
|
+
if m:
|
|
152
|
+
hits.append((i, m.group(1)))
|
|
153
|
+
if len(hits) >= 2:
|
|
154
|
+
i, name = hits[0]
|
|
155
|
+
add("hardcoded-model", rel, i,
|
|
156
|
+
f"model id {name!r} appears as a literal {len(hits)}× in this file",
|
|
157
|
+
"Resolve the model from configuration at one boundary; three levels — request, "
|
|
158
|
+
"tenant, system default — is the shape that bills correctly")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
CHECKS = [check_unbounded_loop, check_tool_without_description, check_swallowed_error,
|
|
162
|
+
check_no_timeout, check_hardcoded_model]
|
|
163
|
+
|
|
164
|
+
# What no static pass can reach. Printed every run, never suppressed.
|
|
165
|
+
BLIND = [
|
|
166
|
+
"whether the SYSTEM PROMPT is at the right altitude — or whether it is in this repo at all",
|
|
167
|
+
"whether two tool descriptions actually distinguish themselves to a model",
|
|
168
|
+
"whether the workflow/agent choice was made deliberately or defaulted to an agent",
|
|
169
|
+
"whether retries and fallbacks MULTIPLY (three providers x three retries is nine calls)",
|
|
170
|
+
"whether compaction preserves decisions and open questions, or keeps the discussion",
|
|
171
|
+
"whether tool output is treated as untrusted input",
|
|
172
|
+
"whether evals exist, judge trajectories, and are calibrated",
|
|
173
|
+
"whether an audit row could prove a control was applied (policy version)",
|
|
174
|
+
"everything in a language this pass does not read, and everything in configuration",
|
|
175
|
+
]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def scan(root):
|
|
179
|
+
seen_files = considered = 0
|
|
180
|
+
for rel, text in walk(root):
|
|
181
|
+
considered += 1
|
|
182
|
+
if not agentish(text):
|
|
183
|
+
continue
|
|
184
|
+
seen_files += 1
|
|
185
|
+
lines = text.splitlines()
|
|
186
|
+
for c in CHECKS:
|
|
187
|
+
c(rel, text, lines)
|
|
188
|
+
return seen_files, considered
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def report_text(root, seen, considered):
|
|
192
|
+
# The denominator is not decoration. "read: 1" alone looks like a broken pass; "1 of
|
|
193
|
+
# 4261" says the repository is mostly not an agent, which is a finding in itself when
|
|
194
|
+
# somebody called it one.
|
|
195
|
+
out = [f"agent-audit: {root}",
|
|
196
|
+
f" code files considered: {considered}",
|
|
197
|
+
f" of those, agent-related: {seen}"]
|
|
198
|
+
if not seen:
|
|
199
|
+
out.append(" NOTHING READ — no file showed two independent signs of an agent loop.")
|
|
200
|
+
out.append(" That is a fact about this pass, not about the system. Check the path,")
|
|
201
|
+
out.append(" and whether the agent lives in a language or a config this cannot read.")
|
|
202
|
+
out.append("")
|
|
203
|
+
if FINDINGS:
|
|
204
|
+
out.append(f"FINDINGS ({len(FINDINGS)}) — each is a smell with a location, not a verdict:")
|
|
205
|
+
for f in FINDINGS:
|
|
206
|
+
out.append(f" {f['file']}:{f['line']} [{f['check']}]")
|
|
207
|
+
out.append(f" {f['detail']}")
|
|
208
|
+
out.append(f" fix: {f['fix']}")
|
|
209
|
+
else:
|
|
210
|
+
out.append("FINDINGS (0) — nothing mechanically visible.")
|
|
211
|
+
out.append("")
|
|
212
|
+
out.append("THIS PASS CANNOT SEE — the manual half of the audit, and it is the larger half:")
|
|
213
|
+
for b in BLIND:
|
|
214
|
+
out.append(f" - {b}")
|
|
215
|
+
out.append("")
|
|
216
|
+
out.append("Walk the seven tracks in references/audit.md. Silence above is not a pass.")
|
|
217
|
+
return "\n".join(out)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def self_test():
|
|
221
|
+
"""Plant each defect and require the matching check to fire.
|
|
222
|
+
|
|
223
|
+
A detector nobody has watched fire is not evidence that it works, and every plant
|
|
224
|
+
asserts it changed something so a reworded fixture fails HERE rather than reporting a
|
|
225
|
+
healthy checker as broken.
|
|
226
|
+
"""
|
|
227
|
+
import tempfile
|
|
228
|
+
header = ("import requests\n"
|
|
229
|
+
"system_prompt = 'x'\n"
|
|
230
|
+
"tools = [{'name': 't', 'description': 'does a thing'}]\n"
|
|
231
|
+
"messages = []\n")
|
|
232
|
+
cases = {
|
|
233
|
+
"unbounded-loop": header + "while True:\n pass\n",
|
|
234
|
+
"tool-no-description": header + "T = [{'name': 'a', 'description': ''}]\n",
|
|
235
|
+
"swallowed-error": header + "try:\n x = 1\nexcept Exception:\n pass\n",
|
|
236
|
+
"no-timeout": header + "r = requests.get('https://example.com')\n",
|
|
237
|
+
"hardcoded-model": header + "a = 'claude-opus-4'\nb = 'claude-opus-4'\n",
|
|
238
|
+
}
|
|
239
|
+
failures = 0
|
|
240
|
+
for kind, body in cases.items():
|
|
241
|
+
FINDINGS.clear()
|
|
242
|
+
with tempfile.TemporaryDirectory() as d:
|
|
243
|
+
p = os.path.join(d, "agent.py")
|
|
244
|
+
with open(p, "w", encoding="utf-8") as fh:
|
|
245
|
+
fh.write(body)
|
|
246
|
+
assert agentish(body), f"PLANT DID NOT LAND: fixture for {kind} is not agent-related"
|
|
247
|
+
scan(d)
|
|
248
|
+
got = {f["check"] for f in FINDINGS}
|
|
249
|
+
if kind in got:
|
|
250
|
+
print(f" OK {kind}: detected")
|
|
251
|
+
else:
|
|
252
|
+
print(f" FAIL {kind}: NOT detected (found {sorted(got) or 'nothing'})")
|
|
253
|
+
failures += 1
|
|
254
|
+
# and a clean file must produce nothing, or every finding above is noise
|
|
255
|
+
FINDINGS.clear()
|
|
256
|
+
with tempfile.TemporaryDirectory() as d:
|
|
257
|
+
with open(os.path.join(d, "agent.py"), "w", encoding="utf-8") as fh:
|
|
258
|
+
fh.write(header + "for _ in range(10):\n pass\n"
|
|
259
|
+
"r = requests.get('https://example.com', timeout=5)\n")
|
|
260
|
+
scan(d)
|
|
261
|
+
if FINDINGS:
|
|
262
|
+
print(f" FAIL clean file produced {len(FINDINGS)} finding(s): "
|
|
263
|
+
f"{[f['check'] for f in FINDINGS]}")
|
|
264
|
+
failures += 1
|
|
265
|
+
else:
|
|
266
|
+
print(" OK clean file: silent")
|
|
267
|
+
print(f"\nself-test: {len(cases) + 1 - failures}/{len(cases) + 1} passed")
|
|
268
|
+
return 1 if failures else 0
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def main(argv):
|
|
272
|
+
ap = argparse.ArgumentParser(description="Mechanical half of an agent-system audit.")
|
|
273
|
+
ap.add_argument("path", nargs="?", default=".")
|
|
274
|
+
ap.add_argument("--json", action="store_true")
|
|
275
|
+
ap.add_argument("--self-test", action="store_true")
|
|
276
|
+
a = ap.parse_args(argv)
|
|
277
|
+
|
|
278
|
+
if a.self_test:
|
|
279
|
+
return self_test()
|
|
280
|
+
|
|
281
|
+
root = os.path.abspath(a.path)
|
|
282
|
+
if not os.path.isdir(root):
|
|
283
|
+
print(f"error: {a.path} is not a directory", file=sys.stderr)
|
|
284
|
+
return 2
|
|
285
|
+
seen, considered = scan(root)
|
|
286
|
+
if a.json:
|
|
287
|
+
print(json.dumps({"root": root, "files_considered": considered,
|
|
288
|
+
"files_agent_related": seen, "findings": FINDINGS,
|
|
289
|
+
"cannot_see": BLIND}, indent=2))
|
|
290
|
+
else:
|
|
291
|
+
print(report_text(root, seen, considered))
|
|
292
|
+
# Findings are smells, not failures: exit 0 so this composes in a pipeline, and let the
|
|
293
|
+
# human decide. A non-zero exit here would turn an audit into a gate it was never
|
|
294
|
+
# calibrated to be.
|
|
295
|
+
return 0
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
if __name__ == "__main__":
|
|
299
|
+
sys.exit(main(sys.argv[1:]))
|