Skip to content

Commit 677993c

Browse files
committed
feat(agent-audit): add Hermes Agent + unified breakdown table
Add hermes to the audit client set. Capture via HERMES_HOME-isolated temp config pointing at the OpenAI loopback; one-shot with `hermes chat -q "hi"`. Patch openai-loopback to return non-streaming JSON when the request omits stream:true (hermes default). Replace per-client categorize.mjs tables with a single unified table (one column per agent, canonical row labels) plus a top-5 discrepancies section. Implemented in lib/breakdown-unified.mjs.
1 parent 4a929a4 commit 677993c

4 files changed

Lines changed: 813 additions & 172 deletions

File tree

skills/agent-audit/SKILL.md

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
name: agent-audit
3-
description: Capture each installed coding-agent client's fresh-chat prompt overhead (Claude Code, OpenCode, Codex, pi, Cursor) and report system-only plus first-turn request sizes in characters and approximate tokens. Use when asked to audit, measure, or compare agent prompt overhead.
3+
description: Capture each installed coding-agent client's fresh-chat prompt overhead (Claude Code, OpenCode, Codex, pi, Hermes Agent, Cursor) and report system-only plus first-turn request sizes in characters and approximate tokens. Use when asked to audit, measure, or compare agent prompt overhead.
44
---
55

