Skip to content

Commit d7f14d2

Browse files
holzerjmclaude
andcommitted
CI + verify harness + agentkit niceties + CONTRIBUTING
#1 GitHub Actions CI (uv sync, pytest, validate all skills, selftests, README lint, deploy-config parse) + live CI badge in the README. #2 tests/verify.sh (the no-model matrix CI runs) + Makefile targets (verify/smoke/evals/validate/cheatsheet/gifs) + tools/lint_readmes.py. #3 agentkit, backward-compatible: ChatResult gains a .tier field; route.cascade() degrades gracefully when the strong tier has no key (keeps the cheap answer, records why) instead of crashing; evals --cases N writes benchmark.partial.json so a limited run can't clobber the committed full benchmark. +4 no-model route unit tests (23 pass). #4 CONTRIBUTING.md — the house conventions as an add-your-own-starter guide. Verified: tests/verify.sh ALL GREEN; model-matchmakah still +1.00 after the route change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent f03b224 commit d7f14d2

12 files changed

Lines changed: 302 additions & 7 deletions

File tree

.github/workflows/ci.yml

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
8+
jobs:
9+
verify:
10+
runs-on: ubuntu-latest
11+
steps:
12+
- uses: actions/checkout@v4
13+
14+
- name: Install uv
15+
run: |
16+
curl -LsSf https://astral.sh/uv/install.sh | sh
17+
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
18+
19+
- name: Sync dependencies
20+
run: uv sync
21+
22+
# Mirrors tests/verify.sh — the no-model matrix. The model-dependent
23+
# checks (each project answering, eval pass-rate deltas) need a local
24+
# model and run on a laptop via `make smoke`, not in CI.
25+
- name: Unit tests
26+
run: uv run pytest tests/ -q
27+
28+
- name: Eval engine selftest
29+
run: uv run python -m agentkit.evals --selftest
30+
31+
- name: Validate every skill
32+
run: |
33+
fail=0
34+
for d in $(find projects -name SKILL.md -exec dirname {} \;); do
35+
echo "validating $d"
36+
uv run agentskills validate "$d" || fail=1
37+
done
38+
exit $fail
39+
40+
- name: Deterministic script selftests
41+
run: |
42+
uv run python projects/everyday/paypah-trail/skills/expense-report/scripts/parse_receipt.py --selftest
43+
uv run python projects/fun/dungeon-mastah/skills/campaign-rules/scripts/roll.py --selftest
44+
45+
- name: README lint
46+
run: uv run python tools/lint_readmes.py
47+
48+
- name: Deploy config parses
49+
run: uv run python -c "import tomllib,pathlib; tomllib.loads(pathlib.Path('projects/chief-of-staff/chief-of-stuff/deploy/zeroclaw/config.toml').read_text())"

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ __pycache__/
66
*.egg-info/
77
dist/
88
.pytest_cache/
9+
**/evals/benchmark.partial.json

