forked from WHC/Hermit
Usability pass: tool registry, TUI polish, saved keys and settings
Tools - Table-driven tool registry with permission classes and default/minimal profiles; new tools: edit_file, list_dir, glob, grep, delete_file, move_file, todo_write, web_fetch, ask_user (plus read_file paging). - Built-in Myers diff; write tools return unified diffs. - Persistent permission rules (.agent/permissions.json), per-turn undo snapshots (.agent/undo), AGENTS.md project notes, auto-compact at 80% of the model context, cancellable run_shell with process-group kill. TUI - Table-driven slash commands: /provider /key /effort /status /tools /permissions /yolo /undo /retry /diff /export /copy /rename /delete /init /max-steps /todo, with / and @ completion. - Permission modal with diff and prefix rules, ask_user modal, todo panel, header with model/effort/context, bracketed paste, line editing, wide-char cursor, NO_COLOR and ASCII fallback; Esc never quits. Startup and configuration - API keys are entered in the TUI (hidden) and saved per provider in <config>/credentials (0600); env variables are only a fallback. - Last provider, per-provider model and reasoning effort are remembered in <config>/settings.json; --effort/-e and /effort send reasoning_effort (low|medium|high|max) to models that accept it. - DeepSeek preset uses deepseek-flash / deepseek-v4-pro. - Session flags --new/--continue/--resume/--list-sessions, --yolo, --confirm. Compatibility and tests - ncurses detection, macOS/Homebrew support, make install, XDG config dir, POSIX cleanups; Gitea CI (gcc/clang/asan/musl/macOS). - Unit tests for diff, fs tools, registry, undo/perms and config; e2e fixtures multi_file_refactor and ask_or_todo; vendored cJSON.
This commit is contained in:
+17
-11
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# One-shot: clone (optional) + build CST-Agent on Linux.
|
||||
# One-shot: clone (optional) + build CST-Agent on Linux, WSL or macOS.
|
||||
set -euo pipefail
|
||||
|
||||
REPO_URL="${CST_AGENT_REPO:-https://git.whcself.xyz/WHC/CST-agent.git}"
|
||||
@@ -19,22 +19,28 @@ if [[ ! -f "$DIR/Makefile" ]]; then
|
||||
fi
|
||||
cd "$DIR"
|
||||
|
||||
echo "==> deps hint: gcc/clang make cmake libcurl ncursesw (zenity optional)"
|
||||
if command -v pacman >/dev/null 2>&1; then
|
||||
echo "==> deps hint: gcc/clang make cmake libcurl ncursesw"
|
||||
if [[ "$(uname -s)" == Darwin ]]; then
|
||||
echo " macOS: xcode-select --install && brew install ncurses curl pkg-config"
|
||||
elif command -v pacman >/dev/null 2>&1; then
|
||||
echo " Arch: sudo pacman -S --needed base-devel cmake curl ncurses"
|
||||
elif command -v apt-get >/dev/null 2>&1; then
|
||||
echo " Debian: sudo apt install build-essential cmake libcurl4-openssl-dev libncurses-dev"
|
||||
echo " Debian/Ubuntu/WSL: sudo apt install build-essential cmake pkg-config libcurl4-openssl-dev libncurses-dev"
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
echo " Fedora: sudo dnf install gcc make cmake pkgconf libcurl-devel ncurses-devel"
|
||||
elif command -v apk >/dev/null 2>&1; then
|
||||
echo " Alpine: sudo apk add build-base pkgconf curl-dev ncurses-dev"
|
||||
elif command -v nix-shell >/dev/null 2>&1; then
|
||||
echo " Nix: nix-shell (uses shell.nix), then rerun this script"
|
||||
fi
|
||||
|
||||
JOBS="$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4)"
|
||||
echo "==> make"
|
||||
make -j"$(nproc 2>/dev/null || echo 4)"
|
||||
make -j"$JOBS"
|
||||
make check || true
|
||||
|
||||
echo
|
||||
echo "OK: ./cst-agent"
|
||||
echo "OpenAI-compatible env (see docs/env_openai.md):"
|
||||
echo " export OPENAI_API_KEY=sk-..."
|
||||
echo " export OPENAI_BASE_URL=https://api.openai.com/v1"
|
||||
echo " export OPENAI_MODEL=gpt-4.1-mini"
|
||||
echo " ./cst-agent --workspace ."
|
||||
echo "Or MiniMax default: export CST_API_KEY=... && ./cst-agent --workspace ."
|
||||
echo "Install: make install PREFIX=\$HOME/.local"
|
||||
echo "Run it inside a project directory: cst-agent"
|
||||
echo " (the first start asks for the provider and its API key and saves them)"
|
||||
|
||||
Executable
+148
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env bash
|
||||
# One end-to-end run: copy fixtures/<name> to a temp workspace, run cst-agent
|
||||
# headless on its TASK.md, then judge with the fixture's check.sh.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/e2e_fixture.sh <fixture> [--protocol P] [--provider NAME]
|
||||
# [--model M] [--max-steps N] [--tools minimal|default] [--out DIR]
|
||||
# [--record] [--baseline-only]
|
||||
#
|
||||
# --tools defaults to minimal (the four tools the paper experiments used).
|
||||
#
|
||||
# Artifacts go to <out>/<fixture>__<protocol>__<model>__<stamp>/:
|
||||
# result.json agent.log session.jsonl metrics.jsonl check.log [sse/]
|
||||
#
|
||||
# Environment:
|
||||
# CST_AGENT_BIN agent binary (default: <repo>/cst-agent, built if missing)
|
||||
# API key CST_API_KEY, or the provider variable (DEEPSEEK_API_KEY,
|
||||
# OPENAI_API_KEY, MINIMAX_API_KEY), or the credentials file
|
||||
# E2E_TIMEOUT seconds before the agent is killed (default 900; needs
|
||||
# `timeout` or `gtimeout` from coreutils)
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
FIXTURE=""
|
||||
PROTOCOL="${CST_PROTOCOL:-native-eos}"
|
||||
PROVIDER="${CST_PROVIDER:-}"
|
||||
MODEL="${CST_MODEL:-}"
|
||||
MAX_STEPS=""
|
||||
TOOLS="${CST_E2E_TOOLS:-minimal}"
|
||||
OUT="$ROOT/out/e2e"
|
||||
RECORD=0
|
||||
BASELINE_ONLY=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--protocol) PROTOCOL="$2"; shift 2 ;;
|
||||
--provider) PROVIDER="$2"; shift 2 ;;
|
||||
--model) MODEL="$2"; shift 2 ;;
|
||||
--max-steps) MAX_STEPS="$2"; shift 2 ;;
|
||||
--tools) TOOLS="$2"; shift 2 ;;
|
||||
--out) OUT="$2"; shift 2 ;;
|
||||
--record) RECORD=1; shift ;;
|
||||
--baseline-only) BASELINE_ONLY=1; shift ;;
|
||||
-h|--help) sed -n '2,21p' "$0"; exit 0 ;;
|
||||
-*) echo "unknown option: $1" >&2; exit 64 ;;
|
||||
*) FIXTURE="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
FIX="$ROOT/fixtures/$FIXTURE"
|
||||
if [[ -z "$FIXTURE" || ! -f "$FIX/TASK.md" || ! -x "$FIX/check.sh" ]]; then
|
||||
echo "usage: $0 <fixture> [options] (fixture needs TASK.md + check.sh)" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
BIN="${CST_AGENT_BIN:-$ROOT/cst-agent}"
|
||||
if [[ ! -x "$BIN" ]]; then
|
||||
make -s -C "$ROOT" cst-agent >/dev/null || { echo "build failed" >&2; exit 65; }
|
||||
fi
|
||||
|
||||
STAMP="$(date -u +%Y%m%dT%H%M%SZ)-$$"
|
||||
MODEL_TAG="${MODEL:-default}"
|
||||
RUN="$OUT/${FIXTURE}__${PROTOCOL}__${MODEL_TAG//\//_}__${STAMP}"
|
||||
mkdir -p "$RUN"
|
||||
WORK="$(mktemp -d /tmp/cst-e2e.XXXXXX)"
|
||||
SCRATCH="$(mktemp -d /tmp/cst-e2e-base.XXXXXX)"
|
||||
trap 'rm -rf "$WORK" "$SCRATCH"' EXIT
|
||||
|
||||
copy_fixture() {
|
||||
cp -a "$FIX/." "$1/"
|
||||
rm -rf "$1/out" "$1/.agent" "$1/.baseline_green_ok"
|
||||
}
|
||||
|
||||
json_str() { python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$1"; }
|
||||
|
||||
write_result() {
|
||||
local outcome="$1" agent_rc="$2" check_rc="$3" wall="$4"
|
||||
cat >"$RUN/result.json" <<EOF
|
||||
{"fixture": $(json_str "$FIXTURE"), "protocol": $(json_str "$PROTOCOL"),
|
||||
"provider": $(json_str "${PROVIDER:-default}"), "model": $(json_str "$MODEL_TAG"),
|
||||
"tools": $(json_str "$TOOLS"),
|
||||
"run": $(json_str "$(basename "$RUN")"), "baseline_rc": $BASE_RC,
|
||||
"agent_rc": $agent_rc, "check_rc": $check_rc, "wall_s": $wall,
|
||||
"outcome": $(json_str "$outcome")}
|
||||
EOF
|
||||
echo "outcome=$outcome agent_rc=$agent_rc check_rc=$check_rc wall_s=$wall -> $RUN"
|
||||
}
|
||||
|
||||
# Baseline in a throwaway copy: the task must not already be done.
|
||||
copy_fixture "$SCRATCH"
|
||||
"$SCRATCH/check.sh" >"$RUN/baseline.log" 2>&1
|
||||
BASE_RC=$?
|
||||
if [[ "$BASE_RC" -eq 0 && ! -e "$FIX/.baseline_green_ok" ]]; then
|
||||
write_result unexpected_green_fixture 0 0 0
|
||||
exit 2
|
||||
fi
|
||||
if [[ "$BASELINE_ONLY" -eq 1 ]]; then
|
||||
write_result baseline_only 0 "$BASE_RC" 0
|
||||
exit 0
|
||||
fi
|
||||
|
||||
copy_fixture "$WORK"
|
||||
args=(--headless --workspace "$WORK" --session "$WORK/.agent/e2e.jsonl"
|
||||
--protocol "$PROTOCOL" --tools "$TOOLS")
|
||||
[[ -n "$PROVIDER" ]] && args+=(--provider "$PROVIDER")
|
||||
[[ -n "$MODEL" ]] && args+=(--model "$MODEL")
|
||||
[[ -n "$MAX_STEPS" ]] && args+=(--max-steps "$MAX_STEPS")
|
||||
mkdir -p "$WORK/.agent"
|
||||
|
||||
export CST_AUTO_APPROVE=1
|
||||
export CST_METRICS_PATH="$RUN/metrics.jsonl"
|
||||
if [[ "$RECORD" -eq 1 ]]; then
|
||||
export CST_RECORD_SSE="$RUN/sse"
|
||||
fi
|
||||
|
||||
now() { python3 -c 'import time; print(time.time())'; }
|
||||
with_timeout() {
|
||||
if command -v timeout >/dev/null 2>&1; then
|
||||
timeout "${E2E_TIMEOUT:-900}" "$@"
|
||||
elif command -v gtimeout >/dev/null 2>&1; then
|
||||
gtimeout "${E2E_TIMEOUT:-900}" "$@"
|
||||
else
|
||||
"$@"
|
||||
fi
|
||||
}
|
||||
|
||||
t0=$(now)
|
||||
with_timeout "$BIN" "${args[@]}" --prompt "$(cat "$FIX/TASK.md")" \
|
||||
>"$RUN/agent.log" 2>&1
|
||||
AGENT_RC=$?
|
||||
t1=$(now)
|
||||
WALL=$(python3 -c "print(round($t1-$t0,2))")
|
||||
cp -f "$WORK/.agent/e2e.jsonl" "$RUN/session.jsonl" 2>/dev/null || true
|
||||
|
||||
(cd "$WORK" && ./check.sh) >"$RUN/check.log" 2>&1
|
||||
CHECK_RC=$?
|
||||
|
||||
if [[ "$CHECK_RC" -eq 0 ]]; then
|
||||
OUTCOME=pass
|
||||
elif [[ "$AGENT_RC" -eq 124 ]]; then
|
||||
OUTCOME=timeout
|
||||
elif [[ "$AGENT_RC" -ne 0 ]]; then
|
||||
OUTCOME=agent_error
|
||||
else
|
||||
OUTCOME=fail
|
||||
fi
|
||||
write_result "$OUTCOME" "$AGENT_RC" "$CHECK_RC" "$WALL"
|
||||
[[ "$OUTCOME" == pass ]]
|
||||
+11
-110
@@ -1,114 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
# E2E: copy mini_calc → temp workspace → optional cst-agent headless → make test.
|
||||
# Usage:
|
||||
# ./scripts/e2e_mini_calc.sh # run agent if credentials exist
|
||||
# ./scripts/e2e_mini_calc.sh --baseline-only # only prove fixture starts RED then skip agent
|
||||
# SKIP_AGENT=1 ./scripts/e2e_mini_calc.sh
|
||||
# E2E on fixtures/mini_calc (kept for compatibility; see e2e_fixture.sh).
|
||||
# ./scripts/e2e_mini_calc.sh [--protocol P] [--provider NAME] [--model M]
|
||||
# ./scripts/e2e_mini_calc.sh --baseline-only # only prove the fixture is RED
|
||||
# SKIP_AGENT=1 ./scripts/e2e_mini_calc.sh # same as --baseline-only
|
||||
# API key: CST_API_KEY / DEEPSEEK_API_KEY / OPENAI_API_KEY / MINIMAX_API_KEY
|
||||
# or ~/.config/cst-agent/credentials.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
FIX="$ROOT/fixtures/mini_calc"
|
||||
OUT="$FIX/out"
|
||||
BASELINE_ONLY=0
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
--baseline-only) BASELINE_ONLY=1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
mkdir -p "$OUT"
|
||||
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
WORKDIR="$(mktemp -d /tmp/cst-mini-calc.XXXXXX)"
|
||||
RESULT="$OUT/result-$STAMP.txt"
|
||||
SESSION_COPY="$OUT/session-$STAMP.jsonl"
|
||||
|
||||
cleanup() { rm -rf "$WORKDIR"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "== mini_calc e2e ==" | tee "$RESULT"
|
||||
echo "workdir=$WORKDIR" | tee -a "$RESULT"
|
||||
echo "stamp=$STAMP" | tee -a "$RESULT"
|
||||
|
||||
# Fresh copy (no .agent from previous runs)
|
||||
cp -a "$FIX/." "$WORKDIR/"
|
||||
rm -rf "$WORKDIR/out" "$WORKDIR/.agent"
|
||||
chmod +x "$WORKDIR/tests/smoke.sh"
|
||||
|
||||
cd "$WORKDIR"
|
||||
make clean >/dev/null 2>&1 || true
|
||||
make
|
||||
|
||||
echo "-- baseline make test (expect FAIL before agent) --" | tee -a "$RESULT"
|
||||
set +e
|
||||
make test >"$OUT/baseline-$STAMP.log" 2>&1
|
||||
base_rc=$?
|
||||
set -e
|
||||
echo "baseline_rc=$base_rc" | tee -a "$RESULT"
|
||||
if [[ "$base_rc" -eq 0 ]]; then
|
||||
echo "UNEXPECTED: fixture already passes; bugs may have been fixed in source." | tee -a "$RESULT"
|
||||
echo "outcome=unexpected_green_fixture" | tee -a "$RESULT"
|
||||
exit 2
|
||||
extra=()
|
||||
if [[ "${SKIP_AGENT:-0}" == "1" ]]; then
|
||||
extra+=(--baseline-only)
|
||||
fi
|
||||
echo "baseline: RED (as expected)" | tee -a "$RESULT"
|
||||
|
||||
if [[ "$BASELINE_ONLY" -eq 1 || "${SKIP_AGENT:-0}" == "1" ]]; then
|
||||
echo "outcome=baseline_ok_agent_skipped" | tee -a "$RESULT"
|
||||
echo "SKIP agent step (--baseline-only or SKIP_AGENT=1)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Need API key for agent step
|
||||
has_key=0
|
||||
if [[ -n "${CST_API_KEY:-}" || -n "${MINIMAX_API_KEY:-}" ]]; then
|
||||
has_key=1
|
||||
elif [[ -f "$HOME/.config/cst-agent/credentials" ]]; then
|
||||
has_key=1
|
||||
fi
|
||||
if [[ "$has_key" -eq 0 ]]; then
|
||||
echo "outcome=baseline_ok_no_api_key" | tee -a "$RESULT"
|
||||
echo "No API key; agent step skipped. Set CST_API_KEY or save credentials."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cd "$ROOT"
|
||||
make -s
|
||||
|
||||
mkdir -p "$WORKDIR/.agent"
|
||||
PROMPT="$(cat "$FIX/TASK.md")"
|
||||
export CST_AUTO_APPROVE=1
|
||||
echo "-- cst-agent headless --" | tee -a "$RESULT"
|
||||
set +e
|
||||
"$ROOT/cst-agent" --headless --workspace "$WORKDIR" --session "$WORKDIR/.agent/e2e.jsonl" \
|
||||
--prompt "$PROMPT" >"$OUT/agent-$STAMP.log" 2>&1
|
||||
agent_rc=$?
|
||||
set -e
|
||||
echo "agent_rc=$agent_rc" | tee -a "$RESULT"
|
||||
if [[ -f "$WORKDIR/.agent/e2e.jsonl" ]]; then
|
||||
cp -f "$WORKDIR/.agent/e2e.jsonl" "$SESSION_COPY"
|
||||
echo "session_copy=$SESSION_COPY" | tee -a "$RESULT"
|
||||
fi
|
||||
|
||||
cd "$WORKDIR"
|
||||
set +e
|
||||
make test >"$OUT/after-$STAMP.log" 2>&1
|
||||
test_rc=$?
|
||||
set -e
|
||||
echo "after_test_rc=$test_rc" | tee -a "$RESULT"
|
||||
|
||||
# Classify
|
||||
changed=0
|
||||
if ! diff -q "$FIX/calc.c" "$WORKDIR/calc.c" >/dev/null 2>&1; then
|
||||
changed=1
|
||||
fi
|
||||
echo "calc_c_changed=$changed" | tee -a "$RESULT"
|
||||
|
||||
if [[ "$test_rc" -eq 0 ]]; then
|
||||
echo "outcome=pass" | tee -a "$RESULT"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$changed" -eq 1 ]]; then
|
||||
echo "outcome=partial" | tee -a "$RESULT"
|
||||
exit 1
|
||||
fi
|
||||
echo "outcome=fail" | tee -a "$RESULT"
|
||||
exit 1
|
||||
exec "$ROOT/scripts/e2e_fixture.sh" mini_calc --out "$ROOT/fixtures/mini_calc/out" \
|
||||
"${extra[@]}" "$@"
|
||||
|
||||
Executable
+63
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env bash
|
||||
# Experiment matrix: protocol x model x fixture x N runs, then summarize.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/run_matrix.sh [-p "native-eos native-inc ctl ctl-heredoc xml"]
|
||||
# [-m "deepseek:deepseek-flash openai:gpt-4.1-mini"]
|
||||
# [-f "mini_calc makefile_tab big_file test_fix_test"]
|
||||
# [-n 3] [-o out/matrix-<stamp>] [-r] [-t minimal|default]
|
||||
#
|
||||
# -m provider:model pairs (model may be empty: "deepseek:")
|
||||
# -r also record raw SSE streams per run (for replay experiments)
|
||||
# -t tool profile (default minimal, as in the paper runs)
|
||||
#
|
||||
# Each run is scripts/e2e_fixture.sh; results land in <out>/<run>/ and the
|
||||
# summary in <out>/results.csv and <out>/summary.md.
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
PROTOCOLS="native-eos native-inc ctl ctl-heredoc xml"
|
||||
MODELS="deepseek:deepseek-flash"
|
||||
FIXTURES="mini_calc makefile_tab big_file test_fix_test"
|
||||
N=3
|
||||
OUT="$ROOT/out/matrix-$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
RECORD=()
|
||||
TOOLS=minimal
|
||||
|
||||
while getopts "p:m:f:n:o:rt:h" opt; do
|
||||
case "$opt" in
|
||||
p) PROTOCOLS="$OPTARG" ;;
|
||||
m) MODELS="$OPTARG" ;;
|
||||
f) FIXTURES="$OPTARG" ;;
|
||||
n) N="$OPTARG" ;;
|
||||
o) OUT="$OPTARG" ;;
|
||||
r) RECORD=(--record) ;;
|
||||
t) TOOLS="$OPTARG" ;;
|
||||
h|*) sed -n '2,16p' "$0"; exit 0 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
mkdir -p "$OUT"
|
||||
if [[ -z "${CST_AGENT_BIN:-}" ]]; then
|
||||
make -s -C "$ROOT" cst-agent >/dev/null || { echo "build failed" >&2; exit 65; }
|
||||
fi
|
||||
|
||||
total=0
|
||||
for pm in $MODELS; do
|
||||
for proto in $PROTOCOLS; do
|
||||
for fx in $FIXTURES; do
|
||||
for ((i = 1; i <= N; i++)); do
|
||||
total=$((total + 1))
|
||||
provider="${pm%%:*}"
|
||||
model="${pm#*:}"
|
||||
margs=(--provider "$provider")
|
||||
[[ -n "$model" ]] && margs+=(--model "$model")
|
||||
printf '[%d] %-10s %-12s %-14s #%d ' "$total" "$provider" "$proto" "$fx" "$i"
|
||||
"$ROOT/scripts/e2e_fixture.sh" "$fx" --protocol "$proto" --tools "$TOOLS" "${margs[@]}" \
|
||||
--out "$OUT" "${RECORD[@]}" | sed 's/ -> .*//'
|
||||
done
|
||||
done
|
||||
done
|
||||
done
|
||||
|
||||
python3 "$ROOT/scripts/summarize.py" "$OUT"
|
||||
Executable
+186
@@ -0,0 +1,186 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Summarize e2e runs (scripts/e2e_fixture.sh / run_matrix.sh output).
|
||||
|
||||
Usage: scripts/summarize.py <out-dir> [--md summary.md] [--csv results.csv]
|
||||
|
||||
Every run directory holds result.json and metrics.jsonl. Per run we derive:
|
||||
steps model calls in the turn
|
||||
llm_s sum of stream durations (request start -> stream end)
|
||||
tool_s sum of tool execution time
|
||||
overlap_s tool time that ran while the model was still streaming
|
||||
(eager protocols; 0 for end-of-stream execution)
|
||||
confirm_ms mean call open -> call confirmed (args complete)
|
||||
wait_ms mean call confirmed -> execution start
|
||||
ctl_errors contract errors (model left the CTL/XML grammar)
|
||||
tokens prompt / completion / cached prompt tokens
|
||||
"""
|
||||
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import statistics
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
|
||||
FIELDS = [
|
||||
"run", "fixture", "protocol", "provider", "model", "outcome", "pass",
|
||||
"wall_s", "turn_s", "steps", "calls", "calls_failed", "llm_s", "tool_s",
|
||||
"overlap_s", "confirm_ms", "wait_ms", "ctl_errors", "retries",
|
||||
"prompt_tokens", "completion_tokens", "cached_tokens",
|
||||
]
|
||||
|
||||
|
||||
def load_jsonl(path):
|
||||
rows = []
|
||||
if not os.path.exists(path):
|
||||
return rows
|
||||
with open(path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
rows.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return rows
|
||||
|
||||
|
||||
def analyze(run_dir):
|
||||
with open(os.path.join(run_dir, "result.json"), encoding="utf-8") as f:
|
||||
res = json.load(f)
|
||||
if res.get("outcome") in ("baseline_only", "unexpected_green_fixture"):
|
||||
return None
|
||||
m = load_jsonl(os.path.join(run_dir, "metrics.jsonl"))
|
||||
steps = [r for r in m if r.get("type") == "step"]
|
||||
calls = [r for r in m if r.get("type") == "call"]
|
||||
turns = [r for r in m if r.get("type") == "turn"]
|
||||
end_of = {(s["turn"], s["step"]): s.get("t_stream_end", 0) for s in steps}
|
||||
|
||||
overlap = 0.0
|
||||
confirm, wait = [], []
|
||||
tool_ms = 0.0
|
||||
for c in calls:
|
||||
start, end = c.get("t_exec_start", -1), c.get("t_exec_end", -1)
|
||||
if start >= 0 and end >= start:
|
||||
tool_ms += end - start
|
||||
stream_end = end_of.get((c["turn"], c["step"]))
|
||||
if stream_end is not None and start < stream_end:
|
||||
overlap += min(end, stream_end) - start
|
||||
if c.get("t_call_open", -1) >= 0 and c.get("t_call_ready", -1) >= 0:
|
||||
confirm.append(c["t_call_ready"] - c["t_call_open"])
|
||||
if c.get("t_call_ready", -1) >= 0 and start >= 0:
|
||||
wait.append(start - c["t_call_ready"])
|
||||
|
||||
model = res.get("model", "")
|
||||
if model in ("", "default") and steps:
|
||||
model = steps[0].get("model", model)
|
||||
return {
|
||||
"run": res.get("run", os.path.basename(run_dir)),
|
||||
"fixture": res.get("fixture", ""),
|
||||
"protocol": res.get("protocol", ""),
|
||||
"provider": res.get("provider", ""),
|
||||
"model": model,
|
||||
"outcome": res.get("outcome", ""),
|
||||
"pass": 1 if res.get("outcome") == "pass" else 0,
|
||||
"wall_s": res.get("wall_s", 0),
|
||||
"turn_s": round(sum(t.get("t_total", 0) for t in turns) / 1000, 3),
|
||||
"steps": len(steps),
|
||||
"calls": len(calls),
|
||||
"calls_failed": sum(1 for c in calls if c.get("exit", 0) != 0
|
||||
or c.get("status") != "ok"),
|
||||
"llm_s": round(sum(s.get("t_stream_end", 0) for s in steps) / 1000, 3),
|
||||
"tool_s": round(tool_ms / 1000, 3),
|
||||
"overlap_s": round(overlap / 1000, 3),
|
||||
"confirm_ms": round(statistics.mean(confirm), 1) if confirm else "",
|
||||
"wait_ms": round(statistics.mean(wait), 1) if wait else "",
|
||||
"ctl_errors": sum(1 for s in steps if s.get("parse_error")),
|
||||
"retries": sum(s.get("retries", 0) for s in steps),
|
||||
"prompt_tokens": sum(s.get("prompt_tokens", 0) for s in steps),
|
||||
"completion_tokens": sum(s.get("completion_tokens", 0) for s in steps),
|
||||
"cached_tokens": sum(s.get("cached_tokens", 0) for s in steps),
|
||||
}
|
||||
|
||||
|
||||
def mean(rows, key, nd=2):
|
||||
vals = [r[key] for r in rows if r[key] != ""]
|
||||
if not vals:
|
||||
return "-"
|
||||
v = statistics.mean(vals)
|
||||
return int(round(v)) if nd == 0 else round(v, nd)
|
||||
|
||||
|
||||
def table(rows, keys, title):
|
||||
groups = defaultdict(list)
|
||||
for r in rows:
|
||||
groups[tuple(r[k] for k in keys)].append(r)
|
||||
cols = keys + ["n", "pass%", "steps", "wall_s", "llm_s", "tool_s",
|
||||
"overlap_s", "wait_ms", "ctl_err", "tok_in", "tok_out"]
|
||||
out = [f"### {title}", "", "| " + " | ".join(cols) + " |",
|
||||
"|" + "---|" * len(cols)]
|
||||
for g in sorted(groups):
|
||||
rs = groups[g]
|
||||
n = len(rs)
|
||||
cells = list(g) + [
|
||||
n,
|
||||
round(100.0 * sum(r["pass"] for r in rs) / n),
|
||||
mean(rs, "steps", 1),
|
||||
mean(rs, "wall_s"),
|
||||
mean(rs, "llm_s"),
|
||||
mean(rs, "tool_s"),
|
||||
mean(rs, "overlap_s"),
|
||||
mean(rs, "wait_ms", 0),
|
||||
mean(rs, "ctl_errors", 2),
|
||||
mean(rs, "prompt_tokens", 0),
|
||||
mean(rs, "completion_tokens", 0),
|
||||
]
|
||||
out.append("| " + " | ".join(str(c) for c in cells) + " |")
|
||||
out.append("")
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def main(argv):
|
||||
if len(argv) < 2:
|
||||
print(__doc__)
|
||||
return 64
|
||||
root = argv[1]
|
||||
md_path = os.path.join(root, "summary.md")
|
||||
csv_path = os.path.join(root, "results.csv")
|
||||
if "--md" in argv:
|
||||
md_path = argv[argv.index("--md") + 1]
|
||||
if "--csv" in argv:
|
||||
csv_path = argv[argv.index("--csv") + 1]
|
||||
|
||||
rows = []
|
||||
for name in sorted(os.listdir(root)):
|
||||
d = os.path.join(root, name)
|
||||
if os.path.isfile(os.path.join(d, "result.json")):
|
||||
r = analyze(d)
|
||||
if r:
|
||||
rows.append(r)
|
||||
if not rows:
|
||||
print(f"no runs under {root}")
|
||||
return 1
|
||||
|
||||
with open(csv_path, "w", newline="", encoding="utf-8") as f:
|
||||
w = csv.DictWriter(f, fieldnames=FIELDS)
|
||||
w.writeheader()
|
||||
w.writerows(rows)
|
||||
|
||||
md = [f"## Results ({len(rows)} runs)", "",
|
||||
"wait_ms = call confirmed -> execution start (~0 for eager "
|
||||
"protocols, large when buffered until stream end). overlap_s = "
|
||||
"tool time hidden behind generation.", "",
|
||||
table(rows, ["model", "protocol"], "By model x protocol"),
|
||||
table(rows, ["model", "protocol", "fixture"],
|
||||
"By model x protocol x fixture")]
|
||||
text = "\n".join(md)
|
||||
with open(md_path, "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
print(text)
|
||||
print(f"wrote {csv_path} and {md_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv))
|
||||
Reference in New Issue
Block a user