66
## Purpose
@@ -15,7 +15,7 @@ From the skill directory, or wherever the dir is mounted:
1515
./audit.sh # All detected clients, summary table to stdout
1616
./audit.sh --raw # Also write captured prompts under $AGENT_AUDIT_OUT_DIR
1717
./audit.sh --breakdown # Also print per-category composition tables (base/tools/MCP/skills/memory)
18-
./audit.sh claude codex # Only the named clients (claude, opencode, codex, pi, cursor)
18+
./audit.sh claude codex # Only the named clients (claude, opencode, codex, pi, hermes, cursor)
1919
```
2020

2121
Defaults:
@@ -26,14 +26,20 @@ Defaults:
2626

2727
## Per-category breakdown (`--breakdown`)
2828

29-
The summary table reports both system-only size (`SYS_*`) and fuller first-turn request overhead (`FIRST_*`; see "What the size figures cover"). `--breakdown` adds a second view: how each prompt divides into named categories — the same "where do the bytes go" decomposition Cursor and Claude Desktop surface in their UIs (base prompt vs tool defs vs MCP vs skills vs memory). Implemented in `lib/categorize.mjs`, which re-reads the raw capture audit.sh already wrote into the out dir.
29+
The summary table reports both system-only size (`SYS_*`) and fuller first-turn request overhead (`FIRST_*`; see "What the size figures cover"). `--breakdown` adds two further views rendered by `lib/breakdown-unified.mjs`:
3030

31-
| Client | Source read | Categories surfaced |
31+
1. **Unified table** — one column per agent, mapping per-client raw category labels to a shared canonical set so rows align across agents.
32+
2. **Top-5 discrepancies** — the five canonical categories (or the overall total) with the largest absolute spread across agents, with per-agent values and a plain-English note.
33+
34+
Per-client raw categories and source files:
35+
36+
| Client | Source read | Raw categories surfaced |
3237
|----------|--------------------------|-------------------------------------------------------------------------------------|
3338
| Claude | `claude-request.json` | base prompt (system), built-in tools, MCP tool definitions, memory (CLAUDE.md chain), skills catalogue, MCP server instructions, reminders & user msg |
3439
| OpenCode | `opencode-prompt.json` + `opencode-request.json` | base prompt, environment (`<env>`), memory (AGENTS.md), skills (`<available_skills>`), context-window protection, provider tool schemas |
3540
| Codex | `codex-prompt-input.json` + `codex-request.json` | wire base instructions, permissions instructions, skills (`<skills_instructions>`), provider tool schemas |
3641
| pi | `pi-system.txt` + `pi-request.json` | base prompt, tools list, guidelines & pi docs, environment, provider tool schemas |
42+
| Hermes | `hermes-request.json` | base prompt (preamble), skills (`<available_skills>`), memory (project context injected from cwd), provider tool schemas |
3743

3844
The `FIRST_*` columns now use captured or locally-rendered first-turn request material consistently: prompt text plus provider tool/function schemas. `SYS_*` preserves the narrower local system/developer text for debugging client prompt changes. Categorization is marker-driven (XML tags, section headers); if a client reorders or renames a section, the affected category falls back into "base prompt" rather than erroring — watch for an implausibly large base bucket after a CLI upgrade.
3945

@@ -47,9 +53,10 @@ Each client requires a different trick because none expose "dump my system promp
4753
| OpenCode | Throwaway TS plugin for system text + OpenAI-compatible loopback provider for the actual build-agent request | `lib/opencode-plugin.ts`, `lib/openai-loopback.mjs` |
4854
| Codex | Built-in `codex debug prompt-input` for system text + temporary `CODEX_HOME` custom provider pointed at loopback | `lib/openai-loopback.mjs` |
4955
| pi | Node ESM importing `buildSystemPrompt` and tool definitions from pi's `dist/core/` | `lib/pi-dump.mjs`, `lib/pi-request-dump.mjs` |
56+
| Hermes | Temporary `HERMES_HOME` dir with `config.yaml` pointing at loopback; one-shot via `hermes chat -q "hi"`; OpenAI chat/completions format (non-streaming) | `lib/openai-loopback.mjs` |
5057
| Cursor | Detect install only; no CLI ships on macOS today | none |
5158

52-
The `--breakdown` view is a post-processing pass over those captures, not a separate capture: `lib/categorize.mjs` re-reads each client's raw JSON/text from the out dir.
59+
The `--breakdown` view is a post-processing pass over those captures, not a separate capture: `lib/breakdown-unified.mjs` re-reads each client's raw JSON from the out dir.
5360

5461
## What the size figures cover
5562

@@ -60,6 +67,7 @@ The `--breakdown` view is a post-processing pass over those captures, not a sepa
6067
- Codex's `FIRST_*` comes from a temporary custom provider in `CODEX_HOME` pointed at the loopback server. Its `SYS_*` still uses `codex debug prompt-input` for the local developer payload.
6168
- pi has no provider loopback in the audit yet; `FIRST_*` is rendered locally from pi's system prompt plus the active tool definitions, so it is comparable for prompt/tool overhead but not a wire capture.
6269
- pi walks ancestor directories for `AGENTS.md`/`CLAUDE.md` so its size scales with cwd. The throwaway cwd gives the baseline; rerun from a real project root to compare.
70+
- Hermes: uses `HERMES_HOME` env var to isolate to a temp dir so the real `~/.hermes/config.yaml` is never touched. Hermes injects AGENTS.md/CLAUDE.md from the cwd as "Project Context" — the throwaway cwd gives the baseline (no project context). The version is read from Python package metadata (`importlib.metadata`). Hermes sends non-streaming OpenAI chat/completions requests; the loopback detects `stream: undefined` and returns a plain JSON response (not SSE).
6371

6472
## Caveats
6573

skills/agent-audit/audit.sh

Lines changed: 95 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ KEEP_RAW=0
2424

2525
BREAKDOWN=0
2626

27-
ALL_CLIENTS=(claude opencode codex pi cursor)
27+
ALL_CLIENTS=(claude opencode codex pi hermes cursor)
2828
SELECTED=()
2929

3030
for arg in "$@"; do
@@ -35,7 +35,7 @@ for arg in "$@"; do
3535
sed -n '2,13p' "$0"
3636
exit 0
3737
;;
38-
claude | opencode | codex | pi | cursor) SELECTED+=("$arg") ;;
38+
claude | opencode | codex | pi | hermes | cursor) SELECTED+=("$arg") ;;
3939
*)
4040
echo "agent-audit: unknown argument: $arg" >&2
4141
exit 2
@@ -191,11 +191,11 @@ audit_claude() {
191191

192192
# Invoke claude with the loopback as the API endpoint. -p forces one-shot mode.
193193
# `|| true` because the dummy response may make claude exit non-zero on some versions.
194-
(cd "$cwd" && \
194+
(cd "$cwd" &&
195195
ANTHROPIC_BASE_URL="http://127.0.0.1:$port" \
196-
ANTHROPIC_AUTH_TOKEN="audit-dummy" \
197-
ANTHROPIC_API_KEY="audit-dummy" \
198-
mise_x claude -p "hi" --output-format json >"$OUT_DIR/claude-stdout.json" 2>"$OUT_DIR/claude-stderr.log") || true
196+
ANTHROPIC_AUTH_TOKEN="audit-dummy" \
197+
ANTHROPIC_API_KEY="audit-dummy" \
198+
mise_x claude -p "hi" --output-format json >"$OUT_DIR/claude-stdout.json" 2>"$OUT_DIR/claude-stderr.log") || true
199199

200200
# Loopback should have exited after capturing. Give it a moment.
201201
wait "$server_pid" 2>/dev/null || true
@@ -265,11 +265,11 @@ audit_opencode() {
265265
config=$(printf '{"$schema":"https://opencode.ai/config.json","model":"%s","plugin":["%s"],"provider":{"audit":{"npm":"@ai-sdk/openai-compatible","name":"Audit Loopback","options":{"baseURL":"http://127.0.0.1:%s/v1","apiKey":"audit-dummy"},"models":{"audit-model":{"name":"Audit Model"}}}}}' "$model" "$plugin_url" "$port")
266266

267267
(
268-
cd "$cwd" && \
268+
cd "$cwd" &&
269269
OC_AUDIT_OUT="$system_capture" \
270-
OC_AUDIT_NO_EXIT=1 \
271-
OPENCODE_CONFIG_CONTENT="$config" \
272-
opencode run --model "$model" "hi" >"$OUT_DIR/opencode-stdout.log" 2>"$OUT_DIR/opencode-stderr.log"
270+
OC_AUDIT_NO_EXIT=1 \
271+
OPENCODE_CONFIG_CONTENT="$config" \
272+
opencode run --model "$model" "hi" >"$OUT_DIR/opencode-stdout.log" 2>"$OUT_DIR/opencode-stderr.log"
273273
) || true &
274274
local opencode_pid=$!
275275
for _ in $(seq 1 200); do
@@ -378,10 +378,10 @@ request_max_retries = 0
378378
stream_max_retries = 0
379379
EOF
380380

381-
(cd "$cwd" && \
381+
(cd "$cwd" &&
382382
CODEX_HOME="$codex_home" \
383-
AGENT_AUDIT_DUMMY_KEY="audit-dummy" \
384-
mise_x codex exec --skip-git-repo-check --ephemeral --model audit-model "hi" >"$OUT_DIR/codex-stdout.log" 2>"$OUT_DIR/codex-stderr.log") || true
383+
AGENT_AUDIT_DUMMY_KEY="audit-dummy" \
384+
mise_x codex exec --skip-git-repo-check --ephemeral --model audit-model "hi" >"$OUT_DIR/codex-stdout.log" 2>"$OUT_DIR/codex-stderr.log") || true
385385
wait "$server_pid" 2>/dev/null || true
386386

387387
if [ ! -s "$request_capture" ]; then
@@ -434,6 +434,82 @@ audit_pi() {
434434
emit pi "$version" "$system_chars" "$system_tokens" "$first_turn_chars" "$first_turn_tokens" "rendered system + tool request locally"
435435
}
436436

437+
# ---------- Hermes ----------
438+
audit_hermes() {
439+
local cmd=""
440+
if have hermes; then
441+
cmd="hermes"
442+
elif have hermes-agent; then
443+
cmd="hermes-agent"
444+
else
445+
emit hermes "not installed" 0 0 0 0 "no hermes on PATH"
446+
return
447+
fi
448+
if ! have node; then
449+
emit hermes "?" 0 0 0 0 "node required for loopback"
450+
return
451+
fi
452+
453+
local version
454+
version="$(python3 -c "import importlib.metadata; print(importlib.metadata.version('hermes-agent'))" 2>/dev/null ||
455+
ls -d /opt/homebrew/Cellar/hermes-agent/*/ 2>/dev/null | head -1 | xargs basename ||
456+
echo "unknown")"
457+
[ -z "$version" ] && version="unknown"
458+
459+
local hermes_home cwd capture port_file log_file server_pid port
460+
hermes_home="$OUT_DIR/hermes-home"
461+
mkdir -p "$hermes_home"
462+
cwd="$(throwaway_cwd hermes)"
463+
capture="$OUT_DIR/hermes-request.json"
464+
port_file="$OUT_DIR/hermes.port"
465+
log_file="$OUT_DIR/hermes-loopback.log"
466+
467+
server_pid="$(start_openai_loopback "$capture" "$port_file" "$log_file")"
468+
if ! port="$(wait_for_port "$port_file" "$server_pid")"; then
469+
emit hermes "$version" 0 0 0 0 "loopback failed to bind"
470+
return
471+
fi
472+
473+
# Minimal config: redirect to loopback, disable retries, cap at 1 turn
474+
cat >"$hermes_home/config.yaml" <<EOF
475+
model:
476+
provider: custom
477+
default: audit-model
478+
base_url: http://127.0.0.1:$port/v1
479+
api_key: audit-dummy
480+
agent:
481+
max_turns: 1
482+
api_max_retries: 0
483+
EOF
484+
485+
(cd "$cwd" &&
486+
HERMES_HOME="$hermes_home" \
487+
"$cmd" chat -q "hi" >"$OUT_DIR/hermes-stdout.log" 2>"$OUT_DIR/hermes-stderr.log") || true
488+
wait "$server_pid" 2>/dev/null || true
489+
490+
if [ ! -s "$capture" ]; then
491+
emit hermes "$version" 0 0 0 0 "provider request not captured; check hermes-stderr.log"
492+
return
493+
fi
494+
495+
# Extract the system message text for SYS_* columns
496+
local prompt_file="$OUT_DIR/hermes-system.txt"
497+
node -e '
498+
const j = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
499+
const sys = (j.messages || []).find(m => m.role === "system");
500+
const text = typeof sys?.content === "string" ? sys.content
501+
: (sys?.content || []).map(c => c.text || "").join("");
502+
process.stdout.write(text);
503+
' "$capture" >"$prompt_file" 2>/dev/null || true
504+
505+
local system_chars system_tokens first_turn_chars first_turn_tokens
506+
system_chars=$(count_chars <"$prompt_file")
507+
system_tokens=$(estimate_tokens "$system_chars")
508+
first_turn_chars=$(openai_first_turn_chars "$capture")
509+
first_turn_tokens=$(estimate_tokens "$first_turn_chars")
510+
emit hermes "$version" "$system_chars" "$system_tokens" "$first_turn_chars" "$first_turn_tokens" "captured via loopback"
511+
}
512+
437513
# ---------- Cursor ----------
438514
audit_cursor() {
439515
# Cursor has no headless CLI mode and the app may not be installed.
@@ -467,12 +543,15 @@ if [ "$BREAKDOWN" -eq 1 ]; then
467543
if ! have node; then
468544
printf '\n(breakdown skipped: node not on PATH)\n'
469545
else
470-
printf '\nPer-category composition (where the bytes go):\n'
546+
# Collect only clients that captured data, then render unified table + top-5.
547+
CAPTURED_CLIENTS=()
471548
while IFS=$'\t' read -r client _version _system_chars _system_tokens first_turn_chars _first_turn_tokens _note; do
472-
# Skip clients that never captured anything (e.g. cursor, not installed).
473549
[ "$first_turn_chars" = "0" ] && continue
474-
node "$LIB_DIR/categorize.mjs" "$client" "$OUT_DIR" || true
550+
CAPTURED_CLIENTS+=("$client")
475551
done <"$RESULTS_FILE"
552+
if [ ${#CAPTURED_CLIENTS[@]} -gt 0 ]; then
553+
node "$LIB_DIR/breakdown-unified.mjs" "$OUT_DIR" "${CAPTURED_CLIENTS[@]}" || true
554+
fi
476555
fi
477556
fi
478557

0 commit comments

Comments
 (0)