CONTRIBUTING.md

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# Contributing — add your own starter
2+
3+
This repo is a **GitHub template** (click *Use this template*) and a set of
4+
worked examples that all follow one shape. Adding your own starter — for Build
5+
Day or after — means copying that shape. Here it is.
6+
7+
## Anatomy of a project
8+
9+
```
10+
projects/<theme>/<name>/
11+
├── agent.py # the entry point + the run() contract
12+
├── skills/<skill>/SKILL.md # one validated skill (+ optional scripts/ references/ assets/)
13+
├── evals/evals.json # the house eval method (with vs without skill)
14+
├── fixtures/ # offline fallback for every networked tool
15+
└── docs/MODEL_SELECTION.md + docs/ADLC.md
16+
```
17+
18+
The gentlest reference is [`fridge-whisperer`](projects/everyday/fridge-whisperer/)
19+
(no network, ~80 lines). Copy it and rename.
20+
21+
## The one contract
22+
23+
Every `agent.py` exposes:
24+
25+
```python
26+
def run(task: str, skill=DEFAULT_SKILL): ... # returns an AgentResult (or anything with .text)
27+
```
28+
29+
`skill=None` is the **without-skill baseline** — the same code path, no second
30+
branch. That's what the eval runner toggles. Every `agent.py` also starts with
31+
the self-locating bootstrap so `python agent.py "..."` works from anywhere:
32+
33+
```python
34+
HERE = Path(__file__).resolve().parent
35+
sys.path.insert(0, str(next(p for p in HERE.parents if (p / "agentkit").is_dir())))
36+
from agentkit import run_agent, tool
37+
```
38+
39+
## The harness (`agentkit/`)
40+
41+
```python
42+
from agentkit import run_agent, tool, chat, load_skill, cascade
43+
44+
@tool # schema comes from the signature + docstring (the docstring is a prompt)
45+
def get_thing(query: str):
46+
"""What it does, and WHEN to use it."""
47+
return f"sentence-shaped result for {query}" # small models parse sentences better than bare values
48+
49+
r = run_agent("the task", tools=[get_thing], skill=DEFAULT_SKILL) # the loop
50+
r.text, r.cost_usd, r.usage, r.tool_calls # everything measured
51+
```
52+
53+
`chat()` for one-shot calls, `cascade()` for cheap-first routing, `run_agent()`
54+
for the tool loop. Provider is one env var (`MODEL_PROVIDER`); see
55+
[docs/SETUP.md](docs/SETUP.md).
56+
57+
## House rules (these earn rubric points)
58+
59+
- **Zero/low-arg tools, sentence-shaped returns.** A 3B local model is the
60+
default target; keep tools simple.
61+
- **The fixture rule.** Every networked tool checks `AGENT_OFFLINE` and falls
62+
back to `fixtures/` on *any* exception, with a one-line notice to stderr.
63+
Evals always run offline so numbers are reproducible.
64+
- **System prompt = role only.** All format/rules live in the SKILL.md, never
65+
in `agent.py`.
66+
- **Skill anatomy:** `name` == directory name; a trigger-rich `description`
67+
(what + when); body sections `## Defaults` / `## Workflow` / `## Gotchas` /
68+
`## Output template`. Validate: `uv run agentskills validate skills/<name>`.
69+
- **Assert outcomes, not rituals.** Eval the brief's *content*, not a magic
70+
phrase the model happens to print.
71+
- **Budget:** ~300 lines of code per project (the flagship gets ~500). Readable
72+
beats clever.
73+
74+
## Before you push
75+
76+
```bash
77+
make verify # the no-model matrix (tests, validate, selftests, lint) — also what CI runs
78+
make smoke # every project answers offline on your local model
79+
make evals # full eval pass-rate deltas (needs a local model; slow)
80+
```
81+
82+
CI runs `make verify` on every push and PR. Keep your skill's eval delta ≥ 0,
83+
fill `docs/MODEL_SELECTION.md` with the numbers you measured, and you've cleared
84+
the bar. Then add a row to the picker table in the top-level [README](README.md).
85+
86+
By contributing you agree your work is MIT-licensed, like the rest of the repo.

Makefile

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
.PHONY: help verify smoke evals validate cheatsheet gifs
2+
.DEFAULT_GOAL := help
3+
4+
help: ## List targets
5+
@grep -E '^[a-z-]+:.*##' $(MAKEFILE_LIST) | sed -E 's/:.*## / — /' | sort
6+
7+
verify: ## No-model verification matrix (what CI runs)
8+
@bash tests/verify.sh
9+
10+
smoke: ## Run every project offline on the local model
11+
@bash tests/smoke.sh
12+
13+
evals: ## Run all project evals (needs local model; slow)
14+
@for d in projects/*/*/; do \
15+
[ -f "$$d/evals/evals.json" ] && echo "── $$d" && uv run python -m agentkit.evals "$$d"; \
16+
done
17+
18+
validate: ## Validate every skill against the agentskills spec
19+
@for d in $$(find projects -name SKILL.md -exec dirname {} \;); do \
20+
uv run agentskills validate "$$d"; \
21+
done
22+
23+
cheatsheet: ## Rebuild docs/cheat-sheet.pdf
24+
@uv run --with reportlab python tools/make_cheatsheet.py
25+
26+
gifs: ## Re-record the README terminal GIFs (needs vhs + ffmpeg)
27+
@vhs tools/dungeon-mastah.tape && vhs tools/chief-of-stuff.tape

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
# AgentDay-Example
66

