From 9117708ddee65ce8393e486414b5dc0e724de113 Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Sun, 23 Aug 2026 14:50:01 +0200 Subject: [PATCH 1/6] chore: add agent-kombat plan-debate tool Adds the agent-kombat multi-model plan-debate tool: three models (Claude, Codex, DeepSeek via opencode) each write a plan, read each other's, revise, and a judge picks a winner. - scripts/agent-kombat/ the driver and its libraries - .claude/skills/agent-kombat/ the skill that wraps it - .opencode/agent/ the read-only opencode participant - .gitignore ignore the tmp/ run directory Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012672tdqAcDZ3GyDvNVTMcg --- .claude/skills/agent-kombat/SKILL.md | 320 ++++++++ .gitignore | 4 + .opencode/agent/kombat-debater.md | 229 ++++++ .opencode/opencode.json | 17 + scripts/agent-kombat/UPSTREAM.md | 279 +++++++ scripts/agent-kombat/_lib/adapters.sh | 730 ++++++++++++++++++ scripts/agent-kombat/_lib/common.sh | 274 +++++++ scripts/agent-kombat/_lib/contracts.sh | 346 +++++++++ scripts/agent-kombat/_lib/dispatch.sh | 269 +++++++ scripts/agent-kombat/_lib/orchestration.sh | 364 +++++++++ scripts/agent-kombat/_lib/persistence.sh | 295 +++++++ scripts/agent-kombat/_lib/preflight.sh | 257 ++++++ scripts/agent-kombat/_lib/prompts.sh | 382 +++++++++ scripts/agent-kombat/_lib/roster.sh | 387 ++++++++++ scripts/agent-kombat/_lib/runmodes.sh | 340 ++++++++ scripts/agent-kombat/_lib/safety.sh | 183 +++++ scripts/agent-kombat/agent-kombat.sh | 512 ++++++++++++ .../agent-kombat/context/shared-context.md | 93 +++ scripts/agent-kombat/contracts/artifact.json | 14 + scripts/agent-kombat/contracts/plan.json | 14 + scripts/agent-kombat/roster-check-hook.sh | 121 +++ .../agent-kombat/rosters/deepseek-only.json | 38 + scripts/agent-kombat/rosters/duo.json | 35 + scripts/agent-kombat/rosters/pair.json | 33 + .../agent-kombat/rosters/roster.schema.json | 174 +++++ scripts/agent-kombat/rosters/trio-debate.json | 44 ++ scripts/agent-kombat/rosters/trio.json | 36 + scripts/agent-kombat/smoke.sh | 454 +++++++++++ scripts/agent-kombat/testdata/fake-claude | 222 ++++++ scripts/agent-kombat/testdata/fake-codex | 173 +++++ scripts/agent-kombat/testdata/fake-opencode | 273 +++++++ .../fixtures/opencode-events-real.jsonl | 6 + 32 files changed, 6918 insertions(+) create mode 100644 .claude/skills/agent-kombat/SKILL.md create mode 100644 .opencode/agent/kombat-debater.md create mode 100644 .opencode/opencode.json create mode 100644 scripts/agent-kombat/UPSTREAM.md create mode 100644 scripts/agent-kombat/_lib/adapters.sh create mode 100644 scripts/agent-kombat/_lib/common.sh create mode 100644 scripts/agent-kombat/_lib/contracts.sh create mode 100644 scripts/agent-kombat/_lib/dispatch.sh create mode 100644 scripts/agent-kombat/_lib/orchestration.sh create mode 100644 scripts/agent-kombat/_lib/persistence.sh create mode 100644 scripts/agent-kombat/_lib/preflight.sh create mode 100644 scripts/agent-kombat/_lib/prompts.sh create mode 100644 scripts/agent-kombat/_lib/roster.sh create mode 100644 scripts/agent-kombat/_lib/runmodes.sh create mode 100644 scripts/agent-kombat/_lib/safety.sh create mode 100755 scripts/agent-kombat/agent-kombat.sh create mode 100644 scripts/agent-kombat/context/shared-context.md create mode 100644 scripts/agent-kombat/contracts/artifact.json create mode 100644 scripts/agent-kombat/contracts/plan.json create mode 100755 scripts/agent-kombat/roster-check-hook.sh create mode 100644 scripts/agent-kombat/rosters/deepseek-only.json create mode 100644 scripts/agent-kombat/rosters/duo.json create mode 100644 scripts/agent-kombat/rosters/pair.json create mode 100644 scripts/agent-kombat/rosters/roster.schema.json create mode 100644 scripts/agent-kombat/rosters/trio-debate.json create mode 100644 scripts/agent-kombat/rosters/trio.json create mode 100755 scripts/agent-kombat/smoke.sh create mode 100755 scripts/agent-kombat/testdata/fake-claude create mode 100755 scripts/agent-kombat/testdata/fake-codex create mode 100755 scripts/agent-kombat/testdata/fake-opencode create mode 100644 scripts/agent-kombat/testdata/fixtures/opencode-events-real.jsonl diff --git a/.claude/skills/agent-kombat/SKILL.md b/.claude/skills/agent-kombat/SKILL.md new file mode 100644 index 0000000..e1251cd --- /dev/null +++ b/.claude/skills/agent-kombat/SKILL.md @@ -0,0 +1,320 @@ +--- +name: agent-kombat +description: Run an adversarial multi-model PLAN DEBATE (Claude vs Codex vs DeepSeek-via-opencode) through scripts/agent-kombat/agent-kombat.sh, then ground the winning plan in an isolated subagent before anyone acts on it. PLANNING-ONLY and EXPENSIVE — 8-15 model calls depending on roster, roughly $6-15 and 15-30 minutes of wall clock per run. Use when the user says "agent kombat", "debate this plan", "let the models fight it out", "adversarial plan review", "have Claude and Codex argue about this design", or when a decision is genuinely expensive to undo (public API shape, schema migration, cross-service contract, service split, event envelope change). Do NOT trigger for cheap or obvious changes (.gitignore edits, renames, one-file diffs, doc tweaks, anything a single subagent settles in one pass), and do NOT trigger at execution time — debating work that is already planned is tokenmaxxing. Companion to `second-opinion` (adversarial review of an existing diff/spec — far cheaper, prefer it when the artifact already exists) and `glm-delegate` (one cheap delegated task); this is the expensive multi-round option and inherits their non-negotiable isolated-subagent triage of external-model output. +argument-hint: " [--roster ] [-r N] [--no-judge]" +allowed-tools: [Read, Write, Glob, Grep, Bash, Agent, AskUserQuestion] +--- + +# agent-kombat + +Wrapper for [scripts/agent-kombat/agent-kombat.sh](../../../scripts/agent-kombat/agent-kombat.sh): +an adversarial **plan debate** across heterogeneous harnesses (`claude`, +`codex`, `opencode`). Each participant writes an independent plan, revises it +after reading the others', then a judge picks or merges and flags what is still +unresolved. + +The point is not "more compute" — it's that **different model families fail +differently**. Claude's confidently-invented file path is not GPT's +over-abstracted layering is not DeepSeek's dropped constraint; single-model plan +review shares the reviewer's blind spots with the author. Cross-family +disagreement is the signal, the artifacts are just receipts. Participants are +**anonymous by construction** — every slot's `label` is `Participant A`-style, +never a model name, so nobody defers to a brand. + +## Prerequisites + +Three separate vendor CLIs, and **two of them cannot be installed from this repo**. +Run the free checks at the bottom of this section before assuming a machine is ready. + +| Requirement | How to get it | If missing | +|---|---|---| +| **`bash` >= 4.0** | `brew install bash`, and make sure its dir precedes `/bin` in `PATH` | The driver refuses immediately with a version message. macOS ships bash **3.2** at `/bin/bash`. 4.0 is enough because `wait -n` (4.3+) is deliberately not used and empty-array expansions are guarded | +| **`jq`** and **`shasum`** | `brew install jq` (shasum ships with macOS/perl) | Refused at preflight | +| **`claude`** CLI, authenticated | already present if you are reading this in Claude Code | Only rosters with a `claude` slot are affected | +| **`codex`** CLI, authenticated | **no `make` target exists here** — install and `codex login` yourself | Rosters with a `codex` slot (`pair`, `trio`, `trio-debate`) will not run | +| **`opencode`** + a DeepSeek key | `make install-glm-tools` installs the CLI (it configures Z.ai, which the kombat rosters no longer use); authenticate DeepSeek separately with `opencode auth login` → DeepSeek | Rosters with an `opencode` slot (`trio-debate`, `trio`, `duo`, `deepseek-only`) will not run | +| **`.opencode/agent/kombat-debater.md`** loads | tracked in the repo; nothing to install | Proven by a canary dispatch at preflight; the run is refused if it does not resolve. See the flaky-listing note under Rosters — the listing is not trusted | +| **`shellcheck`** | `brew install shellcheck` | Only needed to *edit* `scripts/agent-kombat/*.sh`; a scoped pre-commit hook runs it | +| **`gum`** | not required | Upstream's UI is not ported | + +Only the harnesses your chosen roster actually names are checked, so a machine +without `codex` can still run `--roster duo`, and one without `opencode` can run +`--roster pair`. + +Verify a machine, all free, no model calls: + +```bash +make kombat-roster-check # every preset parses and validates +bash scripts/agent-kombat/agent-kombat.sh --check-harnesses # each CLI still has the flags we use +make kombat-smoke # full loop against fake harnesses +make kombat-dry-run REQ="anything" # writes every prompt, calls nothing +``` + +`--check-harnesses` is the one to run on a new machine: it greps each CLI's own +`--help` for every flag the adapters depend on, so a vendor renaming a flag is a +clear message rather than a mid-debate failure. + +## Cost — read before you type + +| Knob | Reality | +|------|---------| +| Model calls | **11 nominal, 15 worst case** for `trio-debate` at `-r 2`; **8-11** for `pair`. The driver prints both at startup from `N*(1+rounds+max_extra) + max_extra + 2` — a judge replay runs a full extra round *and* another judge call, so both terms grow. Trust the driver's number, not this table. | +| Money | roughly **$6-15** per run at that shape | +| Wall clock | **15-30 min** | + +**A failed run still costs money.** Each participant is billed for what it consumed +before anything went wrong — an aborted `claude` turn on a large prompt has been +observed billing **$0.53** in cache-creation tokens alone while returning no usable +output. Run the free checks above first; they exist so a broken machine fails for +nothing instead of for dollars. + +**Never run it speculatively** or "to see what it says". If you're unsure the +decision is expensive enough, it isn't — do the cheap thing and keep the money. + +## When to use / when NOT to use + +**Use** — anything where **being wrong is expensive to undo** (data migrated, +contract published, partners integrated): public API / RPC surface, event-envelope +or CloudEvents contract changes; ClickHouse / Postgres migrations, especially with +a down-chain; architecture calls (service split, ownership boundary, +sync-vs-async, retry/DLQ strategy); cross-service contracts two teams agree once +and live with. + +**Don't** — `.gitignore`, renames, formatting, doc edits, one-file changes. Not +**at execution time**: once a plan is accepted, implement it; re-debating settled +work is pure tokenmaxxing. Artifact already exists and wants critique → +`second-opinion`. One cheap delegated pass suffices → `glm-delegate`. And when +the blocker is missing *information* rather than missing judgement, three models +guessing is worse than one `grep`. + +## Rosters + +A roster names the slots: `debaters` (>= 2), `judge`, optional `synthesizer` +(defaults to the judge). Presets live in +[scripts/agent-kombat/rosters/](../../../scripts/agent-kombat/rosters/) (`ls` it +for the current set; the shape is in `rosters/roster.schema.json`, and each +preset's own `description` field states its trade-off — read it before running): + +| Preset | Shape | +|--------|-------| +| `trio-debate` | **built-in default.** 3 debaters (Claude Opus, Codex sol, opencode DeepSeek V4 Pro) + Claude Opus judge. Judge shares harness+model with debater `opus` → session independence, *not* model independence | +| `trio` | Claude Opus vs opencode DeepSeek V4 Pro, **Codex judge** — nobody shares a vendor with the judge. Prefer when the *verdict* is the deliverable | +| `duo` | **two models total:** Claude Opus vs opencode DeepSeek V4 Pro, with Opus also judging in a fresh session. Cheapest useful preset — but it **does** contain an opencode slot, so it needs a working `opencode` + DeepSeek key; `pair` is the one that needs neither. Judge is session- but not model-independent | +| `pair` | Claude Opus vs Codex sol, no opencode. Both enforce their schema natively, so there is no fenced-JSON extraction and nothing to re-ask — the most reliable preset, and the one to use when the opencode agent is not registered (see below) | +| `deepseek-only` | cheap plumbing test of the `fenced_json` path (DeepSeek V4 Flash vs V4 Pro). Not a real debate — never decide anything with it | + +### If an opencode slot warns about the read-only agent + +`opencode agent list` is **unreliable** — the same unchanged tree lists +`kombat-debater` on roughly one invocation in four (measured: `0 1 0 0 0 0 1 0`). +`opencode run --agent` is **not** unreliable: 3/3 dispatches honoured the agent on +a tree whose listing was mostly failing. So the listing is not consulted at all. + +Preflight instead **proves** the agent by dispatching it — a one-word canary turn on +the slot's own model, which exercises exactly the resolution path the debate will +use. It used to rewrite the id to a cheaper sibling (`${model%%-fast}-fast`), which +only worked while the slot was GLM; DeepSeek's cheap tier is `-flash`, so that +rewrite produced a nonexistent model and refused a valid roster. If it does not resolve, the run is **refused** before any +debate dispatch. The alternatives were both unacceptable: gating on the listing +refuses ~75% of valid runs, and warning-then-continuing means the write-capable +`build` agent has already run by the time anything notices. + +Per-turn `not found. Falling back` detection and `assert_tree_untouched` remain, but +they are backstops — both are post-hoc. If the canary fails, check that +`.opencode/agent/kombat-debater.md` exists and parses: + +```sh +opencode run 'hi' --agent kombat-debater -m deepseek/deepseek-v4-pro --format json 2>&1 | head +``` + +`AGENT_KOMBAT_SKIP_OPENCODE_CANARY=1` skips the proof. It warns loudly and you own +the consequence; there is no reason to set it except an outage in the canary model. + +`--roster pair` needs no opencode at all, and stays the fallback for a machine +where opencode cannot be made to work. + +### Selection precedence + +```text +selection: --roster-file > --roster > $AGENT_KOMBAT_ROSTER > built-in "trio-debate" +per-slot model: --model = > $AGENT_KOMBAT_MODEL_ > roster file > die +``` + +`--roster ` searches `./.agent-kombat/rosters/.json` (personal, +gitignored) **then** `scripts/agent-kombat/rosters/.json` (repo preset), +so a personal roster shadows a preset of the same name. Never guess a slot id +for `--model` — confirm it with `--roster-check` first (an unknown slot is a hard +error). + +### Constraints the driver enforces + +- `judge.structured_output` must be `native`. The judge's `recommendation` enum + *is* control flow, so a parse miss there is unrecoverable — unlike a debater + miss, which a re-ask recovers. Override only via `--allow-unstructured-judge`. +- **`opencode` has no structured output at all** (no `--output-schema` in + 1.18.8), so an opencode slot is `fenced_json` and can never judge by default. +- Every `opencode` slot must set `agent` — always `kombat-debater` + ([.opencode/agent/kombat-debater.md](../../../.opencode/agent/kombat-debater.md)). + opencode has no read-only sandbox flag, so that agent's permission set is what + keeps an opencode debater out of your working tree — **and it fails open**: an + unresolvable `--agent` name falls back to `build` (bash and writes allowed) with + exit 0. The driver therefore **refuses to start** unless a canary dispatch proves + the agent resolves, and treats a `not found. Falling back` line mid-run as a hard + turn failure. Do not weaken either guard. +- Every `codex` slot must set `reasoning_effort`: `~/.codex/config.toml` pins + `model_reasoning_effort=xhigh` **globally**, so an unpinned slot bills at xhigh. +- Slot ids unique across debaters + judge + synthesizer. A judge whose + `{harness, model}` matches a debater only **warns** — `pair`, `duo` and + `trio-debate` all trip it deliberately. Understand the warning rather than ignoring it: that + judge has session independence, not model independence, so it may + systematically favour its twin. When the *verdict* is the deliverable, use `trio`. + +### Worked example — 2 models, model-independent judge + +`trio` = debaters `opus` (claude) + `deepseek` (opencode), judge `judge` (codex): + +```bash +bash scripts/agent-kombat/agent-kombat.sh --roster trio -r 2 \ + "@docs/architecture/ene-99-dlq-strategy-spike.md — settle the DLQ replay contract" +``` + +### Worked example — 3 models, with a per-slot model override + +`trio-debate` = debaters `opus`, `sol`, `deepseek` + judge `judge`. Override one +slot without editing the preset (equivalently +`AGENT_KOMBAT_MODEL_DEEPSEEK=deepseek/deepseek-v4-flash`): + +```bash +bash scripts/agent-kombat/agent-kombat.sh --roster trio-debate -r 2 \ + --model deepseek=deepseek/deepseek-v4-flash \ + "plan the property-store tenant-scoping migration, including the down-chain" +``` + +Need a variation the presets don't cover? Copy a preset to +`./.agent-kombat/rosters/.json` (personal, gitignored) and edit there — +never mutate a shipped preset for a one-off run. Valid opencode models (verified +by dispatch on this machine): `deepseek/deepseek-v4-pro`, +`deepseek/deepseek-v4-flash`. There is **no** `-fast` sibling — do not assume one. +`--variant` is real reasoning effort here. The driver refuses a value outside +`none|minimal|low|medium|high|xhigh|max`, because opencode silently ignores an +unknown one rather than erroring. It does **not** check the per-model subset, and +no test pins it — as of 2026-08-21 models.dev reports `v4-pro` declaring +`low|medium|high` and `v4-flash` `high|max`, but that shifts with each release, so +confirm against the model rather than trusting this line. + +## Run it in cost order — free checks first + +```bash +bash scripts/agent-kombat/agent-kombat.sh --roster-check --roster trio-debate # free: roster valid, ids printed +bash scripts/agent-kombat/agent-kombat.sh --check-harnesses # free: claude/codex/opencode callable +bash scripts/agent-kombat/agent-kombat.sh --dry-run -r 2 "" # free: full run plan, 0 model calls +bash scripts/agent-kombat/agent-kombat.sh -r 2 "" # the expensive one +``` + +Dispatch the real run with `run_in_background: true` and poll — it outlives the +10-minute foreground `Bash` timeout, same rule as `glm-delegate`. Exit codes: +`0` ok · `2` unresolved / validation refusal (read the output, don't retry +blindly) · other non-zero = setup error. Interrupted → `--resume `; +finished → `--show `. `--contract plan|artifact|PATH` swaps the schema +and prompts when the deliverable is not plan-shaped; `--dry-run` prints the +resolved contract and the final filename, so read that rather than assuming. + +## Reading the output + +Run dir: `tmp/agent-kombat/debate_YYYYMMDD_HHMMSS/`. + +| Path | What it is | +|------|-----------| +| `requirement.txt` | expanded requirement, `@file` refs inlined — check first when a debate went sideways | +| `config.json` | resolved roster + flags, i.e. what actually ran | +| `events.jsonl` | merged timeline; per-slot `rounds/r{N}-.events.jsonl` merge in at each round barrier | +| `rounds/r{N}-.prompt.txt` / `.turn.json` | exactly what a slot was asked / its structured answer | +| `rounds/r{N}.manifest.json` | published round manifest (the barrier's output). The `.manifest.json` suffix *is* divergence D10 — it keeps the resume glob unambiguous by construction | +| `plan-.md` | each participant's own final plan | +| `judge-verdict.json` | `recommendation` + **`unresolved_issues`** | +| `plan-final.md` | **the deliverable** | +| `triage.md` | the grounding verdict (below) — a run without this is not done | + +`plan-final.md` is what people read. **`unresolved_issues` in +`judge-verdict.json` is what people skip, and it's the highest-value field in +the run**: the things multiple models could not agree on, i.e. exactly the +decisions that need a human. Quote it verbatim to the user; never let a summary +swallow it. + +## MANDATORY final step — ground the plan (upstream has no such step) + +`plan-final.md` is **external model output**; most of it was written by models +with no vetting for this repo. Both `second-opinion` and `glm-delegate` make +isolated-subagent triage of exactly this category non-optional — output is guilty +until proven innocent. Same rule here, no exemption for "a judge already reviewed +it": the judge is external output too. So once `plan-final.md` exists, dispatch +**exactly one** isolated native `Agent` (clean context, read access to the real +repo) to **ground** it: + +- Does every cited file / path / symbol exist? (`Glob`/`Grep`, not memory.) +- Does each claimed convention actually say that? Open `AGENTS.md`, `CLAUDE.md`, + `docs/`, the migration, the contract — quote the line. +- Are the commands, make targets, flags and env vars real? Confirm a make target by + reading the `Makefile` or `make -n`, **never by running it.** Tell the grounding agent + so explicitly: no mutating commands, and no `make` target invoked for real. + +**The run dir is destroyable, and once was destroyed mid-triage.** A `make kombat-clean` ++ `make kombat-smoke` cycle on this branch wiped `tmp/agent-kombat/` while a grounding +pass was still running — taking `plan-final.md`, `judge-verdict.json`, both participant +plans, `requirement.txt`, `rounds/` and the in-progress `triage.md` with it. The +artifacts are gitignored, so the loss was total and a re-run costs the full $6-15. +`kombat-clean` now spares real debates unless `ALL=1`, but two things follow anyway: +anything that must outlive the session gets hand-copied out of `tmp/` **before** you +finish, and a long-running grounding agent should be treated as holding a lock on the +run dir — do not run debate-adjacent make targets while one is in flight. +- Does any step contradict a repo hard rule (test-before-push, migration + down-chain, generated-file ownership, an ADR)? + +Verdict → `triage.md` in the run dir: **accept** (grounded, safe to implement) / +**reject** (cites things that don't exist — name them) / **needs-user-decision** +(legitimate but a judgement call, usually the `unresolved_issues`). Never hand a +**reject** or **needs-user-decision** plan to an implementer as if accepted. + +## Hard rules + +1. **Nothing from `tmp/` is ever committed** — the run dir is gitignored + ([.gitignore](../../../.gitignore): `tmp/`) and stays that way. Worth + keeping? Hand-copy the *reviewed* content into a real doc or Linear ticket. + The driver refuses any workdir outside `$REPO_ROOT/tmp/` (escape hatch + `AGENT_KOMBAT_ALLOW_UNSAFE_WORKDIR=1`) and refuses `.agents/` by name — + `.agents/` is **not** gitignored here, and was upstream's default workdir. +2. **Never point a debate at secrets.** `codex -s read-only` blocks *writes*, + not *reads*: it will happily read `.env` and ship it to OpenAI — the same + exposure `second-opinion` already carries. No `.env`, no key material, no + customer data, no `@` reference that transitively pulls one in. +3. **Never `ooy` / `OPENCODE_YOLO=true` / `--auto` for a participant.** + `--auto` auto-approves every permission not *explicitly* denied, defeating + the read-only guarantee `kombat-debater` exists to provide. `glm-delegate` + uses `OPENCODE_YOLO` deliberately; a debate participant must not. +4. **Planning only.** Claude slots run print-mode with tools off, codex slots + read-only, opencode slots behind `kombat-debater`. The debate never edits. + +## Common mistakes + +| Mistake / red flag | Fix | +|--------------------|-----| +| Running it on a small or obvious change | Don't — ~$6-15 and 20 min for a rename is indefensible | +| Running it during implementation | Planning only; re-debating settled work is tokenmaxxing | +| Skipping `--roster-check` / `--dry-run` | Both free; a bad roster fails *after* you've paid for round 0 | +| Guessing a slot id for `--model` | Driver dies on unknown slots — take ids from `--roster-check` | +| `opencode` slot without `agent: kombat-debater` | Driver refuses; without it the opencode debater can write to your tree | +| `codex` slot without `reasoning_effort` | Inherits global `xhigh` and quietly costs multiples | +| Quoting a `trio-debate` verdict as impartial | Its judge is Opus judging an Opus debater — use `trio` when the verdict is the point | +| Editing a shipped preset for a one-off | Copy it to `./.agent-kombat/rosters/` (gitignored) or use `--model` | +| Deciding anything with `deepseek-only` | It's a cheap plumbing test of the `fenced_json` path, not a debate | +| Treating `plan-final.md` as ground truth | External model output; the `triage.md` grounding pass is mandatory | +| Reporting the plan without `unresolved_issues` | That's the human-decision list — quote it verbatim | +| Committing the run dir, or `--workdir .agents/…` | `tmp/` only; `.agents/` is tracked and refused by name | +| Foreground `Bash` for the real run | Background + poll; a real run outlives the 10-min timeout | + +## See also + +- [scripts/agent-kombat/agent-kombat.sh](../../../scripts/agent-kombat/agent-kombat.sh) — the driver, single source of truth for flags +- [.opencode/agent/kombat-debater.md](../../../.opencode/agent/kombat-debater.md) — the read-only opencode participant +- `second-opinion` (vendored, `make install-claude-skills`) — cheaper external review of an existing artifact +- [glm-delegate](../glm-delegate/SKILL.md) — single delegated task, same triage doctrine diff --git a/.gitignore b/.gitignore index eb31b39..8f29ee6 100644 --- a/.gitignore +++ b/.gitignore @@ -88,3 +88,7 @@ cython_debug/ output/ artifacts/ + +# agent-kombat debate run dir (no trailing slash: the driver runs +# `git check-ignore -q tmp` before the dir exists, and `tmp/` would not match) +tmp diff --git a/.opencode/agent/kombat-debater.md b/.opencode/agent/kombat-debater.md new file mode 100644 index 0000000..fc86fac --- /dev/null +++ b/.opencode/agent/kombat-debater.md @@ -0,0 +1,229 @@ +--- +description: >- + Read-only plan-debate participant for scripts/agent-kombat/agent-kombat.sh. + Reads the repository, reasons about the requirement, and returns exactly one + JSON object. Never writes files, never runs commands, never asks for + permission. Required as --agent on every opencode slot in an agent-kombat + roster; not for interactive use. +mode: primary +permission: + "*": deny + # A blanket `read: allow` is NOT safe here. opencode resolves permissions by + # merging inherited entries with the agent's own, and the agent's entries land + # LAST -- so a bare `read: allow` overrides the inherited `*.env -> ask` guard + # and lets this agent read secrets with no prompt. Verified empirically: with + # `read: allow` alone, `Read .env` succeeded silently. + # + # These are `deny`, not `ask`, on purpose: an `ask` in a non-interactive + # `opencode run` has nobody to answer it and hangs the turn until the driver's + # idle watchdog kills it. Deny fails the tool call and lets the turn continue. + # + # Scoped to credential-BEARING names, not merely credential-SOUNDING ones. A + # `*secret*` or `*.tfvars` entry would deny ~10 tracked, secret-free files a + # debater legitimately needs -- ADR 0005, terraform/modules/keyvault-secret-copy/*, + # the `*.auto.tfvars` env config -- and a denied read degrades grounding silently: + # the model reasons without the file rather than reporting it could not read it. + # Anything genuinely secret is untracked, so extension denies cover it. + # + # Known collateral, accepted: `*credentials*` denies three secret-free tracked + # files (property-store migrations 0020_drop_panasonic_credentials.*.sql and + # tests/unit/test_emqx_init_credentials.py). Kept anyway -- loosening a deny is + # the one direction this file warns against, and a debater that needs those can + # be told their content in the requirement. + read: + "*": allow + "*.env": deny + "*.env.*": deny + ".env": deny + ".env.*": deny + "*.pem": deny + "*_rsa": deny + "*_dsa": deny + "*_ed25519": deny + "id_*": deny + "*credentials*": deny + "*.key": deny + "*.p12": deny + "*.pfx": deny + "*.jks": deny + "*.kdbx": deny + "*.tfstate": deny + "*.tfstate.*": deny + "*.kubeconfig": deny + grep: allow + glob: allow + list: allow + edit: deny + write: deny + patch: deny + bash: deny + webfetch: deny + websearch: deny + task: deny +tools: + write: false + edit: false + patch: false + bash: false + task: false + webfetch: false + websearch: false +--- + +# kombat-debater + +You are one anonymous participant in a structured plan debate. The other +participants are different model families; you do not know which, and it does +not matter. Argue from evidence in the repository, not from authority. + +**Your entire output is one JSON object matching the schema given in the +prompt.** No prose before it, no prose after it, no commentary. When the driver +asks for fenced JSON, emit exactly one ```json fence containing that object and +nothing else. + +Hard constraints: + +- **Never edit, create, move or delete a file.** Not even a scratch file, not + even under `tmp/`. Your only output channel is the JSON object you return. +- **Never run a command.** No `bash`, no build, no test, no git. If a claim + needs verification you cannot do by reading, say so in the JSON (e.g. as a + risk or an open question) instead of trying to verify it. +- **Never ask for permission or confirmation.** You run non-interactively; a + prompt for approval is an indefinite hang, not a question anyone will answer. +- **Never fetch from the network.** No URLs, no docs lookups. Cite files. +- Every file path, symbol, command and convention you cite must be one you + actually read in this repository. An invented path is the single most + expensive failure mode in this pipeline — a downstream grounding pass will + catch it and reject the whole plan. +- Read whatever you need (`read`, `grep`, `glob`, `list`) before answering. + Reading is the only thing you are allowed to do, so do it thoroughly. +- **Do not fall into a read loop.** Read each file at most once, in a generous + window (100+ lines). Never re-read a file you have already read this turn. If + you need more context from a large file, read the next 200-line window — do + not walk it in 20-line increments. After ~10 reads, stop reading and produce + your JSON with what you have. Gaps are cheaper than a stalled turn. The driver + counts your tool calls and warns if a turn is read-heavy without producing + output. + +## Why not a builtin agent + +Neither builtin agent is usable here, and both fail in a way that is easy to +miss: + +- **`plan`** — denies the edit tools but leaves `permission.bash` as + `"*": allow`, so `echo something > file` still writes to the tree; and it + leaves `plan_exit` on `allow`, so the model can switch *itself* back into + build mode mid-turn. A read-only guarantee that the model can revoke is not a + guarantee. +- **`explore`** — allows `bash`, and is `mode: subagent`, so it cannot be + passed as the primary agent of an `opencode run` invocation at all. + +Hence this file. `opencode` has **no read-only sandbox flag** (unlike +`codex -s read-only`), so this agent definition is what stands between an opencode +debater and your working tree. Treat it as load-bearing: do not relax a `deny` +here to make some other workflow convenient. + +## The guarantee is conditional: `--agent` fails OPEN + +Stated precisely, because "this file is the only thing protecting you" is too +strong and the gap matters. An unresolvable agent name does **not** fail: + +```console +$ opencode run --agent definitely-not-an-agent -m zai/glm-5.1-fast "say ok" +! agent "definitely-not-an-agent" not found. Falling back to default agent +> build · glm-5.1-fast +ok # ...and exit code 0 +``` + +`build`'s resolved permissions are `{"*": "allow"}` — bash and writes enabled. So +a typo in a personal roster, a renamed file, or any working directory where +`.opencode/agent/` is not discovered downgrades a read-only debater to a +full-write one, silently, with a zero exit status. + +Two guards close this, and both live in the driver rather than here, because a +file cannot assert its own existence: + +1. `preflight_opencode_agents` (in `_lib/preflight.sh`) **proves** every roster + opencode slot's agent by dispatching a one-word canary turn **before any debate + dispatch**, and refuses the run if it does not resolve. It does not consult + `opencode agent list`: that listing races custom-agent registration and returns + the agent on roughly one invocation in four on an unchanged tree (measured + `0 1 0 0 0 0 1 0`), so it cannot support a decision in either direction. The + canary exercises the same resolution path the debate will use. +2. `adapter_opencode` (in `_lib/adapters.sh`) treats a `not found. Falling + back` line in the harness output as a hard turn failure, so a mid-run + regression cannot slip past. + +`assert_tree_untouched` is the backstop behind both, but it is post-hoc +detection: it tells you the tree was modified, not that it was prevented. So is +guard 2 — by the time the fallback line is read, `build` has already run. Only +guard 1 is preventive, which is why it refuses instead of warning. + +## What is actually verified, and what is not + +Probed 2026-07-31 against `zai/glm-5.2-fast` on opencode 1.18.8: + +| Probe | Result | What it proves | +|---|---|---| +| "create `tmp/probe-oc.txt`" | no file written | no attempt observed — does **not** confirm enforcement | +| "run `echo hi`" | no command executed | no attempt observed — does **not** confirm enforcement | +| read a `*.env` decoy | **tool call DENIED**, matched rule `read:*.env→deny` | the `permission:` block is enforced by opencode, not merely described to the model | +| read a plain `tmp/*.txt` | succeeded, content returned | `read` still works, so a debater can still ground its claims | + +The `.env` probe used a **decoy** file with fabricated content, never the real +`.env` — the point was the denial, and asking an external model to quote a real +secret to prove a deny works would defeat the purpose. + +Re-probed 2026-08-21 against `deepseek/deepseek-v4-pro` (`--variant high`), the +model the rosters now put in this slot, on the same decoy file: + +| Probe | Result | What it proves | +|---|---|---| +| read a `*.env` decoy | **tool call DENIED** at the permission layer; the tool return listed the resolved rule chain | containment does not depend on which model fills the slot | +| read a plain `tmp/*.txt` | succeeded, content returned | `read` still works, so a DeepSeek debater can still ground its claims | + +That re-probe needed a second attempt to be worth anything. Asked plainly, DeepSeek +**declined on its own secret-handling judgement without ever calling the tool** — +which proves nothing about enforcement, exactly the "outcome only" trap the first +limit below describes. Only a prompt that explicitly asked it to attempt the call +and report the tool's verbatim return produced a mechanical denial. When re-probing +a new model, insist on the attempt; a refusal is not evidence. + +Two honest limits on the above: + +- The write and bash probes prove only the **outcome**. Neither model turn + attempted a tool call, so they cannot distinguish "the `tools:` block stripped + the tool from the schema" from "the model complied with the prompt". Prompt + compliance is not a guarantee. The `permission:` block is the enforcement we + rely on, and only the read deny is mechanically demonstrated. +- The `tools:` block remains **unconfirmed as honoured** by 1.18.8. Defence in + depth, not the guarantee. + +The `.env` denial also surfaced opencode's resolved rule chain, which confirms the +merge-order hazard below empirically: the inherited config's `*.env → ask` entries +appear *first*, this agent's `*.env → deny` entries land *last*, and last wins. A +blanket `read: allow` here would therefore have overridden the inherited guard +rather than inheriting it — exactly as the comment on that block warns. + +Re-run these probes after any opencode upgrade, and after any change to +`.opencode/opencode.json` or the user-level opencode config. + +## Config-merge hazard (read before touching opencode.json) + +opencode's `Permission.merge` applies **user config last**, so a broader +config can override what this file denies. Concretely: the tracked +[.opencode/opencode.json](../opencode.json) sets `permission.bash` to a +*pattern map* with `"ask"` entries (`make deploy*`, `terraform apply*`, +`*migrate*up*`, …). If that map wins over this agent's `bash: deny`, a matching +command becomes **`ask`** rather than blocked — and in non-interactive +`opencode run` an `ask` has nobody to answer it, so the debate slot hangs until +the driver's idle timeout kills it. That surfaces as a mysteriously stalled +participant, not as a permission error. + +Two consequences: + +1. Do not "fix" a stalled opencode slot by loosening a `deny` to `ask`. Keep + `deny`; a blocked tool call is a recoverable turn, a hanging one is not. +2. If `.opencode/opencode.json` or the user-level config grows new + `permission.bash` entries, re-run the `opencode agent list` check above and + confirm `bash` still resolves to `deny` for this agent. diff --git a/.opencode/opencode.json b/.opencode/opencode.json new file mode 100644 index 0000000..c2272eb --- /dev/null +++ b/.opencode/opencode.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "bash": { + "make deploy*": "ask", + "make release*": "ask", + "terraform apply*": "ask", + "terraform destroy*": "deny", + "terraform *destroy*": "deny", + "terraform workspace*": "ask", + "gh release create*": "ask", + "*migrate*up*": "ask", + "*migrate*down*": "ask", + "python -m *migrate*": "ask" + } + } +} diff --git a/scripts/agent-kombat/UPSTREAM.md b/scripts/agent-kombat/UPSTREAM.md new file mode 100644 index 0000000..5c3376e --- /dev/null +++ b/scripts/agent-kombat/UPSTREAM.md @@ -0,0 +1,279 @@ +# Upstream provenance and divergence ledger + +## Provenance + +- Upstream: +- Default branch: **`master`** (not `main`) — remote `HEAD` points at `origin/master`. +- Pinned SHA: **`1a034dd56708532732f0734ea1073ad8a943fc5a`** (committed 2026-05-06). +- Licence: **MIT**, `Copyright (c) 2026 agent-kombat contributors`. Permissive — modification and + redistribution are allowed; the one obligation is that the copyright notice and permission notice + travel with the code, which is what this section discharges. Full text: upstream `LICENSE`. +- **Ported:** upstream's single bash driver `agent-kombat` (2462 lines at that pinned SHA) — the + debate loop, round manifests, event log, resume/show, and the judge/synthesis phases. Landed here + as `agent-kombat.sh` (the entry point) plus the `_lib/` libraries it sources — not a single file; + see D17 and the re-diffing table below. +- **Deliberately not ported:** `skills/plan/**` (SKILL.md + 4 reference docs + `agents/openai.yaml`) + and `skills/plan/scripts/plan_core.py` (902 lines). That subtree is a whole second product — a + request classifier plus plan-template renderer invoked as a subprocess at ~L1181-1239 — with its + own drift surface and no relation to running a debate. See D6. +- Upstream's own `tests/agent-kombat-smoke.sh` (623 lines) was read for reference but not ported; + our harness targets our roster model, not upstream's fixed two-agent one. + +## Re-diffing when upstream moves + +The port was originally **one file**, so a change upstream stayed a mechanical diff rather than an +archaeology project. **That rationale stopped holding, and the file has since been split** along its +real seams (adapters / schemas / safety enforcement / persistence / orchestration) into +`agent-kombat.sh` (a ~500-line entry point) plus eleven libraries under `_lib/`. See D17. + +- At 3,781 lines when the split began, the single file was a **6.1× outlier** in this repo — the + next-largest tracked shell script is 620 lines, across 98 of them — against a documented "~800 + lines is a strong signal to split" that explicitly applies to *new* files. +- The mechanical-diff argument assumed line-by-line correspondence with upstream, which the 16 + divergences below had already destroyed: `plan_core.py` dropped, the round-0 prompt builders + merged, N-way mesh replacing the hardcoded two agents, parallel in-round dispatch. Nothing was + preserved by keeping one file. +- `scripts/_lib/gh_token.sh` and `scripts/diagrams/_lib/portable_timeout.sh` are existing + sourced-lib precedents here. + +**On `shellcheck -x` and the split.** An earlier revision of this section claimed `shellcheck -x` +"follows `source`, so the scoped lint survives" — that overstated what was actually true, and is +corrected here rather than swapped for an equally confident replacement claim. Measured by copying +the whole `scripts/agent-kombat` tree to a scratch directory with `_lib/` intact, stripping all +the entry file's `# shellcheck disable=SC2034` directives, and linting the entry file from two +different working directories: + +- cwd = the script's own directory → **exit 0, no SC2034** +- cwd = anywhere else (absolute path) → **every stripped SC2034 fires** + +So `# shellcheck source=_lib/.sh` resolves **relative to shellcheck's working directory, not +relative to the sourcing file**. The pre-commit hook runs from the repo root against the path +`scripts/agent-kombat/agent-kombat.sh`, where `_lib/.sh` does not exist from that cwd — so +under the hook the sources are unresolvable, SC1091 is emitted at *info* severity and suppressed by +`--severity=warning`, and cross-file analysis does not happen at all under the hook. + +What actually keeps the lint working is therefore **one** reason, not two: the hook passes +filenames, so every file — entry point and each library — is linted **standalone**. That is also why +the entry file needs its own SC2034 directives and each library needs its own: neither can see +the other's reads (see `.pre-commit-config.yaml`'s `shellcheck-agent-kombat` hook). The +`# shellcheck source=` directives only take effect for someone running shellcheck directly from +inside `scripts/agent-kombat/` — they are not what preserves the lint under CI or the hook. + +Possible follow-up, not implemented here because it is a lint-behaviour change and out of scope for +this branch: `# shellcheck source-path=SCRIPTDIR` on the entry file would make the directives +resolve regardless of cwd and restore genuine cross-file analysis. + +Re-diffing means diffing upstream against the `_lib/` file that owns the corresponding +responsibility, rather than one whole-file diff: + +| `_lib/*.sh` | Upstream region it corresponds to | +| --- | --- | +| `common.sh` | Output/logging/process helpers: `log_event` (~L963-977), `write_atomic` (~L303-309), `kill_process_tree` (~L189-196), the idle watchdog (~L255-267) | +| `roster.sh` | Upstream has no roster — corresponds to the hardcoded `agent1`/`agent2`/judge config (~L912-929) and judge-model derivation (`init_model_defaults` ~L618-641, `resolve_derived_judge_model` ~L681-686) that this file replaces with data (D1, D14) | +| `adapters.sh` | Per-harness dispatch, the resume/fresh call sites (~L1695-1759) | +| `contracts.sh` | Built-in contract data and validation: inline shell builtins (~L737-775), the JSON overlay loader (~L785-803), `load_contract` (~L805-814) (D4) | +| `safety.sh` | Workdir defaulting and validation: `default_workdir` (~L834-842), `-d/--workdir` handling (~L353-356) (D7) | +| `persistence.sh` | `update_config` (~L954-961), the round manifest writers (`write_round0_manifest` ~L1765-1832, `write_debate_round_manifest` ~L1834-1902) | +| `preflight.sh` | No upstream equivalent — opencode did not exist in upstream's two-harness design (D9); corresponds loosely to the `have gum`/dead-weight checks removed at D11 (~L118-125, ~L500-541, ~L2097-2103, ~L2122-2123) | +| `prompts.sh` | Round-0/debate/judge/synthesis prompt text and plan-context prep: `build_round0_prompt`/`build_round0_codex_prompt` (~L1070-1148, D5), `prepare_round0_plan_context` (~L1181-1239, D6), `write_objections_ledger` (~L1904-1916) | +| `dispatch.sh` | Owns the in-round dispatch *inside* upstream's `run_round0`/`run_debate_round` — sequential upstream: `run_claude_round0` then `run_codex_round0` (~L1946-1947), same in `run_debate_round` (~L1977-1978) — D13 replaces this with parallel dispatch | +| `orchestration.sh` | The debate loop itself: `run_round0`/`run_debate_round` (~L1946-1947, ~L1977-1978), judge replay (`run_judge_loop` ~L2003-2041), synthesis (`run_synthesis` ~L2043-2092) | +| `runmodes.sh` | `--resume` (~L2225-2241), `--show` (~L2142-2165) | + +Derived from the eleven extraction commits on this branch (`git log --oneline -- scripts/agent-kombat/_lib/`), +cross-referenced against the upstream line references already pinned in the divergence ledger below +— not from fresh guesswork about upstream. From the repo root: + +```bash +# tmp/ is gitignored (.gitignore:52) — the clone never enters our history +rm -rf tmp/upstream-agent-kombat +git clone https://github.com/kaushikgopal/agent-kombat.git tmp/upstream-agent-kombat +git -C tmp/upstream-agent-kombat log --oneline 1a034dd5..origin/master # what changed since our pin +git -C tmp/upstream-agent-kombat diff 1a034dd5..origin/master -- agent-kombat +# and, to see how far a given _lib/ file has drifted from the pin -- e.g. persistence.sh, per the +# table above: +diff -u tmp/upstream-agent-kombat/agent-kombat scripts/agent-kombat/_lib/persistence.sh | less +``` + +Read the upstream commit range first; only then decide which hunks are worth carrying over, and +into which `_lib/` file the table says owns that responsibility. Every divergence below is +intentional — a hunk that collides with one of them is a hunk we reject on purpose, not a merge +conflict to resolve. + +## Divergence ledger + +| # | Divergence | Rationale | +| --- | --- | --- | +| D1 | Per-slot model override precedence is stated and enforced as **flag > env > roster file > die** (no silent default). | Upstream's actual precedence is already flag > env > config > default, but its *judge* model is resolved out of band: `init_model_defaults` (~L618-641) runs before `parse_args` (~L2403-2404) and, when `CLAUDE_MODEL`/`AGENT_KOMBAT_CLAUDE_MODEL` is set, stamps `JUDGE_MODEL_SOURCE=derived:claude` (~L638-641). `resolve_derived_judge_model` (~L681-686) then only fires on source `default`, so `--claude-model X` with `CLAUDE_MODEL=Y` exported leaves the judge silently on `Y`. Generic env names like `CLAUDE_MODEL` (~L16-17) are exactly what a sourced `.env` sets, and this repo sources `.env` constantly. Our rule: one precedence chain per slot, no derivation, `die` on unresolved. | +| D2 | `--output-schema` is passed to `codex exec resume` too. | Upstream passes it on the fresh call (~L1695) but omits it on resume (~L1744-1751) — resume rounds are schema-*hoped*, enforced only by a post-hoc `validate_debater_response` (~L1759). codex-cli 0.145.0 accepts `--output-schema` on `resume`; there is no reason to run unenforced. | +| D3 | `reasoning_effort` is always emitted for codex slots as `-c model_reasoning_effort="..."`. | Upstream never sets it (zero occurrences in the file). `~/.codex/config.toml` here pins `model_reasoning_effort=xhigh` globally, so an unpinned slot silently inherits xhigh and its cost. Pinning per slot makes the roster the only thing that decides. | +| D4 | Built-in contracts are data files under `contracts/`, loaded by the same loader as `--contract PATH`. | Upstream has two code paths: builtins as inline shell assignments (~L737-775) and a JSON overlay loader (~L785-803), with `load_contract` (~L805-814) seeding the `artifact` builtin before overlaying JSON. Two representations of one schema plus a third copy of every default in `load_run_config` (~L2180-2192) is a drift class; one loader over data files removes it. | +| D5 | One harness-parameterised round-0 prompt builder. | Upstream keeps `build_round0_prompt` (~L1070-1108) and `build_round0_codex_prompt` (~L1110-1148) as 39-line near-copies differing in two sentences ("You are ... participant 1 of 2" and the structured-output wording). At three harnesses that becomes three copies. | +| D6 | `plan_core.py` and `skills/plan/**` dropped; replaced by a static `context/shared-context.md`. | `prepare_round0_plan_context` (~L1181-1239) shells out to a 902-line Python classifier just to render a prompt preamble, and hard-fails the run if the file is missing (~L1188). A checked-in Markdown file is auditable, diffable, and cannot fail. | +| D7 | Workdir is **enforced** under `$REPO_ROOT/tmp/`, not merely defaulted (escape hatch `AGENT_KOMBAT_ALLOW_UNSAFE_WORKDIR=1`), and `.agents/` is refused by name. | Upstream `default_workdir` (~L834-842) prefers `.agents/plans/` when it exists and otherwise writes `debate_*/` into the CWD, and `-d/--workdir` (~L353-356) is never validated. `tmp/` is gitignored here (`.gitignore:52`); `.agents/` is **not**. Defaulting is not a guarantee — enforcing is. | +| D8 | `idle_timeout_seconds` defaults to **900**. | Upstream ships the watchdog off: `AGENT_IDLE_TIMEOUT` defaults to `0` (~L33) and the timeout branch is gated on `-gt 0` (~L255). opencode offers no timeout mechanism of its own, so a wedged turn hangs the run forever. On by default, tunable per slot. | +| D9 | opencode slots run a dedicated read-only agent (`--agent kombat-debater`, required on every opencode slot); never `--auto`, never `ooy`/`OPENCODE_YOLO`. | New harness — no upstream equivalent. opencode 1.18.8 has no read-only sandbox flag, so the agent definition *is* the sandbox. `ooy` is `OPENCODE_YOLO=true`, i.e. the opposite of read-only, and has no place in a debate that must not touch the tree. | +| D10 | Per-turn payloads are `rounds/r{N}-.turn.json`; published manifests are `rounds/r{N}.manifest.json` (what `write_round_manifest` actually writes and `reconstruct_progress` globs — **not** upstream's bare `rounds/r{N}.json`). | Upstream's `build_round_summary_json` (~L1150-1168) globs `rounds/r*.json` and survives only because of its `map(select(.published == true and ...))` filter — the glob also matches per-agent structured files (`r1-agent1.json`, ~L1724) and judge attempts (`judge-1.json`, ~L1554). Unambiguous by construction beats unambiguous by filter. | +| D11 | `gum` UI, interactive intake, and `--contract-check` removed. | `gum` is not installed here, so every `have gum` branch (~L118-125, ~L500-541, ~L2097-2103, ~L2122-2123) is dead weight; the intake path (~L494-546) can only block a non-interactive caller; `--contract-check` (~L385, ~L2289-2401) burns billed calls on both harnesses and is superseded by `--check-harnesses` / `--roster-check`, which are free. | +| D12 | Default `rounds` is **2**, not 3. | Upstream `ROUNDS=3` (~L22). Cost: rounds are the multiplier on every slot. The judge replay round (`run_judge_loop` ~L2003-2041) still takes a run to 3 when the judge asks for it, so depth is earned rather than paid for up front. | +| D13 | Slots within a round dispatch **in parallel**; each writes `rounds/r{N}-.events.jsonl`, merged into `events.jsonl` at the round barrier. | Upstream is strictly sequential — `run_round0` calls `run_claude_round0` then `run_codex_round0` (~L1946-1947), same in `run_debate_round` (~L1977-1978) — so wall-clock is the sum of the slowest models, and N debaters would make that worse. The merge exists for two distinct reasons. (a) **Corruption:** the parent poll loop is single-threaded, so parent-side logging alone could safely share one file — but each adapter **child** also emits its own events (the `fenced_json` re-ask loop runs inside the child), and concurrent appends to one file interleave **mid-line** (upstream's `log_event` ~L963-977 is a bare `>>`, and macOS ships no `flock(1)` to serialise them). Each slot therefore owns its file, written by the child while it lives and by the parent only after it is dead. (b) **Determinism:** parent-side completion events would otherwise be ordered by whichever slot finished first. Merging in roster-then-timestamp order restores a stable sequence, which is what lets `smoke.sh` assert on the event sequence at all — it verifies two identical runs produce a byte-identical `(event, slot)` sequence. | +| D14 | N-way full mesh; every participant is a roster slot with an **anonymised** label ("Participant A"); judge and synthesizer are first-class slots. | Upstream hardcodes `agent1: {name: "claude"}`, `agent2: {name: "codex"}`, `judge: {name: "claude"}` (~L912-929) and defaults the judge model to the Claude debater's (~L681-686), while its own judge prompt opens "You are an independent judge. You did not participate in the debate." (~L1246). Sharing a harness *and* a model with a debater is not independence. Hence: judge is a slot you must configure, and the driver warns when `judge.{harness,model}` matches any debater's pair. Labels are anonymous so a debater cannot defer to a brand name. | +| D15 | A mandatory isolated-subagent grounding triage of `plan-final.md` before the output is trusted. | Repo rule, inherited from `second-opinion` / `glm-delegate`: model output is guilty until proven innocent. Upstream has no equivalent — `run_synthesis` (~L2043-2092) writes the final Markdown and exits `ok`. A synthesised plan that cites files or APIs that do not exist is the failure mode this catches. | +| D17 | The port is split into `agent-kombat.sh` (a ~500-line entry point) plus eleven libraries under `_lib/`, each sourced unconditionally at file top level, rather than kept as upstream's single file. | Originally kept as one file so a change upstream stayed a mechanical diff; that rationale stopped holding. At 3,781 lines the single file was a **6.1×** outlier against the next-largest tracked shell script in this repo (620 lines, across 98 of them), against this repo's own "~800 lines is a strong signal to split" guidance for *new* files. By the time of the split the port had already diverged from upstream structurally in 16 recorded ways (D1-D16), so no line-by-line correspondence with upstream was being preserved by keeping one file. See "Re-diffing when upstream moves" above for the per-`_lib/` mapping. | + +## Kept deliberately — do not simplify away + +- **`run_with_heartbeat` (~L198-293) + `kill_process_tree` (~L189-196) + the idle watchdog (~L255-267)** — the only thing standing between a wedged harness and an unbounded hang; `pkill -P` is needed because the CLIs spawn children that outlive a plain `kill`. +- **`write_atomic` (~L303-309) and `update_config`'s mktemp-and-mv (~L954-961)** — `config.json` is the resume contract; a half-written one is an unresumable run. (Note: upstream *defines* `write_atomic` and never calls it — the port actually wires it up.) +- **`events.jsonl` + `log_event` (~L963-977)** — the append-only causal record; it is what the smoke test asserts against and what makes a failed run diagnosable after the fact. +- **`--resume` (~L2225-2241) and `--show` (~L2142-2165)** — a debate is expensive; resuming from the last published round instead of re-running it is the whole point of durable state. +- **Round manifests + sha256 (`write_round0_manifest` ~L1765-1832, `write_debate_round_manifest` ~L1834-1902)** — `frozen_inputs` (~L1871-1874) proves each debater argued against the exact bytes we published, not a later revision. +- **The objections ledger (`write_objections_ledger` ~L1904-1916)** — carries unresolved issues forward so the judge sees what was *not* settled, not just the latest polished artifact. +- **The session-id assertions (~L1530, ~L1593, ~L1654, ~L1757, ~L2085)** — a resume that silently starts a fresh session yields a debater with no memory of its own plan; without the assertion that failure is invisible. +- **`expand_prompt_file_refs` (~L582-616)** — `@file` expansion, hardened here: absolute paths and `..` are rejected and total expansion is capped at 256 KB (upstream checks only for `-*` and `*://*` prefixes, ~L591-596, so `@/etc/passwd` or `@../../secrets` expands straight into a prompt sent to three vendors). + +--- + +## opencode agent discovery — RESOLVED: the listing is flaky, the dispatch is not + +**Superseded.** An earlier revision of this section blamed, in turn, project-level +vs global install location, repo content under `.claude/`, and a non-empty +`sandboxes` array on this repo's `project` row in `~/.local/share/opencode/opencode.db`. +**All three were artefacts of a non-deterministic measurement and are retracted.** + +The actual behaviour, measured 2026-07-30 on opencode 1.18.8: + +```text +# eight consecutive `opencode agent list`, ZERO file changes between them: +0 1 0 0 0 0 1 0 # kombat-debater present ~1 run in 4 + +# three consecutive real dispatches on the same tree: +opencode run "Reply with exactly: ok" --agent kombat-debater -m zai/glm-5.2-fast + -> honoured the agent 3/3; no "Falling back" line; tree unmodified +``` + +So `opencode agent list` races custom-agent registration and is unsafe to gate on, +while `opencode run --agent` is reliable. Every bisect built on the listing — +removing `.claude/`, `.claude/skills/`, the agent file, the DB row — produced +whichever answer the coin flip gave, which is why each looked causal in turn. + +**Lesson worth keeping:** test the instrument before trusting a bisect. Eight +repeats of the *unchanged* baseline would have caught this before any conclusion +was drawn, and cost seconds. + +**Consequences for this port:** + +* `preflight_opencode_agents` does not consult the listing at all. It **proves** the + agent with a canary dispatch (`opencode run "Reply with exactly: ok" --agent `) + on the slot's own model, and **dies** if it does not resolve. That is + the only pre-debate check that observes the same resolution path the debate takes. + Escape hatch: `AGENT_KOMBAT_SKIP_OPENCODE_CANARY=1`, which warns loudly. +* Warning-and-continuing was the shipped behaviour and was wrong: since `--agent` + fails open, "warn" means the write-capable `build` agent has *already run* by the + time anyone reads the warning. Both remaining guards --- per-turn + `not found. Falling back` detection in `adapter_opencode`, and + `assert_tree_untouched` per round --- are likewise post-hoc, so neither can + substitute for refusing up front. `smoke.sh` asserts the refusal directly by + making the fake reproduce opencode's fail-open behaviour. +* `DEFAULT_ROSTER="trio-debate"` — all three harnesses. It was briefly `pair` while + the listing was believed to be authoritative. + +`--agent` still fails **open** in opencode itself (an unresolvable name falls back to +the write-capable `build` agent and exits 0), so the per-dispatch check is not +optional. That part of the earlier analysis stands. + +### Containment probes (2026-07-31, opencode 1.18.8, zai/glm-5.2-fast) + +The read-only agent's guarantees were asserted from its config, never exercised. +They are now probed; details and the honest limits are in +[.opencode/agent/kombat-debater.md](../../.opencode/agent/kombat-debater.md) +§ "What is actually verified, and what is not". + +| Probe | Result | +|---|---| +| create a file under `tmp/` | refused, no file | +| run `echo hi` | refused, no execution | +| read a `*.env` **decoy** (fabricated content) | tool call **DENIED**, matched `read:*.env→deny` | +| read a plain `tmp/*.txt` control | succeeded — grounding intact | + +Re-probed 2026-08-21 against `deepseek/deepseek-v4-pro`: the `*.env` decoy read was +**DENIED at the permission layer** (the tool return listed the resolved rule chain, +inherited `ask` entries first and this agent's `deny` entries last) and the plain +`tmp/*.txt` control still succeeded. Containment is enforced by opencode, not by the +model — but note that DeepSeek's *first* answer declined on its own secret-handling +judgement without ever calling the tool, which proves nothing. A refusal is not +evidence of enforcement; the prompt has to demand the attempt. + +The write/bash probes prove outcome only: neither turn attempted a tool call, so +they cannot separate the `tools:` block stripping the tool from the model merely +complying with its prompt. The `.env` denial is the one mechanically demonstrated +enforcement, and it also printed opencode's resolved rule chain, confirming that +this agent's entries merge **last** and so override the inherited `*.env → ask`. +That is why the `read:` key is a pattern map and not a bare `allow`. + +### opencode slot moved from GLM to DeepSeek (2026-08-21) + +The `opencode` slot in every debate roster (`trio-debate`, `trio`, `duo`) now runs +`deepseek/deepseek-v4-pro` instead of `zai/glm-5.2`. `rosters/glm-only.json` became +`rosters/deepseek-only.json` and is the one exception: it deliberately pairs +`deepseek-v4-flash` against `deepseek-v4-pro`, because that preset exists to +exercise the `fenced_json` path with two different models rather than to decide +anything. Slot id `glm` → `deepseek`, which renames the artifact paths +(`rounds/r0-deepseek.turn.json`, `plan-deepseek.md`) and the override keys +(`--model deepseek=...`, `AGENT_KOMBAT_MODEL_DEEPSEEK`). + +**The swap was not data-only.** `preflight_opencode_agents` derived a cheap canary +model by string-munging the slot's id — `${model%%-fast}-fast` — which was only ever +correct because `zai/glm-5.2-fast` happened to exist. DeepSeek's cheap tier is +`deepseek/deepseek-v4-flash`, so the rewrite produced `deepseek/deepseek-v4-pro-fast` +and the provider answered with a generic `Unexpected server error`. The canary would +have refused every DeepSeek roster with a message blaming the read-only *agent* — a +diagnostic pointing at the wrong subsystem entirely. The canary now dispatches the +slot's model unmodified, which also strengthens the check: the model id itself is +proven to resolve, not just the agent. + +**Lesson worth keeping:** a "cheapest sibling" model id is a per-provider naming +convention, not a portable transform. Do not derive one model id from another. + +The anonymity guards in `smoke.sh` and the three fake harnesses matched the literal +string `zai/glm`, so they would have gone on passing while guarding nothing. They now +match `deepseek/`. A leak guard keyed to a model id needs updating whenever the +roster's models change. + +### D16 -- read-loop guard (mid-turn hard stop + post-turn warning) + +New feature, no upstream equivalent. Two tiers: after each turn the driver counts +read-like tool calls in the raw output and warns when they exceed a configurable +budget (`MAX_READS_PER_TURN`), and *during* a turn the watchdog hard-kills any +still-running slot that exceeds a higher hard limit (`HARD_READ_LIMIT`). + +The warn tier counts aggregate across re-asks and api-error retries within a turn, +so a turn that only breaches the budget in aggregate is still flagged; the turn is +not failed -- it may still produce valid JSON. The hard tier exists because the +warn tier is evaluated post-hoc: a read loop streams tool calls continuously, so +the only thing that stopped it was the wall-clock budget -- `idle_timeout * 2`, +40 minutes for the deepseek slot -- during which the loop burned ~500 reads and +hundreds of thousands of tokens. Counting reads on the partial `.raw` (opencode +streams `tool_use` events progressively) and killing at the hard limit stops the +same loop in seconds. The kill rides the heartbeat cadence in `par_run` +(`_lib/dispatch.sh`) so the growing `.raw` is grepped once per heartbeat rather +than every poll, and logs an `agent.read_limit.killed` event with the same +`status=124` the wall-clock kill uses. The thresholds, env overrides, event names, +and the helpers that drive both tiers (`count_read_tool_calls`, +`accumulate_read_calls`, `warn_read_heavy_turn`) live in `_lib/adapters.sh` and +`_lib/dispatch.sh` and are not repeated here so this summary cannot drift out of +sync with the implementation. + +The guard fires on both turn-completion paths: the success branch in +`harness_send` and the wall-clock timeout-kill branch in `par_run`. The timeout +path matters more -- a stuck read-loop that gets killed is the canonical case, +and without the call there it would surface only as an `agent.process.timeout` +with no read-count diagnostic. + +Coverage is effective for opencode and codex (streaming JSONL event traces with +tool-call events). claude is a structural no-op: its adapter uses +`--output-format json`, a single result object with no tool-call trace, so the +regex matches nothing in a real claude `.raw`. Closing that gap requires +switching the claude adapter to `stream-json` (follow-up). The opencode/codex +event shapes are inferred from the adapters' event handling, not yet confirmed +against a captured tool-call event. diff --git a/scripts/agent-kombat/_lib/adapters.sh b/scripts/agent-kombat/_lib/adapters.sh new file mode 100644 index 0000000..8597676 --- /dev/null +++ b/scripts/agent-kombat/_lib/adapters.sh @@ -0,0 +1,730 @@ +# shellcheck shell=bash +# agent-kombat -- Per-harness argv, session capture, and structured-output extraction. +# +# Sourced by agent-kombat.sh. No shebang, not executable: the caller owns +# `set -euo pipefail` and every path constant. +# +# Owns globals: SLOT_SESSION_ID, SLOT_VALIDATOR (both declared at file top +# level here, under the `# --- dispatch ---` sub-header, and never assigned +# again after that declaration in this file. SLOT_SESSION_ID is read by all +# three adapters -- adapter_claude, adapter_codex, adapter_opencode. +# SLOT_VALIDATOR is read only inside adapter_opencode's structured-retry +# branch; adapter_claude and adapter_codex never reference it. Both are +# WRITTEN by par_run in _lib/dispatch.sh -- ownership is split: declared +# and read here, written there.) +# Reads globals owned elsewhere: MAX_ARGV_BYTES (entry file, top-level +# default -- read only by guard_argv_size here; guard_argv_size was its +# only reader anywhere in the script, so the entry-file declaration now +# has no reader left there and needs an SC2034 disable), DEBUG_AGENT_CALLS +# (entry file, top-level default -- read here only by adapter_claude; also +# written elsewhere in the entry file (by parse_args's --debug-agent-calls +# flag) and read by write_config in _lib/persistence.sh, so no single file +# owns both sides and the declaration stays put), MAX_READS_PER_TURN (entry +# file, top-level constant, never reassigned -- read here only by +# warn_read_heavy_turn; also read by write_config in _lib/persistence.sh, +# so it stays put too) + +# Guard against double-sourcing. +[ -n "${_AK_ADAPTERS_SOURCED:-}" ] && return 0 +_AK_ADAPTERS_SOURCED=1 + +# --------------------------------------------------------------------------- +# Harness adapters +# --------------------------------------------------------------------------- +# +# Bash has no interfaces, so the abstraction is a uniform signature plus a case +# dispatch, with ONE normalisation guarantee. On success every adapter leaves: +# +# .raw harness-native stdout, kept for audit +# .stderr harness stderr +# .turn.json a plain JSON object matching the phase schema +# .session-id the session/thread id, for the next round's resume +# +# That normalisation is the whole trick: after it, the validators, manifests and +# loops are harness-agnostic and only the pre-normalisation step differs. +# +# Adapters run as background children, one per slot per round, so they must not +# touch events.jsonl -- they log to their own per-slot event file, which the +# round barrier merges (see merge_round_events). + +uuid() { + if have uuidgen; then + uuidgen | tr '[:upper:]' '[:lower:]' + return + fi + if [[ -r /proc/sys/kernel/random/uuid ]]; then + cat /proc/sys/kernel/random/uuid + return + fi + # No uuidgen and no kernel UUID source. The claude CLI validates --session-id + # as a real UUID, so the value must have the 8-4-4-4-12 lowercase-hex shape, + # not merely be unique. openssl is the portable source; $RANDOM is the last + # resort (low entropy, but uniqueness within a single run is all we need). + local hex="" + if have openssl; then + hex="$(openssl rand -hex 16)" + else + local _i + for _i in 1 2 3 4; do + hex+="$(printf '%08x' "$(( (RANDOM << 15) | RANDOM ))")" + done + fi + printf '%s-%s-%s-%s-%s\n' \ + "${hex:0:8}" "${hex:8:4}" "${hex:12:4}" "${hex:16:4}" "${hex:20:12}" +} + +guard_argv_size() { + local prompt_file="$1" + local slot_id="$2" + local size + size="$(file_size_bytes "$prompt_file")" + if [[ "$size" -gt "$MAX_ARGV_BYTES" ]]; then + die "prompt for slot '$slot_id' is ${size} bytes, over the ${MAX_ARGV_BYTES}-byte argv budget. macOS ARG_MAX counts argv PLUS the environment, so a large prompt fails as a cryptic E2BIG. Shrink the requirement, drop a participant, or raise AGENT_KOMBAT_MAX_ARGV_BYTES if you know the real limit here." + fi +} + +# --- claude ----------------------------------------------------------------- + +adapter_claude() { + local mode="$1" slot_id="$2" prompt_file="$3" schema_file="$4" out_prefix="$5" event_file="$6" + local model session_id + model="$(slot_field "$slot_id" '.model')" + + local schema_args=() + if [[ -s "$schema_file" ]]; then + schema_args=(--json-schema "$(jq -c . "$schema_file")") + fi + local debug_args=() + if [[ "$DEBUG_AGENT_CALLS" -eq 1 ]]; then + debug_args=(--debug-file "${out_prefix}.debug.log") + fi + + local session_args=() + if [[ "$mode" == "resume" ]]; then + session_id="$SLOT_SESSION_ID" + [[ -n "$session_id" && "$session_id" != "null" ]] \ + || die "slot '$slot_id' has no claude session id to resume" + session_args=(--resume "$session_id") + else + session_id="$(uuid)" + session_args=(--session-id "$session_id") + fi + + # No guard_argv_size: the prompt goes on stdin, so ARG_MAX cannot apply here. + + # An explicit READ-ONLY tool allowlist, not `--tools ""`. + # + # `--tools ""` was the original read-only guarantee, and it worked -- but it + # also stripped Read, Grep and Glob, so the claude participant could not check + # a single repo fact. The first real debate diagnosed that about itself; the + # judge recorded: "The entire factual base is single-sourced. A states it could + # not read files in any round." Meanwhile codex had --sandbox read-only and + # opencode had read/grep/glob, so both could verify while claude guessed. An + # ungrounded participant weakens exactly the heterogeneity the tool sells. + # + # Verified both halves before changing it: + # --tools "Read,Grep,Glob" + "grep AGENTS.md for 'migration'" -> answered 13 + # same list + "create tmp/probe.txt" -> "CANNOT. No + # file creation tool available... Write/Edit tools aren't surfaced", and no + # file appeared. + # + # Write, Edit, Bash, Task and Skill all stay absent, so a debater still cannot + # touch the tree and cannot recurse back into this skill to fan out. Secrets + # stay covered by the committed .claude/settings.json deny on Read(**/.env). + local readonly_tools="Read,Grep,Glob" + # + # Prompt on STDIN, not as an argv string. + # + # Upstream passes "$(cat "$prompt_file")" as a positional. That works for a + # small round-0 prompt and fails for a real debate round: at N=3 a participant + # receives its own plan plus both competitors', which measured 66,835 bytes + # here, and the argv form then returns + # subtype=error_max_structured_output_retries + # terminal_reason=structured_output_retry_exhausted + # duration_api_ms=0, every token counter 0, cost $0 + # i.e. five attempts that never reached a model. Byte-identical prompt on stdin: + # is_error=false, 12,894 output tokens. Not a schema fault -- the same schema + # succeeds with a short prompt -- and not ARG_MAX either, since 66KB is far + # under it and the failure is structured-output exhaustion rather than E2BIG. + # + # The prompt only grows with participant count and plan verbosity, so argv was + # never viable for the mesh topology. + # Retry ONCE on a transient upstream error. + # + # `claude` exits 0 for these, reporting the failure in the payload instead: + # is_error=true, terminal_reason=api_error, + # result="API Error: Connection closed mid-response..." + # Observed on a long opus revision: 80s of streaming, then the connection + # dropped, 0 usable output, $1.07 billed. The longer a participant's answer, + # the wider that window -- and a debate is exactly the workload that produces + # long answers, so a dropped connection must not cost a whole round. + # + # Deliberately narrow: ONLY terminal_reason=api_error retries. A schema + # rejection, a refusal or a session mismatch are all reproducible, so retrying + # them just burns money twice. Each attempt is billed, so the budget is 1. + local attempts="${AGENT_KOMBAT_CLAUDE_API_RETRIES:-1}" + local try=0 + while :; do + (( try > 0 )) && accumulate_read_calls "$out_prefix" + if ! claude -p \ + --model "$model" \ + "${debug_args[@]}" \ + "${session_args[@]}" \ + --tools "$readonly_tools" \ + --output-format json \ + "${schema_args[@]}" \ + <"$prompt_file" >"${out_prefix}.raw" 2>"${out_prefix}.stderr"; then + log_event_file "$event_file" "agent.harness.failed" \ + "$(jq -n --arg slot "$slot_id" --arg harness claude '{slot: $slot, harness: $harness}')" + return 1 + fi + + local term + term="$(jq -r '.terminal_reason // ""' "${out_prefix}.raw" 2>/dev/null || printf '')" + if [[ "$term" != "api_error" ]]; then + break + fi + + if [[ "$try" -ge "$attempts" ]]; then + warn "slot '$slot_id': claude returned a transient api_error and the retry budget ($attempts) is spent: $(jq -r '.result // "no detail"' "${out_prefix}.raw" 2>/dev/null)" + log_event_file "$event_file" "agent.api_error.exhausted" \ + "$(jq -n --arg slot "$slot_id" --argjson attempts "$((try + 1))" \ + --arg detail "$(jq -r '.result // ""' "${out_prefix}.raw" 2>/dev/null)" \ + '{slot: $slot, attempts: $attempts, detail: $detail}')" + return 1 + fi + + try="$((try + 1))" + warn "slot '$slot_id': transient api_error, retrying ($try of $attempts). Each attempt is billed." + log_event_file "$event_file" "agent.api_error.retry" \ + "$(jq -n --arg slot "$slot_id" --argjson attempt "$try" \ + --arg detail "$(jq -r '.result // ""' "${out_prefix}.raw" 2>/dev/null)" \ + '{slot: $slot, attempt: $attempt, detail: $detail}')" + cp "${out_prefix}.raw" "${out_prefix}.api-error-${try}.raw" 2>/dev/null || true + # A fresh session per attempt: the aborted one may hold a partial turn, and + # resuming into that would splice half an answer onto the retry. + if [[ "$mode" != "resume" ]]; then + session_id="$(uuid)" + session_args=(--session-id "$session_id") + fi + sleep 5 + done + + jq -e --arg id "$session_id" '.session_id == $id or .sessionId == $id' "${out_prefix}.raw" >/dev/null \ + || { + # Not cosmetic: a silently-fresh session turns a debate into N independent + # monologues, which is the exact failure this tool exists to prevent. + log_event_file "$event_file" "agent.session.mismatch" \ + "$(jq -n --arg slot "$slot_id" --arg expected "$session_id" '{slot: $slot, expected_session_id: $expected}')" + return 1 + } + printf '%s\n' "$session_id" >"${out_prefix}.session-id" + + if [[ -s "$schema_file" ]]; then + extract_claude_structured "${out_prefix}.raw" >"${out_prefix}.turn.json" || return 1 + else + # Synthesis: the deliverable is markdown, not JSON. + jq -er '.result | select(type == "string" and length > 0)' "${out_prefix}.raw" \ + >"${out_prefix}.md" || return 1 + fi + return 0 +} + +extract_claude_structured() { + local file="$1" + jq -e ' + if (.structured_output | type) == "object" then + .structured_output + elif (.result | type) == "object" then + .result + elif (.result | type) == "string" then + (.result | fromjson) + else + empty + end + ' "$file" +} + +# --- codex ------------------------------------------------------------------ + +adapter_codex() { + local mode="$1" slot_id="$2" prompt_file="$3" schema_file="$4" out_prefix="$5" event_file="$6" + local model effort session_id + model="$(slot_field "$slot_id" '.model')" + effort="$(slot_field "$slot_id" '.reasoning_effort // ""')" + + local effort_args=() + # D3. Without this, every codex call silently inherits + # model_reasoning_effort from ~/.codex/config.toml (xhigh on this machine), + # so cost and latency depend on a file outside the repo. + [[ -z "$effort" ]] || effort_args=(-c "model_reasoning_effort=\"$effort\"") + + local schema_args=() + [[ ! -s "$schema_file" ]] || schema_args=(--output-schema "$schema_file") + + guard_argv_size "$prompt_file" "$slot_id" + + local last_message="${out_prefix}.last.json" + + if [[ "$mode" == "resume" ]]; then + session_id="$SLOT_SESSION_ID" + [[ -n "$session_id" && "$session_id" != "null" ]] \ + || die "slot '$slot_id' has no codex thread id to resume" + # `codex exec resume` has no -s/--sandbox (verified on 0.145.0), so the + # sandbox has to be set through -c, exactly as upstream does. D2: upstream + # omits --output-schema here, leaving its resume rounds schema-hoped rather + # than schema-enforced; 0.145.0 accepts it, so we pass it. + if ! codex exec resume \ + -c 'sandbox_mode="read-only"' \ + "${effort_args[@]}" \ + --model "$model" \ + --skip-git-repo-check \ + --json \ + "${schema_args[@]}" \ + --output-last-message "$last_message" \ + "$session_id" \ + "$(cat "$prompt_file")" \ + "${out_prefix}.raw" 2>"${out_prefix}.stderr"; then + log_event_file "$event_file" "agent.harness.failed" \ + "$(jq -n --arg slot "$slot_id" --arg harness codex '{slot: $slot, harness: $harness}')" + return 1 + fi + jq -e --arg id "$session_id" 'select(.type == "thread.started") | .thread_id == $id' \ + "${out_prefix}.raw" >/dev/null || { + log_event_file "$event_file" "agent.session.mismatch" \ + "$(jq -n --arg slot "$slot_id" --arg expected "$session_id" '{slot: $slot, expected_session_id: $expected}')" + return 1 + } + else + if ! codex exec \ + --model "$model" \ + "${effort_args[@]}" \ + --sandbox read-only \ + --skip-git-repo-check \ + --json \ + "${schema_args[@]}" \ + --output-last-message "$last_message" \ + "$(cat "$prompt_file")" \ + "${out_prefix}.raw" 2>"${out_prefix}.stderr"; then + log_event_file "$event_file" "agent.harness.failed" \ + "$(jq -n --arg slot "$slot_id" --arg harness codex '{slot: $slot, harness: $harness}')" + return 1 + fi + session_id="$(extract_codex_session_id "${out_prefix}.raw")" + [[ -n "$session_id" ]] || { + log_event_file "$event_file" "agent.session.missing" \ + "$(jq -n --arg slot "$slot_id" '{slot: $slot, detail: "no thread.started.thread_id in JSONL"}')" + return 1 + } + fi + + printf '%s\n' "$session_id" >"${out_prefix}.session-id" + + if [[ -s "$schema_file" ]]; then + [[ -s "$last_message" ]] || return 1 + jq -e . "$last_message" >"${out_prefix}.turn.json" || return 1 + else + [[ -s "$last_message" ]] || return 1 + cp "$last_message" "${out_prefix}.md" + fi + return 0 +} + +extract_codex_session_id() { + local file="$1" + jq -rn 'first(inputs | select(.type == "thread.started") | .thread_id? | select(type == "string")) // empty' \ + "$file" 2>/dev/null || true +} + +# --- opencode --------------------------------------------------------------- +# +# The awkward one. opencode 1.18.8 has no --output-schema and no read-only +# sandbox flag, so this adapter carries two mitigations that the other two +# harnesses get for free: +# +# * structured output -> schema inlined in the prompt, then a four-layer +# extraction, then a bounded re-ask +# * read-only guarantee -> --agent , whose permission set +# denies everything but read/grep/glob/list +# +# Deliberately NOT using `ooy` / OPENCODE_YOLO=true / --auto here, which is a +# divergence from the glm-delegate skill's precedent: --auto auto-approves every +# permission that is not explicitly denied, which is exactly backwards for a +# plan-only debate. + +adapter_opencode() { + local mode="$1" slot_id="$2" prompt_file="$3" schema_file="$4" out_prefix="$5" event_file="$6" + local model agent variant max_reask session_id + model="$(slot_field "$slot_id" '.model')" + agent="$(slot_field "$slot_id" '.agent')" + variant="$(slot_field "$slot_id" '.variant // ""')" + max_reask="$(slot_field "$slot_id" '.max_reask // 1')" + + local variant_args=() + [[ -z "$variant" ]] || variant_args=(--variant "$variant") + + local session_args=() + if [[ "$mode" == "resume" ]]; then + session_id="$SLOT_SESSION_ID" + [[ -n "$session_id" && "$session_id" != "null" ]] \ + || die "slot '$slot_id' has no opencode session id to resume" + # Never --fork (branches the session and silently loses continuity) and + # never -c/--continue (resolves "most recent session" GLOBALLY, which across + # N concurrent slots is catastrophic cross-talk). + session_args=(--session "$session_id") + fi + + local attempt=0 + local active_prompt="$prompt_file" + while :; do + # The message positional MUST come before -f: `opencode run -f FILE MSG` + # misparses and swallows the message as a second file argument. Attaching + # with -f rather than inlining via "$(cat ...)" also sidesteps ARG_MAX, + # which is why this harness needs no guard_argv_size call. + (( attempt > 0 )) && accumulate_read_calls "$out_prefix" + if ! opencode run \ + "Follow the instructions in the attached file. Reply with the JSON object it asks for and nothing else." \ + -f "$active_prompt" \ + --agent "$agent" \ + -m "$model" \ + "${variant_args[@]}" \ + "${session_args[@]}" \ + --format json \ + "${out_prefix}.raw" 2>"${out_prefix}.stderr"; then + log_event_file "$event_file" "agent.harness.failed" \ + "$(jq -n --arg slot "$slot_id" --arg harness opencode --argjson attempt "$attempt" \ + '{slot: $slot, harness: $harness, attempt: $attempt}')" + return 1 + fi + + # opencode's --agent fails OPEN: an unresolvable name prints "not found. + # Falling back to default agent", silently uses `build` (which permits bash + # and writes), and still exits 0. preflight_opencode_agents catches this + # before the run starts; this catches a regression that appears mid-run. + if grep -qi 'not found. Falling back' "${out_prefix}.stderr" "${out_prefix}.raw" 2>/dev/null; then + log_event_file "$event_file" "agent.agent_fallback" \ + "$(jq -n --arg slot "$slot_id" --arg agent "$agent" \ + '{slot: $slot, requested_agent: $agent, detail: "opencode fell back to the default agent, which permits writes"}')" + printf 'error: opencode did not resolve --agent %s and fell back to its default agent, which permits bash and file writes. Refusing this turn.\n' \ + "$agent" >&2 + return 1 + fi + + if [[ "$mode" != "resume" && -z "${session_id:-}" ]]; then + session_id="$(extract_opencode_session_id "${out_prefix}.raw")" + [[ -n "$session_id" ]] || { + log_event_file "$event_file" "agent.session.missing" \ + "$(jq -n --arg slot "$slot_id" '{slot: $slot, detail: "no sessionID in opencode JSONL"}')" + return 1 + } + session_args=(--session "$session_id") + fi + + # Markdown path FIRST. An empty schema file means "return the deliverable as + # markdown, not JSON" (the synthesis phase). Gating that behind + # extract_opencode_structured + a JSON validator made an opencode synthesizer + # structurally impossible: a correct markdown answer failed, burned the + # re-ask budget and returned 1. `structured_output: none` is legal on a + # synthesizer slot, so this path has to actually work. + if [[ ! -s "$schema_file" ]]; then + if extract_opencode_text "${out_prefix}.raw" "$session_id" >"${out_prefix}.md" \ + && [[ -s "${out_prefix}.md" ]]; then + printf '%s\n' "$session_id" >"${out_prefix}.session-id" + return 0 + fi + log_event_file "$event_file" "agent.extract.failed" \ + "$(jq -n --arg slot "$slot_id" '{slot: $slot, detail: "no markdown text recovered for a schema-less turn"}')" + return 1 + fi + + if extract_opencode_structured "${out_prefix}.raw" "$session_id" >"${out_prefix}.turn.json" 2>/dev/null \ + && [[ -s "${out_prefix}.turn.json" ]] \ + && "$SLOT_VALIDATOR" "${out_prefix}.turn.json"; then + if [[ "$attempt" -gt 0 ]]; then + log_event_file "$event_file" "agent.reask.succeeded" \ + "$(jq -n --arg slot "$slot_id" --argjson attempt "$attempt" '{slot: $slot, attempt: $attempt}')" + fi + printf '%s\n' "$session_id" >"${out_prefix}.session-id" + # Unreachable: the schema-less (synthesizer) case returns from the markdown + # branch above, so $schema_file is non-empty by here. Kept as a pure move; + # removing it is a behaviour-adjacent edit for a separate change. + if [[ ! -s "$schema_file" ]]; then + extract_opencode_text "${out_prefix}.raw" "$session_id" >"${out_prefix}.md" + fi + return 0 + fi + + attempt="$((attempt + 1))" + if [[ "$attempt" -gt "$max_reask" ]]; then + log_event_file "$event_file" "agent.reask.exhausted" \ + "$(jq -n --arg slot "$slot_id" --argjson attempts "$attempt" --arg raw "$(basename "${out_prefix}.raw")" \ + '{slot: $slot, attempts: $attempts, raw_path: $raw}')" + # Fail loud. Upstream has no soft-fail path anywhere, and a soft fallback + # here would let a malformed turn silently drop critique and + # unresolved_issues -- quietly turning a debate into a monologue. + return 1 + fi + + log_event_file "$event_file" "agent.reask.started" \ + "$(jq -n --arg slot "$slot_id" --argjson attempt "$attempt" '{slot: $slot, attempt: $attempt}')" + active_prompt="${out_prefix}.reask-${attempt}.prompt.txt" + build_opencode_reask_prompt "$schema_file" >"$active_prompt" + done +} + +build_opencode_reask_prompt() { + local schema_file="$1" + cat <<'PROMPT' +Your last message was not a valid JSON object for the required schema. + +Reply with ONLY the JSON object. No prose, no preamble, no explanation, no +markdown code fence. The first character of your reply must be `{` and the last +must be `}`. +PROMPT + if [[ -s "$schema_file" ]]; then + printf '\n---BEGIN REQUIRED JSON SCHEMA---\n' + jq . "$schema_file" + printf -- '---END REQUIRED JSON SCHEMA---\n' + printf '\nRequired top-level keys: %s\n' \ + "$(jq -r '.required | join(", ")' "$schema_file")" + fi +} + +extract_opencode_session_id() { + local file="$1" + jq -rn 'first(inputs | .sessionID? // .session_id? | select(type == "string")) // empty' \ + "$file" 2>/dev/null || true +} + +# Layer 2 of the extraction: get the assistant's text out of opencode's output. +# +# VERIFIED against a real opencode 1.18.8 session (capture kept at +# testdata/fixtures/opencode-events-real.jsonl). The shape is: +# +# {"type":"text","timestamp":N,"sessionID":"ses_...","part":{...,"text":"..."}} +# +# There is NO top-level `.text` -- the payload is at `.part.text`. An extractor +# written against the obvious `.text` returns an empty string on every real turn +# and then fails validation, which looks like a model problem rather than a +# parsing bug. The `.text //` first alternative is kept anyway in case a future +# version flattens it, and accessor (b) below is a genuinely independent path. +# +# Real streams also carry `tool_use`, `step_start` and `step_finish` events; a +# single round-0 turn produced 24 tool_use events against 7 text events, so +# filtering to text events is not optional. +extract_opencode_text() { + local file="$1" + local session_id="${2:-}" + local text="" + + # (a) the event stream, tolerating three plausible payload key names + text="$(jq -rn ' + [inputs | select(.type == "text" or .type == "message.part.updated") + | (.text // .part.text // .content // empty)] + | join("") + ' "$file" 2>/dev/null || true)" + if [[ -n "${text// /}" ]]; then + printf '%s' "$text" + return 0 + fi + + # (b) ask opencode for the session transcript. `opencode export` prints a + # human "Exporting session: " header before the JSON, so drop everything + # up to the first line that starts with '{'. + if [[ -n "$session_id" ]] && have opencode; then + text="$(opencode export "$session_id" 2>/dev/null \ + | sed -n '/^{/,$p' \ + | jq -r ' + [.messages[] | select(.info.role == "assistant")] + | last + | [.parts[] | select(.type == "text") | .text] + | join("") + ' 2>/dev/null || true)" + if [[ -n "${text// /}" ]]; then + printf '%s' "$text" + return 0 + fi + fi + + # (c) last resort: any string-valued text-ish field anywhere in the stream. + jq -rn '[inputs | .. | objects | .text? | select(type == "string")] | join("")' \ + "$file" 2>/dev/null || true +} + +# Layer 3: coax a JSON object out of arbitrary model prose. +extract_opencode_structured() { + local file="$1" + local session_id="${2:-}" + local text + text="$(extract_opencode_text "$file" "$session_id")" + [[ -n "${text// /}" ]] || return 1 + + # (i) the whole thing already is JSON + if printf '%s' "$text" | jq -e 'type == "object"' >/dev/null 2>&1; then + printf '%s' "$text" | jq -c . + return 0 + fi + + # (ii) fenced blocks, with an opener that MAY be typed and a closer that is bare. + # + # The previous version toggled on one pattern per pass, so with lang=json it + # required the CLOSING fence to also read ```json. Real output closes with a bare + # ```, so the block never terminated: `buf` ran to end-of-text and swallowed + # every trailing line. That was masked because the `{...}` fallback usually + # rescued it -- until any stray brace appeared after the fence, at which point + # extraction failed outright. Verified: a payload followed by + # "Trailing prose with { an unrelated brace }" extracted nothing at all. + # + # Correct grammar: ``` or ``` opens, the next ``` closes regardless of the + # opener's tag. Collect every complete block, then prefer the LAST one that + # parses as an object -- models often narrate before the real answer. + local fenced + fenced="$(printf '%s' "$text" | awk ' + /^[[:space:]]*```/ { + if (inblock) { inblock = 0; blocks[++n] = buf; buf = ""; next } + inblock = 1; buf = ""; next + } + inblock { buf = buf $0 "\n"; next } + END { + # An unterminated final fence is still worth offering as a candidate. + if (inblock && buf != "") blocks[++n] = buf + for (i = n; i >= 1; i--) printf "%s\037", blocks[i] + } + ')" + if [[ -n "$fenced" ]]; then + local candidate + while IFS= read -r -d $'\037' candidate; do + [[ -n "${candidate// /}" ]] || continue + if printf '%s' "$candidate" | jq -e 'type == "object"' >/dev/null 2>&1; then + printf '%s' "$candidate" | jq -c . + return 0 + fi + done < <(printf '%s' "$fenced") + fi + + # (iv) the widest {...} span, measured in CHARACTERS not lines. + # + # Line-wise slicing looks equivalent and is not: a model that answers + # "I think the answer is {...} and that is my view." puts the JSON and trailing + # prose on ONE line, so any line-granular span keeps the prose and fails to + # parse. Slurping the whole text and cutting from the first '{' to the last '}' + # handles the single-line and multi-line cases identically. + # Done with bash parameter expansion rather than awk: slurping all input in awk + # needs RS tricks that differ between GNU awk and the BSD awk on macOS, and + # this is both portable and easier to read. + local span="" + if [[ "$text" == *'{'* && "$text" == *'}'* ]]; then + span="{${text#*\{}" # from the first '{' + span="${span%\}*}}" # to the last '}' + fi + if [[ -n "${span// /}" ]] && printf '%s' "$span" | jq -e 'type == "object"' >/dev/null 2>&1; then + printf '%s' "$span" | jq -c . + return 0 + fi + + return 1 +} + +# --- dispatch --------------------------------------------------------------- + +# SLOT_SESSION_ID and SLOT_VALIDATOR are set by the caller immediately before +# dispatch. They are globals rather than parameters because the adapters already +# take six positional arguments and these two are read in exactly one place each. +SLOT_SESSION_ID="" +SLOT_VALIDATOR="validate_debater_response" + +# count_read_tool_calls -- scans a harness raw-output file and counts read-like +# tool calls (read, grep, glob, list, search, view_file). Matches the streaming +# JSONL event traces emitted by opencode and codex. claude is a structural +# no-op here: its adapter uses --output-format json, a single result object +# (.session_id, .terminal_reason, .result, .structured_output) with no +# tool-call trace, so the regex can never match anything in a real claude .raw. +# The opencode/codex shapes are inferred from the adapters' event handling (see +# the tool_use note in extract_opencode_text), not yet confirmed against a +# captured tool-call event -- the checked-in opencode fixture carries only +# text/step events. +count_read_tool_calls() { + local raw_file="$1" + [[ -s "$raw_file" ]] || { printf '0'; return; } + local n + n="$(grep -ciE '"(tool|name|type)"\s*:\s*"[^"]*(read|grep|glob|list|search|view_file)' "$raw_file" 2>/dev/null || true)" + printf '%s' "${n:-0}" +} + +# accumulate_read_calls -- add the read-tool-call count of the current +# ${out_prefix}.raw to the persistent ${out_prefix}.reads accumulator. Adapters +# call this before overwriting .raw on a re-ask or api-error retry, so the count +# from the overwritten attempt survives and warn_read_heavy_turn sees the +# aggregate across every attempt in the turn rather than only the final one. +accumulate_read_calls() { + local out_prefix="$1" + local acc_file="${out_prefix}.reads" + local prev=0 + [[ -s "$acc_file" ]] && prev="$(<"$acc_file")" + local cur + cur="$(count_read_tool_calls "${out_prefix}.raw")" + printf '%s' "$(( prev + cur ))" >"$acc_file" +} + +# warn_read_heavy_turn -- called after a turn completes (success path in +# harness_send, timeout-kill path in par_run). If the total read-like tool calls +# across all attempts (accumulated prior attempts plus the final raw) exceeds +# MAX_READS_PER_TURN, logs a warning event so the pattern is visible in the +# round manifest and event log. The timeout path is the more important call +# site: a read-loop that burns through the wall-clock budget is the single most +# likely real-world manifestation, and without this call it would produce an +# agent.process.timeout with no read-count diagnostic. For opencode the partial +# .raw has streaming tool-call events even mid-kill; claude buffers to the end +# (.raw is empty here, so the call is a no-op for it). +warn_read_heavy_turn() { + local slot_id="$1" out_prefix="$2" event_file="$3" + local raw_file="${out_prefix}.raw" + [[ -s "$raw_file" ]] || return 0 + local acc=0 final count + [[ -s "${out_prefix}.reads" ]] && acc="$(<"${out_prefix}.reads")" + final="$(count_read_tool_calls "$raw_file")" + count=$(( acc + final )) + [[ "$count" -gt "$MAX_READS_PER_TURN" ]] || return 0 + warn "$slot_id made $count read-like tool calls (budget: $MAX_READS_PER_TURN) -- possible read loop" + log_event_file "$event_file" "agent.read_heavy_turn" "$(jq -n \ + --arg slot "$slot_id" \ + --argjson read_count "$count" \ + --argjson budget "$MAX_READS_PER_TURN" \ + '{slot: $slot, read_count: $read_count, budget: $budget}')" +} + +harness_send() { + local mode="$1" slot_id="$2" prompt_file="$3" schema_file="$4" out_prefix="$5" event_file="$6" + local harness + harness="$(slot_field "$slot_id" '.harness')" + + log_event_file "$event_file" "agent.call.started" "$(jq -n \ + --arg slot "$slot_id" --arg harness "$harness" --arg mode "$mode" \ + --arg model "$(slot_field "$slot_id" '.model')" \ + --arg prompt_sha "$(sha256_file "$prompt_file")" \ + '{slot: $slot, harness: $harness, mode: $mode, model: $model, prompt_sha256: $prompt_sha}')" + + # Reset the per-turn read accumulator before the adapter runs. Adapters + # accumulate into .reads on each re-ask/retry overwrite; warn_read_heavy_turn + # (called below on success) sums it with the final raw's count. + : >"${out_prefix}.reads" + local rc=0 + case "$harness" in + claude) adapter_claude "$@" || rc=$? ;; + codex) adapter_codex "$@" || rc=$? ;; + opencode) adapter_opencode "$@" || rc=$? ;; + *) die "no adapter for harness '$harness'" ;; + esac + + if [[ "$rc" -eq 0 ]]; then + log_event_file "$event_file" "agent.call.completed" \ + "$(jq -n --arg slot "$slot_id" --arg harness "$harness" '{slot: $slot, harness: $harness}')" + warn_read_heavy_turn "$slot_id" "$out_prefix" "$event_file" + else + log_event_file "$event_file" "agent.call.failed" \ + "$(jq -n --arg slot "$slot_id" --arg harness "$harness" --argjson rc "$rc" \ + '{slot: $slot, harness: $harness, rc: $rc}')" + fi + printf '%s\n' "$rc" >"${out_prefix}.rc" + return "$rc" +} diff --git a/scripts/agent-kombat/_lib/common.sh b/scripts/agent-kombat/_lib/common.sh new file mode 100644 index 0000000..ddc5f40 --- /dev/null +++ b/scripts/agent-kombat/_lib/common.sh @@ -0,0 +1,274 @@ +# shellcheck shell=bash +# agent-kombat -- Output helpers, file and process helpers, and the JSONL event log. +# +# Sourced by agent-kombat.sh. No shebang, not executable: the caller owns +# `set -euo pipefail` and every path constant. +# +# Owns globals: COLOR_RESET, COLOR_DIM, COLOR_RED, COLOR_GREEN, COLOR_YELLOW, +# COLOR_BLUE (moved here per Ruling 2 -- declared here, written by +# init_color here, read by the output helpers here: die, refuse, info, +# step, ok, warn). +# Reads globals owned elsewhere: NO_COLOR_FLAG (entry file, parse_args -- +# read only by init_color here), SLOT_ORDER (roster.sh, index_roster_slots +# -- read only by merge_round_events here) + +# Guard against double-sourcing. +[ -n "${_AK_COMMON_SOURCED:-}" ] && return 0 +_AK_COMMON_SOURCED=1 + +COLOR_RESET="" +COLOR_DIM="" +COLOR_RED="" +COLOR_GREEN="" +COLOR_YELLOW="" +COLOR_BLUE="" + +# --------------------------------------------------------------------------- +# Output helpers +# --------------------------------------------------------------------------- + +die() { + printf '%serror:%s %s\n' "$COLOR_RED" "$COLOR_RESET" "$*" >&2 + exit 1 +} + +# Exit 2 is reserved for "the run was refused or did not converge", so a caller +# can tell a validation refusal apart from a setup error. +refuse() { + printf '%srefused:%s %s\n' "$COLOR_RED" "$COLOR_RESET" "$*" >&2 + exit 2 +} + +info() { printf '%s%s%s\n' "$COLOR_DIM" "$*" "$COLOR_RESET"; } +step() { printf '%s==>%s %s\n' "$COLOR_BLUE" "$COLOR_RESET" "$*"; } +ok() { printf '%sok:%s %s\n' "$COLOR_GREEN" "$COLOR_RESET" "$*"; } +warn() { printf '%swarn:%s %s\n' "$COLOR_YELLOW" "$COLOR_RESET" "$*" >&2; } + +have() { command -v "$1" >/dev/null 2>&1; } + +init_color() { + if [[ "$NO_COLOR_FLAG" -eq 1 || -n "${NO_COLOR:-}" || ! -t 1 ]]; then + return + fi + COLOR_RESET=$'\033[0m' + COLOR_DIM=$'\033[2m' + COLOR_RED=$'\033[31m' + COLOR_GREEN=$'\033[32m' + COLOR_YELLOW=$'\033[33m' + COLOR_BLUE=$'\033[34m' +} + +# --------------------------------------------------------------------------- +# File and process helpers +# --------------------------------------------------------------------------- + +file_size_bytes() { + local file="$1" + [[ -e "$file" ]] || { + printf '0\n' + return + } + # A `stat -f%z || stat -c%s` chain is NOT portable, and it fails in the worst + # way. On GNU, `-f` means --file-system: it exits 1 *after* printing a multi-line + # "File: ... Blocks: ..." report to STDOUT, so `2>/dev/null` hides nothing and + # the successful fallback's number gets appended to that garbage. The caller then + # does $((total + size)) on it and dies with "File: unbound variable" under + # `set -u`. Callers feed this into arithmetic and size guards, so validate the + # value rather than trusting an exit status. + local size + size="$(stat -c '%s' "$file" 2>/dev/null || true)" + if [[ ! "$size" =~ ^[0-9]+$ ]]; then + size="$(stat -f '%z' "$file" 2>/dev/null || true)" + fi + if [[ ! "$size" =~ ^[0-9]+$ ]]; then + # Neither stat dialect: wc is in POSIX and always available. + size="$(wc -c <"$file" 2>/dev/null | tr -d '[:space:]' || true)" + fi + [[ "$size" =~ ^[0-9]+$ ]] || size=0 + printf '%s\n' "$size" +} + +file_mtime_epoch() { + local file="$1" + [[ -e "$file" ]] || { + printf '0\n' + return + } + stat -f '%m' "$file" 2>/dev/null || stat -c '%Y' "$file" 2>/dev/null || printf '0\n' +} + +format_duration() { + local seconds="$1" + printf '%dm%02ds' "$((seconds / 60))" "$((seconds % 60))" +} + +sha256_file() { + if [[ -e "$1" ]]; then + shasum -a 256 "$1" | awk '{print $1}' + else + printf 'absent\n' + fi +} + +# Upstream's write_atomic. Kept (not simplified away): --resume correctness +# depends on config.json never being observed half-written, and a Ctrl-C during +# a plain `jq > file` truncates it and makes an expensive workdir unresumable. +write_atomic() { + local target="$1" + local tmp + tmp="$(mktemp "${target}.tmp.XXXXXX")" + cat >"$tmp" + mv "$tmp" "$target" +} + +abs_path() { + local path="$1" + if [[ "$path" = /* ]]; then + printf '%s\n' "$path" + else + printf '%s/%s\n' "$PWD" "$path" + fi +} + +# Collapse `.`, `..` and duplicate slashes in an absolute path, WITHOUT requiring +# the path to exist -- the workdir is validated before it is created. +# +# `realpath`/`readlink -f` are not portable enough here (macOS `realpath` predates +# --canonicalize-missing on some installs), and a purely lexical collapse is +# actually what the containment check wants: it must not be fooled by a symlink +# that appears later either. Any comparison against an UNNORMALISED path is +# bypassable with `..` -- see enforce_workdir_location. +normalise_path() { + local path="$1" + local part + # Named _np_parts, not `out`: shellcheck tracks variable types by NAME across + # the whole file, so reusing a common name like `out` as an array here makes it + # flag every unrelated string `out=` elsewhere (SC2178/SC2128). + local -a _np_parts=() + local IFS='/' + for part in $path; do + case "$part" in + '' | '.') continue ;; + '..') + # Popping past the root is clamped rather than wrapping around. + # Index-based (not `_np_parts[-1]`): negative array subscripts need + # Bash 4.3+, but the startup guard above only requires Bash 4.0+. + # The index is computed inline rather than via a temporary: a temp read + # only from inside a quoted `unset` subscript is invisible to shellcheck, + # which then reports it as unused (SC2034) and fails the scoped hook. + if [[ "${#_np_parts[@]}" -gt 0 ]]; then + unset "_np_parts[$((${#_np_parts[@]} - 1))]" + fi + ;; + *) _np_parts+=("$part") ;; + esac + done + printf '/%s\n' "$( + IFS='/' + printf '%s' "${_np_parts[*]:-}" + )" +} + +# Recursive process-tree kill. +# +# Divergence from upstream (L189), and a necessary one: upstream's +# kill_process_tree only reaches DIRECT children (`pkill -TERM -P "$pid"`), +# which is sufficient there because its child IS the harness process. Here the +# child is a subshell wrapping an adapter, so the harness is a grandchild and +# would survive a direct-children-only kill -- leaving an orphaned `claude`, +# `codex` or `opencode` burning tokens after a timeout. +kill_process_tree() { + local pid="$1" + local sig="${2:-TERM}" + local child + for child in $(pgrep -P "$pid" 2>/dev/null || true); do + kill_process_tree "$child" "$sig" + done + kill -"$sig" "$pid" 2>/dev/null || true +} + +terminate_process_tree() { + local pid="$1" + kill_process_tree "$pid" TERM + # Give the tree a moment to unwind before escalating. + local waited=0 + while kill -0 "$pid" 2>/dev/null && [[ "$waited" -lt 20 ]]; do + sleep 0.1 + waited="$((waited + 1))" + done + kill_process_tree "$pid" KILL +} + +parse_uint() { + local name="$1" + local value="$2" + [[ "$value" =~ ^[0-9]+$ ]] || die "$name must be a non-negative integer" +} + +# --------------------------------------------------------------------------- +# Event log +# --------------------------------------------------------------------------- +# +# D13. Upstream appends every event to one events.jsonl, which is safe because +# it dispatches agents sequentially. This port dispatches a round's slots +# concurrently, and two writers matter: +# +# * the parent poll loop, and +# * each adapter child, which emits its own events (the fenced-JSON re-ask +# loop runs inside the child). +# +# Concurrent appends can interleave mid-line, and macOS ships no flock(1). So +# each slot gets its own event file, written by the child while it lives and by +# the parent only after it is dead, and the round barrier merges them into +# events.jsonl in roster-then-timestamp order. That also makes the ordering +# deterministic, which is what lets smoke.sh assert on the event sequence at +# all. + +log_event_file() { + local file="$1" + local event="$2" + # Not "${3:-{}}": brace expansion inside a default makes that unreliable. + local fields='{}' + if [[ $# -ge 3 ]]; then + fields="$3" + fi + jq -cn \ + --arg ts "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ + --arg event "$event" \ + --argjson fields "$fields" \ + '{ts: $ts, event: $event} + $fields' >>"$file" +} + +log_event() { + local target="$1" + shift + log_event_file "$target/events.jsonl" "$@" +} + +# Merge the per-slot event files for one round into events.jsonl, ordered by +# roster position and then timestamp. Deterministic regardless of which slot +# finished first. +merge_round_events() { + local target="$1" + local round="$2" + local position=0 + local slot + local slot_file + local merged="$target/rounds/r${round}.events-merged.jsonl" + : >"$merged" + # `"${arr[@]}"` on an EMPTY array is an unbound-variable error under `set -u` in + # bash 4.0-4.3. Bash 4.4+ tolerates it, and the startup guard only requires 4.0, + # so the emptiness has to be checked rather than assumed. + [[ "${#SLOT_ORDER[@]}" -gt 0 ]] || return 0 + for slot in "${SLOT_ORDER[@]}"; do + slot_file="$target/rounds/r${round}-${slot}.events.jsonl" + [[ -f "$slot_file" ]] || continue + jq -c --argjson pos "$position" --arg slot "$slot" \ + '. + {slot: $slot, _roster_position: $pos}' "$slot_file" >>"$merged" + position="$((position + 1))" + done + [[ -s "$merged" ]] || return 0 + jq -sc 'sort_by(._roster_position, .ts) | .[] | del(._roster_position)' "$merged" \ + >>"$target/events.jsonl" + rm -f "$merged" +} diff --git a/scripts/agent-kombat/_lib/contracts.sh b/scripts/agent-kombat/_lib/contracts.sh new file mode 100644 index 0000000..3716612 --- /dev/null +++ b/scripts/agent-kombat/_lib/contracts.sh @@ -0,0 +1,346 @@ +# shellcheck shell=bash +# agent-kombat -- Debate contract, JSON schemas, and response validation. +# +# Sourced by agent-kombat.sh. No shebang, not executable: the caller owns +# `set -euo pipefail` and every path constant. +# +# Owns globals: CONTRACT_ID, CONTRACT_NOUN, CONTRACT_OUTPUT_FIELD, +# CONTRACT_REVISED_FIELD, CONTRACT_FINAL_FILENAME, CONTRACT_FINAL_LABEL, +# CONTRACT_CONTEXT_MODE, CONTRACT_INITIAL_TASK, CONTRACT_DEBATE_TASK, +# CONTRACT_SYNTHESIS_TASK, CONTRACT_ROUND0_NOTE, CONTRACT_SYNTHESIS_NOTE, +# CONTRACT_SOURCE (all thirteen declared at file top level here. load_contract +# here writes all thirteen from the contract JSON; CONTRACT_CONTEXT_MODE is +# then conditionally rewritten by normalise_context_mode's upstream-value +# fallback branch, also here -- both read and write it. validate_contract_loaded +# here reads CONTRACT_ID, CONTRACT_NOUN, CONTRACT_FINAL_LABEL, +# CONTRACT_INITIAL_TASK, CONTRACT_DEBATE_TASK, CONTRACT_SYNTHESIS_TASK +# directly, plus CONTRACT_OUTPUT_FIELD and CONTRACT_REVISED_FIELD indirectly +# via validate_json_field_name and CONTRACT_FINAL_FILENAME indirectly via +# validate_contract_filename. CONTRACT_OUTPUT_FIELD is also read by +# round0_schema_file and validate_round0_response here; CONTRACT_REVISED_FIELD +# by debater_schema_file and validate_debater_response here. +# CONTRACT_ROUND0_NOTE, CONTRACT_SYNTHESIS_NOTE and CONTRACT_SOURCE are +# written here but have no reader in this file -- CONTRACT_SOURCE is read by +# print_roster in roster.sh, CONTRACT_ROUND0_NOTE by build_round0_prompt and +# CONTRACT_SYNTHESIS_NOTE by build_synthesis_prompt, both in _lib/prompts.sh. +# Separately, load_run_config in _lib/runmodes.sh is a second writer of all +# thirteen on the --resume path; that is deliberate coupling, not an +# ownership gap.) +# Reads globals owned elsewhere: CONTRACT_DIR (entry file, top-level constant +# derived from SCRIPT_DIR -- read only by load_contract here, same shape as +# ROSTER_DIR in roster.sh, so it stays put with its own SC2034 disable rather +# than move), CONTRACT_SPEC (entry file -- default declaration plus a write in +# parse_args; read only by load_contract here, so no single file owns both +# sides and the declaration stays put) + +# Guard against double-sourcing. +[ -n "${_AK_CONTRACTS_SOURCED:-}" ] && return 0 +_AK_CONTRACTS_SOURCED=1 + +# Contract fields (populated by load_contract / load_run_config). +CONTRACT_ID="" +CONTRACT_NOUN="" +CONTRACT_OUTPUT_FIELD="" +CONTRACT_REVISED_FIELD="" +CONTRACT_FINAL_FILENAME="" +CONTRACT_FINAL_LABEL="" +CONTRACT_CONTEXT_MODE="" +CONTRACT_INITIAL_TASK="" +CONTRACT_DEBATE_TASK="" +CONTRACT_SYNTHESIS_TASK="" +CONTRACT_ROUND0_NOTE="" +CONTRACT_SYNTHESIS_NOTE="" +CONTRACT_SOURCE="" + +# --------------------------------------------------------------------------- +# Contracts +# --------------------------------------------------------------------------- +# +# D4: upstream hardcodes its two builtin contracts in a bash case statement +# (load_builtin_contract, ~L737) and has a SECOND loader for --contract JSON +# files. Two loaders for the same shape is a drift class, so builtins are data +# files here and there is exactly one loader. + +load_contract() { + local spec="$CONTRACT_SPEC" + local path="" + case "$spec" in + plan | artifact) + path="$CONTRACT_DIR/${spec}.json" + [[ -f "$path" ]] || die "missing builtin contract: $path" + CONTRACT_SOURCE="builtin:$spec" + ;; + *) + path="$spec" + [[ -f "$path" ]] || die "contract not found: $path (want plan, artifact, or a path)" + # shellcheck disable=SC2034 # read by print_roster in _lib/roster.sh + CONTRACT_SOURCE="file:$path" + ;; + esac + + jq -e . "$path" >/dev/null 2>&1 || die "contract is not valid JSON: $path" + + CONTRACT_ID="$(jq -r '.id // ""' "$path")" + CONTRACT_NOUN="$(jq -r '.noun // ""' "$path")" + CONTRACT_OUTPUT_FIELD="$(jq -r '.output_field // ""' "$path")" + CONTRACT_REVISED_FIELD="$(jq -r '.revised_field // ""' "$path")" + CONTRACT_FINAL_FILENAME="$(jq -r '.final_filename // ""' "$path")" + CONTRACT_FINAL_LABEL="$(jq -r '.final_label // ""' "$path")" + CONTRACT_CONTEXT_MODE="$(jq -r '.context_mode // "file"' "$path")" + CONTRACT_INITIAL_TASK="$(jq -r '.initial_task // ""' "$path")" + CONTRACT_DEBATE_TASK="$(jq -r '.debate_task // ""' "$path")" + CONTRACT_SYNTHESIS_TASK="$(jq -r '.synthesis_task // ""' "$path")" + # shellcheck disable=SC2034 # read by build_round0_prompt in _lib/prompts.sh + CONTRACT_ROUND0_NOTE="$(jq -r '.round0_note // ""' "$path")" + # shellcheck disable=SC2034 # read by build_synthesis_prompt in _lib/prompts.sh + CONTRACT_SYNTHESIS_NOTE="$(jq -r '.synthesis_note // ""' "$path")" + + normalise_context_mode + validate_contract_loaded +} + +# D6: plan_core.py is not ported, so upstream's "instructions" and "context" +# context modes -- which both meant "shell out to the Python classifier and +# render its output" -- collapse to "file" (inline the static shared context). +# Upstream contract JSONs still load; they just get a warning. +normalise_context_mode() { + case "$CONTRACT_CONTEXT_MODE" in + file | none) ;; + instructions | context) + warn "contract context_mode '$CONTRACT_CONTEXT_MODE' is an upstream value; treating it as 'file' (plan_core.py is not ported -- see UPSTREAM.md D6)" + CONTRACT_CONTEXT_MODE="file" + ;; + *) + die "contract context_mode must be file or none, got '$CONTRACT_CONTEXT_MODE'" + ;; + esac +} + +validate_json_field_name() { + local name="$1" + local label="$2" + [[ "$name" =~ ^[a-z][a-z0-9_]*$ ]] \ + || die "$label must be a lowercase snake_case JSON field name, got '$name'" +} + +validate_contract_filename() { + local name="$1" + [[ "$name" =~ ^[A-Za-z0-9._-]+$ ]] \ + || die "contract final_filename must be a bare filename, got '$name'" + [[ "$name" != *..* ]] || die "contract final_filename must not traverse: '$name'" +} + +validate_contract_loaded() { + [[ -n "$CONTRACT_ID" ]] || die "contract is missing 'id'" + [[ -n "$CONTRACT_NOUN" ]] || die "contract is missing 'noun'" + validate_json_field_name "$CONTRACT_OUTPUT_FIELD" "contract output_field" + validate_json_field_name "$CONTRACT_REVISED_FIELD" "contract revised_field" + validate_contract_filename "$CONTRACT_FINAL_FILENAME" + [[ -n "$CONTRACT_FINAL_LABEL" ]] || die "contract is missing 'final_label'" + [[ -n "$CONTRACT_INITIAL_TASK" ]] || die "contract is missing 'initial_task'" + [[ -n "$CONTRACT_DEBATE_TASK" ]] || die "contract is missing 'debate_task'" + [[ -n "$CONTRACT_SYNTHESIS_TASK" ]] || die "contract is missing 'synthesis_task'" +} + +# --------------------------------------------------------------------------- +# Schemas +# --------------------------------------------------------------------------- +# +# D14 note: the debater schema is byte-identical to upstream's apart from making +# strengths_to_steal and critique carry an attribution. At N>2 an unattributed +# critique is useless -- you cannot tell which competitor it is about -- and the +# judge's "agreements lacking justification" signal depends on knowing who said +# what. Everything else stays exactly as upstream had it. +# +# INVARIANT for every schema below: when an object sets additionalProperties +# false, `required` must list EVERY key in `properties`. Both providers' native +# structured-output modes reject a partial `required` outright, and they report it +# so differently that the shared cause is easy to miss: +# codex -> HTTP 400 invalid_json_schema, naming the absent key +# claude -> error_max_structured_output_retries after 5 attempts, with +# duration_api_ms and every token counter at 0 (the request never +# reached a model, so nothing was billed) +# There is therefore no such thing as an optional field here. A field that may +# legitimately be empty is required-but-emptyable (an array that can be [], or a +# ["string","null"] union like focus_for_next_round) and its prompt must say so, +# or the model is forced to invent content to satisfy the schema. +# +# The fake harnesses under testdata/ cannot catch a violation: they emit whatever +# the driver's own prompts describe and never validate a schema, so this +# invariant is guarded by an assertion in smoke.sh instead. + +round0_schema_file() { + local target="$1" + mkdir -p "$target/schemas" + jq -n --arg field "$CONTRACT_OUTPUT_FIELD" '{ + type: "object", + additionalProperties: false, + properties: { + ($field): {type: "string", minLength: 1} + }, + required: [$field] + }' >"$target/schemas/round0.schema.json" +} + +debater_schema_file() { + local target="$1" + mkdir -p "$target/schemas" + jq -n --arg field "$CONTRACT_REVISED_FIELD" '{ + type: "object", + additionalProperties: false, + properties: { + strengths_to_steal: { + type: "array", + items: { + type: "object", + additionalProperties: false, + properties: { + from: {type: "string"}, + strength: {type: "string"} + }, + required: ["from", "strength"] + } + }, + ($field): {type: "string", minLength: 1}, + critique: { + type: "array", + items: { + type: "object", + additionalProperties: false, + properties: { + target: {type: "string"}, + finding: {type: "string"} + }, + required: ["target", "finding"] + } + }, + could_not_fault: { + type: "array", + items: {type: "string"} + }, + unresolved_issues: { + type: "array", + items: { + type: "object", + additionalProperties: false, + properties: { + issue: {type: "string"}, + why_it_matters: {type: "string"}, + suggested_test_or_decision_rule: {type: "string"} + }, + required: ["issue", "why_it_matters", "suggested_test_or_decision_rule"] + } + } + }, + required: ["strengths_to_steal", $field, "critique", "could_not_fault", "unresolved_issues"] + }' >"$target/schemas/debater.schema.json" +} + +judge_schema_file() { + local target="$1" + mkdir -p "$target/schemas" + cat >"$target/schemas/judge.schema.json" <<'JSON' +{ + "type": "object", + "additionalProperties": false, + "properties": { + "converged": {"type": "boolean"}, + "converged_participants": { + "type": "array", + "items": {"type": "string"} + }, + "unresolved_issues": { + "type": "array", + "items": {"type": "string"} + }, + "agreements_lacking_justification": { + "type": "array", + "items": {"type": "string"} + }, + "recommendation": { + "type": "string", + "enum": ["synthesize", "another_round"] + }, + "focus_for_next_round": { + "type": ["string", "null"] + }, + "reasoning": {"type": "string"} + }, + "required": [ + "converged", + "converged_participants", + "unresolved_issues", + "agreements_lacking_justification", + "recommendation", + "focus_for_next_round", + "reasoning" + ] +} +JSON +} + +write_all_schemas() { + local target="$1" + round0_schema_file "$target" + debater_schema_file "$target" + judge_schema_file "$target" +} + +validate_round0_response() { + local file="$1" + jq -e --arg field "$CONTRACT_OUTPUT_FIELD" \ + '(.[$field] | type == "string" and length > 0)' "$file" >/dev/null +} + +# Enforce the SAME contract the generated schema states, item shapes included. +# +# This is the only gate on an opencode turn: claude and codex have the schema +# enforced provider-side, opencode has no --output-schema at all. So a loose +# validator here is not defence in depth, it is the entire defence -- and the loose +# version accepted a payload the generated schema rejects with 7 errors (missing +# could_not_fault, `{"nope":1}` for a strength, `{"garbage":true}` for a critique). +# Attribution is the whole point of the N>=3 schema, so an unattributed critique +# passing validation silently destroys the judge's ability to tell who said what. +# +# Mirrors debater_schema_file: same required keys, same closed item shapes. +validate_debater_response() { + local file="$1" + jq -e --arg field "$CONTRACT_REVISED_FIELD" ' + def only(allowed): (keys_unsorted - allowed) == []; + (.strengths_to_steal | type == "array") and + (.[$field] | type == "string" and length > 0) and + (.critique | type == "array") and + (.could_not_fault | type == "array") and + (.unresolved_issues | type == "array") and + all(.strengths_to_steal[]?; ( + (type == "object") and only(["from","strength"]) and + (.from | type == "string" and length > 0) and + (.strength | type == "string" and length > 0) + )) and + all(.critique[]?; ( + (type == "object") and only(["target","finding"]) and + (.target | type == "string" and length > 0) and + (.finding | type == "string" and length > 0) + )) and + all(.could_not_fault[]?; (type == "string")) and + all(.unresolved_issues[]?; ( + (type == "object") and + only(["issue","why_it_matters","suggested_test_or_decision_rule"]) and + (.issue | type == "string" and length > 0) and + (.why_it_matters | type == "string" and length > 0) and + (.suggested_test_or_decision_rule | type == "string" and length > 0) + )) + ' "$file" >/dev/null 2>&1 +} + +validate_judge_verdict() { + local file="$1" + jq -e ' + (.converged | type == "boolean") and + (.unresolved_issues | type == "array") and + (.agreements_lacking_justification | type == "array") and + (.recommendation == "synthesize" or .recommendation == "another_round") and + ((.focus_for_next_round | type == "string") or (.focus_for_next_round == null)) and + (.reasoning | type == "string" and length > 0) + ' "$file" >/dev/null +} diff --git a/scripts/agent-kombat/_lib/dispatch.sh b/scripts/agent-kombat/_lib/dispatch.sh new file mode 100644 index 0000000..44d2df7 --- /dev/null +++ b/scripts/agent-kombat/_lib/dispatch.sh @@ -0,0 +1,269 @@ +# shellcheck shell=bash +# agent-kombat -- Parallel in-round dispatch and barrier merge. +# +# Sourced by agent-kombat.sh. No shebang, not executable: the caller owns +# `set -euo pipefail` and every path constant. +# +# Owns globals: PAR_SLOT, PAR_MODE, PAR_PROMPT, PAR_SCHEMA, PAR_PREFIX, +# PAR_EVENT, PAR_IDLE, PAR_SESSION, PAR_VALIDATOR (all nine declared at +# file top level here; reset by par_reset, appended to by par_add, and +# read throughout par_run and by collect_session_ids -- no reader exists +# anywhere else in the tree) +# Reads globals owned elsewhere: COLOR_DIM, COLOR_RESET (common.sh -- read +# only by par_run, in its failure-reporting loop), HEARTBEAT_INTERVAL, +# HARD_READ_LIMIT, DEFAULT_IDLE_TIMEOUT (entry file, top-level defaults +# written only by parse_args there -- HEARTBEAT_INTERVAL and HARD_READ_LIMIT +# read here by par_run, DEFAULT_IDLE_TIMEOUT read here by slot_idle_timeout), +# SLOT_SESSION_ID, +# SLOT_VALIDATOR (declared in _lib/adapters.sh, defaulted there -- WRITTEN +# here by par_run, immediately before the dispatch subshell fork, so the +# forked child inherits them; not read anywhere in this file, only in the +# three adapters in _lib/adapters.sh) + +# Guard against double-sourcing. +[ -n "${_AK_DISPATCH_SOURCED:-}" ] && return 0 +_AK_DISPATCH_SOURCED=1 + +# --------------------------------------------------------------------------- +# Parallel in-round dispatch +# --------------------------------------------------------------------------- +# +# D13. Upstream dispatches a round's agents SEQUENTIALLY (run_round0 calls +# run_claude_round0 then run_codex_round0, ~L1946-1947). Within a round the +# participants are genuinely independent -- the only real dependency is the +# round barrier -- so they run concurrently here, which turns an N-times-serial +# wait into roughly one agent's latency. +# +# Two mechanisms that upstream did not need: +# +# 1. Per-slot event files, merged deterministically at the barrier. See the +# note above merge_round_events. +# 2. N concurrent watchdogs. Upstream's run_with_heartbeat backgrounds ONE +# process and polls it; this polls a set, tracks per-slot idleness against +# each slot's own budget, and kills only the slot that stalled. +# +# What parallelism costs, stated honestly: interleaved harness stderr (mitigated +# by per-slot log files -- nothing shares a stream) and a real risk of resource +# storms when three agentic CLIs each spawn tool subprocesses at once. + +declare -a PAR_SLOT=() +declare -a PAR_MODE=() +declare -a PAR_PROMPT=() +declare -a PAR_SCHEMA=() +declare -a PAR_PREFIX=() +declare -a PAR_EVENT=() +declare -a PAR_IDLE=() +declare -a PAR_SESSION=() +declare -a PAR_VALIDATOR=() + +par_reset() { + PAR_SLOT=() + PAR_MODE=() + PAR_PROMPT=() + PAR_SCHEMA=() + PAR_PREFIX=() + PAR_EVENT=() + PAR_IDLE=() + PAR_SESSION=() + PAR_VALIDATOR=() +} + +par_add() { + PAR_SLOT+=("$1") + PAR_MODE+=("$2") + PAR_PROMPT+=("$3") + PAR_SCHEMA+=("$4") + PAR_PREFIX+=("$5") + PAR_EVENT+=("$6") + PAR_IDLE+=("$7") + PAR_SESSION+=("$8") + PAR_VALIDATOR+=("$9") +} + +slot_idle_timeout() { + local slot_id="$1" + local value + value="$(slot_field "$slot_id" ".idle_timeout_seconds // $DEFAULT_IDLE_TIMEOUT")" + printf '%s\n' "$value" +} + +# Returns the number of failed slots. Never dies on a slot failure -- the caller +# decides, because a partial round is exactly what --resume exists to repair. +par_run() { + local target="$1" + local phase_label="$2" + local count="${#PAR_SLOT[@]}" + [[ "$count" -gt 0 ]] || return 0 + + local -a pids=() + local -a done_flag=() + local -a status=() + local start + start="$(date +%s)" + + local i + for ((i = 0; i < count; i++)); do + # shellcheck disable=SC2034 # read by adapter_claude/adapter_codex/adapter_opencode in _lib/adapters.sh + SLOT_SESSION_ID="${PAR_SESSION[$i]}" + # shellcheck disable=SC2034 # read by adapter_opencode in _lib/adapters.sh + SLOT_VALIDATOR="${PAR_VALIDATOR[$i]}" + : >"${PAR_EVENT[$i]}" + ( + harness_send \ + "${PAR_MODE[$i]}" \ + "${PAR_SLOT[$i]}" \ + "${PAR_PROMPT[$i]}" \ + "${PAR_SCHEMA[$i]}" \ + "${PAR_PREFIX[$i]}" \ + "${PAR_EVENT[$i]}" + ) >"${PAR_PREFIX[$i]}.child.log" 2>&1 & + pids+=("$!") + done_flag+=(0) + status+=(0) + info "dispatched ${PAR_SLOT[$i]} ($(slot_field "${PAR_SLOT[$i]}" '.harness')) pid ${pids[$i]}" + done + + local remaining="$count" + local next_tick="$((start + HEARTBEAT_INTERVAL))" + while [[ "$remaining" -gt 0 ]]; do + local now + now="$(date +%s)" + for ((i = 0; i < count; i++)); do + [[ "${done_flag[$i]}" -eq 0 ]] || continue + local pid="${pids[$i]}" + + if ! kill -0 "$pid" 2>/dev/null; then + local rc=0 + if wait "$pid"; then rc=0; else rc=$?; fi + status[i]="$rc" + done_flag[i]=1 + remaining="$((remaining - 1))" + if [[ "$rc" -eq 0 ]]; then + ok "${PAR_SLOT[$i]} finished ($(format_duration "$((now - start))"))" + else + warn "${PAR_SLOT[$i]} failed with status $rc after $(format_duration "$((now - start))")" + fi + continue + fi + + # TOTAL WALL-CLOCK cap, not an inactivity cap. Inactivity is not a usable + # signal for any of these harnesses: + # + # claude writes 0 bytes until it finishes -> looks idle its whole run + # codex writes {"type":"thread.started"} IMMEDIATELY, then nothing for + # minutes while it thinks -> looks "productive" for one line and + # idle ever after + # opencode streams, so inactivity would work -- for exactly one of three + # + # A first attempt branched on "has it produced anything yet", which rescued + # claude and left codex still being false-killed: its 101-byte JSONL + # preamble sets produced=1 in the first second, putting it straight back on + # the inactivity path. Rather than keep guessing at what a meaningful byte + # is per harness, time is the only honest signal, so the budget is elapsed + # wall clock for every slot and inactivity is not consulted at all. + # + # Doubled because the roster budgets were written as inactivity numbers. + local budget="${PAR_IDLE[$i]}" + local limit="$((budget * 2))" + if [[ "$budget" -gt 0 && "$((now - start))" -ge "$limit" ]]; then + warn "${PAR_SLOT[$i]} exceeded its ${limit}s wall-clock budget; killing it" + log_event_file "${PAR_EVENT[$i]}" "agent.process.timeout" "$(jq -n \ + --arg slot "${PAR_SLOT[$i]}" \ + --argjson elapsed_seconds "$((now - start))" \ + --argjson budget_seconds "$limit" \ + '{slot: $slot, elapsed_seconds: $elapsed_seconds, budget_seconds: $budget_seconds}')" + terminate_process_tree "$pid" + wait "$pid" 2>/dev/null || true + # A read-loop that burns the wall-clock budget is the most likely + # real-world manifestation of the exact failure this guard exists for. + # opencode streams progressively, so the partial .raw has tool-call + # events even mid-kill; claude buffers (.raw is empty, no-op here). + warn_read_heavy_turn "${PAR_SLOT[$i]}" "${PAR_PREFIX[$i]}" "${PAR_EVENT[$i]}" + status[i]=124 + done_flag[i]=1 + remaining="$((remaining - 1))" + fi + done + + if [[ "$remaining" -gt 0 && "$HEARTBEAT_INTERVAL" -gt 0 && "$now" -ge "$next_tick" ]]; then + # Read-loop hard stop, on the heartbeat cadence so the growing .raw is + # grepped once per heartbeat rather than every poll. A wall-clock kill + # waits the full budget while a read loop streams tool calls the whole + # time; counting reads on the partial .raw catches it in seconds. claude + # buffers (.raw empty until finish) so its count is always 0 here. + local j + for ((j = 0; j < count; j++)); do + [[ "${done_flag[$j]}" -eq 0 ]] || continue + [[ "$HARD_READ_LIMIT" -gt 0 ]] || continue + local reads + reads="$(count_read_tool_calls "${PAR_PREFIX[$j]}.raw")" + if [[ "$reads" -gt "$HARD_READ_LIMIT" ]]; then + warn "${PAR_SLOT[$j]} exceeded the hard read limit (${reads} > ${HARD_READ_LIMIT}); killing it" + log_event_file "${PAR_EVENT[$j]}" "agent.read_limit.killed" "$(jq -n \ + --arg slot "${PAR_SLOT[$j]}" \ + --argjson read_count "$reads" \ + --argjson limit "$HARD_READ_LIMIT" \ + '{slot: $slot, read_count: $read_count, limit: $limit}')" + terminate_process_tree "${pids[$j]}" + wait "${pids[$j]}" 2>/dev/null || true + warn_read_heavy_turn "${PAR_SLOT[$j]}" "${PAR_PREFIX[$j]}" "${PAR_EVENT[$j]}" + status[j]=124 + done_flag[j]=1 + remaining="$((remaining - 1))" + fi + done + local waiting=() + for ((i = 0; i < count; i++)); do + [[ "${done_flag[$i]}" -eq 1 ]] || waiting+=("${PAR_SLOT[$i]}:$(human_size "$(file_size_bytes "${PAR_PREFIX[$i]}.raw")")") + done + info "$phase_label $(format_duration "$((now - start))") | waiting: ${waiting[*]}" + next_tick="$((now + HEARTBEAT_INTERVAL))" + fi + + [[ "$remaining" -eq 0 ]] || sleep 0.3 + done + + local failures=0 + for ((i = 0; i < count; i++)); do + if [[ "${status[$i]}" -ne 0 ]]; then + failures="$((failures + 1))" + # The child's own log is where an adapter's diagnostics land; surface a + # bounded slice so a failure is diagnosable without hunting for files. + if [[ -s "${PAR_PREFIX[$i]}.stderr" ]]; then + printf '%s--- %s stderr (first 40 lines) ---%s\n' "$COLOR_DIM" "${PAR_SLOT[$i]}" "$COLOR_RESET" >&2 + sed -n '1,40p' "${PAR_PREFIX[$i]}.stderr" >&2 + fi + if [[ -s "${PAR_PREFIX[$i]}.child.log" ]]; then + printf '%s--- %s adapter log (first 20 lines) ---%s\n' "$COLOR_DIM" "${PAR_SLOT[$i]}" "$COLOR_RESET" >&2 + sed -n '1,20p' "${PAR_PREFIX[$i]}.child.log" >&2 + fi + fi + done + return "$failures" +} + +human_size() { + local bytes="$1" + if [[ "$bytes" -ge 1048576 ]]; then + printf '%dMB' "$((bytes / 1048576))" + elif [[ "$bytes" -ge 1024 ]]; then + printf '%dKB' "$((bytes / 1024))" + else + printf '%dB' "$bytes" + fi +} + +# Persist the session ids the adapters wrote, so the next round can resume. +collect_session_ids() { + local target="$1" + local i + for ((i = 0; i < ${#PAR_SLOT[@]}; i++)); do + local file="${PAR_PREFIX[$i]}.session-id" + [[ -f "$file" ]] || continue + local sid + sid="$(cat "$file")" + [[ -n "$sid" ]] || continue + update_config "$target" --arg slot "${PAR_SLOT[$i]}" --arg sid "$sid" \ + '.slots[$slot].session_id = $sid' + done +} diff --git a/scripts/agent-kombat/_lib/orchestration.sh b/scripts/agent-kombat/_lib/orchestration.sh new file mode 100644 index 0000000..137ce28 --- /dev/null +++ b/scripts/agent-kombat/_lib/orchestration.sh @@ -0,0 +1,364 @@ +# shellcheck shell=bash +# agent-kombat -- Round 0, debate rounds, judge, synthesis and the final report. +# +# Sourced by agent-kombat.sh. No shebang, not executable: the caller owns +# `set -euo pipefail` and every path constant. +# +# Owns globals: none +# Reads globals owned elsewhere: DEBATER_IDS, JUDGE_ID, SYNTH_ID (roster.sh, +# index_roster_slots -- DEBATER_IDS read by run_round0, run_debate_round +# and print_final_report here; JUDGE_ID read by run_judge_attempt and +# run_synthesis here; SYNTH_ID read by run_synthesis here, via the +# ${SYNTH_ID:-$JUDGE_ID} fallback), CONTRACT_ID, CONTRACT_NOUN, +# CONTRACT_OUTPUT_FIELD, CONTRACT_REVISED_FIELD, CONTRACT_FINAL_FILENAME, +# CONTRACT_FINAL_LABEL (contracts.sh, load_contract -- CONTRACT_ID read by +# run_round0; CONTRACT_NOUN read by run_round0, run_judge_loop and +# print_final_report; CONTRACT_OUTPUT_FIELD read by run_round0; +# CONTRACT_REVISED_FIELD read by run_debate_round; CONTRACT_FINAL_FILENAME +# and CONTRACT_FINAL_LABEL read by run_synthesis and print_final_report), +# ROUNDS, NO_JUDGE, MAX_EXTRA_ROUNDS (entry file, parse_args -- ROUNDS read +# by run_debate_loop; NO_JUDGE and MAX_EXTRA_ROUNDS read by run_judge_loop; +# all three also written a second time by load_run_config in +# _lib/runmodes.sh on the --resume path; ROUNDS and MAX_EXTRA_ROUNDS each +# have a third, conditional writer too -- load_roster's roster-default +# fallback in roster.sh, when the CLI left them empty), COLOR_YELLOW, +# COLOR_RESET (common.sh, init_color -- read only by print_final_report +# here, directly in its unresolved/suspicious-issue printf calls, not via +# warn/info) + +# Guard against double-sourcing. +[ -n "${_AK_ORCHESTRATION_SOURCED:-}" ] && return 0 +_AK_ORCHESTRATION_SOURCED=1 + +# --------------------------------------------------------------------------- +# Round 0 +# --------------------------------------------------------------------------- + +run_round0() { + local target="$1" + local schema_file="$target/schemas/round0.schema.json" + local id + + step "Round 0: ${#DEBATER_IDS[@]} independent ${CONTRACT_NOUN}s (blind)" + update_config "$target" '.phase = "round" | .status = "running" | .current_round = 0' + log_event "$target" "round.started" "$(jq -n --arg round r0 --argjson participants "${#DEBATER_IDS[@]}" \ + '{round: $round, participants: $participants}')" + + prepare_shared_context "$target" + + # Round-0 blindness is structural, not merely prompted: no competitor artifact + # exists yet, so a participant could not read one even if it tried. + par_reset + for id in "${DEBATER_IDS[@]}"; do + local prefix="$target/rounds/r0-${id}" + if slot_turn_valid "$target" 0 "$id" "validate_round0_response"; then + info "round 0: slot '$id' already has a valid turn; not re-dispatching" + continue + fi + build_round0_prompt "$id" "$target" "$schema_file" >"${prefix}.prompt.txt" + par_add "$id" "new" "${prefix}.prompt.txt" "$schema_file" "$prefix" \ + "${prefix}.events.jsonl" "$(slot_idle_timeout "$id")" "" "validate_round0_response" + done + + local failures=0 + par_run "$target" "round 0" || failures=$? + collect_session_ids "$target" + merge_round_events "$target" 0 + assert_tree_untouched "$target" "round 0" + + [[ "$failures" -eq 0 ]] \ + || die "round 0: $failures of ${#DEBATER_IDS[@]} participants failed. Fix the cause and re-run with --resume $target" + + for id in "${DEBATER_IDS[@]}"; do + validate_round0_response "$target/rounds/r0-${id}.turn.json" \ + || die "round 0: slot '$id' produced no valid $CONTRACT_OUTPUT_FIELD (see rounds/r0-${id}.raw)" + extract_plan_markdown "$target" 0 "$id" "$CONTRACT_OUTPUT_FIELD" + done + + publish_round "$target" 0 + write_round_manifest "$target" 0 "independent_$CONTRACT_ID" + update_config "$target" '.published_round = 0 | .current_round = 1 | .last_successful_artifact = "rounds/r0.manifest.json"' + log_event "$target" "round.published" "$(jq -n --arg round r0 --argjson published_round 0 \ + '{round: $round, published_round: $published_round}')" + ok "Round 0 published" +} + +# --------------------------------------------------------------------------- +# Debate rounds +# --------------------------------------------------------------------------- + +run_debate_round() { + local target="$1" + local round="$2" + local focus_file="${3:-}" + local schema_file="$target/schemas/debater.schema.json" + local id + + # Freeze this round's inputs before anything is dispatched. The frozen copies + # are what the manifest hashes, so the record is of what was actually sent. + for id in "${DEBATER_IDS[@]}"; do + cp "$target/plan-${id}.md" "$target/rounds/r${round}-input-${id}.md" + done + + step "Round $round: debate (${#DEBATER_IDS[@]} participants, full mesh)" + update_config "$target" --argjson round "$round" \ + '.phase = "round" | .status = "running" | .current_round = $round' + log_event "$target" "round.started" "$(jq -n --arg round "r${round}" \ + --argjson participants "${#DEBATER_IDS[@]}" \ + --argjson replay "$([[ -n "$focus_file" ]] && printf true || printf false)" \ + '{round: $round, participants: $participants, judge_replay: $replay}')" + + par_reset + for id in "${DEBATER_IDS[@]}"; do + local prefix="$target/rounds/r${round}-${id}" + if slot_turn_valid "$target" "$round" "$id" "validate_debater_response"; then + info "round $round: slot '$id' already has a valid turn; not re-dispatching" + continue + fi + build_debate_prompt "$id" "$target" "$round" "$schema_file" "$focus_file" >"${prefix}.prompt.txt" + local session + session="$(config_get "$target" ".slots[\"$id\"].session_id // \"\"")" + local mode="resume" + # A slot with no session (e.g. one whose round-0 session was lost) starts a + # fresh one rather than dying: its prompt already carries its own plan and + # every competitor's, so it is self-contained. + if [[ -z "$session" || "$session" == "null" ]]; then + mode="new" + warn "slot '$id' has no session id; starting a fresh session for round $round" + fi + par_add "$id" "$mode" "${prefix}.prompt.txt" "$schema_file" "$prefix" \ + "${prefix}.events.jsonl" "$(slot_idle_timeout "$id")" "$session" "validate_debater_response" + done + + local failures=0 + par_run "$target" "round $round" || failures=$? + collect_session_ids "$target" + merge_round_events "$target" "$round" + assert_tree_untouched "$target" "round $round" + + [[ "$failures" -eq 0 ]] \ + || die "round $round: $failures of ${#DEBATER_IDS[@]} participants failed. Re-run with --resume $target to retry only the failed slots" + + for id in "${DEBATER_IDS[@]}"; do + validate_debater_response "$target/rounds/r${round}-${id}.turn.json" \ + || die "round $round: slot '$id' failed debater schema validation (see rounds/r${round}-${id}.turn.json)" + extract_plan_markdown "$target" "$round" "$id" "$CONTRACT_REVISED_FIELD" + done + + write_objections_ledger "$target" "$round" + publish_round "$target" "$round" + write_round_manifest "$target" "$round" "debate" + update_config "$target" --argjson round "$round" --arg artifact "rounds/r${round}.manifest.json" \ + '.published_round = $round | .current_round = ($round + 1) | .last_successful_artifact = $artifact' + log_event "$target" "round.published" "$(jq -n --arg round "r${round}" --argjson published_round "$round" \ + '{round: $round, published_round: $published_round}')" + ok "Round $round published" +} + +run_debate_loop() { + local target="$1" + [[ "$ROUNDS" -gt 0 ]] || return 0 + local start_round + start_round="$(($(config_get "$target" '.published_round') + 1))" + [[ "$start_round" -ge 1 ]] || start_round=1 + local round + for ((round = start_round; round <= ROUNDS; round++)); do + run_debate_round "$target" "$round" + done +} + +# --------------------------------------------------------------------------- +# Judge +# --------------------------------------------------------------------------- + +run_judge_attempt() { + local target="$1" + local attempt="$2" + local schema_file="$target/schemas/judge.schema.json" + local prefix="$target/rounds/judge-${attempt}" + + build_round_summary_json "$target" >"${prefix}-round-summary.json" + build_objections_json "$target" >"${prefix}-objections.json" + build_judge_prompt "$target" "${prefix}-round-summary.json" "${prefix}-objections.json" "$schema_file" \ + >"${prefix}.prompt.txt" + + update_config "$target" '.phase = "judge"' + log_event "$target" "judge.call.started" "$(jq -n --argjson attempt "$attempt" \ + --arg slot "$JUDGE_ID" '{attempt: $attempt, slot: $slot}')" + + par_reset + # A fresh session every attempt: the judge must grade the published artifacts, + # not accumulate its own prior opinions across replays. + par_add "$JUDGE_ID" "new" "${prefix}.prompt.txt" "$schema_file" "$prefix" \ + "${prefix}.events.jsonl" "$(slot_idle_timeout "$JUDGE_ID")" "" "validate_judge_verdict" + + local failures=0 + par_run "$target" "judge attempt $attempt" || failures=$? + # Record the session for the audit trail even though the judge never resumes + # one -- knowing which session produced a verdict is what makes the verdict + # traceable back to a transcript. + collect_session_ids "$target" + # The judge's own events go straight to the main log: there is only one of it, + # so there is no ordering ambiguity to resolve. + cat "${prefix}.events.jsonl" >>"$target/events.jsonl" 2>/dev/null || true + assert_tree_untouched "$target" "judge attempt $attempt" + + [[ "$failures" -eq 0 ]] || die "judge attempt $attempt failed" + validate_judge_verdict "${prefix}.turn.json" \ + || die "judge attempt $attempt failed verdict validation (see ${prefix}.turn.json)" + + cp "${prefix}.turn.json" "$target/judge-verdict.json" + update_config "$target" --arg artifact "judge-verdict.json" '.last_successful_artifact = $artifact' + log_event "$target" "judge.call.completed" "$(jq -n --argjson attempt "$attempt" \ + --arg recommendation "$(jq -r '.recommendation' "$target/judge-verdict.json")" \ + --argjson converged "$(jq -r '.converged' "$target/judge-verdict.json")" \ + '{attempt: $attempt, recommendation: $recommendation, converged: $converged}')" +} + +run_judge_loop() { + local target="$1" + [[ "$NO_JUDGE" -eq 0 ]] || { + info "judge disabled (--no-judge); the final $CONTRACT_NOUN will be synthesized without independent review" + return 0 + } + local status + status="$(config_get "$target" '.status')" + [[ "$status" != "done" ]] || return 0 + + local attempt=1 + local prev_objections="" + while :; do + step "Judge attempt $attempt" + run_judge_attempt "$target" "$attempt" + + local recommendation extra_used + recommendation="$(jq -r '.recommendation' "$target/judge-verdict.json")" + extra_used="$(config_get "$target" '.extra_rounds_used')" + + if [[ "$recommendation" != "another_round" ]]; then + update_config "$target" '.phase = "judge" | .status = "judged"' + break + fi + if [[ "$extra_used" -ge "$MAX_EXTRA_ROUNDS" ]]; then + warn "judge asked for another round but the replay budget ($MAX_EXTRA_ROUNDS) is spent; synthesizing with unresolved issues outstanding" + log_event "$target" "judge.replay.budget_exhausted" "$(jq -n --argjson used "$extra_used" \ + --argjson budget "$MAX_EXTRA_ROUNDS" '{extra_rounds_used: $used, max_extra_rounds: $budget}')" + update_config "$target" '.phase = "judge" | .status = "judged"' + break + fi + + # Stable-disagreement detector, lifted from this repo's local-pr-review loop + # (step 4 there). Upstream only ever stops on the round cap or the judge's + # say-so, so it will happily pay for a replay that reproduces the previous + # round's objections verbatim. + local objections + objections="$(jq -Sc '.unresolved_issues' "$target/judge-verdict.json")" + if [[ -n "$prev_objections" && "$objections" == "$prev_objections" ]]; then + warn "judge returned byte-identical unresolved issues two attempts running: stable disagreement, not progress. Stopping the replay loop." + log_event "$target" "judge.stable_disagreement" "$(jq -n --argjson attempt "$attempt" \ + '{attempt: $attempt}')" + update_config "$target" '.phase = "judge" | .status = "judged"' + break + fi + prev_objections="$objections" + + local next_round focus_file + next_round="$(($(config_get "$target" '.published_round') + 1))" + focus_file="$target/rounds/r${next_round}-judge-focus.txt" + jq -r '.focus_for_next_round // "Focus on the unresolved issues the judge listed."' \ + "$target/judge-verdict.json" >"$focus_file" + update_config "$target" '.extra_rounds_used = (.extra_rounds_used + 1)' + log_event "$target" "judge.replay.requested" "$(jq -n --argjson next_round "$next_round" \ + --arg focus_path "rounds/r${next_round}-judge-focus.txt" \ + '{next_round: $next_round, focus_path: $focus_path}')" + run_debate_round "$target" "$next_round" "$focus_file" + attempt="$((attempt + 1))" + done + ok "Judge verdict written: $target/judge-verdict.json" +} + +# --------------------------------------------------------------------------- +# Synthesis +# --------------------------------------------------------------------------- + +run_synthesis() { + local target="$1" + local synth_id="${SYNTH_ID:-$JUDGE_ID}" + [[ -n "$synth_id" ]] || die "no synthesizer or judge slot to synthesize with" + local prefix="$target/rounds/synthesis" + + step "Synthesis via slot '$synth_id'" + build_synthesis_prompt "$target" >"${prefix}.prompt.txt" + update_config "$target" --arg slot "$synth_id" \ + '.phase = "synthesis" | .status = "running" | .synthesizer_used = $slot' + log_event "$target" "synthesis.call.started" "$(jq -n --arg slot "$synth_id" '{slot: $slot}')" + + # An empty schema file is the signal for "markdown, not JSON" -- synthesis + # returns the deliverable itself. + : >"${prefix}.noschema.json" + + par_reset + par_add "$synth_id" "new" "${prefix}.prompt.txt" "${prefix}.noschema.json" "$prefix" \ + "${prefix}.events.jsonl" "$(slot_idle_timeout "$synth_id")" "" "validate_debater_response" + + local failures=0 + par_run "$target" "synthesis" || failures=$? + collect_session_ids "$target" + cat "${prefix}.events.jsonl" >>"$target/events.jsonl" 2>/dev/null || true + assert_tree_untouched "$target" "synthesis" + + [[ "$failures" -eq 0 ]] || die "synthesis failed" + [[ -s "${prefix}.md" ]] || die "synthesis produced no markdown (see ${prefix}.raw)" + + cp "${prefix}.md" "$target/$CONTRACT_FINAL_FILENAME" + update_config "$target" --arg artifact "$CONTRACT_FINAL_FILENAME" \ + '.phase = "done" | .status = "done" | .last_successful_artifact = $artifact' + log_event "$target" "synthesis.call.completed" "$(jq -n --arg artifact "$CONTRACT_FINAL_FILENAME" \ + '{artifact: $artifact}')" + ok "$CONTRACT_FINAL_LABEL: $target/$CONTRACT_FINAL_FILENAME" +} + +# --------------------------------------------------------------------------- +# Final report +# --------------------------------------------------------------------------- + +print_final_report() { + local target="$1" + printf '\n' + step "Debate complete" + printf 'Run dir: %s\n' "$target" + printf '%s: %s\n' "$CONTRACT_FINAL_LABEL" "$target/$CONTRACT_FINAL_FILENAME" + local id + for id in "${DEBATER_IDS[@]}"; do + printf 'Participant: %-12s %-28s %s\n' \ + "$id" "$(slot_field "$id" '"\(.harness)/\(.model)"')" "$target/plan-${id}.md" + done + if [[ -f "$target/judge-verdict.json" ]]; then + printf 'Judge: converged=%s recommendation=%s\n' \ + "$(jq -r '.converged' "$target/judge-verdict.json")" \ + "$(jq -r '.recommendation' "$target/judge-verdict.json")" + local unresolved + unresolved="$(jq -r '.unresolved_issues | length' "$target/judge-verdict.json")" + if [[ "$unresolved" -gt 0 ]]; then + # The part people skip. Say it loudly. + printf '\n%sUnresolved issues the judge flagged (%s) -- read these before trusting the plan:%s\n' \ + "$COLOR_YELLOW" "$unresolved" "$COLOR_RESET" + jq -r '.unresolved_issues[] | " - " + .' "$target/judge-verdict.json" + fi + local suspicious + suspicious="$(jq -r '.agreements_lacking_justification | length' "$target/judge-verdict.json")" + if [[ "$suspicious" -gt 0 ]]; then + printf '\n%sAgreements the judge could not see justified (%s):%s\n' \ + "$COLOR_YELLOW" "$suspicious" "$COLOR_RESET" + jq -r '.agreements_lacking_justification[] | " - " + .' "$target/judge-verdict.json" + fi + else + warn "no judge verdict: this plan had no independent review" + fi + local dispatches + dispatches="$(grep -c '"event":"agent.call.completed"' "$target/events.jsonl" 2>/dev/null || printf 0)" + printf '\nDispatches completed: %s\n' "$dispatches" + printf 'Events: %s\n' "$target/events.jsonl" + printf '\nNext: ground the final %s against the real repo before acting on it.\n' "$CONTRACT_NOUN" +} + diff --git a/scripts/agent-kombat/_lib/persistence.sh b/scripts/agent-kombat/_lib/persistence.sh new file mode 100644 index 0000000..d85722b --- /dev/null +++ b/scripts/agent-kombat/_lib/persistence.sh @@ -0,0 +1,295 @@ +# shellcheck shell=bash +# agent-kombat -- config.json state and round manifest publication. +# +# Sourced by agent-kombat.sh. No shebang, not executable: the caller owns +# `set -euo pipefail` and every path constant. +# +# Owns globals: none +# Reads globals owned elsewhere: REPO_ROOT (safety.sh, written only by +# detect_repo_root there -- read only here by write_config), ROSTER_JSON +# (entry file, empty default -- written by load_roster and by +# apply_model_overrides in roster.sh, and again by load_run_config in +# runmodes.sh on --resume, so no single owner; read only here by +# write_config), ROSTER_SOURCE (entry file, empty default -- written by +# load_roster in roster.sh and again by load_run_config in runmodes.sh on +# --resume, so no single owner; read only here by write_config), +# DEBATER_IDS (roster.sh, +# written only by index_roster_slots there -- read here by +# write_round_manifest and publish_round), ROUNDS, MAX_EXTRA_ROUNDS (entry +# file, written by parse_args and by load_run_config in runmodes.sh on +# --resume -- both are also conditionally written by load_roster's +# roster-default fallback in roster.sh, so neither has a single owner; +# both read only here by write_config), NO_JUDGE (entry +# file, written by parse_args and conditionally by load_run_config in +# runmodes.sh -- also read by validate_roster and warn_judge_independence +# in roster.sh, and by run_judge_loop in orchestration.sh; read only here +# by write_config), VERSION, UPSTREAM_SHA +# (entry file, top-level constants that are never reassigned -- also read +# by parse_args's --version handler there; read only here by +# write_config), DEBUG_AGENT_CALLS (entry file, written only by parse_args +# -- also read by adapter_claude in adapters.sh; read only here by +# write_config), HEARTBEAT_INTERVAL, DEFAULT_IDLE_TIMEOUT (entry file, +# written only by parse_args -- HEARTBEAT_INTERVAL is also read by par_run +# and DEFAULT_IDLE_TIMEOUT by slot_idle_timeout, both in _lib/dispatch.sh; +# both read only here by write_config), +# MAX_READS_PER_TURN (entry file, top-level constant, never reassigned -- +# also read by warn_read_heavy_turn in adapters.sh; read only here by +# write_config), CONTRACT_ID, CONTRACT_NOUN, CONTRACT_SOURCE, +# CONTRACT_OUTPUT_FIELD, CONTRACT_REVISED_FIELD, CONTRACT_FINAL_FILENAME, +# CONTRACT_FINAL_LABEL, CONTRACT_CONTEXT_MODE, CONTRACT_INITIAL_TASK, +# CONTRACT_DEBATE_TASK, CONTRACT_SYNTHESIS_TASK, CONTRACT_ROUND0_NOTE, +# CONTRACT_SYNTHESIS_NOTE (contracts.sh -- load_contract there writes all +# thirteen from the contract JSON; CONTRACT_CONTEXT_MODE is then +# conditionally rewritten by normalise_context_mode's fallback branch, +# also there -- both read and write it, per contracts.sh's own header. +# load_run_config in _lib/runmodes.sh is a second writer of all thirteen +# on the --resume path -- contracts.sh's header calls this deliberate +# coupling, not an ownership gap. CONTRACT_ID and CONTRACT_NOUN are read by +# both +# write_config and write_round_manifest here; the rest are read only by +# write_config here) + +# Guard against double-sourcing. +[ -n "${_AK_PERSISTENCE_SOURCED:-}" ] && return 0 +_AK_PERSISTENCE_SOURCED=1 + +# --------------------------------------------------------------------------- +# config.json +# --------------------------------------------------------------------------- + +write_config() { + local target="$1" + local origin_cwd="$2" + local phase="$3" + local status="$4" + local config_file="${5:-$target/config.json}" + + local harnesses='{}' + local harness + while IFS= read -r harness; do + [[ -n "$harness" ]] || continue + harnesses="$(printf '%s' "$harnesses" | jq -c \ + --arg name "$harness" \ + --arg path "$(command -v "$harness" 2>/dev/null || printf '')" \ + --arg version "$(tool_version "$harness")" \ + '. + {($name): {path: $path, version: $version}}')" + done < <(roster_harnesses) + harnesses="$(printf '%s' "$harnesses" | jq -c \ + --arg path "$(command -v jq)" --arg version "$(tool_version jq)" \ + '. + {jq: {path: $path, version: $version}}')" + + jq -n \ + --arg origin_cwd "$origin_cwd" \ + --arg repo_root "$REPO_ROOT" \ + --arg workdir "$(abs_path "$target")" \ + --arg phase "$phase" \ + --arg status "$status" \ + --arg version "$VERSION" \ + --arg upstream_sha "$UPSTREAM_SHA" \ + --arg roster_source "$ROSTER_SOURCE" \ + --arg git_head "$(cd "$REPO_ROOT" && git rev-parse HEAD 2>/dev/null || printf 'unknown')" \ + --argjson roster "$ROSTER_JSON" \ + --argjson slots "$(roster_slots_json)" \ + --argjson harnesses "$harnesses" \ + --argjson rounds "$ROUNDS" \ + --argjson max_extra "$MAX_EXTRA_ROUNDS" \ + --argjson judge_enabled "$([[ "$NO_JUDGE" -eq 1 ]] && printf false || printf true)" \ + --argjson debug_agent_calls "$([[ "$DEBUG_AGENT_CALLS" -eq 1 ]] && printf true || printf false)" \ + --argjson heartbeat_interval "$HEARTBEAT_INTERVAL" \ + --argjson default_idle_timeout "$DEFAULT_IDLE_TIMEOUT" \ + --argjson max_reads_per_turn "$MAX_READS_PER_TURN" \ + --arg contract_id "$CONTRACT_ID" \ + --arg contract_noun "$CONTRACT_NOUN" \ + --arg contract_source "$CONTRACT_SOURCE" \ + --arg output_field "$CONTRACT_OUTPUT_FIELD" \ + --arg revised_field "$CONTRACT_REVISED_FIELD" \ + --arg final_filename "$CONTRACT_FINAL_FILENAME" \ + --arg final_label "$CONTRACT_FINAL_LABEL" \ + --arg context_mode "$CONTRACT_CONTEXT_MODE" \ + --arg initial_task "$CONTRACT_INITIAL_TASK" \ + --arg debate_task "$CONTRACT_DEBATE_TASK" \ + --arg synthesis_task "$CONTRACT_SYNTHESIS_TASK" \ + --arg round0_note "$CONTRACT_ROUND0_NOTE" \ + --arg synthesis_note "$CONTRACT_SYNTHESIS_NOTE" \ + '{ + version: $version, + upstream_sha: $upstream_sha, + origin_cwd: $origin_cwd, + repo_root: $repo_root, + git_head: $git_head, + workdir: $workdir, + phase: $phase, + status: $status, + roster: ($roster + {source: $roster_source}), + slots: ($slots | map({key: .id, value: (. + {session_id: null})}) | from_entries), + slot_order: [$slots[].id], + debater_ids: [$slots[] | select(.role == "debater") | .id], + judge_id: ([$slots[] | select(.role == "judge") | .id] | first), + synthesizer_id: ([$slots[] | select(.role == "synthesizer") | .id] | first), + contract: { + id: $contract_id, + noun: $contract_noun, + source: $contract_source, + output_field: $output_field, + revised_field: $revised_field, + final_filename: $final_filename, + final_label: $final_label, + context_mode: $context_mode, + initial_task: $initial_task, + debate_task: $debate_task, + synthesis_task: $synthesis_task, + round0_note: $round0_note, + synthesis_note: $synthesis_note + }, + harnesses: $harnesses, + rounds_planned: $rounds, + max_extra_rounds: $max_extra, + extra_rounds_used: 0, + judge_enabled: $judge_enabled, + runtime: { + debug_agent_calls: $debug_agent_calls, + heartbeat_interval_seconds: $heartbeat_interval, + default_idle_timeout_seconds: $default_idle_timeout, + max_reads_per_turn: $max_reads_per_turn + }, + published_round: -1, + current_round: 0, + last_successful_artifact: null + }' | write_atomic "$config_file" +} + +update_config() { + local target="$1" + shift + local tmp + tmp="$(mktemp "$target/config.json.tmp.XXXXXX")" + jq "$@" "$target/config.json" >"$tmp" + mv "$tmp" "$target/config.json" +} + +config_get() { + local target="$1" + jq -r "$2" "$target/config.json" +} + +# --------------------------------------------------------------------------- +# Manifests and publication +# --------------------------------------------------------------------------- +# +# Kept from upstream: the manifest plus its sha256 receipts is the determinism +# proof -- evidence that round N's inputs were exactly round N-1's published +# artifacts. With N debaters that matters more, not less, because there are more +# ways for a stale input to slip in unnoticed. + +write_round_manifest() { + local target="$1" + local round="$2" + local kind="$3" + local manifest="$target/rounds/r${round}.manifest.json" + local slots='{}' + local id + + for id in "${DEBATER_IDS[@]}"; do + local prefix="$target/rounds/r${round}-${id}" + local rc="unknown" + [[ ! -f "${prefix}.rc" ]] || rc="$(cat "${prefix}.rc")" + slots="$(printf '%s' "$slots" | jq -c \ + --arg id "$id" \ + --arg label "$(slot_field "$id" '.label')" \ + --arg harness "$(slot_field "$id" '.harness')" \ + --arg model "$(slot_field "$id" '.model')" \ + --arg prompt_path "rounds/r${round}-${id}.prompt.txt" \ + --arg prompt_sha "$(sha256_file "${prefix}.prompt.txt")" \ + --arg output_path "rounds/r${round}-${id}.md" \ + --arg output_sha "$(sha256_file "${prefix}.md")" \ + --arg turn_path "rounds/r${round}-${id}.turn.json" \ + --arg raw_path "rounds/r${round}-${id}.raw" \ + --arg session_id "$(config_get "$target" ".slots[\"$id\"].session_id // \"\"")" \ + --arg rc "$rc" \ + '. + {($id): { + label: $label, + harness: $harness, + model: $model, + prompt_path: $prompt_path, + prompt_sha256: $prompt_sha, + output_path: $output_path, + output_sha256: $output_sha, + turn_path: $turn_path, + raw_path: $raw_path, + session_id: $session_id, + exit_code: ($rc | tonumber? // -1), + parse_status: "ok" + }}')" + done + + local frozen='{}' + if [[ "$kind" == "debate" ]]; then + for id in "${DEBATER_IDS[@]}"; do + frozen="$(printf '%s' "$frozen" | jq -c \ + --arg id "$id" \ + --arg path "rounds/r${round}-input-${id}.md" \ + --arg sha "$(sha256_file "$target/rounds/r${round}-input-${id}.md")" \ + '. + {($id): {path: $path, sha256: $sha}}')" + done + else + frozen="$(jq -n \ + --arg req "requirement.txt" \ + --arg req_sha "$(sha256_file "$target/requirement.txt")" \ + --arg ctx "rounds/r0-shared-context.md" \ + --arg ctx_sha "$(sha256_file "$target/rounds/r0-shared-context.md")" \ + '{requirement: {path: $req, sha256: $req_sha}, shared_context: {path: $ctx, sha256: $ctx_sha}}')" + fi + + jq -n \ + --arg round "r${round}" \ + --arg kind "$kind" \ + --arg cwd "$(config_get "$target" '.origin_cwd')" \ + --arg contract_id "$CONTRACT_ID" \ + --arg contract_noun "$CONTRACT_NOUN" \ + --argjson slots "$slots" \ + --argjson frozen "$frozen" \ + --arg objections "rounds/r${round}-objections.json" \ + '{ + round: $round, + kind: $kind, + contract: {id: $contract_id, noun: $contract_noun}, + origin_cwd: $cwd, + frozen_inputs: $frozen, + slots: $slots, + objections_path: $objections, + published: true + }' >"$manifest" +} + +publish_round() { + local target="$1" + local round="$2" + local id + for id in "${DEBATER_IDS[@]}"; do + cp "$target/rounds/r${round}-${id}.md" "$target/plan-${id}.md" + done +} + +# Resume is per-participant within a round, not per-round: a slot that already +# has a validated turn is not re-dispatched and not paid for twice. Strictly +# better than upstream, which discards a partial round wholesale. +slot_turn_valid() { + local target="$1" + local round="$2" + local id="$3" + local validator="$4" + local turn="$target/rounds/r${round}-${id}.turn.json" + [[ -s "$turn" ]] || return 1 + "$validator" "$turn" 2>/dev/null +} + +# Turn the validated structured payload into the markdown a competitor reads +# next round. +extract_plan_markdown() { + local target="$1" + local round="$2" + local id="$3" + local field="$4" + jq -er --arg field "$field" '.[$field] | select(type == "string" and length > 0)' \ + "$target/rounds/r${round}-${id}.turn.json" >"$target/rounds/r${round}-${id}.md" +} diff --git a/scripts/agent-kombat/_lib/preflight.sh b/scripts/agent-kombat/_lib/preflight.sh new file mode 100644 index 0000000..4ee93a5 --- /dev/null +++ b/scripts/agent-kombat/_lib/preflight.sh @@ -0,0 +1,257 @@ +# shellcheck shell=bash +# agent-kombat -- Tool gates, the opencode agent canary, and --check-harnesses. +# +# Sourced by agent-kombat.sh. No shebang, not executable: the caller owns +# `set -euo pipefail` and every path constant. +# +# Owns globals: none +# Reads globals owned elsewhere: none + +# Guard against double-sourcing. +[ -n "${_AK_PREFLIGHT_SOURCED:-}" ] && return 0 +_AK_PREFLIGHT_SOURCED=1 + +# --------------------------------------------------------------------------- +# Preflight +# --------------------------------------------------------------------------- + +preflight_jq() { + have jq || die "jq is required" + have shasum || die "shasum is required" +} + +# Which harnesses does the resolved roster actually need? Only check those -- +# a duo roster should not fail because codex is absent. +roster_harnesses() { + roster_slots_json | jq -r '[.[].harness] | unique | .[]' +} + +tool_version() { + local tool="$1" + case "$tool" in + claude) claude --version 2>/dev/null | head -1 ;; + codex) codex --version 2>/dev/null | head -1 ;; + opencode) opencode --version 2>/dev/null | head -1 ;; + jq) jq --version 2>/dev/null | head -1 ;; + *) printf 'unknown\n' ;; + esac +} + +preflight_tools() { + local harness + while IFS= read -r harness; do + [[ -n "$harness" ]] || continue + have "$harness" || die "roster requires '$harness' but it is not on PATH" + done < <(roster_harnesses) + preflight_opencode_agents +} + +# Verify every opencode slot's agent actually resolves, on EVERY run. +# +# This is not defensive padding. `opencode run --agent ` does not fail: +# it prints "agent \"\" not found. Falling back to default agent", silently +# uses `build` -- whose permission set is {"*": "allow"}, i.e. bash and write +# enabled -- and exits 0. So a typo in a personal roster, a renamed file, or any +# environment where .opencode/agent/*.md is not discovered would downgrade a +# read-only debater to a full-write one with no signal at all. +# +# assert_tree_untouched would eventually notice the damage; this prevents it. +# +# Prove the agent resolves by DISPATCHING, not by listing it. +# +# `opencode agent list` is flaky (measured 0 1 0 0 0 0 1 0 on an unchanged tree), +# so it cannot support a decision in either direction: refusing on it blocks valid +# runs, and warning-and-continuing is worse still, because +# `--agent ` fails OPEN -- opencode falls back to `build` +# ({"*": "allow"}, bash and writes enabled) and exits 0. Detecting the +# `Falling back` line inside a real turn is POST-HOC: by then the write-capable +# agent has already executed. So is assert_tree_untouched. +# +# A canary dispatch is the only check that observes the same code path the debate +# will use, before the debate runs. It costs a two-token reply on the slot's real +# model and it is authoritative. +# +# Exit codes are NOT boolean, because the two ways this fails need different +# diagnostics and point at different files: +# 0 agent resolved and the model answered +# 2 agent did NOT resolve -- opencode fell back to the write-capable 'build' +# agent and still exited 0. Containment is the problem; check the agent file. +# 1 anything else. Since the canary carries the slot's real model, the likely +# cause is a rejected model id -- which fails outright with NO fallback (a +# bogus id answers `Unexpected server error`, rc=1). Blaming the agent file +# here would send the operator to the wrong file entirely. +opencode_agent_proven() { + local want="$1" + local model="$2" + local probe_out + probe_out="$(mktemp)" + local rc=0 + opencode run "Reply with exactly: ok" \ + --agent "$want" -m "$model" --format json \ + "$probe_out" 2>&1 || rc=$? + if grep -qi 'not found. Falling back' "$probe_out"; then + rm -f "$probe_out" + return 2 + fi + rm -f "$probe_out" + [[ "$rc" -eq 0 ]] +} + +preflight_opencode_agents() { + roster_harnesses | grep -qx opencode || return 0 + if [[ "${AGENT_KOMBAT_SKIP_OPENCODE_CANARY:-0}" == "1" ]]; then + warn "AGENT_KOMBAT_SKIP_OPENCODE_CANARY=1: the read-only opencode agent is NOT proven. Only set this when you have verified it another way." + return 0 + fi + + # One canary per distinct {agent, model} pair, NOT per agent. + # + # Each dispatch carries the slot's OWN model, unmodified. This used to rewrite + # the id to a cheaper sibling (`${model%%-fast}-fast`), which only worked because + # the opencode slot was GLM and `zai/glm-5.2-fast` happened to exist. DeepSeek has + # no such suffix -- its cheap tier is `deepseek/deepseek-v4-flash`, so the rewrite + # produced `deepseek/deepseek-v4-pro-fast`, which the provider rejects with a + # generic "Unexpected server error". That turned a working roster into a refusal + # whose message blamed the agent, not the model id. + # + # Paying full price for a two-token canary buys a second guarantee beyond agent + # resolution: the model id itself resolves. That guarantee is per-model, so + # grouping by agent alone would leave every model after the first untested -- + # `deepseek-only` runs two models behind one agent, and its second slot would + # have failed mid-debate instead of at preflight. + local want model slots + while IFS=$'\t' read -r want model slots; do + [[ -n "$want" ]] || continue + [[ -n "$model" ]] || die "opencode slot(s) '${slots}' name agent '$want' with no model; cannot prove the read-only agent without one" + local probe_rc=0 + opencode_agent_proven "$want" "$model" || probe_rc=$? + if [[ "$probe_rc" -eq 0 ]]; then + ok "opencode agent '$want' on '$model' PROVEN by canary dispatch (slots: ${slots}; read-only guarantee in place)" + elif [[ "$probe_rc" -eq 2 ]]; then + die "opencode agent '$want' did not resolve on a canary dispatch (slots: ${slots}), so opencode would fall back to the write-capable 'build' agent. Refusing rather than discovering it mid-debate, after that agent has already run. + Check: ls .opencode/agent/${want}.md + Debug: opencode run 'hi' --agent ${want} -m ${model} --format json 2>&1 | head + Or run a roster with no opencode slot: --roster pair" + else + die "canary dispatch failed for model '$model' on agent '$want' (slots: ${slots}). The agent resolved, so this is the model or the provider -- a rejected model id fails outright with no fallback. Refusing before any debate dispatch. + Check: opencode models | grep -F '${model}' + Debug: opencode run 'hi' --agent ${want} -m ${model} --format json 2>&1 | head + Fix: correct the model on slot(s) '${slots}', or override with --model =" + fi + done < <(opencode_canary_targets) +} + +# Distinct {agent, model} pairs across every opencode slot, as +# `agentmodelslot_ids`. Naming the slots keeps a failure actionable: the +# pair alone does not say which roster entry to go and fix. +opencode_canary_targets() { + roster_slots_json | jq -r ' + [.[] | select(.harness == "opencode")] + | group_by([.agent, .model]) + | .[] + | [(.[0].agent // ""), (.[0].model // ""), ([.[].id] | join(","))] + | @tsv' +} + +# --check-harnesses: the three CLIs ship on independent release cadences and any +# of them can rename a flag out from under us. This greps each --help for every +# flag the adapters depend on, so a breaking upstream change is a clear message +# instead of a mid-debate failure. Costs nothing. +run_check_harnesses() { + local failures=0 + local harness + + check_flag() { + local tool="$1" + local help_cmd="$2" + local flag="$3" + if printf '%s' "$help_cmd" | bash 2>&1 | grep -q -- "$flag"; then + ok "$tool $flag" + else + warn "$tool is missing the flag this port depends on: $flag" + failures="$((failures + 1))" + fi + } + + while IFS= read -r harness; do + [[ -n "$harness" ]] || continue + have "$harness" || { + warn "$harness not on PATH" + failures="$((failures + 1))" + continue + } + step "$harness $(tool_version "$harness")" + case "$harness" in + claude) + check_flag claude 'claude --help' '--json-schema' + check_flag claude 'claude --help' '--output-format' + check_flag claude 'claude --help' '--session-id' + check_flag claude 'claude --help' '--resume' + check_flag claude 'claude --help' '--tools' + ;; + codex) + check_flag codex 'codex exec --help' '--output-schema' + check_flag codex 'codex exec --help' '--output-last-message' + check_flag codex 'codex exec --help' '--skip-git-repo-check' + check_flag codex 'codex exec --help' '--json' + check_flag 'codex resume' 'codex exec resume --help' '--output-schema' + check_flag 'codex resume' 'codex exec resume --help' '--output-last-message' + ;; + opencode) + check_flag opencode 'opencode run --help' '--session' + check_flag opencode 'opencode run --help' '--agent' + check_flag opencode 'opencode run --help' '--format' + check_flag opencode 'opencode run --help' '--file' + # Asserted ABSENT rather than present: if opencode ever gains real + # structured output, the fenced_json fallback should be retired, and + # this is where we would find out. + if opencode run --help 2>&1 | grep -q -- '--output-schema'; then + warn "opencode now has --output-schema: the fenced_json adapter can be replaced by native structured output (see UPSTREAM.md)" + else + info "opencode has no --output-schema (expected); fenced_json extraction is required" + fi + ;; + esac + done < <(roster_harnesses) + + # The read-only opencode agent is load-bearing, so PROVE it the same way + # preflight does -- a canary dispatch. + # + # This used one `opencode agent list` sample and was therefore a coin flip: a + # reviewer saw the listing find kombat-debater and the very next + # `--check-harnesses` report it unregistered and exit 2. A diagnostic that + # contradicts itself between consecutive runs is worse than none, because it + # trains people to ignore it. + if roster_harnesses | grep -qx opencode; then + # Same coverage as preflight_opencode_agents: one dispatch per distinct + # {agent, model} pair, each on the slot's own model. A diagnostic that probes + # fewer configurations than the debate will use reports green on an untested one. + local want_agent want_model want_slots + while IFS=$'\t' read -r want_agent want_model want_slots; do + [[ -n "$want_agent" ]] || continue + if [[ -z "$want_model" ]]; then + # Skip only this canary. Returning early here would jump past the + # accumulated-failures check below and report a green harness run. + warn "opencode slot(s) '${want_slots}' name agent '$want_agent' with no model; skipping the canary dispatch" + continue + fi + info "proving opencode agent '$want_agent' by canary dispatch on $want_model (slots: ${want_slots}; a few tokens, the listing is too flaky to decide on)" + local probe_rc=0 + opencode_agent_proven "$want_agent" "$want_model" || probe_rc=$? + if [[ "$probe_rc" -eq 0 ]]; then + ok "opencode agent '$want_agent' on '$want_model' PROVEN (read-only guarantee in place)" + elif [[ "$probe_rc" -eq 2 ]]; then + warn "opencode agent '$want_agent' did NOT resolve on a canary dispatch (slots: ${want_slots}): opencode would fall back to the write-capable 'build' agent. Check .opencode/agent/${want_agent}.md exists and parses." + failures="$((failures + 1))" + else + warn "canary dispatch failed for model '$want_model' on agent '$want_agent' (slots: ${want_slots}). The agent resolved, so this is the model or the provider. Check the id with: opencode models | grep -F '${want_model}'" + failures="$((failures + 1))" + fi + done < <(opencode_canary_targets) + fi + + if [[ "$failures" -gt 0 ]]; then + refuse "$failures harness check(s) failed" + fi + ok "all harness flags present" +} diff --git a/scripts/agent-kombat/_lib/prompts.sh b/scripts/agent-kombat/_lib/prompts.sh new file mode 100644 index 0000000..8294bb5 --- /dev/null +++ b/scripts/agent-kombat/_lib/prompts.sh @@ -0,0 +1,382 @@ +# shellcheck shell=bash +# agent-kombat -- Prompt builders, shared context and the objections ledger. +# +# Sourced by agent-kombat.sh. No shebang, not executable: the caller owns +# `set -euo pipefail` and every path constant. +# +# Owns globals: none +# Reads globals owned elsewhere: CONTRACT_NOUN (contracts.sh; read by +# build_round0_prompt, build_debate_prompt, build_judge_prompt and +# build_synthesis_prompt -- inside the unquoted heredocs in +# build_round0_prompt and build_debate_prompt, and in ordinary statements +# elsewhere), CONTRACT_ROUND0_NOTE (contracts.sh; read only by +# build_round0_prompt, and only from inside its unquoted heredoc), +# CONTRACT_OUTPUT_FIELD (contracts.sh; read by build_round0_prompt), +# CONTRACT_INITIAL_TASK (contracts.sh; read by build_round0_prompt), +# CONTRACT_DEBATE_TASK (contracts.sh; read by build_debate_prompt), +# CONTRACT_REVISED_FIELD (contracts.sh; read only by build_debate_prompt, +# and only from inside its unquoted heredoc), CONTRACT_SYNTHESIS_TASK and +# CONTRACT_SYNTHESIS_NOTE (contracts.sh; read by build_synthesis_prompt), +# CONTRACT_CONTEXT_MODE (contracts.sh; read by prepare_shared_context), +# DEBATER_IDS (roster.sh; read by build_round0_prompt, build_debate_prompt, +# build_judge_prompt, build_synthesis_prompt and write_objections_ledger, +# always in ordinary statements -- the unquoted heredoc in +# build_debate_prompt instead interpolates ${#peers[@]}, a local array +# copied from DEBATER_IDS, not the global itself), JUDGE_ID (roster.sh; +# read by build_judge_prompt), CONTEXT_FILE (entry file top-level constant; +# read only by prepare_shared_context here -- its only reader anywhere in +# the tree). +# +# Duplication on file (not fixed here): check_round0_prompt_parity, in +# _lib/runmodes.sh, hardcodes three literal sentences that must byte-match +# harness_schema_sentence's output, here. A shared constant would be the +# right fix; that is a future commit. + +# Guard against double-sourcing. +[ -n "${_AK_PROMPTS_SOURCED:-}" ] && return 0 +_AK_PROMPTS_SOURCED=1 + +# --------------------------------------------------------------------------- +# Prompt builders +# --------------------------------------------------------------------------- +# +# D5: upstream keeps TWO round-0 builders (build_round0_prompt ~L1070 for claude, +# build_round0_codex_prompt ~L1110 for codex) that are 40-line near-copies +# differing in one sentence about how to emit JSON. That is already a copy-drift +# hazard at two harnesses and unmaintainable at three, so there is one builder +# here and the harness-specific sentence comes from a table. +# +# Participants are ALWAYS referred to by their anonymous roster label, never by +# harness or model. At N>=3 model-identity bias is real: a DeepSeek debater told it +# is arguing with Opus defers to it. Upstream never had to care -- with a fixed +# 2-agent lineup its prompts say "participant 1 of 2" -- but a configurable +# roster makes leaking the lineup into the prompt both possible and harmful. + +harness_schema_sentence() { + local harness="$1" + case "$harness" in + claude) + printf 'Your response must match the provided structured output schema.\n' + ;; + codex) + printf 'Your final response must be JSON matching the provided output schema.\n' + ;; + opencode) + # opencode 1.18.8 cannot enforce a schema, so the schema has to travel in + # the prompt and the instruction has to be blunt. + printf 'Your final response must be ONLY a single JSON object matching the schema below. No prose, no explanation, no markdown code fence.\n' + ;; + *) + die "no schema sentence for harness '$harness'" + ;; + esac +} + +# For fenced_json slots the schema itself is appended to the prompt, since the +# harness cannot be handed one. +append_inline_schema() { + local harness="$1" + local schema_file="$2" + [[ "$harness" == "opencode" ]] || return 0 + printf '\n---BEGIN REQUIRED JSON SCHEMA---\n' + jq . "$schema_file" + printf -- '---END REQUIRED JSON SCHEMA---\n' +} + +build_round0_prompt() { + local slot_id="$1" + local target="$2" + local schema_file="$3" + local label + local harness + label="$(slot_field "$slot_id" '.label')" + harness="$(slot_field "$slot_id" '.harness')" + + printf 'You are %s, one of %d independent participants.\n\n' "$label" "${#DEBATER_IDS[@]}" + printf '%s\n' "$CONTRACT_INITIAL_TASK" + cat <"$out" + ;; + esac + log_event "$target" "context.prepared" "$(jq -n \ + --arg mode "$CONTRACT_CONTEXT_MODE" \ + --arg path "rounds/r0-shared-context.md" \ + --arg sha "$(sha256_file "$out")" \ + '{context_mode: $mode, path: $path, sha256: $sha}')" +} + +# D10: per-turn artifacts are named r{N}-.turn.json precisely so this glob +# is unambiguous by construction. Upstream globs rounds/r*.json for round +# manifests and only survives because of the .published filter -- the same glob +# also matches its per-turn r1-agent2.json files. +build_round_summary_json() { + local target="$1" + local manifests=("$target"/rounds/r*.manifest.json) + if [[ ! -e "${manifests[0]}" ]]; then + printf '[]\n' + return + fi + jq -s 'map(select(.published == true) | { + round, + kind, + published, + frozen_inputs, + slots: (.slots | map_values({label, parse_status, output_path})), + objections_path + })' "${manifests[@]}" +} + +build_objections_json() { + local target="$1" + local files=("$target"/rounds/r*-objections.json) + if [[ ! -e "${files[0]}" ]]; then + printf '[]\n' + return + fi + jq -s '.' "${files[@]}" +} + +write_objections_ledger() { + local target="$1" + local round="$2" + local combined='{}' + local id + for id in "${DEBATER_IDS[@]}"; do + local turn="$target/rounds/r${round}-${id}.turn.json" + local issues='[]' + if [[ -f "$turn" ]]; then + issues="$(jq -c '.unresolved_issues // []' "$turn")" + fi + combined="$(printf '%s' "$combined" | jq -c \ + --arg id "$id" \ + --arg label "$(slot_field "$id" '.label')" \ + --argjson issues "$issues" \ + '. + {($id): {label: $label, unresolved_issues: $issues}}')" + done + jq -n --arg round "r${round}" --argjson participants "$combined" \ + '{round: $round, participants: $participants}' \ + >"$target/rounds/r${round}-objections.json" +} diff --git a/scripts/agent-kombat/_lib/roster.sh b/scripts/agent-kombat/_lib/roster.sh new file mode 100644 index 0000000..feeb3bd --- /dev/null +++ b/scripts/agent-kombat/_lib/roster.sh @@ -0,0 +1,387 @@ +# shellcheck shell=bash +# agent-kombat -- Roster resolution, loading, validation and printing. +# +# Sourced by agent-kombat.sh. No shebang, not executable: the caller owns +# `set -euo pipefail` and every path constant. +# +# Owns globals: DEBATER_IDS, SLOT_ORDER, JUDGE_ID, SYNTH_ID (declared here, +# written only by index_roster_slots here, which also reads JUDGE_ID and +# SYNTH_ID back to append them onto SLOT_ORDER. DEBATER_IDS and SLOT_ORDER +# are also read by validate_roster here; JUDGE_ID is also read by +# validate_roster and warn_judge_independence here; DEBATER_IDS is also read +# by warn_judge_independence and print_roster here (print_roster never +# touches JUDGE_ID). DEBATER_IDS is also read across most later libs +# (prompts.sh, orchestration.sh, runmodes.sh, and persistence.sh -- by +# write_round_manifest and publish_round there); JUDGE_ID is also read in +# prompts.sh and orchestration.sh (not dispatch.sh -- neither global is +# referenced there); SLOT_ORDER is also read by merge_round_events in +# common.sh; SYNTH_ID is also read by run_synthesis in orchestration.sh.) +# Reads globals owned elsewhere: ROSTER_NAME (entry file -- read and written +# here too by load_roster, but validate_runtime_options in the entry file +# and reconstruct_progress in runmodes.sh also read it, so no single file +# owns both sides and the declaration stays put), ROSTER_FILE (entry file +# -- read only here by load_roster; parse_args writes it and +# validate_runtime_options in the entry file and reconstruct_progress in +# runmodes.sh read it again), ROSTER_JSON (entry file, empty default -- +# written by load_roster here (the file-path branch and the flag/env/ +# default branch) and again by apply_model_overrides's two rewrite sites +# here; read by roster_get, roster_slots_json, index_roster_slots, and by +# apply_model_overrides itself (which reads the prior value before each +# rewrite) -- NOT by validate_roster or print_roster, which reach it only +# indirectly through the roster_get / slot_field helpers. Written again by +# load_run_config in runmodes.sh on --resume, so no single owner), +# ROSTER_SOURCE (entry file -- written by load_roster here (the +# file-path branch and the flag/env/default branch); read by +# validate_roster and print_roster here -- NOT apply_model_overrides, +# which never references the shell global ROSTER_SOURCE (it touches the +# unrelated jq field .model_source inside ROSTER_JSON instead). Written +# again by load_run_config in runmodes.sh on --resume, so no single +# owner), ROUNDS, +# MAX_EXTRA_ROUNDS (entry file, parse_args/load_run_config -- read AND +# conditionally written here too, by load_roster's roster-default fallback +# when the CLI left them empty; no single file owns both sides, so the +# declarations stay in the entry file), NO_JUDGE (entry file, +# parse_args/load_run_config -- read only here, by validate_roster and +# warn_judge_independence), ALLOW_UNSTRUCTURED_JUDGE (entry file, parse_args +# -- read only by validate_roster here), ROSTER_DIR (entry file, top-level +# default -- read only by resolve_roster_path/load_roster here), +# DEFAULT_ROSTER (entry file, top-level default -- read only by load_roster +# here), REQUIRED_OPENCODE_AGENT (entry file, top-level default -- read only +# by validate_roster here), CONTRACT_ID, CONTRACT_NOUN, CONTRACT_SOURCE +# (contracts.sh, load_contract -- also written by load_run_config in +# runmodes.sh on --resume; read only by print_roster here), MODEL_OVERRIDE +# (entry file, parse_args -- read only by +# apply_model_overrides here), COLOR_RED, COLOR_RESET (common.sh -- read +# only by validate_roster here) + +# Guard against double-sourcing. +[ -n "${_AK_ROSTER_SOURCED:-}" ] && return 0 +_AK_ROSTER_SOURCED=1 + +# --------------------------------------------------------------------------- +# Roster loading, resolution and validation +# --------------------------------------------------------------------------- + +# Populated by load_roster / load_run_config. +declare -a DEBATER_IDS=() +declare -a SLOT_ORDER=() # debaters, then judge, then synthesizer +JUDGE_ID="" +SYNTH_ID="" + +resolve_roster_path() { + local name="$1" + local candidate + for candidate in "./.agent-kombat/rosters/${name}.json" "$ROSTER_DIR/${name}.json"; do + if [[ -f "$candidate" ]]; then + printf '%s\n' "$candidate" + return 0 + fi + done + return 1 +} + +# D1: precedence is flag > env > file > default. Upstream inverts this -- its +# CLI flags LOSE to environment variables and to a local config file, which is +# a foot-gun in a repo where `source .env` is routine. +load_roster() { + local path="" + if [[ -n "$ROSTER_FILE" ]]; then + path="$ROSTER_FILE" + [[ -f "$path" ]] || die "roster file not found: $path" + ROSTER_SOURCE="file:$path" + else + local name="$ROSTER_NAME" + local origin="flag" + if [[ -z "$name" ]]; then + name="${AGENT_KOMBAT_ROSTER:-}" + origin="env" + fi + if [[ -z "$name" ]]; then + name="$DEFAULT_ROSTER" + origin="default" + fi + path="$(resolve_roster_path "$name")" \ + || die "roster '$name' not found in ./.agent-kombat/rosters/ or $ROSTER_DIR/" + ROSTER_NAME="$name" + ROSTER_SOURCE="$origin:$name" + fi + + jq -e . "$path" >/dev/null 2>&1 || die "roster is not valid JSON: $path" + ROSTER_JSON="$(jq -c . "$path")" + + apply_model_overrides + index_roster_slots + validate_roster + + # Roster-level defaults, still overridable by the CLI flags parsed earlier. + if [[ -z "$ROUNDS" ]]; then + ROUNDS="$(roster_get '.rounds // 2')" + fi + if [[ -z "$MAX_EXTRA_ROUNDS" ]]; then + MAX_EXTRA_ROUNDS="$(roster_get '.max_extra_rounds // 1')" + fi +} + +roster_get() { + printf '%s' "$ROSTER_JSON" | jq -r "$1" +} + +# All slots as a flat array of {role, ...slot} objects, in canonical order. +roster_slots_json() { + printf '%s' "$ROSTER_JSON" | jq -c ' + [ (.debaters[] | . + {role: "debater"}) ] + + (if .judge then [ .judge + {role: "judge"} ] else [] end) + + (if .synthesizer then [ .synthesizer + {role: "synthesizer"} ] else [] end) + ' +} + +slot_json() { + local id="$1" + roster_slots_json | jq -ce --arg id "$id" '.[] | select(.id == $id)' \ + || die "no such slot in roster: $id" +} + +slot_field() { + local id="$1" + local filter="$2" + slot_json "$id" | jq -r "$filter" +} + +apply_model_overrides() { + local id + local env_name + local value + local ids + ids="$(printf '%s' "$ROSTER_JSON" | jq -r ' + [ (.debaters[]?.id), (.judge?.id // empty), (.synthesizer?.id // empty) ] | .[] + ')" + while IFS= read -r id; do + [[ -n "$id" ]] || continue + value="" + local source="" + if [[ -n "${MODEL_OVERRIDE[$id]:-}" ]]; then + value="${MODEL_OVERRIDE[$id]}" + source="flag" + elif [[ "$id" =~ ^[a-z][a-z0-9_-]{0,15}$ ]]; then + # Only derive an environment variable name from an id that already matches + # the slot-id pattern. A malformed id (a space, say) would otherwise make + # bash abort with "invalid variable name" here, BEFORE validate_roster gets + # to report the real problem with a proper exit code. + env_name="AGENT_KOMBAT_MODEL_$(printf '%s' "$id" | tr '[:lower:]-' '[:upper:]_')" + if [[ -n "${!env_name:-}" ]]; then + value="${!env_name}" + source="env" + fi + fi + [[ -n "$value" ]] || continue + ROSTER_JSON="$(printf '%s' "$ROSTER_JSON" | jq -c \ + --arg id "$id" --arg model "$value" --arg source "$source" ' + def patch: if .id == $id then .model = $model | .model_source = $source else . end; + .debaters |= map(patch) + | (if .judge then .judge |= patch else . end) + | (if .synthesizer then .synthesizer |= patch else . end) + ')" + info "model override: $id -> $value ($source)" + done <<<"$ids" + + # An override naming a slot that does not exist was silently ignored, while + # SKILL.md documents it as a hard error. Honour the documentation: a typo'd slot + # id otherwise means you believe you swapped a model and you did not. + local known + known="$(printf '%s' "$ROSTER_JSON" | jq -r ' + [ (.debaters[]?.id), (.judge?.id // empty), (.synthesizer?.id // empty) ] | .[] + ')" + local requested + for requested in "${!MODEL_OVERRIDE[@]}"; do + printf '%s\n' "$known" | grep -qxF "$requested" \ + || die "--model names slot '$requested', which is not in roster '$(roster_get '.id // "?"')'. Known slots: $(printf '%s' "$known" | tr '\n' ' ')" + done + + # Everything not overridden is sourced from the roster file itself. + ROSTER_JSON="$(printf '%s' "$ROSTER_JSON" | jq -c ' + def mark: if has("model_source") then . else .model_source = "roster" end; + .debaters |= map(mark) + | (if .judge then .judge |= mark else . end) + | (if .synthesizer then .synthesizer |= mark else . end) + ')" +} + +index_roster_slots() { + DEBATER_IDS=() + SLOT_ORDER=() + local id + while IFS= read -r id; do + [[ -n "$id" ]] || continue + DEBATER_IDS+=("$id") + SLOT_ORDER+=("$id") + done < <(printf '%s' "$ROSTER_JSON" | jq -r '.debaters[]?.id // empty') + + JUDGE_ID="$(roster_get '.judge.id // ""')" + SYNTH_ID="$(roster_get '.synthesizer.id // ""')" + [[ -z "$JUDGE_ID" ]] || SLOT_ORDER+=("$JUDGE_ID") + [[ -z "$SYNTH_ID" ]] || SLOT_ORDER+=("$SYNTH_ID") +} + +validate_roster() { + local errors=() + local id + local harness + local mode + local role + + [[ "${#DEBATER_IDS[@]}" -ge 2 ]] \ + || errors+=("roster needs at least 2 debaters, found ${#DEBATER_IDS[@]}") + + # Duplicate slot ids would collide in artifact paths, silently overwriting one + # participant's plan with another's. + local dupes + dupes="$(printf '%s\n' "${SLOT_ORDER[@]}" | sort | uniq -d | tr '\n' ' ')" + [[ -z "${dupes// /}" ]] || errors+=("duplicate slot ids: $dupes") + + local slots + slots="$(roster_slots_json)" + local count + count="$(printf '%s' "$slots" | jq 'length')" + local i + for ((i = 0; i < count; i++)); do + local slot + slot="$(printf '%s' "$slots" | jq -c ".[$i]")" + id="$(printf '%s' "$slot" | jq -r '.id // ""')" + harness="$(printf '%s' "$slot" | jq -r '.harness // ""')" + mode="$(printf '%s' "$slot" | jq -r '.structured_output // ""')" + role="$(printf '%s' "$slot" | jq -r '.role')" + + [[ "$id" =~ ^[a-z][a-z0-9_-]{0,15}$ ]] \ + || errors+=("slot id '$id' must match ^[a-z][a-z0-9_-]{0,15}\$ (artifact paths derive from it)") + case "$harness" in + claude | codex | opencode) ;; + *) errors+=("slot '$id' has unknown harness '$harness' (want claude|codex|opencode)") ;; + esac + case "$mode" in + native | fenced_json) ;; + none) + [[ "$role" == "synthesizer" ]] \ + || errors+=("slot '$id' uses structured_output 'none', legal only on a synthesizer") + ;; + *) errors+=("slot '$id' has unknown structured_output '$mode'") ;; + esac + [[ -n "$(printf '%s' "$slot" | jq -r '.model // ""')" ]] \ + || errors+=("slot '$id' has no model") + [[ -n "$(printf '%s' "$slot" | jq -r '.label // ""')" ]] \ + || errors+=("slot '$id' has no label") + + if [[ "$harness" == "opencode" ]]; then + local slot_agent + slot_agent="$(printf '%s' "$slot" | jq -r '.agent // ""')" + if [[ -z "$slot_agent" ]]; then + errors+=("opencode slot '$id' must set 'agent' -- opencode has no read-only sandbox flag, so the agent's permission set is the ONLY thing stopping it writing to the tree") + elif [[ "$slot_agent" != "$REQUIRED_OPENCODE_AGENT" ]]; then + # An allowlist, not just a presence check. Requiring *some* agent is not a + # guarantee: a roster could name a builtin like `build`, whose resolved + # permissions are {"*": "allow"} (bash and writes enabled), and validation + # would have happily accepted it. + errors+=("opencode slot '$id' sets agent '$slot_agent'; only '$REQUIRED_OPENCODE_AGENT' is permitted. Naming any other agent -- e.g. a builtin like 'build' -- would hand a debater write access to the tree.") + fi + [[ "$mode" != "native" ]] \ + || errors+=("opencode slot '$id' cannot use structured_output 'native': opencode 1.18.8 has no --output-schema") + + # A misspelt variant is a SILENT failure, unlike a misspelt model: opencode + # forwards an unknown value without complaint and the provider ignores it, so + # the slot quietly runs at the model's default effort while the roster claims + # otherwise -- and a flagship slot bills as if you had bought high effort. + # + # Checked against the universal effort vocabulary, NOT a per-model table. + # Which efforts a given model accepts is provider metadata that changes with + # every model release (v4-pro takes low|medium|high, v4-flash high|max), so + # encoding it here would reject valid rosters the day a new tier ships. This + # catches the typo, not the mismatch; the per-model constraints are documented + # on the `variant` property in rosters/roster.schema.json. + local slot_variant + slot_variant="$(printf '%s' "$slot" | jq -r '.variant // ""')" + if [[ -n "$slot_variant" ]]; then + case "$slot_variant" in + none | minimal | low | medium | high | xhigh | max) ;; + *) errors+=("opencode slot '$id' sets variant '$slot_variant', which is not a recognised reasoning effort (none|minimal|low|medium|high|xhigh|max). opencode forwards an unknown variant without error and the provider ignores it, so this would silently run at the model's default effort.") ;; + esac + fi + fi + if [[ "$harness" == "codex" ]]; then + # D3: ~/.codex/config.toml sets model_reasoning_effort globally (xhigh on + # this machine). Unpinned, debate cost and latency are hostage to whatever + # the user last put in a global config file. + [[ -n "$(printf '%s' "$slot" | jq -r '.reasoning_effort // ""')" ]] \ + || errors+=("codex slot '$id' must pin reasoning_effort, else it inherits the global model_reasoning_effort from ~/.codex/config.toml") + fi + done + + # The judge is the one slot whose output drives control flow: `recommendation` + # decides whether a replay round happens. A best-effort text extraction miss + # there does not degrade quality, it silently changes what the program does. + if [[ -n "$JUDGE_ID" && "$NO_JUDGE" -eq 0 ]]; then + local judge_mode + judge_mode="$(slot_field "$JUDGE_ID" '.structured_output')" + if [[ "$judge_mode" != "native" ]]; then + if [[ "$ALLOW_UNSTRUCTURED_JUDGE" -eq 1 ]]; then + warn "judge slot '$JUDGE_ID' has structured_output '$judge_mode' and --allow-unstructured-judge was passed; a parse miss will silently skip the replay round" + else + errors+=("judge slot '$JUDGE_ID' must use structured_output 'native' (its recommendation enum drives control flow); override with --allow-unstructured-judge") + fi + fi + fi + if [[ -z "$JUDGE_ID" && "$NO_JUDGE" -eq 0 ]]; then + errors+=("roster has no judge slot; pass --no-judge to run without one") + fi + + if [[ "${#errors[@]}" -gt 0 ]]; then + local err + for err in "${errors[@]}"; do + printf '%sroster:%s %s\n' "$COLOR_RED" "$COLOR_RESET" "$err" >&2 + done + refuse "roster validation failed (${#errors[@]} problem(s)) -- source $ROSTER_SOURCE" + fi + + warn_judge_independence +} + +# Upstream's judge is not actually independent: judge=claude with the same +# default model as agent1. Making the judge a roster slot exposes the problem +# instead of hiding it -- but a roster CAN still put the same model on both +# sides, and when it does we say so rather than letting the judge prompt's +# "You did not participate in the debate" go unqualified. +warn_judge_independence() { + [[ -n "$JUDGE_ID" && "$NO_JUDGE" -eq 0 ]] || return 0 + local judge_pair + judge_pair="$(slot_field "$JUDGE_ID" '"\(.harness)/\(.model)"')" + local id + for id in "${DEBATER_IDS[@]}"; do + local pair + pair="$(slot_field "$id" '"\(.harness)/\(.model)"')" + if [[ "$pair" == "$judge_pair" ]]; then + warn "judge slot '$JUDGE_ID' runs the same $judge_pair as debater '$id': the judge is session-independent (a fresh process, no shared context) but NOT model-independent. Use the 'trio' roster for a judge on a different model." + return 0 + fi + done +} + +print_roster() { + step "Roster: $(roster_get '.id // "?"') (source: $ROSTER_SOURCE)" + info "$(roster_get '.description // ""')" + local slots + slots="$(roster_slots_json)" + printf '%s' "$slots" | jq -r ' + .[] | " \(.role | .[0:1] | ascii_upcase)\(.role[1:])\t\(.id)\t\(.label)\t\(.harness)/\(.model)\t\(.structured_output)\t(model from \(.model_source // "roster"))" + ' | column -t -s $'\t' 2>/dev/null || printf '%s' "$slots" | jq -r '.[] | " \(.role) \(.id) \(.harness)/\(.model)"' + info "Rounds: $ROUNDS + up to $MAX_EXTRA_ROUNDS judge replay" + info "Contract: $CONTRACT_ID ($CONTRACT_NOUN) from $CONTRACT_SOURCE" + # Worst case means every judge replay is spent. A replay runs a FULL debate + # round (N debater dispatches) and is then judged again, so both terms grow: + # debaters: N * (1 + rounds + max_extra) round 0, each round, each replay + # judge: 1 + max_extra one attempt per verdict + # synthesis: 1 + # The earlier formula counted only `rounds + max_extra` judge calls and no + # replay debater dispatches, so it understated the worst case (13 vs 15 for + # trio-debate at -r 2). + local dispatches + dispatches="$((${#DEBATER_IDS[@]} * (1 + ROUNDS + MAX_EXTRA_ROUNDS) + MAX_EXTRA_ROUNDS + 2))" + info "Worst-case dispatches: $dispatches (nominal without a judge replay: $((${#DEBATER_IDS[@]} * (1 + ROUNDS) + 2)))" +} diff --git a/scripts/agent-kombat/_lib/runmodes.sh b/scripts/agent-kombat/_lib/runmodes.sh new file mode 100644 index 0000000..de9e71f --- /dev/null +++ b/scripts/agent-kombat/_lib/runmodes.sh @@ -0,0 +1,340 @@ +# shellcheck shell=bash +# agent-kombat -- Debate skeleton, --show, --resume and --dry-run. +# +# Sourced by agent-kombat.sh. No shebang, not executable: the caller owns +# `set -euo pipefail` and every path constant. +# +# Owns globals: none +# Reads globals owned elsewhere: DEBATER_IDS (roster.sh, index_roster_slots +# -- read by create_debate_skeleton, reconstruct_progress, run_dry and +# check_round0_prompt_parity), ROSTER_NAME, ROSTER_FILE (entry file, +# parse_args -- read only by reconstruct_progress here, to warn that they +# are ignored on resume), ROUNDS, REQUIREMENT (entry file, parse_args and +# load_requirement_file/expand_prompt_file_refs -- ROUNDS read by +# create_debate_skeleton (ROUNDS also has a third, conditional writer: +# load_roster's roster-default fallback in roster.sh, when the CLI left it +# empty); REQUIREMENT read by create_debate_skeleton and run_dry; both are +# also RE-WRITTEN here by load_run_config on the --resume path, restoring +# the values the run was started with rather than the current CLI +# defaults), MAX_EXTRA_ROUNDS (same third, conditional writer as ROUNDS, +# via the same load_roster fallback), NO_JUDGE (entry file, +# parse_args -- consumed by run_judge_loop in orchestration.sh; WRITTEN +# here, not read, by load_run_config on the --resume path), +# CONTRACT_ID, CONTRACT_FINAL_FILENAME (contracts.sh -- CONTRACT_ID read +# by create_debate_skeleton; CONTRACT_FINAL_FILENAME read by resume_run; +# both are also RE-WRITTEN here by load_run_config on the --resume path, +# along with the other eleven CONTRACT_* fields (CONTRACT_NOUN, +# CONTRACT_SOURCE, CONTRACT_OUTPUT_FIELD, CONTRACT_REVISED_FIELD, +# CONTRACT_FINAL_LABEL, CONTRACT_CONTEXT_MODE, CONTRACT_INITIAL_TASK, +# CONTRACT_DEBATE_TASK, CONTRACT_SYNTHESIS_TASK, CONTRACT_ROUND0_NOTE, +# CONTRACT_SYNTHESIS_NOTE) which load_run_config writes here but never +# reads back -- a resumed run must reconstruct the contract it started +# with, not the current defaults), ROSTER_JSON, ROSTER_SOURCE (roster.sh +# -- likewise RE-WRITTEN, not read, by load_run_config here on the +# --resume path, restoring the roster the run was started with). +# +# Duplication on file (not fixed here): check_round0_prompt_parity, here, +# hardcodes three literal sentences that must byte-match +# harness_schema_sentence's output in _lib/prompts.sh. That duplication now +# spans two files. A shared constant would be the right fix; that is a +# future commit. + +# Guard against double-sourcing. +[ -n "${_AK_RUNMODES_SOURCED:-}" ] && return 0 +_AK_RUNMODES_SOURCED=1 + +# --------------------------------------------------------------------------- +# Skeleton, show, resume +# --------------------------------------------------------------------------- + +create_debate_skeleton() { + local target="$1" + [[ ! -e "$target" ]] || die "workdir already exists: $target" + mkdir -p "$target/rounds" "$target/schemas" + printf '%s\n' "$REQUIREMENT" >"$target/requirement.txt" + : >"$target/events.jsonl" + write_config "$target" "$PWD" "setup" "initialized" + write_all_schemas "$target" + snapshot_tree_baseline "$target" + log_event "$target" "debate.initialized" "$(jq -n \ + --arg workdir "$(abs_path "$target")" \ + --argjson rounds "$ROUNDS" \ + --arg contract "$CONTRACT_ID" \ + --arg roster "$(roster_get '.id // "?"')" \ + --argjson participants "${#DEBATER_IDS[@]}" \ + '{workdir: $workdir, rounds_planned: $rounds, contract: $contract, roster: $roster, participants: $participants}')" +} + +show_debate() { + local target="$1" + [[ -d "$target" ]] || die "show workdir does not exist: $target" + [[ -f "$target/config.json" ]] || die "missing config.json in $target" + + jq '{ + status, phase, version, upstream_sha, git_head, + roster: .roster.id, roster_source: .roster.source, + contract: .contract.id, + rounds_planned, extra_rounds_used, max_extra_rounds, judge_enabled, + published_round, current_round, last_successful_artifact, + slots: (.slots | map_values({label, harness, model, structured_output, session_id, model_source})) + }' "$target/config.json" + + local noun final_filename final_label + noun="$(jq -r '.contract.noun // "plan"' "$target/config.json")" + final_filename="$(jq -r '.contract.final_filename // "plan-final.md"' "$target/config.json")" + final_label="$(jq -r '.contract.final_label // "Final plan"' "$target/config.json")" + + printf '\n' + local id + while IFS= read -r id; do + [[ -n "$id" ]] || continue + if [[ -f "$target/plan-${id}.md" ]]; then + printf '%s %s: %s\n' "$id" "$noun" "$target/plan-${id}.md" + fi + done < <(jq -r '.debater_ids[]?' "$target/config.json") + + [[ ! -f "$target/judge-verdict.json" ]] || printf 'Judge verdict: %s\n' "$target/judge-verdict.json" + [[ ! -f "$target/$final_filename" ]] || printf '%s: %s\n' "$final_label" "$target/$final_filename" + [[ ! -f "$target/triage.md" ]] || printf 'Grounding triage: %s\n' "$target/triage.md" + [[ ! -f "$target/events.jsonl" ]] || printf 'Events: %s (%s lines)\n' \ + "$target/events.jsonl" "$(wc -l <"$target/events.jsonl" | tr -d ' ')" +} + +# Restore the roster, contract and runtime settings a run was started with. +# Resuming with a DIFFERENT roster is refused: comparing a round-1 plan from one +# model with a round-2 plan from another is not a debate, it is a splice. +load_run_config() { + local target="$1" + # shellcheck disable=SC2034 # read by validate_roster/print_roster in _lib/roster.sh and write_config in _lib/persistence.sh + ROSTER_JSON="$(jq -c '.roster | del(.source)' "$target/config.json")" + # shellcheck disable=SC2034 # read by validate_roster/print_roster in _lib/roster.sh and write_config in _lib/persistence.sh + ROSTER_SOURCE="$(jq -r '.roster.source // "resumed"' "$target/config.json")" + ROUNDS="$(config_get "$target" '.rounds_planned')" + # shellcheck disable=SC2034 # read by run_judge_loop in _lib/orchestration.sh + MAX_EXTRA_ROUNDS="$(config_get "$target" '.max_extra_rounds')" + # shellcheck disable=SC2034 # read by run_judge_loop in _lib/orchestration.sh + [[ "$(config_get "$target" '.judge_enabled')" != "false" ]] || NO_JUDGE=1 + REQUIREMENT="$(cat "$target/requirement.txt")" + + CONTRACT_ID="$(config_get "$target" '.contract.id')" + # shellcheck disable=SC2034 # read by run_round0/run_judge_loop/print_final_report in _lib/orchestration.sh + CONTRACT_NOUN="$(config_get "$target" '.contract.noun')" + # shellcheck disable=SC2034 # read by print_roster in _lib/roster.sh and write_config in _lib/persistence.sh + CONTRACT_SOURCE="$(config_get "$target" '.contract.source')" + # shellcheck disable=SC2034 # read by run_round0 in _lib/orchestration.sh + CONTRACT_OUTPUT_FIELD="$(config_get "$target" '.contract.output_field')" + # shellcheck disable=SC2034 # read by run_debate_round in _lib/orchestration.sh + CONTRACT_REVISED_FIELD="$(config_get "$target" '.contract.revised_field')" + CONTRACT_FINAL_FILENAME="$(config_get "$target" '.contract.final_filename')" + # shellcheck disable=SC2034 # read by run_synthesis/print_final_report in _lib/orchestration.sh + CONTRACT_FINAL_LABEL="$(config_get "$target" '.contract.final_label')" + # shellcheck disable=SC2034 # read by prepare_shared_context in _lib/prompts.sh + CONTRACT_CONTEXT_MODE="$(config_get "$target" '.contract.context_mode')" + # shellcheck disable=SC2034 # read by build_round0_prompt in _lib/prompts.sh + CONTRACT_INITIAL_TASK="$(config_get "$target" '.contract.initial_task')" + # shellcheck disable=SC2034 # read by build_debate_prompt in _lib/prompts.sh + CONTRACT_DEBATE_TASK="$(config_get "$target" '.contract.debate_task')" + # shellcheck disable=SC2034 # read by build_synthesis_prompt in _lib/prompts.sh + CONTRACT_SYNTHESIS_TASK="$(config_get "$target" '.contract.synthesis_task')" + # shellcheck disable=SC2034 # read by build_round0_prompt in _lib/prompts.sh + CONTRACT_ROUND0_NOTE="$(config_get "$target" '.contract.round0_note')" + # shellcheck disable=SC2034 # read by build_synthesis_prompt in _lib/prompts.sh + CONTRACT_SYNTHESIS_NOTE="$(config_get "$target" '.contract.synthesis_note')" + + index_roster_slots + validate_contract_loaded + # Re-validate on resume. config.json is a plain file in a writable directory, so + # a resumed roster is untrusted input: without this, hand-editing + # .roster.debaters[].agent to "build" would dispatch a write-capable opencode + # agent, since validate_roster is where the allowlist lives. That is the field + # dispatch actually reads -- .slots carries only session ids. Cheap, and it also + # catches a config written by an older driver whose rules have since tightened. + validate_roster +} + +reconstruct_progress() { + local target="$1" + [[ -d "$target" ]] || die "resume workdir does not exist: $target" + [[ -f "$target/config.json" ]] || die "missing config.json in $target" + [[ -f "$target/requirement.txt" ]] || die "missing requirement.txt in $target" + [[ -d "$target/rounds" ]] || die "missing rounds/ in $target" + + if [[ -n "$ROSTER_NAME" || -n "$ROSTER_FILE" ]]; then + warn "--roster/--roster-file are ignored on resume: the run's own roster is authoritative (mixing models mid-debate is not a debate)" + fi + + # D10 pays off here: r*.manifest.json cannot collide with a per-turn file, so + # this glob needs no defensive filtering to be correct. + local max_round=-1 + local manifest + for manifest in "$target"/rounds/r*.manifest.json; do + [[ -e "$manifest" ]] || continue + local round_num + round_num="$(jq -r 'select(.published == true) | .round | sub("^r"; "") | tonumber' "$manifest" 2>/dev/null || true)" + [[ -n "$round_num" ]] || continue + [[ "$round_num" -le "$max_round" ]] || max_round="$round_num" + done + + if [[ "$max_round" -ge 0 ]]; then + local id + for id in "${DEBATER_IDS[@]}"; do + [[ -f "$target/rounds/r${max_round}-${id}.md" ]] \ + || die "resume: missing artifact for slot '$id' in the last published round r${max_round}" + cp "$target/rounds/r${max_round}-${id}.md" "$target/plan-${id}.md" + done + update_config "$target" --argjson round "$max_round" \ + --arg artifact "rounds/r${max_round}.manifest.json" \ + '.published_round = $round | .current_round = ($round + 1) | .last_successful_artifact = $artifact' + fi + log_event "$target" "resume.reconstructed" "$(jq -n --argjson published_round "$max_round" \ + '{published_round: $published_round}')" + info "resumed at published round r${max_round}" +} + +resume_run() { + local target="$1" + # Containment FIRST, before anything is read from or written into the directory. + # main() only enforced this on the fresh path, so `--resume` was an unchecked + # write target: a crafted directory outside tmp/ would have been read for its + # config and then written to for every subsequent round. + enforce_workdir_location "$target" + [[ -f "$target/config.json" ]] || die "resume workdir does not exist or has no config.json: $target" + load_run_config "$target" + reconstruct_progress "$target" + preflight_tools + # Regenerate the schemas rather than reusing the ones on disk. They are pure + # functions of the contract fields load_run_config just restored, so for an + # unchanged driver this rewrites them byte-identically -- but when the reason + # the run died IS a malformed schema, reusing the stale copy makes the failure + # unfixable in place and forces a fresh run that re-pays for round 0. + write_all_schemas "$target" + # Re-baseline: the tree has legitimately moved on since the original run, and + # holding a resumed run to a stale snapshot would fail for the wrong reason. + snapshot_tree_baseline "$target" + + if [[ "$(config_get "$target" '.status')" == "done" && -f "$target/$CONTRACT_FINAL_FILENAME" ]]; then + ok "run already complete: $target/$CONTRACT_FINAL_FILENAME" + return 0 + fi + + if [[ "$(config_get "$target" '.published_round')" -lt 0 ]]; then + run_round0 "$target" + fi + run_debate_loop "$target" + run_judge_loop "$target" + run_synthesis "$target" + print_final_report "$target" +} + +# --------------------------------------------------------------------------- +# Dry run +# --------------------------------------------------------------------------- +# +# Writes the whole run directory -- resolved config, all three schemas, and every +# round-0 prompt -- and calls nothing. Free, and it is what makes the round-0 +# fairness property checkable: the prompts are on disk to diff. +run_dry() { + local target="$1" + mkdir -p "$target/rounds" "$target/schemas" + printf '%s\n' "$REQUIREMENT" >"$target/requirement.txt" + : >"$target/events.jsonl" + write_config "$target" "$PWD" "dry-run" "dry-run" + write_all_schemas "$target" + prepare_shared_context "$target" + + local schema_file="$target/schemas/round0.schema.json" + local id + for id in "${DEBATER_IDS[@]}"; do + build_round0_prompt "$id" "$target" "$schema_file" >"$target/rounds/r0-${id}.prompt.txt" + done + + step "Dry run: no agent calls were made" + print_roster + printf '\nWorkdir: %s\n' "$(abs_path "$target")" + printf 'Prompts written:\n' + for id in "${DEBATER_IDS[@]}"; do + printf ' %-12s %8s bytes %s\n' "$id" \ + "$(file_size_bytes "$target/rounds/r0-${id}.prompt.txt")" \ + "rounds/r0-${id}.prompt.txt" + done + + printf '\nCommands that would run in round 0:\n' + for id in "${DEBATER_IDS[@]}"; do + printf ' %s\n' "$(describe_dispatch "$id" "$schema_file")" + done + + # Round-0 fairness: every participant must get the same prompt apart from its + # own label. Verified here rather than asserted in prose. + check_round0_prompt_parity "$target" + printf '\nInspect: %s\n' "$target/config.json" +} + +# A human-readable rendering of the exact argv each adapter will use. Not +# executed -- it exists so a reviewer can see the flags without reading bash. +describe_dispatch() { + local slot_id="$1" + local schema_file="$2" + local harness model + harness="$(slot_field "$slot_id" '.harness')" + model="$(slot_field "$slot_id" '.model')" + case "$harness" in + claude) + # Must mirror adapter_claude exactly: read-only allowlist, prompt on stdin. + # A preview that shows `--tools ""` or an argv prompt teaches the two things + # that were bugs. + printf 'claude -p --model %s --session-id --tools "Read,Grep,Glob" --output-format json --json-schema <%s> < rounds/r0-%s.prompt.txt' \ + "$model" "$(basename "$schema_file")" "$slot_id" + ;; + codex) + printf 'codex exec --model %s -c model_reasoning_effort="%s" --sandbox read-only --skip-git-repo-check --json --output-schema %s --output-last-message rounds/r0-%s.last.json "$(cat rounds/r0-%s.prompt.txt)"' \ + "$model" "$(slot_field "$slot_id" '.reasoning_effort // ""')" \ + "$(basename "$schema_file")" "$slot_id" "$slot_id" + ;; + opencode) + printf 'opencode run "" -f rounds/r0-%s.prompt.txt --agent %s -m %s --variant %s --format json' \ + "$slot_id" "$(slot_field "$slot_id" '.agent')" "$model" \ + "$(slot_field "$slot_id" '.variant // "default"')" + ;; + esac +} + +# Strip each participant's own label out of its round-0 prompt; what remains must +# be identical across participants. If it is not, the comparison the whole tool +# rests on is invalid, so this is a hard failure and not a warning. +check_round0_prompt_parity() { + local target="$1" + local tmp + tmp="$(mktemp -d)" + local id + local reference="" + local mismatch=0 + for id in "${DEBATER_IDS[@]}"; do + local label + label="$(slot_field "$id" '.label')" + # Normalise away the two things that legitimately differ per harness: the + # inline-schema block (only fenced_json slots carry one, because their + # harness cannot be handed a schema) and the one-line schema instruction. + # `cat -s` collapses the blank line the deleted block leaves behind; without + # it this check fails on whitespace and teaches people to ignore it. + sed -e "s/${label}//g" "$target/rounds/r0-${id}.prompt.txt" \ + | sed -e '/---BEGIN REQUIRED JSON SCHEMA---/,/---END REQUIRED JSON SCHEMA---/d' \ + | grep -v 'Your response must match the provided structured output schema' \ + | grep -v 'Your final response must be JSON matching the provided output schema' \ + | grep -v 'Your final response must be ONLY a single JSON object' \ + | cat -s \ + >"$tmp/$id.norm" + if [[ -z "$reference" ]]; then + reference="$tmp/$id.norm" + elif ! diff -q "$reference" "$tmp/$id.norm" >/dev/null; then + warn "round-0 prompt for '$id' differs from the reference beyond its own label:" + diff "$reference" "$tmp/$id.norm" | sed -n '1,20p' >&2 + mismatch=1 + fi + done + rm -rf "$tmp" + if [[ "$mismatch" -eq 1 ]]; then + die "round-0 prompts are not equivalent across participants; the debate would not be a fair comparison" + fi + ok "round-0 prompt parity: all ${#DEBATER_IDS[@]} prompts identical modulo self-label and harness schema wording" +} + diff --git a/scripts/agent-kombat/_lib/safety.sh b/scripts/agent-kombat/_lib/safety.sh new file mode 100644 index 0000000..76a20ca --- /dev/null +++ b/scripts/agent-kombat/_lib/safety.sh @@ -0,0 +1,183 @@ +# shellcheck shell=bash +# agent-kombat -- Workdir containment and the read-only working-tree guard. +# +# Sourced by agent-kombat.sh. No shebang, not executable: the caller owns +# `set -euo pipefail` and every path constant. +# +# Owns globals: REPO_ROOT (declared at file top level here. WRITTEN only by +# detect_repo_root here. Read here by default_workdir, enforce_workdir_location +# and tree_state; also read by expand_prompt_file_refs in the entry file, +# and by write_config in _lib/persistence.sh -- neither of those two writes +# it, so this file is the sole writer and the declaration moves here with +# it.) +# Reads globals owned elsewhere: none + +# Guard against double-sourcing. +[ -n "${_AK_SAFETY_SOURCED:-}" ] && return 0 +_AK_SAFETY_SOURCED=1 + +REPO_ROOT="" + +# --------------------------------------------------------------------------- +# Workdir +# --------------------------------------------------------------------------- + +detect_repo_root() { + REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)" + [[ -n "$REPO_ROOT" ]] || REPO_ROOT="$PWD" +} + +default_workdir() { + printf '%s/tmp/agent-kombat/debate_%s\n' "$REPO_ROOT" "$(date '+%Y%m%d_%H%M%S')" +} + +# D7: upstream merely DEFAULTS its workdir (to .agents/plans/). A default is not +# an enforcement, and in this repo the difference is load-bearing: tmp/ is +# gitignored but .agents/ is NOT, so an upstream-default workdir would drop a +# debate transcript straight into committable territory. +# Resolve a path's DEEPEST EXISTING ancestor through symlinks, then re-append the +# not-yet-created tail. `cd -P` is what does the real work: it resolves every +# symlink component, which a lexical collapse cannot. +# +# Needed because lexical normalisation alone is not containment. Verified escape +# before this existed: `--workdir tmp/aklink/debate`, with tmp/aklink -> a +# directory outside the repo, passed the prefix test and created +# /private/tmp/ak-outside/debate. The old comment claimed the lexical form "cannot +# be fooled by a symlink introduced later", which was simply false -- a symlink +# *parent* defeats it outright. +resolve_through_symlinks() { + local path="$1" + local tail="" + while [[ ! -e "$path" && "$path" != "/" && -n "$path" ]]; do + tail="$(basename "$path")${tail:+/$tail}" + path="$(dirname "$path")" + done + local base + base="$(cd -P "$path" 2>/dev/null && pwd -P)" || { + printf '%s\n' "$1" + return 0 + } + printf '%s\n' "${base%/}${tail:+/$tail}" +} + +enforce_workdir_location() { + local target="$1" + local resolved + # NORMALISE before comparing. abs_path only prepends $PWD, so a prefix test + # against an unnormalised string is trivially bypassable: + # --workdir tmp/../../../../private/tmp/x + # starts with "$PWD/tmp/" and would pass, while actually resolving outside the + # repo entirely. Verified escaping to /private/tmp before this fix. + resolved="$(normalise_path "$(abs_path "$target")")" + + # Belt and braces: reject a literal parent-directory component outright, so the + # refusal does not depend on realpath semantics for a not-yet-existing path. + case "/$target/" in + */../*) die "workdir must not contain a '..' component: $target" ;; + esac + + if [[ "$resolved" == *"/.agents/"* ]]; then + die "refusing workdir under .agents/: that is upstream's default, but .agents/ is NOT gitignored in this repo, so debate artifacts there would be committable. Use $REPO_ROOT/tmp/ instead." + fi + + if [[ "${AGENT_KOMBAT_ALLOW_UNSAFE_WORKDIR:-0}" == "1" ]]; then + warn "AGENT_KOMBAT_ALLOW_UNSAFE_WORKDIR=1: skipping the tmp/ containment check for $resolved" + return 0 + fi + + # Compare CANONICAL paths on both sides. A lexical prefix test is not + # containment: with tmp/aklink -> outside the repo, `tmp/aklink/debate` + # normalises to a string under $REPO_ROOT/tmp and then writes outside it. + # Verified: that exact input created /private/tmp/ak-outside/debate. + local tmp_root canonical + tmp_root="$(resolve_through_symlinks "$REPO_ROOT/tmp")" + canonical="$(resolve_through_symlinks "$resolved")" + if [[ "$canonical" != "$tmp_root" && "$canonical" != "$tmp_root"/* ]]; then + die "workdir must live under $tmp_root (got $target, which resolves to $canonical). Debates are never committed; set AGENT_KOMBAT_ALLOW_UNSAFE_WORKDIR=1 only if you know why." + fi + + # Reject a symlink anywhere in the existing chain even when it currently points + # inside tmp/. The target of a symlink can be repointed between this check and + # the writes that follow, so allowing one makes containment a race. + local probe="$resolved" + while [[ "$probe" != "/" && -n "$probe" && "$probe" != "$tmp_root" ]]; do + if [[ -L "$probe" ]]; then + die "workdir path component is a symlink: $probe. Containment cannot be guaranteed through a link whose target may change after this check." + fi + probe="$(dirname "$probe")" + done + + # Do not merely assume tmp/ is ignored -- assert it, so a change to .gitignore + # cannot quietly turn every future debate into a committable artifact. + if ! (cd "$REPO_ROOT" && git check-ignore -q tmp 2>/dev/null); then + die "$tmp_root is not gitignored; refusing to write debate artifacts there. Add 'tmp/' to .gitignore." + fi +} + +# --------------------------------------------------------------------------- +# Read-only guard +# --------------------------------------------------------------------------- +# +# A cheap global backstop that does not depend on any one harness's sandbox +# actually working: after every round, the working tree must look the same as it +# did when the run started. One check, and it catches an opencode permission gap, +# a codex sandbox escape, or a claude --tools regression equally well. +# +# Compared against a BASELINE captured at run start rather than requiring a clean +# tree: a real development checkout is routinely dirty, and a guard that cries +# wolf on every uncommitted file is a guard people disable. +# Snapshot path+status AND CONTENT. +# +# Status pairs alone are not a tamper check: overwriting a file that is already +# ` M`, or replacing an existing `??` file, leaves the porcelain output byte +# identical. So a harness could rewrite any already-dirty file and the backstop +# would report the tree unchanged. +# +# Content is captured as (a) one hash over the full tracked diff and (b) a hash +# per untracked file, both excluding tmp/ since that is where debates legitimately +# write. +tree_state() { + ( + cd "$REPO_ROOT" || return 0 + git status --porcelain 2>/dev/null | grep -v '^?? tmp/' | LC_ALL=C sort + printf 'tracked-diff %s\n' \ + "$(git diff HEAD --binary 2>/dev/null | shasum -a 256 | awk '{print $1}')" + git status --porcelain 2>/dev/null \ + | sed -n 's/^?? //p' | grep -v '^tmp/' | LC_ALL=C sort \ + | while IFS= read -r f; do + [[ -f "$f" ]] || continue + printf 'untracked %s %s\n' "$(shasum -a 256 "$f" | awk '{print $1}')" "$f" + done + ) 2>/dev/null || true +} + +snapshot_tree_baseline() { + local target="$1" + tree_state >"$target/tree-baseline.txt" || true +} + +assert_tree_untouched() { + local target="$1" + local phase="$2" + local baseline="$target/tree-baseline.txt" + # No baseline means we cannot say anything honest about what changed, so say + # that rather than silently passing. + if [[ ! -f "$baseline" ]]; then + warn "no tree baseline recorded; skipping the read-only check for $phase" + return 0 + fi + local current + current="$(mktemp)" + # Same content-aware snapshot as the baseline, or the comparison is meaningless. + tree_state >"$current" || true + local drift + drift="$(diff "$baseline" "$current" || true)" + rm -f "$current" + if [[ -n "$drift" ]]; then + warn "working tree changed during $phase -- a harness may have escaped its read-only sandbox:" + printf '%s\n' "$drift" >&2 + log_event "$target" "readonly.violation" "$(jq -n --arg phase "$phase" \ + --arg detail "$(printf '%s' "$drift" | head -20)" '{phase: $phase, detail: $detail}')" + die "refusing to continue: the debate is plan-only and must not modify the tree. Review the diff above; if it was you and not a harness, re-run with --resume." + fi +} diff --git a/scripts/agent-kombat/agent-kombat.sh b/scripts/agent-kombat/agent-kombat.sh new file mode 100755 index 0000000..bfa2a42 --- /dev/null +++ b/scripts/agent-kombat/agent-kombat.sh @@ -0,0 +1,512 @@ +#!/usr/bin/env bash +# +# agent-kombat -- multi-model adversarial plan debate. +# +# Ported from https://github.com/kaushikgopal/agent-kombat +# upstream branch: master +# upstream SHA: 1a034dd56708532732f0734ea1073ad8a943fc5a (2462 lines) +# +# Upstream hardcodes exactly two debaters (agent1=claude, agent2=codex) and a +# judge that is also claude. This port makes the roster data: N debaters plus a +# judge and synthesizer, each bound to an arbitrary (harness, model) pair by a +# JSON preset under rosters/. See UPSTREAM.md for the full divergence ledger -- +# every deliberate difference from upstream is numbered there (D1..D17), and +# UPSTREAM.md also records how to re-diff this file against upstream. +# +# An entry point plus sourced libraries under _lib/ (see the source block +# below). UPSTREAM.md records why the original single-file decision was +# reversed. +# +set -euo pipefail + +# Fail early and legibly rather than with a confusing syntax error. This port +# needs associative arrays (bash 4.0+). `wait -n` is deliberately NOT used, which +# is what keeps the floor at 4.0 rather than 4.3. macOS still ships bash +# 3.2 at /bin/bash, so a machine without a modern bash first on PATH would +# otherwise die deep inside roster parsing with no clue why. +if [[ -z "${BASH_VERSINFO:-}" || "${BASH_VERSINFO[0]}" -lt 4 ]]; then + printf 'error: agent-kombat.sh requires bash >= 4.0 (associative arrays); running %s\n' \ + "${BASH_VERSION:-unknown}" >&2 + printf 'hint: macOS ships bash 3.2 at /bin/bash. Install a modern bash (brew install bash)\n' >&2 + printf ' and make sure its directory precedes /bin in PATH.\n' >&2 + exit 1 +fi + +VERSION="0.2.0" +UPSTREAM_SHA="1a034dd56708532732f0734ea1073ad8a943fc5a" + +SCRIPT_SOURCE="${BASH_SOURCE[0]}" +while [[ -L "$SCRIPT_SOURCE" ]]; do + SCRIPT_DIR_CANDIDATE="$(cd -P "$(dirname "$SCRIPT_SOURCE")" && pwd)" + SCRIPT_SOURCE="$(readlink "$SCRIPT_SOURCE")" + [[ "$SCRIPT_SOURCE" = /* ]] || SCRIPT_SOURCE="$SCRIPT_DIR_CANDIDATE/$SCRIPT_SOURCE" +done +SCRIPT_DIR="$(cd -P "$(dirname "$SCRIPT_SOURCE")" && pwd)" + +# shellcheck disable=SC2034 # read by resolve_roster_path/load_roster in _lib/roster.sh +ROSTER_DIR="$SCRIPT_DIR/rosters" +# shellcheck disable=SC2034 # read by load_contract in _lib/contracts.sh +CONTRACT_DIR="$SCRIPT_DIR/contracts" +# shellcheck disable=SC2034 # read by prepare_shared_context in _lib/prompts.sh +CONTEXT_FILE="${AGENT_KOMBAT_CONTEXT_FILE:-$SCRIPT_DIR/context/shared-context.md}" + +# --------------------------------------------------------------------------- +# Runtime options +# --------------------------------------------------------------------------- + +# Three debaters by default: claude, codex and opencode/DeepSeek, which is the whole +# point of the tool -- heterogeneous models fail differently. Briefly defaulted to +# the 2-harness `pair` while opencode's read-only agent looked unavailable; that +# turned out to be a flaky `opencode agent list`, not a flaky `opencode run` +# (see UPSTREAM.md, "opencode agent discovery"). `pair` remains the fallback for +# a machine with no working opencode. +# shellcheck disable=SC2034 # read by load_roster in _lib/roster.sh +DEFAULT_ROSTER="trio-debate" + +# The ONLY opencode agent a roster may name. Requiring merely *some* agent is not +# a guarantee -- a roster naming a builtin like `build` gets {"*": "allow"}, i.e. +# bash and writes. Enforced by validate_roster. +# shellcheck disable=SC2034 # read by validate_roster in _lib/roster.sh +REQUIRED_OPENCODE_AGENT="kombat-debater" + +ROSTER_NAME="" +ROSTER_FILE="" +# shellcheck disable=SC2034 # read by validate_roster/print_roster in _lib/roster.sh and write_config in _lib/persistence.sh +ROSTER_JSON="" +# shellcheck disable=SC2034 # read by validate_roster/print_roster in _lib/roster.sh and write_config in _lib/persistence.sh +ROSTER_SOURCE="" +ROUNDS="" +MAX_EXTRA_ROUNDS="" +WORKDIR="" +DRY_RUN=0 +ROSTER_CHECK=0 +CHECK_HARNESSES=0 +NO_COLOR_FLAG=0 +NO_JUDGE=0 +ALLOW_UNSTRUCTURED_JUDGE=0 +SHOW_WORKDIR="" +RESUME_WORKDIR="" +REQUIREMENT_FILE="" +CONTRACT_SPEC="plan" +REQUIREMENT="" + +DEBUG_AGENT_CALLS="${AGENT_KOMBAT_DEBUG_AGENT_CALLS:-0}" +HEARTBEAT_INTERVAL="${AGENT_KOMBAT_HEARTBEAT_SECONDS:-10}" +# D8: upstream defaults the watchdog OFF (0). We default it on, because opencode +# offers no timeout mechanism of its own -- a wedged opencode turn would otherwise +# block the whole run forever. Per-slot rosters can raise it. +DEFAULT_IDLE_TIMEOUT="${AGENT_KOMBAT_AGENT_IDLE_TIMEOUT_SECONDS:-900}" +# shellcheck disable=SC2034 # read by guard_argv_size in _lib/adapters.sh +MAX_ARGV_BYTES="${AGENT_KOMBAT_MAX_ARGV_BYTES:-400000}" +MAX_EXPANSION_BYTES="${AGENT_KOMBAT_MAX_EXPANSION_BYTES:-262144}" +# A debater that makes more read-like tool calls than this in one turn is +# likely stuck in a read loop. The driver warns (does not fail) -- the turn +# may still produce valid JSON after all that reading. +# shellcheck disable=SC2034 # read by warn_read_heavy_turn in _lib/adapters.sh and write_config in _lib/persistence.sh +MAX_READS_PER_TURN="${AGENT_KOMBAT_MAX_READS_PER_TURN:-20}" +# A debater that makes more read-like tool calls than this in one turn is +# hard-killed mid-turn by the watchdog -- unlike MAX_READS_PER_TURN, which only +# warns post-hoc. opencode streams tool_use events to .raw progressively, so a +# read loop is detectable while it is still running; this is the threshold that +# stops it in seconds instead of letting it burn the whole wall-clock budget. +# shellcheck disable=SC2034 # read by par_run in _lib/dispatch.sh +HARD_READ_LIMIT="${AGENT_KOMBAT_HARD_READ_LIMIT:-60}" + +# Per-slot model overrides collected from --model =. +declare -A MODEL_OVERRIDE=() + +# --- sourced libraries ------------------------------------------------------ +# Resolved from SCRIPT_DIR (BASH_SOURCE-derived, symlink-walked above) rather +# than $0 or $PWD: the unit tests source this file via `bash -c`, where $0 is +# `bash`, and the driver is invoked from the repo root, from worktrees and via +# make. These must stay above the AGENT_KOMBAT_LIB_ONLY guard at the bottom -- +# the tests source this file to call individual functions. +# shellcheck source=_lib/common.sh +source "$SCRIPT_DIR/_lib/common.sh" +# shellcheck source=_lib/roster.sh +source "$SCRIPT_DIR/_lib/roster.sh" +# shellcheck source=_lib/adapters.sh +source "$SCRIPT_DIR/_lib/adapters.sh" +# shellcheck source=_lib/contracts.sh +source "$SCRIPT_DIR/_lib/contracts.sh" +# shellcheck source=_lib/safety.sh +source "$SCRIPT_DIR/_lib/safety.sh" +# shellcheck source=_lib/persistence.sh +source "$SCRIPT_DIR/_lib/persistence.sh" +# shellcheck source=_lib/preflight.sh +source "$SCRIPT_DIR/_lib/preflight.sh" +# shellcheck source=_lib/prompts.sh +source "$SCRIPT_DIR/_lib/prompts.sh" +# shellcheck source=_lib/dispatch.sh +source "$SCRIPT_DIR/_lib/dispatch.sh" +# shellcheck source=_lib/orchestration.sh +source "$SCRIPT_DIR/_lib/orchestration.sh" +# shellcheck source=_lib/runmodes.sh +source "$SCRIPT_DIR/_lib/runmodes.sh" + +usage() { + cat <<'USAGE' +Usage: + agent-kombat.sh [options] "requirement" + agent-kombat.sh --roster-check [--roster NAME] + agent-kombat.sh --check-harnesses + agent-kombat.sh --show WORKDIR + agent-kombat.sh --resume WORKDIR + +Roster: + --roster NAME Roster preset (default: trio-debate). Searched in + ./.agent-kombat/rosters/ then