7+
[![CI](https://github.com/holzerjm/AgentDay-Example/actions/workflows/ci.yml/badge.svg)](https://github.com/holzerjm/AgentDay-Example/actions/workflows/ci.yml)
78
[![License: MIT](https://img.shields.io/badge/license-MIT-3DA639)](LICENSE)
89
![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-3776AB?logo=python&logoColor=white)
910
![TOA Agent Build Day](https://img.shields.io/badge/TOA%20Agent%20Build%20Day-Jun%2027%2C%202026-CC0000)
@@ -111,7 +112,8 @@ before the event; fork them during it.
111112
[Lane 2 playbook (fork BriefingClaw)](docs/LANE2-PLAYBOOK.md) ·
112113
[Ten more project ideas](docs/ideas-appendix.md) ·
113114
[Printable cheat sheet (PDF)](docs/cheat-sheet.pdf) ·
114-
[Templates](templates/)
115+
[Templates](templates/) ·
116+
[Contributing — add your own starter](CONTRIBUTING.md)
115117

116118
Reference architecture for the event:
117119
[BriefingClaw](https://github.com/holzerjm/GACEP-Spring-2026-demo) — the

agentkit/evals.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -204,8 +204,12 @@ def run_evals(
204204
bench["modes"]["with_skill"]["pass_rate"]
205205
- bench["modes"]["without_skill"]["pass_rate"], 2)
206206
}
207-
(project_dir / "evals" / "benchmark.json").write_text(json.dumps(bench, indent=2), encoding="utf-8")
208-
print(f"\nwrote {project_dir / 'evals' / 'benchmark.json'}")
207+
# A limited run (--cases N) must not clobber the committed full benchmark.
208+
partial = limit is not None and limit < len(spec["cases"])
209+
bench["partial"] = partial
210+
out = project_dir / "evals" / ("benchmark.partial.json" if partial else "benchmark.json")
211+
out.write_text(json.dumps(bench, indent=2), encoding="utf-8")
212+
print(f"\nwrote {out}" + (" (partial — committed benchmark.json left intact)" if partial else ""))
209213
for mode, s in bench["modes"].items():
210214
print(f" {mode:>14}: {s['passed']}/{s['total']} pass (${s['cost_usd']}, avg {s['latency_s_avg']}s)")
211215
if "delta" in bench:

agentkit/llm.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ class ChatResult(NamedTuple):
8989
usage: dict # {"prompt_tokens": int, "completion_tokens": int}
9090
latency_s: float
9191
cost_usd: float
92+
tier: str = "default" # which tier served this call — build routing tables for free
9293

9394

9495
def _load_dotenv() -> None:
@@ -186,6 +187,7 @@ def chat_raw(
186187
usage=usage,
187188
latency_s=latency,
188189
cost_usd=estimate_cost(handle.model, usage),
190+
tier=handle.tier,
189191
)
190192

191193

agentkit/route.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,18 @@ def cascade(
5858
first = llm.chat(task, system=system, provider=cheap[0], tier=cheap[1])
5959
confidence, grade = self_grade(task, first.text, provider=cheap[0])
6060
attempts = [first, grade]
61-
escalated = confidence < threshold
62-
if escalated:
63-
final = llm.chat(task, system=system, provider=strong[0], tier=strong[1])
64-
attempts.append(final)
61+
escalated, note = False, "confidence above threshold — stayed cheap"
62+
if confidence < threshold:
63+
try:
64+
final = llm.chat(task, system=system, provider=strong[0], tier=strong[1])
65+
attempts.append(final)
66+
escalated, note = True, "escalated to strong tier"
67+
except Exception as e: # noqa: BLE001
68+
# Graceful degradation: a missing strong-tier key (or an
69+
# unreachable endpoint) keeps the cheap answer instead of
70+
# crashing the run — the table_row note records what happened.
71+
note = (f"wanted to escalate ({confidence:.2f} < {threshold}) but "
72+
f"strong tier unavailable: {type(e).__name__}")
6573
text = attempts[-1].text if escalated else first.text
6674
cost = round(sum(a.cost_usd for a in attempts), 6)
6775
return RouteResult(
@@ -76,5 +84,6 @@ def cascade(
7684
"confidence": round(confidence, 2),
7785
"cost_usd": cost,
7886
"latency_s": round(sum(a.latency_s for a in attempts), 2),
87+
"note": note,
7988
},
8089
)

tests/smoke.sh

100644100755
File mode changed.

tests/test_agentkit.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,3 +105,49 @@ def test_script_assertion(tmp_path):
105105
(tmp_path / "ck.py").write_text("import sys,json; c=json.loads(sys.argv[1]); sys.exit(0 if 'Report' in sys.stdin.read() and c['id']=='t' else 1)\n")
106106
got = check(OUT, {"type": "script", "path": "ck.py"}, project_dir=tmp_path, case={"id": "t"})
107107
assert got.ok, got.evidence
108+
109+
110+
# ── routing (no live model — monkeypatch llm.chat) ─────────────────────────
111+
112+
from agentkit import ChatResult, route # noqa: E402
113+
114+
115+
def _cr(text):
116+
return ChatResult(text=text, model="stub", provider="local",
117+
usage={"prompt_tokens": 1, "completion_tokens": 1}, latency_s=0.0, cost_usd=0.0)
118+
119+
120+
def test_chatresult_has_tier_default():
121+
assert _cr("x").tier == "default"
122+
123+
124+
def test_cascade_stays_cheap_when_confident(monkeypatch):
125+
seq = iter([_cr("the cheap answer"), _cr('{"confidence": 0.95}')])
126+
monkeypatch.setattr(route.llm, "chat", lambda *a, **k: next(seq))
127+
r = route.cascade("2+2?", threshold=0.7)
128+
assert not r.escalated and r.text == "the cheap answer" and r.confidence >= 0.7
129+
130+
131+
def test_cascade_escalates_when_unsure(monkeypatch):
132+
seq = iter([_cr("weak"), _cr('{"confidence": 0.2}'), _cr("the strong answer")])
133+
monkeypatch.setattr(route.llm, "chat", lambda *a, **k: next(seq))
134+
r = route.cascade("hard", threshold=0.7)
135+
assert r.escalated and r.text == "the strong answer"
136+
137+
138+
def test_cascade_degrades_gracefully_without_strong_key(monkeypatch):
139+
calls = {"n": 0}
140+
141+
def fake(*a, **k):
142+
calls["n"] += 1
143+
if calls["n"] == 1:
144+
return _cr("local answer")
145+
if calls["n"] == 2:
146+
return _cr('{"confidence": 0.1}')
147+
raise RuntimeError("ANTHROPIC_API_KEY is not set") # no strong-tier key
148+
149+
monkeypatch.setattr(route.llm, "chat", fake)
150+
r = route.cascade("hard", threshold=0.7)
151+
assert not r.escalated # never crashes
152+
assert r.text == "local answer" # keeps the cheap answer
153+
assert "unavailable" in r.table_row["note"].lower()

0 commit comments

Comments
 (0)