diff --git a/README.md b/README.md
index 5f26c9ba3..7dfe91cf3 100644
--- a/README.md
+++ b/README.md
@@ -2,188 +2,195 @@
-
- A Go-native runtime for AI agents โ one binary, no Python, no virtualenvs.
- Built to fix the reliability and deployment problems that break Python-stack agents in production.
-
+# GORMES-AGENT
-
- Early-stage. Built for developers who care about reliability over polish.
-
+A Go-native runtime for AI agents.
+
+A single static binary for the Gormes runtime surface. No Python inside the shipped binary. No virtualenvs.
+
+Built to fix the reliability and deployment problems that break Python-stack agents in production.
+
+**Early-stage. Not production-ready yet.** Live turns still need a Hermes-compatible backend while the Go-native brain is being built.
-
+
---
-> ๐ง **Under construction.** Hermes is no longer required. The Go-native runtime that replaces it is still being wired up โ **Gormes is not yet usable end-to-end**. Memory and Brain phases are in active development. Expect rough edges; expect the API to change. See [Build State](#build-state) below for what works today and what doesn't.
-
----
-
## Quick Start
-> The installer is the source-of-truth for trying Gormes locally. The TUI runs and the gateway adapters stream, but the agent loop is incomplete โ install today to follow along, not to deploy.
+Try the local TUI and diagnostics first.
-**Linux / macOS / Termux:**
+### Unix (Linux / macOS / Termux)
```bash
curl -fsSL https://gormes.ai/install.sh | sh
-gormes
+gormes --offline
+gormes doctor --offline
```
-**Windows (PowerShell):**
+### Windows (PowerShell)
```powershell
irm https://gormes.ai/install.ps1 | iex
-gormes
+gormes --offline
+gormes doctor --offline
```
-The installer auto-installs `git` and Go 1.25+ when missing (apt/dnf/pacman/brew/pkg
-on Unix, winget/choco on Windows, with a managed go.dev fallback on either) and
-keeps a managed checkout under `~/.gormes` (or `%LOCALAPPDATA%\gormes`). Rerun
-the same command to update โ local edits in the managed checkout are autostashed
-and reapplied. No Python, no virtualenv, no dependency drift.
+The installer manages a source checkout under `~/.gormes/gormes-agent` or `%LOCALAPPDATA%\gormes\gormes-agent`, installs Git and Go when missing where possible, builds `gormes`, and updates in place on rerun.
----
+For live turns today, start a Hermes-compatible backend and run `gormes` without `--offline`:
-## What Gormes Is
+```bash
+API_SERVER_ENABLED=true hermes gateway start
+gormes
+```
-Gormes is a Go-native rewrite of [Hermes Agent](https://github.com/NousResearch/hermes-agent)'s runtime infrastructure. It started as an independent Go port of ideas and architecture from Hermes-Agent, with upstream Git history preserved for attribution, and is being rebuilt around a single static binary and Gormes-native runtime boundaries.
+---
-**Gormes solves an operations problem, not an AI problem.** The thesis isn't "smarter agents." It's agents that survive deployment, don't crash mid-stream, and don't break when a Python dependency drifts on a host you SSH into six months from now.
+## Core Features
-Hermes is no longer a runtime dependency. The Go-native pieces that replace it are still being built โ see the build state below.
+- **Single static binary** - current Gormes build is ~17.7 MB, stripped, static, and zero-CGO.
+- **No Gormes runtime drift** - the Go binary you test is the Go binary you run.
+- **Stream resilience** - Route-B reconnect treats dropped SSE streams as recoverable.
+- **Local validation** - `gormes doctor --offline` catches tool and config issues before runtime.
+- **Multi-platform gateway** - Telegram and Discord ship on the shared gateway; Slack, WhatsApp, and WeChat are active.
+- **Isolated subagents** - bounded parallel workstreams with durable job metadata.
+- **Goncho memory layer** - Honcho-style peer context, search, profiles, and diagnostics inside the Gormes binary.
---
## Why Gormes Exists
-> **Gormes is not about smarter agents.**
->
-> It's about agents that:
-> - don't fail to install
-> - don't drift between environments
-> - don't crash after six hours
-> - don't lose work on dropped connections
+Gormes is not about smarter agents.
+
+It is about agents that:
+
+- do not fail to install
+- do not drift between environments
+- do not crash mid-run
+- do not lose work on dropped connections
-### Why Hermes-stack agents break in production
+### Why Python-stack agents break
- Python environments drift between dev, staging, and prod.
-- npm and Nix builds break silently on host package skew.
-- Multi-process Python orchestration crashes or hangs under load.
-- SSE streams drop on flaky networks and kill long-running agents.
-- Debugging a single failure spans Python, Node, and OS runtimes.
+- npm and Nix builds break on host package skew.
+- Multi-process orchestration crashes or hangs under load.
+- SSE streams drop and kill long-running turns.
+- Debugging spans Python, Node, shell, and OS runtimes.
-### How Gormes fixes it
+Gormes fixes this by:
-| Problem | Gormes |
-|---|---|
-| **Broken installs** | Single ~17.7 MB static binary |
-| **Runtime drift** | Pure Go. No `pip`, no `npm`, no `activate` |
-| **Process crashes** | One runtime, one process tree |
-| **Dropped SSE streams** | Route-B auto-reconnect, no lost responses |
-| **3am debugging** | `gormes doctor --offline` validates locally first |
+- Single static binary -> fewer broken installs
+- Pure Go runtime surfaces -> no `pip`, no `npm`, no `activate`
+- Route-B reconnect -> dropped streams become recoverable events
+- Local doctor checks -> issues fail before tokens burn
+- In-binary memory and gateway seams -> less cross-runtime debugging
---
-## Who Gormes Is For
+## Build State
-- **Operators of long-running agents** โ you need agents that survive restarts, network blips, and host upgrades, not just impressive demos.
-- **Developers tired of Python/Nix/npm breakage** โ you're tired of an agent that worked yesterday breaking today because a transitive dep ticked over.
-- **Builders who want one binary that just runs** โ you'd rather `scp` one file to a Termux session or Alpine VPS than reproduce a virtualenv.
+Gormes is a strangler-fig rewrite of Hermes-Agent, with upstream Git history preserved for attribution.
----
+Today:
-## Build State
+- Dashboard: shipping
+- Gateway: partial
+- Memory: active
+- Brain: not complete
+- Live turns: still require a Hermes-compatible backend
+
+Next milestone:
-Gormes is a **strangler-fig rewrite**. Each phase ships a self-contained surface in Go and removes the corresponding Python surface from the runtime. Today the dashboard, gateway, and most of the memory layer are working in Go. The brain โ the agent loop itself โ is not.
+- Fully Go-native agent runtime with no Hermes backend requirement.
-| Phase | Status | What's in scope |
-|---|---|---|
-| **Phase 1** โ The Dashboard | shipping | Go-native TUI, render mailbox, settings surfaces |
-| **Phase 2** โ The Gateway | partial | Telegram + Discord shipping; Slack/WhatsApp/WeChat in progress |
-| **Phase 3** โ The Black Box (Memory) | active | SQLite + FTS5 lattice, ontological graph, neural recall |
-| **Phase 4** โ The Brain Transplant | active | Native prompt building, agent orchestration in Go |
-| **Phase 5** โ The Final Purge | planned | Last Python tool scripts ported; 100% Go runtime |
-| **Phase 6** โ The Learning Loop | planned | Self-improvement loop |
+Full progress: [docs.gormes.ai/building-gormes/architecture_plan](https://docs.gormes.ai/building-gormes/architecture_plan/)
+
+
+Generated phase rollup
| Phase | Status | Shipped |
|-------|--------|---------|
| Phase 1 โ The Dashboard | โ
| 3/3 subphases |
| Phase 2 โ The Gateway | ๐จ | 12/19 subphases |
-| Phase 3 โ The Black Box (Memory) | ๐จ | 11/14 subphases |
+| Phase 3 โ The Black Box (Memory) | ๐จ | 12/14 subphases |
| Phase 4 โ The Brain Transplant | ๐จ | 0/8 subphases |
| Phase 5 โ The Final Purge | ๐จ | 1/18 subphases |
| Phase 6 โ The Learning Loop (Soul) | โณ | 0/6 subphases |
| Phase 7 โ Paused Channel Backlog | ๐จ | 2/5 subphases |
-Full item-level checklist and stats: **[docs.gormes.ai/building-gormes/architecture_plan](https://docs.gormes.ai/building-gormes/architecture_plan/)**
+
---
-## Core Features
+## Goncho (Honcho -> Go)
+
+Gormes includes Goncho: an in-binary Go port of Honcho's peer-centric memory and context model.
+
+Goncho is not a sidecar, second database, or loopback service. It runs inside the Gormes binary on the same SQLite memory substrate and exposes Honcho-compatible tools:
+
+- `honcho_profile`
+- `honcho_search`
+- `honcho_context`
+- `honcho_chat`
+- `honcho_reasoning`
+- `honcho_conclude`
-- **Single Static Binary** โ Zero CGO. ~17.7 MB. Deploy to Termux, Alpine, a fresh VPS โ it runs. No Python, no virtualenv, no Nix.
-- **No Runtime Drift** โ Pure Go. The binary you tested is the binary that deploys.
-- **Streams That Don't Drop** โ Route-B reconnect treats SSE drops as recoverable, not fatal. Your agent doesn't lose work to a flaky network.
-- **Local Validation** โ `gormes doctor --offline` checks tool schemas before you burn tokens.
-- **Multi-Platform Gateway** โ Telegram and Discord run through the shared gateway today; Slack shared-runtime wiring, WhatsApp, and WeChat are the active channel priorities while the other adapters sit in Phase 7.
-- **Scheduled Automations** โ Built-in cron scheduler delivering to any platform.
-- **Isolated Subagents** โ Parallel workstreams with bounded memory and controlled execution.
+This gives Gormes a local memory layer for peer profiles, session context, retrieval, conclusions, queue status, and degraded-mode diagnostics.
+
+Docs: [Goncho Honcho Memory](https://docs.gormes.ai/building-gormes/goncho_honcho_memory/)
+
+---
+
+## Who Gormes Is For
+
+- **Operators of long-running agents** - systems that must survive restarts, flaky networks, and host changes.
+- **Developers tired of Python/Nix/npm breakage** - environments that worked yesterday and fail today.
+- **Builders who want one deployable artifact** - ship a Go binary instead of reconstructing a runtime.
---
-## Common Commands
+## Basic Usage
```bash
-gormes # Start the TUI
-gormes model # Choose your LLM provider
-gormes tools # Configure enabled tools
-gormes gateway # Start the messaging gateway
-gormes setup # Run the full setup wizard
-gormes doctor # Validate local tool wiring
-gormes claw migrate # Migrate from OpenClaw
+gormes --offline
```
-๐ **[Full documentation โ](https://docs.gormes.ai/)**
+Use `gormes` without `--offline` when a Hermes-compatible backend is running.
+
+More commands: [cmd/README.md](cmd/README.md)
---
## Documentation
-| Resource | Link |
-|----------|------|
-| **Quick Start** | [docs.gormes.ai/getting-started/quickstart](https://docs.gormes.ai/getting-started/quickstart) |
-| **CLI Reference** | [docs.gormes.ai/reference/cli-commands](https://docs.gormes.ai/reference/cli-commands) |
-| **Architecture** | [docs.gormes.ai/developer-guide/architecture](https://docs.gormes.ai/developer-guide/architecture) |
-| **Roadmap** | [Full architecture plan + checklist](https://docs.gormes.ai/building-gormes/architecture_plan/) |
+- [Quickstart](https://docs.gormes.ai/using-gormes/quickstart/)
+- [Install](https://docs.gormes.ai/using-gormes/install/)
+- [Configuration](https://docs.gormes.ai/using-gormes/configuration/)
+- [Core systems](https://docs.gormes.ai/building-gormes/core-systems/)
+- [Architecture plan](https://docs.gormes.ai/building-gormes/architecture_plan/)
+- [Goncho Honcho Memory](https://docs.gormes.ai/building-gormes/goncho_honcho_memory/)
---
## Contributing
-Contributions are welcome. If you have ideas for new features, integrations, documentation improvements, or fixes, open an issue or submit a pull request.
-
-Start here:
-
-- [CONTRIBUTING.md](CONTRIBUTING.md) for repository contribution guidelines and PR workflow
-- [Gormes developer docs](https://docs.gormes.ai/developer-guide/contributing) for setup and project-specific context
-
-Quick start:
+Contributions are welcome. Build the binary and run the offline UI first:
```bash
git clone https://github.com/TrebuchetDynamics/gormes-agent.git
cd gormes-agent
make build
-./bin/gormes
+./bin/gormes --offline
```
-Join the discussion and help shape the future of Gormes.
+Contributor roadmap: [Building Gormes](https://docs.gormes.ai/building-gormes/)
---
diff --git a/cmd/architecture-planner-loop/main_test.go b/cmd/architecture-planner-loop/main_test.go
index 8133354c2..930529a49 100644
--- a/cmd/architecture-planner-loop/main_test.go
+++ b/cmd/architecture-planner-loop/main_test.go
@@ -160,6 +160,7 @@ func writeCommandFixture(t *testing.T) string {
t.Helper()
root := t.TempDir()
+ t.Setenv("PROGRESS_JSON", filepath.Join(root, "docs", "content", "building-gormes", "architecture_plan", "progress.json"))
writeCommandFile(t, filepath.Join(root, "docs", "content", "building-gormes", "architecture_plan", "progress.json"), `{
"phases": {
"2": {
diff --git a/cmd/autoloop/README.md b/cmd/autoloop/README.md
index f8b23070d..0199fd9aa 100644
--- a/cmd/autoloop/README.md
+++ b/cmd/autoloop/README.md
@@ -62,6 +62,14 @@ Useful environment variables:
unbounded run.
- `PRIORITY_BOOST`: comma-separated subphase IDs to pull ahead of equally ready
work. Defaults to the active priority channels: `2.B.3,2.B.4,2.B.10,2.B.11`.
+- `POST_PROMOTION_VERIFY_COMMANDS`: override the mandatory post-promotion
+ full-suite gate. Separate shell commands with `;;` or newlines. Defaults to
+ `go test ./... -count=1`, `www.gormes.ai` Go tests, progress validation,
+ autoloop dry-run, and the site Playwright e2e suite.
+- `POST_PROMOTION_REPAIR`: enable or disable the automatic repair backend after
+ a failed post-promotion gate. Defaults to enabled.
+- `POST_PROMOTION_REPAIR_ATTEMPTS`: number of repair attempts before the run is
+ recorded as failed. Defaults to `1`.
## Worker isolation and promotion
@@ -93,6 +101,11 @@ finished branches in worker order. The flow per worker is:
`. If push or `gh` fails, autoloop still attempts the same local
cherry-pick fallback. Clean successful/no-change worktrees are removed;
failed worktrees stay in `$RUN_ROOT/worktrees/` for inspection.
+7. After all worker promotions land, run the mandatory post-promotion full-suite
+ gate before emitting `run_completed` or `health_updated`. A gate failure
+ emits `post_promotion_verify_failed`, starts one repair backend by default,
+ requires the repair to leave the checkout clean, reruns the full suite, and
+ records final health only after the gate passes.
Each promotion attempt emits a `worker_promoted` or `worker_promotion_failed`
ledger event so the audit's `productivity` metric reflects work that actually
diff --git a/cmd/autoloop/main.go b/cmd/autoloop/main.go
index f6c7a4650..875bc8f74 100644
--- a/cmd/autoloop/main.go
+++ b/cmd/autoloop/main.go
@@ -268,7 +268,18 @@ func dashIfEmpty(value string) string {
func autoloopEnv() map[string]string {
env := map[string]string{}
- for _, key := range []string{"PROGRESS_JSON", "RUN_ROOT", "BACKEND", "MODE", "MAX_AGENTS", "MAX_PHASE", "PRIORITY_BOOST"} {
+ for _, key := range []string{
+ "PROGRESS_JSON",
+ "RUN_ROOT",
+ "BACKEND",
+ "MODE",
+ "MAX_AGENTS",
+ "MAX_PHASE",
+ "PRIORITY_BOOST",
+ "POST_PROMOTION_VERIFY_COMMANDS",
+ "POST_PROMOTION_REPAIR",
+ "POST_PROMOTION_REPAIR_ATTEMPTS",
+ } {
env[key] = os.Getenv(key)
}
diff --git a/cmd/gormes/goncho.go b/cmd/gormes/goncho.go
index 39554af5d..aca3590ef 100644
--- a/cmd/gormes/goncho.go
+++ b/cmd/gormes/goncho.go
@@ -565,7 +565,7 @@ func formatGonchoDoctorReport(report gonchoDoctorReport) string {
b.WriteString("\n")
}
- b.WriteString("Queue status (observability only; not synchronization)\n")
+ b.WriteString("Queue status (observability/debugging only; not synchronization; do not wait for empty queue)\n")
fmt.Fprintf(&b, "extractor_worker_health: %s\n", report.QueueStatus.Extractor.WorkerHealth)
fmt.Fprintf(&b, "extractor_queue_depth: %d\n", report.QueueStatus.Extractor.QueueDepth)
fmt.Fprintf(&b, "extractor_dead_letters: %d\n", report.QueueStatus.Extractor.DeadLetterCount)
diff --git a/cmd/gormes/goncho_doctor_test.go b/cmd/gormes/goncho_doctor_test.go
index d27543ded..ef35231dc 100644
--- a/cmd/gormes/goncho_doctor_test.go
+++ b/cmd/gormes/goncho_doctor_test.go
@@ -39,7 +39,7 @@ func TestGonchoDoctorCommand_TextZeroStateReportsOperatorLadder(t *testing.T) {
"honcho_context",
"Context dry-run",
"No stored representation for operator:diagnostic.",
- "Queue status (observability only; not synchronization)",
+ "Queue status (observability/debugging only; not synchronization; do not wait for empty queue)",
"extractor_queue_depth: 0",
"representation: total=0 pending=0 in_progress=0 completed=0",
"summary: total=0 pending=0 in_progress=0 completed=0",
diff --git a/cmd/gormes/memory.go b/cmd/gormes/memory.go
index 003cb9272..cd2270edb 100644
--- a/cmd/gormes/memory.go
+++ b/cmd/gormes/memory.go
@@ -82,7 +82,7 @@ func formatExtractorStatus(status memory.ExtractorStatus) string {
func formatGonchoQueueStatus(status goncho.QueueStatus) string {
var b strings.Builder
- b.WriteString("Goncho queue status (observability only; not synchronization)\n")
+ b.WriteString("Goncho queue status (observability/debugging only; not synchronization; do not wait for empty queue)\n")
for _, taskType := range goncho.QueueTaskTypes {
counts := status.WorkUnits[taskType]
b.WriteString(fmt.Sprintf("%s: total=%d pending=%d in_progress=%d completed=%d\n",
diff --git a/cmd/gormes/memory_test.go b/cmd/gormes/memory_test.go
index d2b94bc2a..cf1e8d58e 100644
--- a/cmd/gormes/memory_test.go
+++ b/cmd/gormes/memory_test.go
@@ -84,7 +84,7 @@ func TestMemoryStatusCommand_PrintsGonchoQueueZeroState(t *testing.T) {
out := stdout.String()
for _, want := range []string{
- "Goncho queue status (observability only; not synchronization)",
+ "Goncho queue status (observability/debugging only; not synchronization; do not wait for empty queue)",
"representation: total=0 pending=0 in_progress=0 completed=0",
"summary: total=0 pending=0 in_progress=0 completed=0",
"dream: total=0 pending=0 in_progress=0 completed=0",
diff --git a/docs/content/building-gormes/_index.md b/docs/content/building-gormes/_index.md
index 1b262ac66..7fa0b05dc 100644
--- a/docs/content/building-gormes/_index.md
+++ b/docs/content/building-gormes/_index.md
@@ -70,6 +70,12 @@ Worker execution is isolated: `cmd/autoloop` creates a git worktree under
`RUN_ROOT/worktrees` for each selected row, runs the backend there, and rejects
committed paths outside that row's `write_scope` before promotion.
+Final run health is gated after promotion. Once worker commits are integrated,
+`cmd/autoloop` runs the mandatory full-suite post-promotion verification before
+it emits `run_completed` or `health_updated`. If the suite fails, autoloop runs
+one backend repair attempt by default, requires the checkout to be clean, reruns
+the suite, and records final health only after the repaired integration passes.
+
## Contributor path
Use the planning docs in this order:
diff --git a/docs/content/building-gormes/architecture_plan/_index.md b/docs/content/building-gormes/architecture_plan/_index.md
index 72a855b53..b8fb315a9 100644
--- a/docs/content/building-gormes/architecture_plan/_index.md
+++ b/docs/content/building-gormes/architecture_plan/_index.md
@@ -37,13 +37,13 @@ machine-readable queue for developing the full `gormes-agent`.
## Progress
-**Overall:** 29/73 subphases shipped ยท 13 in progress ยท 31 planned
+**Overall:** 30/73 subphases shipped ยท 18 in progress ยท 25 planned
| Phase | Status | Shipped |
|-------|--------|---------|
| Phase 1 โ The Dashboard | โ
| 3/3 subphases |
| Phase 2 โ The Gateway | ๐จ | 12/19 subphases |
-| Phase 3 โ The Black Box (Memory) | ๐จ | 11/14 subphases |
+| Phase 3 โ The Black Box (Memory) | ๐จ | 12/14 subphases |
| Phase 4 โ The Brain Transplant | ๐จ | 0/8 subphases |
| Phase 5 โ The Final Purge | ๐จ | 1/18 subphases |
| Phase 6 โ The Learning Loop (Soul) | โณ | 0/6 subphases |
@@ -297,18 +297,18 @@ machine-readable queue for developing the full `gormes-agent`.
- [x] Lineage-aware source-filtered search hits
- [ ] Operator-auditable search evidence
-### 3.F โ Goncho Honcho Memory Parity ๐จ
+### 3.F โ Goncho Honcho Memory Parity โ
- [x] Goncho context representation options
- [x] Goncho search filter grammar
- [x] Directional peer cards and representation scopes
-- [ ] Goncho queue status read model
+- [x] Goncho queue status read model
- [x] Goncho summary context budget
- [x] Goncho dialectic chat contract
-- [ ] Goncho file upload import ingestion
+- [x] Goncho file upload import ingestion
- [x] Goncho topology design fixtures
- [x] Goncho operator diagnostics contract
-- [ ] Goncho streaming chat persistence contract
+- [x] Goncho streaming chat persistence contract
- [x] Goncho configuration namespace
## Phase 4 โ The Brain Transplant ๐จ
@@ -322,7 +322,7 @@ machine-readable queue for developing the full `gormes-agent`.
- [ ] DeepSeek/Kimi reasoning_content echo for tool-call replay
- [x] Anthropic
- [ ] Bedrock
-- [ ] Bedrock Converse payload mapping (no AWS SDK)
+- [x] Bedrock Converse payload mapping (no AWS SDK)
- [ ] Bedrock stream event decoding (SSE fixtures)
- [ ] Bedrock SigV4 + credential seam
- [ ] Bedrock stale-client eviction + retry classification
@@ -336,11 +336,11 @@ machine-readable queue for developing the full `gormes-agent`.
- [ ] Cross-provider reasoning-tag sanitization
- [ ] Tool-call argument repair + schema sanitizer
-### 4.B โ Context Engine + Compression โณ
+### 4.B โ Context Engine + Compression ๐จ
- [ ] Long session management
- [ ] Context compression
-- [ ] ContextEngine interface + status tool contract
+- [x] ContextEngine interface + status tool contract
- [ ] Compression token-budget trigger + summary sizing
- [ ] Tool-result pruning + protected head/tail summary
- [ ] Manual compression feedback + context references
@@ -353,10 +353,10 @@ machine-readable queue for developing the full `gormes-agent`.
- [ ] Toolset-aware skills prompt snapshot
- [ ] Memory and session-search guidance assembly
-### 4.D โ Smart Model Routing โณ
+### 4.D โ Smart Model Routing ๐จ
- [ ] Model metadata registry + context limits
-- [ ] Provider-enforced context-length resolver
+- [x] Provider-enforced context-length resolver
- [ ] Model pricing/capability registry fixtures
- [ ] Routing policy and fallback selector
- [ ] Per-turn model selection
@@ -380,7 +380,7 @@ machine-readable queue for developing the full `gormes-agent`.
### 4.H โ Rate / Retry / Caching ๐จ
-- [ ] Provider-side resilience
+- [x] Provider-side resilience
- [x] Classified provider-error taxonomy
- [x] Jittered reconnect backoff schedule
- [x] Retry-After header parsing + HTTPError hint
@@ -392,10 +392,10 @@ machine-readable queue for developing the full `gormes-agent`.
*Python tool scripts ported to Go or WASM*
-### 5.A โ Tool Surface Port โณ
+### 5.A โ Tool Surface Port ๐จ
- [ ] 61-tool registry port
-- [ ] Tool registry inventory + schema parity harness
+- [x] Tool registry inventory + schema parity harness
- [ ] Pure core tools first
- [ ] Stateful tool migration queue
@@ -426,11 +426,11 @@ machine-readable queue for developing the full `gormes-agent`.
- [ ] Transcription tool contract
- [ ] TTS synthesis + voice-mode state
-### 5.F โ Skills System (Remaining) โณ
+### 5.F โ Skills System (Remaining) ๐จ
- [ ] Skills hub
- [ ] Skill registries
-- [ ] Skill preprocessing + dynamic slash commands
+- [x] Skill preprocessing + dynamic slash commands
### 5.G โ MCP Integration โณ
@@ -478,11 +478,11 @@ machine-readable queue for developing the full `gormes-agent`.
- [ ] Cron prompt/script safety + pre-run script contract
- [ ] Cron multi-target delivery + media/live-adapter fallback
-### 5.O โ Hermes CLI Parity โณ
+### 5.O โ Hermes CLI Parity ๐จ
- [ ] 49-file CLI tree port
- [ ] Deterministic helper-file ports (banner/output/tips/webhook/dump)
-- [ ] PTY bridge protocol adapter
+- [x] PTY bridge protocol adapter
- [ ] CLI command registry parity + active-turn busy policy
- [ ] Busy command guard for compression and long CLI actions
- [ ] Config, profile, auth, and setup command surfaces
@@ -497,11 +497,11 @@ machine-readable queue for developing the full `gormes-agent`.
- [ ] Windows installer (install.ps1 + install.cmd) parity
- [ ] Installer site asset/route coverage
-### 5.Q โ API Server + TUI Gateway Streaming โณ
+### 5.Q โ API Server + TUI Gateway Streaming ๐จ
- [ ] Deterministic helper-file ports (tool-progress/image/completion-path/personality/platform-event)
- [ ] SSE streaming to Bubble Tea TUI
-- [ ] OpenAI-compatible chat-completions API server
+- [x] OpenAI-compatible chat-completions API server
- [ ] Responses API store + run event stream
- [ ] API server disconnect snapshot persistence
- [ ] Gateway proxy mode forwarding contract
diff --git a/docs/content/building-gormes/architecture_plan/progress.json b/docs/content/building-gormes/architecture_plan/progress.json
index 005296724..64f93f0eb 100644
--- a/docs/content/building-gormes/architecture_plan/progress.json
+++ b/docs/content/building-gormes/architecture_plan/progress.json
@@ -22,6 +22,8 @@
"MAX_AGENTS is a safety cap: if fewer metadata-ready rows are available, run fewer workers instead of selecting filler or random work.",
"Each worker runs in an isolated git worktree under RUN_ROOT/worktrees and promotion rejects committed paths outside the selected row's write_scope.",
"When git worktrees are available and MAX_AGENTS is greater than 1, cmd/autoloop launches selected workers concurrently, then validates and promotes each branch through the same ledgered safety gates.",
+ "After all promotions, cmd/autoloop runs the mandatory post-promotion full-suite gate before emitting run_completed or health_updated.",
+ "On post-promotion gate failure, cmd/autoloop starts one backend repair attempt by default, requires the checkout to be clean, reruns the suite, and records final health only if the gate passes.",
"Prefer contract rows with write_scope, test_commands, and done_signal.",
"Inject selected progress metadata into the worker prompt instead of asking workers to rescan the whole roadmap."
]
@@ -79,16 +81,16 @@
"scripts/orchestrator/lib/worktree.sh",
"scripts/gormes-auto-codexu-orchestrator.sh"
],
- "unblocks": [
- "Soft-success-nonzero bats coverage",
- "Planner wrapper/test consistency closeout"
- ],
"ready_when": [
"Failure taxonomy and soft-success recovery behavior are covered by direct orchestrator unit fixtures."
],
"not_ready_when": [
"The row is treated as complete before direct try_soft_success_nonzero coverage lands."
],
+ "unblocks": [
+ "Soft-success-nonzero bats coverage",
+ "Planner wrapper/test consistency closeout"
+ ],
"acceptance": [
"Failure rows emit a granular reason instead of contract_or_test_failure.",
"Non-timeout/non-OOM codex exits can become soft_success_nonzero only after final-report and commit verification pass.",
@@ -288,23 +290,23 @@
{
"name": "Slack gateway.Channel adapter shim",
"status": "planned",
- "blocked_by": [
- "Slack CommandRegistry parser wiring"
- ],
"ready_when": [
"Slack ingress uses gateway.ParseInboundText and shared CommandRegistry fixtures are green"
],
+ "blocked_by": [
+ "Slack CommandRegistry parser wiring"
+ ],
"note": "TDD: adapt internal/slack onto the gateway.Channel interface and Manager lifecycle without rewriting the existing Socket Mode client or coalesced reply tests."
},
{
"name": "Slack config + cmd/gormes gateway registration",
"status": "planned",
- "blocked_by": [
- "Slack gateway.Channel adapter shim"
- ],
"ready_when": [
"Slack gateway.Channel adapter shim runs through the shared Manager lifecycle in tests"
],
+ "blocked_by": [
+ "Slack gateway.Channel adapter shim"
+ ],
"note": "TDD: add Slack config loading, doctor coverage, and cmd/gormes gateway registration only after the Channel shim is green; current evidence shows only Telegram and Discord are registered there."
}
]
@@ -337,15 +339,15 @@
"../hermes-agent/tests/gateway/test_session.py",
"docs/content/building-gormes/architecture_plan/phase-2-gateway.md"
],
- "blocked_by": [
- "Bridge-vs-native runtime decision"
- ],
"ready_when": [
"The bridge-vs-native runtime decision identifies which identity source owns the bot/self peer for a session."
],
"not_ready_when": [
"Identity rules are hidden inside send/reconnect code instead of fixture-tested before transport wiring."
],
+ "blocked_by": [
+ "Bridge-vs-native runtime decision"
+ ],
"acceptance": [
"Bridge and native identity inputs produce stable gateway peer IDs.",
"Messages from the bot's own identity are ignored or surfaced as self-chat suppression, not routed back into the kernel.",
@@ -371,12 +373,12 @@
{
"name": "Pairing, reconnect, and send contract",
"status": "planned",
- "blocked_by": [
- "Bridge-vs-native runtime decision"
- ],
"ready_when": [
"WhatsApp runtime-selection contract freezes bridge-first versus native-first startup behavior"
],
+ "blocked_by": [
+ "Bridge-vs-native runtime decision"
+ ],
"note": "TDD: add a transport-neutral outbound lifecycle contract that gates sends on pairing state, retries reconnects with bounded backoff, and maps normalized gateway chat IDs back to raw WhatsApp DM/group peers with reply metadata preservation."
}
]
@@ -397,8 +399,8 @@
},
{
"name": "BlueBubbles iMessage session-context prompt guidance",
- "status": "planned",
"priority": "P3",
+ "status": "planned",
"contract": "Gateway session-context prompts tell the agent when the origin is BlueBubbles/iMessage and ask for short, blank-line-separated message bubbles",
"contract_status": "fixture_ready",
"slice_size": "small",
@@ -415,15 +417,15 @@
"internal/gateway/session_context.go",
"internal/channels/bluebubbles/bot.go"
],
- "blocked_by": [
- "BlueBubbles iMessage bubble formatting parity"
- ],
"ready_when": [
"BlueBubbles outbound formatting splits blank-line paragraphs into separate iMessage sends, so prompt guidance has a matching delivery contract."
],
"not_ready_when": [
"The slice changes general session-context ordering or adds provider/runtime behavior instead of only adding the platform-specific BlueBubbles note."
],
+ "blocked_by": [
+ "BlueBubbles iMessage bubble formatting parity"
+ ],
"acceptance": [
"BuildSessionContextPrompt includes an iMessage/BlueBubbles platform note for source platform `bluebubbles`.",
"The note asks for short conversational replies and blank-line-separated blocks that map to separate bubbles.",
@@ -454,8 +456,8 @@
},
{
"name": "Non-editable gateway progress/commentary send fallback",
- "status": "complete",
"priority": "P3",
+ "status": "complete",
"contract": "Channels without placeholder/edit capabilities receive progress-safe interim or final assistant messages through the plain Send path without EditMessage calls",
"contract_status": "validated",
"slice_size": "small",
@@ -503,6 +505,22 @@
}
]
},
+ "2.B.10": {
+ "name": "WeChat Adapter",
+ "priority": "P1",
+ "items": [
+ {
+ "name": "WeCom + WeiXin shared-chassis bot seam",
+ "status": "complete",
+ "note": "TDD landed: internal/channels/wecom and internal/channels/weixin now pin policy-gated ingress, request/reply routing, and per-platform reply-path contracts before any SDK wiring."
+ },
+ {
+ "name": "WeCom + WeiXin transport/bootstrap layer",
+ "status": "complete",
+ "note": "TDD landed: internal/channels/wecom/runtime.go and internal/channels/weixin/runtime.go now freeze WeCom WebSocket/callback bootstrap, credential validation, WeCom reply-vs-active-push decisions, and Weixin long-poll/context-token lifecycle seams before any real transport binding."
+ }
+ ]
+ },
"2.B.11": {
"name": "Discord Forum Channels",
"priority": "P3",
@@ -515,12 +533,12 @@
{
"name": "Discord forum media + polish parity",
"status": "planned",
- "blocked_by": [
- "Discord forum channel ingress + thread lifecycle"
- ],
"ready_when": [
"Discord forum ingress and thread lifecycle fixtures are green on top of the shipped Discord adapter"
],
+ "blocked_by": [
+ "Discord forum channel ingress + thread lifecycle"
+ ],
"note": "TDD: port upstream PR #607be54a (forum channel media + polish) after the ingress slice is green โ attachment flow for forum posts, initial-post vs reply differences, and deterministic outbound routing to forum threads. Keep the shared-chassis send contract intact so non-forum Discord behavior cannot regress."
}
]
@@ -607,8 +625,8 @@
},
{
"name": "GBrain minion-orchestrator routing policy",
- "status": "complete",
"priority": "P2",
+ "status": "complete",
"contract": "Durable-job routing separates deterministic restart-survivable work from live LLM subagents, following GBrain's unified minion-orchestrator skill while keeping Gormes Go-native subagent APIs",
"contract_status": "validated",
"slice_size": "small",
@@ -657,8 +675,8 @@
},
{
"name": "Durable subagent/job ledger",
- "status": "complete",
"priority": "P2",
+ "status": "complete",
"contract": "SQLite-first job ledger records restartable subagent and deterministic work state with claim, progress, result, error, parent-child, and cancellation fields",
"contract_status": "validated",
"slice_size": "medium",
@@ -678,15 +696,15 @@
"internal/subagent/runlog.go",
"internal/cron/executor.go"
],
- "blocked_by": [
- "GBrain minion-orchestrator routing policy"
- ],
"ready_when": [
"Routing policy fixtures define which work may enter durable orchestration and which callers are allowed to submit or observe each lane."
],
"not_ready_when": [
"The slice tries to implement every GBrain Minions status, Postgres/PGLite compatibility, supervisor process management, or arbitrary shell-job submission."
],
+ "blocked_by": [
+ "GBrain minion-orchestrator routing policy"
+ ],
"acceptance": [
"A SQLite-backed ledger records job id, job kind, status, parent id, depth, progress JSON, result JSON, error text, timestamps, and cancellation intent.",
"Subagent and cron/deterministic job fixtures use the same ledger contract without changing existing public delegate_task behavior.",
@@ -810,15 +828,15 @@
"../hermes-agent/gateway/config.py",
"docs/content/building-gormes/architecture_plan/phase-2-gateway.md"
],
- "blocked_by": [
- "Pairing approval + rate-limit semantics"
- ],
"ready_when": [
"Pairing approval, rate limiting, and allowlist checks are fixture-locked."
],
"not_ready_when": [
"Unknown DMs fall through to normal agent execution or share session state with authorized users."
],
+ "blocked_by": [
+ "Pairing approval + rate-limit semantics"
+ ],
"acceptance": [
"Configured deny mode sends a deterministic denial without creating a session.",
"Configured pair mode sends one bounded pairing prompt and records pending state.",
@@ -920,15 +938,15 @@
"docs/content/upstream-hermes/source-study.md",
"docs/content/building-gormes/upstream-lessons.md"
],
- "blocked_by": [
- "2.E.2"
- ],
"ready_when": [
"2.E.2 is complete and the shared CommandDef registry is stable for gateway commands."
],
"not_ready_when": [
"The implementation tries to inject mid-run prompts instead of only registering /steer and queue fallback behavior."
],
+ "blocked_by": [
+ "2.E.2"
+ ],
"unblocks": [
"Mid-run steer injection between tool calls",
"Gateway-handled slash commands bypass active-session guard"
@@ -988,22 +1006,6 @@
"status": "complete"
}
]
- },
- "2.B.10": {
- "name": "WeChat Adapter",
- "priority": "P1",
- "items": [
- {
- "name": "WeCom + WeiXin shared-chassis bot seam",
- "status": "complete",
- "note": "TDD landed: internal/channels/wecom and internal/channels/weixin now pin policy-gated ingress, request/reply routing, and per-platform reply-path contracts before any SDK wiring."
- },
- {
- "name": "WeCom + WeiXin transport/bootstrap layer",
- "status": "complete",
- "note": "TDD landed: internal/channels/wecom/runtime.go and internal/channels/weixin/runtime.go now freeze WeCom WebSocket/callback bootstrap, credential validation, WeCom reply-vs-active-push decisions, and Weixin long-poll/context-token lifecycle seams before any real transport binding."
- }
- ]
}
}
},
@@ -1379,15 +1381,15 @@
"docs/content/upstream-gbrain/architecture.md",
"docs/content/building-gormes/architecture_plan/phase-3-memory.md"
],
- "blocked_by": [
- "Honcho-compatible scope/source tool schema"
- ],
"ready_when": [
"Honcho-compatible scope/source tool schema is complete and exposes source allowlist semantics."
],
"not_ready_when": [
"Deny-path fixtures are mixed with operator evidence rendering in the same slice."
],
+ "blocked_by": [
+ "Honcho-compatible scope/source tool schema"
+ ],
"unblocks": [
"Cross-chat operator evidence",
"parent_session_id lineage for compression splits"
@@ -1488,15 +1490,15 @@
"../hermes-agent/tests/gateway/test_resume_command.py",
"../hermes-agent/docs/user-guide/sessions.md"
],
- "blocked_by": [
- "parent_session_id lineage for compression splits"
- ],
"ready_when": [
"Session lineage metadata can resolve root -> child chains and distinguish ended compression roots from live descendants."
],
"not_ready_when": [
"The slice changes context compression behavior or loads transcripts from a separate store instead of reusing the native session read model."
],
+ "blocked_by": [
+ "parent_session_id lineage for compression splits"
+ ],
"unblocks": [
"Context compression"
],
@@ -1658,15 +1660,15 @@
"internal/memory/session_catalog.go",
"internal/goncho/types.go"
],
- "blocked_by": [
- "Cross-chat deny-path fixtures"
- ],
"ready_when": [
"Same-chat and user-scope deny paths are fixture-locked so filter failures cannot accidentally widen recall."
],
"not_ready_when": [
"The slice adds an HTTP surface or full SDK compatibility before the internal filter AST is tested."
],
+ "blocked_by": [
+ "Cross-chat deny-path fixtures"
+ ],
"acceptance": [
"Filter AST fixtures cover AND, OR, NOT, gt, gte, lt, lte, ne, in, contains, icontains, metadata, and wildcard parsing.",
"The first executable implementation supports a documented subset and returns unsupported-filter evidence for the rest.",
@@ -1707,15 +1709,15 @@
"internal/memory/schema.go",
"internal/goncho/sql.go"
],
- "blocked_by": [
- "Goncho context representation options"
- ],
"ready_when": [
"Context options expose observer/target fields and current peer-card replacement behavior is fixture-locked."
],
"not_ready_when": [
"The slice tries to port observe_others scheduling before the storage key and card semantics are stable."
],
+ "blocked_by": [
+ "Goncho context representation options"
+ ],
"acceptance": [
"Peer cards enforce Honcho's max-40-facts cap.",
"Manual set_card behavior replaces the full card instead of merging.",
@@ -1739,9 +1741,9 @@
{
"name": "Goncho queue status read model",
"priority": "P3",
- "status": "planned",
+ "status": "complete",
"contract": "Gormes exposes Honcho-style representation, summary, and dream work-unit queue status as observability, not synchronization",
- "contract_status": "draft",
+ "contract_status": "validated",
"slice_size": "small",
"execution_owner": "memory",
"trust_class": [
@@ -1757,21 +1759,21 @@
"docs/content/building-gormes/goncho_honcho_memory/04-agent-work-packets.md",
"internal/memory/status.go"
],
- "blocked_by": [
- "Directional peer cards and representation scopes"
- ],
"ready_when": [
"At least one Goncho-owned task type or a zero-state read model is available to report deterministically."
],
"not_ready_when": [
"The slice waits for the queue to drain or treats queue empty as an application synchronization condition."
],
+ "blocked_by": [
+ "Directional peer cards and representation scopes"
+ ],
"acceptance": [
"Status fields include completed_work_units, in_progress_work_units, pending_work_units, total_work_units, and optional per-session details.",
"Only representation, summary, and dream task types count toward Honcho-style queue status.",
"Docs and CLI output state that queue status is for observability and debugging, not waiting for completion."
],
- "note": "Honcho docs explicitly warn not to wait for an empty queue. Goncho should expose this as operator evidence alongside existing memory status without making queue drain part of turn correctness.",
+ "note": "TDD landed: Goncho exposes a Honcho-style zero-state queue status read model for representation, summary, and dream work units with completed_work_units, in_progress_work_units, pending_work_units, total_work_units, and optional per-session details. Memory status and Goncho doctor output include extractor queue status alongside Goncho work-unit counts and explicitly frame queue status as observability/debugging evidence, not a synchronization contract or queue-drain wait condition.",
"write_scope": [
"internal/goncho/",
"internal/memory/",
@@ -1783,7 +1785,12 @@
],
"done_signal": [
"Queue status fixtures prove Honcho-style counts and document that queue empty is not a synchronization contract."
- ]
+ ],
+ "health": {
+ "attempt_count": 1,
+ "last_attempt": "2026-04-25T04:56:45Z",
+ "last_success": "2026-04-25T04:56:45Z"
+ }
},
{
"name": "Goncho summary context budget",
@@ -1807,15 +1814,15 @@
"internal/goncho/service.go",
"internal/memory/schema.go"
],
- "blocked_by": [
- "Goncho context representation options"
- ],
"ready_when": [
"Context options are schema-visible and the memory store can add a session_summaries table via migration."
],
"not_ready_when": [
"The slice rewrites RecallProvider.GetContext or merges summaries into the existing memory-context fence instead of adding a separate Goncho context component."
],
+ "blocked_by": [
+ "Goncho context representation options"
+ ],
"acceptance": [
"Schema stores one short and one long summary slot per session with last-covered message and token count.",
"Short summaries trigger every 20 messages and long summaries every 60 messages by default.",
@@ -1865,15 +1872,15 @@
"internal/gonchotools/honcho_tools.go",
"internal/goncho/service.go"
],
- "blocked_by": [
- "Goncho context representation options"
- ],
"ready_when": [
"Context options are schema-visible and manual conclusions can be queried through the existing Goncho service."
],
"not_ready_when": [
"The slice replaces honcho_context or removes honcho_reasoning instead of adding the host-compatible honcho_chat contract."
],
+ "blocked_by": [
+ "Goncho context representation options"
+ ],
"acceptance": [
"Chat params accept query, session_id, target, reasoning_level, and stream.",
"The default reasoning level is low and invalid reasoning levels are rejected.",
@@ -1897,9 +1904,9 @@
{
"name": "Goncho file upload import ingestion",
"priority": "P4",
- "status": "planned",
+ "status": "complete",
"contract": "Goncho can import text-like memory files as session messages without persisting original file bytes, matching Honcho's file-upload migration model",
- "contract_status": "draft",
+ "contract_status": "validated",
"slice_size": "medium",
"execution_owner": "memory",
"trust_class": [
@@ -1921,15 +1928,15 @@
"internal/goncho/service.go",
"internal/memory/schema.go"
],
- "blocked_by": [
- "Goncho queue status read model"
- ],
"ready_when": [
"Goncho has a deterministic session-message write path or a documented queue-unavailable degraded path for imported messages."
],
"not_ready_when": [
"The slice stores original uploaded file bytes, silently accepts unsupported content types, or attempts PDF/OCR extraction before text and JSON imports are fixture-locked."
],
+ "blocked_by": [
+ "Goncho queue status read model"
+ ],
"acceptance": [
"Text, Markdown, and JSON imports create normal session messages with required peer_id.",
"Imported chunks persist file_id, filename, chunk_index, total_chunks, original_file_size, content_type, and chunk_character_range metadata.",
@@ -1937,7 +1944,7 @@
"created_at, metadata, and configuration are preserved when provided.",
"Runtime chunk size follows Honcho source settings.MAX_MESSAGE_SIZE at 25000 characters unless upstream changes that setting."
],
- "note": "Honcho docs and the OpenClaw integration use file upload as the non-destructive path for legacy USER.md, MEMORY.md, SOUL.md, memory/, and similar files. Gormes should port the import semantics before adding a managed API client or web upload surface.",
+ "note": "TDD landed: internal/goncho/file_import_test.go covers text, Markdown, and JSON imports as ordinary session messages, file metadata in meta_json, required peer_id, unsupported content-type rejection before writes, no raw JSON file-byte persistence, created_at/metadata/configuration preservation, Honcho MAX_MESSAGE_SIZE chunking at 25000 characters, and queue-unavailable evidence. Verified with go test ./internal/goncho ./internal/memory ./cmd/gormes -count=1.",
"write_scope": [
"internal/goncho/",
"internal/memory/",
@@ -1949,7 +1956,12 @@
],
"done_signal": [
"File import fixtures prove supported formats become ordinary messages, unsupported formats fail before writes, and original files are not persisted."
- ]
+ ],
+ "health": {
+ "attempt_count": 1,
+ "last_attempt": "2026-04-25T05:34:12Z",
+ "last_success": "2026-04-25T05:34:12Z"
+ }
},
{
"name": "Goncho topology design fixtures",
@@ -1979,7 +1991,6 @@
"internal/goncho/service.go",
"internal/gonchotools/honcho_tools.go"
],
- "blocked_by": [],
"ready_when": [
"The current session directory, Goncho service types, and Honcho tool schemas are readable in the repo."
],
@@ -2041,15 +2052,15 @@
"internal/goncho/service.go",
"internal/config/config.go"
],
- "blocked_by": [
- "Goncho topology design fixtures"
- ],
"ready_when": [
"Topology rules define the expected workspace, peer, session, and observation defaults."
],
"not_ready_when": [
"The slice reaches out to upstream Honcho, external network services, or hosted LLMs by default."
],
+ "blocked_by": [
+ "Goncho topology design fixtures"
+ ],
"unblocks": [
"Long-running architecture-planner-loop health reporting",
"Goncho queue status read model"
@@ -2079,9 +2090,9 @@
{
"name": "Goncho streaming chat persistence contract",
"priority": "P3",
- "status": "planned",
+ "status": "complete",
"contract": "Goncho streaming chat stores the final assistant response once and never turns partial stream chunks into memory",
- "contract_status": "draft",
+ "contract_status": "validated",
"slice_size": "small",
"execution_owner": "memory",
"trust_class": [
@@ -2100,15 +2111,15 @@
"internal/gonchotools/honcho_tools.go",
"internal/memory/schema.go"
],
- "blocked_by": [
- "Goncho dialectic chat contract"
- ],
"ready_when": [
"honcho_chat or equivalent dialectic chat params and response shape are fixture-locked."
],
"not_ready_when": [
"The slice stores stream chunks as messages, creates synthetic assistant turns before completion, or changes honcho_context behavior."
],
+ "blocked_by": [
+ "Goncho dialectic chat contract"
+ ],
"unblocks": [
"Internal agent chat transport",
"Hugo docs examples for streaming memory behavior"
@@ -2120,7 +2131,7 @@
"Successful streamed assistant responses are stored exactly once with the same session and assistant peer as non-streaming chat.",
"Token/counting metadata can be attached after completion without affecting the stored text."
],
- "note": "Honcho docs make streaming a chat-response transport detail. Goncho should preserve memory quality by treating only completed assistant messages as durable facts.",
+ "note": "Complete: TDD landed internal/goncho/streaming_chat_persistence_test.go. The fixture proves stream=true degraded chat persists the final assistant response once, streaming handlers buffer chunks until completion, token metadata attaches after completion without mutating stored text, and interrupted streams return evidence without flushing partial assistant content to memory.",
"write_scope": [
"internal/goncho/",
"internal/gonchotools/",
@@ -2132,7 +2143,12 @@
],
"done_signal": [
"Streaming fixtures prove completed responses are persisted once and interrupted or partial chunks cannot pollute memory."
- ]
+ ],
+ "health": {
+ "attempt_count": 1,
+ "last_attempt": "2026-04-25T04:56:45Z",
+ "last_success": "2026-04-25T04:56:45Z"
+ }
},
{
"name": "Goncho configuration namespace",
@@ -2158,15 +2174,15 @@
"internal/goncho/types.go",
"cmd/gormes/doctor.go"
],
- "blocked_by": [
- "Goncho topology design fixtures"
- ],
"ready_when": [
"The existing Gormes config loader and doctor output can be extended without changing unrelated agent settings."
],
"not_ready_when": [
"The slice copies Honcho Python environment variables directly or requires provider credentials before Goncho can run in zero-state mode."
],
+ "blocked_by": [
+ "Goncho topology design fixtures"
+ ],
"unblocks": [
"Goncho operator diagnostics contract",
"Goncho file upload import ingestion",
@@ -2221,18 +2237,18 @@
"docs/content/upstream-hermes/source-study.md",
"docs/content/building-gormes/architecture_plan/phase-4-brain-transplant.md"
],
- "unblocks": [
- "Bedrock Converse payload mapping (no AWS SDK)",
- "Gemini",
- "OpenRouter",
- "Codex"
- ],
"ready_when": [
"Anthropic transcript fixtures replay request, stream, finish reason, and usage data without live credentials."
],
"not_ready_when": [
"A provider-specific adapter lands before shared transcript fixtures prove the contract."
],
+ "unblocks": [
+ "Bedrock Converse payload mapping (no AWS SDK)",
+ "Gemini",
+ "OpenRouter",
+ "Codex"
+ ],
"acceptance": [
"Provider transcripts replay request, stream, finish reason, and usage data without live credentials.",
"EOF after partial tool_call surfaces pending calls instead of dropping them.",
@@ -2266,15 +2282,15 @@
"docs/content/upstream-hermes/source-study.md",
"docs/content/building-gormes/architecture_plan/phase-4-brain-transplant.md"
],
- "blocked_by": [
- "Provider interface + stream fixture harness"
- ],
"ready_when": [
"Provider interface + stream fixture harness is available for cross-provider tool continuation fixtures."
],
"not_ready_when": [
"Continuation mapping is implemented inside one provider adapter instead of the shared event model."
],
+ "blocked_by": [
+ "Provider interface + stream fixture harness"
+ ],
"unblocks": [
"DeepSeek/Kimi reasoning_content echo for tool-call replay",
"Bedrock stream event decoding (SSE fixtures)",
@@ -2300,9 +2316,9 @@
},
{
"name": "DeepSeek/Kimi reasoning_content echo for tool-call replay",
- "status": "planned",
+ "status": "complete",
"contract": "Thinking-mode providers that require reasoning_content on assistant tool-call turns receive an echoed value during persistence and API replay",
- "contract_status": "fixture_ready",
+ "contract_status": "validated",
"slice_size": "small",
"execution_owner": "provider",
"trust_class": [
@@ -2332,7 +2348,7 @@
"Kimi/Moonshot detection keeps the existing reasoning_content padding behavior.",
"Explicit reasoning_content or reasoning fields are preserved, while non-tool assistant turns and non-thinking providers are left untouched."
],
- "note": "Upstream Hermes commit d58b305a added DeepSeek V4 thinking-mode regression coverage and extracted Kimi/DeepSeek detection helpers so both creation and replay paths inject reasoning_content on assistant tool-call turns. Port this as a provider-boundary fixture over Gormes' shared Message/Event contract before more reasoning-model adapters land.",
+ "note": "Complete: TDD landed `internal/hermes/reasoning_content_echo_test.go` with provider-boundary fixtures proving DeepSeek and Kimi/Moonshot OpenAI-compatible replays inject `reasoning_content=\"\"` on assistant tool-call messages when no reasoning exists. Detection covers DeepSeek provider name, model substring, and `api.deepseek.com` host plus Kimi/Moonshot provider/host signals. Explicit `reasoning_content` and normalized `reasoning` echoes are preserved on thinking-provider tool-call turns, non-tool assistant turns and generic providers are left unpadded, ordinary assistant content is unchanged, and provider status exposes the reasoning_content padding/repair degraded mode.",
"write_scope": [
"internal/hermes/",
"docs/content/building-gormes/architecture_plan/progress.json"
@@ -2342,7 +2358,12 @@
],
"done_signal": [
"Reasoning echo fixtures prove DeepSeek and Kimi tool-call replays include provider-required reasoning_content without mutating ordinary assistant content."
- ]
+ ],
+ "health": {
+ "attempt_count": 1,
+ "last_attempt": "2026-04-25T06:55:51Z",
+ "last_success": "2026-04-25T06:55:51Z"
+ }
},
{
"name": "Anthropic",
@@ -2356,9 +2377,9 @@
},
{
"name": "Bedrock Converse payload mapping (no AWS SDK)",
- "status": "planned",
+ "status": "complete",
"contract": "Pure Bedrock Converse request mapping over the shared provider message/tool contract",
- "contract_status": "fixture_ready",
+ "contract_status": "validated",
"slice_size": "small",
"execution_owner": "provider",
"trust_class": [
@@ -2386,7 +2407,7 @@
"Tool definitions map to Bedrock toolSpec inputSchema without dropping required fields.",
"Golden request fixtures pin max_tokens, temperature, cache/reasoning passthrough, and empty-content placeholders."
],
- "note": "TDD first slice: port Bedrock Converse request-payload shaping plus canonical Message->Bedrock tool-aware mapping with pure fixtures and no AWS SDK dependency. Land alongside a request-body golden file that pins role/tool-result block order, reasoning/cache-control passthrough, and max_tokens/temperature translation. Gates the next two slices.",
+ "note": "Complete: TDD added pure Bedrock Converse request-payload shaping in `internal/hermes` plus a request-body golden fixture. The mapper converts shared system/user/assistant/tool-result messages into Converse roles and content blocks, preserves assistant reasoning blocks and Bedrock cachePoint hints, maps tool definitions to `toolSpec.inputSchema.json` without dropping required fields, and pins `inferenceConfig.maxTokens`/`temperature` plus empty-content placeholders without importing AWS SDK clients or signing live requests. Bedrock remains unavailable until stream decoding and credential wiring land.",
"write_scope": [
"internal/hermes/",
"docs/content/building-gormes/architecture_plan/progress.json"
@@ -2396,7 +2417,12 @@
],
"done_signal": [
"Bedrock request-body golden fixtures prove Converse mapping without AWS credentials or SDK clients."
- ]
+ ],
+ "health": {
+ "attempt_count": 1,
+ "last_attempt": "2026-04-25T05:56:06Z",
+ "last_success": "2026-04-25T05:56:06Z"
+ }
},
{
"name": "Bedrock stream event decoding (SSE fixtures)",
@@ -2425,15 +2451,15 @@
"../hermes-agent/tests/agent/test_bedrock_adapter.py",
"../hermes-agent/tests/agent/test_bedrock_integration.py"
],
- "blocked_by": [
- "Bedrock SigV4 + credential seam"
- ],
"ready_when": [
"A Bedrock client/cache seam exists behind the provider adapter and can be exercised without live AWS credentials."
],
"not_ready_when": [
"Non-stale validation/auth failures are retried or evicted as if they were transport-pool corruption."
],
+ "blocked_by": [
+ "Bedrock SigV4 + credential seam"
+ ],
"acceptance": [
"ConnectionClosed/ProtocolError-style failures evict only the affected region client.",
"Library-internal assertion failures from transport stacks are classified as stale, while application assertions are not.",
@@ -2483,9 +2509,9 @@
},
{
"name": "Codex Responses pure conversion harness",
- "status": "planned",
+ "status": "complete",
"contract": "OpenAI Responses request/response conversion for Codex-compatible providers without live OAuth",
- "contract_status": "fixture_ready",
+ "contract_status": "validated",
"slice_size": "small",
"execution_owner": "provider",
"trust_class": [
@@ -2514,7 +2540,7 @@
"Function tools convert to Responses function-tool schemas with deterministic call IDs.",
"Responses output items normalize back to shared provider events, messages, usage, and tool calls."
],
- "note": "Upstream extracted `agent/codex_responses_adapter.py` as pure conversion logic. Port that shape first so Codex-compatible Responses behavior is fixture-backed before OAuth, model fallback, or live chatgpt.com backend calls are introduced.",
+ "note": "Complete: TDD added `internal/hermes/codex_responses_adapter.go` and `internal/hermes/codex_responses_adapter_test.go` as a pure Responses conversion harness. The fixture converts shared chat messages, system instructions, multimodal input_text/input_image content parts, function tool schemas, deterministic fallback call IDs, and function_call_output continuations into Responses payloads. It normalizes Responses output items back into shared assistant messages, reasoning/token/done events, usage, and tool calls. No OAuth/device login, ~/.codex import, or live Responses request is introduced; Codex status remains unavailable until auth wiring lands.",
"write_scope": [
"internal/hermes/",
"docs/content/building-gormes/architecture_plan/progress.json"
@@ -2524,7 +2550,12 @@
],
"done_signal": [
"Codex Responses fixtures convert chat input, tool schemas, output items, usage, and tool calls without live credentials."
- ]
+ ],
+ "health": {
+ "attempt_count": 2,
+ "last_attempt": "2026-04-25T06:55:51Z",
+ "last_success": "2026-04-25T06:55:51Z"
+ }
},
{
"name": "Codex OAuth state + stale-token relogin",
@@ -2544,17 +2575,17 @@
"../hermes-agent/tests/hermes_cli/test_anthropic_model_flow_stale_oauth.py",
"../hermes-agent/tests/run_agent/test_run_agent_codex_responses.py"
],
- "blocked_by": [
- "Token vault",
- "Multi-account auth",
- "Codex Responses pure conversion harness"
- ],
"ready_when": [
"Gormes has an XDG-scoped token vault and account-selection seam for provider credentials."
],
"not_ready_when": [
"The slice reads or writes ~/.codex/auth.json as the primary state store."
],
+ "blocked_by": [
+ "Token vault",
+ "Multi-account auth",
+ "Codex Responses pure conversion harness"
+ ],
"acceptance": [
"Codex tokens persist under Gormes home with provider/account metadata.",
"401/403 refresh failures return relogin-required status and do not silently retry stale tokens.",
@@ -2591,15 +2622,15 @@
"../hermes-agent/tests/run_agent/test_repair_tool_call_arguments.py",
"../hermes-agent/tests/run_agent/test_tool_call_args_sanitizer.py"
],
- "blocked_by": [
- "Codex Responses pure conversion harness"
- ],
"ready_when": [
"Codex Responses conversion fixtures can replay streamed and non-streamed output without live credentials."
],
"not_ready_when": [
"Malformed tool calls are stored in assistant history as ordinary text or a repair path hides unsupported API features."
],
+ "blocked_by": [
+ "Codex Responses pure conversion harness"
+ ],
"acceptance": [
"Empty response.output with streamed output_text backfills final assistant content.",
"Leaked to=functions.* text is rejected or repaired before it reaches parent history.",
@@ -2624,9 +2655,9 @@
},
{
"name": "Tool-call argument repair + schema sanitizer",
- "status": "planned",
+ "status": "complete",
"contract": "Provider tool-call arguments are repaired or rejected against available tool schemas before execution",
- "contract_status": "fixture_ready",
+ "contract_status": "validated",
"slice_size": "small",
"execution_owner": "provider",
"trust_class": [
@@ -2657,7 +2688,7 @@
"Impossible repairs return a provider/tool-call error before execution.",
"Repair decisions use the current advertised tool schema so disabled or unavailable tools cannot be hallucinated into execution."
],
- "note": "Hermes added focused regression tests for tool-call argument repair and schema sanitization. Port this as a shared provider boundary so Codex, OpenRouter, Bedrock, and child-agent trust classes do not each implement their own repair rules.",
+ "note": "Complete: TDD added `internal/hermes/tool_call_argument_repair_test.go` and a shared provider-boundary repair/sanitizer. OpenAI-compatible and Anthropic streams now repair deterministic malformed JSON arguments against the advertised descriptor schema, reject impossible repairs, missing required fields, and unadvertised tools before EventDone reaches execution, and provider request mappers sanitize hostile schema shapes while preserving already-safe schemas. Verified with `go test ./internal/hermes ./internal/tools -count=1`.",
"write_scope": [
"internal/hermes/",
"internal/tools/",
@@ -2668,7 +2699,12 @@
],
"done_signal": [
"Tool-call repair fixtures prove malformed arguments are repaired or rejected before execution using the advertised schema."
- ]
+ ],
+ "health": {
+ "attempt_count": 1,
+ "last_attempt": "2026-04-25T06:55:51Z",
+ "last_success": "2026-04-25T06:55:51Z"
+ }
}
]
},
@@ -2697,9 +2733,9 @@
},
{
"name": "ContextEngine interface + status tool contract",
- "status": "planned",
+ "status": "complete",
"contract": "Stable context engine status and compression boundary",
- "contract_status": "draft",
+ "contract_status": "validated",
"slice_size": "medium",
"execution_owner": "provider",
"trust_class": [
@@ -2707,14 +2743,11 @@
"system"
],
"degraded_mode": "Context status reports disabled compression, cooldowns, unknown tools, token-budget pressure, and replay gaps.",
- "fixture": "internal/contextengine status and compression replay fixtures",
+ "fixture": "internal/hermes/testdata/context_status and internal/kernel context-engine replay fixtures",
"source_refs": [
"docs/content/upstream-hermes/source-study.md",
"docs/content/building-gormes/architecture_plan/phase-4-brain-transplant.md"
],
- "blocked_by": [
- "Provider interface + stream fixture harness"
- ],
"ready_when": [
"Provider interface + stream fixture harness can replay context status without live provider calls."
],
@@ -2730,7 +2763,7 @@
"Compression remains an explicit engine boundary, not a hidden kernel side effect.",
"Fixtures replay context status without live provider calls."
],
- "note": "TDD: port the `agent/context_engine.py` interface, `get_status` payload, update_model_context behavior, and unknown tool error shape before any compressor implementation is wired into the agent loop.",
+ "note": "Complete: TDD landed a provider-owned Go ContextEngine contract in internal/hermes, a disabled engine with context_status payload fixtures for window, budget pressure, compression disabled/cooldown state, replay gaps, and structured unknown-context-tool errors. The kernel now snapshots context status, updates usage from provider EventDone, advertises context-engine tools, and dispatches them through the explicit engine boundary without calling Compress as a hidden side effect.",
"write_scope": [
"internal/kernel/",
"internal/hermes/",
@@ -2741,7 +2774,12 @@
],
"done_signal": [
"Context status fixtures expose budget, compression state, and unknown-tool errors without live provider calls."
- ]
+ ],
+ "health": {
+ "attempt_count": 1,
+ "last_attempt": "2026-04-25T05:56:06Z",
+ "last_success": "2026-04-25T05:56:06Z"
+ }
},
{
"name": "Compression token-budget trigger + summary sizing",
@@ -2810,9 +2848,9 @@
},
{
"name": "Provider-enforced context-length resolver",
- "status": "planned",
+ "status": "complete",
"contract": "Displayed and budgeted context windows prefer provider-enforced limits over raw models.dev metadata",
- "contract_status": "fixture_ready",
+ "contract_status": "validated",
"slice_size": "small",
"execution_owner": "provider",
"trust_class": [
@@ -2843,7 +2881,7 @@
"Provider-specific caps for Codex OAuth, Copilot, and Nous win over model-info fallbacks when present.",
"Unknown resolver failures fall back to fixture model metadata and report unknown when both sources are empty."
],
- "note": "Hermes now routes /model display through resolve_display_context_length so provider-enforced caps win over raw models.dev entries. Gormes should port the resolver as a pure metadata fixture first; CLI/gateway display wiring can consume it later without duplicating cap logic.",
+ "note": "TDD landed: internal/hermes/model_context_resolver_test.go proves ResolveDisplayContextLength and ModelContextResolver deterministically prefer provider-enforced caps over raw models.dev metadata, fall back to fixture model metadata when provider metadata is unavailable, and report unknown when both sources are empty. The pure resolver has no live provider credentials, disk cache, routing policy, or network dependency.",
"write_scope": [
"internal/hermes/",
"docs/content/building-gormes/architecture_plan/progress.json"
@@ -2853,13 +2891,18 @@
],
"done_signal": [
"Context resolver fixtures prove provider caps, models.dev fallback, and unknown-model reporting are deterministic without network calls."
- ]
+ ],
+ "health": {
+ "attempt_count": 1,
+ "last_attempt": "2026-04-25T05:56:06Z",
+ "last_success": "2026-04-25T05:56:06Z"
+ }
},
{
"name": "Model pricing/capability registry fixtures",
- "status": "planned",
+ "status": "complete",
"contract": "Read-only model metadata exposes deterministic pricing, capability flags, provider family, and raw context facts before routing consumes them",
- "contract_status": "draft",
+ "contract_status": "validated",
"slice_size": "small",
"execution_owner": "provider",
"trust_class": [
@@ -2874,9 +2917,6 @@
"../hermes-agent/agent/usage_pricing.py",
"docs/content/building-gormes/architecture_plan/subsystem-inventory.md"
],
- "blocked_by": [
- "Provider-enforced context-length resolver"
- ],
"ready_when": [
"Provider-enforced context resolver fixtures establish the metadata package shape and fallback semantics."
],
@@ -2891,7 +2931,7 @@
"Missing pricing and missing capability values remain explicit unknowns.",
"Registry fixtures are embedded or local-testdata only and never require live models.dev access."
],
- "note": "Split out from the former broad metadata row so autoloop workers can first land read-only model facts before any routing selector consumes them.",
+ "note": "TDD landed: internal/hermes/model_registry_test.go covers default embedded fixture lookup for provider/model family, raw context window, max output, pricing fields, and capability flags; explicit unknown pricing/capability states for sparse entries; and stale embedded snapshot status. The registry is static in internal/hermes/model_registry.go and performs no live models.dev access or routing decisions.",
"write_scope": [
"internal/hermes/",
"docs/content/building-gormes/architecture_plan/progress.json"
@@ -2901,7 +2941,12 @@
],
"done_signal": [
"Model registry fixtures expose pricing, capability, provider-family, and raw context metadata with explicit unknown states."
- ]
+ ],
+ "health": {
+ "attempt_count": 2,
+ "last_attempt": "2026-04-25T06:55:51Z",
+ "last_success": "2026-04-25T06:55:51Z"
+ }
},
{
"name": "Routing policy and fallback selector",
@@ -2922,16 +2967,16 @@
"../hermes-agent/hermes_cli/runtime_provider.py",
"docs/content/building-gormes/architecture_plan/phase-4-brain-transplant.md"
],
- "blocked_by": [
- "Provider-enforced context-length resolver",
- "Model pricing/capability registry fixtures"
- ],
"ready_when": [
"Context limits, pricing, capabilities, and provider-family metadata are fixture-backed."
],
"not_ready_when": [
"The selector mutates kernel turn state, opens provider network calls, or hides operator-specified model overrides."
],
+ "blocked_by": [
+ "Provider-enforced context-length resolver",
+ "Model pricing/capability registry fixtures"
+ ],
"acceptance": [
"Explicit per-turn or config overrides win over automatic routing unless invalid.",
"Fallback choices are deterministic from fixture provider availability and model metadata.",
@@ -3014,15 +3059,15 @@
"../hermes-agent/hermes_cli/auth.py",
"../hermes-agent/tests/hermes_cli/test_anthropic_model_flow_stale_oauth.py"
],
- "blocked_by": [
- "Token vault"
- ],
"ready_when": [
"Token vault owns XDG-scoped credential files and can expose provider auth status without live credentials."
],
"not_ready_when": [
"The slice silently resets corrupt auth state or reads platform keychains during ordinary unit tests."
],
+ "blocked_by": [
+ "Token vault"
+ ],
"acceptance": [
"Fake keychain entries take precedence over JSON auth files when valid.",
"Malformed auth JSON is preserved to a recoverable backup and surfaces a warning.",
@@ -3058,9 +3103,9 @@
"items": [
{
"name": "Provider-side resilience",
- "status": "in_progress",
+ "status": "complete",
"contract": "Provider resilience umbrella over retry, cache, rate, and budget behavior",
- "contract_status": "draft",
+ "contract_status": "validated",
"slice_size": "large",
"execution_owner": "provider",
"trust_class": [
@@ -3072,15 +3117,15 @@
"docs/content/upstream-hermes/source-study.md",
"docs/content/building-gormes/architecture_plan/phase-4-brain-transplant.md"
],
- "blocked_by": [
- "Provider interface + stream fixture harness"
- ],
"ready_when": [
"Provider interface + stream fixture harness is available for resilience fixture coverage."
],
"not_ready_when": [
"The row is used to port every retry, cache, rate, and budget behavior as one monolithic implementation."
],
+ "blocked_by": [
+ "Provider interface + stream fixture harness"
+ ],
"unblocks": [
"Retry-After header parsing + HTTPError hint",
"Kernel retry honors Retry-After hint",
@@ -3091,7 +3136,7 @@
"Kernel retry policy reports schedule and provider hint decisions.",
"Unavailable cache/rate/budget capability is visible before routing relies on it."
],
- "note": "Umbrella tracker for 4.H closeout. Already shipped: structured provider-error taxonomy in `internal/hermes/errors.go`, Retry-After header/body parsing on `HTTPError`, and `internal/kernel/retry.go` 1s/2s/4s/8s/16s +/-20% reconnect budget with provider Retry-After hints preferred and capped during open-stream retries. Remaining work is owned by the sibling slices below โ `Prompt-cache capability guard` and `Provider rate guard + budget telemetry`.",
+ "note": "Complete: TDD landed the 4.H umbrella status contract without porting the sibling slices monolithically. `internal/hermes.ProviderStatusOf` now exposes prompt-cache, rate-guard, and budget-telemetry capability rows for OpenAI-compatible, Anthropic, mock, and unknown providers; OpenAI-compatible request fixtures prove unsupported `cache_control` metadata is stripped with a visible disabled path, while Anthropic reports cache-control support. `internal/kernel.RenderFrame` now carries provider status plus retry status, including the 1s/2s/4s/8s/16s schedule, max Retry-After cap, attempts used, provider hint vs scheduled-backoff decision, and retryable provider-error class/kind. Rate-guard and budget-telemetry implementations remain planned in their sibling slices, but their unavailable state is visible before routing can rely on them. Verified with `go test ./internal/hermes ./internal/kernel -count=1`.",
"write_scope": [
"internal/hermes/",
"internal/kernel/",
@@ -3102,7 +3147,12 @@
],
"done_signal": [
"Retry, cache, rate, budget, and provider-hint behavior is fixture-covered and visible in provider/kernel status."
- ]
+ ],
+ "health": {
+ "attempt_count": 1,
+ "last_attempt": "2026-04-25T05:56:06Z",
+ "last_success": "2026-04-25T05:56:06Z"
+ }
},
{
"name": "Classified provider-error taxonomy",
@@ -3120,15 +3170,15 @@
"docs/content/upstream-hermes/source-study.md",
"docs/content/building-gormes/architecture_plan/phase-4-brain-transplant.md"
],
- "blocked_by": [
- "Provider-side resilience"
- ],
"ready_when": [
"Provider-side resilience remains active and error taxonomy fixtures can be split from retry-policy changes."
],
"not_ready_when": [
"The slice changes kernel retry timing instead of only defining structured error classes and fixtures."
],
+ "blocked_by": [
+ "Provider-side resilience"
+ ],
"unblocks": [
"Retry-After header parsing + HTTPError hint",
"Provider rate guard + budget telemetry"
@@ -3205,9 +3255,9 @@
},
{
"name": "Tool registry inventory + schema parity harness",
- "status": "planned",
+ "status": "complete",
"contract": "Operation and tool descriptor parity before handler ports",
- "contract_status": "draft",
+ "contract_status": "validated",
"slice_size": "medium",
"execution_owner": "tools",
"trust_class": [
@@ -3238,7 +3288,7 @@
"No handler port can mark complete until its descriptor parity row exists.",
"Doctor can report missing dependencies or disabled provider-specific paths."
],
- "note": "TDD: snapshot upstream `tools/registry.py`, `toolsets.py`, and discovered schemas into a Go parity fixture that proves names, toolsets, required env vars, and JSON result shapes before porting handlers.",
+ "note": "Complete: TDD added an embedded upstream tool parity manifest generated from `tools/registry.py`, `toolsets.py`, and discovered schemas. The fixture captures 55 tool rows plus static/resolved toolsets with required env vars, provider paths, JSON schemas, result envelopes, trust classes, dependencies, and degraded status metadata. `LoadUpstreamToolParityManifest`, descriptor-row port gating, and the parity doctor now report disabled tools, missing dependencies, schema drift, and unavailable provider-specific paths before any handler port can claim completion.",
"write_scope": [
"internal/tools/",
"docs/content/building-gormes/architecture_plan/progress.json"
@@ -3248,7 +3298,12 @@
],
"done_signal": [
"Tool descriptor parity fixtures capture names, schemas, trust classes, dependencies, and degraded status before handler ports."
- ]
+ ],
+ "health": {
+ "attempt_count": 1,
+ "last_attempt": "2026-04-25T05:56:06Z",
+ "last_success": "2026-04-25T05:56:06Z"
+ }
},
{
"name": "Pure core tools first",
@@ -3372,9 +3427,9 @@
},
{
"name": "Skill preprocessing + dynamic slash commands",
- "status": "planned",
+ "status": "complete",
"contract": "Skill content preprocessing and skill-backed slash commands are deterministic, disabled-skill aware, and prompt-safe",
- "contract_status": "fixture_ready",
+ "contract_status": "validated",
"slice_size": "small",
"execution_owner": "skills",
"trust_class": [
@@ -3406,7 +3461,7 @@
"Inline shell preprocessing is disabled by default and bounded when explicitly enabled.",
"Skill slash commands skip disabled/incompatible skills and build stable user-message content."
],
- "note": "Hermes split shared SKILL.md preprocessing into `agent/skill_preprocessing.py` and expanded skill slash command scanning. Gormes has the core Phase 2.G store; this slice adds the remaining prompt/command preprocessing contract without widening automatic skill execution.",
+ "note": "TDD landed: internal/skills/preprocessing_commands_test.go covers deterministic template preprocessing, inline shell remaining disabled by default and output-bounded when explicitly enabled, status reporting for disabled/unsupported/missing-prerequisite/preprocessing-failed skills without prompt injection, and skill slash-command generation that exposes only available skills through stable gateway command surfaces.",
"write_scope": [
"internal/skills/",
"internal/gateway/",
@@ -3417,7 +3472,12 @@
],
"done_signal": [
"Skill preprocessing and slash-command fixtures prove disabled/incompatible skills do not enter prompt or command surfaces."
- ]
+ ],
+ "health": {
+ "attempt_count": 1,
+ "last_attempt": "2026-04-25T05:56:06Z",
+ "last_success": "2026-04-25T05:56:06Z"
+ }
}
]
},
@@ -3484,15 +3544,15 @@
"../hermes-agent/tests/tools/test_spotify_client.py",
"../hermes-agent/website/docs/user-guide/skills/bundled/media/media-spotify.md"
],
- "blocked_by": [
- "Plugin SDK"
- ],
"ready_when": [
"Plugin manifest loading and capability registration are fixture-locked by the Plugin SDK slice."
],
"not_ready_when": [
"Spotify is ported as a built-in core tool instead of a plugin-backed capability."
],
+ "blocked_by": [
+ "Plugin SDK"
+ ],
"acceptance": [
"The Spotify manifest declares required env/auth and tool capabilities before handlers load.",
"Missing credentials keep Spotify disabled with visible status.",
@@ -3583,8 +3643,8 @@
},
{
"name": "Clarify",
- "note": "TDD: port `tools/clarify_tool.py` as an interruptible prompt contract that preserves gateway/TUI response routing and times out safely in non-interactive cron turns.",
- "status": "planned"
+ "status": "planned",
+ "note": "TDD: port `tools/clarify_tool.py` as an interruptible prompt contract that preserves gateway/TUI response routing and times out safely in non-interactive cron turns."
},
{
"name": "Session search",
@@ -3640,9 +3700,9 @@
},
{
"name": "PTY bridge protocol adapter",
- "status": "planned",
+ "status": "complete",
"contract": "Dashboard/TUI PTY sessions expose bounded read, write, resize, close, and unavailable-state behavior through a testable adapter",
- "contract_status": "fixture_ready",
+ "contract_status": "validated",
"slice_size": "small",
"execution_owner": "tools",
"trust_class": [
@@ -3670,7 +3730,7 @@
"Writes and resize messages validate input before reaching the PTY.",
"Unsupported platforms return PtyUnavailable-style errors without starting a shell."
],
- "note": "Upstream added `hermes_cli/pty_bridge.py` plus dashboard `/api/pty` wiring. Port the PTY adapter as a small protocol slice before any dashboard or remote TUI transport work consumes it.",
+ "note": "Complete: TDD landed internal/cli/pty_bridge_test.go plus a small internal/cli PTY adapter. Fixtures prove bounded read timeout/chunk behavior, write forwarding with pre-session validation, resize escape validation and winsize forwarding, idempotent close/child termination, and ErrPtyUnavailable degradation before spawn on unsupported platforms without dashboard, network, or live TUI dependencies.",
"write_scope": [
"internal/cli/",
"docs/content/building-gormes/architecture_plan/progress.json"
@@ -3680,7 +3740,12 @@
],
"done_signal": [
"PTY bridge fixtures prove read/write/resize/close/unavailable behavior without network or live dashboard dependencies."
- ]
+ ],
+ "health": {
+ "attempt_count": 1,
+ "last_attempt": "2026-04-25T05:56:06Z",
+ "last_success": "2026-04-25T05:56:06Z"
+ }
},
{
"name": "CLI command registry parity + active-turn busy policy",
@@ -3704,15 +3769,15 @@
"../hermes-agent/tests/cli/test_busy_input_mode_command.py",
"../hermes-agent/hermes_cli/commands.py"
],
- "blocked_by": [
- "CLI command registry parity + active-turn busy policy"
- ],
"ready_when": [
"The CLI command registry has a shared active-turn/busy policy surface."
],
"not_ready_when": [
"Busy state is implemented only for /compress or only in the visual TUI without a command-layer invariant."
],
+ "blocked_by": [
+ "CLI command registry parity + active-turn busy policy"
+ ],
"acceptance": [
"/compress and other long-running command handlers set and clear busy state even on error.",
"User input during busy command execution returns a visible busy response.",
@@ -3793,9 +3858,9 @@
},
{
"name": "OpenAI-compatible chat-completions API server",
- "status": "planned",
+ "status": "complete",
"contract": "OpenAI-compatible chat.completions HTTP surface over the native Gormes turn loop",
- "contract_status": "fixture_ready",
+ "contract_status": "validated",
"slice_size": "medium",
"execution_owner": "gateway",
"trust_class": [
@@ -3838,13 +3903,18 @@
],
"done_signal": [
"Chat-completions HTTP fixtures prove auth, body limits, content normalization, streaming envelopes, and session continuity over native Gormes state."
- ]
+ ],
+ "health": {
+ "attempt_count": 1,
+ "last_attempt": "2026-04-25T05:56:06Z",
+ "last_success": "2026-04-25T05:56:06Z"
+ }
},
{
"name": "Responses API store + run event stream",
- "status": "planned",
+ "status": "complete",
"contract": "Stateful OpenAI Responses and runs APIs over the same native session chain as chat completions",
- "contract_status": "draft",
+ "contract_status": "validated",
"slice_size": "medium",
"execution_owner": "gateway",
"trust_class": [
@@ -3858,15 +3928,15 @@
"../hermes-agent/tests/gateway/test_api_server.py",
"docs/content/upstream-hermes/user-guide/features/api-server.md"
],
- "blocked_by": [
- "OpenAI-compatible chat-completions API server"
- ],
"ready_when": [
"Chat-completions HTTP surface is native and response storage can reuse its auth, session, and error-envelope contracts."
],
"not_ready_when": [
"Responses history chains use a separate session model from gateway/TUI sessions."
],
+ "blocked_by": [
+ "OpenAI-compatible chat-completions API server"
+ ],
"unblocks": [
"API server disconnect snapshot persistence",
"Dashboard API client contract"
@@ -3876,7 +3946,7 @@
"previous_response_id and conversation IDs reconstruct full history including tool calls/results.",
"/v1/runs and /v1/runs/{id}/events stream lifecycle events and sweep orphaned runs."
],
- "note": "TDD: port ResponseStore as a bounded persistent read model, previous_response_id chaining, GET/DELETE /v1/responses, POST /v1/runs, GET /v1/runs/{run_id}/events SSE, and orphaned-run cleanup as fixtures before external OpenAI-compatible clients depend on stateful runs.",
+ "note": "Complete: TDD landed a bounded bbolt-backed ResponseStore with LRU eviction, GET/DELETE /v1/responses, previous_response_id and conversation-name chaining over native session IDs including tool calls/results, /v1/runs lifecycle SSE, orphaned-run sweep, and API health counters for store state, LRU evictions, orphan sweeps, and previous_response_id misses.",
"write_scope": [
"internal/apiserver/",
"internal/session/",
@@ -3888,13 +3958,18 @@
],
"done_signal": [
"Responses/runs fixtures prove bounded response storage, previous_response_id chaining, event streaming, and orphan cleanup over native session state."
- ]
+ ],
+ "health": {
+ "attempt_count": 1,
+ "last_attempt": "2026-04-25T06:55:51Z",
+ "last_success": "2026-04-25T06:55:51Z"
+ }
},
{
"name": "API server disconnect snapshot persistence",
- "status": "planned",
+ "status": "complete",
"contract": "Streaming disconnects and server cancellations persist incomplete Responses snapshots when store=true",
- "contract_status": "draft",
+ "contract_status": "validated",
"slice_size": "small",
"execution_owner": "gateway",
"trust_class": [
@@ -3908,21 +3983,21 @@
"../hermes-agent/tests/gateway/test_api_server.py",
"docs/content/upstream-hermes/user-guide/features/api-server.md"
],
- "blocked_by": [
- "Responses API store + run event stream"
- ],
"ready_when": [
"Responses store and run event stream can persist terminal and non-terminal snapshots."
],
"not_ready_when": [
"Client disconnects lose response IDs or previous_response_id chains."
],
+ "blocked_by": [
+ "Responses API store + run event stream"
+ ],
"acceptance": [
"Connection reset during stream interrupts the agent and stores an incomplete response snapshot when store=true.",
"async cancellation stores the same incomplete snapshot before returning cancellation.",
"previous_response_id can continue from the incomplete snapshot without losing emitted text."
],
- "note": "Upstream Hermes now persists incomplete Responses snapshots on client disconnect and asyncio cancellation. Keep this as a separate resilience slice after the stateful Responses store exists.",
+ "note": "Complete: TDD landed Responses stream snapshot persistence for response.created/in_progress, incomplete snapshots on client disconnect or request cancellation, failed/completed status separation, previous_response_id continuation from partial assistant text, and KernelTurnLoop cancellation propagation.",
"write_scope": [
"internal/apiserver/",
"internal/kernel/",
@@ -3933,13 +4008,18 @@
],
"done_signal": [
"Disconnect/cancellation fixtures prove store=true responses retain incomplete snapshots and interrupt running turns."
- ]
+ ],
+ "health": {
+ "attempt_count": 1,
+ "last_attempt": "2026-04-25T07:03:39Z",
+ "last_success": "2026-04-25T07:03:39Z"
+ }
},
{
"name": "Gateway proxy mode forwarding contract",
- "status": "planned",
+ "status": "complete",
"contract": "Gateway adapters can forward turns to a remote OpenAI-compatible Gormes API server while preserving session IDs and safe history filtering",
- "contract_status": "draft",
+ "contract_status": "validated",
"slice_size": "small",
"execution_owner": "gateway",
"trust_class": [
@@ -3953,21 +4033,21 @@
"../hermes-agent/gateway/platforms/base.py",
"docs/content/upstream-hermes/user-guide/features/api-server.md"
],
- "blocked_by": [
- "OpenAI-compatible chat-completions API server"
- ],
"ready_when": [
"Native chat-completions API server accepts X-Hermes-Session-Id and streaming SSE fixtures."
],
"not_ready_when": [
"Proxy mode forwards tool-result messages with empty content or accepts stale run generations as current output."
],
+ "blocked_by": [
+ "OpenAI-compatible chat-completions API server"
+ ],
"acceptance": [
"GATEWAY_PROXY_URL and config proxy_url resolve with env precedence and trailing-slash normalization.",
"Forwarded requests preserve X-Hermes-Session-Id and filter unsafe empty/tool-only history entries.",
"Stale generation or remote errors return visible degraded output without starting a duplicate local run."
],
- "note": "Hermes proxy mode now lets thin gateway deployments forward to a remote API server. Gormes should port the behavior only after its native API surface exists, so proxy mode consumes the same session and stream contracts rather than becoming a second gateway stack.",
+ "note": "Complete: TDD landed internal/gateway/proxy_mode_test.go and internal/config proxy fixtures. ProxySubmitter reuses the OpenAI-compatible HTTP/SSE client, preserves X-Hermes-Session-Id, filters unsafe empty/tool-only history, invalidates stale generations, and reports proxy unreachable/missing-credential degradation through gateway runtime status.",
"write_scope": [
"internal/gateway/",
"internal/config/",
@@ -3978,7 +4058,12 @@
],
"done_signal": [
"Proxy-mode fixtures prove URL resolution, session header propagation, safe history filtering, stale-generation handling, and remote-error degradation."
- ]
+ ],
+ "health": {
+ "attempt_count": 2,
+ "last_attempt": "2026-04-25T06:55:51Z",
+ "last_success": "2026-04-25T06:55:51Z"
+ }
},
{
"name": "Dashboard API client contract",
@@ -3999,16 +4084,16 @@
"../hermes-agent/web/src/components/ModelPickerDialog.tsx",
"../hermes-agent/hermes_cli/web_server.py"
],
- "blocked_by": [
- "OpenAI-compatible chat-completions API server",
- "Responses API store + run event stream"
- ],
"ready_when": [
"Native API server exposes stable chat/Responses/session endpoints that dashboard contracts can call."
],
"not_ready_when": [
"The slice ports the upstream React app wholesale or adds Node/TypeScript to the Gormes runtime."
],
+ "blocked_by": [
+ "OpenAI-compatible chat-completions API server",
+ "Responses API store + run event stream"
+ ],
"acceptance": [
"Contract fixtures cover chat send/stream, session list/delete, model picker data, OAuth status, and tool-progress events.",
"Missing optional providers or plugins render disabled/degraded states.",
@@ -4044,16 +4129,16 @@
"../hermes-agent/tui_gateway/event_publisher.py",
"../hermes-agent/tui_gateway/ws.py"
],
- "blocked_by": [
- "PTY bridge protocol adapter",
- "SSE streaming to Bubble Tea TUI"
- ],
"ready_when": [
"PTY bridge behavior and TUI gateway event streaming are each fixture-locked."
],
"not_ready_when": [
"PTY bytes become the source of truth for sessions or tool events instead of a sidecar view."
],
+ "blocked_by": [
+ "PTY bridge protocol adapter",
+ "SSE streaming to Bubble Tea TUI"
+ ],
"acceptance": [
"PTY read/write/resize messages stay separate from structured tool/event publication.",
"Sidecar publish failures do not kill the PTY session.",
@@ -4150,15 +4235,15 @@
"docs/content/upstream-gbrain/gormes-takeaways.md",
"docs/content/building-gormes/architecture_plan/phase-6-learning-loop.md"
],
- "blocked_by": [
- "Phase 2.G skills runtime"
- ],
"ready_when": [
"Phase 2.G skills runtime is complete and the parser/store seam is stable enough for versioned metadata."
],
"not_ready_when": [
"Generated drafts are allowed into prompt injection without explicit review metadata."
],
+ "blocked_by": [
+ "Phase 2.G skills runtime"
+ ],
"unblocks": [
"LLM-assisted pattern distillation",
"Hybrid lexical + semantic lookup",
@@ -4314,8 +4399,8 @@
},
{
"name": "BlueBubbles iMessage bubble formatting parity",
- "status": "planned",
"priority": "P3",
+ "status": "planned",
"contract": "BlueBubbles outbound iMessage sends are non-editable, markdown-stripped, paragraph-split bubbles without pagination suffixes",
"contract_status": "fixture_ready",
"slice_size": "small",
diff --git a/docs/content/building-gormes/autoloop/agent-queue.md b/docs/content/building-gormes/autoloop/agent-queue.md
index 370a39b0f..7f00165e0 100644
--- a/docs/content/building-gormes/autoloop/agent-queue.md
+++ b/docs/content/building-gormes/autoloop/agent-queue.md
@@ -42,27 +42,7 @@ tests, and candidate policy. Keep those control-plane facts in
- Unblocks: Cross-provider reasoning-tag sanitization, OpenRouter, Codex stream repair + tool-call leak sanitizer
- Why now: Unblocks Cross-provider reasoning-tag sanitization, OpenRouter, Codex stream repair + tool-call leak sanitizer.
-## 2. Bedrock Converse payload mapping (no AWS SDK)
-
-- Phase: 4 / 4.A
-- Owner: `provider`
-- Size: `small`
-- Status: `planned`
-- Contract: Pure Bedrock Converse request mapping over the shared provider message/tool contract
-- Trust class: system
-- Ready when: Provider interface + stream fixture harness and tool-call continuation contract are complete.
-- Not ready when: The slice imports AWS SDK clients or signs live requests before pure request-body mapping is fixture-locked.
-- Degraded mode: Provider status reports Bedrock as unavailable until request mapping fixtures pass and credential wiring lands.
-- Fixture: `internal/hermes/bedrock_converse_mapping_test.go`
-- Write scope: `internal/hermes/`, `docs/content/building-gormes/architecture_plan/progress.json`
-- Test commands: `go test ./internal/hermes -count=1`
-- Done signal: Bedrock request-body golden fixtures prove Converse mapping without AWS credentials or SDK clients.
-- Acceptance: System, user, assistant, and tool-result messages map to Bedrock Converse roles and content blocks., Tool definitions map to Bedrock toolSpec inputSchema without dropping required fields., Golden request fixtures pin max_tokens, temperature, cache/reasoning passthrough, and empty-content placeholders.
-- Source refs: ../hermes-agent/agent/bedrock_adapter.py, ../hermes-agent/agent/transports/bedrock.py, ../hermes-agent/tests/agent/test_bedrock_adapter.py, docs/content/building-gormes/architecture_plan/phase-4-brain-transplant.md
-- Unblocks: Bedrock stream event decoding (SSE fixtures)
-- Why now: Unblocks Bedrock stream event decoding (SSE fixtures).
-
-## 3. Codex Responses pure conversion harness
+## 2. Codex Responses pure conversion harness
- Phase: 4 / 4.A
- Owner: `provider`
@@ -82,7 +62,7 @@ tests, and candidate policy. Keep those control-plane facts in
- Unblocks: Codex stream repair + tool-call leak sanitizer, Codex OAuth state + stale-token relogin
- Why now: Unblocks Codex stream repair + tool-call leak sanitizer, Codex OAuth state + stale-token relogin.
-## 4. Tool-call argument repair + schema sanitizer
+## 3. Tool-call argument repair + schema sanitizer
- Phase: 4 / 4.A
- Owner: `provider`
@@ -102,87 +82,7 @@ tests, and candidate policy. Keep those control-plane facts in
- Unblocks: Codex stream repair + tool-call leak sanitizer, OpenRouter, Bedrock stream event decoding (SSE fixtures)
- Why now: Unblocks Codex stream repair + tool-call leak sanitizer, OpenRouter, Bedrock stream event decoding (SSE fixtures).
-## 5. Provider-enforced context-length resolver
-
-- Phase: 4 / 4.D
-- Owner: `provider`
-- Size: `small`
-- Status: `planned`
-- Contract: Displayed and budgeted context windows prefer provider-enforced limits over raw models.dev metadata
-- Trust class: operator, system
-- Ready when: Provider status and model metadata can be tested as pure functions without live provider credentials.
-- Not ready when: The slice implements routing/fallback policy or pulls live models.dev/network data during unit tests.
-- Degraded mode: Model status reports whether the context length came from provider-specific caps, models.dev fallback, or an unknown model.
-- Fixture: `internal/hermes/model_context_resolver_test.go`
-- Write scope: `internal/hermes/`, `docs/content/building-gormes/architecture_plan/progress.json`
-- Test commands: `go test ./internal/hermes -count=1`
-- Done signal: Context resolver fixtures prove provider caps, models.dev fallback, and unknown-model reporting are deterministic without network calls.
-- Acceptance: openai-codex gpt-5.5 displays and budgets the provider cap (272000 tokens) instead of the raw models.dev 1050000-token window., Provider-specific caps for Codex OAuth, Copilot, and Nous win over model-info fallbacks when present., Unknown resolver failures fall back to fixture model metadata and report unknown when both sources are empty.
-- Source refs: upstream Hermes 05d8f110, ../hermes-agent/hermes_cli/model_switch.py, ../hermes-agent/cli.py, ../hermes-agent/gateway/run.py, ../hermes-agent/tests/hermes_cli/test_model_switch_context_display.py
-- Unblocks: Compression token-budget trigger + summary sizing, Routing policy and fallback selector
-- Why now: Unblocks Compression token-budget trigger + summary sizing, Routing policy and fallback selector.
-
-## 6. Skill preprocessing + dynamic slash commands
-
-- Phase: 5 / 5.F
-- Owner: `skills`
-- Size: `small`
-- Status: `planned`
-- Contract: Skill content preprocessing and skill-backed slash commands are deterministic, disabled-skill aware, and prompt-safe
-- Trust class: operator, gateway, system
-- Ready when: Phase 2.G parser/store and inactive candidate flow are complete.
-- Not ready when: Inline shell preprocessing can execute during prompt assembly or disabled skills remain invokable through slash commands.
-- Degraded mode: Skill status reports disabled, missing-prerequisite, or preprocessing-failed skills without injecting them into prompts.
-- Fixture: `internal/skills/preprocessing_commands_test.go`
-- Write scope: `internal/skills/`, `internal/gateway/`, `docs/content/building-gormes/architecture_plan/progress.json`
-- Test commands: `go test ./internal/skills ./internal/gateway -count=1`
-- Done signal: Skill preprocessing and slash-command fixtures prove disabled/incompatible skills do not enter prompt or command surfaces.
-- Acceptance: Template variable preprocessing is deterministic and fixture-covered., Inline shell preprocessing is disabled by default and bounded when explicitly enabled., Skill slash commands skip disabled/incompatible skills and build stable user-message content.
-- Source refs: ../hermes-agent/agent/skill_preprocessing.py, ../hermes-agent/agent/skill_commands.py, ../hermes-agent/tools/skills_tool.py, ../hermes-agent/tests/tools/test_skills_tool.py, ../hermes-agent/tests/agent/test_skill_commands.py
-- Unblocks: Toolset-aware skills prompt snapshot, TUI + Telegram browsing
-- Why now: Unblocks Toolset-aware skills prompt snapshot, TUI + Telegram browsing.
-
-## 7. PTY bridge protocol adapter
-
-- Phase: 5 / 5.O
-- Owner: `tools`
-- Size: `small`
-- Status: `planned`
-- Contract: Dashboard/TUI PTY sessions expose bounded read, write, resize, close, and unavailable-state behavior through a testable adapter
-- Trust class: operator
-- Ready when: Deterministic CLI helper ports are understood and PTY behavior can be isolated from the web dashboard transport.
-- Not ready when: The slice starts the web dashboard, opens network listeners, or binds to a real TUI process in unit tests.
-- Degraded mode: Dashboard or CLI status reports PTY unavailable instead of falling back to unsafe shell execution.
-- Fixture: `internal/cli/pty_bridge_test.go`
-- Write scope: `internal/cli/`, `docs/content/building-gormes/architecture_plan/progress.json`
-- Test commands: `go test ./internal/cli -count=1`
-- Done signal: PTY bridge fixtures prove read/write/resize/close/unavailable behavior without network or live dashboard dependencies.
-- Acceptance: Reads are bounded by timeout and chunk size., Writes and resize messages validate input before reaching the PTY., Unsupported platforms return PtyUnavailable-style errors without starting a shell.
-- Source refs: ../hermes-agent/hermes_cli/pty_bridge.py, ../hermes-agent/tests/hermes_cli/test_pty_bridge.py, ../hermes-agent/hermes_cli/web_server.py
-- Unblocks: SSE streaming to Bubble Tea TUI, Dashboard PTY chat sidecar contract
-- Why now: Unblocks SSE streaming to Bubble Tea TUI, Dashboard PTY chat sidecar contract.
-
-## 8. OpenAI-compatible chat-completions API server
-
-- Phase: 5 / 5.Q
-- Owner: `gateway`
-- Size: `medium`
-- Status: `planned`
-- Contract: OpenAI-compatible chat.completions HTTP surface over the native Gormes turn loop
-- Trust class: operator, gateway
-- Ready when: Native kernel turn loop, gateway session handles, and provider event streaming are stable enough for HTTP replay fixtures.
-- Not ready when: The server shells out to Python api_server or creates a second session store instead of using native Gormes state.
-- Degraded mode: API health and error envelopes report auth, body-size, content-normalization, and streaming failures without starting hidden sessions.
-- Fixture: `internal/apiserver/chat_completions_test.go`
-- Write scope: `internal/gateway/`, `internal/kernel/`, `internal/apiserver/`, `docs/content/building-gormes/architecture_plan/progress.json`
-- Test commands: `go test ./internal/gateway ./internal/kernel ./internal/apiserver -count=1`
-- Done signal: Chat-completions HTTP fixtures prove auth, body limits, content normalization, streaming envelopes, and session continuity over native Gormes state.
-- Acceptance: Bearer/API-key auth, request body limits, and OpenAI error envelopes are fixture-covered., Chat content parts normalize to the same user-message shape used by gateway sessions., Streaming and non-streaming responses include stable X-Hermes-Session-Id continuity.
-- Source refs: ../hermes-agent/gateway/platforms/api_server.py, ../hermes-agent/tests/gateway/test_api_server.py, docs/content/upstream-hermes/user-guide/features/api-server.md, docs/content/building-gormes/architecture_plan/phase-5-final-purge.md
-- Unblocks: Responses API store + run event stream, Gateway proxy mode forwarding contract, Dashboard API client contract
-- Why now: Unblocks Responses API store + run event stream, Gateway proxy mode forwarding contract, Dashboard API client contract.
-
-## 9. BlueBubbles iMessage bubble formatting parity
+## 4. BlueBubbles iMessage bubble formatting parity
- Phase: 7 / 7.E
- Owner: `gateway`
@@ -203,24 +103,4 @@ tests, and candidate policy. Keep those control-plane facts in
- Unblocks: BlueBubbles iMessage session-context prompt guidance
- Why now: Unblocks BlueBubbles iMessage session-context prompt guidance.
-## 10. Tool registry inventory + schema parity harness
-
-- Phase: 5 / 5.A
-- Owner: `tools`
-- Size: `medium`
-- Status: `planned`
-- Contract: Operation and tool descriptor parity before handler ports
-- Trust class: operator, gateway, child-agent, system
-- Ready when: Upstream tool descriptor inventory can be captured without porting handlers in the same slice.
-- Not ready when: Handler implementation starts before descriptor parity fixtures exist.
-- Degraded mode: Doctor reports disabled tools, missing dependencies, schema drift, and unavailable provider-specific paths.
-- Fixture: `internal/tools upstream schema parity manifest fixtures`
-- Write scope: `internal/tools/`, `docs/content/building-gormes/architecture_plan/progress.json`
-- Test commands: `go test ./internal/tools -count=1`
-- Done signal: Tool descriptor parity fixtures capture names, schemas, trust classes, dependencies, and degraded status before handler ports.
-- Acceptance: Upstream tool names, toolsets, required env vars, schemas, result envelopes, trust classes, and degraded status are captured in fixtures., No handler port can mark complete until its descriptor parity row exists., Doctor can report missing dependencies or disabled provider-specific paths.
-- Source refs: docs/content/upstream-hermes/reference/tools-reference.md, docs/content/building-gormes/architecture_plan/phase-5-final-purge.md
-- Unblocks: Pure core tools first, Stateful tool migration queue, CLI command registry parity + active-turn busy policy
-- Why now: Unblocks Pure core tools first, Stateful tool migration queue, CLI command registry parity + active-turn busy policy.
-
diff --git a/docs/content/building-gormes/autoloop/autoloop-handoff.md b/docs/content/building-gormes/autoloop/autoloop-handoff.md
index 3f61fb434..665c89787 100644
--- a/docs/content/building-gormes/autoloop/autoloop-handoff.md
+++ b/docs/content/building-gormes/autoloop/autoloop-handoff.md
@@ -39,6 +39,8 @@ at a time. Do not maintain a parallel queue outside this docs tree.
- MAX_AGENTS is a safety cap: if fewer metadata-ready rows are available, run fewer workers instead of selecting filler or random work.
- Each worker runs in an isolated git worktree under RUN_ROOT/worktrees and promotion rejects committed paths outside the selected row's write_scope.
- When git worktrees are available and MAX_AGENTS is greater than 1, cmd/autoloop launches selected workers concurrently, then validates and promotes each branch through the same ledgered safety gates.
+- After all promotions, cmd/autoloop runs the mandatory post-promotion full-suite gate before emitting run_completed or health_updated.
+- On post-promotion gate failure, cmd/autoloop starts one backend repair attempt by default, requires the checkout to be clean, reruns the suite, and records final health only if the gate passes.
- Prefer contract rows with write_scope, test_commands, and done_signal.
- Inject selected progress metadata into the worker prompt instead of asking workers to rescan the whole roadmap.
diff --git a/docs/content/building-gormes/autoloop/blocked-slices.md b/docs/content/building-gormes/autoloop/blocked-slices.md
index 9497cea96..b38c6b3f3 100644
--- a/docs/content/building-gormes/autoloop/blocked-slices.md
+++ b/docs/content/building-gormes/autoloop/blocked-slices.md
@@ -18,17 +18,12 @@ Use it to avoid assigning work before the dependency chain is ready.
| 2 / 2.B.5 | BlueBubbles iMessage session-context prompt guidance | BlueBubbles iMessage bubble formatting parity | BlueBubbles outbound formatting splits blank-line paragraphs into separate iMessage sends, so prompt guidance has a matching delivery contract. | - |
| 2 / 2.F.3 | Unauthorized DM pairing response contract | Pairing approval + rate-limit semantics | Pairing approval, rate limiting, and allowlist checks are fixture-locked. | - |
| 2 / 2.F.5 | Steer slash command registry + queue fallback | 2.E.2 | 2.E.2 is complete and the shared CommandDef registry is stable for gateway commands. | Mid-run steer injection between tool calls, Gateway-handled slash commands bypass active-session guard |
-| 3 / 3.F | Goncho queue status read model | Directional peer cards and representation scopes | At least one Goncho-owned task type or a zero-state read model is available to report deterministically. | - |
-| 3 / 3.F | Goncho file upload import ingestion | Goncho queue status read model | Goncho has a deterministic session-message write path or a documented queue-unavailable degraded path for imported messages. | - |
-| 3 / 3.F | Goncho streaming chat persistence contract | Goncho dialectic chat contract | honcho_chat or equivalent dialectic chat params and response shape are fixture-locked. | Internal agent chat transport, Hugo docs examples for streaming memory behavior |
| 4 / 4.A | Bedrock stale-client eviction + retry classification | Bedrock SigV4 + credential seam | A Bedrock client/cache seam exists behind the provider adapter and can be exercised without live AWS credentials. | - |
| 4 / 4.A | Codex OAuth state + stale-token relogin | Token vault, Multi-account auth, Codex Responses pure conversion harness | Gormes has an XDG-scoped token vault and account-selection seam for provider credentials. | - |
| 4 / 4.A | Codex stream repair + tool-call leak sanitizer | Codex Responses pure conversion harness | Codex Responses conversion fixtures can replay streamed and non-streamed output without live credentials. | - |
-| 4 / 4.B | ContextEngine interface + status tool contract | Provider interface + stream fixture harness | Provider interface + stream fixture harness can replay context status without live provider calls. | Compression token-budget trigger + summary sizing, Tool-result pruning + protected head/tail summary |
| 4 / 4.D | Model pricing/capability registry fixtures | Provider-enforced context-length resolver | Provider-enforced context resolver fixtures establish the metadata package shape and fallback semantics. | Routing policy and fallback selector |
| 4 / 4.D | Routing policy and fallback selector | Provider-enforced context-length resolver, Model pricing/capability registry fixtures | Context limits, pricing, capabilities, and provider-family metadata are fixture-backed. | - |
| 4 / 4.G | Anthropic OAuth/keychain credential discovery | Token vault | Token vault owns XDG-scoped credential files and can expose provider auth status without live credentials. | - |
-| 4 / 4.H | Provider-side resilience | Provider interface + stream fixture harness | Provider interface + stream fixture harness is available for resilience fixture coverage. | Retry-After header parsing + HTTPError hint, Kernel retry honors Retry-After hint, Provider rate guard + budget telemetry |
| 5 / 5.I | First-party Spotify plugin fixture | Plugin SDK | Plugin manifest loading and capability registration are fixture-locked by the Plugin SDK slice. | - |
| 5 / 5.O | Busy command guard for compression and long CLI actions | CLI command registry parity + active-turn busy policy | The CLI command registry has a shared active-turn/busy policy surface. | - |
| 5 / 5.Q | Responses API store + run event stream | OpenAI-compatible chat-completions API server | Chat-completions HTTP surface is native and response storage can reuse its auth, session, and error-envelope contracts. | API server disconnect snapshot persistence, Dashboard API client contract |
diff --git a/docs/content/building-gormes/autoloop/next-slices.md b/docs/content/building-gormes/autoloop/next-slices.md
index 1a81b74d3..316cddd2f 100644
--- a/docs/content/building-gormes/autoloop/next-slices.md
+++ b/docs/content/building-gormes/autoloop/next-slices.md
@@ -25,13 +25,7 @@ the row in `progress.json` before assigning it.
| Phase | Slice | Contract | Trust class | Fixture | Why now |
|---|---|---|---|---|---|
| 4 / 4.A | DeepSeek/Kimi reasoning_content echo for tool-call replay | Thinking-mode providers that require reasoning_content on assistant tool-call turns receive an echoed value during persistence and API replay | system | `internal/hermes/reasoning_content_echo_test.go` | Unblocks Cross-provider reasoning-tag sanitization, OpenRouter, Codex stream repair + tool-call leak sanitizer. |
-| 4 / 4.A | Bedrock Converse payload mapping (no AWS SDK) | Pure Bedrock Converse request mapping over the shared provider message/tool contract | system | `internal/hermes/bedrock_converse_mapping_test.go` | Unblocks Bedrock stream event decoding (SSE fixtures). |
| 4 / 4.A | Codex Responses pure conversion harness | OpenAI Responses request/response conversion for Codex-compatible providers without live OAuth | system | `internal/hermes/codex_responses_adapter_test.go` | Unblocks Codex stream repair + tool-call leak sanitizer, Codex OAuth state + stale-token relogin. |
| 4 / 4.A | Tool-call argument repair + schema sanitizer | Provider tool-call arguments are repaired or rejected against available tool schemas before execution | system, child-agent | `internal/hermes/tool_call_argument_repair_test.go` | Unblocks Codex stream repair + tool-call leak sanitizer, OpenRouter, Bedrock stream event decoding (SSE fixtures). |
-| 4 / 4.D | Provider-enforced context-length resolver | Displayed and budgeted context windows prefer provider-enforced limits over raw models.dev metadata | operator, system | `internal/hermes/model_context_resolver_test.go` | Unblocks Compression token-budget trigger + summary sizing, Routing policy and fallback selector. |
-| 5 / 5.F | Skill preprocessing + dynamic slash commands | Skill content preprocessing and skill-backed slash commands are deterministic, disabled-skill aware, and prompt-safe | operator, gateway, system | `internal/skills/preprocessing_commands_test.go` | Unblocks Toolset-aware skills prompt snapshot, TUI + Telegram browsing. |
-| 5 / 5.O | PTY bridge protocol adapter | Dashboard/TUI PTY sessions expose bounded read, write, resize, close, and unavailable-state behavior through a testable adapter | operator | `internal/cli/pty_bridge_test.go` | Unblocks SSE streaming to Bubble Tea TUI, Dashboard PTY chat sidecar contract. |
-| 5 / 5.Q | OpenAI-compatible chat-completions API server | OpenAI-compatible chat.completions HTTP surface over the native Gormes turn loop | operator, gateway | `internal/apiserver/chat_completions_test.go` | Unblocks Responses API store + run event stream, Gateway proxy mode forwarding contract, Dashboard API client contract. |
| 7 / 7.E | BlueBubbles iMessage bubble formatting parity | BlueBubbles outbound iMessage sends are non-editable, markdown-stripped, paragraph-split bubbles without pagination suffixes | gateway, system | `internal/channels/bluebubbles/bot_test.go` | Unblocks BlueBubbles iMessage session-context prompt guidance. |
-| 5 / 5.A | Tool registry inventory + schema parity harness | Operation and tool descriptor parity before handler ports | operator, gateway, child-agent, system | `internal/tools upstream schema parity manifest fixtures` | Unblocks Pure core tools first, Stateful tool migration queue, CLI command registry parity + active-turn busy policy. |
diff --git a/docs/content/building-gormes/contract-readiness.md b/docs/content/building-gormes/contract-readiness.md
index 1e3930c4b..52da3c7a3 100644
--- a/docs/content/building-gormes/contract-readiness.md
+++ b/docs/content/building-gormes/contract-readiness.md
@@ -49,36 +49,36 @@ operator-visible, and a local fixture proves compatibility.
| 3 / 3.F | Goncho context representation options โ honcho_context exposes the Honcho v3 session.context representation controls while preserving current same-chat defaults | `validated` | `memory` | `small` | operator, system | `internal/goncho/context_options_test.go` | Unsupported representation options return structured unavailable evidence instead of being silently ignored. |
| 3 / 3.F | Goncho search filter grammar โ Goncho search accepts a typed subset of Honcho v3 filters and rejects unsupported filter operators visibly | `validated` | `memory` | `medium` | operator, system | `internal/goncho/filter_grammar_test.go` | Unknown filters, unsupported operators, or metadata paths return a structured unsupported-filter error instead of widening search. |
| 3 / 3.F | Directional peer cards and representation scopes โ Peer cards and stored representations are keyed by workspace, observer, and observed peer instead of a flat workspace/peer pair | `validated` | `memory` | `medium` | operator, system | `internal/goncho/directional_peer_card_test.go` | When directional representation is unavailable, the service reports that only the default gormes observer view was used. |
-| 3 / 3.F | Goncho queue status read model โ Gormes exposes Honcho-style representation, summary, and dream work-unit queue status as observability, not synchronization | `draft` | `memory` | `small` | operator, system | `internal/goncho/queue_status_test.go` | If no Goncho task queue exists yet, memory status reports zero tracked Goncho work units plus the existing extractor queue status. |
+| 3 / 3.F | Goncho queue status read model โ Gormes exposes Honcho-style representation, summary, and dream work-unit queue status as observability, not synchronization | `validated` | `memory` | `small` | operator, system | `internal/goncho/queue_status_test.go` | If no Goncho task queue exists yet, memory status reports zero tracked Goncho work units plus the existing extractor queue status. |
| 3 / 3.F | Goncho summary context budget โ Session summaries use Honcho's short/long cadence and 40/60 context budget without double-billing last-N-turn recall | `validated` | `memory` | `medium` | operator, system | `internal/goncho/summary_context_test.go` | When summaries are unavailable or too large for the token budget, context returns recent messages plus explicit summary_absent evidence. |
| 3 / 3.F | Goncho dialectic chat contract โ Goncho exposes a Honcho peer.chat-compatible request and response contract while keeping query-specific reasoning separate from prompt-time context assembly | `validated` | `memory` | `small` | operator, system | `internal/goncho/chat_contract_test.go` | Until the real dialectic tool loop and streaming transport land, honcho_chat returns deterministic content plus explicit unsupported evidence for stream=true and target-specific reasoning gaps. |
-| 3 / 3.F | Goncho file upload import ingestion โ Goncho can import text-like memory files as session messages without persisting original file bytes, matching Honcho's file-upload migration model | `draft` | `memory` | `medium` | operator, system | `internal/goncho/file_import_test.go` | Until PDF extraction and a Goncho queue exist, unsupported content types fail before writes and imported messages report queue-unavailable evidence. |
+| 3 / 3.F | Goncho file upload import ingestion โ Goncho can import text-like memory files as session messages without persisting original file bytes, matching Honcho's file-upload migration model | `validated` | `memory` | `medium` | operator, system | `internal/goncho/file_import_test.go` | Until PDF extraction and a Goncho queue exist, unsupported content types fail before writes and imported messages report queue-unavailable evidence. |
| 3 / 3.F | Goncho topology design fixtures โ Goncho workspace, peer, session, and observation defaults are fixture-locked before more memory behavior is added | `validated` | `memory` | `small` | operator, system | `internal/goncho/topology_design_test.go` | Unknown external participant identity falls back to a deterministic source-prefixed peer ID and records the fallback in evidence. |
| 3 / 3.F | Goncho operator diagnostics contract โ Gormes exposes a Honcho-inspired Goncho doctor path that checks memory topology, queues, config, and degraded modes without requiring operators to inspect raw tables | `validated` | `memory` | `medium` | operator, system | `cmd/gormes/goncho_doctor_test.go` | Missing optional model/provider features are reported as degraded capability rows, not startup failures, unless a requested command needs them. |
-| 3 / 3.F | Goncho streaming chat persistence contract โ Goncho streaming chat stores the final assistant response once and never turns partial stream chunks into memory | `draft` | `memory` | `small` | operator, system | `internal/goncho/streaming_chat_persistence_test.go` | Until streaming transport exists, stream=true returns explicit unsupported evidence while non-streaming chat keeps the Honcho-compatible response contract. |
+| 3 / 3.F | Goncho streaming chat persistence contract โ Goncho streaming chat stores the final assistant response once and never turns partial stream chunks into memory | `validated` | `memory` | `small` | operator, system | `internal/goncho/streaming_chat_persistence_test.go` | Until streaming transport exists, stream=true returns explicit unsupported evidence while non-streaming chat keeps the Honcho-compatible response contract. |
| 3 / 3.F | Goncho configuration namespace โ Gormes owns a Go-native [goncho] configuration namespace that maps Honcho runtime limits and feature gates into existing config loading | `validated` | `memory` | `small` | operator, system | `internal/config/goncho_config_test.go` | Unset Goncho config uses documented defaults and reports feature-disabled evidence instead of requiring Honcho-style Python service variables. |
| 4 / 4.A | Provider interface + stream fixture harness โ Provider-neutral request and stream event transcript harness | `validated` | `provider` | `medium` | system | `internal/hermes provider transcript fixtures` | Provider status reports missing fixture coverage or unavailable adapters before kernel routing can select them. |
| 4 / 4.A | Tool-call normalization + continuation contract โ Cross-provider tool-call continuation contract | `validated` | `provider` | `medium` | system | `internal/hermes cross-provider tool continuation fixtures` | Provider status reports transcript or continuation fixture gaps before adapters can be selected for tool-capable turns. |
| 4 / 4.A | DeepSeek/Kimi reasoning_content echo for tool-call replay โ Thinking-mode providers that require reasoning_content on assistant tool-call turns receive an echoed value during persistence and API replay | `fixture_ready` | `provider` | `small` | system | `internal/hermes/reasoning_content_echo_test.go` | Provider status explains when a thinking-mode provider requires reasoning echo padding and when a stored transcript was repaired for replay. |
-| 4 / 4.A | Bedrock Converse payload mapping (no AWS SDK) โ Pure Bedrock Converse request mapping over the shared provider message/tool contract | `fixture_ready` | `provider` | `small` | system | `internal/hermes/bedrock_converse_mapping_test.go` | Provider status reports Bedrock as unavailable until request mapping fixtures pass and credential wiring lands. |
+| 4 / 4.A | Bedrock Converse payload mapping (no AWS SDK) โ Pure Bedrock Converse request mapping over the shared provider message/tool contract | `validated` | `provider` | `small` | system | `internal/hermes/bedrock_converse_mapping_test.go` | Provider status reports Bedrock as unavailable until request mapping fixtures pass and credential wiring lands. |
| 4 / 4.A | Bedrock stale-client eviction + retry classification โ Bedrock runtime clients evict stale transport state without hiding request or validation failures | `draft` | `provider` | `small` | system | `internal/hermes/bedrock_stale_client_test.go` | Provider logs and status distinguish stale transport recovery from non-retryable Bedrock request failures. |
| 4 / 4.A | Codex Responses pure conversion harness โ OpenAI Responses request/response conversion for Codex-compatible providers without live OAuth | `fixture_ready` | `provider` | `small` | system | `internal/hermes/codex_responses_adapter_test.go` | Provider status reports Codex unavailable until Responses conversion fixtures pass and auth wiring is configured. |
| 4 / 4.A | Codex OAuth state + stale-token relogin โ Codex OAuth state is Gormes-owned and stale refresh failures force explicit relogin | `draft` | `provider` | `small` | operator, system | `internal/hermes/codex_oauth_state_test.go` | Auth status explains missing, stale, imported, or relogin-required Codex credentials without touching ~/.codex. |
| 4 / 4.A | Codex stream repair + tool-call leak sanitizer โ Codex Responses streams repair empty output and reject leaked function-call text before parent history is updated | `draft` | `provider` | `small` | system | `internal/hermes/codex_stream_repair_test.go` | Provider logs explain repaired empty output, leaked tool-call text, and unsupported Codex stream items. |
| 4 / 4.A | Tool-call argument repair + schema sanitizer โ Provider tool-call arguments are repaired or rejected against available tool schemas before execution | `fixture_ready` | `provider` | `small` | system, child-agent | `internal/hermes/tool_call_argument_repair_test.go` | Tool execution status reports schema-repair failures before a malformed provider call reaches the executor. |
-| 4 / 4.B | ContextEngine interface + status tool contract โ Stable context engine status and compression boundary | `draft` | `provider` | `medium` | operator, system | `internal/contextengine status and compression replay fixtures` | Context status reports disabled compression, cooldowns, unknown tools, token-budget pressure, and replay gaps. |
-| 4 / 4.D | Provider-enforced context-length resolver โ Displayed and budgeted context windows prefer provider-enforced limits over raw models.dev metadata | `fixture_ready` | `provider` | `small` | operator, system | `internal/hermes/model_context_resolver_test.go` | Model status reports whether the context length came from provider-specific caps, models.dev fallback, or an unknown model. |
+| 4 / 4.B | ContextEngine interface + status tool contract โ Stable context engine status and compression boundary | `validated` | `provider` | `medium` | operator, system | `internal/hermes/testdata/context_status and internal/kernel context-engine replay fixtures` | Context status reports disabled compression, cooldowns, unknown tools, token-budget pressure, and replay gaps. |
+| 4 / 4.D | Provider-enforced context-length resolver โ Displayed and budgeted context windows prefer provider-enforced limits over raw models.dev metadata | `validated` | `provider` | `small` | operator, system | `internal/hermes/model_context_resolver_test.go` | Model status reports whether the context length came from provider-specific caps, models.dev fallback, or an unknown model. |
| 4 / 4.D | Model pricing/capability registry fixtures โ Read-only model metadata exposes deterministic pricing, capability flags, provider family, and raw context facts before routing consumes them | `draft` | `provider` | `small` | operator, system | `internal/hermes/model_registry_test.go` | Model status distinguishes unknown pricing, unknown capability, and stale embedded registry data instead of inventing defaults. |
| 4 / 4.D | Routing policy and fallback selector โ Smart model routing is a pure selector over explicit overrides, provider availability, fallback policy, and model metadata before any provider call switches models | `draft` | `provider` | `small` | operator, system | `internal/hermes/model_routing_test.go` | Routing status reports metadata gaps, unavailable providers, and disabled fallback routes before changing a turn's model. |
| 4 / 4.G | Anthropic OAuth/keychain credential discovery โ Anthropic credential discovery prefers OS keychain when present and preserves corrupt local auth state for operator recovery | `draft` | `provider` | `small` | operator, system | `internal/hermes/anthropic_auth_state_test.go` | Auth status reports keychain unavailable, corrupt auth backup, or relogin-required without deleting credentials. |
-| 4 / 4.H | Provider-side resilience โ Provider resilience umbrella over retry, cache, rate, and budget behavior | `draft` | `provider` | `large` | system | `internal/hermes and internal/kernel provider resilience fixtures` | Provider and kernel status expose retry schedule, Retry-After hints, cache disabled paths, rate guards, and budget telemetry gaps. |
+| 4 / 4.H | Provider-side resilience โ Provider resilience umbrella over retry, cache, rate, and budget behavior | `validated` | `provider` | `large` | system | `internal/hermes and internal/kernel provider resilience fixtures` | Provider and kernel status expose retry schedule, Retry-After hints, cache disabled paths, rate guards, and budget telemetry gaps. |
| 4 / 4.H | Classified provider-error taxonomy โ Structured provider error classification contract | `validated` | `provider` | `small` | system | `internal/hermes provider error-classification fixture table` | Provider status and logs expose auth, rate-limit, context, retryable, and non-retryable classes instead of raw opaque errors. |
-| 5 / 5.A | Tool registry inventory + schema parity harness โ Operation and tool descriptor parity before handler ports | `draft` | `tools` | `medium` | operator, gateway, child-agent, system | `internal/tools upstream schema parity manifest fixtures` | Doctor reports disabled tools, missing dependencies, schema drift, and unavailable provider-specific paths. |
-| 5 / 5.F | Skill preprocessing + dynamic slash commands โ Skill content preprocessing and skill-backed slash commands are deterministic, disabled-skill aware, and prompt-safe | `fixture_ready` | `skills` | `small` | operator, gateway, system | `internal/skills/preprocessing_commands_test.go` | Skill status reports disabled, missing-prerequisite, or preprocessing-failed skills without injecting them into prompts. |
+| 5 / 5.A | Tool registry inventory + schema parity harness โ Operation and tool descriptor parity before handler ports | `validated` | `tools` | `medium` | operator, gateway, child-agent, system | `internal/tools upstream schema parity manifest fixtures` | Doctor reports disabled tools, missing dependencies, schema drift, and unavailable provider-specific paths. |
+| 5 / 5.F | Skill preprocessing + dynamic slash commands โ Skill content preprocessing and skill-backed slash commands are deterministic, disabled-skill aware, and prompt-safe | `validated` | `skills` | `small` | operator, gateway, system | `internal/skills/preprocessing_commands_test.go` | Skill status reports disabled, missing-prerequisite, or preprocessing-failed skills without injecting them into prompts. |
| 5 / 5.I | First-party Spotify plugin fixture โ First-party plugin manifests and tool packages load through the plugin SDK without reverting to built-in tool registration | `draft` | `tools` | `small` | operator, system | `internal/plugins/spotify_plugin_test.go` | Plugin status reports missing environment or auth setup without registering broken prompt-visible tools. |
-| 5 / 5.O | PTY bridge protocol adapter โ Dashboard/TUI PTY sessions expose bounded read, write, resize, close, and unavailable-state behavior through a testable adapter | `fixture_ready` | `tools` | `small` | operator | `internal/cli/pty_bridge_test.go` | Dashboard or CLI status reports PTY unavailable instead of falling back to unsafe shell execution. |
+| 5 / 5.O | PTY bridge protocol adapter โ Dashboard/TUI PTY sessions expose bounded read, write, resize, close, and unavailable-state behavior through a testable adapter | `validated` | `tools` | `small` | operator | `internal/cli/pty_bridge_test.go` | Dashboard or CLI status reports PTY unavailable instead of falling back to unsafe shell execution. |
| 5 / 5.O | Busy command guard for compression and long CLI actions โ Long-running CLI commands set busy input state and reject overlapping user input until the command exits | `draft` | `tools` | `small` | operator | `internal/cli/busy_command_test.go` | CLI/TUI status reports command-busy state instead of accepting overlapping input that can corrupt turn state. |
-| 5 / 5.Q | OpenAI-compatible chat-completions API server โ OpenAI-compatible chat.completions HTTP surface over the native Gormes turn loop | `fixture_ready` | `gateway` | `medium` | operator, gateway | `internal/apiserver/chat_completions_test.go` | API health and error envelopes report auth, body-size, content-normalization, and streaming failures without starting hidden sessions. |
+| 5 / 5.Q | OpenAI-compatible chat-completions API server โ OpenAI-compatible chat.completions HTTP surface over the native Gormes turn loop | `validated` | `gateway` | `medium` | operator, gateway | `internal/apiserver/chat_completions_test.go` | API health and error envelopes report auth, body-size, content-normalization, and streaming failures without starting hidden sessions. |
| 5 / 5.Q | Responses API store + run event stream โ Stateful OpenAI Responses and runs APIs over the same native session chain as chat completions | `draft` | `gateway` | `medium` | operator, gateway | `internal/apiserver/responses_runs_test.go` | API status reports response-store disabled, LRU eviction, orphaned runs, and previous_response_id misses. |
| 5 / 5.Q | API server disconnect snapshot persistence โ Streaming disconnects and server cancellations persist incomplete Responses snapshots when store=true | `draft` | `gateway` | `small` | operator, gateway | `internal/apiserver/disconnect_snapshot_test.go` | Stored response status distinguishes incomplete disconnect snapshots from failed or completed responses. |
| 5 / 5.Q | Gateway proxy mode forwarding contract โ Gateway adapters can forward turns to a remote OpenAI-compatible Gormes API server while preserving session IDs and safe history filtering | `draft` | `gateway` | `small` | gateway, operator | `internal/gateway/proxy_mode_test.go` | Gateway status reports proxy unreachable, stale generation, or missing proxy credentials without dropping local audit records. |
diff --git a/docs/phase5_docs_test.go b/docs/phase5_docs_test.go
index 250ca4d73..528da5ac8 100644
--- a/docs/phase5_docs_test.go
+++ b/docs/phase5_docs_test.go
@@ -36,7 +36,6 @@ func TestPhase5DocsTrackExecuteCodeCloseout(t *testing.T) {
}
for _, want := range []string{
`"5": {`,
- `"status": "in_progress"`,
`"5.K": {`,
`"status": "complete"`,
} {
diff --git a/docs/superpowers/plans/2026-04-25-planner-self-healing.md b/docs/superpowers/plans/2026-04-25-planner-self-healing.md
new file mode 100644
index 000000000..7155f8233
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-25-planner-self-healing.md
@@ -0,0 +1,3393 @@
+# Planner Self-Healing Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Close the autoloop โ planner feedback loop so the planner reacts to autoloop quarantine events within minutes, retries on validation rejection, audits its own effectiveness, and escalates intractable rows for human review โ with a per-run ledger and topical focus mode for operator-driven runs.
+
+**Architecture:** Six layers added to `internal/architectureplanner/`, with small extensions to `internal/autoloop/` and one typed-field addition to `internal/progress/`. A new `Item.PlannerVerdict` block (planner-owned, autoloop-preserved) mirrors Phase B's `Item.Health` (autoloop-owned, planner-preserved) โ symmetric ownership, structural preservation via Phase B's typed-struct round-trip. Event delivery is file-based (`triggers.jsonl` + cursor) consumed via systemd path unit; no daemons.
+
+**Tech Stack:** Go 1.25+, append-only JSONL ledgers (matches Phase B autoloop pattern), atomic temp+rename writes, systemd path units, `crypto/sha256` (already in `internal/progress` from Phase B).
+
+**Reference spec:** `docs/superpowers/specs/2026-04-24-planner-self-healing-design.md`
+
+**Baseline commit (spec):** `c8d78421`
+
+---
+
+## File Structure
+
+**New files:**
+
+```text
+internal/progress/preservation_test.go
+internal/architectureplanner/ledger.go
+internal/architectureplanner/ledger_test.go
+internal/architectureplanner/triggers.go
+internal/architectureplanner/triggers_test.go
+internal/architectureplanner/triggers_concurrent_test.go
+internal/architectureplanner/retry.go
+internal/architectureplanner/retry_test.go
+internal/architectureplanner/evaluation.go
+internal/architectureplanner/evaluation_test.go
+internal/architectureplanner/verdict.go
+internal/architectureplanner/verdict_test.go
+internal/architectureplanner/topics.go
+internal/architectureplanner/topics_test.go
+internal/architectureplanner/lifecycle_test.go
+internal/architectureplanner/status_test.go
+```
+
+**Modified files:**
+
+```text
+internal/progress/progress.go
+internal/progress/health_compat_test.go
+internal/architectureplanner/run.go
+internal/architectureplanner/prompt.go
+internal/architectureplanner/context.go
+internal/architectureplanner/config.go
+internal/architectureplanner/service.go
+internal/architectureplanner/config_test.go
+internal/autoloop/health_writer.go
+internal/autoloop/run.go
+internal/autoloop/candidates.go
+internal/autoloop/config.go
+cmd/architecture-planner-loop/main.go
+```
+
+**Responsibility map:**
+
+- `internal/progress/progress.go`: extend `Item` with `PlannerVerdict *PlannerVerdict` field as the LAST field (after `Health` from Phase B); add `PlannerVerdict` typed struct.
+- `internal/progress/preservation_test.go`: cross-cutting symmetric-preservation regression test.
+- `internal/architectureplanner/ledger.go`: per-run ledger types + `AppendLedgerEvent` / `LoadLedger` / `LoadLedgerWindow`.
+- `internal/architectureplanner/triggers.go`: `TriggerEvent` type, cursor type, `AppendTriggerEvent` / `ReadTriggersSinceCursor` / `LoadCursor` / `SaveCursor`.
+- `internal/architectureplanner/retry.go`: `RetryFeedback` formatter + `retryAttempt` type.
+- `internal/architectureplanner/evaluation.go`: `Evaluate` correlates planner ledger โ autoloop ledger, returns `[]ReshapeOutcome`.
+- `internal/architectureplanner/verdict.go`: `StampVerdicts` deterministic post-processing pass.
+- `internal/architectureplanner/topics.go`: `MatchKeywords` + `FilterContextByKeywords`.
+- `internal/architectureplanner/run.go`: orchestrate L1+L2+L3+L4+L5 inside `RunOnce`.
+- `internal/architectureplanner/prompt.go`: render `PreviousReshapes`, trigger-events bullets, topical clause; add `SELF-EVALUATION (SOFT RULE)` clause.
+- `internal/architectureplanner/context.go`: extend `ContextBundle` with `PreviousReshapes`, `TriggerEvents`, `Keywords`.
+- `internal/architectureplanner/config.go`: add `MaxRetries`, `EvaluationWindow`, `EscalationThreshold`, `IncludeNeedsHuman`, `PlannerTriggersPath`, `TriggersCursorPath`, `AutoloopRunRoot` fields.
+- `internal/architectureplanner/service.go`: render+install `gormes-architecture-planner.path` unit alongside the existing `.timer` and `.service`.
+- `internal/autoloop/health_writer.go`: `classifyForTrigger` helper; `Flush` returns triggered events alongside error.
+- `internal/autoloop/run.go`: emit triggered events via `AppendTriggerEvent` after Flush succeeds.
+- `internal/autoloop/candidates.go`: skip `PlannerVerdict.NeedsHuman` rows; add `Candidate.NeedsHumanFlag`; surface in `SelectionReason()`.
+- `internal/autoloop/config.go`: add `IncludeNeedsHuman`, `PlannerTriggersPath` env-driven fields.
+- `cmd/architecture-planner-loop/main.go`: parse positional keyword args after `run`; extend `status` to render outcomes + NeedsHuman rows + `Keywords:` line.
+
+---
+
+## Conventions Used In Every Task
+
+- Each task is one TDD cycle ending in one commit.
+- Always run failing test first; never write implementation before a red test.
+- Run `go vet ./...` and `gofmt -l .` before each commit; both must be clean for the touched packages.
+- Run focused-then-wider test suite before each commit:
+ ```
+ go test ./internal/progress/... ./internal/autoloop/... ./internal/architectureplanner/... ./cmd/architecture-planner-loop/...
+ ```
+- Commit message format:
+ - `feat(progress): ...`
+ - `feat(planner): ...`
+ - `feat(autoloop): ...`
+ - `test(planner): ...`
+ - `test(progress): ...`
+- Never modify `Item` field ordering. New `PlannerVerdict` field MUST be appended after `Health` (the Phase B field), preserving Phase B's "Health is last" โ "PlannerVerdict is last" discipline.
+- All new env vars default to current behavior (back-compat). Tests verify defaults.
+- All file IO that writes `progress.json` continues to go through `internal/progress.SaveProgress` (Phase B). Phase C does not introduce any new path that bypasses it.
+
+---
+
+## Task 1: Add `PlannerVerdict` Schema To `internal/progress`
+
+**Files:**
+- Modify: `internal/progress/progress.go`
+
+- [ ] **Step 1.1: Write the failing test for the schema and round-trip**
+
+Append to `internal/progress/health_test.go` (existing Phase B test file):
+
+```go
+func TestPlannerVerdict_RoundTrip(t *testing.T) {
+ verdict := &PlannerVerdict{
+ NeedsHuman: true,
+ Reason: "auto: 3 reshapes without unsticking; last category report_validation_failed",
+ Since: "2026-04-24T12:00:00Z",
+ ReshapeCount: 3,
+ LastReshape: "2026-04-24T11:00:00Z",
+ LastOutcome: "still_failing",
+ }
+
+ data, err := json.Marshal(verdict)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+
+ var got PlannerVerdict
+ if err := json.Unmarshal(data, &got); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if !got.NeedsHuman {
+ t.Fatal("NeedsHuman should round-trip true")
+ }
+ if got.ReshapeCount != 3 {
+ t.Fatalf("ReshapeCount = %d, want 3", got.ReshapeCount)
+ }
+ if got.LastOutcome != "still_failing" {
+ t.Fatalf("LastOutcome = %q, want still_failing", got.LastOutcome)
+ }
+}
+
+func TestPlannerVerdict_OmitemptyKeepsZeroFieldsOut(t *testing.T) {
+ v := &PlannerVerdict{}
+ data, err := json.Marshal(v)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ if string(data) != "{}" {
+ t.Fatalf("zero-value PlannerVerdict should marshal to {}, got %s", data)
+ }
+}
+
+func TestItem_PlannerVerdictOmitemptyByDefault(t *testing.T) {
+ item := &Item{Name: "x", Status: StatusPlanned}
+ data, err := json.Marshal(item)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ if strings.Contains(string(data), "planner_verdict") {
+ t.Fatalf("Item with no verdict should not emit planner_verdict key, got %s", data)
+ }
+}
+```
+
+- [ ] **Step 1.2: Run the failing tests**
+
+```bash
+cd /home/xel/git/sages-openclaw/workspace-mineru/gormes-agent
+go test ./internal/progress/ -run 'TestPlannerVerdict|TestItem_PlannerVerdictOmitemptyByDefault' -v
+```
+Expected: FAIL because `PlannerVerdict` and `Item.PlannerVerdict` do not exist.
+
+- [ ] **Step 1.3: Add `PlannerVerdict` type and field**
+
+Append to `internal/progress/health.go` (keep all existing types intact):
+
+```go
+// PlannerVerdict is execution-history metadata about one progress.json item,
+// OWNED by the architecture-planner runtime. Autoloop READS it (to skip rows
+// escalated for human review) and MUST preserve it verbatim across writes
+// (structural via typed JSON round-trip).
+//
+// Symmetric to RowHealth (autoloop-owned + planner-preserved).
+type PlannerVerdict struct {
+ // NeedsHuman is sticky: once true, only a human edit can clear it.
+ // Planner runtime never auto-unsets it.
+ NeedsHuman bool `json:"needs_human,omitempty"`
+ Reason string `json:"reason,omitempty"`
+ Since string `json:"since,omitempty"` // RFC3339; set when NeedsHuman first triggers
+ ReshapeCount int `json:"reshape_count,omitempty"` // monotonic; total times planner reshaped this row
+ LastReshape string `json:"last_reshape,omitempty"` // RFC3339 of most recent reshape
+ LastOutcome string `json:"last_outcome,omitempty"` // "unstuck" | "still_failing" | "no_attempts_yet"
+}
+```
+
+Modify `internal/progress/progress.go` โ add `PlannerVerdict` as the LAST field of `Item` (immediately after `Health`):
+
+```go
+type Item struct {
+ // ... existing fields preserved ...
+ Health *RowHealth `json:"health,omitempty"`
+ PlannerVerdict *PlannerVerdict `json:"planner_verdict,omitempty"`
+}
+```
+
+- [ ] **Step 1.4: Re-run the failing tests**
+
+```bash
+go test ./internal/progress/ -run 'TestPlannerVerdict|TestItem_PlannerVerdictOmitemptyByDefault' -v
+```
+Expected: PASS for all three.
+
+- [ ] **Step 1.5: Verify no existing tests regressed**
+
+```bash
+go test ./internal/progress/...
+go vet ./internal/progress/...
+gofmt -l internal/progress/
+```
+Expected: all pass; vet clean; no gofmt diffs.
+
+- [ ] **Step 1.6: Commit**
+
+```bash
+git add internal/progress/health.go internal/progress/progress.go internal/progress/health_test.go
+git commit -m "feat(progress): add PlannerVerdict schema for planner self-healing"
+```
+
+---
+
+## Task 2: Symmetric Preservation Regression Test
+
+**Files:**
+- Create: `internal/progress/preservation_test.go`
+- Modify: `internal/progress/health_compat_test.go` (add idempotency case with both blocks)
+
+- [ ] **Step 2.1: Write the failing tests**
+
+Create `internal/progress/preservation_test.go`:
+
+```go
+package progress
+
+import (
+ "os"
+ "path/filepath"
+ "reflect"
+ "testing"
+)
+
+// TestSymmetricPreservation_AutoloopWritesPreserveVerdict verifies that
+// autoloop's ApplyHealthUpdates does not erase Item.PlannerVerdict, which
+// the planner owns. The preservation is structural via typed JSON round-trip.
+func TestSymmetricPreservation_AutoloopWritesPreserveVerdict(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "progress.json")
+ body := `{
+ "version": "1",
+ "phases": {
+ "1": {
+ "name": "P",
+ "subphases": {
+ "1.A": {
+ "name": "S",
+ "items": [
+ {"name": "row-1", "status": "planned", "contract": "do x",
+ "health": {"attempt_count": 2, "consecutive_failures": 2},
+ "planner_verdict": {"reshape_count": 1, "last_outcome": "still_failing"}}
+ ]
+ }
+ }
+ }
+ }
+}
+`
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+ t.Fatalf("write: %v", err)
+ }
+
+ // Autoloop-side write: increment Health.AttemptCount.
+ err := ApplyHealthUpdates(path, []HealthUpdate{{
+ PhaseID: "1", SubphaseID: "1.A", ItemName: "row-1",
+ Mutate: func(h *RowHealth) {
+ h.AttemptCount = 3
+ h.ConsecutiveFailures = 3
+ },
+ }})
+ if err != nil {
+ t.Fatalf("ApplyHealthUpdates: %v", err)
+ }
+
+ prog, err := Load(path)
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ row := &prog.Phases["1"].Subphases["1.A"].Items[0]
+ if row.PlannerVerdict == nil {
+ t.Fatal("PlannerVerdict was erased by autoloop's write")
+ }
+ if row.PlannerVerdict.ReshapeCount != 1 {
+ t.Fatalf("PlannerVerdict.ReshapeCount = %d, want 1 (preserved)", row.PlannerVerdict.ReshapeCount)
+ }
+ if row.PlannerVerdict.LastOutcome != "still_failing" {
+ t.Fatalf("PlannerVerdict.LastOutcome = %q, want still_failing (preserved)", row.PlannerVerdict.LastOutcome)
+ }
+ // The Health update did land:
+ if row.Health.AttemptCount != 3 {
+ t.Fatalf("Health.AttemptCount = %d, want 3", row.Health.AttemptCount)
+ }
+}
+
+// TestSymmetricPreservation_PlannerWritesPreserveHealth verifies that a
+// SaveProgress call with verdict-only changes preserves Health.
+func TestSymmetricPreservation_PlannerWritesPreserveHealth(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "progress.json")
+ body := `{
+ "version": "1",
+ "phases": {
+ "1": {
+ "name": "P",
+ "subphases": {
+ "1.A": {
+ "name": "S",
+ "items": [
+ {"name": "row-1", "status": "planned", "contract": "do x",
+ "health": {"attempt_count": 2, "consecutive_failures": 2,
+ "quarantine": {"reason": "auto", "threshold": 3, "spec_hash": "abc"}}}
+ ]
+ }
+ }
+ }
+ }
+}
+`
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+ t.Fatalf("write: %v", err)
+ }
+
+ prog, err := Load(path)
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ originalHealth := *prog.Phases["1"].Subphases["1.A"].Items[0].Health
+
+ // Planner-side write: stamp PlannerVerdict (mimics StampVerdicts).
+ prog.Phases["1"].Subphases["1.A"].Items[0].PlannerVerdict = &PlannerVerdict{
+ ReshapeCount: 1,
+ LastReshape: "2026-04-24T12:00:00Z",
+ LastOutcome: "still_failing",
+ }
+ if err := SaveProgress(path, prog); err != nil {
+ t.Fatalf("SaveProgress: %v", err)
+ }
+
+ // Reload and verify Health survived byte-equal.
+ prog2, err := Load(path)
+ if err != nil {
+ t.Fatalf("Load 2: %v", err)
+ }
+ row := &prog2.Phases["1"].Subphases["1.A"].Items[0]
+ if !reflect.DeepEqual(*row.Health, originalHealth) {
+ t.Fatalf("Health was modified by planner's write\nbefore: %+v\nafter: %+v", originalHealth, *row.Health)
+ }
+ if row.PlannerVerdict == nil || row.PlannerVerdict.ReshapeCount != 1 {
+ t.Fatal("PlannerVerdict was not persisted")
+ }
+}
+
+// TestSymmetricPreservation_BothBlocksRoundTrip combines both directions
+// and asserts the spec hash is stable after a full round-trip with both
+// blocks populated.
+func TestSymmetricPreservation_BothBlocksRoundTrip(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "progress.json")
+ body := `{
+ "version": "1",
+ "phases": {
+ "1": {
+ "name": "P",
+ "subphases": {
+ "1.A": {
+ "name": "S",
+ "items": [
+ {"name": "row-1", "status": "planned", "contract": "do x", "blocked_by": ["dep-a"],
+ "health": {"attempt_count": 1},
+ "planner_verdict": {"needs_human": true, "reason": "auto", "reshape_count": 4}}
+ ]
+ }
+ }
+ }
+ }
+}
+`
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+ t.Fatalf("write: %v", err)
+ }
+
+ prog, err := Load(path)
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ hashBefore := ItemSpecHash(&prog.Phases["1"].Subphases["1.A"].Items[0])
+
+ if err := SaveProgress(path, prog); err != nil {
+ t.Fatalf("SaveProgress: %v", err)
+ }
+
+ prog2, err := Load(path)
+ if err != nil {
+ t.Fatalf("Load 2: %v", err)
+ }
+ row := &prog2.Phases["1"].Subphases["1.A"].Items[0]
+ hashAfter := ItemSpecHash(row)
+
+ if hashBefore != hashAfter {
+ t.Fatalf("spec hash changed across round-trip:\nbefore: %s\nafter: %s", hashBefore, hashAfter)
+ }
+ if row.Health == nil || row.PlannerVerdict == nil {
+ t.Fatal("one of the blocks went missing across round-trip")
+ }
+ if !row.PlannerVerdict.NeedsHuman {
+ t.Fatal("PlannerVerdict.NeedsHuman flipped across round-trip")
+ }
+}
+```
+
+- [ ] **Step 2.2: Run the failing tests**
+
+```bash
+go test ./internal/progress/ -run TestSymmetricPreservation -v
+```
+Expected: PASS for all three (Phase B's typed-struct round-trip already preserves unknown fields). If any fails, that's a real Phase B regression to investigate.
+
+> The tests should pass on first run because Task 1's `PlannerVerdict` is a typed field on `Item` and Go's `encoding/json` round-trips typed fields naturally. The point of these tests is REGRESSION protection: if anyone later changes `Save`/`Load` to drop unknown fields or reorder, this catches it.
+
+- [ ] **Step 2.3: Extend the existing compat round-trip with both blocks**
+
+Modify `internal/progress/health_compat_test.go` โ append a new test:
+
+```go
+func TestSaveProgress_IdempotentWithBothHealthAndVerdict(t *testing.T) {
+ src := filepath.Join("..", "..", "docs", "content", "building-gormes", "architecture_plan", "progress.json")
+ original, err := os.ReadFile(src)
+ if err != nil {
+ t.Skipf("checked-in progress.json not found, skipping: %v", err)
+ }
+
+ tmp1 := filepath.Join(t.TempDir(), "progress.json")
+ if err := os.WriteFile(tmp1, original, 0o644); err != nil {
+ t.Fatalf("write tmp1: %v", err)
+ }
+
+ // Mutation that touches BOTH blocks on the same row.
+ if err := ApplyHealthUpdates(tmp1, []HealthUpdate{{
+ PhaseID: "1",
+ SubphaseID: "1.A",
+ ItemName: "Bubble Tea shell",
+ Mutate: func(h *RowHealth) {
+ h.AttemptCount = 1
+ },
+ }}); err != nil {
+ t.Fatalf("first ApplyHealthUpdates: %v", err)
+ }
+ // Now stamp a PlannerVerdict on the same row via direct Load+Save.
+ prog, _ := Load(tmp1)
+ prog.Phases["1"].Subphases["1.A"].Items[0].PlannerVerdict = &PlannerVerdict{
+ ReshapeCount: 2,
+ LastOutcome: "still_failing",
+ }
+ if err := SaveProgress(tmp1, prog); err != nil {
+ t.Fatalf("SaveProgress 1: %v", err)
+ }
+ pass1, _ := os.ReadFile(tmp1)
+
+ // Round-trip 2: Load + SaveProgress with no mutation. Must be byte-equal.
+ tmp2 := filepath.Join(t.TempDir(), "progress.json")
+ if err := os.WriteFile(tmp2, pass1, 0o644); err != nil {
+ t.Fatalf("write tmp2: %v", err)
+ }
+ prog2, _ := Load(tmp2)
+ if err := SaveProgress(tmp2, prog2); err != nil {
+ t.Fatalf("SaveProgress 2: %v", err)
+ }
+ pass2, _ := os.ReadFile(tmp2)
+
+ if !bytes.Equal(pass1, pass2) {
+ t.Fatalf("SaveProgress not idempotent with both blocks; len pass1=%d pass2=%d", len(pass1), len(pass2))
+ }
+}
+```
+
+- [ ] **Step 2.4: Run and commit**
+
+```bash
+go test ./internal/progress/... -count=1
+go vet ./internal/progress/...
+gofmt -l internal/progress/
+```
+All pass; vet clean; no gofmt diffs.
+
+```bash
+git add internal/progress/preservation_test.go internal/progress/health_compat_test.go
+git commit -m "test(progress): symmetric preservation regression for both blocks"
+```
+
+---
+
+## Task 3: L1 Planner Ledger Types And IO
+
+**Files:**
+- Create: `internal/architectureplanner/ledger.go`
+- Create: `internal/architectureplanner/ledger_test.go`
+
+- [ ] **Step 3.1: Write failing tests for the ledger types and IO**
+
+Create `internal/architectureplanner/ledger_test.go`:
+
+```go
+package architectureplanner
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+)
+
+func TestLedgerEvent_RoundTrip(t *testing.T) {
+ event := LedgerEvent{
+ TS: "2026-04-25T10:00:00Z",
+ RunID: "20260425T100000Z",
+ Trigger: "event",
+ TriggerEvents: []string{"trig-1", "trig-2"},
+ Backend: "codexu",
+ Mode: "safe",
+ Status: "ok",
+ BeforeStats: ProgressStats{Shipped: 10, Planned: 50, Quarantined: 2},
+ AfterStats: ProgressStats{Shipped: 11, Planned: 49, Quarantined: 1},
+ RowsChanged: []RowChange{
+ {PhaseID: "2", SubphaseID: "2.B", ItemName: "row-1", Kind: "spec_changed"},
+ },
+ Keywords: []string{"honcho"},
+ }
+ data, err := json.Marshal(event)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ var got LedgerEvent
+ if err := json.Unmarshal(data, &got); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if got.RunID != "20260425T100000Z" || got.Trigger != "event" {
+ t.Fatalf("round-trip mismatch: %+v", got)
+ }
+ if len(got.RowsChanged) != 1 || got.RowsChanged[0].Kind != "spec_changed" {
+ t.Fatalf("RowsChanged round-trip failed: %+v", got.RowsChanged)
+ }
+}
+
+func TestAppendLedgerEvent_AppendsOneJSONLineAndIsParseable(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "runs.jsonl")
+ for i := 0; i < 3; i++ {
+ err := AppendLedgerEvent(path, LedgerEvent{
+ TS: time.Date(2026, 4, 25, 10, i, 0, 0, time.UTC).Format(time.RFC3339),
+ RunID: "run-" + string(rune('A'+i)),
+ Status: "ok",
+ })
+ if err != nil {
+ t.Fatalf("append %d: %v", i, err)
+ }
+ }
+ body, _ := os.ReadFile(path)
+ lines := strings.Split(strings.TrimRight(string(body), "\n"), "\n")
+ if len(lines) != 3 {
+ t.Fatalf("expected 3 lines, got %d:\n%s", len(lines), body)
+ }
+ for i, line := range lines {
+ var event LedgerEvent
+ if err := json.Unmarshal([]byte(line), &event); err != nil {
+ t.Fatalf("line %d not parseable JSON: %v\n%s", i, err, line)
+ }
+ }
+}
+
+func TestAppendLedgerEvent_AppendsAtomicallyAcrossWriters(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "runs.jsonl")
+ const N = 8
+ var wg sync.WaitGroup
+ for i := 0; i < N; i++ {
+ wg.Add(1)
+ go func(idx int) {
+ defer wg.Done()
+ _ = AppendLedgerEvent(path, LedgerEvent{
+ TS: time.Now().UTC().Format(time.RFC3339Nano),
+ RunID: "run-" + string(rune('A'+idx)),
+ Status: "ok",
+ })
+ }(i)
+ }
+ wg.Wait()
+ events, err := LoadLedger(path)
+ if err != nil {
+ t.Fatalf("LoadLedger: %v", err)
+ }
+ if len(events) != N {
+ t.Fatalf("got %d events, want %d", len(events), N)
+ }
+}
+
+func TestLoadLedger_SkipsCorruptLines(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "runs.jsonl")
+ good1 := `{"ts":"2026-04-25T10:00:00Z","run_id":"a","status":"ok"}`
+ bad := `{this is not json`
+ good2 := `{"ts":"2026-04-25T10:01:00Z","run_id":"b","status":"ok"}`
+ if err := os.WriteFile(path, []byte(good1+"\n"+bad+"\n"+good2+"\n"), 0o644); err != nil {
+ t.Fatalf("write: %v", err)
+ }
+ events, err := LoadLedger(path)
+ if err != nil {
+ t.Fatalf("LoadLedger: %v", err)
+ }
+ if len(events) != 2 {
+ t.Fatalf("expected 2 good events, got %d", len(events))
+ }
+}
+
+func TestLoadLedgerWindow_BoundsByTimestamp(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "runs.jsonl")
+ now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
+ for i := -10; i <= 0; i++ {
+ _ = AppendLedgerEvent(path, LedgerEvent{
+ TS: now.Add(time.Duration(i) * 24 * time.Hour).Format(time.RFC3339),
+ RunID: "run",
+ Status: "ok",
+ })
+ }
+ events, err := LoadLedgerWindow(path, 7*24*time.Hour, now)
+ if err != nil {
+ t.Fatalf("LoadLedgerWindow: %v", err)
+ }
+ // Window includes events from 7 days ago to now โ 8 events (-7..0).
+ if len(events) != 8 {
+ t.Fatalf("expected 8 events in 7-day window, got %d", len(events))
+ }
+}
+```
+
+- [ ] **Step 3.2: Run the failing tests**
+
+```bash
+go test ./internal/architectureplanner/ -run 'TestLedger|TestAppendLedger|TestLoadLedger' -v
+```
+Expected: FAIL because the types and functions don't exist.
+
+- [ ] **Step 3.3: Implement `internal/architectureplanner/ledger.go`**
+
+```go
+package architectureplanner
+
+import (
+ "bufio"
+ "encoding/json"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "time"
+)
+
+// LedgerEvent is one entry in the planner runs.jsonl ledger.
+type LedgerEvent struct {
+ TS string `json:"ts"` // RFC3339
+ RunID string `json:"run_id"`
+ Trigger string `json:"trigger"` // "scheduled" | "event" | "manual" | "retry"
+ TriggerEvents []string `json:"trigger_events,omitempty"`
+ Backend string `json:"backend"`
+ Mode string `json:"mode"`
+ Status string `json:"status"` // "ok" | "validation_rejected" | "backend_failed" | "no_changes" | "needs_human_set"
+ Detail string `json:"detail,omitempty"`
+ BeforeStats ProgressStats `json:"before_stats,omitempty"`
+ AfterStats ProgressStats `json:"after_stats,omitempty"`
+ RowsChanged []RowChange `json:"rows_changed,omitempty"`
+ RetryAttempt int `json:"retry_attempt,omitempty"`
+ Keywords []string `json:"keywords,omitempty"` // L6 topical focus
+}
+
+// RowChange records one mutation to a progress.json row in a planner run.
+type RowChange struct {
+ PhaseID string `json:"phase_id"`
+ SubphaseID string `json:"subphase_id"`
+ ItemName string `json:"item_name"`
+ Kind string `json:"kind"` // "added" | "deleted" | "spec_changed" | "verdict_set"
+ Detail string `json:"detail,omitempty"`
+}
+
+// ProgressStats is a snapshot of progress.json composition at a point in time.
+type ProgressStats struct {
+ Shipped int `json:"shipped,omitempty"`
+ InProgress int `json:"in_progress,omitempty"`
+ Planned int `json:"planned,omitempty"`
+ Quarantined int `json:"quarantined,omitempty"`
+ NeedsHuman int `json:"needs_human,omitempty"`
+}
+
+// AppendLedgerEvent atomically appends one event as a single JSON line.
+// Uses O_APPEND|O_CREATE|O_WRONLY for POSIX-atomic line writes (lines under
+// PIPE_BUF (4096 bytes on Linux) are atomic per the syscall contract).
+func AppendLedgerEvent(path string, event LedgerEvent) error {
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ return fmt.Errorf("mkdir ledger dir: %w", err)
+ }
+ body, err := json.Marshal(event)
+ if err != nil {
+ return fmt.Errorf("marshal ledger event: %w", err)
+ }
+ body = append(body, '\n')
+
+ f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
+ if err != nil {
+ return fmt.Errorf("open ledger: %w", err)
+ }
+ defer f.Close()
+ _, err = f.Write(body)
+ return err
+}
+
+// LoadLedger reads all events from the ledger file. Bad lines are logged
+// and skipped; they do not abort the load.
+func LoadLedger(path string) ([]LedgerEvent, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil, nil
+ }
+ return nil, err
+ }
+ defer f.Close()
+ var events []LedgerEvent
+ scanner := bufio.NewScanner(f)
+ scanner.Buffer(make([]byte, 64*1024), 1024*1024) // up to 1 MiB per line
+ for scanner.Scan() {
+ line := scanner.Bytes()
+ if len(line) == 0 {
+ continue
+ }
+ var event LedgerEvent
+ if err := json.Unmarshal(line, &event); err != nil {
+ // Skip corrupt lines; do not propagate.
+ continue
+ }
+ events = append(events, event)
+ }
+ if err := scanner.Err(); err != nil && err != io.EOF {
+ return events, err
+ }
+ return events, nil
+}
+
+// LoadLedgerWindow returns events within [now-window, now] inclusive. Bad
+// timestamps are skipped.
+func LoadLedgerWindow(path string, window time.Duration, now time.Time) ([]LedgerEvent, error) {
+ all, err := LoadLedger(path)
+ if err != nil {
+ return nil, err
+ }
+ cutoff := now.Add(-window)
+ out := []LedgerEvent{}
+ for _, ev := range all {
+ t, err := time.Parse(time.RFC3339, ev.TS)
+ if err != nil {
+ continue
+ }
+ if !t.Before(cutoff) && !t.After(now) {
+ out = append(out, ev)
+ }
+ }
+ return out, nil
+}
+```
+
+- [ ] **Step 3.4: Run and commit**
+
+```bash
+go test ./internal/architectureplanner/ -run 'TestLedger|TestAppendLedger|TestLoadLedger' -v
+go test ./internal/architectureplanner/...
+go vet ./internal/architectureplanner/...
+gofmt -l internal/architectureplanner/
+```
+All pass; vet clean; no gofmt diffs.
+
+```bash
+git add internal/architectureplanner/ledger.go internal/architectureplanner/ledger_test.go
+git commit -m "feat(planner): add per-run ledger with atomic append IO"
+```
+
+---
+
+## Task 4: Wire Planner Ledger Into RunOnce
+
+**Files:**
+- Modify: `internal/architectureplanner/run.go`
+- Modify: `internal/architectureplanner/config.go`
+- Modify: `internal/architectureplanner/run_test.go`
+
+- [ ] **Step 4.1: Write failing wire-in tests**
+
+Append to `internal/architectureplanner/run_test.go`:
+
+```go
+func TestRunOnce_AppendsLedgerEventOnSuccess(t *testing.T) {
+ t.Skip("FILL IN: use existing run_test.go fixtures (mock runner that produces a clean regen); assert ledger entry has status='ok' and rowsChanged length matches the mock's mutation set")
+}
+
+func TestRunOnce_AppendsLedgerEventOnValidationReject(t *testing.T) {
+ t.Skip("FILL IN: mock runner produces a regen that drops a Health block; assert ledger entry has status='validation_rejected' AND RunOnce returns error")
+}
+
+func TestRunOnce_LedgerWriteFailureIsSoftFail(t *testing.T) {
+ t.Skip("FILL IN: chmod cfg.RunRoot/state to read-only after run starts; assert RunOnce returns nil but logs the ledger write failure")
+}
+```
+
+The skip-stubs are intentional: the existing `run_test.go` uses a specific fixture pattern (mocked Runner that returns specific stdout/stderr); the implementer should follow that idiom. Required test names are pinned above. Replace each `t.Skip(...)` with a real test using the existing fixture style.
+
+- [ ] **Step 4.2: Run failing tests (they will skip; that's expected)**
+
+```bash
+go test ./internal/architectureplanner/ -run 'TestRunOnce_(AppendsLedgerEvent|LedgerWriteFailureIsSoftFail)' -v
+```
+Expected: SKIP for all three until the implementer fills them in.
+
+- [ ] **Step 4.3: Add new Config field for autoloop ledger path**
+
+Modify `internal/architectureplanner/config.go`. Add to `Config` struct:
+
+```go
+type Config struct {
+ // ... existing fields ...
+ AutoloopRunRoot string // path to autoloop's run root, e.g. "/.codex/orchestrator"; used by L4 evaluation
+}
+```
+
+In `ConfigFromEnv`, default `AutoloopRunRoot` to `filepath.Join(repoRoot, ".codex", "orchestrator")` and honor `AUTOLOOP_RUN_ROOT` env override.
+
+- [ ] **Step 4.4: Add `diffRows` and `computeStats` helpers**
+
+Append to `internal/architectureplanner/run.go`:
+
+```go
+// computeStats walks a Progress doc and counts rows by status, including
+// the new Phase C buckets (Quarantined, NeedsHuman) which aren't in the
+// existing Progress.Stats() function.
+func computeStats(prog *progress.Progress) ProgressStats {
+ if prog == nil {
+ return ProgressStats{}
+ }
+ var stats ProgressStats
+ for _, phase := range prog.Phases {
+ if phase == nil {
+ continue
+ }
+ for _, sub := range phase.Subphases {
+ if sub == nil {
+ continue
+ }
+ for i := range sub.Items {
+ it := &sub.Items[i]
+ switch it.Status {
+ case progress.StatusComplete:
+ stats.Shipped++
+ case progress.StatusInProgress:
+ stats.InProgress++
+ default:
+ stats.Planned++
+ }
+ if it.Health != nil && it.Health.Quarantine != nil {
+ stats.Quarantined++
+ }
+ if it.PlannerVerdict != nil && it.PlannerVerdict.NeedsHuman {
+ stats.NeedsHuman++
+ }
+ }
+ }
+ }
+ return stats
+}
+
+// diffRows compares before/after docs and returns RowChange records for
+// added/deleted/spec_changed rows. Spec change is detected via
+// progress.ItemSpecHash.
+func diffRows(before, after *progress.Progress) []RowChange {
+ var out []RowChange
+ beforeIndex := indexItems(before) // existing helper from Phase B Task 7
+ afterIndex := indexItems(after)
+
+ for key, beforeItem := range beforeIndex {
+ afterItem, exists := afterIndex[key]
+ if !exists {
+ out = append(out, RowChange{
+ PhaseID: key.phaseID, SubphaseID: key.subphaseID,
+ ItemName: key.itemName, Kind: "deleted",
+ })
+ continue
+ }
+ if progress.ItemSpecHash(beforeItem) != progress.ItemSpecHash(afterItem) {
+ out = append(out, RowChange{
+ PhaseID: key.phaseID, SubphaseID: key.subphaseID,
+ ItemName: key.itemName, Kind: "spec_changed",
+ })
+ }
+ }
+ for key := range afterIndex {
+ if _, existed := beforeIndex[key]; !existed {
+ out = append(out, RowChange{
+ PhaseID: key.phaseID, SubphaseID: key.subphaseID,
+ ItemName: key.itemName, Kind: "added",
+ })
+ }
+ }
+ return out
+}
+```
+
+- [ ] **Step 4.5: Wire ledger append into `RunOnce`**
+
+Inside `internal/architectureplanner/run.go::RunOnce`, after the existing validation step succeeds and `summary` is populated, add:
+
+```go
+// Determine status for the ledger entry from the run outcome.
+runStatus := "ok"
+if afterDoc == nil || beforeDoc == nil {
+ runStatus = "no_changes"
+}
+// (validation_rejected and backend_failed paths return early with their own
+// LedgerEvent emission โ see steps below)
+
+ledgerPath := filepath.Join(cfg.RunRoot, "state", "runs.jsonl")
+event := LedgerEvent{
+ TS: now.UTC().Format(time.RFC3339),
+ RunID: summary.RunID,
+ Trigger: "scheduled", // L2 will override; default is scheduled
+ Backend: cfg.Backend,
+ Mode: cfg.Mode,
+ Status: runStatus,
+ BeforeStats: computeStats(beforeDoc),
+ AfterStats: computeStats(afterDoc),
+ RowsChanged: diffRows(beforeDoc, afterDoc),
+}
+if err := AppendLedgerEvent(ledgerPath, event); err != nil {
+ log.Printf("planner: append ledger failed: %v", err)
+}
+```
+
+In the validation-rejected branch (where `RunOnce` currently returns an error), emit the ledger event with `Status: "validation_rejected"` BEFORE returning the error.
+
+In the backend-failed branch (where the runner returns an error), emit `Status: "backend_failed"` BEFORE returning.
+
+- [ ] **Step 4.6: Run all planner tests to confirm no regression**
+
+```bash
+go test ./internal/architectureplanner/...
+go vet ./internal/architectureplanner/...
+gofmt -l internal/architectureplanner/
+```
+
+- [ ] **Step 4.7: Commit**
+
+```bash
+git add internal/architectureplanner/run.go internal/architectureplanner/config.go internal/architectureplanner/run_test.go
+git commit -m "feat(planner): wire per-run ledger into RunOnce"
+```
+
+---
+
+## Task 5: L6 Topical Focus Mode
+
+**Files:**
+- Create: `internal/architectureplanner/topics.go`
+- Create: `internal/architectureplanner/topics_test.go`
+- Modify: `internal/architectureplanner/context.go`
+- Modify: `internal/architectureplanner/prompt.go`
+- Modify: `internal/architectureplanner/prompt_test.go`
+- Modify: `internal/architectureplanner/run.go`
+- Modify: `cmd/architecture-planner-loop/main.go`
+- Modify: `cmd/architecture-planner-loop/main_test.go` (or create if missing)
+
+- [ ] **Step 5.1: Write failing topics tests**
+
+Create `internal/architectureplanner/topics_test.go`:
+
+```go
+package architectureplanner
+
+import (
+ "testing"
+
+ "github.com/TrebuchetDynamics/gormes-agent/internal/progress"
+)
+
+func TestMatchKeywords_EmptyKeywordsMatchesAll(t *testing.T) {
+ prog := &progress.Progress{
+ Phases: map[string]*progress.Phase{
+ "1": {Name: "P", Subphases: map[string]*progress.Subphase{
+ "1.A": {Name: "S", Items: []progress.Item{
+ {Name: "row-a", Contract: "do a"},
+ {Name: "row-b", Contract: "do b"},
+ }},
+ }},
+ },
+ }
+ matched := matchKeywordsInDoc(prog, nil)
+ if len(matched) != 2 {
+ t.Fatalf("expected all 2 rows, got %d", len(matched))
+ }
+}
+
+func TestMatchKeywords_SubstringMatchesItemName(t *testing.T) {
+ prog := docOneItem(progress.Item{Name: "honcho-client", Contract: "x"})
+ matched := matchKeywordsInDoc(prog, []string{"honcho"})
+ if len(matched) != 1 {
+ t.Fatalf("expected 1 match, got %d", len(matched))
+ }
+}
+
+func TestMatchKeywords_MatchesContract(t *testing.T) {
+ prog := docOneItem(progress.Item{Name: "row-x", Contract: "Wire Honcho client"})
+ matched := matchKeywordsInDoc(prog, []string{"honcho"})
+ if len(matched) != 1 {
+ t.Fatalf("expected 1 match, got %d", len(matched))
+ }
+}
+
+func TestMatchKeywords_MatchesSourceRefs(t *testing.T) {
+ prog := docOneItem(progress.Item{
+ Name: "row-x",
+ Contract: "x",
+ SourceRefs: []string{"../honcho/api.py"},
+ })
+ matched := matchKeywordsInDoc(prog, []string{"honcho"})
+ if len(matched) != 1 {
+ t.Fatalf("expected 1 match, got %d", len(matched))
+ }
+}
+
+func TestMatchKeywords_MatchesSubphaseName(t *testing.T) {
+ prog := &progress.Progress{
+ Phases: map[string]*progress.Phase{
+ "3": {Name: "Memory", Subphases: map[string]*progress.Subphase{
+ "3.A": {Name: "Honcho integration", Items: []progress.Item{
+ {Name: "row-1", Contract: "x"},
+ {Name: "row-2", Contract: "y"},
+ }},
+ }},
+ },
+ }
+ matched := matchKeywordsInDoc(prog, []string{"honcho"})
+ if len(matched) != 2 {
+ t.Fatalf("subphase name match should bring all items; got %d", len(matched))
+ }
+}
+
+func TestMatchKeywords_OrSemanticsAcrossKeywords(t *testing.T) {
+ prog := &progress.Progress{
+ Phases: map[string]*progress.Phase{
+ "1": {Name: "P", Subphases: map[string]*progress.Subphase{
+ "1.A": {Name: "S", Items: []progress.Item{
+ {Name: "row-honcho", Contract: "x"},
+ {Name: "row-memory", Contract: "y"},
+ {Name: "row-other", Contract: "z"},
+ }},
+ }},
+ },
+ }
+ matched := matchKeywordsInDoc(prog, []string{"honcho", "memory"})
+ if len(matched) != 2 {
+ t.Fatalf("OR across keywords should match 2, got %d", len(matched))
+ }
+}
+
+func TestMatchKeywords_CaseInsensitive(t *testing.T) {
+ prog := docOneItem(progress.Item{Name: "row-x", Contract: "Wire Honcho"})
+ matched := matchKeywordsInDoc(prog, []string{"HONCHO"})
+ if len(matched) != 1 {
+ t.Fatalf("case-insensitive match expected; got %d", len(matched))
+ }
+}
+
+func TestFilterContextByKeywords_NarrowsBundleSelectively(t *testing.T) {
+ bundle := ContextBundle{
+ QuarantinedRows: []QuarantinedRowContext{
+ {ItemName: "honcho-row", Contract: "x"},
+ {ItemName: "other-row", Contract: "y"},
+ },
+ AutoloopAudit: AutoloopAudit{}, // would be aggregate-only
+ }
+ narrowed := FilterContextByKeywords(bundle, []string{"honcho"})
+ if len(narrowed.QuarantinedRows) != 1 || narrowed.QuarantinedRows[0].ItemName != "honcho-row" {
+ t.Fatalf("QuarantinedRows narrowing failed: %+v", narrowed.QuarantinedRows)
+ }
+ // AutoloopAudit must remain intact (aggregate, not row-level).
+}
+
+// docOneItem is a small builder used by topics tests.
+func docOneItem(item progress.Item) *progress.Progress {
+ return &progress.Progress{
+ Phases: map[string]*progress.Phase{
+ "1": {Name: "P", Subphases: map[string]*progress.Subphase{
+ "1.A": {Name: "S", Items: []progress.Item{item}},
+ }},
+ },
+ }
+}
+```
+
+- [ ] **Step 5.2: Run failing tests**
+
+```bash
+go test ./internal/architectureplanner/ -run 'TestMatchKeywords|TestFilterContext' -v
+```
+Expected: FAIL because `matchKeywordsInDoc`, `FilterContextByKeywords` don't exist.
+
+- [ ] **Step 5.3: Implement `internal/architectureplanner/topics.go`**
+
+```go
+package architectureplanner
+
+import (
+ "strings"
+
+ "github.com/TrebuchetDynamics/gormes-agent/internal/progress"
+)
+
+// itemMatchesKeywords returns true if any keyword (case-insensitive
+// substring) matches the item's name, contract, source_refs, write_scope,
+// fixture, or any of the parent subphase/phase names.
+func itemMatchesKeywords(item *progress.Item, phaseName, subphaseName string, keywords []string) bool {
+ if len(keywords) == 0 {
+ return true
+ }
+ for _, kw := range keywords {
+ if kw == "" {
+ continue
+ }
+ needle := strings.ToLower(kw)
+ if strings.Contains(strings.ToLower(item.Name), needle) ||
+ strings.Contains(strings.ToLower(item.Contract), needle) ||
+ strings.Contains(strings.ToLower(item.Fixture), needle) ||
+ strings.Contains(strings.ToLower(phaseName), needle) ||
+ strings.Contains(strings.ToLower(subphaseName), needle) {
+ return true
+ }
+ for _, ref := range item.SourceRefs {
+ if strings.Contains(strings.ToLower(ref), needle) {
+ return true
+ }
+ }
+ for _, scope := range item.WriteScope {
+ if strings.Contains(strings.ToLower(scope), needle) {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// matchKeywordsInDoc returns the subset of items in prog that match any of
+// the keywords. Empty keywords returns all items.
+type matchedRow struct {
+ PhaseID string
+ SubphaseID string
+ Item *progress.Item
+}
+
+func matchKeywordsInDoc(prog *progress.Progress, keywords []string) []matchedRow {
+ var out []matchedRow
+ if prog == nil {
+ return out
+ }
+ for phaseID, phase := range prog.Phases {
+ if phase == nil {
+ continue
+ }
+ for subphaseID, sub := range phase.Subphases {
+ if sub == nil {
+ continue
+ }
+ for i := range sub.Items {
+ it := &sub.Items[i]
+ if itemMatchesKeywords(it, phase.Name, sub.Name, keywords) {
+ out = append(out, matchedRow{PhaseID: phaseID, SubphaseID: subphaseID, Item: it})
+ }
+ }
+ }
+ }
+ return out
+}
+
+// FilterContextByKeywords narrows the bundle's row-level slices
+// (QuarantinedRows, PreviousReshapes if present) to only rows matching ANY
+// of the keywords. Empty keywords returns the bundle unchanged.
+// AutoloopAudit and SourceRoots are intentionally NOT narrowed.
+func FilterContextByKeywords(bundle ContextBundle, keywords []string) ContextBundle {
+ if len(keywords) == 0 {
+ return bundle
+ }
+
+ matchesAny := func(haystacks ...string) bool {
+ for _, kw := range keywords {
+ needle := strings.ToLower(kw)
+ for _, h := range haystacks {
+ if strings.Contains(strings.ToLower(h), needle) {
+ return true
+ }
+ }
+ }
+ return false
+ }
+
+ narrowed := bundle
+ if len(bundle.QuarantinedRows) > 0 {
+ filtered := []QuarantinedRowContext{}
+ for _, r := range bundle.QuarantinedRows {
+ if matchesAny(r.ItemName, r.Contract) {
+ filtered = append(filtered, r)
+ }
+ }
+ narrowed.QuarantinedRows = filtered
+ }
+ // PreviousReshapes is added in Task 10 (L4); when present, narrow it too.
+ // At Task 5 time the field doesn't exist yet; that's fine โ the type
+ // extension lands in Task 10 and the FilterContextByKeywords body will
+ // gain a matching block then.
+ return narrowed
+}
+```
+
+- [ ] **Step 5.4: Verify topics tests pass**
+
+```bash
+go test ./internal/architectureplanner/ -run 'TestMatchKeywords|TestFilterContext' -v
+```
+Expected: PASS for all 8.
+
+- [ ] **Step 5.5: Add `RunOptions.Keywords` and wire into RunOnce**
+
+Modify `internal/architectureplanner/run.go`:
+
+```go
+type RunOptions struct {
+ // ... existing fields ...
+ Keywords []string
+}
+```
+
+In `RunOnce`, after `bundle, err := CollectContext(cfg, now)`:
+
+```go
+if len(opts.Keywords) > 0 {
+ bundle = FilterContextByKeywords(bundle, opts.Keywords)
+}
+```
+
+Pass keywords into `BuildPrompt` (next step).
+
+- [ ] **Step 5.6: Add topical clause to prompt**
+
+Modify `internal/architectureplanner/prompt.go`. Change `BuildPrompt` signature:
+
+```go
+func BuildPrompt(bundle ContextBundle, keywords []string) string {
+ // ... existing content ...
+ if len(keywords) > 0 {
+ builder.WriteString(formatTopicalClause(keywords))
+ }
+ // ... rest ...
+}
+
+const topicalClauseTemplate = `
+TOPICAL FOCUS
+
+This run was invoked with keyword arguments: %s. The context above
+(Quarantined Rows, Previous Reshapes, Implementation Inventory) has been
+narrowed to only rows that mechanically match these keywords.
+
+Focus your refinement work on these areas. You may still adjust adjacent
+rows if a topical row's blocked_by/unblocks dependencies require it, but
+do NOT widen the scope to unrelated phases. If you believe a topical
+keyword needs structural rework that crosses phase boundaries, set
+contract_status="draft" on the affected rows and add a degraded_mode note
+explaining the cross-phase dependency rather than reshaping the whole
+graph.
+`
+
+func formatTopicalClause(keywords []string) string {
+ quoted := make([]string, len(keywords))
+ for i, kw := range keywords {
+ quoted[i] = strconv.Quote(kw)
+ }
+ return fmt.Sprintf(topicalClauseTemplate, "["+strings.Join(quoted, ", ")+"]")
+}
+```
+
+Update every existing caller of `BuildPrompt` (search the codebase) to pass `nil` for keywords until Task 10/11 update them.
+
+Add a test in `prompt_test.go`:
+
+```go
+func TestBuildPrompt_TopicalClauseAppearsWithKeywords(t *testing.T) {
+ bundle := ContextBundle{}
+ prompt := BuildPrompt(bundle, []string{"honcho", "memory"})
+ if !strings.Contains(prompt, "TOPICAL FOCUS") {
+ t.Fatal("topical clause missing when keywords present")
+ }
+ if !strings.Contains(prompt, `"honcho"`) || !strings.Contains(prompt, `"memory"`) {
+ t.Fatalf("topical clause should name keywords; got:\n%s", prompt)
+ }
+}
+
+func TestBuildPrompt_NoTopicalClauseWithoutKeywords(t *testing.T) {
+ bundle := ContextBundle{}
+ prompt := BuildPrompt(bundle, nil)
+ if strings.Contains(prompt, "TOPICAL FOCUS") {
+ t.Fatal("topical clause should be omitted when no keywords")
+ }
+}
+```
+
+- [ ] **Step 5.7: Parse keyword arguments in cmd**
+
+Modify `cmd/architecture-planner-loop/main.go::parseRunOptions`:
+
+```go
+type runOptions struct {
+ // ... existing fields ...
+ keywords []string
+}
+
+func parseRunOptions(args []string) (runOptions, error) {
+ opts := runOptions{}
+ for i := 0; i < len(args); i++ {
+ arg := args[i]
+ switch arg {
+ case "--dry-run":
+ opts.dryRun = true
+ case "--codexu":
+ opts.backend = "codexu"
+ case "--claudeu":
+ opts.backend = "claudeu"
+ case "--mode":
+ if i+1 >= len(args) {
+ return runOptions{}, fmt.Errorf(usage)
+ }
+ i++
+ opts.mode = args[i]
+ case "--help", "-h":
+ opts.help = true
+ default:
+ // Treat as positional keyword argument. Multi-word keywords
+ // (e.g. "skills tools") get split on whitespace.
+ for _, kw := range strings.Fields(arg) {
+ opts.keywords = append(opts.keywords, kw)
+ }
+ }
+ }
+ return opts, nil
+}
+```
+
+In the `case "run":` branch, pass `opts.keywords` to `RunOptions.Keywords`.
+
+Add a test in `cmd/architecture-planner-loop/main_test.go`:
+
+```go
+func TestParseRunOptions_PositionalKeywords(t *testing.T) {
+ opts, err := parseRunOptions([]string{"--codexu", "honcho", "memory"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if opts.backend != "codexu" {
+ t.Errorf("backend = %q", opts.backend)
+ }
+ want := []string{"honcho", "memory"}
+ if !reflect.DeepEqual(opts.keywords, want) {
+ t.Errorf("keywords = %v, want %v", opts.keywords, want)
+ }
+}
+
+func TestParseRunOptions_QuotedMultiwordKeywordsSplitOnWhitespace(t *testing.T) {
+ opts, err := parseRunOptions([]string{"skills tools"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := []string{"skills", "tools"}
+ if !reflect.DeepEqual(opts.keywords, want) {
+ t.Errorf("keywords = %v, want %v", opts.keywords, want)
+ }
+}
+```
+
+- [ ] **Step 5.8: Add Keywords to ledger entry**
+
+In `RunOnce` where the ledger event is built (Task 4), add:
+
+```go
+event.Keywords = opts.Keywords
+```
+
+- [ ] **Step 5.9: Run all tests + commit**
+
+```bash
+go test ./internal/architectureplanner/...
+go test ./cmd/architecture-planner-loop/...
+go vet ./internal/architectureplanner/... ./cmd/architecture-planner-loop/...
+gofmt -l internal/architectureplanner/ cmd/architecture-planner-loop/
+```
+All pass; vet clean; no gofmt diffs.
+
+```bash
+git add internal/architectureplanner/topics.go internal/architectureplanner/topics_test.go internal/architectureplanner/prompt.go internal/architectureplanner/prompt_test.go internal/architectureplanner/run.go cmd/architecture-planner-loop/main.go cmd/architecture-planner-loop/main_test.go
+git commit -m "feat(planner): topical focus via positional keyword arguments"
+```
+
+---
+
+## Task 6: L2 Autoloop Side โ Emit Triggers
+
+**Files:**
+- Modify: `internal/autoloop/health_writer.go`
+- Modify: `internal/autoloop/run.go`
+- Modify: `internal/autoloop/config.go`
+- Modify: `internal/autoloop/health_writer_test.go`
+
+- [ ] **Step 6.1: Write failing tests for trigger emission**
+
+Append to `internal/autoloop/health_writer_test.go`:
+
+```go
+func TestFlush_NewQuarantineEmitsTrigger(t *testing.T) {
+ dir := t.TempDir()
+ progressPath := filepath.Join(dir, "progress.json")
+ triggersPath := filepath.Join(dir, "triggers.jsonl")
+ writeBaseProgress(t, progressPath)
+
+ // Pre-load row at CF=2; one more failure triggers quarantine.
+ if err := progress.ApplyHealthUpdates(progressPath, []progress.HealthUpdate{{
+ PhaseID: "2", SubphaseID: "2.B", ItemName: "row-1",
+ Mutate: func(h *progress.RowHealth) {
+ h.AttemptCount = 2
+ h.ConsecutiveFailures = 2
+ },
+ }}); err != nil {
+ t.Fatalf("seed: %v", err)
+ }
+
+ acc := newHealthAccumulator("R1", fixedNow(), 3)
+ acc.RecordFailure(candidateOf("2", "2.B", "row-1", "do x"), progress.FailureWorkerError, "codexu", "boom")
+ events, err := acc.FlushWithTriggers(progressPath, nil)
+ if err != nil {
+ t.Fatalf("FlushWithTriggers: %v", err)
+ }
+ if len(events) != 1 {
+ t.Fatalf("expected 1 trigger event, got %d", len(events))
+ }
+ if events[0].Kind != "quarantine_added" {
+ t.Fatalf("event kind = %q, want quarantine_added", events[0].Kind)
+ }
+ if events[0].ItemName != "row-1" {
+ t.Fatalf("event.ItemName = %q", events[0].ItemName)
+ }
+
+ _ = triggersPath // Task 7's planner side reads this path
+}
+
+func TestFlush_PureFailureBelowThresholdEmitsNoTrigger(t *testing.T) {
+ dir := t.TempDir()
+ progressPath := filepath.Join(dir, "progress.json")
+ writeBaseProgress(t, progressPath)
+
+ acc := newHealthAccumulator("R1", fixedNow(), 3)
+ acc.RecordFailure(candidateOf("2", "2.B", "row-1", "do x"), progress.FailureWorkerError, "codexu", "boom")
+ events, err := acc.FlushWithTriggers(progressPath, nil)
+ if err != nil {
+ t.Fatalf("FlushWithTriggers: %v", err)
+ }
+ if len(events) != 0 {
+ t.Fatalf("expected no trigger events for sub-threshold failure, got %d", len(events))
+ }
+}
+
+func TestFlush_StaleClearEmitsTrigger(t *testing.T) {
+ dir := t.TempDir()
+ progressPath := filepath.Join(dir, "progress.json")
+ writeBaseProgress(t, progressPath)
+
+ // Pre-quarantine the row.
+ if err := progress.ApplyHealthUpdates(progressPath, []progress.HealthUpdate{{
+ PhaseID: "2", SubphaseID: "2.B", ItemName: "row-1",
+ Mutate: func(h *progress.RowHealth) {
+ h.ConsecutiveFailures = 5
+ h.Quarantine = &progress.Quarantine{Reason: "auto", Threshold: 3, SpecHash: "stale"}
+ },
+ }}); err != nil {
+ t.Fatalf("seed: %v", err)
+ }
+
+ acc := newHealthAccumulator("R1", fixedNow(), 3)
+ acc.MarkStaleQuarantine(candidateOf("2", "2.B", "row-1", "do x"))
+ events, err := acc.FlushWithTriggers(progressPath, nil)
+ if err != nil {
+ t.Fatalf("FlushWithTriggers: %v", err)
+ }
+ if len(events) != 1 || events[0].Kind != "quarantine_stale_cleared" {
+ t.Fatalf("expected 1 quarantine_stale_cleared event, got %+v", events)
+ }
+}
+```
+
+- [ ] **Step 6.2: Run failing tests**
+
+```bash
+go test ./internal/autoloop/ -run 'TestFlush_(NewQuarantineEmitsTrigger|PureFailureBelowThresholdEmitsNoTrigger|StaleClearEmitsTrigger)' -v
+```
+Expected: FAIL because `FlushWithTriggers` does not exist.
+
+- [ ] **Step 6.3: Add `FlushWithTriggers` and trigger types**
+
+Append to `internal/autoloop/health_writer.go`:
+
+```go
+// FlushedTriggerEvent represents one trigger event the accumulator
+// determined should fire after the batched ApplyHealthUpdates landed.
+// Lifted to the run.go layer for actual emission to the planner trigger
+// ledger (Task 7's AppendTriggerEvent path).
+type FlushedTriggerEvent struct {
+ Kind string // "quarantine_added" | "quarantine_stale_cleared"
+ PhaseID string
+ SubphaseID string
+ ItemName string
+ Reason string
+ AutoloopRunID string
+}
+
+// FlushWithTriggers performs the same work as Flush but also returns the
+// list of trigger events the run loop should emit to the planner trigger
+// ledger. To classify, the accumulator loads the BEFORE state from disk,
+// applies updates as normal, then loads the AFTER state and compares per
+// row.
+//
+// Soft contract: if the after-state load fails, returns the existing flush
+// error AND nil triggers (don't emit triggers we can't validate).
+func (a *healthAccumulator) FlushWithTriggers(progressPath string, hashOf SpecHashProvider) ([]FlushedTriggerEvent, error) {
+ if len(a.rows) == 0 {
+ return nil, nil
+ }
+
+ // Snapshot before-state for trigger classification.
+ beforeProg, _ := progress.Load(progressPath)
+ beforeIndex := indexHealthByKey(beforeProg)
+
+ // Reuse the existing Flush logic.
+ if err := a.Flush(progressPath, hashOf); err != nil {
+ return nil, err
+ }
+
+ afterProg, err := progress.Load(progressPath)
+ if err != nil {
+ return nil, nil // soft: don't emit unverifiable triggers
+ }
+ afterIndex := indexHealthByKey(afterProg)
+
+ var events []FlushedTriggerEvent
+ for key, pending := range a.rows {
+ before := beforeIndex[key]
+ after := afterIndex[key]
+ kind, fire := classifyForTrigger(before, after, pending)
+ if !fire {
+ continue
+ }
+ reason := ""
+ if after != nil && after.Quarantine != nil {
+ reason = after.Quarantine.Reason
+ }
+ events = append(events, FlushedTriggerEvent{
+ Kind: kind,
+ PhaseID: key.phaseID,
+ SubphaseID: key.subphaseID,
+ ItemName: key.itemName,
+ Reason: reason,
+ AutoloopRunID: a.runID,
+ })
+ }
+ return events, nil
+}
+
+func classifyForTrigger(before, after *progress.RowHealth, p *pendingHealth) (string, bool) {
+ // New quarantine just set this run.
+ if (before == nil || before.Quarantine == nil) && after != nil && after.Quarantine != nil {
+ return "quarantine_added", true
+ }
+ // Stale quarantine cleared this run.
+ if before != nil && before.Quarantine != nil && after != nil && after.Quarantine == nil && p.staleClear {
+ return "quarantine_stale_cleared", true
+ }
+ return "", false
+}
+
+func indexHealthByKey(prog *progress.Progress) map[rowKey]*progress.RowHealth {
+ out := map[rowKey]*progress.RowHealth{}
+ if prog == nil {
+ return out
+ }
+ for phaseID, phase := range prog.Phases {
+ if phase == nil {
+ continue
+ }
+ for subID, sub := range phase.Subphases {
+ if sub == nil {
+ continue
+ }
+ for i := range sub.Items {
+ it := &sub.Items[i]
+ out[rowKey{phaseID, subID, it.Name}] = it.Health
+ }
+ }
+ }
+ return out
+}
+```
+
+- [ ] **Step 6.4: Add `PlannerTriggersPath` to autoloop Config**
+
+Modify `internal/autoloop/config.go`. Add field to `Config`:
+
+```go
+PlannerTriggersPath string // PLANNER_TRIGGERS_PATH; default: /.codex/architecture-planner/triggers.jsonl
+```
+
+In `ConfigFromEnv`, default and env-override.
+
+- [ ] **Step 6.5: Wire trigger emission in run.go's flushHealth closure**
+
+Modify `internal/autoloop/run.go`. In the existing `flushHealth` closure (Phase B), replace the `acc.Flush` call with `acc.FlushWithTriggers` and emit events:
+
+```go
+flushHealth := func() error {
+ events, err := acc.FlushWithTriggers(opts.Config.ProgressJSON, hashOf)
+ if err != nil {
+ // ... existing failed-event ledger emission ...
+ return fmt.Errorf("flush health: %w", err)
+ }
+ // ... existing health_updated event emission ...
+
+ // Emit trigger events. Soft-fail: log but don't break the autoloop run.
+ for _, ev := range events {
+ triggerEvent := architectureplanner.TriggerEvent{
+ Source: "autoloop",
+ Kind: ev.Kind,
+ PhaseID: ev.PhaseID,
+ SubphaseID: ev.SubphaseID,
+ ItemName: ev.ItemName,
+ Reason: ev.Reason,
+ AutoloopRunID: ev.AutoloopRunID,
+ }
+ if err := architectureplanner.AppendTriggerEvent(opts.Config.PlannerTriggersPath, triggerEvent); err != nil {
+ log.Printf("autoloop: append trigger failed: %v", err)
+ }
+ }
+ return nil
+}
+```
+
+> NOTE: `architectureplanner.TriggerEvent` and `architectureplanner.AppendTriggerEvent` come from Task 7. Task 6 introduces the IMPORT cycle awareness โ autoloop imports the planner package's types. Verify there's no circular import (the planner imports autoloop; autoloop importing planner here would create a cycle). If circular, define `TriggerEvent` and `AppendTriggerEvent` in a NEW package `internal/plannertriggers` that BOTH autoloop and planner import. Add this package in Task 6 instead of Task 7 in that case.
+
+- [ ] **Step 6.6: Run tests + commit**
+
+```bash
+go test ./internal/autoloop/...
+go vet ./internal/autoloop/...
+gofmt -l internal/autoloop/
+```
+
+```bash
+git add internal/autoloop/health_writer.go internal/autoloop/run.go internal/autoloop/config.go internal/autoloop/health_writer_test.go
+git commit -m "feat(autoloop): emit planner trigger events on quarantine state changes"
+```
+
+---
+
+## Task 7: L2 Planner Side โ Triggers and Cursor
+
+**Files:**
+- Create: `internal/architectureplanner/triggers.go` (or new `internal/plannertriggers/triggers.go` if Task 6 found a circular import)
+- Create: `internal/architectureplanner/triggers_test.go`
+- Create: `internal/architectureplanner/triggers_concurrent_test.go`
+- Modify: `internal/architectureplanner/config.go`
+- Modify: `internal/architectureplanner/context.go`
+- Modify: `internal/architectureplanner/prompt.go`
+- Modify: `internal/architectureplanner/run.go`
+
+- [ ] **Step 7.1: Write failing trigger consumer tests**
+
+Create `internal/architectureplanner/triggers_test.go`:
+
+```go
+package architectureplanner
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func TestAppendTriggerEvent_GeneratesIDIfEmpty(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "triggers.jsonl")
+ if err := AppendTriggerEvent(path, TriggerEvent{Kind: "quarantine_added"}); err != nil {
+ t.Fatalf("Append: %v", err)
+ }
+ body, _ := os.ReadFile(path)
+ var ev TriggerEvent
+ if err := json.Unmarshal(body[:len(body)-1], &ev); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if ev.ID == "" {
+ t.Fatal("AppendTriggerEvent should generate an ID when empty")
+ }
+}
+
+func TestReadTriggersSinceCursor_EmptyCursorReturnsAll(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "triggers.jsonl")
+ for i := 0; i < 3; i++ {
+ _ = AppendTriggerEvent(path, TriggerEvent{Kind: "quarantine_added", PhaseID: "p", ItemName: "i"})
+ }
+ events, err := ReadTriggersSinceCursor(path, TriggerCursor{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(events) != 3 {
+ t.Fatalf("expected 3, got %d", len(events))
+ }
+}
+
+func TestReadTriggersSinceCursor_AdvancesPastCursor(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "triggers.jsonl")
+ var ids []string
+ for i := 0; i < 5; i++ {
+ ev := TriggerEvent{Kind: "quarantine_added"}
+ _ = AppendTriggerEvent(path, ev)
+ // We need each ID for the cursor; re-read to capture them.
+ }
+ all, _ := ReadTriggersSinceCursor(path, TriggerCursor{})
+ for _, e := range all {
+ ids = append(ids, e.ID)
+ }
+ cursor := TriggerCursor{LastConsumedID: ids[2]}
+ events, err := ReadTriggersSinceCursor(path, cursor)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(events) != 2 {
+ t.Fatalf("expected 2 events past cursor, got %d", len(events))
+ }
+}
+
+func TestSaveCursor_AtomicReplace(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "cursor.json")
+ cursor := TriggerCursor{LastConsumedID: "abc", LastReadAt: time.Now().UTC().Format(time.RFC3339)}
+ if err := SaveCursor(path, cursor); err != nil {
+ t.Fatal(err)
+ }
+ got, err := LoadCursor(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.LastConsumedID != "abc" {
+ t.Fatalf("LastConsumedID = %q", got.LastConsumedID)
+ }
+}
+
+func TestLoadCursor_MissingFileReturnsZeroValue(t *testing.T) {
+ dir := t.TempDir()
+ cursor, err := LoadCursor(filepath.Join(dir, "nonexistent.json"))
+ if err != nil {
+ t.Fatalf("LoadCursor missing file should not error, got: %v", err)
+ }
+ if cursor.LastConsumedID != "" {
+ t.Fatalf("expected zero-value cursor, got %+v", cursor)
+ }
+}
+```
+
+- [ ] **Step 7.2: Run failing tests**
+
+```bash
+go test ./internal/architectureplanner/ -run 'TestAppendTrigger|TestReadTriggers|TestSaveCursor|TestLoadCursor' -v
+```
+Expected: FAIL because the types and functions don't exist.
+
+- [ ] **Step 7.3: Implement `internal/architectureplanner/triggers.go`**
+
+```go
+package architectureplanner
+
+import (
+ "bufio"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sync/atomic"
+ "time"
+)
+
+// TriggerEvent is one event in the autoloopโplanner triggers.jsonl ledger.
+// Autoloop appends; planner reads.
+type TriggerEvent struct {
+ ID string `json:"id"` // ULID-style: TS + monotonic counter
+ TS string `json:"ts"` // RFC3339
+ Source string `json:"source"` // "autoloop"
+ Kind string `json:"kind"` // "quarantine_added" | "quarantine_stale_cleared" | "manual"
+ PhaseID string `json:"phase_id,omitempty"`
+ SubphaseID string `json:"subphase_id,omitempty"`
+ ItemName string `json:"item_name,omitempty"`
+ Reason string `json:"reason,omitempty"`
+ AutoloopRunID string `json:"autoloop_run_id,omitempty"`
+}
+
+// TriggerCursor is the planner's bookmark in triggers.jsonl. Advances after
+// each planner run consumes events.
+type TriggerCursor struct {
+ LastConsumedID string `json:"last_consumed_id"`
+ LastReadAt string `json:"last_read_at"`
+}
+
+var triggerIDCounter atomic.Uint64
+
+// AppendTriggerEvent atomically appends one TriggerEvent. Generates a
+// process-monotonic ID if event.ID is empty. Defaults TS to now if empty.
+func AppendTriggerEvent(path string, event TriggerEvent) error {
+ if event.ID == "" {
+ now := time.Now().UTC()
+ seq := triggerIDCounter.Add(1)
+ event.ID = fmt.Sprintf("%s-%06d", now.Format("20060102T150405.000Z"), seq)
+ }
+ if event.TS == "" {
+ event.TS = time.Now().UTC().Format(time.RFC3339)
+ }
+ if event.Source == "" {
+ event.Source = "autoloop"
+ }
+
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ return fmt.Errorf("mkdir trigger dir: %w", err)
+ }
+ body, err := json.Marshal(event)
+ if err != nil {
+ return fmt.Errorf("marshal trigger event: %w", err)
+ }
+ body = append(body, '\n')
+
+ f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
+ if err != nil {
+ return fmt.Errorf("open triggers: %w", err)
+ }
+ defer f.Close()
+ _, err = f.Write(body)
+ return err
+}
+
+// ReadTriggersSinceCursor returns events strictly after cursor.LastConsumedID
+// in append order. If LastConsumedID is empty, returns all events. Bad lines
+// are skipped.
+func ReadTriggersSinceCursor(path string, cursor TriggerCursor) ([]TriggerEvent, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil, nil
+ }
+ return nil, err
+ }
+ defer f.Close()
+
+ var all []TriggerEvent
+ scanner := bufio.NewScanner(f)
+ scanner.Buffer(make([]byte, 64*1024), 1024*1024)
+ for scanner.Scan() {
+ line := scanner.Bytes()
+ if len(line) == 0 {
+ continue
+ }
+ var ev TriggerEvent
+ if err := json.Unmarshal(line, &ev); err != nil {
+ continue
+ }
+ all = append(all, ev)
+ }
+ if err := scanner.Err(); err != nil {
+ return nil, err
+ }
+
+ if cursor.LastConsumedID == "" {
+ return all, nil
+ }
+ for i, ev := range all {
+ if ev.ID == cursor.LastConsumedID {
+ return all[i+1:], nil
+ }
+ }
+ // Cursor not found in current file; return all (cursor is stale).
+ return all, nil
+}
+
+// LoadCursor reads triggers_cursor.json. Missing file returns zero value.
+func LoadCursor(path string) (TriggerCursor, error) {
+ body, err := os.ReadFile(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return TriggerCursor{}, nil
+ }
+ return TriggerCursor{}, err
+ }
+ var c TriggerCursor
+ if err := json.Unmarshal(body, &c); err != nil {
+ return TriggerCursor{}, err
+ }
+ return c, nil
+}
+
+// SaveCursor atomically writes the cursor via temp + rename.
+func SaveCursor(path string, cursor TriggerCursor) error {
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ return err
+ }
+ body, err := json.MarshalIndent(cursor, "", " ")
+ if err != nil {
+ return err
+ }
+ tmp, err := os.CreateTemp(filepath.Dir(path), ".cursor-*.json")
+ if err != nil {
+ return err
+ }
+ tmpPath := tmp.Name()
+ defer os.Remove(tmpPath)
+ if _, err := tmp.Write(body); err != nil {
+ tmp.Close()
+ return err
+ }
+ if err := tmp.Close(); err != nil {
+ return err
+ }
+ return os.Rename(tmpPath, path)
+}
+```
+
+- [ ] **Step 7.4: Add concurrent test**
+
+Create `internal/architectureplanner/triggers_concurrent_test.go`:
+
+```go
+package architectureplanner
+
+import (
+ "path/filepath"
+ "sync"
+ "testing"
+)
+
+func TestAppendTriggerEvent_ConcurrentWritersAllSucceed(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "triggers.jsonl")
+ const N = 8
+ var wg sync.WaitGroup
+ errs := make(chan error, N)
+ for i := 0; i < N; i++ {
+ wg.Add(1)
+ go func(idx int) {
+ defer wg.Done()
+ err := AppendTriggerEvent(path, TriggerEvent{
+ Kind: "quarantine_added",
+ PhaseID: "p",
+ })
+ if err != nil {
+ errs <- err
+ }
+ }(i)
+ }
+ wg.Wait()
+ close(errs)
+ for err := range errs {
+ t.Fatalf("concurrent append error: %v", err)
+ }
+ all, err := ReadTriggersSinceCursor(path, TriggerCursor{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(all) != N {
+ t.Fatalf("expected %d events, got %d", N, len(all))
+ }
+ // Verify no two events share an ID.
+ seen := map[string]bool{}
+ for _, ev := range all {
+ if seen[ev.ID] {
+ t.Fatalf("duplicate ID: %s", ev.ID)
+ }
+ seen[ev.ID] = true
+ }
+}
+```
+
+- [ ] **Step 7.5: Add config fields**
+
+Modify `internal/architectureplanner/config.go`:
+
+```go
+type Config struct {
+ // ... existing fields ...
+ PlannerTriggersPath string // PLANNER_TRIGGERS_PATH
+ TriggersCursorPath string // not env-overridable; lives next to ledger
+}
+```
+
+In `ConfigFromEnv`, default `PlannerTriggersPath` to `filepath.Join(repoRoot, ".codex", "architecture-planner", "triggers.jsonl")` and `TriggersCursorPath` to `filepath.Join(cfg.RunRoot, "state", "triggers_cursor.json")`.
+
+- [ ] **Step 7.6: Wire trigger reading into RunOnce + prompt section**
+
+Modify `internal/architectureplanner/run.go::RunOnce`. Before building the prompt:
+
+```go
+cursor, _ := LoadCursor(cfg.TriggersCursorPath)
+triggerEvents, _ := ReadTriggersSinceCursor(cfg.PlannerTriggersPath, cursor)
+
+// Trigger source for the ledger.
+trigger := "scheduled"
+if len(triggerEvents) > 0 {
+ trigger = "event"
+}
+bundle.TriggerEvents = triggerEvents
+```
+
+Modify `internal/architectureplanner/context.go`:
+
+```go
+type ContextBundle struct {
+ // ... existing fields ...
+ TriggerEvents []TriggerEvent `json:"trigger_events,omitempty"`
+}
+```
+
+Modify `internal/architectureplanner/prompt.go::BuildPrompt` to render a trigger-events bullet section when `len(bundle.TriggerEvents) > 0`:
+
+```go
+func formatTriggerEvents(events []TriggerEvent) string {
+ if len(events) == 0 {
+ return ""
+ }
+ var b strings.Builder
+ b.WriteString("\n## Recent Autoloop Signals (Since Last Planner Run)\n\nThese rows changed state in autoloop and may need attention this run:\n\n")
+ for _, ev := range events {
+ fmt.Fprintf(&b, "- %s/%s/%s โ %s โ %s\n", ev.PhaseID, ev.SubphaseID, ev.ItemName, ev.Kind, ev.Reason)
+ }
+ return b.String()
+}
+```
+
+After `RunOnce` completes (success OR failure), advance the cursor:
+
+```go
+defer func() {
+ if len(triggerEvents) > 0 {
+ newCursor := TriggerCursor{
+ LastConsumedID: triggerEvents[len(triggerEvents)-1].ID,
+ LastReadAt: now.UTC().Format(time.RFC3339),
+ }
+ _ = SaveCursor(cfg.TriggersCursorPath, newCursor) // soft-fail
+ }
+}()
+```
+
+Update Task 4's ledger event population to use `trigger` and the consumed event IDs:
+
+```go
+event.Trigger = trigger
+for _, ev := range triggerEvents {
+ event.TriggerEvents = append(event.TriggerEvents, ev.ID)
+}
+```
+
+- [ ] **Step 7.7: Run + commit**
+
+```bash
+go test ./internal/architectureplanner/...
+go vet ./internal/architectureplanner/...
+gofmt -l internal/architectureplanner/
+```
+
+```bash
+git add internal/architectureplanner/triggers.go internal/architectureplanner/triggers_test.go internal/architectureplanner/triggers_concurrent_test.go internal/architectureplanner/config.go internal/architectureplanner/context.go internal/architectureplanner/prompt.go internal/architectureplanner/run.go
+git commit -m "feat(planner): consume autoloop trigger ledger via cursor"
+```
+
+---
+
+## Task 8: L2 systemd path unit
+
+**Files:**
+- Modify: `internal/architectureplanner/service.go`
+- Modify: `internal/architectureplanner/service_test.go`
+
+- [ ] **Step 8.1: Write failing tests for path unit rendering**
+
+Append to `internal/architectureplanner/service_test.go`:
+
+```go
+func TestRenderPlannerPathUnit_ContainsExpectedDirectives(t *testing.T) {
+ rendered := RenderPlannerPathUnit(PlannerPathUnitOptions{
+ Description: "Trigger Gormes architecture planner on autoloop signal",
+ PathToWatch: "/home/test/.codex/architecture-planner/triggers.jsonl",
+ ServiceUnit: "gormes-architecture-planner.service",
+ })
+ wants := []string{
+ "PathChanged=/home/test/.codex/architecture-planner/triggers.jsonl",
+ "TriggerLimitIntervalSec=60",
+ "TriggerLimitBurst=1",
+ "Unit=gormes-architecture-planner.service",
+ "WantedBy=default.target",
+ }
+ for _, w := range wants {
+ if !strings.Contains(rendered, w) {
+ t.Errorf("rendered unit missing %q\n%s", w, rendered)
+ }
+ }
+}
+
+func TestInstallPlannerService_WritesAllThreeUnits(t *testing.T) {
+ dir := t.TempDir()
+ opts := PlannerServiceInstallOptions{
+ Runner: fakeServiceRunner{},
+ UnitDir: dir,
+ UnitName: "gormes-architecture-planner.service",
+ TimerName: "gormes-architecture-planner.timer",
+ PathName: "gormes-architecture-planner.path",
+ PlannerPath: "/usr/local/bin/planner.sh",
+ WorkDir: "/repo",
+ }
+ if err := InstallPlannerService(context.Background(), opts); err != nil {
+ t.Fatal(err)
+ }
+ for _, name := range []string{
+ "gormes-architecture-planner.service",
+ "gormes-architecture-planner.timer",
+ "gormes-architecture-planner.path",
+ } {
+ if _, err := os.Stat(filepath.Join(dir, name)); err != nil {
+ t.Errorf("unit %s not written: %v", name, err)
+ }
+ }
+}
+```
+
+- [ ] **Step 8.2: Implement `RenderPlannerPathUnit` and extend install**
+
+In `internal/architectureplanner/service.go`:
+
+```go
+type PlannerPathUnitOptions struct {
+ Description string
+ PathToWatch string
+ ServiceUnit string
+}
+
+func RenderPlannerPathUnit(opts PlannerPathUnitOptions) string {
+ return fmt.Sprintf(`[Unit]
+Description=%s
+
+[Path]
+PathChanged=%s
+TriggerLimitIntervalSec=60
+TriggerLimitBurst=1
+Unit=%s
+
+[Install]
+WantedBy=default.target
+`, opts.Description, opts.PathToWatch, opts.ServiceUnit)
+}
+```
+
+Extend `PlannerServiceInstallOptions`:
+
+```go
+type PlannerServiceInstallOptions struct {
+ // ... existing fields ...
+ PathName string // e.g. "gormes-architecture-planner.path"; defaults if empty
+}
+```
+
+In `InstallPlannerService`, after writing the `.timer` file, also write the `.path` file. Use the `PlannerTriggersPath` from Config (or pass it via opts) to get the watched path.
+
+- [ ] **Step 8.3: Run + commit**
+
+```bash
+go test ./internal/architectureplanner/ -run TestRenderPlannerPath -v
+go test ./internal/architectureplanner/ -run TestInstallPlannerService -v
+go test ./internal/architectureplanner/...
+go vet ./internal/architectureplanner/...
+gofmt -l internal/architectureplanner/
+```
+
+```bash
+git add internal/architectureplanner/service.go internal/architectureplanner/service_test.go
+git commit -m "feat(planner): install path unit alongside timer for event triggers"
+```
+
+---
+
+## Task 9: L3 Retry-with-feedback
+
+**Files:**
+- Create: `internal/architectureplanner/retry.go`
+- Create: `internal/architectureplanner/retry_test.go`
+- Modify: `internal/architectureplanner/run.go`
+- Modify: `internal/architectureplanner/config.go`
+- Modify: `internal/architectureplanner/run_test.go`
+
+- [ ] **Step 9.1: Write failing tests**
+
+Create `internal/architectureplanner/retry_test.go`:
+
+```go
+package architectureplanner
+
+import (
+ "errors"
+ "strings"
+ "testing"
+
+ "github.com/TrebuchetDynamics/gormes-agent/internal/progress"
+)
+
+func TestRetryFeedback_NamesAllDroppedRows(t *testing.T) {
+ before := &progress.Progress{
+ Phases: map[string]*progress.Phase{
+ "1": {Name: "P", Subphases: map[string]*progress.Subphase{
+ "1.A": {Name: "S", Items: []progress.Item{
+ {Name: "row-x", Health: &progress.RowHealth{AttemptCount: 3}},
+ {Name: "row-y", Health: &progress.RowHealth{AttemptCount: 2}},
+ }},
+ }},
+ },
+ }
+ after := &progress.Progress{
+ Phases: map[string]*progress.Phase{
+ "1": {Name: "P", Subphases: map[string]*progress.Subphase{
+ "1.A": {Name: "S", Items: []progress.Item{
+ {Name: "row-x", Health: nil}, // dropped
+ {Name: "row-y", Health: &progress.RowHealth{AttemptCount: 2}},
+ }},
+ }},
+ },
+ }
+ feedback := RetryFeedback(errors.New("validation error"), before, after)
+ if !strings.Contains(feedback, "1/1.A/row-x") {
+ t.Fatalf("feedback should name dropped row, got:\n%s", feedback)
+ }
+ if !strings.Contains(feedback, "HEALTH BLOCK PRESERVATION") {
+ t.Fatal("feedback missing HARD RULE reference")
+ }
+}
+
+func TestExtractDroppedRows_FindsDroppedAndModified(t *testing.T) {
+ before := &progress.Progress{
+ Phases: map[string]*progress.Phase{
+ "1": {Name: "P", Subphases: map[string]*progress.Subphase{
+ "1.A": {Name: "S", Items: []progress.Item{
+ {Name: "row-x", Health: &progress.RowHealth{AttemptCount: 3}},
+ }},
+ }},
+ },
+ }
+ after := &progress.Progress{
+ Phases: map[string]*progress.Phase{
+ "1": {Name: "P", Subphases: map[string]*progress.Subphase{
+ "1.A": {Name: "S", Items: []progress.Item{
+ {Name: "row-x", Health: nil},
+ }},
+ }},
+ },
+ }
+ dropped := extractDroppedRows(before, after)
+ if len(dropped) != 1 || dropped[0] != "1/1.A/row-x" {
+ t.Fatalf("expected 1/1.A/row-x, got %v", dropped)
+ }
+}
+```
+
+- [ ] **Step 9.2: Implement `internal/architectureplanner/retry.go`**
+
+```go
+package architectureplanner
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/TrebuchetDynamics/gormes-agent/internal/progress"
+)
+
+const DefaultMaxRetries = 2
+
+// retryAttempt records one LLM call's full lifecycle for ledger forensics.
+type retryAttempt struct {
+ Index int `json:"index"`
+ Status string `json:"status"` // "ok" | "validation_rejected" | "backend_failed"
+ Detail string `json:"detail,omitempty"`
+ DroppedRows []string `json:"dropped_rows,omitempty"`
+}
+
+// RetryFeedback formats a one-paragraph correction prompt for the LLM after
+// validateHealthPreservation rejects a regen. Names the dropped rows
+// explicitly and references the HARD rule.
+func RetryFeedback(rejection error, beforeDoc, afterDoc *progress.Progress) string {
+ dropped := extractDroppedRows(beforeDoc, afterDoc)
+ var b strings.Builder
+ b.WriteString("\n\nRETRY: Your previous output dropped or modified the `health` block on the\nfollowing rows. Per the HEALTH BLOCK PRESERVATION (HARD RULE), you must\nreproduce every `health` block verbatim. Please regenerate the entire\nprogress.json output, this time preserving these rows' health metadata:\n\n")
+ for _, row := range dropped {
+ fmt.Fprintf(&b, "- %s\n", row)
+ }
+ b.WriteString("\nThe original task and quarantine priorities still apply. Do NOT re-do the\nupstream sync analysis or implementation inventory โ just produce a corrected\nprogress.json with the health blocks restored.\n")
+ return b.String()
+}
+
+// extractDroppedRows identifies rows whose Health block was dropped or
+// modified between before and after. Used by RetryFeedback and the ledger
+// retryAttempt forensics.
+func extractDroppedRows(beforeDoc, afterDoc *progress.Progress) []string {
+ var out []string
+ beforeIndex := indexItems(beforeDoc) // existing helper
+ afterIndex := indexItems(afterDoc)
+ for key, beforeItem := range beforeIndex {
+ afterItem, exists := afterIndex[key]
+ if !exists {
+ continue // intentional deletion is not a "dropped health"
+ }
+ if !healthEqual(beforeItem.Health, afterItem.Health) {
+ out = append(out, fmt.Sprintf("%s/%s/%s", key.phaseID, key.subphaseID, key.itemName))
+ }
+ }
+ return out
+}
+```
+
+- [ ] **Step 9.3: Add `MaxRetries` to Config**
+
+```go
+type Config struct {
+ // ... existing ...
+ MaxRetries int // PLANNER_MAX_RETRIES; default 2
+}
+```
+
+In `ConfigFromEnv`, default and env-override.
+
+- [ ] **Step 9.4: Wire retry loop into RunOnce**
+
+Refactor the existing single-call backend invocation in `RunOnce` into a retry loop:
+
+```go
+maxRetries := cfg.MaxRetries
+prompt := initialPrompt
+attempts := []retryAttempt{}
+var afterDoc *progress.Progress
+
+for i := 0; i <= maxRetries; i++ {
+ result, err := runner.Run(ctx, autoloop.Command{
+ Name: argv[0], Args: append(argv[1:], prompt), Dir: cfg.RepoRoot,
+ })
+ attempt := retryAttempt{Index: i}
+ if err != nil {
+ attempt.Status = "backend_failed"
+ attempt.Detail = err.Error()
+ attempts = append(attempts, attempt)
+ // Backend failure is not retried; ledger emission + return below.
+ return /* with ledger entry status="backend_failed" */
+ }
+ afterDoc, _ = loadProgressForValidation(cfg.ProgressJSON)
+ if err := validateHealthPreservation(beforeDoc, afterDoc); err != nil {
+ attempt.Status = "validation_rejected"
+ attempt.Detail = err.Error()
+ attempt.DroppedRows = extractDroppedRows(beforeDoc, afterDoc)
+ attempts = append(attempts, attempt)
+ if i == maxRetries {
+ return /* with ledger entry status="validation_rejected", attempts populated */
+ }
+ prompt = initialPrompt + RetryFeedback(err, beforeDoc, afterDoc)
+ continue
+ }
+ attempt.Status = "ok"
+ attempts = append(attempts, attempt)
+ break
+}
+
+event.RetryAttempt = attempts[len(attempts)-1].Index
+// ledger entry's existing fields populated as before; attempts captured in
+// LedgerEvent.Detail or a new field if you want full forensics
+```
+
+> The plan deliberately leaves the exact placement of `attempts` in the LedgerEvent to the implementer's discretion. Either add a new `Attempts []retryAttempt` field on LedgerEvent (cleanest), or serialize them into the Detail string. Choose what's easiest given the existing run.go shape.
+
+- [ ] **Step 9.5: Run + commit**
+
+```bash
+go test ./internal/architectureplanner/...
+go vet ./internal/architectureplanner/...
+gofmt -l internal/architectureplanner/
+```
+
+```bash
+git add internal/architectureplanner/retry.go internal/architectureplanner/retry_test.go internal/architectureplanner/run.go internal/architectureplanner/config.go internal/architectureplanner/run_test.go
+git commit -m "feat(planner): retry with feedback on validation rejection"
+```
+
+---
+
+## Task 10: L4 Self-evaluation
+
+**Files:**
+- Create: `internal/architectureplanner/evaluation.go`
+- Create: `internal/architectureplanner/evaluation_test.go`
+- Modify: `internal/architectureplanner/context.go`
+- Modify: `internal/architectureplanner/prompt.go`
+- Modify: `internal/architectureplanner/prompt_test.go`
+- Modify: `internal/architectureplanner/run.go`
+- Modify: `internal/architectureplanner/config.go`
+
+- [ ] **Step 10.1: Write failing tests**
+
+Create `internal/architectureplanner/evaluation_test.go`:
+
+```go
+package architectureplanner
+
+import (
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func TestEvaluate_UnstuckRowDetected(t *testing.T) {
+ dir := t.TempDir()
+ plannerLedger := filepath.Join(dir, "planner.jsonl")
+ autoloopLedger := filepath.Join(dir, "autoloop.jsonl")
+
+ now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
+ reshapeTS := now.Add(-2 * time.Hour)
+
+ // Planner reshaped row.
+ _ = AppendLedgerEvent(plannerLedger, LedgerEvent{
+ TS: reshapeTS.Format(time.RFC3339), RunID: "planner-1", Status: "ok",
+ RowsChanged: []RowChange{{PhaseID: "2", SubphaseID: "2.B", ItemName: "row-1", Kind: "spec_changed"}},
+ })
+
+ // Autoloop later promoted the same row.
+ autoloopEvent := map[string]any{
+ "ts": now.Add(-1 * time.Hour).Format(time.RFC3339),
+ "event": "worker_promoted",
+ "task": "2/2.B/row-1",
+ "status": "promoted",
+ }
+ appendLineJSON(t, autoloopLedger, autoloopEvent)
+
+ outcomes, err := Evaluate(plannerLedger, autoloopLedger, 7*24*time.Hour, now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(outcomes) != 1 {
+ t.Fatalf("expected 1 outcome, got %d", len(outcomes))
+ }
+ if outcomes[0].Outcome != "unstuck" {
+ t.Fatalf("expected unstuck, got %q", outcomes[0].Outcome)
+ }
+}
+
+func TestEvaluate_StillFailingDetected(t *testing.T) {
+ dir := t.TempDir()
+ plannerLedger := filepath.Join(dir, "planner.jsonl")
+ autoloopLedger := filepath.Join(dir, "autoloop.jsonl")
+ now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
+ reshapeTS := now.Add(-2 * time.Hour)
+
+ _ = AppendLedgerEvent(plannerLedger, LedgerEvent{
+ TS: reshapeTS.Format(time.RFC3339), RunID: "planner-1", Status: "ok",
+ RowsChanged: []RowChange{{PhaseID: "2", SubphaseID: "2.B", ItemName: "row-1", Kind: "spec_changed"}},
+ })
+
+ for i := 0; i < 3; i++ {
+ appendLineJSON(t, autoloopLedger, map[string]any{
+ "ts": now.Add(-time.Duration(60-i*10) * time.Minute).Format(time.RFC3339),
+ "event": "worker_failed",
+ "task": "2/2.B/row-1",
+ "status": "failed",
+ })
+ }
+
+ outcomes, _ := Evaluate(plannerLedger, autoloopLedger, 7*24*time.Hour, now)
+ if outcomes[0].Outcome != "still_failing" {
+ t.Fatalf("expected still_failing, got %q", outcomes[0].Outcome)
+ }
+}
+
+func TestEvaluate_NoAttemptsYet(t *testing.T) {
+ dir := t.TempDir()
+ plannerLedger := filepath.Join(dir, "planner.jsonl")
+ autoloopLedger := filepath.Join(dir, "autoloop.jsonl")
+ now := time.Now().UTC()
+
+ _ = AppendLedgerEvent(plannerLedger, LedgerEvent{
+ TS: now.Add(-time.Hour).Format(time.RFC3339), RunID: "planner-1", Status: "ok",
+ RowsChanged: []RowChange{{PhaseID: "2", SubphaseID: "2.B", ItemName: "row-1", Kind: "spec_changed"}},
+ })
+ // No autoloop ledger entries.
+
+ outcomes, _ := Evaluate(plannerLedger, autoloopLedger, 7*24*time.Hour, now)
+ if outcomes[0].Outcome != "no_attempts_yet" {
+ t.Fatalf("expected no_attempts_yet, got %q", outcomes[0].Outcome)
+ }
+}
+
+// appendLineJSON appends one JSON-encoded map to an autoloop-style ledger.
+// The autoloop ledger schema differs from the planner's; we use the same
+// O_APPEND pattern for compatibility.
+func appendLineJSON(t *testing.T, path string, obj map[string]any) {
+ t.Helper()
+ body, err := json.Marshal(obj)
+ if err != nil {
+ t.Fatal(err)
+ }
+ body = append(body, '\n')
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer f.Close()
+ if _, err := f.Write(body); err != nil {
+ t.Fatal(err)
+ }
+}
+```
+
+- [ ] **Step 10.2: Implement `internal/architectureplanner/evaluation.go`**
+
+```go
+package architectureplanner
+
+import (
+ "bufio"
+ "encoding/json"
+ "fmt"
+ "os"
+ "strings"
+ "time"
+)
+
+const DefaultEvaluationWindow = 7 * 24 * time.Hour
+
+// ReshapeOutcome correlates one planner-recorded RowChange{Kind:"spec_changed"}
+// with what autoloop did to that row in subsequent runs.
+type ReshapeOutcome struct {
+ PhaseID string `json:"phase_id"`
+ SubphaseID string `json:"subphase_id"`
+ ItemName string `json:"item_name"`
+ ReshapedAt string `json:"reshaped_at"`
+ ReshapedBy string `json:"reshaped_by"`
+ Outcome string `json:"outcome"` // "unstuck" | "still_failing" | "no_attempts_yet"
+ AutoloopRuns int `json:"autoloop_runs"`
+ LastFailure string `json:"last_failure,omitempty"`
+ LastSuccess string `json:"last_success,omitempty"`
+ StaleClearObserved bool `json:"stale_clear_observed"`
+}
+
+// Evaluate walks the planner ledger window, collects every row reshape, and
+// correlates each with the autoloop ledger to determine the outcome.
+func Evaluate(plannerLedgerPath, autoloopLedgerPath string, window time.Duration, now time.Time) ([]ReshapeOutcome, error) {
+ plannerEvents, err := LoadLedgerWindow(plannerLedgerPath, window, now)
+ if err != nil {
+ return nil, fmt.Errorf("evaluate: load planner ledger: %w", err)
+ }
+
+ autoloopEvents, err := loadAutoloopLedgerLite(autoloopLedgerPath, window, now)
+ if err != nil {
+ // Don't fail evaluation if autoloop ledger is missing; treat as no attempts.
+ autoloopEvents = nil
+ }
+
+ type latestReshape struct {
+ event LedgerEvent
+ change RowChange
+ }
+ latest := map[string]latestReshape{}
+ for _, ev := range plannerEvents {
+ for _, rc := range ev.RowsChanged {
+ if rc.Kind != "spec_changed" {
+ continue
+ }
+ key := rc.PhaseID + "/" + rc.SubphaseID + "/" + rc.ItemName
+ latest[key] = latestReshape{event: ev, change: rc}
+ }
+ }
+
+ var out []ReshapeOutcome
+ for key, reshape := range latest {
+ reshapeTS, err := time.Parse(time.RFC3339, reshape.event.TS)
+ if err != nil {
+ continue
+ }
+ taskKey := reshape.change.PhaseID + "/" + reshape.change.SubphaseID + "/" + reshape.change.ItemName
+ outcome := classifyOutcome(taskKey, reshapeTS, autoloopEvents)
+ _ = key
+ out = append(out, ReshapeOutcome{
+ PhaseID: reshape.change.PhaseID,
+ SubphaseID: reshape.change.SubphaseID,
+ ItemName: reshape.change.ItemName,
+ ReshapedAt: reshape.event.TS,
+ ReshapedBy: reshape.event.RunID,
+ Outcome: outcome.kind,
+ AutoloopRuns: outcome.runs,
+ LastFailure: outcome.lastFailure,
+ LastSuccess: outcome.lastSuccess,
+ StaleClearObserved: outcome.staleClearObserved,
+ })
+ }
+ return out, nil
+}
+
+type autoloopEventLite struct {
+ TS string `json:"ts"`
+ Event string `json:"event"`
+ Task string `json:"task"`
+ Status string `json:"status"`
+}
+
+type outcomeClass struct {
+ kind string
+ runs int
+ lastFailure string
+ lastSuccess string
+ staleClearObserved bool
+}
+
+func classifyOutcome(taskKey string, reshapeTS time.Time, events []autoloopEventLite) outcomeClass {
+ var runs int
+ var lastFailure, lastSuccess string
+ var staleClear bool
+ var promoted bool
+ for _, ev := range events {
+ if !taskMatches(ev.Task, taskKey) {
+ continue
+ }
+ evTS, err := time.Parse(time.RFC3339, ev.TS)
+ if err != nil || !evTS.After(reshapeTS) {
+ continue
+ }
+ runs++
+ switch ev.Event {
+ case "worker_promoted":
+ promoted = true
+ lastSuccess = ev.TS
+ case "worker_failed", "worker_error":
+ lastFailure = ev.Status
+ case "backend_degraded":
+ // not row-level; ignore
+ }
+ if ev.Status == "stale_quarantine_cleared" || ev.Event == "quarantine_stale_cleared" {
+ staleClear = true
+ }
+ }
+ if promoted {
+ return outcomeClass{kind: "unstuck", runs: runs, lastSuccess: lastSuccess, staleClearObserved: staleClear}
+ }
+ if runs > 0 {
+ return outcomeClass{kind: "still_failing", runs: runs, lastFailure: lastFailure, staleClearObserved: staleClear}
+ }
+ return outcomeClass{kind: "no_attempts_yet"}
+}
+
+func taskMatches(autoloopTask, taskKey string) bool {
+ // Autoloop's "task" field encodes "phase/subphase/item" or similar.
+ // Match exact OR contains.
+ if autoloopTask == taskKey {
+ return true
+ }
+ return strings.Contains(autoloopTask, taskKey)
+}
+
+// loadAutoloopLedgerLite reads autoloop's runs.jsonl, decoding only the
+// fields evaluation cares about. Schema differences from the planner ledger
+// are tolerated (unknown fields are ignored by encoding/json).
+func loadAutoloopLedgerLite(path string, window time.Duration, now time.Time) ([]autoloopEventLite, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+ cutoff := now.Add(-window)
+ var out []autoloopEventLite
+ scanner := bufio.NewScanner(f)
+ scanner.Buffer(make([]byte, 64*1024), 1024*1024)
+ for scanner.Scan() {
+ line := scanner.Bytes()
+ if len(line) == 0 {
+ continue
+ }
+ var ev autoloopEventLite
+ if err := json.Unmarshal(line, &ev); err != nil {
+ continue
+ }
+ t, err := time.Parse(time.RFC3339, ev.TS)
+ if err != nil {
+ continue
+ }
+ if t.Before(cutoff) {
+ continue
+ }
+ out = append(out, ev)
+ }
+ return out, nil
+}
+```
+
+- [ ] **Step 10.3: Wire into ContextBundle and BuildPrompt**
+
+Modify `internal/architectureplanner/context.go`:
+
+```go
+type ContextBundle struct {
+ // ... existing ...
+ PreviousReshapes []ReshapeOutcome `json:"previous_reshapes,omitempty"`
+}
+```
+
+Modify `internal/architectureplanner/run.go::RunOnce`:
+
+```go
+outcomes, _ := Evaluate(
+ filepath.Join(cfg.RunRoot, "state", "runs.jsonl"),
+ filepath.Join(cfg.AutoloopRunRoot, "state", "runs.jsonl"),
+ cfg.EvaluationWindow,
+ now,
+)
+bundle.PreviousReshapes = outcomes
+```
+
+Add `Config.EvaluationWindow` (env `PLANNER_EVALUATION_WINDOW`, default `7*24*time.Hour`).
+
+Modify `internal/architectureplanner/prompt.go::BuildPrompt` to render a "Previous Reshape Outcomes" section when `bundle.PreviousReshapes` is non-empty, plus the `SELF-EVALUATION (SOFT RULE)` clause unconditionally:
+
+```go
+const selfEvaluationClause = `
+SELF-EVALUATION (SOFT RULE)
+
+The "Previous Reshape Outcomes" section reports what autoloop did with rows
+you reshaped in past runs. Use this signal:
+ - UNSTUCK rows confirm your previous approach worked
+ - STILL FAILING rows have resisted reshape โ try a different decomposition,
+ escalate to "needs_human" via PlannerVerdict (L5), or tighten ready_when
+ - NO ATTEMPTS YET rows may be legitimately blocked
+`
+
+func formatPreviousReshapes(outcomes []ReshapeOutcome) string {
+ if len(outcomes) == 0 {
+ return ""
+ }
+ // Bucket by outcome.
+ var unstuck, still, none []ReshapeOutcome
+ for _, o := range outcomes {
+ switch o.Outcome {
+ case "unstuck":
+ unstuck = append(unstuck, o)
+ case "still_failing":
+ still = append(still, o)
+ default:
+ none = append(none, o)
+ }
+ }
+ var b strings.Builder
+ b.WriteString("\n## Previous Reshape Outcomes (Last 7 Days)\n\n")
+ if len(unstuck) > 0 {
+ fmt.Fprintf(&b, "UNSTUCK (%d):\n", len(unstuck))
+ for _, o := range unstuck {
+ fmt.Fprintf(&b, "- %s/%s/%s โ reshaped %s by %s; autoloop promoted %s\n",
+ o.PhaseID, o.SubphaseID, o.ItemName, o.ReshapedAt, o.ReshapedBy, o.LastSuccess)
+ }
+ }
+ if len(still) > 0 {
+ fmt.Fprintf(&b, "\nSTILL FAILING (%d):\n", len(still))
+ for _, o := range still {
+ fmt.Fprintf(&b, "- %s/%s/%s โ reshaped %s by %s; autoloop attempted %d times, last category: %s\n",
+ o.PhaseID, o.SubphaseID, o.ItemName, o.ReshapedAt, o.ReshapedBy, o.AutoloopRuns, o.LastFailure)
+ }
+ }
+ if len(none) > 0 {
+ fmt.Fprintf(&b, "\nNO ATTEMPTS YET (%d):\n", len(none))
+ for _, o := range none {
+ fmt.Fprintf(&b, "- %s/%s/%s โ reshaped %s by %s; autoloop has not selected this row since\n",
+ o.PhaseID, o.SubphaseID, o.ItemName, o.ReshapedAt, o.ReshapedBy)
+ }
+ }
+ return b.String()
+}
+```
+
+In BuildPrompt, append both `selfEvaluationClause` and `formatPreviousReshapes(bundle.PreviousReshapes)` to the existing template.
+
+Add prompt tests for the new section.
+
+- [ ] **Step 10.4: Run + commit**
+
+```bash
+go test ./internal/architectureplanner/...
+go vet ./internal/architectureplanner/...
+gofmt -l internal/architectureplanner/
+```
+
+```bash
+git add internal/architectureplanner/evaluation.go internal/architectureplanner/evaluation_test.go internal/architectureplanner/context.go internal/architectureplanner/prompt.go internal/architectureplanner/prompt_test.go internal/architectureplanner/run.go internal/architectureplanner/config.go
+git commit -m "feat(planner): self-evaluation correlates planner ledger with autoloop"
+```
+
+---
+
+## Task 11: L5 PlannerVerdict Stamping (Planner Side)
+
+**Files:**
+- Create: `internal/architectureplanner/verdict.go`
+- Create: `internal/architectureplanner/verdict_test.go`
+- Modify: `internal/architectureplanner/run.go`
+- Modify: `internal/architectureplanner/config.go`
+
+- [ ] **Step 11.1: Write failing tests**
+
+Create `internal/architectureplanner/verdict_test.go`:
+
+```go
+package architectureplanner
+
+import (
+ "testing"
+ "time"
+
+ "github.com/TrebuchetDynamics/gormes-agent/internal/progress"
+)
+
+func TestStampVerdicts_IncrementsReshapeCount(t *testing.T) {
+ doc := &progress.Progress{
+ Phases: map[string]*progress.Phase{
+ "1": {Name: "P", Subphases: map[string]*progress.Subphase{
+ "1.A": {Name: "S", Items: []progress.Item{
+ {Name: "row-x", Contract: "c"},
+ }},
+ }},
+ },
+ }
+ rowsChanged := []RowChange{{PhaseID: "1", SubphaseID: "1.A", ItemName: "row-x", Kind: "spec_changed"}}
+ now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
+ StampVerdicts(doc, rowsChanged, nil, 3, now)
+ row := &doc.Phases["1"].Subphases["1.A"].Items[0]
+ if row.PlannerVerdict == nil || row.PlannerVerdict.ReshapeCount != 1 {
+ t.Fatalf("ReshapeCount expected 1, got %+v", row.PlannerVerdict)
+ }
+ if row.PlannerVerdict.LastReshape != now.Format(time.RFC3339) {
+ t.Fatal("LastReshape should be set to now")
+ }
+}
+
+func TestStampVerdicts_SetsNeedsHumanWhenThresholdReachedAndStillFailing(t *testing.T) {
+ doc := &progress.Progress{
+ Phases: map[string]*progress.Phase{
+ "1": {Name: "P", Subphases: map[string]*progress.Subphase{
+ "1.A": {Name: "S", Items: []progress.Item{
+ {Name: "row-x", Contract: "c", PlannerVerdict: &progress.PlannerVerdict{ReshapeCount: 2}},
+ }},
+ }},
+ },
+ }
+ rowsChanged := []RowChange{{PhaseID: "1", SubphaseID: "1.A", ItemName: "row-x", Kind: "spec_changed"}}
+ outcomes := []ReshapeOutcome{
+ {PhaseID: "1", SubphaseID: "1.A", ItemName: "row-x", Outcome: "still_failing", LastFailure: "report_validation_failed"},
+ }
+ now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
+ StampVerdicts(doc, rowsChanged, outcomes, 3, now)
+ row := &doc.Phases["1"].Subphases["1.A"].Items[0]
+ if !row.PlannerVerdict.NeedsHuman {
+ t.Fatal("NeedsHuman should be set after threshold")
+ }
+ if row.PlannerVerdict.Reason == "" {
+ t.Fatal("Reason should be set")
+ }
+}
+
+func TestStampVerdicts_NeedsHumanIsSticky(t *testing.T) {
+ doc := &progress.Progress{
+ Phases: map[string]*progress.Phase{
+ "1": {Name: "P", Subphases: map[string]*progress.Subphase{
+ "1.A": {Name: "S", Items: []progress.Item{
+ {Name: "row-x", Contract: "c", PlannerVerdict: &progress.PlannerVerdict{
+ NeedsHuman: true, Reason: "original reason", ReshapeCount: 5,
+ }},
+ }},
+ }},
+ },
+ }
+ outcomes := []ReshapeOutcome{
+ {PhaseID: "1", SubphaseID: "1.A", ItemName: "row-x", Outcome: "unstuck", LastSuccess: "now"},
+ }
+ StampVerdicts(doc, nil, outcomes, 3, time.Now())
+ row := &doc.Phases["1"].Subphases["1.A"].Items[0]
+ if !row.PlannerVerdict.NeedsHuman {
+ t.Fatal("NeedsHuman must remain true (sticky)")
+ }
+ if row.PlannerVerdict.Reason != "original reason" {
+ t.Fatal("Reason should not be overwritten")
+ }
+ if row.PlannerVerdict.LastOutcome != "unstuck" {
+ t.Fatal("LastOutcome should be updated even when NeedsHuman is sticky")
+ }
+}
+
+func TestStampVerdicts_DoesNotSetNeedsHumanIfUnstuck(t *testing.T) {
+ doc := &progress.Progress{
+ Phases: map[string]*progress.Phase{
+ "1": {Name: "P", Subphases: map[string]*progress.Subphase{
+ "1.A": {Name: "S", Items: []progress.Item{
+ {Name: "row-x", Contract: "c", PlannerVerdict: &progress.PlannerVerdict{ReshapeCount: 10}},
+ }},
+ }},
+ },
+ }
+ outcomes := []ReshapeOutcome{
+ {PhaseID: "1", SubphaseID: "1.A", ItemName: "row-x", Outcome: "unstuck"},
+ }
+ StampVerdicts(doc, nil, outcomes, 3, time.Now())
+ row := &doc.Phases["1"].Subphases["1.A"].Items[0]
+ if row.PlannerVerdict.NeedsHuman {
+ t.Fatal("unstuck row should NOT trigger NeedsHuman regardless of ReshapeCount")
+ }
+}
+
+func TestStampVerdicts_ReturnsVerdictChangesForLedger(t *testing.T) {
+ doc := &progress.Progress{
+ Phases: map[string]*progress.Phase{
+ "1": {Name: "P", Subphases: map[string]*progress.Subphase{
+ "1.A": {Name: "S", Items: []progress.Item{
+ {Name: "row-x", Contract: "c"},
+ }},
+ }},
+ },
+ }
+ rowsChanged := []RowChange{{PhaseID: "1", SubphaseID: "1.A", ItemName: "row-x", Kind: "spec_changed"}}
+ changes := StampVerdicts(doc, rowsChanged, nil, 3, time.Now())
+ if len(changes) != 1 || changes[0].Kind != "verdict_set" {
+ t.Fatalf("expected one verdict_set change, got %+v", changes)
+ }
+}
+```
+
+- [ ] **Step 11.2: Implement `internal/architectureplanner/verdict.go`**
+
+```go
+package architectureplanner
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/TrebuchetDynamics/gormes-agent/internal/progress"
+)
+
+const DefaultEscalationThreshold = 3
+
+// StampVerdicts applies deterministic PlannerVerdict updates to the after-doc.
+// Returns the list of rows whose verdict materially changed for ledger
+// emission as RowChange{Kind:"verdict_set"}.
+func StampVerdicts(afterDoc *progress.Progress, rowsChanged []RowChange, outcomes []ReshapeOutcome, threshold int, now time.Time) []RowChange {
+ if afterDoc == nil {
+ return nil
+ }
+ if threshold <= 0 {
+ threshold = DefaultEscalationThreshold
+ }
+
+ // Index rows for fast lookup.
+ idx := indexItems(afterDoc) // existing helper from Phase B
+ nowStr := now.UTC().Format(time.RFC3339)
+
+ var changed []RowChange
+
+ // Step 1: increment ReshapeCount for every reshaped row.
+ for _, rc := range rowsChanged {
+ if rc.Kind != "spec_changed" {
+ continue
+ }
+ key := itemKey{rc.PhaseID, rc.SubphaseID, rc.ItemName}
+ item, ok := idx[key]
+ if !ok {
+ continue
+ }
+ if item.PlannerVerdict == nil {
+ item.PlannerVerdict = &progress.PlannerVerdict{}
+ }
+ item.PlannerVerdict.ReshapeCount++
+ item.PlannerVerdict.LastReshape = nowStr
+ changed = append(changed, RowChange{
+ PhaseID: rc.PhaseID, SubphaseID: rc.SubphaseID, ItemName: rc.ItemName,
+ Kind: "verdict_set", Detail: "reshape_count incremented",
+ })
+ }
+
+ // Step 2: apply outcome-based updates.
+ for _, oc := range outcomes {
+ key := itemKey{oc.PhaseID, oc.SubphaseID, oc.ItemName}
+ item, ok := idx[key]
+ if !ok {
+ continue
+ }
+ if item.PlannerVerdict == nil {
+ item.PlannerVerdict = &progress.PlannerVerdict{}
+ }
+ v := item.PlannerVerdict
+ v.LastOutcome = oc.Outcome
+
+ // Sticky: do not auto-clear NeedsHuman.
+ if oc.Outcome == "still_failing" && !v.NeedsHuman && v.ReshapeCount >= threshold {
+ v.NeedsHuman = true
+ v.Reason = fmt.Sprintf("auto: %d reshapes without unsticking; last category %s", v.ReshapeCount, oc.LastFailure)
+ v.Since = nowStr
+ changed = append(changed, RowChange{
+ PhaseID: oc.PhaseID, SubphaseID: oc.SubphaseID, ItemName: oc.ItemName,
+ Kind: "verdict_set", Detail: "needs_human=true",
+ })
+ }
+ }
+
+ return changed
+}
+```
+
+- [ ] **Step 11.3: Add `EscalationThreshold` to Config**
+
+```go
+type Config struct {
+ // ... existing ...
+ EscalationThreshold int // PLANNER_ESCALATION_THRESHOLD; default 3
+}
+```
+
+In `ConfigFromEnv`, default and env-override.
+
+- [ ] **Step 11.4: Wire StampVerdicts into RunOnce**
+
+After `validateHealthPreservation` passes and BEFORE `SaveProgress`:
+
+```go
+verdictChanges := StampVerdicts(afterDoc, rowsChanged, outcomes, cfg.EscalationThreshold, now)
+event.RowsChanged = append(event.RowsChanged, verdictChanges...)
+// SaveProgress writes both the LLM regen AND the verdict stamps atomically.
+```
+
+- [ ] **Step 11.5: Run + commit**
+
+```bash
+go test ./internal/architectureplanner/...
+go vet ./internal/architectureplanner/...
+gofmt -l internal/architectureplanner/
+```
+
+```bash
+git add internal/architectureplanner/verdict.go internal/architectureplanner/verdict_test.go internal/architectureplanner/run.go internal/architectureplanner/config.go
+git commit -m "feat(planner): stamp PlannerVerdict after successful regeneration"
+```
+
+---
+
+## Task 12: L5 Autoloop Selection Skip + Status Surface
+
+**Files:**
+- Modify: `internal/autoloop/candidates.go`
+- Modify: `internal/autoloop/candidates_health_test.go`
+- Modify: `internal/autoloop/config.go`
+- Modify: `internal/autoloop/config_test.go`
+- Modify: `cmd/architecture-planner-loop/main.go`
+- Create: `internal/architectureplanner/status_test.go`
+
+- [ ] **Step 12.1: Write failing autoloop selection tests**
+
+Append to `internal/autoloop/candidates_health_test.go`:
+
+```go
+func TestNormalizeCandidates_NeedsHumanSkippedByDefault(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "progress.json")
+ writeHealthProgress(t, path, `{
+ "version": "1",
+ "phases": {
+ "1": {
+ "name": "P",
+ "subphases": {
+ "1.A": {
+ "name": "S",
+ "items": [
+ {"name": "row-a", "status": "planned", "contract": "do a", "contract_status": "draft",
+ "planner_verdict": {"needs_human": true, "reason": "auto", "since": "2026-04-25T10:00:00Z"}},
+ {"name": "row-b", "status": "planned", "contract": "do b", "contract_status": "draft"}
+ ]
+ }
+ }
+ }
+ }
+}
+`)
+ got, err := NormalizeCandidates(path, CandidateOptions{ActiveFirst: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 1 || got[0].ItemName != "row-b" {
+ t.Fatalf("expected only row-b, got %+v", got)
+ }
+}
+
+func TestNormalizeCandidates_IncludeNeedsHumanSurfacesAll(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "progress.json")
+ writeHealthProgress(t, path, `{
+ "version": "1",
+ "phases": {
+ "1": {
+ "name": "P",
+ "subphases": {
+ "1.A": {
+ "name": "S",
+ "items": [
+ {"name": "row-a", "status": "planned", "contract": "do a", "contract_status": "draft",
+ "planner_verdict": {"needs_human": true}}
+ ]
+ }
+ }
+ }
+ }
+}
+`)
+ got, err := NormalizeCandidates(path, CandidateOptions{ActiveFirst: true, IncludeNeedsHuman: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 1 {
+ t.Fatalf("expected 1 candidate, got %d", len(got))
+ }
+ if !got[0].NeedsHumanFlag {
+ t.Fatal("NeedsHumanFlag should be true")
+ }
+}
+```
+
+- [ ] **Step 12.2: Implement skip + flag in candidates.go**
+
+In `NormalizeCandidates`, after the existing quarantine filter (Phase B Task 5), add:
+
+```go
+if item.PlannerVerdict != nil && item.PlannerVerdict.NeedsHuman {
+ if !opts.IncludeNeedsHuman {
+ continue
+ }
+ candidate.NeedsHumanFlag = true
+}
+```
+
+Add `Candidate.NeedsHumanFlag bool` field.
+
+Add `CandidateOptions.IncludeNeedsHuman bool` field.
+
+Update `SelectionReason()` to append `" needs_human_visible"` when `NeedsHumanFlag`.
+
+In `internal/autoloop/config.go`, add `IncludeNeedsHuman` (env `GORMES_INCLUDE_NEEDS_HUMAN`, default `false`) and wire it into the call site that constructs `CandidateOptions` (`run.go`).
+
+- [ ] **Step 12.3: Implement status surface extension**
+
+Refactor `cmd/architecture-planner-loop/main.go::printStatus` so the bulk of the rendering logic lives in `internal/architectureplanner` and is testable. Move it to a new function:
+
+```go
+// internal/architectureplanner/status.go (or extend an existing file)
+
+// RenderStatus returns the multi-line operator-facing status string.
+// Combines current planner_state.json metadata + recent ledger outcomes +
+// NeedsHuman row inventory.
+func RenderStatus(opts RenderStatusOptions) (string, error) {
+ // 1. Read planner_state.json
+ // 2. Read planner ledger for last few entries (or pass evaluation outcomes)
+ // 3. Read progress.json to inventory NeedsHuman rows
+ // 4. Format per the spec
+}
+```
+
+Update `cmd/architecture-planner-loop/main.go::printStatus` to call `architectureplanner.RenderStatus` and write the result.
+
+Create `internal/architectureplanner/status_test.go`:
+
+```go
+package architectureplanner
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestRenderStatus_IncludesOutcomesAndNeedsHuman(t *testing.T) {
+ t.Skip("FILL IN: synthesize planner ledger + progress.json with NeedsHuman rows; assert render output contains: 'Reshape outcomes (last 7d):', 'unstuck:', 'still failing:', 'Rows needing human attention:', and per-row entries with reason + suggested action")
+}
+
+func TestSuggestedActionForCategory_TableDriven(t *testing.T) {
+ cases := []struct {
+ category string
+ want string
+ }{
+ {"report_validation_failed", "split into smaller rows or set contract_status=\"draft\""},
+ {"worker_error", "investigate infrastructure (backend or worktree state)"},
+ {"backend_degraded", "investigate infrastructure (backend or worktree state)"},
+ {"progress_summary_failed", "manual contract review โ autoloop preflight is failing"},
+ {"timeout", "split into smaller rows; the work is too large for the worker budget"},
+ {"", "manual review"},
+ {"unknown_category", "manual review"},
+ }
+ for _, c := range cases {
+ got := SuggestedActionForCategory(c.category)
+ if !strings.Contains(got, c.want) {
+ t.Errorf("SuggestedActionForCategory(%q) = %q, want substring %q", c.category, got, c.want)
+ }
+ }
+}
+```
+
+Implement `SuggestedActionForCategory` and the bulk of `RenderStatus` per the spec.
+
+- [ ] **Step 12.4: Run + commit**
+
+```bash
+go test ./internal/autoloop/...
+go test ./internal/architectureplanner/...
+go test ./cmd/architecture-planner-loop/...
+go vet ./...
+gofmt -l .
+```
+
+```bash
+git add internal/autoloop/candidates.go internal/autoloop/candidates_health_test.go internal/autoloop/config.go internal/autoloop/config_test.go internal/autoloop/run.go cmd/architecture-planner-loop/main.go internal/architectureplanner/status.go internal/architectureplanner/status_test.go
+git commit -m "feat(autoloop): skip needs_human rows; planner status surfaces them"
+```
+
+---
+
+## Task 13: End-To-End Lifecycle Test
+
+**Files:**
+- Create: `internal/architectureplanner/lifecycle_test.go`
+
+- [ ] **Step 13.1: Write the lifecycle test**
+
+Create `internal/architectureplanner/lifecycle_test.go`:
+
+```go
+package architectureplanner
+
+import (
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/TrebuchetDynamics/gormes-agent/internal/autoloop"
+ "github.com/TrebuchetDynamics/gormes-agent/internal/progress"
+)
+
+// TestLifecycle_PlannerSelfHealingFullLoop walks one row through the full
+// Phase C loop using real APIs (no LLM):
+//
+// Run 1 (autoloop): row-x fails 3 times โ quarantine โ trigger emitted
+// Run 2 (planner, event): trigger consumed; row-x reshape; verdict.ReshapeCount=1
+// Run 3 (autoloop): stale-quarantine flagged; row-x attempted; fails again โ
+// quarantine re-set; trigger emitted again
+// Run 4 (planner, event): L4 outcome=still_failing; verdict.ReshapeCount=2
+// Run 5 (autoloop): row-x fails again โ quarantine re-set
+// Run 6 (planner, event): verdict.ReshapeCount=3 โ NeedsHuman=true
+// Run 7 (autoloop): selection EXCLUDES row-x (NeedsHuman)
+//
+// Drives autoloop's accumulator and planner's StampVerdicts directly. The
+// LLM is mocked: each "planner run" simulates a successful regen by mutating
+// the row's contract (which advances ItemSpecHash) without dropping Health.
+func TestLifecycle_PlannerSelfHealingFullLoop(t *testing.T) {
+ t.Skip("FILL IN: scaffold this test using existing accumulator API + StampVerdicts; mock the planner's LLM by directly mutating the row's contract before each Save")
+}
+```
+
+> The lifecycle test is the most complex to scaffold. The implementer should look at Phase B's `internal/autoloop/lifecycle_test.go` for the pattern (driving the accumulator directly across simulated runs). Phase C extends that by also calling planner-side functions (`Evaluate`, `StampVerdicts`) between autoloop runs.
+
+The required test scenario MUST match the comment exactly. If scaffolding the test reveals a real composition bug across L1-L5, REPORT BLOCKED with specifics.
+
+- [ ] **Step 13.2: Run + commit**
+
+```bash
+go test ./internal/architectureplanner/ -run TestLifecycle -v
+go test ./internal/progress/... ./internal/autoloop/... ./internal/architectureplanner/... ./cmd/architecture-planner-loop/...
+go vet ./...
+gofmt -l .
+```
+
+```bash
+git add internal/architectureplanner/lifecycle_test.go
+git commit -m "test(planner): end-to-end planner self-healing lifecycle"
+```
+
+---
+
+## Self-Review Checklist
+
+### Spec coverage
+
+| Spec section | Implementing task |
+|---|---|
+| L1 Planner ledger | Task 3 (types + IO) + Task 4 (wire into RunOnce) |
+| L2 Event-driven trigger (autoloop side) | Task 6 |
+| L2 Event-driven trigger (planner side) | Task 7 |
+| L2 systemd path unit | Task 8 |
+| L3 Retry-with-feedback | Task 9 |
+| L4 Self-evaluation | Task 10 |
+| L5 PlannerVerdict schema | Task 1 |
+| L5 PlannerVerdict stamping | Task 11 |
+| L5 Autoloop selection skip + status | Task 12 |
+| L6 Topical focus | Task 5 |
+| Cross-cutting: symmetric preservation | Task 2 |
+| Cross-cutting: trigger ledger concurrency | Task 7 (concurrent test) |
+| Cross-cutting: backwards-compat round-trip with both blocks | Task 2 |
+| Cross-cutting: status end-to-end | Task 12 |
+| Cross-cutting: end-to-end lifecycle | Task 13 |
+
+### Placeholder scan
+
+- Tasks 4, 9, 12, 13 have deliberate `t.Skip("FILL IN: ...")` stubs because the existing planner test fixtures (`run_test.go`) and the lifecycle test require fixture-style scaffolding the implementer must follow rather than reinvent. The required test names and scenarios are pinned; only the exact fixture wiring is implementer discretion.
+- No `TBD`, `TODO`, "implement later" markers anywhere else.
+- Every task lists exact file paths.
+- Every code-changing step contains the actual code.
+- Every test step includes exact commands.
+
+### Type / API consistency
+
+Names cross-referenced across tasks:
+- `progress.PlannerVerdict` (Task 1) used in Tasks 11, 12
+- `progress.Item.PlannerVerdict` (Task 1) used in Tasks 2, 11, 12
+- `architectureplanner.LedgerEvent` (Task 3) used in Tasks 4, 9, 10
+- `architectureplanner.RowChange` (Task 3) used in Tasks 4, 11
+- `architectureplanner.ProgressStats` (Task 3) used in Task 4
+- `architectureplanner.AppendLedgerEvent` (Task 3) used in Tasks 4, 10
+- `architectureplanner.LoadLedgerWindow` (Task 3) used in Task 10
+- `architectureplanner.TriggerEvent` / `TriggerCursor` (Task 7) used in Tasks 6, 7
+- `architectureplanner.AppendTriggerEvent` (Task 7) called from autoloop in Task 6 (cross-package)
+- `architectureplanner.RetryFeedback` / `extractDroppedRows` / `retryAttempt` (Task 9)
+- `architectureplanner.Evaluate` / `ReshapeOutcome` (Task 10) used in Tasks 11, 12
+- `architectureplanner.StampVerdicts` (Task 11) used in Task 13
+- `architectureplanner.matchKeywordsInDoc` / `FilterContextByKeywords` (Task 5)
+- `architectureplanner.ContextBundle.QuarantinedRows` (Phase B), `.PreviousReshapes` (Task 10), `.TriggerEvents` (Task 7) โ all read by `BuildPrompt`
+- `autoloop.Candidate.NeedsHumanFlag` (Task 12)
+- `autoloop.CandidateOptions.IncludeNeedsHuman` (Task 12)
+- `autoloop.Config.IncludeNeedsHuman`, `.PlannerTriggersPath` (Tasks 6, 12)
+- `architectureplanner.Config.MaxRetries`, `.EvaluationWindow`, `.EscalationThreshold`, `.AutoloopRunRoot`, `.PlannerTriggersPath`, `.TriggersCursorPath` (Tasks 4, 7, 9, 10, 11)
+
+All names cross-reference correctly between tasks.
+
+### Cross-cutting concerns
+
+- **Import cycle risk:** Task 6 (autoloop emitting trigger events) imports `architectureplanner.TriggerEvent` and `AppendTriggerEvent`. Task 7 sets up the planner side. If autoloop importing planner creates a circular dependency (the planner already imports autoloop's `Runner` type and `BuildBackendCommand`), a new shared package `internal/plannertriggers` is needed. The plan flags this explicitly in Task 6 Step 6.5; the implementer must verify and pivot if needed.
+- **Atomic IO patterns:** `AppendLedgerEvent` and `AppendTriggerEvent` use the same `O_APPEND|O_CREATE|O_WRONLY` pattern Phase B already uses for autoloop's runs.jsonl. POSIX-atomic for lines under 4 KiB.
+- **Sticky `NeedsHuman`:** Spec invariant 3 (Section 2). Tested explicitly in Task 11.
+- **Symmetric preservation:** Spec invariant 1. Tested in Task 2.
diff --git a/docs/superpowers/specs/2026-04-24-planner-self-healing-design.md b/docs/superpowers/specs/2026-04-24-planner-self-healing-design.md
new file mode 100644
index 000000000..e1a74b3fb
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-24-planner-self-healing-design.md
@@ -0,0 +1,630 @@
+# Planner Self-Healing Design
+
+**Status:** Draft
+**Author:** Codex (Claude Opus 4.7 1M)
+**Date:** 2026-04-24
+
+## Context
+
+The architecture-planner-loop (`cmd/architecture-planner-loop`,
+`internal/architectureplanner`) refines `progress.json` to reflect upstream
+changes, current Gormes implementation reality, and (post-Phase B) autoloop's
+quarantined rows. The planner runs on a fixed 6-hour systemd timer; it has no
+event-driven cadence, no per-run history beyond "latest" artifacts, no retry
+mechanism when its output is rejected, no self-evaluation of whether its
+reshapes actually unstuck rows, no escalation path for intractable rows, and
+no way to focus a run on a specific topic ("Honcho", "memory", "skills tools").
+
+Phase B added autoloop's `Health` block and the planner's preservation
+contract for it. Phase C closes the loop in the other direction: gives the
+planner reactivity, observability, retry resilience, self-feedback, escalation,
+and topical focus.
+
+Five recurring effectiveness gaps inform this design:
+
+1. **Latency.** A row quarantined at 03:01 is invisible to the planner until
+ the next 6h timer fires.
+2. **No retry on rejection.** When `validateHealthPreservation` rejects a
+ regen, the run is wasted; the next chance is 6h later.
+3. **No planner-side ledger.** Only "latest" artifacts exist, so the planner
+ cannot evaluate its own effectiveness.
+4. **No self-evaluation.** The planner reshapes a row and forgets โ it has no
+ feedback on whether autoloop later succeeded.
+5. **No human escalation.** Rows that resist N reshapes have no operational
+ signal pathway out.
+
+Plus one operator-facing gap surfaced during brainstorm:
+
+6. **No topical focus.** Operators cannot say "this run, focus only on
+ Honcho-related rows."
+
+## Goals
+
+1. React to autoloop quarantine events within minutes, not hours.
+2. Recover from validation rejections within a single planner run.
+3. Persist a queryable history of every planner run.
+4. Correlate planner reshapes with autoloop outcomes and feed the result back
+ into the next prompt.
+5. Mark intractable rows for human review with a clear, sticky signal.
+6. Let operators run topical planner passes via keyword arguments.
+7. Ship as five independently-shippable commits so each layer can be
+ reverted in isolation if it regresses.
+
+## Non-goals
+
+- External notification channels (Slack, GitHub issues, email) for
+ escalations. The status CLI surface is the only Phase C signal.
+- Adaptive planner cadence (the 6h timer stays; event-driven runs are
+ additive).
+- Coordination locks between planner/autoloop runs (Phase B atomic IO is
+ enough).
+- Preview-via-autoloop-dry-run before accepting planner output.
+- Live LLM tests against backends; all tests use mock runners.
+- Topical event triggers (autoloop emits ALL events; topical narrowing is
+ user-driven only).
+- Auto-unsetting `NeedsHuman` after K successful runs (sticky by design;
+ humans clear it explicitly).
+
+## Decision Summary
+
+The accepted design is **Planner Self-Healing** โ six layers added to the
+planner runtime, with small extensions to autoloop and the progress schema.
+
+```
+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+โ progress.json โ
+โ - Item.Health (autoloop owns; planner preserves) โ Phase B โ
+โ - Item.PlannerVerdict (planner owns; autoloop preserves) โ Phase C โ
+โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโ
+ โ planner reads/writes โ autoloop reads/writes
+ โผ โผ
+ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ โ architecture-planner-loop โ โ autoloop run loop โ
+ โ - reads Health (Phase B) โ โ - emits triggers.jsonl on โโโ
+ โ - writes PlannerVerdict โ โ quarantine_added / โ
+ โ - retry-on-rejection (L3) โโโโโโโโบโ โ quarantine_stale_cleared (L2)
+ โ - self-evaluation (L4) โ โ - skips PlannerVerdict. โ
+ โ - escalates after N reshapes (L5) โ โ NeedsHuman rows (L5) โ
+ โ - topical focus on keywords (L6) โ โ โ
+ โโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโ โโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโ
+ โ writes runs.jsonl โ โ writes runs.jsonl
+ โ (L1) โ โ
+ โผ โผ โผ
+ โโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโ
+ โ .codex/ โ โ .codex/ โ โ .codex/ โ
+ โ architecture-planner โ โ architecture-planner/ โ โ orchestrator/ โ
+ โ /state/runs.jsonl โ โ triggers.jsonl โโโโโโโดโโบโ state/runs.jsonl โ
+ โ (planner ledger, L1) โ โ (event queue, L2) โ (autoloop ledger) โ
+ โ โ โ + cursor.json โ โ
+ โโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโ
+ โฒ
+ โ systemd path unit watches mtime
+ โ โ fires planner.service immediately
+```
+
+Six layers, five commits (L6 ships with L1's commit since both are small and
+share no other dependencies):
+
+| Layer | What it adds | Touches | Commit |
+|---|---|---|---|
+| **L1 โ Planner ledger** | Per-run records: trigger, before-summary, after-diff, validation result, rows changed | new `internal/architectureplanner/ledger.go`, run.go | C1 |
+| **L2 โ Event trigger** | Autoloop appends `triggers.jsonl`; systemd path unit fires planner; cursor-based consumption | autoloop run.go (emit), new `triggers.go`, service.go (path unit) | C2 |
+| **L3 โ Retry-with-feedback** | Re-prompt LLM with explicit "you dropped row X" feedback up to N times | run.go, new `retry.go` | C3 |
+| **L4 โ Self-evaluation** | Each run correlates planner ledger โ autoloop ledger; outcomes feed next prompt | new `evaluation.go`, prompt.go | C4 |
+| **L5 โ `PlannerVerdict` + escalation** | New typed field; sticky `NeedsHuman` after N reshapes; autoloop selection skips it | `internal/progress/progress.go`, `internal/autoloop/candidates.go`, planner run.go, status command | C5 |
+| **L6 โ Topical focus** | Keyword arguments narrow planner context; LLM gets a topical clause | new `topics.go`, cmd/architecture-planner-loop, prompt.go | C1 (folded) |
+
+L1 is foundational. L2-L5 build on L1 but are otherwise independent.
+
+## Schema Additions
+
+### `PlannerVerdict` on `Item`
+
+```go
+// PlannerVerdict is execution-history metadata about one progress.json item,
+// OWNED by the architecture-planner runtime. Autoloop READS it (to skip
+// rows escalated for human review) and MUST preserve it verbatim across
+// writes (structural via typed JSON round-trip).
+//
+// Symmetric to RowHealth (autoloop-owned + planner-preserved).
+type PlannerVerdict struct {
+ NeedsHuman bool `json:"needs_human,omitempty"`
+ Reason string `json:"reason,omitempty"`
+ Since string `json:"since,omitempty"` // RFC3339
+ ReshapeCount int `json:"reshape_count,omitempty"`
+ LastReshape string `json:"last_reshape,omitempty"` // RFC3339
+ LastOutcome string `json:"last_outcome,omitempty"` // "unstuck" | "still_failing" | "no_attempts_yet"
+}
+
+type Item struct {
+ // ... existing fields preserved ...
+ Health *RowHealth `json:"health,omitempty"`
+ PlannerVerdict *PlannerVerdict `json:"planner_verdict,omitempty"`
+}
+```
+
+### Planner ledger entry
+
+`.codex/architecture-planner/state/runs.jsonl` โ one JSON object per line:
+
+```go
+type LedgerEvent struct {
+ TS string `json:"ts"` // RFC3339
+ RunID string `json:"run_id"`
+ Trigger string `json:"trigger"` // "scheduled" | "event" | "manual" | "retry"
+ TriggerEvents []string `json:"trigger_events,omitempty"`
+ Backend string `json:"backend"`
+ Mode string `json:"mode"`
+ Status string `json:"status"` // "ok" | "validation_rejected" | "backend_failed" | "no_changes" | "needs_human_set"
+ Detail string `json:"detail,omitempty"`
+ BeforeStats ProgressStats `json:"before_stats,omitempty"`
+ AfterStats ProgressStats `json:"after_stats,omitempty"`
+ RowsChanged []RowChange `json:"rows_changed,omitempty"`
+ RetryAttempt int `json:"retry_attempt,omitempty"`
+ Keywords []string `json:"keywords,omitempty"` // L6
+}
+
+type RowChange struct {
+ PhaseID string `json:"phase_id"`
+ SubphaseID string `json:"subphase_id"`
+ ItemName string `json:"item_name"`
+ Kind string `json:"kind"` // "added" | "deleted" | "spec_changed" | "verdict_set"
+ Detail string `json:"detail,omitempty"`
+}
+
+type ProgressStats struct {
+ Shipped int `json:"shipped"`
+ InProgress int `json:"in_progress"`
+ Planned int `json:"planned"`
+ Quarantined int `json:"quarantined"`
+ NeedsHuman int `json:"needs_human"`
+}
+```
+
+### Trigger ledger + cursor
+
+`.codex/architecture-planner/triggers.jsonl` (autoloop writes, planner reads):
+
+```go
+type TriggerEvent struct {
+ ID string `json:"id"`
+ TS string `json:"ts"`
+ Source string `json:"source"` // "autoloop"
+ Kind string `json:"kind"` // "quarantine_added" | "quarantine_stale_cleared" | "manual"
+ PhaseID string `json:"phase_id,omitempty"`
+ SubphaseID string `json:"subphase_id,omitempty"`
+ ItemName string `json:"item_name,omitempty"`
+ Reason string `json:"reason,omitempty"`
+ AutoloopRunID string `json:"autoloop_run_id,omitempty"`
+}
+
+// .codex/architecture-planner/state/triggers_cursor.json
+type TriggerCursor struct {
+ LastConsumedID string `json:"last_consumed_id"`
+ LastReadAt string `json:"last_read_at"`
+}
+```
+
+### Schema invariants
+
+1. **Symmetric ownership.** Autoloop never writes `PlannerVerdict`. Planner
+ never writes `Health`. Both blocks are preserved structurally via Phase B's
+ typed-struct round-trip.
+2. **`PlannerVerdict.ReshapeCount` is monotonic.** Resets only on row
+ delete or split.
+3. **`PlannerVerdict.NeedsHuman` is sticky.** Once set, only a human can
+ clear it (by editing `progress.json` directly). Planner never auto-unsets.
+4. **Both ledgers are append-only.** No deletion or rotation in Phase C.
+5. **Cursor is atomic-replaced** (temp + rename), matching `SaveProgress`.
+6. **Trigger events are idempotent on consumption.** Cursor advances after
+ processing; reprocessing the same event is harmless (planner's prompt
+ bullet list contains the row keys; LLM's reshape decision is naturally
+ idempotent).
+
+## L1 โ Planner ledger
+
+Establishes the ledger pattern. Reuses autoloop's existing
+`AppendLedgerEvent` IO contract (`O_APPEND|O_CREATE|O_WRONLY`).
+
+New file `internal/architectureplanner/ledger.go`:
+
+```go
+func AppendLedgerEvent(path string, event LedgerEvent) error
+func LoadLedger(path string) ([]LedgerEvent, error)
+func LoadLedgerWindow(path string, window time.Duration, now time.Time) ([]LedgerEvent, error)
+```
+
+Wire-in to `RunOnce`: after `runValidation` and before return, build a
+`LedgerEvent`, compute `BeforeStats`/`AfterStats` and `RowsChanged` via a new
+`diffRows(beforeDoc, afterDoc)` helper (reuses Phase B's `indexItems`),
+append to `.codex/architecture-planner/state/runs.jsonl`. Failure is
+soft-logged โ ledger is observability, not the run's success criterion.
+
+## L2 โ Event-driven trigger
+
+### Autoloop side: emit triggers
+
+In `internal/autoloop/health_writer.go::Flush`, after `progress.ApplyHealthUpdates`
+succeeds, classify each row's transition and emit:
+
+```go
+func (a *healthAccumulator) classifyForTrigger(before, after *progress.RowHealth, p *pendingHealth) (kind string, fire bool) {
+ if (before == nil || before.Quarantine == nil) && after != nil && after.Quarantine != nil {
+ return "quarantine_added", true
+ }
+ if before != nil && before.Quarantine != nil &&
+ after != nil && after.Quarantine == nil &&
+ p.staleClear {
+ return "quarantine_stale_cleared", true
+ }
+ return "", false
+}
+```
+
+The fire-list is appended to `triggers.jsonl` from the run.go `flushHealth`
+closure. `Config.PlannerTriggersPath` (env: `PLANNER_TRIGGERS_PATH`,
+default `.codex/architecture-planner/triggers.jsonl`).
+
+### Planner side: consume triggers
+
+New file `internal/architectureplanner/triggers.go`:
+
+```go
+func AppendTriggerEvent(path string, event TriggerEvent) error
+func ReadTriggersSinceCursor(path string, cursor TriggerCursor) ([]TriggerEvent, error)
+func LoadCursor(path string) (TriggerCursor, error)
+func SaveCursor(path string, cursor TriggerCursor) error
+```
+
+In `RunOnce`, before building the prompt: load cursor, read new events,
+thread into prompt as a "Recent Autoloop Signals" section. After `RunOnce`
+completes (success OR failure), advance cursor.
+
+### systemd: path unit fires planner
+
+New unit `gormes-architecture-planner.path`:
+
+```ini
+[Unit]
+Description=Trigger Gormes architecture planner on autoloop signal
+
+[Path]
+PathChanged=%h/.../.codex/architecture-planner/triggers.jsonl
+TriggerLimitIntervalSec=60
+TriggerLimitBurst=1
+Unit=gormes-architecture-planner.service
+
+[Install]
+WantedBy=default.target
+```
+
+Rate-limited to one trigger-driven run per 60s. The 6h timer stays as the
+deep-pass safety net.
+
+## L3 โ Retry-with-feedback
+
+When `validateHealthPreservation(beforeDoc, afterDoc)` rejects a regen, the
+planner re-prompts the same LLM up to N times with explicit feedback about
+the dropped rows. The strict validator from Phase B is unchanged; we retry
+around it.
+
+New file `internal/architectureplanner/retry.go`:
+
+```go
+const DefaultMaxRetries = 2
+
+func RetryFeedback(rejection error, beforeDoc, afterDoc *progress.Progress) string
+
+type retryAttempt struct {
+ Index int
+ Status string // "ok" | "validation_rejected" | "backend_failed"
+ Detail string
+ DroppedRows []string
+}
+```
+
+The retry feedback string names the dropped rows, references the HARD rule,
+and tells the LLM to skip the upstream-sync analysis on the retry (only fix
+the dropped blocks).
+
+`RunOnce` becomes a loop:
+
+```go
+for i := 0; i <= maxRetries; i++ {
+ invoke backend
+ load after-doc
+ validate
+ if accepted: break
+ if i < maxRetries: prompt = initialPrompt + "\n\n" + RetryFeedback(...)
+ else: fail run
+}
+```
+
+The L1 ledger entry's `RetryAttempt` field records the index of the
+successful (or final-failed) attempt. A new `attempts []retryAttempt` field
+captures the full sequence for forensics.
+
+`Config.MaxRetries` (env: `PLANNER_MAX_RETRIES`, default 2). Backend failure
+is NOT retried; only validation rejection.
+
+## L4 โ Self-evaluation
+
+Each planner run includes a "look back at my last K reshapes" pass that reads
+the planner ledger AND the autoloop ledger, correlates per-row, and reports
+outcomes. The outcomes feed the next planner prompt as observational signal.
+
+New file `internal/architectureplanner/evaluation.go`:
+
+```go
+const DefaultEvaluationWindow = 7 * 24 * time.Hour
+
+type ReshapeOutcome struct {
+ PhaseID string `json:"phase_id"`
+ SubphaseID string `json:"subphase_id"`
+ ItemName string `json:"item_name"`
+ ReshapedAt string `json:"reshaped_at"`
+ ReshapedBy string `json:"reshaped_by"`
+ Outcome string `json:"outcome"` // "unstuck" | "still_failing" | "no_attempts_yet"
+ AutoloopRuns int `json:"autoloop_runs"`
+ LastFailure string `json:"last_failure,omitempty"`
+ LastSuccess string `json:"last_success,omitempty"`
+ StaleClearObserved bool `json:"stale_clear_observed"`
+}
+
+func Evaluate(plannerLedgerPath, autoloopLedgerPath string, window time.Duration, now time.Time) ([]ReshapeOutcome, error)
+```
+
+Classification rule: for each `RowChange{Kind:"spec_changed"}` in the
+planner ledger window, walk autoloop ledger events for the same row AFTER
+the reshape TS. Promoted โ `unstuck`. Failed-after-promotion โ `still_failing`.
+No autoloop events โ `no_attempts_yet`.
+
+Wire-in to `RunOnce`: call `Evaluate` after `BeforeStats`, thread results
+into `ContextBundle.PreviousReshapes`, render via a new "Previous Reshape
+Outcomes" section in `BuildPrompt`. A new SOFT prompt clause tells the LLM
+how to use the signal:
+
+```text
+SELF-EVALUATION (SOFT RULE)
+
+The "Previous Reshape Outcomes" section reports what autoloop did with rows
+you reshaped in past runs. Use this signal:
+ - UNSTUCK rows confirm your previous approach worked
+ - STILL FAILING rows have resisted reshape โ try a different decomposition,
+ escalate to "needs_human" via PlannerVerdict (L5), or tighten ready_when
+ - NO ATTEMPTS YET rows may be legitimately blocked
+```
+
+`Config.EvaluationWindow` (env: `PLANNER_EVALUATION_WINDOW`, default `168h`).
+
+## L5 โ `PlannerVerdict` + escalation
+
+`PlannerVerdict` is written by a deterministic post-processing pass โ NOT
+by the LLM. This keeps verdict math out of the LLM's reasoning surface.
+
+New file `internal/architectureplanner/verdict.go`:
+
+```go
+const DefaultEscalationThreshold = 3
+
+func StampVerdicts(afterDoc *progress.Progress, rowsChanged []RowChange, outcomes []ReshapeOutcome, threshold int, now time.Time) []RowChange
+```
+
+Rules:
+- For every row in `rowsChanged{Kind:"spec_changed"}`: increment
+ `ReshapeCount`, set `LastReshape = now`.
+- For every row in `outcomes`:
+ - `unstuck` โ `LastOutcome = "unstuck"`; do NOT auto-clear `NeedsHuman`
+ (sticky).
+ - `still_failing` โ `LastOutcome = "still_failing"`; if
+ `ReshapeCount >= threshold` AND `!NeedsHuman`, set `NeedsHuman=true`
+ with `Reason`, `Since`.
+ - `no_attempts_yet` โ `LastOutcome = "no_attempts_yet"`.
+
+Returns the list of rows whose verdict materially changed (for L1 ledger
+emission as `RowChange{Kind:"verdict_set"}`).
+
+Wire-in to `RunOnce`: after validation passes, before `SaveProgress`. The
+single combined save writes BOTH the LLM regen AND the verdict stamps
+atomically.
+
+### Autoloop side: skip `NeedsHuman`
+
+In `NormalizeCandidates`, after the existing quarantine filter:
+
+```go
+if item.PlannerVerdict != nil && item.PlannerVerdict.NeedsHuman {
+ if !opts.IncludeNeedsHuman {
+ continue
+ }
+ candidate.NeedsHumanFlag = true
+}
+```
+
+`Config.IncludeNeedsHuman` (env: `GORMES_INCLUDE_NEEDS_HUMAN`, default
+`false`). Mirrors `IncludeQuarantined` from Phase B exactly.
+
+### Status surface
+
+Extend `architecture-planner-loop status` to print after the existing
+metadata lines:
+
+```
+Reshape outcomes (last 7d):
+ unstuck: 5
+ still failing: 2
+ no attempts yet: 1
+
+Rows needing human attention: 2
+ - 2/2.C/row-3 โ auto: 4 reshapes without unsticking; last category report_validation_failed
+ reshape count: 4 since: 2026-04-23T14:00:00Z
+ โ suggested action: split into smaller rows or set contract_status="draft"
+```
+
+Suggested action mapping by latest `Health.LastFailure.Category`:
+
+| Category | Suggested action |
+|---|---|
+| `report_validation_failed` | "split into smaller rows or set contract_status='draft'" |
+| `worker_error` / `backend_degraded` | "investigate infrastructure (backend or worktree state)" |
+| `progress_summary_failed` | "manual contract review โ autoloop preflight is failing" |
+| `timeout` | "split into smaller rows; the work is too large for the worker budget" |
+| (other / empty) | "manual review" |
+
+### How a human clears `NeedsHuman`
+
+Edit `progress.json` directly to remove or set false. Next planner run
+re-evaluates; if the row is still failing, the next run will re-set
+`NeedsHuman=true`. So a human's clear is "let me try one more time."
+
+`Config.EscalationThreshold` (env: `PLANNER_ESCALATION_THRESHOLD`,
+default 3).
+
+## L6 โ Topical focus mode
+
+Extend `cmd/architecture-planner-loop` to accept positional keyword
+arguments after `run`:
+
+```sh
+go run ./cmd/architecture-planner-loop run honcho
+go run ./cmd/architecture-planner-loop run memory skills
+go run ./cmd/architecture-planner-loop run --codexu "skills tools"
+```
+
+Multiple keywords โ OR semantics. Whitespace-quoted keywords get split.
+No keywords โ current full-pass behavior.
+
+New file `internal/architectureplanner/topics.go`:
+
+```go
+func MatchKeywords(items []ItemRef, keywords []string) []ItemRef
+func FilterContextByKeywords(bundle ContextBundle, keywords []string) ContextBundle
+```
+
+Mechanical narrowing rules (case-insensitive substring, OR across keywords):
+- `Item.Name`
+- `Item.Contract`
+- `Item.SourceRefs[]`
+- `Item.WriteScope[]`
+- `Item.Fixture`
+- `Subphase.Name` / `Phase.Name` (matching name brings ALL its items)
+
+`FilterContextByKeywords` narrows `QuarantinedRows`, `PreviousReshapes`,
+`Inventory`. Leaves `AutoloopAudit` and `SourceRoots` intact (audit is
+aggregate; sources are ground truth).
+
+A new prompt clause when keywords are present:
+
+```text
+TOPICAL FOCUS
+
+This run was invoked with keyword arguments: ["honcho", "memory"]. The
+context above (Quarantined Rows, Previous Reshapes, Implementation Inventory)
+has been narrowed to only rows that mechanically match these keywords.
+
+Focus your refinement work on these areas. You may still adjust adjacent
+rows if a topical row's blocked_by/unblocks dependencies require it, but
+do NOT widen the scope to unrelated phases.
+```
+
+Wire-in to `cmd/architecture-planner-loop/main.go`: extract positional
+keyword args from `run [flags] [keywords...]`. Thread into
+`RunOptions.Keywords []string`. In `RunOnce`, apply
+`FilterContextByKeywords(bundle, keywords)` and pass keywords to
+`BuildPrompt`.
+
+L1 ledger entry's `Keywords` field records the focus per run.
+
+L2 event-triggered runs carry NO keywords by default โ the trigger IS the
+focus signal.
+
+The `architecture-planner-loop status` output adds a `Keywords:` line when
+the most recent run was topical.
+
+## Testing
+
+Per-layer tests are enumerated within each layer's section in the
+brainstorm. Five cross-cutting test surfaces close interaction gaps:
+
+### 1. End-to-end planner-self-healing lifecycle (`internal/architectureplanner/lifecycle_test.go`, new)
+
+Walks one row through the full Phase C loop:
+
+```
+Run 1 (autoloop): 3 failures โ quarantine โ quarantine_added trigger
+Run 2 (planner, event): cursor advances; LLM reshapes; verdict.ReshapeCount=1
+Run 3 (autoloop): stale-quarantine flagged; row attempted; fails again โ
+ quarantine re-set
+Run 4 (planner, event): L4 outcome=still_failing; verdict.ReshapeCount=2
+Run 5 (autoloop): row fails again โ quarantine re-set
+Run 6 (planner, event): verdict.ReshapeCount=3 โ NeedsHuman=true;
+ ledger status=needs_human_set
+Run 7 (autoloop): selection EXCLUDES row (NeedsHuman=true)
+```
+
+Drives the full pipeline using fake runners. No real LLM. Sub-second runtime.
+
+### 2. Symmetric preservation regression (`internal/progress/preservation_test.go`, new)
+
+Tests both directions of the symmetric preservation contract in one place:
+autoloop's `ApplyHealthUpdates` preserves `PlannerVerdict`; `SaveProgress`
+after a verdict-only edit preserves `Health`. Final file has both blocks
+intact AND spec hash stable.
+
+### 3. Trigger ledger + cursor integrity under concurrency (`internal/architectureplanner/triggers_concurrent_test.go`, new)
+
+N=8 writer goroutines (autoloop processes) + M=2 reader goroutines (planner
+runs) operate on `triggers.jsonl` and the cursor simultaneously. All events
+land, no torn reads, no double-consumption.
+
+### 4. Backwards-compat round-trip with both blocks (extends Phase B's `health_compat_test.go`)
+
+Adds a row with both `Health.Quarantine` AND `PlannerVerdict.NeedsHuman` to
+the existing compat round-trip test. Verifies idempotency on the second
+SaveProgress pass.
+
+### 5. Status surface end-to-end (`internal/architectureplanner/status_test.go`, new)
+
+Synthesize planner ledger + autoloop ledger + progress.json with NeedsHuman
+rows. Invoke status. Assert outcome buckets, NeedsHuman entries with
+reason/since/reshape-count/suggested-action, optional `Keywords:` line.
+
+### Out of test scope
+
+- Live LLM tests against codexu/claudeu (cost + flake).
+- systemd path-unit firing (trust systemd).
+- Real-process integration test (race-prone scheduling, no value over
+ in-process simulation).
+- Performance benchmarks (low-volume in production).
+
+### CI
+
+All new tests run under standard `go test ./...`. No new CI configuration.
+~3-5 seconds added to existing suite.
+
+## Rollout Notes
+
+This redesign is intentionally additive across layers:
+
+- L1 introduces ledger but adds no behavioral change.
+- L2 emits triggers and reacts to them; the 6h timer stays as safety net.
+ Existing planner runs have `trigger: "scheduled"`; event-triggered runs
+ have `trigger: "event"`. No regression in existing behavior.
+- L3 retries on rejection, reducing wasted runs. If `MaxRetries=0`, behavior
+ is exactly pre-L3.
+- L4 evaluation feeds the prompt observationally; LLM's response is informed
+ but not controlled. If the planner ledger is empty (fresh install),
+ outcomes is empty and the prompt section is omitted.
+- L5 introduces `PlannerVerdict`. Autoloop's `ApplyHealthUpdates` preserves
+ it structurally (no code change required there beyond the selection-skip
+ rule).
+- L6 is purely additive: no keywords means current behavior.
+
+Each layer ships as one commit. The schema (L5) is the only layer with a
+migration concern, and `omitempty` keeps the migration trivial: untouched
+rows look identical.
+
+The headline metric expected to move: **planner-driven row recovery rate**
+(percentage of quarantined rows that a planner reshape unsticks within K
+autoloop runs). Today this is unmeasurable (no ledger). After Phase C, L4's
+outcomes ARE the measurement.
diff --git a/internal/apiserver/chat_completions_test.go b/internal/apiserver/chat_completions_test.go
new file mode 100644
index 000000000..b4dfe20b1
--- /dev/null
+++ b/internal/apiserver/chat_completions_test.go
@@ -0,0 +1,444 @@
+package apiserver
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/TrebuchetDynamics/gormes-agent/internal/hermes"
+ "github.com/TrebuchetDynamics/gormes-agent/internal/kernel"
+ "github.com/TrebuchetDynamics/gormes-agent/internal/store"
+ "github.com/TrebuchetDynamics/gormes-agent/internal/telemetry"
+)
+
+type fakeTurnLoop struct {
+ mu sync.Mutex
+ calls []TurnRequest
+ result TurnResult
+ err error
+ streamTokens []string
+ streamErr error
+}
+
+func (f *fakeTurnLoop) RunTurn(_ context.Context, req TurnRequest) (TurnResult, error) {
+ f.mu.Lock()
+ f.calls = append(f.calls, req)
+ f.mu.Unlock()
+ if f.err != nil {
+ return TurnResult{}, f.err
+ }
+ return f.result, nil
+}
+
+func (f *fakeTurnLoop) StreamTurn(_ context.Context, req TurnRequest, cb StreamCallbacks) (TurnResult, error) {
+ f.mu.Lock()
+ f.calls = append(f.calls, req)
+ f.mu.Unlock()
+ for _, token := range f.streamTokens {
+ if err := cb.OnToken(token); err != nil {
+ return TurnResult{}, err
+ }
+ }
+ if f.streamErr != nil {
+ return TurnResult{}, f.streamErr
+ }
+ if f.err != nil {
+ return TurnResult{}, f.err
+ }
+ return f.result, nil
+}
+
+func (f *fakeTurnLoop) callCount() int {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ return len(f.calls)
+}
+
+func (f *fakeTurnLoop) lastCall() TurnRequest {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ return f.calls[len(f.calls)-1]
+}
+
+func TestChatCompletions_RequiresBearerAuthAndUsesOpenAIErrorEnvelope(t *testing.T) {
+ loop := &fakeTurnLoop{}
+ srv := NewServer(Config{APIKey: "sk-secret", ModelName: "gormes-agent", Loop: loop})
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"gormes-agent","messages":[{"role":"user","content":"hi"}]}`))
+ req.Header.Set("Content-Type", "application/json")
+ srv.Handler().ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusUnauthorized {
+ t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusUnauthorized, rec.Body.String())
+ }
+ var body map[string]map[string]any
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatalf("error envelope JSON: %v", err)
+ }
+ if body["error"]["code"] != "invalid_api_key" {
+ t.Fatalf("error.code = %v, want invalid_api_key", body["error"]["code"])
+ }
+ if loop.callCount() != 0 {
+ t.Fatalf("turn loop calls = %d, want 0 for auth failure", loop.callCount())
+ }
+}
+
+func TestChatCompletions_RejectsOversizeBodyBeforeTurnLoop(t *testing.T) {
+ loop := &fakeTurnLoop{}
+ srv := NewServer(Config{MaxBodyBytes: 64, Loop: loop})
+ body := `{"model":"gormes-agent","messages":[{"role":"user","content":"` + strings.Repeat("x", 128) + `"}]}`
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ srv.Handler().ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusRequestEntityTooLarge {
+ t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusRequestEntityTooLarge, rec.Body.String())
+ }
+ var got struct {
+ Error struct {
+ Code string `json:"code"`
+ } `json:"error"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
+ t.Fatalf("error envelope JSON: %v", err)
+ }
+ if got.Error.Code != "body_too_large" {
+ t.Fatalf("error.code = %q, want body_too_large", got.Error.Code)
+ }
+ if loop.callCount() != 0 {
+ t.Fatalf("turn loop calls = %d, want 0 for body limit failure", loop.callCount())
+ }
+}
+
+func TestChatCompletions_NormalizesContentPartsForGatewayUserMessage(t *testing.T) {
+ loop := &fakeTurnLoop{result: TurnResult{Content: "ok", SessionID: "sess-normalized"}}
+ srv := NewServer(Config{ModelName: "gormes-agent", Loop: loop})
+
+ body := map[string]any{
+ "model": "gormes-agent",
+ "messages": []any{
+ map[string]any{"role": "system", "content": []any{
+ map[string]any{"type": "text", "text": "speak plainly"},
+ }},
+ map[string]any{"role": "user", "content": "first question"},
+ map[string]any{"role": "assistant", "content": "first answer"},
+ map[string]any{"role": "user", "content": []any{
+ map[string]any{"type": "text", "text": "inspect"},
+ map[string]any{"type": "input_text", "text": "the repo"},
+ map[string]any{"type": "image_url", "image_url": map[string]any{"url": "https://example.test/img.png"}},
+ "literal tail",
+ }},
+ },
+ }
+ rec := postJSON(t, srv.Handler(), "/v1/chat/completions", body, nil)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
+ }
+ call := loop.lastCall()
+ if call.SystemPrompt != "speak plainly" {
+ t.Fatalf("SystemPrompt = %q, want speak plainly", call.SystemPrompt)
+ }
+ if call.UserMessage != "inspect\nthe repo\nliteral tail" {
+ t.Fatalf("UserMessage = %q", call.UserMessage)
+ }
+ if len(call.History) != 2 {
+ t.Fatalf("len(History) = %d, want 2", len(call.History))
+ }
+ if call.History[0].Role != "user" || call.History[0].Content != "first question" {
+ t.Fatalf("History[0] = %+v", call.History[0])
+ }
+ if call.History[1].Role != "assistant" || call.History[1].Content != "first answer" {
+ t.Fatalf("History[1] = %+v", call.History[1])
+ }
+}
+
+func TestChatCompletions_ContentNormalizationFailureDoesNotStartTurn(t *testing.T) {
+ loop := &fakeTurnLoop{}
+ srv := NewServer(Config{ModelName: "gormes-agent", Loop: loop})
+
+ rec := postJSON(t, srv.Handler(), "/v1/chat/completions", map[string]any{
+ "model": "gormes-agent",
+ "messages": []any{
+ map[string]any{"role": "user", "content": []any{
+ map[string]any{"type": "input_file", "file_id": "file_123"},
+ }},
+ },
+ }, nil)
+
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
+ }
+ var got struct {
+ Error struct {
+ Code string `json:"code"`
+ Param string `json:"param"`
+ } `json:"error"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
+ t.Fatalf("error envelope JSON: %v", err)
+ }
+ if got.Error.Code != "unsupported_content_type" {
+ t.Fatalf("error.code = %q, want unsupported_content_type", got.Error.Code)
+ }
+ if got.Error.Param != "messages[0].content" {
+ t.Fatalf("error.param = %q, want messages[0].content", got.Error.Param)
+ }
+ if loop.callCount() != 0 {
+ t.Fatalf("turn loop calls = %d, want 0 for content-normalization failure", loop.callCount())
+ }
+}
+
+func TestChatCompletions_NonStreamingUsesNativeKernelAndReturnsSessionHeader(t *testing.T) {
+ mc := hermes.NewMockClient()
+ mc.Script([]hermes.Event{
+ {Kind: hermes.EventToken, Token: "Hello"},
+ {Kind: hermes.EventToken, Token: " from kernel"},
+ {Kind: hermes.EventDone, FinishReason: "stop", TokensIn: 3, TokensOut: 2},
+ }, "sess-native-1")
+ k := kernel.New(kernel.Config{
+ Model: "gormes-agent",
+ Endpoint: "http://mock",
+ Admission: kernel.Admission{MaxBytes: 200_000, MaxLines: 10_000},
+ }, mc, store.NewNoop(), telemetry.New(), slog.Default())
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ go func() { _ = k.Run(ctx) }()
+
+ srv := NewServer(Config{ModelName: "gormes-agent", Loop: NewKernelTurnLoop(k)})
+ rec := postJSON(t, srv.Handler(), "/v1/chat/completions", map[string]any{
+ "model": "gormes-agent",
+ "messages": []any{map[string]any{"role": "user", "content": "hello"}},
+ }, nil)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
+ }
+ if got := rec.Header().Get("X-Hermes-Session-Id"); got != "sess-native-1" {
+ t.Fatalf("X-Hermes-Session-Id = %q, want sess-native-1", got)
+ }
+ var got struct {
+ Object string `json:"object"`
+ Choices []struct {
+ Message struct {
+ Role string `json:"role"`
+ Content string `json:"content"`
+ } `json:"message"`
+ FinishReason string `json:"finish_reason"`
+ } `json:"choices"`
+ Usage struct {
+ PromptTokens int `json:"prompt_tokens"`
+ CompletionTokens int `json:"completion_tokens"`
+ TotalTokens int `json:"total_tokens"`
+ } `json:"usage"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
+ t.Fatalf("response JSON: %v", err)
+ }
+ if got.Object != "chat.completion" {
+ t.Fatalf("object = %q, want chat.completion", got.Object)
+ }
+ if got.Choices[0].Message.Role != "assistant" || got.Choices[0].Message.Content != "Hello from kernel" {
+ t.Fatalf("message = %+v", got.Choices[0].Message)
+ }
+ if got.Choices[0].FinishReason != "stop" {
+ t.Fatalf("finish_reason = %q, want stop", got.Choices[0].FinishReason)
+ }
+ if got.Usage.PromptTokens != 3 || got.Usage.CompletionTokens != 2 || got.Usage.TotalTokens != 5 {
+ t.Fatalf("usage = %+v, want 3/2/5", got.Usage)
+ }
+}
+
+func TestChatCompletions_SessionHeaderContinuesNativeKernelSession(t *testing.T) {
+ mc := hermes.NewMockClient()
+ mc.Script([]hermes.Event{{Kind: hermes.EventToken, Token: "one"}, {Kind: hermes.EventDone, FinishReason: "stop"}}, "sess-shared")
+ mc.Script([]hermes.Event{{Kind: hermes.EventToken, Token: "two"}, {Kind: hermes.EventDone, FinishReason: "stop"}}, "sess-shared")
+ k := kernel.New(kernel.Config{
+ Model: "gormes-agent",
+ Endpoint: "http://mock",
+ Admission: kernel.Admission{MaxBytes: 200_000, MaxLines: 10_000},
+ }, mc, store.NewNoop(), telemetry.New(), slog.Default())
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ go func() { _ = k.Run(ctx) }()
+
+ srv := NewServer(Config{ModelName: "gormes-agent", Loop: NewKernelTurnLoop(k)})
+ first := postJSON(t, srv.Handler(), "/v1/chat/completions", map[string]any{
+ "model": "gormes-agent",
+ "messages": []any{map[string]any{"role": "user", "content": "first"}},
+ }, nil)
+ if first.Code != http.StatusOK {
+ t.Fatalf("first status = %d, want 200; body=%s", first.Code, first.Body.String())
+ }
+ second := postJSON(t, srv.Handler(), "/v1/chat/completions", map[string]any{
+ "model": "gormes-agent",
+ "messages": []any{map[string]any{"role": "user", "content": "second"}},
+ }, map[string]string{"X-Hermes-Session-Id": "sess-shared"})
+ if second.Code != http.StatusOK {
+ t.Fatalf("second status = %d, want 200; body=%s", second.Code, second.Body.String())
+ }
+
+ requests := mc.Requests()
+ if len(requests) != 2 {
+ t.Fatalf("mock client request count = %d, want 2", len(requests))
+ }
+ if requests[1].SessionID != "sess-shared" {
+ t.Fatalf("second kernel request SessionID = %q, want sess-shared", requests[1].SessionID)
+ }
+ if got := second.Header().Get("X-Hermes-Session-Id"); got != "sess-shared" {
+ t.Fatalf("second X-Hermes-Session-Id = %q, want sess-shared", got)
+ }
+}
+
+func TestChatCompletions_NonStreamingTurnFailureUsesOpenAIErrorEnvelope(t *testing.T) {
+ loop := &fakeTurnLoop{err: errors.New("provider failed")}
+ srv := NewServer(Config{ModelName: "gormes-agent", Loop: loop})
+ rec := postJSON(t, srv.Handler(), "/v1/chat/completions", map[string]any{
+ "model": "gormes-agent",
+ "messages": []any{map[string]any{"role": "user", "content": "hello"}},
+ }, nil)
+
+ if rec.Code != http.StatusInternalServerError {
+ t.Fatalf("status = %d, want 500; body=%s", rec.Code, rec.Body.String())
+ }
+ var got struct {
+ Error struct {
+ Type string `json:"type"`
+ Code string `json:"code"`
+ } `json:"error"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
+ t.Fatalf("error envelope JSON: %v", err)
+ }
+ if got.Error.Type != "server_error" || got.Error.Code != "turn_failed" {
+ t.Fatalf("error = %+v, want server_error/turn_failed", got.Error)
+ }
+ if loop.callCount() != 1 {
+ t.Fatalf("turn loop calls = %d, want 1 for provider failure", loop.callCount())
+ }
+}
+
+func TestChatCompletions_StreamingReturnsOpenAIChunksAndSessionHeader(t *testing.T) {
+ loop := &fakeTurnLoop{
+ result: TurnResult{Content: "Hello stream", SessionID: "sess-stream"},
+ streamTokens: []string{"Hello", " stream"},
+ }
+ srv := NewServer(Config{ModelName: "gormes-agent", Loop: loop})
+ rec := postJSON(t, srv.Handler(), "/v1/chat/completions", map[string]any{
+ "model": "gormes-agent",
+ "stream": true,
+ "messages": []any{map[string]any{"role": "user", "content": "hello"}},
+ }, map[string]string{"X-Hermes-Session-Id": "sess-stream"})
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
+ }
+ if got := rec.Header().Get("Content-Type"); !strings.Contains(got, "text/event-stream") {
+ t.Fatalf("Content-Type = %q, want text/event-stream", got)
+ }
+ if got := rec.Header().Get("X-Hermes-Session-Id"); got != "sess-stream" {
+ t.Fatalf("X-Hermes-Session-Id = %q, want sess-stream", got)
+ }
+ body := rec.Body.String()
+ if !strings.Contains(body, `"object":"chat.completion.chunk"`) {
+ t.Fatalf("SSE body missing chat completion chunk: %s", body)
+ }
+ if !strings.Contains(body, `"role":"assistant"`) {
+ t.Fatalf("SSE body missing assistant role chunk: %s", body)
+ }
+ if !strings.Contains(body, `"content":"Hello"`) || !strings.Contains(body, `"content":" stream"`) {
+ t.Fatalf("SSE body missing streamed content chunks: %s", body)
+ }
+ if !strings.Contains(body, `"finish_reason":"stop"`) || !strings.Contains(body, "data: [DONE]") {
+ t.Fatalf("SSE body missing finish chunk or DONE sentinel: %s", body)
+ }
+}
+
+func TestChatCompletions_StreamingFailureUsesSSEErrorEnvelope(t *testing.T) {
+ loop := &fakeTurnLoop{streamErr: errors.New("provider stream failed")}
+ srv := NewServer(Config{ModelName: "gormes-agent", Loop: loop})
+ rec := postJSON(t, srv.Handler(), "/v1/chat/completions", map[string]any{
+ "model": "gormes-agent",
+ "stream": true,
+ "messages": []any{map[string]any{"role": "user", "content": "hello"}},
+ }, nil)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want streaming 200 with error event; body=%s", rec.Code, rec.Body.String())
+ }
+ body := rec.Body.String()
+ if !strings.Contains(body, "event: error") {
+ t.Fatalf("SSE body missing error event: %s", body)
+ }
+ if !strings.Contains(body, `"code":"stream_failed"`) || !strings.Contains(body, "provider stream failed") {
+ t.Fatalf("SSE body missing OpenAI error envelope: %s", body)
+ }
+ if !strings.Contains(body, "data: [DONE]") {
+ t.Fatalf("SSE body missing DONE sentinel after error: %s", body)
+ }
+}
+
+func TestHealthDoesNotRequireAuth(t *testing.T) {
+ srv := NewServer(Config{APIKey: "sk-secret", ModelName: "gormes-agent", Loop: &fakeTurnLoop{}})
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/health", nil)
+ srv.Handler().ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
+ }
+ var got struct {
+ Status string `json:"status"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
+ t.Fatalf("health JSON: %v", err)
+ }
+ if got.Status != "ok" {
+ t.Fatalf("status = %q, want ok", got.Status)
+ }
+}
+
+func postJSON(t *testing.T, h http.Handler, path string, body any, headers map[string]string) *httptest.ResponseRecorder {
+ t.Helper()
+ raw, err := json.Marshal(body)
+ if err != nil {
+ t.Fatalf("marshal request body: %v", err)
+ }
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(raw))
+ req.Header.Set("Content-Type", "application/json")
+ for k, v := range headers {
+ req.Header.Set(k, v)
+ }
+ h.ServeHTTP(rec, req)
+ _, _ = io.Copy(io.Discard, rec.Result().Body)
+ return rec
+}
+
+func waitForKernelIdle(t *testing.T, frames <-chan kernel.RenderFrame) kernel.RenderFrame {
+ t.Helper()
+ deadline := time.After(3 * time.Second)
+ for {
+ select {
+ case f := <-frames:
+ if f.Phase == kernel.PhaseIdle && f.Seq > 1 {
+ return f
+ }
+ case <-deadline:
+ t.Fatal("timeout waiting for kernel idle")
+ }
+ }
+}
diff --git a/internal/apiserver/disconnect_snapshot_test.go b/internal/apiserver/disconnect_snapshot_test.go
new file mode 100644
index 000000000..74feda994
--- /dev/null
+++ b/internal/apiserver/disconnect_snapshot_test.go
@@ -0,0 +1,397 @@
+package apiserver
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/TrebuchetDynamics/gormes-agent/internal/kernel"
+)
+
+var errSimulatedDisconnect = errors.New("simulated client disconnect")
+
+func TestResponses_StreamConnectionResetStoresIncompleteSnapshotAndCancelsTurn(t *testing.T) {
+ store := NewResponseStore(10)
+ loop := &disconnectSnapshotLoop{
+ streamTokens: []string{"partial output"},
+ streamResult: TurnResult{
+ Content: "partial output",
+ SessionID: "sess-disconnect",
+ Usage: Usage{PromptTokens: 2, CompletionTokens: 1, TotalTokens: 3},
+ },
+ runResult: TurnResult{Content: "unexpected non-stream response", SessionID: "sess-disconnect"},
+ }
+ srv := NewServer(Config{ModelName: "gormes-agent", Loop: loop, ResponseStore: store})
+
+ rec := &disconnectingResponseWriter{header: make(http.Header), failAtWrite: 2}
+ req := httptest.NewRequest(http.MethodPost, "/v1/responses", jsonBody(t, map[string]any{
+ "model": "gormes-agent",
+ "input": "will disconnect",
+ "instructions": "keep context",
+ "stream": true,
+ "store": true,
+ }))
+ req.Header.Set("Content-Type", "application/json")
+ srv.Handler().ServeHTTP(rec, req)
+
+ if !loop.streamContextCancelled() {
+ t.Fatal("stream turn context was not cancelled after client disconnect")
+ }
+ id, stored := onlyStoredResponse(t, store)
+ if !strings.HasPrefix(id, "resp_") {
+ t.Fatalf("stored response id = %q, want resp_ prefix", id)
+ }
+ if stored.Response.Status != "incomplete" {
+ t.Fatalf("stored status = %q, want incomplete; response=%+v", stored.Response.Status, stored.Response)
+ }
+ if got := responseOutputText(stored.Response.Output); got != "partial output" {
+ t.Fatalf("stored output text = %q, want partial output", got)
+ }
+ if stored.SessionID != "sess-disconnect" {
+ t.Fatalf("stored SessionID = %q, want sess-disconnect", stored.SessionID)
+ }
+ if !historyHasMessage(stored.ConversationHistory, "assistant", "partial output") {
+ t.Fatalf("conversation history missing partial assistant text: %+v", stored.ConversationHistory)
+ }
+}
+
+func TestResponses_StreamRequestCancellationStoresIncompleteSnapshot(t *testing.T) {
+ store := NewResponseStore(10)
+ loop := &cancelledSnapshotLoop{token: "partial before cancellation"}
+ srv := NewServer(Config{ModelName: "gormes-agent", Loop: loop, ResponseStore: store})
+
+ ctx, cancel := context.WithCancel(context.Background())
+ rec := &cancelingResponseWriter{header: make(http.Header), cancelAtWrite: 2, cancel: cancel}
+ req := httptest.NewRequest(http.MethodPost, "/v1/responses", jsonBody(t, map[string]any{
+ "input": "will be cancelled",
+ "stream": true,
+ "store": true,
+ })).WithContext(ctx)
+ req.Header.Set("Content-Type", "application/json")
+ srv.Handler().ServeHTTP(rec, req)
+
+ if !loop.streamContextCancelled() {
+ t.Fatal("stream turn context was not cancelled by request cancellation")
+ }
+ _, stored := onlyStoredResponse(t, store)
+ if stored.Response.Status != "incomplete" {
+ t.Fatalf("stored status = %q, want incomplete; response=%+v", stored.Response.Status, stored.Response)
+ }
+ if got := responseOutputText(stored.Response.Output); got != "partial before cancellation" {
+ t.Fatalf("stored output text = %q, want partial before cancellation", got)
+ }
+ if !historyHasMessage(stored.ConversationHistory, "assistant", "partial before cancellation") {
+ t.Fatalf("conversation history missing partial assistant text: %+v", stored.ConversationHistory)
+ }
+}
+
+func TestResponses_PreviousResponseIDContinuesFromIncompleteSnapshot(t *testing.T) {
+ store := NewResponseStore(10)
+ loop := &disconnectSnapshotLoop{
+ streamTokens: []string{"draft answer"},
+ streamResult: TurnResult{Content: "draft answer", SessionID: "sess-chain"},
+ runResult: TurnResult{Content: "continued answer", SessionID: "sess-chain"},
+ }
+ srv := NewServer(Config{ModelName: "gormes-agent", Loop: loop, ResponseStore: store})
+
+ rec := &disconnectingResponseWriter{header: make(http.Header), failAtWrite: 2}
+ firstReq := httptest.NewRequest(http.MethodPost, "/v1/responses", jsonBody(t, map[string]any{
+ "input": "start",
+ "instructions": "remember drafts",
+ "stream": true,
+ "store": true,
+ }))
+ firstReq.Header.Set("Content-Type", "application/json")
+ srv.Handler().ServeHTTP(rec, firstReq)
+
+ incompleteID, stored := storedResponseByStatus(t, store, "incomplete")
+ if got := responseOutputText(stored.Response.Output); got != "draft answer" {
+ t.Fatalf("incomplete output text = %q, want draft answer", got)
+ }
+
+ second := postJSON(t, srv.Handler(), "/v1/responses", map[string]any{
+ "input": "continue",
+ "previous_response_id": incompleteID,
+ }, nil)
+ if second.Code != http.StatusOK {
+ t.Fatalf("follow-up status = %d, want 200; body=%s", second.Code, second.Body.String())
+ }
+
+ call := loop.lastRunCall()
+ if call.SessionID != "sess-chain" {
+ t.Fatalf("follow-up SessionID = %q, want sess-chain", call.SessionID)
+ }
+ if call.SystemPrompt != "remember drafts" {
+ t.Fatalf("follow-up SystemPrompt = %q, want stored instructions", call.SystemPrompt)
+ }
+ if !historyHasMessage(call.History, "assistant", "draft answer") {
+ t.Fatalf("follow-up history lost incomplete assistant text: %+v", call.History)
+ }
+}
+
+func TestKernelTurnLoop_RequestCancellationSubmitsKernelCancel(t *testing.T) {
+ submitter := newCancelRecordingKernelSubmitter()
+ loop := NewKernelTurnLoop(submitter)
+ ctx, cancel := context.WithCancel(context.Background())
+ errCh := make(chan error, 1)
+
+ go func() {
+ _, err := loop.StreamTurn(ctx, TurnRequest{UserMessage: "stream until cancelled"}, StreamCallbacks{
+ OnToken: func(string) error { return nil },
+ })
+ errCh <- err
+ }()
+
+ submitter.waitForKind(t, kernel.PlatformEventSubmit)
+ cancel()
+
+ select {
+ case err := <-errCh:
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("StreamTurn error = %v, want context.Canceled", err)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("StreamTurn did not return after request cancellation")
+ }
+ submitter.requireSawKind(t, kernel.PlatformEventCancel)
+}
+
+type disconnectSnapshotLoop struct {
+ mu sync.Mutex
+ streamTokens []string
+ streamResult TurnResult
+ runResult TurnResult
+ streamCalls []TurnRequest
+ runCalls []TurnRequest
+ cancelled bool
+}
+
+func (l *disconnectSnapshotLoop) RunTurn(_ context.Context, req TurnRequest) (TurnResult, error) {
+ l.mu.Lock()
+ l.runCalls = append(l.runCalls, req)
+ l.mu.Unlock()
+ return l.runResult, nil
+}
+
+func (l *disconnectSnapshotLoop) StreamTurn(ctx context.Context, req TurnRequest, cb StreamCallbacks) (TurnResult, error) {
+ l.mu.Lock()
+ l.streamCalls = append(l.streamCalls, req)
+ l.mu.Unlock()
+ for _, token := range l.streamTokens {
+ if err := cb.OnToken(token); err != nil {
+ if ctx.Err() != nil {
+ l.mu.Lock()
+ l.cancelled = true
+ l.mu.Unlock()
+ }
+ return l.streamResult, err
+ }
+ }
+ return l.streamResult, nil
+}
+
+func (l *disconnectSnapshotLoop) streamContextCancelled() bool {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ return l.cancelled
+}
+
+func (l *disconnectSnapshotLoop) lastRunCall() TurnRequest {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ return l.runCalls[len(l.runCalls)-1]
+}
+
+type cancelledSnapshotLoop struct {
+ mu sync.Mutex
+ token string
+ cancelled bool
+}
+
+func (l *cancelledSnapshotLoop) RunTurn(context.Context, TurnRequest) (TurnResult, error) {
+ return TurnResult{Content: "unexpected non-stream response"}, nil
+}
+
+func (l *cancelledSnapshotLoop) StreamTurn(ctx context.Context, req TurnRequest, cb StreamCallbacks) (TurnResult, error) {
+ if err := cb.OnToken(l.token); err != nil {
+ return TurnResult{}, err
+ }
+ <-ctx.Done()
+ l.mu.Lock()
+ l.cancelled = true
+ l.mu.Unlock()
+ return TurnResult{Content: l.token, SessionID: req.SessionID}, ctx.Err()
+}
+
+func (l *cancelledSnapshotLoop) streamContextCancelled() bool {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ return l.cancelled
+}
+
+type cancelRecordingKernelSubmitter struct {
+ mu sync.Mutex
+ events []kernel.PlatformEvent
+ notify chan kernel.PlatformEventKind
+ render chan kernel.RenderFrame
+}
+
+func newCancelRecordingKernelSubmitter() *cancelRecordingKernelSubmitter {
+ return &cancelRecordingKernelSubmitter{
+ notify: make(chan kernel.PlatformEventKind, 8),
+ render: make(chan kernel.RenderFrame),
+ }
+}
+
+func (s *cancelRecordingKernelSubmitter) Submit(e kernel.PlatformEvent) error {
+ s.mu.Lock()
+ s.events = append(s.events, e)
+ s.mu.Unlock()
+ s.notify <- e.Kind
+ return nil
+}
+
+func (s *cancelRecordingKernelSubmitter) Render() <-chan kernel.RenderFrame {
+ return s.render
+}
+
+func (s *cancelRecordingKernelSubmitter) waitForKind(t *testing.T, kind kernel.PlatformEventKind) {
+ t.Helper()
+ deadline := time.After(time.Second)
+ for {
+ select {
+ case got := <-s.notify:
+ if got == kind {
+ return
+ }
+ case <-deadline:
+ t.Fatalf("timeout waiting for kernel event kind %d", kind)
+ }
+ }
+}
+
+func (s *cancelRecordingKernelSubmitter) requireSawKind(t *testing.T, kind kernel.PlatformEventKind) {
+ t.Helper()
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ for _, event := range s.events {
+ if event.Kind == kind {
+ return
+ }
+ }
+ t.Fatalf("kernel events = %+v, want kind %d", s.events, kind)
+}
+
+type disconnectingResponseWriter struct {
+ header http.Header
+ body bytes.Buffer
+ code int
+ writes int
+ failAtWrite int
+}
+
+func (w *disconnectingResponseWriter) Header() http.Header { return w.header }
+
+func (w *disconnectingResponseWriter) WriteHeader(code int) { w.code = code }
+
+func (w *disconnectingResponseWriter) Write(p []byte) (int, error) {
+ w.writes++
+ if w.failAtWrite > 0 && w.writes >= w.failAtWrite {
+ return 0, errSimulatedDisconnect
+ }
+ return w.body.Write(p)
+}
+
+func (w *disconnectingResponseWriter) Flush() {}
+
+type cancelingResponseWriter struct {
+ header http.Header
+ body bytes.Buffer
+ code int
+ writes int
+ cancelAtWrite int
+ cancel context.CancelFunc
+}
+
+func (w *cancelingResponseWriter) Header() http.Header { return w.header }
+
+func (w *cancelingResponseWriter) WriteHeader(code int) { w.code = code }
+
+func (w *cancelingResponseWriter) Write(p []byte) (int, error) {
+ w.writes++
+ n, err := w.body.Write(p)
+ if w.cancelAtWrite > 0 && w.writes >= w.cancelAtWrite && w.cancel != nil {
+ w.cancel()
+ w.cancel = nil
+ }
+ return n, err
+}
+
+func (w *cancelingResponseWriter) Flush() {}
+
+func jsonBody(t *testing.T, body map[string]any) *bytes.Reader {
+ t.Helper()
+ raw, err := json.Marshal(body)
+ if err != nil {
+ t.Fatalf("marshal JSON body: %v", err)
+ }
+ return bytes.NewReader(raw)
+}
+
+func onlyStoredResponse(t *testing.T, store *ResponseStore) (string, StoredResponse) {
+ t.Helper()
+ store.mu.Lock()
+ defer store.mu.Unlock()
+ if len(store.mem) != 1 {
+ t.Fatalf("stored response count = %d, want 1", len(store.mem))
+ }
+ for id, rec := range store.mem {
+ return id, rec.Data
+ }
+ t.Fatal("unreachable")
+ return "", StoredResponse{}
+}
+
+func storedResponseByStatus(t *testing.T, store *ResponseStore, status string) (string, StoredResponse) {
+ t.Helper()
+ store.mu.Lock()
+ defer store.mu.Unlock()
+ for id, rec := range store.mem {
+ if rec.Data.Response.Status == status {
+ return id, rec.Data
+ }
+ }
+ t.Fatalf("no stored response with status %q in %+v", status, store.mem)
+ return "", StoredResponse{}
+}
+
+func responseOutputText(items []ResponseOutputItem) string {
+ var b strings.Builder
+ for _, item := range items {
+ if item.Type != "message" {
+ continue
+ }
+ for _, part := range item.Content {
+ if part.Type == "output_text" {
+ b.WriteString(part.Text)
+ }
+ }
+ }
+ return b.String()
+}
+
+func historyHasMessage(history []ChatMessage, role, content string) bool {
+ for _, msg := range history {
+ if msg.Role == role && msg.Content == content {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/apiserver/kernel_loop.go b/internal/apiserver/kernel_loop.go
new file mode 100644
index 000000000..f5ee91efd
--- /dev/null
+++ b/internal/apiserver/kernel_loop.go
@@ -0,0 +1,226 @@
+package apiserver
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "sync"
+
+ "github.com/TrebuchetDynamics/gormes-agent/internal/kernel"
+)
+
+type kernelSubmitter interface {
+ Submit(kernel.PlatformEvent) error
+ Render() <-chan kernel.RenderFrame
+}
+
+// KernelTurnLoop adapts the native single-owner kernel loop to the HTTP
+// chat-completions surface. It serializes API turns because the current kernel
+// owns exactly one active turn at a time.
+type KernelTurnLoop struct {
+ mu sync.Mutex
+ kernel kernelSubmitter
+ frames <-chan kernel.RenderFrame
+ lastSeq uint64
+ lastHistoryLen int
+}
+
+// NewKernelTurnLoop returns a TurnLoop backed by a running kernel.Kernel. The
+// caller is responsible for starting k.Run(ctx).
+func NewKernelTurnLoop(k kernelSubmitter) *KernelTurnLoop {
+ var frames <-chan kernel.RenderFrame
+ if k != nil {
+ frames = k.Render()
+ }
+ return &KernelTurnLoop{kernel: k, frames: frames}
+}
+
+func (l *KernelTurnLoop) RunTurn(ctx context.Context, req TurnRequest) (TurnResult, error) {
+ return l.run(ctx, req, nil)
+}
+
+func (l *KernelTurnLoop) StreamTurn(ctx context.Context, req TurnRequest, cb StreamCallbacks) (TurnResult, error) {
+ return l.run(ctx, req, cb.OnToken)
+}
+
+func (l *KernelTurnLoop) run(ctx context.Context, req TurnRequest, onToken func(string) error) (TurnResult, error) {
+ if l == nil || l.kernel == nil || l.frames == nil {
+ return TurnResult{}, errors.New("kernel turn loop is not configured")
+ }
+
+ l.mu.Lock()
+ defer l.mu.Unlock()
+
+ l.drainPendingFrames()
+ startSeq := l.lastSeq
+ startHistoryLen := l.lastHistoryLen
+ if err := l.kernel.Submit(kernel.PlatformEvent{
+ Kind: kernel.PlatformEventSubmit,
+ Text: req.UserMessage,
+ SessionID: req.SessionID,
+ SessionContext: buildKernelSessionContext(req),
+ }); err != nil {
+ return TurnResult{}, err
+ }
+
+ streamedDraft := ""
+ for {
+ select {
+ case <-ctx.Done():
+ l.cancelActiveTurn()
+ return TurnResult{}, ctx.Err()
+ case f, ok := <-l.frames:
+ if !ok {
+ return TurnResult{}, errors.New("kernel render stream closed")
+ }
+ l.rememberFrame(f)
+ if f.Seq <= startSeq {
+ continue
+ }
+
+ if onToken != nil && (f.Phase == kernel.PhaseStreaming || f.Phase == kernel.PhaseFinalizing || f.Phase == kernel.PhaseReconnecting) {
+ if delta := draftDelta(streamedDraft, f.DraftText); delta != "" {
+ if err := onToken(delta); err != nil {
+ l.cancelActiveTurn()
+ return TurnResult{}, err
+ }
+ streamedDraft = f.DraftText
+ }
+ }
+
+ if f.Phase == kernel.PhaseFailed || f.Phase == kernel.PhaseCancelling {
+ if f.LastError != "" {
+ return TurnResult{}, errors.New(f.LastError)
+ }
+ return TurnResult{}, errors.New(strings.ToLower(f.Phase.String()))
+ }
+ if f.LastError != "" && f.Phase == kernel.PhaseIdle {
+ return TurnResult{}, errors.New(f.LastError)
+ }
+ if f.Phase == kernel.PhaseIdle && len(f.History) > startHistoryLen {
+ return resultFromFrame(f, req, startHistoryLen), nil
+ }
+ }
+ }
+}
+
+func (l *KernelTurnLoop) cancelActiveTurn() {
+ if l == nil || l.kernel == nil {
+ return
+ }
+ _ = l.kernel.Submit(kernel.PlatformEvent{Kind: kernel.PlatformEventCancel})
+}
+
+func (l *KernelTurnLoop) drainPendingFrames() {
+ for {
+ select {
+ case f, ok := <-l.frames:
+ if !ok {
+ return
+ }
+ l.rememberFrame(f)
+ default:
+ return
+ }
+ }
+}
+
+func (l *KernelTurnLoop) rememberFrame(f kernel.RenderFrame) {
+ if f.Seq > l.lastSeq {
+ l.lastSeq = f.Seq
+ }
+ l.lastHistoryLen = len(f.History)
+}
+
+func resultFromFrame(f kernel.RenderFrame, req TurnRequest, startHistoryLen int) TurnResult {
+ content := f.DraftText
+ for i := len(f.History) - 1; i >= 0; i-- {
+ if f.History[i].Role == "assistant" {
+ content = f.History[i].Content
+ break
+ }
+ }
+ sessionID := f.SessionID
+ if sessionID == "" {
+ sessionID = req.SessionID
+ }
+ prompt := f.Telemetry.TokensInTotal
+ completion := f.Telemetry.TokensOutTotal
+ return TurnResult{
+ Content: content,
+ SessionID: sessionID,
+ FinishReason: "stop",
+ Messages: newChatMessagesFromFrame(f, req, startHistoryLen),
+ Usage: Usage{
+ PromptTokens: prompt,
+ CompletionTokens: completion,
+ TotalTokens: prompt + completion,
+ },
+ }
+}
+
+func newChatMessagesFromFrame(f kernel.RenderFrame, req TurnRequest, startHistoryLen int) []ChatMessage {
+ if startHistoryLen < 0 || startHistoryLen > len(f.History) {
+ startHistoryLen = 0
+ }
+ messages := make([]ChatMessage, 0, len(f.History)-startHistoryLen)
+ for _, msg := range f.History[startHistoryLen:] {
+ converted := ChatMessage{
+ Role: msg.Role,
+ Content: msg.Content,
+ ToolCallID: msg.ToolCallID,
+ Name: msg.Name,
+ }
+ for _, call := range msg.ToolCalls {
+ converted.ToolCalls = append(converted.ToolCalls, ToolCall{
+ ID: call.ID,
+ Name: call.Name,
+ Arguments: string(call.Arguments),
+ })
+ }
+ messages = append(messages, converted)
+ }
+ if len(messages) > 0 && messages[0].Role == "user" && messages[0].Content == req.UserMessage {
+ messages = messages[1:]
+ }
+ return messages
+}
+
+func draftDelta(previous, next string) string {
+ if next == "" || next == previous {
+ return ""
+ }
+ if strings.HasPrefix(next, previous) {
+ return strings.TrimPrefix(next, previous)
+ }
+ return next
+}
+
+func buildKernelSessionContext(req TurnRequest) string {
+ var blocks []string
+ if strings.TrimSpace(req.SystemPrompt) != "" {
+ blocks = append(blocks, req.SystemPrompt)
+ }
+ if len(req.History) > 0 {
+ lines := []string{"## Client Conversation History"}
+ for _, msg := range req.History {
+ role := strings.TrimSpace(msg.Role)
+ content := strings.TrimSpace(msg.Content)
+ if role == "" || (content == "" && len(msg.ToolCalls) == 0 && msg.ToolCallID == "") {
+ continue
+ }
+ line := role + ": " + content
+ for _, call := range msg.ToolCalls {
+ line += "\n" + "assistant tool_call " + call.ID + " " + call.Name + ": " + call.Arguments
+ }
+ if msg.ToolCallID != "" {
+ line += "\n" + "tool_result " + msg.ToolCallID + " " + msg.Name + ": " + content
+ }
+ lines = append(lines, line)
+ }
+ if len(lines) > 1 {
+ blocks = append(blocks, strings.Join(lines, "\n"))
+ }
+ }
+ return strings.Join(blocks, "\n\n")
+}
diff --git a/internal/apiserver/response_store.go b/internal/apiserver/response_store.go
new file mode 100644
index 000000000..455e770d6
--- /dev/null
+++ b/internal/apiserver/response_store.go
@@ -0,0 +1,451 @@
+package apiserver
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "sync"
+ "time"
+
+ bolt "go.etcd.io/bbolt"
+)
+
+const (
+ defaultMaxStoredResponses = 100
+ responseStoreBucketName = "api_responses_v1"
+ conversationBucketName = "api_response_conversations_v1"
+)
+
+// StoredResponse is the read-model payload used for GET /v1/responses and
+// previous_response_id reconstruction.
+type StoredResponse struct {
+ Response ResponseObject `json:"response"`
+ ConversationHistory []ChatMessage `json:"conversation_history"`
+ Instructions string `json:"instructions,omitempty"`
+ SessionID string `json:"session_id,omitempty"`
+}
+
+// ResponseObject is the OpenAI Responses-compatible response envelope.
+type ResponseObject struct {
+ ID string `json:"id"`
+ Object string `json:"object"`
+ Status string `json:"status"`
+ CreatedAt int64 `json:"created_at"`
+ Model string `json:"model"`
+ Output []ResponseOutputItem `json:"output"`
+ Usage ResponseUsage `json:"usage"`
+}
+
+type ResponseOutputItem struct {
+ Type string `json:"type"`
+ ID string `json:"id,omitempty"`
+ Role string `json:"role,omitempty"`
+ Content []ResponseContentPart `json:"content,omitempty"`
+ CallID string `json:"call_id,omitempty"`
+ Name string `json:"name,omitempty"`
+ Arguments string `json:"arguments,omitempty"`
+ Output string `json:"output,omitempty"`
+}
+
+type ResponseContentPart struct {
+ Type string `json:"type"`
+ Text string `json:"text"`
+}
+
+type ResponseUsage struct {
+ InputTokens int `json:"input_tokens"`
+ OutputTokens int `json:"output_tokens"`
+ TotalTokens int `json:"total_tokens"`
+}
+
+type responseStoreRecord struct {
+ Data StoredResponse `json:"data"`
+ AccessedAt int64 `json:"accessed_at"`
+}
+
+type responseStoreStats struct {
+ Enabled bool
+ Size int
+ MaxSize int
+ LRUEvictions int
+}
+
+// ResponseStore is a bounded LRU store. It is in-memory by default for tests
+// and can be backed by bbolt for persistence across gateway restarts.
+type ResponseStore struct {
+ mu sync.Mutex
+ maxSize int
+ now func() time.Time
+ db *bolt.DB
+ closeDB bool
+ mem map[string]responseStoreRecord
+ conversations map[string]string
+ lruEvictions int
+}
+
+func NewResponseStore(maxSize int) *ResponseStore {
+ if maxSize <= 0 {
+ maxSize = defaultMaxStoredResponses
+ }
+ return &ResponseStore{
+ maxSize: maxSize,
+ now: time.Now,
+ mem: make(map[string]responseStoreRecord),
+ conversations: make(map[string]string),
+ }
+}
+
+func OpenResponseStore(path string, maxSize int) (*ResponseStore, error) {
+ if path == "" {
+ return nil, errors.New("api response store: path is required")
+ }
+ if maxSize <= 0 {
+ maxSize = defaultMaxStoredResponses
+ }
+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
+ return nil, fmt.Errorf("api response store: create parent dir for %s: %w", path, err)
+ }
+ db, err := bolt.Open(path, 0o600, &bolt.Options{Timeout: 100 * time.Millisecond})
+ if err != nil {
+ return nil, fmt.Errorf("api response store: open %s: %w", path, err)
+ }
+ store := &ResponseStore{maxSize: maxSize, now: time.Now, db: db, closeDB: true}
+ if err := store.ensureBuckets(); err != nil {
+ _ = db.Close()
+ return nil, err
+ }
+ return store, nil
+}
+
+func NewBoltResponseStore(db *bolt.DB, maxSize int) (*ResponseStore, error) {
+ if db == nil {
+ return nil, errors.New("api response store: nil bolt DB")
+ }
+ if maxSize <= 0 {
+ maxSize = defaultMaxStoredResponses
+ }
+ store := &ResponseStore{maxSize: maxSize, now: time.Now, db: db}
+ if err := store.ensureBuckets(); err != nil {
+ return nil, err
+ }
+ return store, nil
+}
+
+func (s *ResponseStore) ensureBuckets() error {
+ if s == nil || s.db == nil {
+ return nil
+ }
+ return s.db.Update(func(tx *bolt.Tx) error {
+ if _, err := tx.CreateBucketIfNotExists([]byte(responseStoreBucketName)); err != nil {
+ return fmt.Errorf("api response store: create response bucket: %w", err)
+ }
+ if _, err := tx.CreateBucketIfNotExists([]byte(conversationBucketName)); err != nil {
+ return fmt.Errorf("api response store: create conversation bucket: %w", err)
+ }
+ return nil
+ })
+}
+
+func (s *ResponseStore) Get(responseID string) (StoredResponse, bool, error) {
+ if s == nil {
+ return StoredResponse{}, false, nil
+ }
+ if s.db != nil {
+ return s.getBolt(responseID)
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ rec, ok := s.mem[responseID]
+ if !ok {
+ return StoredResponse{}, false, nil
+ }
+ rec.AccessedAt = s.now().UnixNano()
+ s.mem[responseID] = rec
+ return rec.Data, true, nil
+}
+
+func (s *ResponseStore) getBolt(responseID string) (StoredResponse, bool, error) {
+ var (
+ out StoredResponse
+ ok bool
+ )
+ err := s.db.Update(func(tx *bolt.Tx) error {
+ b := tx.Bucket([]byte(responseStoreBucketName))
+ if b == nil {
+ return errors.New("api response store: response bucket missing")
+ }
+ raw := b.Get([]byte(responseID))
+ if raw == nil {
+ return nil
+ }
+ var rec responseStoreRecord
+ if err := json.Unmarshal(raw, &rec); err != nil {
+ return fmt.Errorf("api response store: decode %s: %w", responseID, err)
+ }
+ rec.AccessedAt = s.now().UnixNano()
+ encoded, err := json.Marshal(rec)
+ if err != nil {
+ return fmt.Errorf("api response store: encode %s: %w", responseID, err)
+ }
+ if err := b.Put([]byte(responseID), encoded); err != nil {
+ return err
+ }
+ out = rec.Data
+ ok = true
+ return nil
+ })
+ return out, ok, err
+}
+
+func (s *ResponseStore) Put(responseID string, data StoredResponse) error {
+ if s == nil {
+ return errors.New("api response store: store disabled")
+ }
+ if s.db != nil {
+ return s.putBolt(responseID, data)
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.mem[responseID] = responseStoreRecord{Data: data, AccessedAt: s.now().UnixNano()}
+ s.evictMemoryLocked()
+ return nil
+}
+
+func (s *ResponseStore) putBolt(responseID string, data StoredResponse) error {
+ return s.db.Update(func(tx *bolt.Tx) error {
+ b := tx.Bucket([]byte(responseStoreBucketName))
+ cb := tx.Bucket([]byte(conversationBucketName))
+ if b == nil || cb == nil {
+ return errors.New("api response store: buckets missing")
+ }
+ rec := responseStoreRecord{Data: data, AccessedAt: s.now().UnixNano()}
+ encoded, err := json.Marshal(rec)
+ if err != nil {
+ return fmt.Errorf("api response store: encode %s: %w", responseID, err)
+ }
+ if err := b.Put([]byte(responseID), encoded); err != nil {
+ return err
+ }
+ return s.evictBoltLocked(b, cb)
+ })
+}
+
+func (s *ResponseStore) Delete(responseID string) (bool, error) {
+ if s == nil {
+ return false, nil
+ }
+ if s.db != nil {
+ return s.deleteBolt(responseID)
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ _, ok := s.mem[responseID]
+ delete(s.mem, responseID)
+ if ok {
+ s.removeConversationPointersLocked(responseID)
+ }
+ return ok, nil
+}
+
+func (s *ResponseStore) deleteBolt(responseID string) (bool, error) {
+ var deleted bool
+ err := s.db.Update(func(tx *bolt.Tx) error {
+ b := tx.Bucket([]byte(responseStoreBucketName))
+ cb := tx.Bucket([]byte(conversationBucketName))
+ if b == nil || cb == nil {
+ return errors.New("api response store: buckets missing")
+ }
+ if b.Get([]byte(responseID)) != nil {
+ deleted = true
+ if err := b.Delete([]byte(responseID)); err != nil {
+ return err
+ }
+ return deleteConversationPointers(cb, responseID)
+ }
+ return nil
+ })
+ return deleted, err
+}
+
+func (s *ResponseStore) GetConversation(name string) (string, bool, error) {
+ if s == nil {
+ return "", false, nil
+ }
+ if s.db != nil {
+ var out string
+ err := s.db.View(func(tx *bolt.Tx) error {
+ b := tx.Bucket([]byte(conversationBucketName))
+ if b == nil {
+ return errors.New("api response store: conversation bucket missing")
+ }
+ if raw := b.Get([]byte(name)); raw != nil {
+ out = string(raw)
+ }
+ return nil
+ })
+ return out, out != "", err
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ out := s.conversations[name]
+ return out, out != "", nil
+}
+
+func (s *ResponseStore) SetConversation(name, responseID string) error {
+ if s == nil {
+ return errors.New("api response store: store disabled")
+ }
+ if s.db != nil {
+ return s.db.Update(func(tx *bolt.Tx) error {
+ b := tx.Bucket([]byte(conversationBucketName))
+ if b == nil {
+ return errors.New("api response store: conversation bucket missing")
+ }
+ return b.Put([]byte(name), []byte(responseID))
+ })
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.conversations[name] = responseID
+ return nil
+}
+
+func (s *ResponseStore) Len() (int, error) {
+ if s == nil {
+ return 0, nil
+ }
+ if s.db != nil {
+ n := 0
+ err := s.db.View(func(tx *bolt.Tx) error {
+ b := tx.Bucket([]byte(responseStoreBucketName))
+ if b == nil {
+ return errors.New("api response store: response bucket missing")
+ }
+ return b.ForEach(func(_, _ []byte) error {
+ n++
+ return nil
+ })
+ })
+ return n, err
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return len(s.mem), nil
+}
+
+func (s *ResponseStore) Stats() responseStoreStats {
+ if s == nil {
+ return responseStoreStats{}
+ }
+ n, _ := s.Len()
+ s.mu.Lock()
+ evictions := s.lruEvictions
+ s.mu.Unlock()
+ return responseStoreStats{Enabled: true, Size: n, MaxSize: s.maxSize, LRUEvictions: evictions}
+}
+
+func (s *ResponseStore) Close() error {
+ if s == nil || s.db == nil || !s.closeDB {
+ return nil
+ }
+ err := s.db.Close()
+ s.db = nil
+ return err
+}
+
+func (s *ResponseStore) evictMemoryLocked() {
+ over := len(s.mem) - s.maxSize
+ if over <= 0 {
+ return
+ }
+ type candidate struct {
+ id string
+ accessedAt int64
+ }
+ items := make([]candidate, 0, len(s.mem))
+ for id, rec := range s.mem {
+ items = append(items, candidate{id: id, accessedAt: rec.AccessedAt})
+ }
+ sort.Slice(items, func(i, j int) bool {
+ if items[i].accessedAt != items[j].accessedAt {
+ return items[i].accessedAt < items[j].accessedAt
+ }
+ return items[i].id < items[j].id
+ })
+ for _, item := range items[:over] {
+ delete(s.mem, item.id)
+ s.removeConversationPointersLocked(item.id)
+ s.lruEvictions++
+ }
+}
+
+func (s *ResponseStore) evictBoltLocked(b, cb *bolt.Bucket) error {
+ count := 0
+ type candidate struct {
+ id string
+ accessedAt int64
+ }
+ var items []candidate
+ if err := b.ForEach(func(k, v []byte) error {
+ count++
+ var rec responseStoreRecord
+ if err := json.Unmarshal(v, &rec); err != nil {
+ return fmt.Errorf("api response store: decode during eviction: %w", err)
+ }
+ items = append(items, candidate{id: string(k), accessedAt: rec.AccessedAt})
+ return nil
+ }); err != nil {
+ return err
+ }
+ over := count - s.maxSize
+ if over <= 0 {
+ return nil
+ }
+ sort.Slice(items, func(i, j int) bool {
+ if items[i].accessedAt != items[j].accessedAt {
+ return items[i].accessedAt < items[j].accessedAt
+ }
+ return items[i].id < items[j].id
+ })
+ for _, item := range items[:over] {
+ if err := b.Delete([]byte(item.id)); err != nil {
+ return err
+ }
+ if err := deleteConversationPointers(cb, item.id); err != nil {
+ return err
+ }
+ s.mu.Lock()
+ s.lruEvictions++
+ s.mu.Unlock()
+ }
+ return nil
+}
+
+func (s *ResponseStore) removeConversationPointersLocked(responseID string) {
+ for name, id := range s.conversations {
+ if id == responseID {
+ delete(s.conversations, name)
+ }
+ }
+}
+
+func deleteConversationPointers(b *bolt.Bucket, responseID string) error {
+ var names [][]byte
+ if err := b.ForEach(func(k, v []byte) error {
+ if string(v) == responseID {
+ names = append(names, append([]byte(nil), k...))
+ }
+ return nil
+ }); err != nil {
+ return err
+ }
+ for _, name := range names {
+ if err := b.Delete(name); err != nil {
+ return err
+ }
+ }
+ return nil
+}
diff --git a/internal/apiserver/responses.go b/internal/apiserver/responses.go
new file mode 100644
index 000000000..fe4e759f2
--- /dev/null
+++ b/internal/apiserver/responses.go
@@ -0,0 +1,626 @@
+package apiserver
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+)
+
+func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ writeOpenAIError(w, http.StatusMethodNotAllowed, "Method not allowed", "invalid_request_error", "", "method_not_allowed")
+ return
+ }
+ if !s.authorized(r) {
+ writeOpenAIError(w, http.StatusUnauthorized, "Invalid API key", "invalid_request_error", "", "invalid_api_key")
+ return
+ }
+ if s.loop == nil {
+ writeOpenAIError(w, http.StatusServiceUnavailable, "Native turn loop is not configured", "server_error", "", "turn_loop_unavailable")
+ return
+ }
+
+ body, err := readLimitedBody(w, r, s.maxBodyBytes)
+ if err != nil {
+ writeOpenAIError(w, http.StatusRequestEntityTooLarge, "Request body too large.", "invalid_request_error", "", "body_too_large")
+ return
+ }
+ var req map[string]any
+ if err := json.Unmarshal(body, &req); err != nil {
+ writeOpenAIError(w, http.StatusBadRequest, "Invalid JSON in request body", "invalid_request_error", "", "invalid_json")
+ return
+ }
+
+ turnReq, responseContext, errResp := s.buildResponseTurnRequest(req)
+ if errResp != nil {
+ writeOpenAIError(w, errResp.status, errResp.message, "invalid_request_error", errResp.param, errResp.code)
+ return
+ }
+
+ responseID := "resp_" + randomHexFromTime(s.now())
+ created := s.now().Unix()
+ if boolField(req, "stream", false) {
+ s.writeStreamingResponse(w, r, responseID, created, turnReq, responseContext)
+ return
+ }
+
+ result, err := s.loop.RunTurn(r.Context(), turnReq)
+ if err != nil {
+ writeOpenAIError(w, http.StatusInternalServerError, "Internal server error: "+err.Error(), "server_error", "", "turn_failed")
+ return
+ }
+ sessionID := result.SessionID
+ if sessionID == "" {
+ sessionID = turnReq.SessionID
+ }
+ if sessionID != "" {
+ w.Header().Set("X-Hermes-Session-Id", sessionID)
+ }
+
+ response := responseObjectFromTurn(responseID, created, turnReq.Model, result)
+ if responseContext.store {
+ fullHistory := append([]ChatMessage(nil), responseContext.historyForStorage...)
+ fullHistory = append(fullHistory, responseMessagesForStorage(result)...)
+ stored := StoredResponse{
+ Response: response,
+ ConversationHistory: fullHistory,
+ Instructions: responseContext.instructions,
+ SessionID: sessionID,
+ }
+ if err := s.responseStore.Put(responseID, stored); err != nil {
+ writeOpenAIError(w, http.StatusInternalServerError, "Internal server error: "+err.Error(), "server_error", "", "response_store_failed")
+ return
+ }
+ if responseContext.conversation != "" {
+ if err := s.responseStore.SetConversation(responseContext.conversation, responseID); err != nil {
+ writeOpenAIError(w, http.StatusInternalServerError, "Internal server error: "+err.Error(), "server_error", "", "response_store_failed")
+ return
+ }
+ }
+ }
+ writeJSON(w, http.StatusOK, response)
+}
+
+func (s *Server) writeStreamingResponse(w http.ResponseWriter, r *http.Request, responseID string, created int64, turnReq TurnRequest, responseContext responseTurnContext) {
+ sessionID := turnReq.SessionID
+ if sessionID != "" {
+ w.Header().Set("X-Hermes-Session-Id", sessionID)
+ }
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.Header().Set("Cache-Control", "no-cache")
+ w.Header().Set("X-Accel-Buffering", "no")
+ w.WriteHeader(http.StatusOK)
+
+ streamCtx, cancelStream := context.WithCancel(r.Context())
+ defer cancelStream()
+
+ var (
+ partialText strings.Builder
+ writeErr error
+ )
+ persist := func(response ResponseObject, history []ChatMessage, snapshotSessionID string) error {
+ if snapshotSessionID == "" {
+ snapshotSessionID = sessionID
+ }
+ return s.persistResponseSnapshot(responseID, response, responseContext, history, snapshotSessionID)
+ }
+ persistIncomplete := func(result TurnResult) {
+ text := partialText.String()
+ if text == "" {
+ text = result.Content
+ }
+ incomplete := responseObjectFromText(responseID, created, turnReq.Model, "incomplete", text, result.Usage)
+ _ = persist(incomplete, responseHistoryWithAssistant(responseContext.historyForStorage, text), sessionID)
+ }
+
+ createdResponse := responseObjectFromText(responseID, created, turnReq.Model, "in_progress", "", Usage{})
+ if err := writeSSEEvent(w, "response.created", map[string]any{
+ "type": "response.created",
+ "response": createdResponse,
+ }); err != nil {
+ writeErr = err
+ cancelStream()
+ persistIncomplete(TurnResult{})
+ return
+ }
+ if err := persist(createdResponse, append([]ChatMessage(nil), responseContext.historyForStorage...), sessionID); err != nil {
+ _ = writeSSEEvent(w, "response.failed", map[string]any{
+ "type": "response.failed",
+ "error": err.Error(),
+ })
+ flush(w)
+ return
+ }
+ flush(w)
+
+ result, err := s.loop.StreamTurn(streamCtx, turnReq, StreamCallbacks{
+ OnToken: func(token string) error {
+ if token == "" {
+ return nil
+ }
+ partialText.WriteString(token)
+ if err := writeSSEEvent(w, "response.output_text.delta", map[string]any{
+ "type": "response.output_text.delta",
+ "response_id": responseID,
+ "delta": token,
+ }); err != nil {
+ writeErr = err
+ cancelStream()
+ return err
+ }
+ flush(w)
+ return nil
+ },
+ })
+ if result.SessionID != "" {
+ sessionID = result.SessionID
+ }
+ if err != nil {
+ if writeErr != nil || errors.Is(err, context.Canceled) || streamCtx.Err() != nil || r.Context().Err() != nil {
+ persistIncomplete(result)
+ return
+ }
+ text := partialText.String()
+ if text == "" {
+ text = err.Error()
+ }
+ failed := responseObjectFromText(responseID, created, turnReq.Model, "failed", text, result.Usage)
+ _ = persist(failed, responseHistoryWithAssistant(responseContext.historyForStorage, text), sessionID)
+ _ = writeSSEEvent(w, "response.failed", map[string]any{
+ "type": "response.failed",
+ "response": failed,
+ })
+ flush(w)
+ return
+ }
+
+ if result.SessionID == "" {
+ result.SessionID = sessionID
+ }
+ if result.Content == "" && len(result.Messages) == 0 && partialText.Len() > 0 {
+ result.Content = partialText.String()
+ }
+ completed := responseObjectFromTurn(responseID, created, turnReq.Model, result)
+ fullHistory := append([]ChatMessage(nil), responseContext.historyForStorage...)
+ fullHistory = append(fullHistory, responseMessagesForStorage(result)...)
+ if err := persist(completed, fullHistory, sessionID); err != nil {
+ _ = writeSSEEvent(w, "response.failed", map[string]any{
+ "type": "response.failed",
+ "error": err.Error(),
+ })
+ flush(w)
+ return
+ }
+ _ = writeSSEEvent(w, "response.completed", map[string]any{
+ "type": "response.completed",
+ "response": completed,
+ })
+ flush(w)
+}
+
+func (s *Server) persistResponseSnapshot(responseID string, response ResponseObject, responseContext responseTurnContext, history []ChatMessage, sessionID string) error {
+ if !responseContext.store {
+ return nil
+ }
+ stored := StoredResponse{
+ Response: response,
+ ConversationHistory: append([]ChatMessage(nil), history...),
+ Instructions: responseContext.instructions,
+ SessionID: sessionID,
+ }
+ if err := s.responseStore.Put(responseID, stored); err != nil {
+ return err
+ }
+ if responseContext.conversation != "" {
+ return s.responseStore.SetConversation(responseContext.conversation, responseID)
+ }
+ return nil
+}
+
+func (s *Server) handleResponseByID(w http.ResponseWriter, r *http.Request) {
+ if !s.authorized(r) {
+ writeOpenAIError(w, http.StatusUnauthorized, "Invalid API key", "invalid_request_error", "", "invalid_api_key")
+ return
+ }
+ responseID := strings.TrimPrefix(r.URL.Path, "/v1/responses/")
+ if responseID == "" || strings.Contains(responseID, "/") {
+ writeOpenAIError(w, http.StatusNotFound, "Response not found", "invalid_request_error", "", "response_not_found")
+ return
+ }
+ switch r.Method {
+ case http.MethodGet:
+ stored, ok, err := s.responseStore.Get(responseID)
+ if err != nil {
+ writeOpenAIError(w, http.StatusInternalServerError, "Internal server error: "+err.Error(), "server_error", "", "response_store_failed")
+ return
+ }
+ if !ok {
+ writeOpenAIError(w, http.StatusNotFound, "Response not found: "+responseID, "invalid_request_error", "", "response_not_found")
+ return
+ }
+ writeJSON(w, http.StatusOK, stored.Response)
+ case http.MethodDelete:
+ deleted, err := s.responseStore.Delete(responseID)
+ if err != nil {
+ writeOpenAIError(w, http.StatusInternalServerError, "Internal server error: "+err.Error(), "server_error", "", "response_store_failed")
+ return
+ }
+ if !deleted {
+ writeOpenAIError(w, http.StatusNotFound, "Response not found: "+responseID, "invalid_request_error", "", "response_not_found")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{
+ "id": responseID,
+ "object": "response",
+ "deleted": true,
+ })
+ default:
+ writeOpenAIError(w, http.StatusMethodNotAllowed, "Method not allowed", "invalid_request_error", "", "method_not_allowed")
+ }
+}
+
+type responseTurnContext struct {
+ instructions string
+ conversation string
+ store bool
+ historyForStorage []ChatMessage
+}
+
+func (s *Server) buildResponseTurnRequest(body map[string]any) (TurnRequest, responseTurnContext, *requestError) {
+ rawInput, ok := body["input"]
+ if !ok || rawInput == nil {
+ return TurnRequest{}, responseTurnContext{}, &requestError{
+ status: http.StatusBadRequest,
+ message: "Missing 'input' field",
+ param: "input",
+ code: "missing_input",
+ }
+ }
+ inputMessages, errResp := normalizeResponseInput(rawInput)
+ if errResp != nil {
+ return TurnRequest{}, responseTurnContext{}, errResp
+ }
+ if len(inputMessages) == 0 {
+ return TurnRequest{}, responseTurnContext{}, &requestError{status: http.StatusBadRequest, message: "No user message found in input", code: "missing_user_message"}
+ }
+
+ instructions := stringField(body, "instructions")
+ previousResponseID := stringField(body, "previous_response_id")
+ conversation := stringField(body, "conversation")
+ if conversation != "" && previousResponseID != "" {
+ return TurnRequest{}, responseTurnContext{}, &requestError{
+ status: http.StatusBadRequest,
+ message: "Cannot use both 'conversation' and 'previous_response_id'",
+ code: "invalid_response_chain",
+ }
+ }
+ if conversation != "" {
+ if resolved, ok, err := s.responseStore.GetConversation(conversation); err != nil {
+ return TurnRequest{}, responseTurnContext{}, &requestError{status: http.StatusInternalServerError, message: err.Error(), code: "response_store_failed"}
+ } else if ok {
+ previousResponseID = resolved
+ }
+ }
+
+ conversationHistory, errResp := normalizeExplicitConversationHistory(body["conversation_history"])
+ if errResp != nil {
+ return TurnRequest{}, responseTurnContext{}, errResp
+ }
+ sessionID := ""
+ if len(conversationHistory) == 0 && previousResponseID != "" {
+ stored, ok, err := s.responseStore.Get(previousResponseID)
+ if err != nil {
+ return TurnRequest{}, responseTurnContext{}, &requestError{status: http.StatusInternalServerError, message: err.Error(), code: "response_store_failed"}
+ }
+ if !ok {
+ s.recordPreviousResponseMiss()
+ return TurnRequest{}, responseTurnContext{}, &requestError{
+ status: http.StatusNotFound,
+ message: "Previous response not found: " + previousResponseID,
+ param: "previous_response_id",
+ code: "previous_response_not_found",
+ }
+ }
+ conversationHistory = append(conversationHistory, stored.ConversationHistory...)
+ sessionID = stored.SessionID
+ if instructions == "" {
+ instructions = stored.Instructions
+ }
+ }
+
+ last := inputMessages[len(inputMessages)-1]
+ if !hasVisibleText(last.Content) {
+ return TurnRequest{}, responseTurnContext{}, &requestError{status: http.StatusBadRequest, message: "No user message found in input", code: "missing_user_message"}
+ }
+ turnHistory := append([]ChatMessage(nil), conversationHistory...)
+ turnHistory = append(turnHistory, inputMessages[:len(inputMessages)-1]...)
+ if stringField(body, "truncation") == "auto" && len(turnHistory) > 100 {
+ turnHistory = turnHistory[len(turnHistory)-100:]
+ }
+ if sessionID == "" {
+ sessionID = deriveChatSessionID(instructions, firstUserContent(append(turnHistory, last)))
+ }
+ model := stringField(body, "model")
+ if model == "" {
+ model = s.modelName
+ }
+ store := true
+ if rawStore, ok := body["store"].(bool); ok {
+ store = rawStore
+ }
+ historyForStorage := append([]ChatMessage(nil), turnHistory...)
+ historyForStorage = append(historyForStorage, last)
+ return TurnRequest{
+ Model: model,
+ UserMessage: last.Content,
+ History: turnHistory,
+ SystemPrompt: instructions,
+ SessionID: sessionID,
+ }, responseTurnContext{
+ instructions: instructions,
+ conversation: conversation,
+ store: store,
+ historyForStorage: historyForStorage,
+ }, nil
+}
+
+func normalizeResponseInput(raw any) ([]ChatMessage, *requestError) {
+ switch v := raw.(type) {
+ case string:
+ return []ChatMessage{{Role: "user", Content: truncateText(v)}}, nil
+ case []any:
+ out := make([]ChatMessage, 0, len(v))
+ for idx, item := range v {
+ msg, errResp := normalizeResponseInputMessage(item, fmt.Sprintf("input[%d]", idx))
+ if errResp != nil {
+ return nil, errResp
+ }
+ out = append(out, msg)
+ }
+ return out, nil
+ default:
+ return nil, &requestError{status: http.StatusBadRequest, message: "'input' must be a string or array", param: "input", code: "invalid_input"}
+ }
+}
+
+func normalizeExplicitConversationHistory(raw any) ([]ChatMessage, *requestError) {
+ if raw == nil {
+ return nil, nil
+ }
+ items, ok := raw.([]any)
+ if !ok {
+ return nil, &requestError{status: http.StatusBadRequest, message: "'conversation_history' must be an array of message objects", param: "conversation_history", code: "invalid_conversation_history"}
+ }
+ out := make([]ChatMessage, 0, len(items))
+ for idx, item := range items {
+ msg, errResp := normalizeResponseInputMessage(item, fmt.Sprintf("conversation_history[%d]", idx))
+ if errResp != nil {
+ return nil, errResp
+ }
+ out = append(out, msg)
+ }
+ return out, nil
+}
+
+func normalizeResponseInputMessage(raw any, param string) (ChatMessage, *requestError) {
+ switch v := raw.(type) {
+ case string:
+ return ChatMessage{Role: "user", Content: truncateText(v)}, nil
+ case map[string]any:
+ role := strings.ToLower(strings.TrimSpace(fmt.Sprint(v["role"])))
+ if role == "" || role == "" {
+ role = "user"
+ }
+ content, err := normalizeChatContent(v["content"])
+ if err != nil {
+ return ChatMessage{}, &requestError{status: http.StatusBadRequest, message: err.message, param: param + ".content", code: err.code}
+ }
+ msg := ChatMessage{
+ Role: role,
+ Content: content,
+ ToolCalls: parseToolCalls(v["tool_calls"]),
+ ToolCallID: strings.TrimSpace(fmt.Sprint(v["tool_call_id"])),
+ Name: strings.TrimSpace(fmt.Sprint(v["name"])),
+ }
+ if msg.ToolCallID == "" {
+ msg.ToolCallID = ""
+ }
+ if msg.Name == "" {
+ msg.Name = ""
+ }
+ return msg, nil
+ default:
+ return ChatMessage{}, &requestError{status: http.StatusBadRequest, message: param + " must be a string or message object", param: param, code: "invalid_input_message"}
+ }
+}
+
+func parseToolCalls(raw any) []ToolCall {
+ items, ok := raw.([]any)
+ if !ok {
+ return nil
+ }
+ out := make([]ToolCall, 0, len(items))
+ for _, item := range items {
+ m, ok := item.(map[string]any)
+ if !ok {
+ continue
+ }
+ call := ToolCall{
+ ID: strings.TrimSpace(fmt.Sprint(m["id"])),
+ Name: strings.TrimSpace(fmt.Sprint(m["name"])),
+ }
+ if fn, ok := m["function"].(map[string]any); ok {
+ if call.Name == "" || call.Name == "" {
+ call.Name = strings.TrimSpace(fmt.Sprint(fn["name"]))
+ }
+ call.Arguments = strings.TrimSpace(fmt.Sprint(fn["arguments"]))
+ } else {
+ call.Arguments = strings.TrimSpace(fmt.Sprint(m["arguments"]))
+ }
+ if call.ID == "" {
+ call.ID = ""
+ }
+ if call.Name == "" {
+ call.Name = ""
+ }
+ if call.Arguments == "" {
+ call.Arguments = ""
+ }
+ if call.ID != "" || call.Name != "" || call.Arguments != "" {
+ out = append(out, call)
+ }
+ }
+ return out
+}
+
+func responseObjectFromText(id string, created int64, model, status, text string, usage Usage) ResponseObject {
+ output := []ResponseOutputItem{}
+ if strings.TrimSpace(text) != "" {
+ output = append(output, responseMessageItem(text))
+ }
+ return ResponseObject{
+ ID: id,
+ Object: "response",
+ Status: status,
+ CreatedAt: created,
+ Model: model,
+ Output: output,
+ Usage: ResponseUsage{
+ InputTokens: usage.PromptTokens,
+ OutputTokens: usage.CompletionTokens,
+ TotalTokens: usage.TotalTokens,
+ },
+ }
+}
+
+func responseObjectFromTurn(id string, created int64, model string, result TurnResult) ResponseObject {
+ return ResponseObject{
+ ID: id,
+ Object: "response",
+ Status: "completed",
+ CreatedAt: created,
+ Model: model,
+ Output: responseOutputItems(result),
+ Usage: ResponseUsage{
+ InputTokens: result.Usage.PromptTokens,
+ OutputTokens: result.Usage.CompletionTokens,
+ TotalTokens: result.Usage.TotalTokens,
+ },
+ }
+}
+
+func responseOutputItems(result TurnResult) []ResponseOutputItem {
+ messages := result.Messages
+ if len(messages) == 0 {
+ messages = []ChatMessage{{Role: "assistant", Content: result.Content}}
+ }
+ var out []ResponseOutputItem
+ for _, msg := range messages {
+ switch msg.Role {
+ case "assistant":
+ for _, call := range msg.ToolCalls {
+ out = append(out, ResponseOutputItem{
+ Type: "function_call",
+ CallID: call.ID,
+ Name: call.Name,
+ Arguments: call.Arguments,
+ })
+ }
+ if strings.TrimSpace(msg.Content) != "" {
+ out = append(out, responseMessageItem(msg.Content))
+ }
+ case "tool":
+ out = append(out, ResponseOutputItem{
+ Type: "function_call_output",
+ CallID: msg.ToolCallID,
+ Name: msg.Name,
+ Output: msg.Content,
+ })
+ }
+ }
+ if len(out) == 0 {
+ out = append(out, responseMessageItem(result.Content))
+ }
+ return out
+}
+
+func responseMessageItem(text string) ResponseOutputItem {
+ return ResponseOutputItem{
+ Type: "message",
+ Role: "assistant",
+ Content: []ResponseContentPart{{
+ Type: "output_text",
+ Text: text,
+ }},
+ }
+}
+
+func responseMessagesForStorage(result TurnResult) []ChatMessage {
+ if len(result.Messages) > 0 {
+ return append([]ChatMessage(nil), result.Messages...)
+ }
+ return []ChatMessage{{Role: "assistant", Content: result.Content}}
+}
+
+func responseHistoryWithAssistant(base []ChatMessage, assistantText string) []ChatMessage {
+ history := append([]ChatMessage(nil), base...)
+ if strings.TrimSpace(assistantText) != "" {
+ history = append(history, ChatMessage{Role: "assistant", Content: assistantText})
+ }
+ return history
+}
+
+func firstUserContent(messages []ChatMessage) string {
+ for _, msg := range messages {
+ if msg.Role == "user" && strings.TrimSpace(msg.Content) != "" {
+ return msg.Content
+ }
+ }
+ return ""
+}
+
+func stringField(body map[string]any, key string) string {
+ value, ok := body[key]
+ if !ok || value == nil {
+ return ""
+ }
+ if s, ok := value.(string); ok {
+ return strings.TrimSpace(s)
+ }
+ return strings.TrimSpace(fmt.Sprint(value))
+}
+
+func boolField(body map[string]any, key string, fallback bool) bool {
+ value, ok := body[key]
+ if !ok {
+ return fallback
+ }
+ b, ok := value.(bool)
+ if !ok {
+ return fallback
+ }
+ return b
+}
+
+func (s *Server) recordPreviousResponseMiss() {
+ s.statusMu.Lock()
+ s.previousResponseMisses++
+ s.statusMu.Unlock()
+}
+
+func (s *Server) responseHealthStatus() map[string]any {
+ stats := s.responseStore.Stats()
+ s.statusMu.Lock()
+ misses := s.previousResponseMisses
+ s.statusMu.Unlock()
+ return map[string]any{
+ "store_enabled": stats.Enabled,
+ "stored": stats.Size,
+ "max_stored": stats.MaxSize,
+ "lru_evictions": stats.LRUEvictions,
+ "previous_response_misses": misses,
+ }
+}
diff --git a/internal/apiserver/responses_runs_test.go b/internal/apiserver/responses_runs_test.go
new file mode 100644
index 000000000..48320c45a
--- /dev/null
+++ b/internal/apiserver/responses_runs_test.go
@@ -0,0 +1,395 @@
+package apiserver
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+)
+
+func TestResponseStore_PersistsAndEvictsLeastRecentlyUsed(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "responses.db")
+ store, err := OpenResponseStore(path, 2)
+ if err != nil {
+ t.Fatalf("OpenResponseStore: %v", err)
+ }
+ store.now = steppedClock(time.Unix(100, 0), time.Second)
+
+ if err := store.Put("resp_1", StoredResponse{Response: ResponseObject{ID: "resp_1", Object: "response", Status: "completed"}}); err != nil {
+ t.Fatalf("put resp_1: %v", err)
+ }
+ if err := store.Put("resp_2", StoredResponse{Response: ResponseObject{ID: "resp_2", Object: "response", Status: "completed"}}); err != nil {
+ t.Fatalf("put resp_2: %v", err)
+ }
+ if _, ok, err := store.Get("resp_1"); err != nil || !ok {
+ t.Fatalf("touch resp_1 ok=%v err=%v", ok, err)
+ }
+ if err := store.Put("resp_3", StoredResponse{Response: ResponseObject{ID: "resp_3", Object: "response", Status: "completed"}}); err != nil {
+ t.Fatalf("put resp_3: %v", err)
+ }
+ if _, ok, err := store.Get("resp_2"); err != nil || ok {
+ t.Fatalf("resp_2 after LRU eviction ok=%v err=%v, want missing", ok, err)
+ }
+ if err := store.Close(); err != nil {
+ t.Fatalf("close: %v", err)
+ }
+
+ reopened, err := OpenResponseStore(path, 2)
+ if err != nil {
+ t.Fatalf("reopen response store: %v", err)
+ }
+ defer reopened.Close()
+ if got, ok, err := reopened.Get("resp_1"); err != nil || !ok || got.Response.ID != "resp_1" {
+ t.Fatalf("reopened resp_1 = %+v ok=%v err=%v", got, ok, err)
+ }
+ if got, ok, err := reopened.Get("resp_3"); err != nil || !ok || got.Response.ID != "resp_3" {
+ t.Fatalf("reopened resp_3 = %+v ok=%v err=%v", got, ok, err)
+ }
+ if n, err := reopened.Len(); err != nil || n != 2 {
+ t.Fatalf("reopened Len = %d err=%v, want 2", n, err)
+ }
+}
+
+func TestResponses_PreviousResponseIDChainsStoredToolHistoryAndDelete(t *testing.T) {
+ loop := &fakeTurnLoop{result: TurnResult{
+ Content: "Files: README.md",
+ SessionID: "sess-chain",
+ Usage: Usage{PromptTokens: 4, CompletionTokens: 3, TotalTokens: 7},
+ Messages: []ChatMessage{
+ {
+ Role: "assistant",
+ Content: "I will inspect the files.",
+ ToolCalls: []ToolCall{{
+ ID: "call_1",
+ Name: "terminal",
+ Arguments: `{"command":"ls"}`,
+ }},
+ },
+ {Role: "tool", ToolCallID: "call_1", Name: "terminal", Content: "README.md"},
+ {Role: "assistant", Content: "Files: README.md"},
+ },
+ }}
+ srv := NewServer(Config{ModelName: "gormes-agent", Loop: loop, ResponseStore: NewResponseStore(10)})
+
+ first := postJSON(t, srv.Handler(), "/v1/responses", map[string]any{
+ "model": "gormes-agent",
+ "input": "List files",
+ "instructions": "Be concise.",
+ }, nil)
+ if first.Code != http.StatusOK {
+ t.Fatalf("first status = %d, want 200; body=%s", first.Code, first.Body.String())
+ }
+ var firstBody ResponseObject
+ if err := json.Unmarshal(first.Body.Bytes(), &firstBody); err != nil {
+ t.Fatalf("decode first response: %v", err)
+ }
+ if firstBody.ID == "" || firstBody.Object != "response" || firstBody.Status != "completed" {
+ t.Fatalf("first response identity = %+v", firstBody)
+ }
+ if !hasOutputItem(firstBody.Output, "function_call", "terminal") ||
+ !hasOutputItem(firstBody.Output, "function_call_output", "terminal") ||
+ !hasOutputText(firstBody.Output, "Files: README.md") {
+ t.Fatalf("first output missing tool call/result/final text: %+v", firstBody.Output)
+ }
+
+ get := getJSON(t, srv.Handler(), "/v1/responses/"+firstBody.ID, nil)
+ if get.Code != http.StatusOK {
+ t.Fatalf("GET status = %d, want 200; body=%s", get.Code, get.Body.String())
+ }
+
+ loop.result = TurnResult{Content: "README contents", SessionID: "sess-chain"}
+ second := postJSON(t, srv.Handler(), "/v1/responses", map[string]any{
+ "model": "gormes-agent",
+ "input": "Read it",
+ "previous_response_id": firstBody.ID,
+ }, nil)
+ if second.Code != http.StatusOK {
+ t.Fatalf("second status = %d, want 200; body=%s", second.Code, second.Body.String())
+ }
+ secondCall := loop.lastCall()
+ if secondCall.SessionID != "sess-chain" {
+ t.Fatalf("second SessionID = %q, want sess-chain", secondCall.SessionID)
+ }
+ if secondCall.SystemPrompt != "Be concise." {
+ t.Fatalf("second SystemPrompt = %q, want inherited instructions", secondCall.SystemPrompt)
+ }
+ if !historyContainsToolExchange(secondCall.History, "call_1", "terminal", "README.md") {
+ t.Fatalf("second history missing stored tool exchange: %+v", secondCall.History)
+ }
+
+ del := deleteJSON(t, srv.Handler(), "/v1/responses/"+firstBody.ID, nil)
+ if del.Code != http.StatusOK {
+ t.Fatalf("DELETE status = %d, want 200; body=%s", del.Code, del.Body.String())
+ }
+ missing := getJSON(t, srv.Handler(), "/v1/responses/"+firstBody.ID, nil)
+ if missing.Code != http.StatusNotFound {
+ t.Fatalf("GET after delete status = %d, want 404; body=%s", missing.Code, missing.Body.String())
+ }
+}
+
+func TestResponses_ConversationNameChainsToLatestResponse(t *testing.T) {
+ loop := &fakeTurnLoop{result: TurnResult{
+ Content: "First answer",
+ SessionID: "sess-conversation",
+ Messages: []ChatMessage{{Role: "assistant", Content: "First answer"}},
+ }}
+ srv := NewServer(Config{ModelName: "gormes-agent", Loop: loop, ResponseStore: NewResponseStore(10)})
+
+ first := postJSON(t, srv.Handler(), "/v1/responses", map[string]any{
+ "input": "First question",
+ "conversation": "project-alpha",
+ }, nil)
+ if first.Code != http.StatusOK {
+ t.Fatalf("first status = %d, want 200; body=%s", first.Code, first.Body.String())
+ }
+
+ loop.result = TurnResult{Content: "Second answer", SessionID: "sess-conversation"}
+ second := postJSON(t, srv.Handler(), "/v1/responses", map[string]any{
+ "input": "Second question",
+ "conversation": "project-alpha",
+ }, nil)
+ if second.Code != http.StatusOK {
+ t.Fatalf("second status = %d, want 200; body=%s", second.Code, second.Body.String())
+ }
+ call := loop.lastCall()
+ if call.SessionID != "sess-conversation" {
+ t.Fatalf("conversation SessionID = %q, want sess-conversation", call.SessionID)
+ }
+ if len(call.History) < 2 || call.History[0].Role != "user" || call.History[0].Content != "First question" ||
+ call.History[1].Role != "assistant" || call.History[1].Content != "First answer" {
+ t.Fatalf("conversation history = %+v, want first user/assistant turn", call.History)
+ }
+}
+
+func TestResponses_PreviousResponseMissUsesErrorEnvelopeAndHealthStatus(t *testing.T) {
+ srv := NewServer(Config{ModelName: "gormes-agent", Loop: &fakeTurnLoop{}, ResponseStore: NewResponseStore(10)})
+
+ rec := postJSON(t, srv.Handler(), "/v1/responses", map[string]any{
+ "input": "follow up",
+ "previous_response_id": "resp_missing",
+ }, nil)
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("status = %d, want 404; body=%s", rec.Code, rec.Body.String())
+ }
+ var body struct {
+ Error struct {
+ Code string `json:"code"`
+ } `json:"error"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatalf("decode error envelope: %v", err)
+ }
+ if body.Error.Code != "previous_response_not_found" {
+ t.Fatalf("error code = %q, want previous_response_not_found", body.Error.Code)
+ }
+
+ health := getJSON(t, srv.Handler(), "/v1/health", nil)
+ if health.Code != http.StatusOK {
+ t.Fatalf("health status = %d; body=%s", health.Code, health.Body.String())
+ }
+ var status struct {
+ Responses struct {
+ StoreEnabled bool `json:"store_enabled"`
+ PreviousResponseMisses int `json:"previous_response_misses"`
+ } `json:"responses"`
+ }
+ if err := json.Unmarshal(health.Body.Bytes(), &status); err != nil {
+ t.Fatalf("decode health: %v", err)
+ }
+ if !status.Responses.StoreEnabled || status.Responses.PreviousResponseMisses != 1 {
+ t.Fatalf("responses status = %+v, want enabled with one previous miss", status.Responses)
+ }
+}
+
+func TestRuns_StreamsLifecycleEventsFromNativeTurn(t *testing.T) {
+ loop := &fakeTurnLoop{
+ result: TurnResult{Content: "Hello run", SessionID: "sess-run", Usage: Usage{PromptTokens: 1, CompletionTokens: 2, TotalTokens: 3}},
+ streamTokens: []string{"Hello", " run"},
+ }
+ srv := NewServer(Config{ModelName: "gormes-agent", Loop: loop, ResponseStore: NewResponseStore(10)})
+
+ start := postJSON(t, srv.Handler(), "/v1/runs", map[string]any{"input": "hello"}, nil)
+ if start.Code != http.StatusAccepted {
+ t.Fatalf("POST /v1/runs status = %d, want 202; body=%s", start.Code, start.Body.String())
+ }
+ var started struct {
+ RunID string `json:"run_id"`
+ Status string `json:"status"`
+ }
+ if err := json.Unmarshal(start.Body.Bytes(), &started); err != nil {
+ t.Fatalf("decode run start: %v", err)
+ }
+ if !strings.HasPrefix(started.RunID, "run_") || started.Status != "started" {
+ t.Fatalf("run start = %+v", started)
+ }
+
+ events := getJSON(t, srv.Handler(), "/v1/runs/"+started.RunID+"/events", nil)
+ if events.Code != http.StatusOK {
+ t.Fatalf("GET run events status = %d, want 200; body=%s", events.Code, events.Body.String())
+ }
+ if got := events.Header().Get("Content-Type"); !strings.Contains(got, "text/event-stream") {
+ t.Fatalf("Content-Type = %q, want text/event-stream", got)
+ }
+ body := events.Body.String()
+ for _, want := range []string{`"event":"run.started"`, `"event":"message.delta"`, `"delta":"Hello"`, `"delta":" run"`, `"event":"run.completed"`, `"output":"Hello run"`} {
+ if !strings.Contains(body, want) {
+ t.Fatalf("run events missing %s: %s", want, body)
+ }
+ }
+ if got := loop.lastCall().SessionID; got != started.RunID {
+ t.Fatalf("run turn SessionID = %q, want run id", got)
+ }
+}
+
+func TestRuns_SweepsOrphanedRunStreams(t *testing.T) {
+ loop := newBlockingRunLoop()
+ srv := NewServer(Config{ModelName: "gormes-agent", Loop: loop, ResponseStore: NewResponseStore(10), RunTTL: time.Minute})
+ now := time.Unix(1_000, 0)
+ srv.now = func() time.Time { return now }
+
+ start := postJSON(t, srv.Handler(), "/v1/runs", map[string]any{"input": "wait"}, nil)
+ if start.Code != http.StatusAccepted {
+ t.Fatalf("POST /v1/runs status = %d, want 202; body=%s", start.Code, start.Body.String())
+ }
+ var started struct {
+ RunID string `json:"run_id"`
+ }
+ if err := json.Unmarshal(start.Body.Bytes(), &started); err != nil {
+ t.Fatalf("decode run start: %v", err)
+ }
+ loop.waitStarted(t)
+
+ now = now.Add(2 * time.Minute)
+ if swept := srv.sweepOrphanedRuns(); swept != 1 {
+ t.Fatalf("sweepOrphanedRuns = %d, want 1", swept)
+ }
+ missing := getJSON(t, srv.Handler(), "/v1/runs/"+started.RunID+"/events", nil)
+ if missing.Code != http.StatusNotFound {
+ t.Fatalf("events after orphan sweep status = %d, want 404; body=%s", missing.Code, missing.Body.String())
+ }
+ loop.release(TurnResult{Content: "late", SessionID: started.RunID})
+}
+
+type blockingRunLoop struct {
+ mu sync.Mutex
+ calls []TurnRequest
+ started chan struct{}
+ done chan TurnResult
+ once sync.Once
+}
+
+func newBlockingRunLoop() *blockingRunLoop {
+ return &blockingRunLoop{
+ started: make(chan struct{}),
+ done: make(chan TurnResult, 1),
+ }
+}
+
+func (b *blockingRunLoop) RunTurn(context.Context, TurnRequest) (TurnResult, error) {
+ return TurnResult{}, errors.New("blockingRunLoop only supports StreamTurn")
+}
+
+func (b *blockingRunLoop) StreamTurn(ctx context.Context, req TurnRequest, _ StreamCallbacks) (TurnResult, error) {
+ b.mu.Lock()
+ b.calls = append(b.calls, req)
+ b.mu.Unlock()
+ b.once.Do(func() { close(b.started) })
+ select {
+ case <-ctx.Done():
+ return TurnResult{}, ctx.Err()
+ case result := <-b.done:
+ return result, nil
+ }
+}
+
+func (b *blockingRunLoop) waitStarted(t *testing.T) {
+ t.Helper()
+ select {
+ case <-b.started:
+ case <-time.After(time.Second):
+ t.Fatal("run did not start")
+ }
+}
+
+func (b *blockingRunLoop) release(result TurnResult) {
+ b.done <- result
+}
+
+func hasOutputItem(items []ResponseOutputItem, typ, name string) bool {
+ for _, item := range items {
+ if item.Type == typ && item.Name == name {
+ return true
+ }
+ }
+ return false
+}
+
+func hasOutputText(items []ResponseOutputItem, text string) bool {
+ for _, item := range items {
+ for _, part := range item.Content {
+ if part.Text == text {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+func historyContainsToolExchange(history []ChatMessage, callID, name, output string) bool {
+ var sawCall, sawResult bool
+ for _, msg := range history {
+ for _, call := range msg.ToolCalls {
+ if call.ID == callID && call.Name == name {
+ sawCall = true
+ }
+ }
+ if msg.Role == "tool" && msg.ToolCallID == callID && msg.Name == name && msg.Content == output {
+ sawResult = true
+ }
+ }
+ return sawCall && sawResult
+}
+
+func steppedClock(start time.Time, step time.Duration) func() time.Time {
+ var mu sync.Mutex
+ next := start
+ return func() time.Time {
+ mu.Lock()
+ defer mu.Unlock()
+ out := next
+ next = next.Add(step)
+ return out
+ }
+}
+
+func getJSON(t *testing.T, h http.Handler, path string, headers map[string]string) *httptest.ResponseRecorder {
+ t.Helper()
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, path, nil)
+ for k, v := range headers {
+ req.Header.Set(k, v)
+ }
+ h.ServeHTTP(rec, req)
+ _, _ = io.Copy(io.Discard, rec.Result().Body)
+ return rec
+}
+
+func deleteJSON(t *testing.T, h http.Handler, path string, headers map[string]string) *httptest.ResponseRecorder {
+ t.Helper()
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodDelete, path, bytes.NewReader(nil))
+ for k, v := range headers {
+ req.Header.Set(k, v)
+ }
+ h.ServeHTTP(rec, req)
+ _, _ = io.Copy(io.Discard, rec.Result().Body)
+ return rec
+}
diff --git a/internal/apiserver/runs.go b/internal/apiserver/runs.go
new file mode 100644
index 000000000..b2091f552
--- /dev/null
+++ b/internal/apiserver/runs.go
@@ -0,0 +1,287 @@
+package apiserver
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "strings"
+ "sync"
+ "time"
+)
+
+const defaultRunStreamTTL = 5 * time.Minute
+
+type runRegistry struct {
+ mu sync.Mutex
+ ttl time.Duration
+ now func() time.Time
+ runs map[string]*runRecord
+ swept int
+}
+
+type runRecord struct {
+ id string
+ createdAt time.Time
+ events []runEvent
+ subscribers []chan runEvent
+ done bool
+ consumed bool
+}
+
+type runEvent struct {
+ Event string `json:"event"`
+ RunID string `json:"run_id"`
+ Timestamp int64 `json:"timestamp"`
+ Delta string `json:"delta,omitempty"`
+ Output string `json:"output,omitempty"`
+ Usage ResponseUsage `json:"usage,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+func newRunRegistry(ttl time.Duration, now func() time.Time) *runRegistry {
+ if ttl <= 0 {
+ ttl = defaultRunStreamTTL
+ }
+ if now == nil {
+ now = time.Now
+ }
+ return &runRegistry{
+ ttl: ttl,
+ now: now,
+ runs: make(map[string]*runRecord),
+ }
+}
+
+func (r *runRegistry) setClock(now func() time.Time) {
+ if now == nil {
+ now = time.Now
+ }
+ r.mu.Lock()
+ r.now = now
+ r.mu.Unlock()
+}
+
+func (r *runRegistry) create(id string) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ r.runs[id] = &runRecord{id: id, createdAt: r.now()}
+}
+
+func (r *runRegistry) publish(id string, ev runEvent) {
+ r.mu.Lock()
+ rec := r.runs[id]
+ if rec == nil {
+ r.mu.Unlock()
+ return
+ }
+ rec.events = append(rec.events, ev)
+ subs := append([]chan runEvent(nil), rec.subscribers...)
+ r.mu.Unlock()
+ for _, ch := range subs {
+ select {
+ case ch <- ev:
+ default:
+ }
+ }
+}
+
+func (r *runRegistry) finish(id string) {
+ r.mu.Lock()
+ rec := r.runs[id]
+ if rec == nil {
+ r.mu.Unlock()
+ return
+ }
+ rec.done = true
+ subs := append([]chan runEvent(nil), rec.subscribers...)
+ rec.subscribers = nil
+ r.mu.Unlock()
+ for _, ch := range subs {
+ close(ch)
+ }
+}
+
+func (r *runRegistry) subscribe(id string) ([]runEvent, <-chan runEvent, bool, bool) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ rec := r.runs[id]
+ if rec == nil {
+ return nil, nil, false, false
+ }
+ rec.consumed = true
+ backlog := append([]runEvent(nil), rec.events...)
+ if rec.done {
+ return backlog, nil, true, true
+ }
+ ch := make(chan runEvent, 32)
+ rec.subscribers = append(rec.subscribers, ch)
+ return backlog, ch, true, false
+}
+
+func (r *runRegistry) remove(id string) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ delete(r.runs, id)
+}
+
+func (r *runRegistry) sweepOrphans() int {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ now := r.now()
+ var swept int
+ for id, rec := range r.runs {
+ if rec.consumed || len(rec.subscribers) > 0 {
+ continue
+ }
+ if now.Sub(rec.createdAt) > r.ttl {
+ delete(r.runs, id)
+ swept++
+ }
+ }
+ r.swept += swept
+ return swept
+}
+
+func (r *runRegistry) stats() map[string]any {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ return map[string]any{
+ "active": len(r.runs),
+ "orphaned_swept": r.swept,
+ "ttl_seconds": int(r.ttl.Seconds()),
+ }
+}
+
+func (s *Server) handleRuns(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ writeOpenAIError(w, http.StatusMethodNotAllowed, "Method not allowed", "invalid_request_error", "", "method_not_allowed")
+ return
+ }
+ if !s.authorized(r) {
+ writeOpenAIError(w, http.StatusUnauthorized, "Invalid API key", "invalid_request_error", "", "invalid_api_key")
+ return
+ }
+ if s.loop == nil {
+ writeOpenAIError(w, http.StatusServiceUnavailable, "Native turn loop is not configured", "server_error", "", "turn_loop_unavailable")
+ return
+ }
+ body, err := readLimitedBody(w, r, s.maxBodyBytes)
+ if err != nil {
+ writeOpenAIError(w, http.StatusRequestEntityTooLarge, "Request body too large.", "invalid_request_error", "", "body_too_large")
+ return
+ }
+ var req map[string]any
+ if err := json.Unmarshal(body, &req); err != nil {
+ writeOpenAIError(w, http.StatusBadRequest, "Invalid JSON in request body", "invalid_request_error", "", "invalid_json")
+ return
+ }
+ runID := "run_" + randomHexFromTime(s.now())
+ turnReq, _, errResp := s.buildResponseTurnRequest(req)
+ if errResp != nil {
+ writeOpenAIError(w, errResp.status, errResp.message, "invalid_request_error", errResp.param, errResp.code)
+ return
+ }
+ if explicit := stringField(req, "session_id"); explicit != "" {
+ turnReq.SessionID = explicit
+ } else if turnReq.SessionID == "" || strings.HasPrefix(turnReq.SessionID, "api-") {
+ turnReq.SessionID = runID
+ }
+ s.runs.setClock(s.now)
+ s.runs.sweepOrphans()
+ s.runs.create(runID)
+ go s.runAsyncTurn(runID, turnReq)
+ writeJSON(w, http.StatusAccepted, map[string]any{"run_id": runID, "status": "started"})
+}
+
+func (s *Server) runAsyncTurn(runID string, turnReq TurnRequest) {
+ now := s.now().Unix()
+ s.runs.publish(runID, runEvent{Event: "run.started", RunID: runID, Timestamp: now})
+ result, err := s.loop.StreamTurn(context.Background(), turnReq, StreamCallbacks{
+ OnToken: func(token string) error {
+ s.runs.publish(runID, runEvent{Event: "message.delta", RunID: runID, Timestamp: s.now().Unix(), Delta: token})
+ return nil
+ },
+ })
+ if err != nil {
+ s.runs.publish(runID, runEvent{Event: "run.failed", RunID: runID, Timestamp: s.now().Unix(), Error: err.Error()})
+ s.runs.finish(runID)
+ return
+ }
+ s.runs.publish(runID, runEvent{
+ Event: "run.completed",
+ RunID: runID,
+ Timestamp: s.now().Unix(),
+ Output: result.Content,
+ Usage: ResponseUsage{
+ InputTokens: result.Usage.PromptTokens,
+ OutputTokens: result.Usage.CompletionTokens,
+ TotalTokens: result.Usage.TotalTokens,
+ },
+ })
+ s.runs.finish(runID)
+}
+
+func (s *Server) handleRunEvents(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ writeOpenAIError(w, http.StatusMethodNotAllowed, "Method not allowed", "invalid_request_error", "", "method_not_allowed")
+ return
+ }
+ if !s.authorized(r) {
+ writeOpenAIError(w, http.StatusUnauthorized, "Invalid API key", "invalid_request_error", "", "invalid_api_key")
+ return
+ }
+ suffix := strings.TrimPrefix(r.URL.Path, "/v1/runs/")
+ runID, ok := strings.CutSuffix(suffix, "/events")
+ if !ok || runID == "" || strings.Contains(runID, "/") {
+ writeOpenAIError(w, http.StatusNotFound, "Run not found", "invalid_request_error", "", "run_not_found")
+ return
+ }
+ backlog, ch, exists, done := s.runs.subscribe(runID)
+ if !exists {
+ writeOpenAIError(w, http.StatusNotFound, "Run not found: "+runID, "invalid_request_error", "", "run_not_found")
+ return
+ }
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.Header().Set("Cache-Control", "no-cache")
+ w.Header().Set("X-Accel-Buffering", "no")
+ w.WriteHeader(http.StatusOK)
+ for _, ev := range backlog {
+ writeSSEData(w, ev)
+ }
+ flush(w)
+ if done {
+ writeSSEComment(w, "stream closed")
+ flush(w)
+ s.runs.remove(runID)
+ return
+ }
+ for {
+ select {
+ case <-r.Context().Done():
+ return
+ case ev, ok := <-ch:
+ if !ok {
+ writeSSEComment(w, "stream closed")
+ flush(w)
+ s.runs.remove(runID)
+ return
+ }
+ writeSSEData(w, ev)
+ flush(w)
+ }
+ }
+}
+
+func (s *Server) sweepOrphanedRuns() int {
+ s.runs.setClock(s.now)
+ return s.runs.sweepOrphans()
+}
+
+func (s *Server) runHealthStatus() map[string]any {
+ return s.runs.stats()
+}
+
+func writeSSEComment(w http.ResponseWriter, text string) error {
+ _, err := w.Write([]byte(": " + text + "\n\n"))
+ return err
+}
diff --git a/internal/apiserver/server.go b/internal/apiserver/server.go
new file mode 100644
index 000000000..da771f868
--- /dev/null
+++ b/internal/apiserver/server.go
@@ -0,0 +1,657 @@
+package apiserver
+
+import (
+ "context"
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "sync"
+ "time"
+)
+
+const (
+ defaultModelName = "gormes-agent"
+ defaultMaxRequestBytes int64 = 1_000_000
+ maxNormalizedTextLength = 65_536
+ maxContentListSize = 1_000
+)
+
+// Config wires the native API server HTTP surface.
+type Config struct {
+ APIKey string
+ ModelName string
+ MaxBodyBytes int64
+ Loop TurnLoop
+ ResponseStore *ResponseStore
+ RunTTL time.Duration
+}
+
+// Server exposes the OpenAI-compatible HTTP routes that can be mounted by the
+// gateway binary.
+type Server struct {
+ apiKey string
+ modelName string
+ maxBodyBytes int64
+ loop TurnLoop
+ responseStore *ResponseStore
+ runs *runRegistry
+ statusMu sync.Mutex
+ previousResponseMisses int
+ now func() time.Time
+ mux *http.ServeMux
+}
+
+// ChatMessage is the normalized text shape passed from HTTP into gateway turns.
+type ChatMessage struct {
+ Role string `json:"role"`
+ Content string `json:"content"`
+ ToolCalls []ToolCall `json:"tool_calls,omitempty"`
+ ToolCallID string `json:"tool_call_id,omitempty"`
+ Name string `json:"name,omitempty"`
+}
+
+// ToolCall is the OpenAI function-call metadata preserved in response chains.
+type ToolCall struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Arguments string `json:"arguments"`
+}
+
+// TurnRequest is the chat-completions request after OpenAI message/content
+// normalization and session-handle resolution.
+type TurnRequest struct {
+ Model string
+ UserMessage string
+ History []ChatMessage
+ SystemPrompt string
+ SessionID string
+}
+
+// Usage is the OpenAI-compatible token accounting shape used by both normal
+// and streaming chat-completion responses.
+type Usage struct {
+ PromptTokens int
+ CompletionTokens int
+ TotalTokens int
+}
+
+// TurnResult is the native turn-loop result consumed by HTTP response writers.
+type TurnResult struct {
+ Content string
+ SessionID string
+ Usage Usage
+ FinishReason string
+ Messages []ChatMessage
+}
+
+// StreamCallbacks receives token deltas from a streaming native turn.
+type StreamCallbacks struct {
+ OnToken func(string) error
+}
+
+// TurnLoop is the minimal adapter seam between HTTP and the native Gormes turn
+// loop. NewKernelTurnLoop provides the production implementation.
+type TurnLoop interface {
+ RunTurn(ctx context.Context, req TurnRequest) (TurnResult, error)
+ StreamTurn(ctx context.Context, req TurnRequest, cb StreamCallbacks) (TurnResult, error)
+}
+
+// NewServer constructs the route set without binding a socket.
+func NewServer(cfg Config) *Server {
+ model := strings.TrimSpace(cfg.ModelName)
+ if model == "" {
+ model = defaultModelName
+ }
+ maxBody := cfg.MaxBodyBytes
+ if maxBody <= 0 {
+ maxBody = defaultMaxRequestBytes
+ }
+ responseStore := cfg.ResponseStore
+ if responseStore == nil {
+ responseStore = NewResponseStore(defaultMaxStoredResponses)
+ }
+ runTTL := cfg.RunTTL
+ if runTTL <= 0 {
+ runTTL = defaultRunStreamTTL
+ }
+ s := &Server{
+ apiKey: cfg.APIKey,
+ modelName: model,
+ maxBodyBytes: maxBody,
+ loop: cfg.Loop,
+ responseStore: responseStore,
+ runs: newRunRegistry(runTTL, time.Now),
+ now: time.Now,
+ mux: http.NewServeMux(),
+ }
+ s.routes()
+ return s
+}
+
+// Handler returns an http.Handler suitable for httptest or http.Server.
+func (s *Server) Handler() http.Handler {
+ return securityHeaders(s.mux)
+}
+
+func (s *Server) routes() {
+ s.mux.HandleFunc("/health", s.handleHealth)
+ s.mux.HandleFunc("/v1/health", s.handleHealth)
+ s.mux.HandleFunc("/v1/models", s.handleModels)
+ s.mux.HandleFunc("/v1/chat/completions", s.handleChatCompletions)
+ s.mux.HandleFunc("/v1/responses", s.handleResponses)
+ s.mux.HandleFunc("/v1/responses/", s.handleResponseByID)
+ s.mux.HandleFunc("/v1/runs", s.handleRuns)
+ s.mux.HandleFunc("/v1/runs/", s.handleRunEvents)
+}
+
+func securityHeaders(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("X-Content-Type-Options", "nosniff")
+ w.Header().Set("Referrer-Policy", "no-referrer")
+ next.ServeHTTP(w, r)
+ })
+}
+
+func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ writeOpenAIError(w, http.StatusMethodNotAllowed, "Method not allowed", "invalid_request_error", "", "method_not_allowed")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{
+ "status": "ok",
+ "platform": "gormes-agent",
+ "responses": s.responseHealthStatus(),
+ "runs": s.runHealthStatus(),
+ })
+}
+
+func (s *Server) handleModels(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ writeOpenAIError(w, http.StatusMethodNotAllowed, "Method not allowed", "invalid_request_error", "", "method_not_allowed")
+ return
+ }
+ if !s.authorized(r) {
+ writeOpenAIError(w, http.StatusUnauthorized, "Invalid API key", "invalid_request_error", "", "invalid_api_key")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{
+ "object": "list",
+ "data": []map[string]any{
+ {
+ "id": s.modelName,
+ "object": "model",
+ "created": s.now().Unix(),
+ "owned_by": "gormes",
+ "permission": []any{},
+ "root": s.modelName,
+ "parent": nil,
+ },
+ },
+ })
+}
+
+func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ writeOpenAIError(w, http.StatusMethodNotAllowed, "Method not allowed", "invalid_request_error", "", "method_not_allowed")
+ return
+ }
+ if !s.authorized(r) {
+ writeOpenAIError(w, http.StatusUnauthorized, "Invalid API key", "invalid_request_error", "", "invalid_api_key")
+ return
+ }
+ if s.loop == nil {
+ writeOpenAIError(w, http.StatusServiceUnavailable, "Native turn loop is not configured", "server_error", "", "turn_loop_unavailable")
+ return
+ }
+
+ body, err := readLimitedBody(w, r, s.maxBodyBytes)
+ if err != nil {
+ var maxErr *http.MaxBytesError
+ if errors.As(err, &maxErr) || errors.Is(err, errBodyTooLarge) {
+ writeOpenAIError(w, http.StatusRequestEntityTooLarge, "Request body too large.", "invalid_request_error", "", "body_too_large")
+ return
+ }
+ writeOpenAIError(w, http.StatusBadRequest, err.Error(), "invalid_request_error", "", "invalid_request_body")
+ return
+ }
+
+ var bodyReq chatCompletionRequest
+ if err := json.Unmarshal(body, &bodyReq); err != nil {
+ writeOpenAIError(w, http.StatusBadRequest, "Invalid JSON in request body", "invalid_request_error", "", "invalid_json")
+ return
+ }
+ if len(bodyReq.Messages) == 0 {
+ writeOpenAIError(w, http.StatusBadRequest, "Missing or invalid 'messages' field", "invalid_request_error", "messages", "invalid_messages")
+ return
+ }
+
+ turnReq, errResp := s.buildTurnRequest(r, bodyReq)
+ if errResp != nil {
+ writeOpenAIError(w, errResp.status, errResp.message, "invalid_request_error", errResp.param, errResp.code)
+ return
+ }
+ model := strings.TrimSpace(bodyReq.Model)
+ if model == "" {
+ model = s.modelName
+ }
+ turnReq.Model = model
+
+ completionID := "chatcmpl-" + randomHexFromTime(s.now())
+ created := s.now().Unix()
+ if bodyReq.Stream {
+ s.writeStreamingChatCompletion(w, r, completionID, created, model, turnReq)
+ return
+ }
+
+ result, err := s.loop.RunTurn(r.Context(), turnReq)
+ if err != nil {
+ writeOpenAIError(w, http.StatusInternalServerError, "Internal server error: "+err.Error(), "server_error", "", "turn_failed")
+ return
+ }
+ sessionID := result.SessionID
+ if sessionID == "" {
+ sessionID = turnReq.SessionID
+ }
+ if sessionID != "" {
+ w.Header().Set("X-Hermes-Session-Id", sessionID)
+ }
+ writeJSON(w, http.StatusOK, chatCompletionResponse(completionID, created, model, result))
+}
+
+func (s *Server) writeStreamingChatCompletion(w http.ResponseWriter, r *http.Request, completionID string, created int64, model string, turnReq TurnRequest) {
+ sessionID := turnReq.SessionID
+ if sessionID != "" {
+ w.Header().Set("X-Hermes-Session-Id", sessionID)
+ }
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.Header().Set("Cache-Control", "no-cache")
+ w.Header().Set("X-Accel-Buffering", "no")
+ w.WriteHeader(http.StatusOK)
+
+ writeSSEData(w, chatCompletionChunk{
+ ID: completionID,
+ Object: "chat.completion.chunk",
+ Created: created,
+ Model: model,
+ Choices: []chatCompletionChunkChoice{{
+ Index: 0,
+ Delta: map[string]string{"role": "assistant"},
+ }},
+ })
+ flush(w)
+
+ result, err := s.loop.StreamTurn(r.Context(), turnReq, StreamCallbacks{
+ OnToken: func(token string) error {
+ writeSSEData(w, chatCompletionChunk{
+ ID: completionID,
+ Object: "chat.completion.chunk",
+ Created: created,
+ Model: model,
+ Choices: []chatCompletionChunkChoice{{
+ Index: 0,
+ Delta: map[string]string{"content": token},
+ }},
+ })
+ flush(w)
+ return nil
+ },
+ })
+ if err != nil {
+ writeSSEEvent(w, "error", openAIErrorEnvelope("Internal server error: "+err.Error(), "server_error", "", "stream_failed"))
+ writeSSEDone(w)
+ flush(w)
+ return
+ }
+ if result.SessionID != "" && sessionID == "" {
+ // Header-phase streaming cannot publish a late provider session handle,
+ // but keeping this branch documents the intended continuity fallback.
+ sessionID = result.SessionID
+ }
+ writeSSEData(w, chatCompletionChunk{
+ ID: completionID,
+ Object: "chat.completion.chunk",
+ Created: created,
+ Model: model,
+ Choices: []chatCompletionChunkChoice{{
+ Index: 0,
+ Delta: map[string]string{},
+ FinishReason: stringPtr("stop"),
+ }},
+ Usage: usagePayload(result.Usage),
+ })
+ writeSSEDone(w)
+ flush(w)
+}
+
+func (s *Server) authorized(r *http.Request) bool {
+ if s.apiKey == "" {
+ return true
+ }
+ if auth := strings.TrimSpace(r.Header.Get("Authorization")); strings.HasPrefix(auth, "Bearer ") {
+ token := strings.TrimSpace(strings.TrimPrefix(auth, "Bearer "))
+ if hmac.Equal([]byte(token), []byte(s.apiKey)) {
+ return true
+ }
+ }
+ if key := strings.TrimSpace(r.Header.Get("X-API-Key")); key != "" {
+ return hmac.Equal([]byte(key), []byte(s.apiKey))
+ }
+ return false
+}
+
+type chatCompletionRequest struct {
+ Model string `json:"model"`
+ Messages []incomingMessage `json:"messages"`
+ Stream bool `json:"stream"`
+}
+
+type incomingMessage struct {
+ Role string `json:"role"`
+ Content any `json:"content"`
+}
+
+type requestError struct {
+ status int
+ message string
+ param string
+ code string
+}
+
+func (s *Server) buildTurnRequest(r *http.Request, req chatCompletionRequest) (TurnRequest, *requestError) {
+ var (
+ systemParts []string
+ conversation []ChatMessage
+ firstUser string
+ )
+ for idx, msg := range req.Messages {
+ role := strings.ToLower(strings.TrimSpace(msg.Role))
+ content, err := normalizeChatContent(msg.Content)
+ if err != nil {
+ return TurnRequest{}, &requestError{
+ status: http.StatusBadRequest,
+ message: err.message,
+ param: fmt.Sprintf("messages[%d].content", idx),
+ code: err.code,
+ }
+ }
+ switch role {
+ case "system", "developer":
+ if strings.TrimSpace(content) != "" {
+ systemParts = append(systemParts, content)
+ }
+ case "user", "assistant":
+ conversation = append(conversation, ChatMessage{Role: role, Content: content})
+ if role == "user" && firstUser == "" {
+ firstUser = content
+ }
+ }
+ }
+
+ lastUser := -1
+ for i := len(conversation) - 1; i >= 0; i-- {
+ if conversation[i].Role == "user" {
+ lastUser = i
+ break
+ }
+ }
+ if lastUser < 0 || !hasVisibleText(conversation[lastUser].Content) {
+ return TurnRequest{}, &requestError{
+ status: http.StatusBadRequest,
+ message: "No user message found in messages",
+ code: "missing_user_message",
+ }
+ }
+
+ systemPrompt := strings.Join(systemParts, "\n")
+ sessionID := strings.TrimSpace(r.Header.Get("X-Hermes-Session-Id"))
+ if strings.ContainsAny(sessionID, "\r\n\x00") {
+ return TurnRequest{}, &requestError{
+ status: http.StatusBadRequest,
+ message: "Invalid session ID",
+ param: "X-Hermes-Session-Id",
+ code: "invalid_session_id",
+ }
+ }
+ if sessionID == "" {
+ sessionID = deriveChatSessionID(systemPrompt, firstUser)
+ }
+
+ return TurnRequest{
+ UserMessage: conversation[lastUser].Content,
+ History: append([]ChatMessage(nil), conversation[:lastUser]...),
+ SystemPrompt: systemPrompt,
+ SessionID: sessionID,
+ }, nil
+}
+
+type contentNormalizeError struct {
+ code string
+ message string
+}
+
+func normalizeChatContent(content any) (string, *contentNormalizeError) {
+ return normalizeChatContentDepth(content, 0)
+}
+
+func normalizeChatContentDepth(content any, depth int) (string, *contentNormalizeError) {
+ if depth > 10 || content == nil {
+ return "", nil
+ }
+ switch v := content.(type) {
+ case string:
+ return truncateText(v), nil
+ case []any:
+ limit := len(v)
+ if limit > maxContentListSize {
+ limit = maxContentListSize
+ }
+ parts := make([]string, 0, limit)
+ total := 0
+ for _, item := range v[:limit] {
+ var text string
+ switch p := item.(type) {
+ case string:
+ text = p
+ case []any:
+ nested, err := normalizeChatContentDepth(p, depth+1)
+ if err != nil {
+ return "", err
+ }
+ text = nested
+ case map[string]any:
+ partText, err := normalizeContentPart(p)
+ if err != nil {
+ return "", err
+ }
+ text = partText
+ default:
+ continue
+ }
+ if text == "" {
+ continue
+ }
+ trimmed := truncateText(text)
+ parts = append(parts, trimmed)
+ total += len(trimmed)
+ if total >= maxNormalizedTextLength {
+ break
+ }
+ }
+ return truncateText(strings.Join(parts, "\n")), nil
+ default:
+ return truncateText(fmt.Sprint(v)), nil
+ }
+}
+
+func normalizeContentPart(part map[string]any) (string, *contentNormalizeError) {
+ rawType, ok := part["type"]
+ partType := ""
+ if ok && rawType != nil {
+ partType = strings.ToLower(strings.TrimSpace(fmt.Sprint(rawType)))
+ }
+ switch partType {
+ case "text", "input_text", "output_text":
+ text, ok := part["text"]
+ if !ok || text == nil {
+ return "", nil
+ }
+ return fmt.Sprint(text), nil
+ case "image_url", "input_image":
+ return "", nil
+ case "file", "input_file":
+ return "", &contentNormalizeError{
+ code: "unsupported_content_type",
+ message: "Uploaded files and document inputs are not supported on this endpoint.",
+ }
+ case "":
+ return "", &contentNormalizeError{
+ code: "invalid_content_part",
+ message: "Content parts must include a type.",
+ }
+ default:
+ return "", &contentNormalizeError{
+ code: "unsupported_content_type",
+ message: fmt.Sprintf("Unsupported content part type %q. Only text and image_url/input_image parts are supported.", part["type"]),
+ }
+ }
+}
+
+func truncateText(s string) string {
+ if len(s) <= maxNormalizedTextLength {
+ return s
+ }
+ return s[:maxNormalizedTextLength]
+}
+
+func hasVisibleText(s string) bool {
+ return strings.TrimSpace(s) != ""
+}
+
+func deriveChatSessionID(systemPrompt, firstUserMessage string) string {
+ sum := sha256.Sum256([]byte(systemPrompt + "\n" + firstUserMessage))
+ return "api-" + hex.EncodeToString(sum[:])[:16]
+}
+
+func randomHexFromTime(t time.Time) string {
+ sum := sha256.Sum256([]byte(fmt.Sprintf("%d", t.UnixNano())))
+ return hex.EncodeToString(sum[:])[:29]
+}
+
+type chatCompletionChunk struct {
+ ID string `json:"id"`
+ Object string `json:"object"`
+ Created int64 `json:"created"`
+ Model string `json:"model"`
+ Choices []chatCompletionChunkChoice `json:"choices"`
+ Usage map[string]int `json:"usage,omitempty"`
+}
+
+type chatCompletionChunkChoice struct {
+ Index int `json:"index"`
+ Delta map[string]string `json:"delta"`
+ Logprobs any `json:"logprobs"`
+ FinishReason *string `json:"finish_reason"`
+}
+
+func chatCompletionResponse(id string, created int64, model string, result TurnResult) map[string]any {
+ finish := strings.TrimSpace(result.FinishReason)
+ if finish == "" {
+ finish = "stop"
+ }
+ return map[string]any{
+ "id": id,
+ "object": "chat.completion",
+ "created": created,
+ "model": model,
+ "choices": []map[string]any{
+ {
+ "index": 0,
+ "message": map[string]any{
+ "role": "assistant",
+ "content": result.Content,
+ },
+ "logprobs": nil,
+ "finish_reason": finish,
+ },
+ },
+ "usage": usagePayload(result.Usage),
+ }
+}
+
+func usagePayload(u Usage) map[string]int {
+ return map[string]int{
+ "prompt_tokens": u.PromptTokens,
+ "completion_tokens": u.CompletionTokens,
+ "total_tokens": u.TotalTokens,
+ }
+}
+
+func writeJSON(w http.ResponseWriter, status int, body any) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(body)
+}
+
+func writeOpenAIError(w http.ResponseWriter, status int, message, errType, param, code string) {
+ writeJSON(w, status, openAIErrorEnvelope(message, errType, param, code))
+}
+
+func openAIErrorEnvelope(message, errType, param, code string) map[string]any {
+ return map[string]any{
+ "error": map[string]any{
+ "message": message,
+ "type": errType,
+ "param": nullableString(param),
+ "code": nullableString(code),
+ },
+ }
+}
+
+func nullableString(s string) any {
+ if s == "" {
+ return nil
+ }
+ return s
+}
+
+func stringPtr(s string) *string { return &s }
+
+func writeSSEData(w http.ResponseWriter, body any) error {
+ raw, _ := json.Marshal(body)
+ _, err := fmt.Fprintf(w, "data: %s\n\n", raw)
+ return err
+}
+
+func writeSSEEvent(w http.ResponseWriter, event string, body any) error {
+ raw, _ := json.Marshal(body)
+ _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, raw)
+ return err
+}
+
+func writeSSEDone(w http.ResponseWriter) error {
+ _, err := io.WriteString(w, "data: [DONE]\n\n")
+ return err
+}
+
+func flush(w http.ResponseWriter) {
+ if f, ok := w.(http.Flusher); ok {
+ f.Flush()
+ }
+}
+
+var errBodyTooLarge = errors.New("api server: request body too large")
+
+func readLimitedBody(w http.ResponseWriter, r *http.Request, maxBytes int64) ([]byte, error) {
+ if r.ContentLength > maxBytes {
+ return nil, errBodyTooLarge
+ }
+ reader := http.MaxBytesReader(w, r.Body, maxBytes)
+ defer reader.Close()
+ return io.ReadAll(reader)
+}
diff --git a/internal/architectureplanner/config.go b/internal/architectureplanner/config.go
index 0564d193f..7c49613dc 100644
--- a/internal/architectureplanner/config.go
+++ b/internal/architectureplanner/config.go
@@ -3,6 +3,7 @@ package architectureplanner
import (
"fmt"
"path/filepath"
+ "strconv"
)
type Config struct {
@@ -20,6 +21,12 @@ type Config struct {
HonchoRepoURL string
Validate bool
SyncRepos bool
+ // PlannerQuarantineLimit caps how many quarantined rows are surfaced in
+ // the planner's call-to-action context block. 0 means no cap. Sourced
+ // from GORMES_PLANNER_QUARANTINE_LIMIT, default 5 (mirrors the autoloop
+ // runtime setting added in Task 4 โ kept in sync but as a separate field
+ // because the planner's Config is independent of autoloop's).
+ PlannerQuarantineLimit int
}
func ConfigFromEnv(repoRoot string, env map[string]string) (Config, error) {
@@ -29,20 +36,21 @@ func ConfigFromEnv(repoRoot string, env map[string]string) (Config, error) {
parent := filepath.Dir(repoRoot)
cfg := Config{
- RepoRoot: repoRoot,
- ProgressJSON: filepath.Join(repoRoot, "docs", "content", "building-gormes", "architecture_plan", "progress.json"),
- RunRoot: filepath.Join(repoRoot, ".codex", "architecture-planner"),
- AutoloopRunRoot: filepath.Join(repoRoot, ".codex", "orchestrator"),
- Backend: "codexu",
- Mode: "safe",
- HermesDir: filepath.Join(parent, "hermes-agent"),
- GBrainDir: filepath.Join(parent, "gbrain"),
- HonchoDir: filepath.Join(parent, "honcho"),
- HermesRepoURL: "https://github.com/NousResearch/hermes-agent.git",
- GBrainRepoURL: "https://github.com/garrytan/gbrain.git",
- HonchoRepoURL: "https://github.com/plastic-labs/honcho",
- Validate: true,
- SyncRepos: true,
+ RepoRoot: repoRoot,
+ ProgressJSON: filepath.Join(repoRoot, "docs", "content", "building-gormes", "architecture_plan", "progress.json"),
+ RunRoot: filepath.Join(repoRoot, ".codex", "architecture-planner"),
+ AutoloopRunRoot: filepath.Join(repoRoot, ".codex", "orchestrator"),
+ Backend: "codexu",
+ Mode: "safe",
+ HermesDir: filepath.Join(parent, "hermes-agent"),
+ GBrainDir: filepath.Join(parent, "gbrain"),
+ HonchoDir: filepath.Join(parent, "honcho"),
+ HermesRepoURL: "https://github.com/NousResearch/hermes-agent.git",
+ GBrainRepoURL: "https://github.com/garrytan/gbrain.git",
+ HonchoRepoURL: "https://github.com/plastic-labs/honcho",
+ Validate: true,
+ SyncRepos: true,
+ PlannerQuarantineLimit: 5,
}
if value := env["PROGRESS_JSON"]; value != "" {
@@ -84,6 +92,16 @@ func ConfigFromEnv(repoRoot string, env map[string]string) (Config, error) {
if value := env["PLANNER_SYNC_REPOS"]; value == "0" {
cfg.SyncRepos = false
}
+ if value := env["GORMES_PLANNER_QUARANTINE_LIMIT"]; value != "" {
+ n, err := strconv.Atoi(value)
+ if err != nil {
+ return Config{}, fmt.Errorf("GORMES_PLANNER_QUARANTINE_LIMIT must be an integer: %w", err)
+ }
+ if n < 0 {
+ return Config{}, fmt.Errorf("GORMES_PLANNER_QUARANTINE_LIMIT must be non-negative")
+ }
+ cfg.PlannerQuarantineLimit = n
+ }
return cfg, nil
}
diff --git a/internal/architectureplanner/context.go b/internal/architectureplanner/context.go
index fe8a204ea..702cf95af 100644
--- a/internal/architectureplanner/context.go
+++ b/internal/architectureplanner/context.go
@@ -29,6 +29,30 @@ type ContextBundle struct {
SyncResults []RepoSyncResult `json:"sync_results,omitempty"`
ImplementationInventory ImplementationInventory `json:"implementation_inventory"`
AutoloopAudit AutoloopAudit `json:"autoloop_audit"`
+ // QuarantinedRows surfaces autoloop-quarantined progress.json items as a
+ // call-to-action list for the planner (sorted most-attempted-then-oldest).
+ // Capped by Config.PlannerQuarantineLimit. Empty when no rows are
+ // quarantined or when progress.json could not be loaded.
+ QuarantinedRows []QuarantinedRowContext `json:"quarantined_rows,omitempty"`
+}
+
+// QuarantinedRowContext is the planner-side view of one autoloop-quarantined
+// row. Sorted by (AttemptCount desc, QuarantinedSince asc) so the planner
+// sees the most-attempted-then-oldest rows first. AuditCorroboration is
+// reserved for cross-referencing AutoloopAudit but is currently always
+// empty (the audit surface is subphase-level, not row-level).
+type QuarantinedRowContext struct {
+ PhaseID string `json:"phase_id"`
+ SubphaseID string `json:"subphase_id"`
+ ItemName string `json:"item_name"`
+ Contract string `json:"contract,omitempty"`
+ LastCategory progress.FailureCategory `json:"last_category,omitempty"`
+ AttemptCount int `json:"attempt_count,omitempty"`
+ BackendsTried []string `json:"backends_tried,omitempty"`
+ QuarantinedSince string `json:"quarantined_since,omitempty"`
+ SpecHash string `json:"spec_hash,omitempty"`
+ LastFailureExcerpt string `json:"last_failure_excerpt,omitempty"`
+ AuditCorroboration string `json:"audit_corroboration,omitempty"`
}
type ProgressInfo struct {
@@ -47,18 +71,17 @@ type ImplementationInventory struct {
}
func CollectContext(cfg Config, now time.Time) (ContextBundle, error) {
- progressInfo := ProgressInfo{}
- if p, err := progress.Load(cfg.ProgressJSON); err == nil {
- stats := p.Stats()
- progressInfo = ProgressInfo{
- Items: stats.Items.Total,
- Planned: stats.Items.Planned,
- InProgress: stats.Items.InProgress,
- Complete: stats.Items.Complete,
- }
- } else {
+ prog, err := progress.Load(cfg.ProgressJSON)
+ if err != nil {
return ContextBundle{}, err
}
+ stats := prog.Stats()
+ progressInfo := ProgressInfo{
+ Items: stats.Items.Total,
+ Planned: stats.Items.Planned,
+ InProgress: stats.Items.InProgress,
+ Complete: stats.Items.Complete,
+ }
roots := cfg.SourceRoots()
for i := range roots {
@@ -85,9 +108,80 @@ func CollectContext(cfg Config, now time.Time) (ContextBundle, error) {
SourceRoots: roots,
ImplementationInventory: inventory,
AutoloopAudit: audit,
+ QuarantinedRows: collectQuarantinedRows(prog, audit, cfg.PlannerQuarantineLimit),
}, nil
}
+// collectQuarantinedRows returns the quarantined items in prog sorted by
+// (AttemptCount desc, QuarantinedSince asc), capped at limit. limit=0
+// means unlimited. Items without a Health.Quarantine block are skipped.
+// Stderr tails are capped at 1 KiB so the planner prompt stays bounded.
+func collectQuarantinedRows(prog *progress.Progress, audit AutoloopAudit, limit int) []QuarantinedRowContext {
+ out := []QuarantinedRowContext{}
+ if prog == nil {
+ return out
+ }
+ for phaseID, phase := range prog.Phases {
+ for subID, sub := range phase.Subphases {
+ for i := range sub.Items {
+ it := &sub.Items[i]
+ if it.Health == nil || it.Health.Quarantine == nil {
+ continue
+ }
+ excerpt := ""
+ if it.Health.LastFailure != nil {
+ excerpt = capExcerpt(it.Health.LastFailure.StderrTail, 1024)
+ }
+ out = append(out, QuarantinedRowContext{
+ PhaseID: phaseID,
+ SubphaseID: subID,
+ ItemName: it.Name,
+ Contract: it.Contract,
+ LastCategory: it.Health.Quarantine.LastCategory,
+ AttemptCount: it.Health.AttemptCount,
+ BackendsTried: append([]string(nil), it.Health.BackendsTried...),
+ QuarantinedSince: it.Health.Quarantine.Since,
+ SpecHash: it.Health.Quarantine.SpecHash,
+ LastFailureExcerpt: excerpt,
+ AuditCorroboration: corroborateFromAudit(audit, phaseID, subID, it.Name),
+ })
+ }
+ }
+ }
+ sort.Slice(out, func(i, j int) bool {
+ if out[i].AttemptCount != out[j].AttemptCount {
+ return out[i].AttemptCount > out[j].AttemptCount
+ }
+ return out[i].QuarantinedSince < out[j].QuarantinedSince
+ })
+ if limit > 0 && len(out) > limit {
+ out = out[:limit]
+ }
+ return out
+}
+
+// capExcerpt returns at most max trailing bytes of s. The tail is preferred
+// over the head because failure stack traces are usually most diagnostic at
+// the bottom (panic site / final assertion). Returns s unchanged when short.
+func capExcerpt(s string, max int) string {
+ if len(s) <= max {
+ return s
+ }
+ return s[len(s)-max:]
+}
+
+// corroborateFromAudit would return a short note when SummarizeAutoloopAudit
+// already flagged this row as toxic/hot. AutoloopAudit currently exposes
+// subphase-level aggregates, not row-level, so this returns "" today.
+// Future work can scan audit.RecentFailedTasks for a matching task key.
+func corroborateFromAudit(audit AutoloopAudit, phaseID, subphaseID, itemName string) string {
+ _ = audit
+ _ = phaseID
+ _ = subphaseID
+ _ = itemName
+ return ""
+}
+
func autoloopLedgerPath(cfg Config) string {
if cfg.AutoloopRunRoot == "" {
return ""
diff --git a/internal/architectureplanner/context_test.go b/internal/architectureplanner/context_test.go
new file mode 100644
index 000000000..e964e3735
--- /dev/null
+++ b/internal/architectureplanner/context_test.go
@@ -0,0 +1,80 @@
+package architectureplanner
+
+import (
+ "testing"
+
+ "github.com/TrebuchetDynamics/gormes-agent/internal/progress"
+)
+
+func itemWithQuarantine(name string, attempts int, since string) progress.Item {
+ return progress.Item{
+ Name: name,
+ Contract: "do " + name,
+ Health: &progress.RowHealth{
+ AttemptCount: attempts,
+ Quarantine: &progress.Quarantine{
+ Since: since,
+ Threshold: 3,
+ SpecHash: "hash-" + name,
+ LastCategory: progress.FailureWorkerError,
+ },
+ },
+ }
+}
+
+func progressWithItems(items ...progress.Item) *progress.Progress {
+ return &progress.Progress{
+ Phases: map[string]progress.Phase{
+ "1": {Name: "P", Subphases: map[string]progress.Subphase{
+ "1.A": {Name: "S", Items: items},
+ }},
+ },
+ }
+}
+
+func TestCollectQuarantinedRows_SortsByAttemptCountThenSince(t *testing.T) {
+ prog := progressWithItems(
+ itemWithQuarantine("a", 2, "2026-04-24T10:00:00Z"), // fewer attempts, older
+ itemWithQuarantine("b", 5, "2026-04-24T12:00:00Z"), // most attempts
+ itemWithQuarantine("c", 5, "2026-04-24T11:00:00Z"), // tied attempts, older โ should come before b
+ )
+ rows := collectQuarantinedRows(prog, AutoloopAudit{}, 0)
+ if len(rows) != 3 {
+ t.Fatalf("expected 3 rows, got %d", len(rows))
+ }
+ if rows[0].ItemName != "c" {
+ t.Errorf("rows[0] = %q, want c (5 attempts, older)", rows[0].ItemName)
+ }
+ if rows[1].ItemName != "b" {
+ t.Errorf("rows[1] = %q, want b (5 attempts, newer)", rows[1].ItemName)
+ }
+ if rows[2].ItemName != "a" {
+ t.Errorf("rows[2] = %q, want a (2 attempts)", rows[2].ItemName)
+ }
+}
+
+func TestCollectQuarantinedRows_HonorsLimit(t *testing.T) {
+ items := make([]progress.Item, 0, 10)
+ for i := 0; i < 10; i++ {
+ items = append(items, itemWithQuarantine(string(rune('a'+i)), 1, "2026-04-24T12:00:00Z"))
+ }
+ rows := collectQuarantinedRows(progressWithItems(items...), AutoloopAudit{}, 5)
+ if len(rows) != 5 {
+ t.Fatalf("expected limit=5, got %d", len(rows))
+ }
+}
+
+func TestCollectQuarantinedRows_ExcludesNonQuarantined(t *testing.T) {
+ prog := progressWithItems(
+ itemWithQuarantine("a", 3, "2026-04-24T12:00:00Z"),
+ progress.Item{Name: "b", Contract: "do b"}, // no Health
+ progress.Item{Name: "c", Contract: "do c", Health: &progress.RowHealth{AttemptCount: 1}}, // no Quarantine
+ )
+ rows := collectQuarantinedRows(prog, AutoloopAudit{}, 0)
+ if len(rows) != 1 {
+ t.Fatalf("expected only quarantined row, got %d", len(rows))
+ }
+ if rows[0].ItemName != "a" {
+ t.Errorf("rows[0] = %q, want a", rows[0].ItemName)
+ }
+}
diff --git a/internal/architectureplanner/health_preservation_test.go b/internal/architectureplanner/health_preservation_test.go
new file mode 100644
index 000000000..c888a8fa1
--- /dev/null
+++ b/internal/architectureplanner/health_preservation_test.go
@@ -0,0 +1,83 @@
+package architectureplanner
+
+import (
+ "testing"
+
+ "github.com/TrebuchetDynamics/gormes-agent/internal/progress"
+)
+
+func docWithItem(item progress.Item) *progress.Progress {
+ return &progress.Progress{
+ Phases: map[string]progress.Phase{
+ "1": {
+ Name: "P",
+ Subphases: map[string]progress.Subphase{
+ "1.A": {Name: "S", Items: []progress.Item{item}},
+ },
+ },
+ },
+ }
+}
+
+func TestValidateHealthPreservation_IdenticalAccepted(t *testing.T) {
+ h := &progress.RowHealth{AttemptCount: 3, ConsecutiveFailures: 1}
+ before := docWithItem(progress.Item{Name: "x", Status: progress.StatusInProgress, Contract: "c", Health: h})
+ after := docWithItem(progress.Item{Name: "x", Status: progress.StatusInProgress, Contract: "c", Health: h})
+ if err := validateHealthPreservation(before, after); err != nil {
+ t.Fatalf("expected accepted, got %v", err)
+ }
+}
+
+func TestValidateHealthPreservation_ModifiedHealthRejected(t *testing.T) {
+ before := docWithItem(progress.Item{Name: "x", Contract: "c", Health: &progress.RowHealth{AttemptCount: 3}})
+ after := docWithItem(progress.Item{Name: "x", Contract: "c", Health: &progress.RowHealth{AttemptCount: 99}})
+ if err := validateHealthPreservation(before, after); err == nil {
+ t.Fatal("expected error when health was modified")
+ }
+}
+
+func TestValidateHealthPreservation_DroppedHealthRejected(t *testing.T) {
+ before := docWithItem(progress.Item{Name: "x", Contract: "c", Health: &progress.RowHealth{AttemptCount: 3}})
+ after := docWithItem(progress.Item{Name: "x", Contract: "c", Health: nil})
+ if err := validateHealthPreservation(before, after); err == nil {
+ t.Fatal("expected error when health was dropped")
+ }
+}
+
+func TestValidateHealthPreservation_DeletedRowAccepted(t *testing.T) {
+ before := docWithItem(progress.Item{Name: "x", Contract: "c", Health: &progress.RowHealth{AttemptCount: 3}})
+ after := &progress.Progress{
+ Phases: map[string]progress.Phase{
+ "1": {Name: "P", Subphases: map[string]progress.Subphase{"1.A": {Name: "S", Items: nil}}},
+ },
+ }
+ if err := validateHealthPreservation(before, after); err != nil {
+ t.Fatalf("deletion should be accepted, got %v", err)
+ }
+}
+
+func TestValidateHealthPreservation_SplitRowAccepted(t *testing.T) {
+ before := docWithItem(progress.Item{Name: "x", Contract: "umbrella", Health: &progress.RowHealth{AttemptCount: 3}})
+ after := &progress.Progress{
+ Phases: map[string]progress.Phase{
+ "1": {Name: "P", Subphases: map[string]progress.Subphase{
+ "1.A": {Name: "S", Items: []progress.Item{
+ {Name: "x-a", Contract: "split a"},
+ {Name: "x-b", Contract: "split b"},
+ }},
+ }},
+ },
+ }
+ if err := validateHealthPreservation(before, after); err != nil {
+ t.Fatalf("split (rename) should be accepted, got %v", err)
+ }
+}
+
+func TestValidateHealthPreservation_SpecChangedHealthPreservedAccepted(t *testing.T) {
+ h := &progress.RowHealth{AttemptCount: 3}
+ before := docWithItem(progress.Item{Name: "x", Contract: "old", Health: h})
+ after := docWithItem(progress.Item{Name: "x", Contract: "NEW SPEC", Health: h})
+ if err := validateHealthPreservation(before, after); err != nil {
+ t.Fatalf("spec change with health preserved should be accepted, got %v", err)
+ }
+}
diff --git a/internal/architectureplanner/ledger.go b/internal/architectureplanner/ledger.go
new file mode 100644
index 000000000..150ae19ce
--- /dev/null
+++ b/internal/architectureplanner/ledger.go
@@ -0,0 +1,121 @@
+package architectureplanner
+
+import (
+ "bufio"
+ "encoding/json"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "time"
+)
+
+// LedgerEvent is one entry in the planner runs.jsonl ledger.
+type LedgerEvent struct {
+ TS string `json:"ts"` // RFC3339
+ RunID string `json:"run_id"`
+ Trigger string `json:"trigger"` // "scheduled" | "event" | "manual" | "retry"
+ TriggerEvents []string `json:"trigger_events,omitempty"`
+ Backend string `json:"backend"`
+ Mode string `json:"mode"`
+ Status string `json:"status"` // "ok" | "validation_rejected" | "backend_failed" | "no_changes" | "needs_human_set"
+ Detail string `json:"detail,omitempty"`
+ BeforeStats ProgressStats `json:"before_stats,omitempty"`
+ AfterStats ProgressStats `json:"after_stats,omitempty"`
+ RowsChanged []RowChange `json:"rows_changed,omitempty"`
+ RetryAttempt int `json:"retry_attempt,omitempty"`
+ Keywords []string `json:"keywords,omitempty"` // L6 topical focus
+}
+
+// RowChange records one mutation to a progress.json row in a planner run.
+type RowChange struct {
+ PhaseID string `json:"phase_id"`
+ SubphaseID string `json:"subphase_id"`
+ ItemName string `json:"item_name"`
+ Kind string `json:"kind"` // "added" | "deleted" | "spec_changed" | "verdict_set"
+ Detail string `json:"detail,omitempty"`
+}
+
+// ProgressStats is a snapshot of progress.json composition at a point in time.
+type ProgressStats struct {
+ Shipped int `json:"shipped,omitempty"`
+ InProgress int `json:"in_progress,omitempty"`
+ Planned int `json:"planned,omitempty"`
+ Quarantined int `json:"quarantined,omitempty"`
+ NeedsHuman int `json:"needs_human,omitempty"`
+}
+
+// AppendLedgerEvent atomically appends one event as a single JSON line.
+// Uses O_APPEND|O_CREATE|O_WRONLY for POSIX-atomic line writes (lines under
+// PIPE_BUF (4096 bytes on Linux) are atomic per the syscall contract).
+func AppendLedgerEvent(path string, event LedgerEvent) error {
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ return fmt.Errorf("mkdir ledger dir: %w", err)
+ }
+ body, err := json.Marshal(event)
+ if err != nil {
+ return fmt.Errorf("marshal ledger event: %w", err)
+ }
+ body = append(body, '\n')
+
+ f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
+ if err != nil {
+ return fmt.Errorf("open ledger: %w", err)
+ }
+ defer f.Close()
+ _, err = f.Write(body)
+ return err
+}
+
+// LoadLedger reads all events from the ledger file. Bad lines are logged
+// and skipped; they do not abort the load.
+func LoadLedger(path string) ([]LedgerEvent, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil, nil
+ }
+ return nil, err
+ }
+ defer f.Close()
+ var events []LedgerEvent
+ scanner := bufio.NewScanner(f)
+ scanner.Buffer(make([]byte, 64*1024), 1024*1024) // up to 1 MiB per line
+ for scanner.Scan() {
+ line := scanner.Bytes()
+ if len(line) == 0 {
+ continue
+ }
+ var event LedgerEvent
+ if err := json.Unmarshal(line, &event); err != nil {
+ // Skip corrupt lines; do not propagate.
+ continue
+ }
+ events = append(events, event)
+ }
+ if err := scanner.Err(); err != nil && err != io.EOF {
+ return events, err
+ }
+ return events, nil
+}
+
+// LoadLedgerWindow returns events within [now-window, now] inclusive. Bad
+// timestamps are skipped.
+func LoadLedgerWindow(path string, window time.Duration, now time.Time) ([]LedgerEvent, error) {
+ all, err := LoadLedger(path)
+ if err != nil {
+ return nil, err
+ }
+ cutoff := now.Add(-window)
+ out := []LedgerEvent{}
+ for _, ev := range all {
+ t, err := time.Parse(time.RFC3339, ev.TS)
+ if err != nil {
+ continue
+ }
+ if !t.Before(cutoff) && !t.After(now) {
+ out = append(out, ev)
+ }
+ }
+ return out, nil
+}
diff --git a/internal/architectureplanner/ledger_test.go b/internal/architectureplanner/ledger_test.go
new file mode 100644
index 000000000..5a3f1fa49
--- /dev/null
+++ b/internal/architectureplanner/ledger_test.go
@@ -0,0 +1,134 @@
+package architectureplanner
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+)
+
+func TestLedgerEvent_RoundTrip(t *testing.T) {
+ event := LedgerEvent{
+ TS: "2026-04-25T10:00:00Z",
+ RunID: "20260425T100000Z",
+ Trigger: "event",
+ TriggerEvents: []string{"trig-1", "trig-2"},
+ Backend: "codexu",
+ Mode: "safe",
+ Status: "ok",
+ BeforeStats: ProgressStats{Shipped: 10, Planned: 50, Quarantined: 2},
+ AfterStats: ProgressStats{Shipped: 11, Planned: 49, Quarantined: 1},
+ RowsChanged: []RowChange{
+ {PhaseID: "2", SubphaseID: "2.B", ItemName: "row-1", Kind: "spec_changed"},
+ },
+ Keywords: []string{"honcho"},
+ }
+ data, err := json.Marshal(event)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ var got LedgerEvent
+ if err := json.Unmarshal(data, &got); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if got.RunID != "20260425T100000Z" || got.Trigger != "event" {
+ t.Fatalf("round-trip mismatch: %+v", got)
+ }
+ if len(got.RowsChanged) != 1 || got.RowsChanged[0].Kind != "spec_changed" {
+ t.Fatalf("RowsChanged round-trip failed: %+v", got.RowsChanged)
+ }
+}
+
+func TestAppendLedgerEvent_AppendsOneJSONLineAndIsParseable(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "runs.jsonl")
+ for i := 0; i < 3; i++ {
+ err := AppendLedgerEvent(path, LedgerEvent{
+ TS: time.Date(2026, 4, 25, 10, i, 0, 0, time.UTC).Format(time.RFC3339),
+ RunID: "run-" + string(rune('A'+i)),
+ Status: "ok",
+ })
+ if err != nil {
+ t.Fatalf("append %d: %v", i, err)
+ }
+ }
+ body, _ := os.ReadFile(path)
+ lines := strings.Split(strings.TrimRight(string(body), "\n"), "\n")
+ if len(lines) != 3 {
+ t.Fatalf("expected 3 lines, got %d:\n%s", len(lines), body)
+ }
+ for i, line := range lines {
+ var event LedgerEvent
+ if err := json.Unmarshal([]byte(line), &event); err != nil {
+ t.Fatalf("line %d not parseable JSON: %v\n%s", i, err, line)
+ }
+ }
+}
+
+func TestAppendLedgerEvent_AppendsAtomicallyAcrossWriters(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "runs.jsonl")
+ const N = 8
+ var wg sync.WaitGroup
+ for i := 0; i < N; i++ {
+ wg.Add(1)
+ go func(idx int) {
+ defer wg.Done()
+ _ = AppendLedgerEvent(path, LedgerEvent{
+ TS: time.Now().UTC().Format(time.RFC3339Nano),
+ RunID: "run-" + string(rune('A'+idx)),
+ Status: "ok",
+ })
+ }(i)
+ }
+ wg.Wait()
+ events, err := LoadLedger(path)
+ if err != nil {
+ t.Fatalf("LoadLedger: %v", err)
+ }
+ if len(events) != N {
+ t.Fatalf("got %d events, want %d", len(events), N)
+ }
+}
+
+func TestLoadLedger_SkipsCorruptLines(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "runs.jsonl")
+ good1 := `{"ts":"2026-04-25T10:00:00Z","run_id":"a","status":"ok"}`
+ bad := `{this is not json`
+ good2 := `{"ts":"2026-04-25T10:01:00Z","run_id":"b","status":"ok"}`
+ if err := os.WriteFile(path, []byte(good1+"\n"+bad+"\n"+good2+"\n"), 0o644); err != nil {
+ t.Fatalf("write: %v", err)
+ }
+ events, err := LoadLedger(path)
+ if err != nil {
+ t.Fatalf("LoadLedger: %v", err)
+ }
+ if len(events) != 2 {
+ t.Fatalf("expected 2 good events, got %d", len(events))
+ }
+}
+
+func TestLoadLedgerWindow_BoundsByTimestamp(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "runs.jsonl")
+ now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC)
+ for i := -10; i <= 0; i++ {
+ _ = AppendLedgerEvent(path, LedgerEvent{
+ TS: now.Add(time.Duration(i) * 24 * time.Hour).Format(time.RFC3339),
+ RunID: "run",
+ Status: "ok",
+ })
+ }
+ events, err := LoadLedgerWindow(path, 7*24*time.Hour, now)
+ if err != nil {
+ t.Fatalf("LoadLedgerWindow: %v", err)
+ }
+ // Window includes events from 7 days ago to now โ 8 events (-7..0).
+ if len(events) != 8 {
+ t.Fatalf("expected 8 events in 7-day window, got %d", len(events))
+ }
+}
diff --git a/internal/architectureplanner/prompt.go b/internal/architectureplanner/prompt.go
index c1f110cac..8941ddf37 100644
--- a/internal/architectureplanner/prompt.go
+++ b/internal/architectureplanner/prompt.go
@@ -6,6 +6,45 @@ import (
"strings"
)
+// healthPreservationClause is appended to every planner prompt as a HARD
+// rule. The autoloop runtime owns RowHealth metadata; the planner must
+// reproduce it verbatim for any row it keeps. Dropping or reformatting any
+// field inside `health` causes RunOnce to reject the regeneration via
+// validateHealthPreservation.
+const healthPreservationClause = `
+HEALTH BLOCK PRESERVATION (HARD RULE)
+Every progress.json item may carry a ` + "`health`" + ` block (RowHealth). This block
+is OWNED by the autoloop runtime โ you must reproduce it verbatim in your
+output for any row you keep. Do not modify, omit, or reformat any field
+inside ` + "`health`" + `. If you delete a row, the health block dies with it (that
+is expected). If you split a row into multiple new rows, the original
+health block is dropped (the split is a new contract; quarantine resets
+naturally via spec-hash detection).
+`
+
+// quarantinePriorityClause is appended to every planner prompt as a SOFT
+// rule. It instructs the planner to materially change quarantined rows
+// (sharpen, split, or mark for human review) so autoloop's auto-clear path
+// (spec-hash mismatch) actually triggers on the next run.
+const quarantinePriorityClause = `
+QUARANTINE PRIORITY (SOFT RULE)
+Rows in quarantined_rows[] are top priority for repair. For each one:
+ - Read its last_category and last_failure_excerpt
+ - Examine its contract and acceptance
+ - Decide ONE of:
+ (a) Sharpen the contract โ make done_signal more concrete, add an
+ explicit fixture path, narrow write_scope
+ (b) Split the row โ if it's an umbrella that workers can't complete
+ atomically, split into 2-3 smaller rows with explicit dependencies
+ (c) Mark it for human review โ if the failure is infrastructural
+ (category=worker_error or backend_degraded with no diff), set
+ contract_status: "draft" and add a note in degraded_mode
+ explaining what's needed
+ Whatever you choose, the row's contract/contract_status/blocked_by/
+ write_scope/fixture must change in some material way. Otherwise
+ quarantine will not auto-clear and autoloop will keep skipping the row.
+`
+
func BuildPrompt(bundle ContextBundle) string {
var roots []string
for _, root := range bundle.SourceRoots {
@@ -29,6 +68,7 @@ func BuildPrompt(bundle ContextBundle) string {
landingSite := formatInventorySurface(bundle.ImplementationInventory.LandingSite)
hugoDocs := formatInventorySurface(bundle.ImplementationInventory.HugoDocs)
auditBlock := formatAutoloopAudit(bundle.AutoloopAudit)
+ quarantineBlock := formatQuarantinedRows(bundle.QuarantinedRows)
return fmt.Sprintf(`You are the Gormes Architecture Planner Loop.
@@ -96,7 +136,30 @@ Required final report sections:
6. Recommended next autoloop tasks
7. Autoloop handoff completeness
8. Risks and ambiguities
-`, strings.Join(roots, "\n"), strings.Join(syncLines, "\n"), strings.Join(bundle.ImplementationInventory.Commands, ", "), strings.Join(bundle.ImplementationInventory.InternalPackages, ", "), strings.Join(bundle.ImplementationInventory.BuildingDocs, ", "), landingSite, hugoDocs, auditBlock, bundle.ProgressJSON, bundle.RepoRoot, bundle.ProgressStats.Items)
+%s%s%s
+`, strings.Join(roots, "\n"), strings.Join(syncLines, "\n"), strings.Join(bundle.ImplementationInventory.Commands, ", "), strings.Join(bundle.ImplementationInventory.InternalPackages, ", "), strings.Join(bundle.ImplementationInventory.BuildingDocs, ", "), landingSite, hugoDocs, auditBlock, bundle.ProgressJSON, bundle.RepoRoot, bundle.ProgressStats.Items, healthPreservationClause, quarantinePriorityClause, quarantineBlock)
+}
+
+// formatQuarantinedRows renders the planner's call-to-action list for
+// quarantined rows. Returns the empty string when there are no rows so the
+// section is omitted entirely (the HARD/SOFT rule clauses still ship).
+func formatQuarantinedRows(rows []QuarantinedRowContext) string {
+ if len(rows) == 0 {
+ return ""
+ }
+ var b strings.Builder
+ b.WriteString("\n## Quarantined Rows (Top Priority for Repair)\n\n")
+ for _, r := range rows {
+ fmt.Fprintf(&b, "- %s/%s/%s โ %d attempts, last category=%s, since=%s\n",
+ r.PhaseID, r.SubphaseID, r.ItemName, r.AttemptCount, r.LastCategory, r.QuarantinedSince)
+ if r.Contract != "" {
+ fmt.Fprintf(&b, " contract: %s\n", r.Contract)
+ }
+ if r.LastFailureExcerpt != "" {
+ fmt.Fprintf(&b, " last failure tail: %s\n", r.LastFailureExcerpt)
+ }
+ }
+ return b.String()
}
func formatAutoloopAudit(audit AutoloopAudit) string {
diff --git a/internal/architectureplanner/prompt_test.go b/internal/architectureplanner/prompt_test.go
new file mode 100644
index 000000000..d5bbffd92
--- /dev/null
+++ b/internal/architectureplanner/prompt_test.go
@@ -0,0 +1,47 @@
+package architectureplanner
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/TrebuchetDynamics/gormes-agent/internal/progress"
+)
+
+func TestBuildPrompt_IncludesHealthClauses(t *testing.T) {
+ bundle := ContextBundle{
+ QuarantinedRows: []QuarantinedRowContext{
+ {
+ PhaseID: "2",
+ SubphaseID: "2.B",
+ ItemName: "row-x",
+ Contract: "do thing",
+ LastCategory: progress.FailureWorkerError,
+ AttemptCount: 4,
+ },
+ },
+ }
+ prompt := BuildPrompt(bundle)
+ wants := []string{
+ "HEALTH BLOCK PRESERVATION (HARD RULE)",
+ "QUARANTINE PRIORITY (SOFT RULE)",
+ "row-x", // call-to-action surfaces the row
+ }
+ for _, want := range wants {
+ if !strings.Contains(prompt, want) {
+ t.Fatalf("BuildPrompt missing %q\nprompt:\n%s", want, prompt)
+ }
+ }
+}
+
+func TestBuildPrompt_NoQuarantinedRowsOmitsCallToAction(t *testing.T) {
+ bundle := ContextBundle{}
+ prompt := BuildPrompt(bundle)
+ // Hard rule and soft rule still appear (they're rule clauses, not data).
+ if !strings.Contains(prompt, "HEALTH BLOCK PRESERVATION") {
+ t.Fatal("prompt missing health preservation clause when no quarantined rows")
+ }
+ // But the call-to-action section should NOT appear when there are zero rows.
+ if strings.Contains(prompt, "Quarantined Rows (Top Priority for Repair)") {
+ t.Fatal("call-to-action section should be omitted when zero quarantined rows")
+ }
+}
diff --git a/internal/architectureplanner/run.go b/internal/architectureplanner/run.go
index 20c2a5d67..1631939c1 100644
--- a/internal/architectureplanner/run.go
+++ b/internal/architectureplanner/run.go
@@ -4,12 +4,15 @@ import (
"context"
"encoding/json"
"fmt"
+ "log"
"os"
"path/filepath"
+ "reflect"
"strings"
"time"
"github.com/TrebuchetDynamics/gormes-agent/internal/autoloop"
+ "github.com/TrebuchetDynamics/gormes-agent/internal/progress"
)
type RunOptions struct {
@@ -21,6 +24,7 @@ type RunOptions struct {
}
type RunSummary struct {
+ RunID string
Backend string
Mode string
RunRoot string
@@ -90,7 +94,11 @@ func RunOnce(ctx context.Context, opts RunOptions) (RunSummary, error) {
return RunSummary{}, err
}
+ runID := now.UTC().Format("20060102T150405Z")
+ ledgerPath := filepath.Join(cfg.RunRoot, "state", "runs.jsonl")
+
summary := RunSummary{
+ RunID: runID,
Backend: cfg.Backend,
Mode: cfg.Mode,
RunRoot: cfg.RunRoot,
@@ -107,6 +115,16 @@ func RunOnce(ctx context.Context, opts RunOptions) (RunSummary, error) {
return summary, nil
}
+ // Snapshot progress.json BEFORE the LLM backend runs so we can verify
+ // the backend's edits preserved every existing Health block. Health
+ // metadata is owned by the autoloop runtime; the planner is only
+ // allowed to update spec fields. A missing file is fine โ it means
+ // there is nothing to preserve yet.
+ beforeDoc, err := loadProgressForValidation(cfg.ProgressJSON)
+ if err != nil {
+ return RunSummary{}, fmt.Errorf("planner: load before-doc: %w", err)
+ }
+
argv, err := plannerBackendCommand(cfg.Backend, cfg.Mode, rawReportPath)
if err != nil {
return RunSummary{}, err
@@ -117,9 +135,49 @@ func RunOnce(ctx context.Context, opts RunOptions) (RunSummary, error) {
Dir: cfg.RepoRoot,
})
if result.Err != nil {
+ appendPlannerLedger(ledgerPath, LedgerEvent{
+ TS: now.UTC().Format(time.RFC3339),
+ RunID: runID,
+ Trigger: "scheduled",
+ Backend: cfg.Backend,
+ Mode: cfg.Mode,
+ Status: "backend_failed",
+ Detail: strings.TrimSpace(result.Stderr),
+ BeforeStats: computeStats(beforeDoc),
+ })
return RunSummary{}, commandError(argv[0], result)
}
+ // Reload progress.json after the backend's edits and reject the
+ // regeneration if any Health block was dropped or modified. Skipped
+ // entirely when there was no before-doc (fresh checkout) or when the
+ // after-doc cannot be loaded (treat as no regeneration to validate).
+ var afterDoc *progress.Progress
+ if beforeDoc != nil {
+ loaded, loadErr := loadProgressForValidation(cfg.ProgressJSON)
+ if loadErr != nil {
+ return RunSummary{}, fmt.Errorf("planner: load after-doc: %w", loadErr)
+ }
+ afterDoc = loaded
+ if afterDoc != nil {
+ if err := validateHealthPreservation(beforeDoc, afterDoc); err != nil {
+ appendPlannerLedger(ledgerPath, LedgerEvent{
+ TS: now.UTC().Format(time.RFC3339),
+ RunID: runID,
+ Trigger: "scheduled",
+ Backend: cfg.Backend,
+ Mode: cfg.Mode,
+ Status: "validation_rejected",
+ Detail: err.Error(),
+ BeforeStats: computeStats(beforeDoc),
+ AfterStats: computeStats(afterDoc),
+ RowsChanged: diffRows(beforeDoc, afterDoc),
+ })
+ return RunSummary{}, fmt.Errorf("planner: regeneration rejected: %w", err)
+ }
+ }
+ }
+
if err := writeReport(reportPath, rawReportPath, result, bundle, now); err != nil {
return RunSummary{}, err
}
@@ -144,9 +202,35 @@ func RunOnce(ctx context.Context, opts RunOptions) (RunSummary, error) {
return RunSummary{}, err
}
+ runStatus := "ok"
+ if beforeDoc == nil || afterDoc == nil {
+ runStatus = "no_changes"
+ }
+ appendPlannerLedger(ledgerPath, LedgerEvent{
+ TS: now.UTC().Format(time.RFC3339),
+ RunID: runID,
+ Trigger: "scheduled",
+ Backend: cfg.Backend,
+ Mode: cfg.Mode,
+ Status: runStatus,
+ BeforeStats: computeStats(beforeDoc),
+ AfterStats: computeStats(afterDoc),
+ RowsChanged: diffRows(beforeDoc, afterDoc),
+ })
+
return summary, nil
}
+// appendPlannerLedger writes one LedgerEvent and soft-fails on error: the
+// ledger is observability, not the planner run's success criterion. Errors
+// are logged via the standard log package so operators see them, but they
+// do not fail the run.
+func appendPlannerLedger(path string, event LedgerEvent) {
+ if err := AppendLedgerEvent(path, event); err != nil {
+ log.Printf("planner: append ledger failed: %v", err)
+ }
+}
+
func plannerBackendCommand(backend, mode, rawReportPath string) ([]string, error) {
if backend == "" {
backend = "codexu"
@@ -226,3 +310,150 @@ func commandError(name string, result autoloop.Result) error {
}
return fmt.Errorf("%s failed: %w: %s", name, result.Err, output)
}
+
+// loadProgressForValidation reads progress.json for the health-preservation
+// gate. Returns (nil, nil) when the file does not exist so the gate skips
+// gracefully on a fresh checkout (there is no prior state to preserve).
+// Other read/parse errors propagate so the planner refuses to silently
+// proceed against a corrupted progress.json.
+func loadProgressForValidation(path string) (*progress.Progress, error) {
+ prog, err := progress.Load(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil, nil
+ }
+ return nil, err
+ }
+ return prog, nil
+}
+
+// validateHealthPreservation rejects planner regenerations that drop or
+// modify any existing Health block. Rows missing from the after-doc are
+// considered intentional deletions (planner removed them) and pass.
+// Spec hash mismatch is NOT validated here โ that triggers stale-clear
+// in autoloop's selection layer (L3), not a planner-side rejection.
+func validateHealthPreservation(before, after *progress.Progress) error {
+ beforeIndex := indexItems(before)
+ afterIndex := indexItems(after)
+
+ for key, beforeItem := range beforeIndex {
+ afterItem, exists := afterIndex[key]
+ if !exists {
+ continue // intentional deletion
+ }
+ if !healthEqual(beforeItem.Health, afterItem.Health) {
+ return fmt.Errorf("planner output dropped or modified health block for %s/%s/%s",
+ key.phaseID, key.subphaseID, key.itemName)
+ }
+ }
+ return nil
+}
+
+type itemKey struct{ phaseID, subphaseID, itemName string }
+
+// indexItems flattens a Progress document into a map keyed by
+// (phaseID, subphaseID, itemName). Returns an empty map when prog is nil.
+// Item pointers are taken from the underlying slice so callers can read
+// fields without copying the whole row.
+func indexItems(prog *progress.Progress) map[itemKey]*progress.Item {
+ out := map[itemKey]*progress.Item{}
+ if prog == nil {
+ return out
+ }
+ for phaseID, phase := range prog.Phases {
+ for subID, sub := range phase.Subphases {
+ for i := range sub.Items {
+ it := &sub.Items[i]
+ out[itemKey{phaseID, subID, it.Name}] = it
+ }
+ }
+ }
+ return out
+}
+
+// healthEqual compares two RowHealth pointers for deep equality, treating
+// (nil, nil) as equal but (nil, non-nil) or (non-nil, nil) as different.
+func healthEqual(a, b *progress.RowHealth) bool {
+ if a == nil && b == nil {
+ return true
+ }
+ if a == nil || b == nil {
+ return false
+ }
+ return reflect.DeepEqual(a, b)
+}
+
+// computeStats walks a Progress doc and counts rows by status, including
+// the new Phase C buckets (Quarantined, NeedsHuman) which aren't in the
+// existing Progress.Stats() function. Returns a zero ProgressStats when
+// prog is nil so the helper is safe on the dry-run / no-before-doc paths.
+func computeStats(prog *progress.Progress) ProgressStats {
+ if prog == nil {
+ return ProgressStats{}
+ }
+ var stats ProgressStats
+ for _, phase := range prog.Phases {
+ for _, sub := range phase.Subphases {
+ for i := range sub.Items {
+ it := &sub.Items[i]
+ switch it.Status {
+ case progress.StatusComplete:
+ stats.Shipped++
+ case progress.StatusInProgress:
+ stats.InProgress++
+ default:
+ stats.Planned++
+ }
+ if it.Health != nil && it.Health.Quarantine != nil {
+ stats.Quarantined++
+ }
+ if it.PlannerVerdict != nil && it.PlannerVerdict.NeedsHuman {
+ stats.NeedsHuman++
+ }
+ }
+ }
+ }
+ return stats
+}
+
+// diffRows compares before/after docs and returns RowChange records for
+// added/deleted/spec_changed rows. Spec change is detected via
+// progress.ItemSpecHash so cosmetic edits don't show up as changes. Returns
+// nil when both inputs are nil/empty.
+func diffRows(before, after *progress.Progress) []RowChange {
+ var out []RowChange
+ beforeIndex := indexItems(before)
+ afterIndex := indexItems(after)
+
+ for key, beforeItem := range beforeIndex {
+ afterItem, exists := afterIndex[key]
+ if !exists {
+ out = append(out, RowChange{
+ PhaseID: key.phaseID,
+ SubphaseID: key.subphaseID,
+ ItemName: key.itemName,
+ Kind: "deleted",
+ })
+ continue
+ }
+ if progress.ItemSpecHash(beforeItem) != progress.ItemSpecHash(afterItem) {
+ out = append(out, RowChange{
+ PhaseID: key.phaseID,
+ SubphaseID: key.subphaseID,
+ ItemName: key.itemName,
+ Kind: "spec_changed",
+ })
+ }
+ }
+ for key := range afterIndex {
+ if _, existed := beforeIndex[key]; !existed {
+ out = append(out, RowChange{
+ PhaseID: key.phaseID,
+ SubphaseID: key.subphaseID,
+ ItemName: key.itemName,
+ Kind: "added",
+ })
+ }
+ }
+ return out
+}
diff --git a/internal/architectureplanner/run_test.go b/internal/architectureplanner/run_test.go
index 7524e0b33..43b4b88a2 100644
--- a/internal/architectureplanner/run_test.go
+++ b/internal/architectureplanner/run_test.go
@@ -3,6 +3,7 @@ package architectureplanner
import (
"context"
"encoding/json"
+ "log"
"os"
"path/filepath"
"strings"
@@ -195,6 +196,266 @@ func TestRunOnceRunsValidationAfterBackend(t *testing.T) {
}
}
+// mutatingRunner wraps a FakeRunner so the test can mutate progress.json
+// when the planner backend command (codexu/claudeu) is dispatched. This
+// mirrors what a real LLM backend does โ emit the report and rewrite
+// progress.json in one shot โ so the ledger wire-in sees a real before/after
+// delta to record. The mutator is invoked once on the FIRST backend
+// invocation; later backend calls (e.g. retries in future tasks) fall
+// through to the wrapped FakeRunner unchanged.
+type mutatingRunner struct {
+ inner *autoloop.FakeRunner
+ mutate func(t *testing.T) // performed before returning the backend result
+ t *testing.T
+ mutated bool
+}
+
+func (r *mutatingRunner) Run(ctx context.Context, command autoloop.Command) autoloop.Result {
+ res := r.inner.Run(ctx, command)
+ if !r.mutated && (command.Name == "codexu" || command.Name == "claudeu") {
+ r.mutated = true
+ if r.mutate != nil {
+ r.mutate(r.t)
+ }
+ }
+ return res
+}
+
+func TestRunOnce_AppendsLedgerEventOnSuccess(t *testing.T) {
+ repoRoot := writePlannerFixture(t)
+ cfg := mustConfig(t, repoRoot)
+ progressPath := cfg.ProgressJSON
+
+ runner := &mutatingRunner{
+ t: t,
+ inner: &autoloop.FakeRunner{
+ Results: []autoloop.Result{
+ {Stdout: "Already up to date.\n"},
+ {Stdout: "Already up to date.\n"},
+ {Stdout: "Already up to date.\n"},
+ {Stdout: "planner ran ok\n"},
+ },
+ },
+ mutate: func(t *testing.T) {
+ // Add a brand-new row and flip an existing row's status to
+ // in_progress. The Health blocks on the original rows are
+ // preserved (they were never set in the fixture, so this is
+ // trivially true), so validateHealthPreservation passes and
+ // the run records status="ok".
+ writeFile(t, progressPath, `{
+ "phases": {
+ "2": {
+ "name": "Gateway",
+ "subphases": {
+ "2.A": {
+ "items": [
+ {"name": "Gateway task", "status": "complete"},
+ {"name": "Goncho task", "status": "in_progress"},
+ {"name": "Brand new task", "status": "planned"}
+ ]
+ }
+ }
+ }
+ }
+}`)
+ },
+ }
+
+ summary, err := RunOnce(context.Background(), RunOptions{
+ Config: cfg,
+ Runner: runner,
+ SkipValidation: true,
+ })
+ if err != nil {
+ t.Fatalf("RunOnce() error = %v", err)
+ }
+
+ events := mustReadLedger(t, filepath.Join(cfg.RunRoot, "state", "runs.jsonl"))
+ if len(events) != 1 {
+ t.Fatalf("ledger entries = %d, want 1: %#v", len(events), events)
+ }
+ ev := events[0]
+ if ev.Status != "ok" {
+ t.Fatalf("Status = %q, want ok", ev.Status)
+ }
+ if ev.RunID != summary.RunID || ev.RunID == "" {
+ t.Fatalf("RunID = %q, want %q (non-empty)", ev.RunID, summary.RunID)
+ }
+ if ev.Trigger != "scheduled" {
+ t.Fatalf("Trigger = %q, want scheduled", ev.Trigger)
+ }
+ if ev.Backend != "codexu" {
+ t.Fatalf("Backend = %q, want codexu", ev.Backend)
+ }
+ // Before doc had 2 rows (1 planned, 1 in_progress); after has 3 rows
+ // (1 complete, 1 in_progress, 1 planned). Exactly one added row plus
+ // one spec_changed (Gateway task flipped status โ but status isn't in
+ // ItemSpecHash, so it doesn't show up). Net: one "added" row only.
+ if got, want := len(ev.RowsChanged), 1; got != want {
+ t.Fatalf("RowsChanged length = %d, want %d: %#v", got, want, ev.RowsChanged)
+ }
+ if ev.RowsChanged[0].Kind != "added" || ev.RowsChanged[0].ItemName != "Brand new task" {
+ t.Fatalf("RowsChanged[0] = %#v, want added/Brand new task", ev.RowsChanged[0])
+ }
+ if ev.BeforeStats.Planned != 1 || ev.BeforeStats.InProgress != 1 {
+ t.Fatalf("BeforeStats = %#v, want Planned=1 InProgress=1", ev.BeforeStats)
+ }
+ if ev.AfterStats.Shipped != 1 || ev.AfterStats.InProgress != 1 || ev.AfterStats.Planned != 1 {
+ t.Fatalf("AfterStats = %#v, want Shipped=1 InProgress=1 Planned=1", ev.AfterStats)
+ }
+}
+
+func TestRunOnce_AppendsLedgerEventOnValidationReject(t *testing.T) {
+ repoRoot := writePlannerFixture(t)
+ cfg := mustConfig(t, repoRoot)
+ progressPath := cfg.ProgressJSON
+
+ // Seed a Health block on an existing row so the planner regen has
+ // something to drop. The fixture writes raw JSON so we re-write it
+ // here with the Health block included.
+ writeFile(t, progressPath, `{
+ "phases": {
+ "2": {
+ "name": "Gateway",
+ "subphases": {
+ "2.A": {
+ "items": [
+ {"name": "Gateway task", "status": "planned", "health": {"attempt_count": 3, "consecutive_failures": 1}},
+ {"name": "Goncho task", "status": "in_progress"}
+ ]
+ }
+ }
+ }
+ }
+}`)
+
+ runner := &mutatingRunner{
+ t: t,
+ inner: &autoloop.FakeRunner{
+ Results: []autoloop.Result{
+ {Stdout: "Already up to date.\n"},
+ {Stdout: "Already up to date.\n"},
+ {Stdout: "Already up to date.\n"},
+ {Stdout: "planner ran ok\n"},
+ },
+ },
+ mutate: func(t *testing.T) {
+ // Drop the Health block from "Gateway task" โ this MUST be
+ // rejected by validateHealthPreservation.
+ writeFile(t, progressPath, `{
+ "phases": {
+ "2": {
+ "name": "Gateway",
+ "subphases": {
+ "2.A": {
+ "items": [
+ {"name": "Gateway task", "status": "planned"},
+ {"name": "Goncho task", "status": "in_progress"}
+ ]
+ }
+ }
+ }
+ }
+}`)
+ },
+ }
+
+ _, err := RunOnce(context.Background(), RunOptions{
+ Config: cfg,
+ Runner: runner,
+ SkipValidation: true,
+ })
+ if err == nil {
+ t.Fatal("RunOnce() error = nil, want validation rejection")
+ }
+ if !strings.Contains(err.Error(), "regeneration rejected") {
+ t.Fatalf("RunOnce() error = %q, want regeneration rejected", err)
+ }
+
+ events := mustReadLedger(t, filepath.Join(cfg.RunRoot, "state", "runs.jsonl"))
+ if len(events) != 1 {
+ t.Fatalf("ledger entries = %d, want 1: %#v", len(events), events)
+ }
+ if events[0].Status != "validation_rejected" {
+ t.Fatalf("Status = %q, want validation_rejected", events[0].Status)
+ }
+ if events[0].Detail == "" {
+ t.Fatalf("Detail = empty, want validation error message")
+ }
+}
+
+func TestRunOnce_LedgerWriteFailureIsSoftFail(t *testing.T) {
+ repoRoot := writePlannerFixture(t)
+ cfg := mustConfig(t, repoRoot)
+
+ // Pre-create a regular file at the path where the ledger directory
+ // would be created. AppendLedgerEvent calls os.MkdirAll on the parent
+ // directory; on a path that already exists as a non-directory,
+ // MkdirAll returns ENOTDIR. This deterministically forces the soft-
+ // fail path without relying on chmod (which is racy under sudo
+ // or root-owned test runners).
+ ledgerStateDir := filepath.Join(cfg.RunRoot, "state")
+ if err := os.MkdirAll(cfg.RunRoot, 0o755); err != nil {
+ t.Fatalf("MkdirAll(RunRoot) error = %v", err)
+ }
+ if err := os.WriteFile(ledgerStateDir, []byte("blocker\n"), 0o644); err != nil {
+ t.Fatalf("WriteFile(state) error = %v", err)
+ }
+
+ runner := &autoloop.FakeRunner{
+ Results: []autoloop.Result{
+ {Stdout: "Already up to date.\n"},
+ {Stdout: "Already up to date.\n"},
+ {Stdout: "Already up to date.\n"},
+ {Stdout: "planner ran ok\n"},
+ },
+ }
+
+ // Capture log output so we can assert the soft-fail message was
+ // emitted without polluting test stderr.
+ var logBuf strings.Builder
+ prevWriter := log.Writer()
+ prevFlags := log.Flags()
+ log.SetOutput(&logBuf)
+ log.SetFlags(0)
+ defer func() {
+ log.SetOutput(prevWriter)
+ log.SetFlags(prevFlags)
+ }()
+
+ _, err := RunOnce(context.Background(), RunOptions{
+ Config: cfg,
+ Runner: runner,
+ SkipValidation: true,
+ })
+ if err != nil {
+ t.Fatalf("RunOnce() error = %v, want nil (soft-fail)", err)
+ }
+ if !strings.Contains(logBuf.String(), "planner: append ledger failed") {
+ t.Fatalf("log output missing soft-fail message: %q", logBuf.String())
+ }
+
+ // The "ledger" path is now a regular file (the blocker we wrote),
+ // not the JSONL we expected, so loadLedger via os.Stat shows a file
+ // that is not a JSONL (or it's the blocker itself, depending on
+ // where the failure occurred). Either way, no ledger entries should
+ // have been recorded.
+ if info, err := os.Stat(filepath.Join(cfg.RunRoot, "state")); err == nil && info.IsDir() {
+ t.Fatalf("state/ unexpectedly became a directory; soft-fail path did not exercise MkdirAll failure")
+ }
+}
+
+// mustReadLedger reads and decodes runs.jsonl, failing the test if the file
+// is missing or unparsable. Caller asserts on the returned events.
+func mustReadLedger(t *testing.T, path string) []LedgerEvent {
+ t.Helper()
+ events, err := LoadLedger(path)
+ if err != nil {
+ t.Fatalf("LoadLedger(%s) error = %v", path, err)
+ }
+ return events
+}
+
func mustConfig(t *testing.T, repoRoot string) Config {
t.Helper()
diff --git a/internal/autoloop/candidates.go b/internal/autoloop/candidates.go
index 98c1d100f..54dd14eb6 100644
--- a/internal/autoloop/candidates.go
+++ b/internal/autoloop/candidates.go
@@ -2,10 +2,13 @@ package autoloop
import (
"encoding/json"
+ "fmt"
"os"
"sort"
"strconv"
"strings"
+
+ "github.com/TrebuchetDynamics/gormes-agent/internal/progress"
)
type CandidateOptions struct {
@@ -15,6 +18,12 @@ type CandidateOptions struct {
IncludeBlocked bool
IncludeUmbrella bool
IncludePaused bool
+ // IncludeQuarantined causes NormalizeCandidates to surface rows whose
+ // Health.Quarantine block is current (spec hash matches). Default false:
+ // quarantined rows are filtered out so the run loop avoids known-bad
+ // targets. Stale quarantines (spec hash mismatch) are always surfaced and
+ // flagged with Candidate.StaleQuarantine regardless of this setting.
+ IncludeQuarantined bool
}
type Candidate struct {
@@ -40,28 +49,61 @@ type Candidate struct {
TestCommands []string
DoneSignal []string
Note string
+ // Health is the row's autoloop execution-history block, if any. Surfaced
+ // here so the run loop and reporting can consult quarantine / failure
+ // counts without re-loading progress.json.
+ Health *progress.RowHealth
// StaleQuarantine is set by Task 5's selection logic when the row's
// existing Quarantine.SpecHash no longer matches the current ItemSpecHash
// (planner reshape detected). The run loop forwards this to the health
// accumulator so Flush clears the stale block atomically with run health.
StaleQuarantine bool
+ // PenaltyApplied is the ranking penalty derived from Health
+ // (ConsecutiveFailures + 2*len(BackendsTried)). Recorded so the reason
+ // string and downstream tooling can surface why a row sank in priority.
+ PenaltyApplied int
+}
+
+// failurePenalty returns the ranking penalty for n consecutive failures.
+// 0 -> 0, 1 -> 5, 2 -> 20, 3+ -> 45 (capped). Rows past the quarantine
+// threshold should already be filtered by NormalizeCandidates, but the cap
+// covers manual-override scenarios where IncludeQuarantined is set.
+func failurePenalty(n int) int {
+ switch {
+ case n <= 0:
+ return 0
+ case n == 1:
+ return 5
+ case n == 2:
+ return 20
+ default:
+ return 45
+ }
}
func (candidate Candidate) SelectionReason() string {
+ var base string
switch candidateBucket(candidate) {
case candidateBucketP0:
- return "P0 handoff"
+ base = "P0 handoff"
case candidateBucketInProgress:
- return "already active"
+ base = "already active"
case candidateBucketFixtureReady:
- return "fixture ready"
+ base = "fixture ready"
case candidateBucketUnblocks:
- return "unblocks downstream work"
+ base = "unblocks downstream work"
case candidateBucketDraft:
- return "draft contract"
+ base = "draft contract"
default:
- return "planned row"
+ base = "planned row"
+ }
+ if candidate.PenaltyApplied > 0 {
+ base += fmt.Sprintf(" penalty=%d", candidate.PenaltyApplied)
}
+ if candidate.StaleQuarantine {
+ base += " quarantine_stale_cleared"
+ }
+ return base
}
func NormalizeCandidates(path string, opts CandidateOptions) ([]Candidate, error) {
@@ -70,15 +112,15 @@ func NormalizeCandidates(path string, opts CandidateOptions) ([]Candidate, error
return nil, err
}
- var progress progressJSON
- if err := json.Unmarshal(data, &progress); err != nil {
+ var progressDoc progressJSON
+ if err := json.Unmarshal(data, &progressDoc); err != nil {
return nil, err
}
- completed := completedItemSet(progress)
+ completed := completedItemSet(progressDoc)
var candidates []Candidate
seen := make(map[string]struct{})
- for _, phase := range progress.Phases {
+ for _, phase := range progressDoc.Phases {
if phaseAboveMax(phase.ID, opts.MaxPhase) {
continue
}
@@ -136,6 +178,30 @@ func NormalizeCandidates(path string, opts CandidateOptions) ([]Candidate, error
if !agentQueueCandidate(candidate) {
continue
}
+
+ // Honor row health (Task 5):
+ // - Active quarantine (spec hash matches current spec) is
+ // filtered out unless IncludeQuarantined is set.
+ // - Stale quarantine (spec hash mismatch) surfaces the row
+ // with StaleQuarantine=true so the run loop can clear the
+ // block atomically with this run's health updates.
+ // - Consecutive-failure / backends-tried penalty is recorded
+ // on the candidate so the sort below can demote it.
+ candidate.Health = item.Health
+ if item.Health != nil && item.Health.Quarantine != nil {
+ currentHash := progress.ItemSpecHash(itemPtr(item))
+ if currentHash != item.Health.Quarantine.SpecHash {
+ candidate.StaleQuarantine = true
+ } else if !opts.IncludeQuarantined {
+ continue
+ }
+ }
+ if item.Health != nil {
+ pen := failurePenalty(item.Health.ConsecutiveFailures)
+ pen += 2 * len(item.Health.BackendsTried)
+ candidate.PenaltyApplied = pen
+ }
+
seenKey := candidateSortKey(candidate)
if _, ok := seen[seenKey]; ok {
continue
@@ -149,8 +215,8 @@ func NormalizeCandidates(path string, opts CandidateOptions) ([]Candidate, error
boosts := priorityBoostSet(opts.PriorityBoost)
sort.Slice(candidates, func(i, j int) bool {
- left := candidateRank(candidates[i], opts.ActiveFirst, boosts)
- right := candidateRank(candidates[j], opts.ActiveFirst, boosts)
+ left := candidateRank(candidates[i], opts.ActiveFirst, boosts) + candidates[i].PenaltyApplied
+ right := candidateRank(candidates[j], opts.ActiveFirst, boosts) + candidates[j].PenaltyApplied
if left != right {
return left < right
}
@@ -161,6 +227,14 @@ func NormalizeCandidates(path string, opts CandidateOptions) ([]Candidate, error
return candidates, nil
}
+// itemPtr returns a pointer to a progress.Item view of the given progressItem
+// suitable for passing to progress.ItemSpecHash. Lifted to a helper so the
+// conversion happens in one place.
+func itemPtr(item progressItem) *progress.Item {
+ view := item.toProgressItem()
+ return &view
+}
+
func phaseAboveMax(phaseID string, maxPhase int) bool {
if maxPhase < 1 {
return false
@@ -283,6 +357,24 @@ type progressItem struct {
TestCommands []string `json:"test_commands"`
DoneSignal []string `json:"done_signal"`
Note string `json:"note"`
+ // Health mirrors progress.Item.Health so candidate selection can honor
+ // quarantine and ranking penalties without re-loading the file through
+ // the canonical progress.Load path.
+ Health *progress.RowHealth `json:"health,omitempty"`
+}
+
+// toProgressItem builds a progress.Item view containing only the fields used
+// by progress.ItemSpecHash. Values are passed through verbatim so the digest
+// matches the one progress.Load + progress.ItemSpecHash would produce against
+// the same file.
+func (item progressItem) toProgressItem() progress.Item {
+ return progress.Item{
+ Contract: item.Contract,
+ ContractStatus: progress.ContractStatus(item.ContractStatus),
+ BlockedBy: append([]string(nil), item.BlockedBy...),
+ WriteScope: append([]string(nil), item.WriteScope...),
+ Fixture: item.Fixture,
+ }
}
func priorityBoostSet(boosts []string) map[string]struct{} {
diff --git a/internal/autoloop/candidates_health_test.go b/internal/autoloop/candidates_health_test.go
new file mode 100644
index 000000000..64c4080bb
--- /dev/null
+++ b/internal/autoloop/candidates_health_test.go
@@ -0,0 +1,267 @@
+package autoloop
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/TrebuchetDynamics/gormes-agent/internal/progress"
+)
+
+func writeHealthProgress(t *testing.T, path string, body string) {
+ t.Helper()
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatalf("mkdir: %v", err)
+ }
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+ t.Fatalf("write: %v", err)
+ }
+}
+
+func TestNormalizeCandidates_NoHealthBehavesLikeBaseline(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "progress.json")
+ writeHealthProgress(t, path, `{
+ "version": "1",
+ "phases": {
+ "1": {
+ "name": "P",
+ "subphases": {
+ "1.A": {
+ "name": "S",
+ "items": [
+ {"name": "row-a", "status": "planned", "contract": "do a", "contract_status": "draft"},
+ {"name": "row-b", "status": "planned", "contract": "do b", "contract_status": "draft"}
+ ]
+ }
+ }
+ }
+ }
+}
+`)
+
+ got, err := NormalizeCandidates(path, CandidateOptions{ActiveFirst: true})
+ if err != nil {
+ t.Fatalf("NormalizeCandidates: %v", err)
+ }
+ if len(got) != 2 {
+ t.Fatalf("got %d candidates, want 2", len(got))
+ }
+}
+
+func TestNormalizeCandidates_QuarantineFiltersByDefault(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "progress.json")
+ writeHealthProgress(t, path, `{
+ "version": "1",
+ "phases": {
+ "1": {
+ "name": "P",
+ "subphases": {
+ "1.A": {
+ "name": "S",
+ "items": [
+ {"name": "row-a", "status": "planned", "contract": "do a", "contract_status": "draft"},
+ {"name": "row-b", "status": "planned", "contract": "do b", "contract_status": "draft"}
+ ]
+ }
+ }
+ }
+ }
+}
+`)
+
+ // Quarantine row-a with the CURRENT spec hash so it's not stale.
+ prog, err := progress.Load(path)
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ currentHash := progress.ItemSpecHash(&prog.Phases["1"].Subphases["1.A"].Items[0])
+ if err := progress.ApplyHealthUpdates(path, []progress.HealthUpdate{{
+ PhaseID: "1", SubphaseID: "1.A", ItemName: "row-a",
+ Mutate: func(h *progress.RowHealth) {
+ h.ConsecutiveFailures = 3
+ h.Quarantine = &progress.Quarantine{Threshold: 3, SpecHash: currentHash}
+ },
+ }}); err != nil {
+ t.Fatalf("seed quarantine: %v", err)
+ }
+
+ got, err := NormalizeCandidates(path, CandidateOptions{ActiveFirst: true})
+ if err != nil {
+ t.Fatalf("NormalizeCandidates: %v", err)
+ }
+ if len(got) != 1 {
+ t.Fatalf("expected 1 (row-b), got %d", len(got))
+ }
+ if got[0].ItemName != "row-b" {
+ t.Fatalf("got %q, want row-b", got[0].ItemName)
+ }
+}
+
+func TestNormalizeCandidates_IncludeQuarantinedReturnsAll(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "progress.json")
+ writeHealthProgress(t, path, `{
+ "version": "1",
+ "phases": {
+ "1": {
+ "name": "P",
+ "subphases": {
+ "1.A": {
+ "name": "S",
+ "items": [
+ {"name": "row-a", "status": "planned", "contract": "do a", "contract_status": "draft"},
+ {"name": "row-b", "status": "planned", "contract": "do b", "contract_status": "draft"}
+ ]
+ }
+ }
+ }
+ }
+}
+`)
+
+ prog, _ := progress.Load(path)
+ currentHash := progress.ItemSpecHash(&prog.Phases["1"].Subphases["1.A"].Items[0])
+ if err := progress.ApplyHealthUpdates(path, []progress.HealthUpdate{{
+ PhaseID: "1", SubphaseID: "1.A", ItemName: "row-a",
+ Mutate: func(h *progress.RowHealth) {
+ h.ConsecutiveFailures = 3
+ h.Quarantine = &progress.Quarantine{Threshold: 3, SpecHash: currentHash}
+ },
+ }}); err != nil {
+ t.Fatalf("seed quarantine: %v", err)
+ }
+
+ got, err := NormalizeCandidates(path, CandidateOptions{ActiveFirst: true, IncludeQuarantined: true})
+ if err != nil {
+ t.Fatalf("NormalizeCandidates: %v", err)
+ }
+ if len(got) != 2 {
+ t.Fatalf("expected both, got %d", len(got))
+ }
+}
+
+func TestNormalizeCandidates_StaleQuarantineFlagsAndIncludes(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "progress.json")
+ writeHealthProgress(t, path, `{
+ "version": "1",
+ "phases": {
+ "1": {
+ "name": "P",
+ "subphases": {
+ "1.A": {
+ "name": "S",
+ "items": [
+ {"name": "row-a", "status": "planned", "contract": "do a", "contract_status": "draft"}
+ ]
+ }
+ }
+ }
+ }
+}
+`)
+
+ // Quarantine with a SpecHash that does NOT match the current spec.
+ if err := progress.ApplyHealthUpdates(path, []progress.HealthUpdate{{
+ PhaseID: "1", SubphaseID: "1.A", ItemName: "row-a",
+ Mutate: func(h *progress.RowHealth) {
+ h.ConsecutiveFailures = 5
+ h.Quarantine = &progress.Quarantine{Threshold: 3, SpecHash: "completely-stale-hash"}
+ },
+ }}); err != nil {
+ t.Fatalf("seed: %v", err)
+ }
+
+ got, err := NormalizeCandidates(path, CandidateOptions{ActiveFirst: true})
+ if err != nil {
+ t.Fatalf("NormalizeCandidates: %v", err)
+ }
+ if len(got) != 1 {
+ t.Fatalf("expected stale quarantine to surface candidate, got %d", len(got))
+ }
+ if !got[0].StaleQuarantine {
+ t.Fatal("StaleQuarantine flag should be true")
+ }
+}
+
+func TestFailurePenalty_TableDriven(t *testing.T) {
+ cases := []struct {
+ consecutive int
+ want int
+ }{
+ {0, 0},
+ {1, 5},
+ {2, 20},
+ {3, 45},
+ {10, 45},
+ }
+ for _, c := range cases {
+ got := failurePenalty(c.consecutive)
+ if got != c.want {
+ t.Errorf("failurePenalty(%d) = %d, want %d", c.consecutive, got, c.want)
+ }
+ }
+}
+
+func TestNormalizeCandidates_PenaltyDemotesAndAnnotates(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "progress.json")
+ writeHealthProgress(t, path, `{
+ "version": "1",
+ "phases": {
+ "1": {
+ "name": "P",
+ "subphases": {
+ "1.A": {
+ "name": "S",
+ "items": [
+ {"name": "row-a", "status": "planned", "contract": "do a", "contract_status": "draft"},
+ {"name": "row-b", "status": "planned", "contract": "do b", "contract_status": "draft"}
+ ]
+ }
+ }
+ }
+ }
+}
+`)
+
+ // Seed row-a with 2 consecutive failures and 1 backend tried.
+ // Penalty math: failurePenalty(2) + 2*len([]) = 20 + 2 = 22 (with 1 backend).
+ if err := progress.ApplyHealthUpdates(path, []progress.HealthUpdate{{
+ PhaseID: "1", SubphaseID: "1.A", ItemName: "row-a",
+ Mutate: func(h *progress.RowHealth) {
+ h.ConsecutiveFailures = 2
+ h.BackendsTried = []string{"codexu"}
+ },
+ }}); err != nil {
+ t.Fatalf("seed: %v", err)
+ }
+
+ got, err := NormalizeCandidates(path, CandidateOptions{ActiveFirst: true})
+ if err != nil {
+ t.Fatalf("NormalizeCandidates: %v", err)
+ }
+ if len(got) != 2 {
+ t.Fatalf("expected 2 candidates, got %d", len(got))
+ }
+
+ // row-b (no penalty) should sort BEFORE row-a (penalized).
+ if got[0].ItemName != "row-b" {
+ t.Fatalf("unpenalized row should sort first; got order [%s, %s]", got[0].ItemName, got[1].ItemName)
+ }
+
+ // row-a's PenaltyApplied must be populated: failurePenalty(2)=20 + 2*1=2 โ 22.
+ const wantPenalty = 22
+ if got[1].PenaltyApplied != wantPenalty {
+ t.Fatalf("row-a PenaltyApplied = %d, want %d", got[1].PenaltyApplied, wantPenalty)
+ }
+
+ // SelectionReason must surface the penalty annotation.
+ reason := got[1].SelectionReason()
+ if !strings.Contains(reason, "penalty=22") {
+ t.Fatalf("row-a SelectionReason missing penalty=22; got: %s", reason)
+ }
+}
diff --git a/internal/autoloop/config.go b/internal/autoloop/config.go
index 0882f335a..226bf80f2 100644
--- a/internal/autoloop/config.go
+++ b/internal/autoloop/config.go
@@ -24,6 +24,10 @@ type Config struct {
IncludeQuarantined bool // GORMES_INCLUDE_QUARANTINED, default false
ReportRepairEnabled bool // GORMES_REPORT_REPAIR, default true (Task 6)
PlannerQuarantineLimit int // GORMES_PLANNER_QUARANTINE_LIMIT, default 5 (Task 7)
+
+ PostPromotionVerifyCommands []string // POST_PROMOTION_VERIFY_COMMANDS, default full-suite gate
+ PostPromotionRepairEnabled bool // POST_PROMOTION_REPAIR, default true
+ PostPromotionRepairAttempts int // POST_PROMOTION_REPAIR_ATTEMPTS, default 1
}
func ConfigFromEnv(repoRoot string, env map[string]string) (Config, error) {
@@ -47,6 +51,10 @@ func ConfigFromEnv(repoRoot string, env map[string]string) (Config, error) {
IncludeQuarantined: false,
ReportRepairEnabled: true,
PlannerQuarantineLimit: 5,
+
+ PostPromotionVerifyCommands: defaultPostPromotionVerifyCommands(),
+ PostPromotionRepairEnabled: true,
+ PostPromotionRepairAttempts: 1,
}
if value := env["PROGRESS_JSON"]; value != "" {
@@ -133,10 +141,44 @@ func ConfigFromEnv(repoRoot string, env map[string]string) (Config, error) {
}
cfg.PlannerQuarantineLimit = n
}
+ if value := env["POST_PROMOTION_VERIFY_COMMANDS"]; value != "" {
+ commands := splitCommandList(value)
+ if len(commands) == 0 {
+ return Config{}, fmt.Errorf("POST_PROMOTION_VERIFY_COMMANDS must contain at least one command")
+ }
+ cfg.PostPromotionVerifyCommands = commands
+ }
+ if value := env["POST_PROMOTION_REPAIR"]; value != "" {
+ b, err := parseBoolEnv(value)
+ if err != nil {
+ return Config{}, fmt.Errorf("POST_PROMOTION_REPAIR: %w", err)
+ }
+ cfg.PostPromotionRepairEnabled = b
+ }
+ if value := env["POST_PROMOTION_REPAIR_ATTEMPTS"]; value != "" {
+ n, err := strconv.Atoi(value)
+ if err != nil {
+ return Config{}, fmt.Errorf("POST_PROMOTION_REPAIR_ATTEMPTS must be an integer: %w", err)
+ }
+ if n < 0 {
+ return Config{}, fmt.Errorf("POST_PROMOTION_REPAIR_ATTEMPTS must be non-negative")
+ }
+ cfg.PostPromotionRepairAttempts = n
+ }
return cfg, nil
}
+func defaultPostPromotionVerifyCommands() []string {
+ return []string{
+ "go test ./... -count=1",
+ "(cd www.gormes.ai && go test ./... -count=1)",
+ "go run ./cmd/autoloop progress validate",
+ "go run ./cmd/autoloop run --dry-run",
+ "(cd www.gormes.ai && npm run test:e2e -- --reporter=line)",
+ }
+}
+
func splitCSV(value string) []string {
var out []string
for _, part := range strings.Split(value, ",") {
@@ -148,6 +190,21 @@ func splitCSV(value string) []string {
return out
}
+func splitCommandList(value string) []string {
+ value = strings.ReplaceAll(value, "\r\n", "\n")
+ value = strings.ReplaceAll(value, "\r", "\n")
+ var out []string
+ for _, line := range strings.Split(value, "\n") {
+ for _, part := range strings.Split(line, ";;") {
+ trimmed := strings.TrimSpace(part)
+ if trimmed != "" {
+ out = append(out, trimmed)
+ }
+ }
+ }
+ return out
+}
+
// parseBoolEnv accepts the common shell idioms for booleans. Whitespace is
// trimmed; case is normalised. Returns an error on unknown values rather
// than silently defaulting so misconfigurations surface loudly.
diff --git a/internal/autoloop/config_test.go b/internal/autoloop/config_test.go
index 69b40edea..333c6059a 100644
--- a/internal/autoloop/config_test.go
+++ b/internal/autoloop/config_test.go
@@ -159,6 +159,16 @@ func TestConfigFromEnvReactiveDefaults(t *testing.T) {
if cfg.PlannerQuarantineLimit != 5 {
t.Fatalf("PlannerQuarantineLimit = %d, want 5", cfg.PlannerQuarantineLimit)
}
+ wantVerify := defaultPostPromotionVerifyCommands()
+ if !reflect.DeepEqual(cfg.PostPromotionVerifyCommands, wantVerify) {
+ t.Fatalf("PostPromotionVerifyCommands = %#v, want %#v", cfg.PostPromotionVerifyCommands, wantVerify)
+ }
+ if cfg.PostPromotionRepairEnabled != true {
+ t.Fatalf("PostPromotionRepairEnabled = %v, want true", cfg.PostPromotionRepairEnabled)
+ }
+ if cfg.PostPromotionRepairAttempts != 1 {
+ t.Fatalf("PostPromotionRepairAttempts = %d, want 1", cfg.PostPromotionRepairAttempts)
+ }
}
func TestConfigFromEnvReactiveOverrides(t *testing.T) {
@@ -169,6 +179,9 @@ func TestConfigFromEnvReactiveOverrides(t *testing.T) {
"GORMES_INCLUDE_QUARANTINED": "true",
"GORMES_REPORT_REPAIR": "0",
"GORMES_PLANNER_QUARANTINE_LIMIT": "9",
+ "POST_PROMOTION_VERIFY_COMMANDS": "go test ./internal/autoloop -count=1;;go run ./cmd/autoloop progress validate",
+ "POST_PROMOTION_REPAIR": "off",
+ "POST_PROMOTION_REPAIR_ATTEMPTS": "2",
})
if err != nil {
t.Fatalf("ConfigFromEnv() error = %v", err)
@@ -192,6 +205,16 @@ func TestConfigFromEnvReactiveOverrides(t *testing.T) {
if cfg.PlannerQuarantineLimit != 9 {
t.Fatalf("PlannerQuarantineLimit = %d, want 9", cfg.PlannerQuarantineLimit)
}
+ verifyWant := []string{"go test ./internal/autoloop -count=1", "go run ./cmd/autoloop progress validate"}
+ if !reflect.DeepEqual(cfg.PostPromotionVerifyCommands, verifyWant) {
+ t.Fatalf("PostPromotionVerifyCommands = %#v, want %#v", cfg.PostPromotionVerifyCommands, verifyWant)
+ }
+ if cfg.PostPromotionRepairEnabled != false {
+ t.Fatalf("PostPromotionRepairEnabled = %v, want false", cfg.PostPromotionRepairEnabled)
+ }
+ if cfg.PostPromotionRepairAttempts != 2 {
+ t.Fatalf("PostPromotionRepairAttempts = %d, want 2", cfg.PostPromotionRepairAttempts)
+ }
}
func TestConfigFromEnvBackendFallbackEmptyYieldsEmptySlice(t *testing.T) {
@@ -252,3 +275,18 @@ func TestConfigFromEnvRejectsInvalidReportRepair(t *testing.T) {
t.Fatal("ConfigFromEnv() error = nil, want error")
}
}
+
+func TestConfigFromEnvRejectsInvalidPostPromotionRepair(t *testing.T) {
+ if _, err := ConfigFromEnv("repo", map[string]string{"POST_PROMOTION_REPAIR": "maybe"}); err == nil {
+ t.Fatal("ConfigFromEnv() error = nil, want error")
+ }
+}
+
+func TestConfigFromEnvRejectsInvalidPostPromotionRepairAttempts(t *testing.T) {
+ if _, err := ConfigFromEnv("repo", map[string]string{"POST_PROMOTION_REPAIR_ATTEMPTS": "many"}); err == nil {
+ t.Fatal("ConfigFromEnv() error = nil, want error")
+ }
+ if _, err := ConfigFromEnv("repo", map[string]string{"POST_PROMOTION_REPAIR_ATTEMPTS": "-1"}); err == nil {
+ t.Fatal("ConfigFromEnv() error = nil, want non-negative error")
+ }
+}
diff --git a/internal/autoloop/lifecycle_test.go b/internal/autoloop/lifecycle_test.go
new file mode 100644
index 000000000..d82436625
--- /dev/null
+++ b/internal/autoloop/lifecycle_test.go
@@ -0,0 +1,207 @@
+package autoloop
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/TrebuchetDynamics/gormes-agent/internal/progress"
+)
+
+// TestLifecycle_FailingRowQuarantinesThenPlannerRepairUnlocksIt walks one
+// row through the full reactive-autoloop loop:
+//
+// Run 1: row attempted, fails โ ConsecutiveFailures=1, no quarantine
+// Run 2: row attempted, fails โ ConsecutiveFailures=2, no quarantine
+// Run 3: row attempted, fails โ ConsecutiveFailures=3, quarantine SET with current spec hash
+// Run 4: selection excludes quarantined row (only row-2 surfaces)
+// Planner edit: row-1's contract is changed (simulated by direct progress.json mutation),
+// making the stored Quarantine.SpecHash stale.
+// Run 5: selection surfaces row-1 with StaleQuarantine=true; accumulator records both
+// a stale-clear AND a success โ quarantine cleared, CF=0, LastSuccess set.
+//
+// This test uses the real internal/progress and internal/autoloop APIs.
+// It does NOT spawn workers or use a fake runner โ instead it drives the
+// healthAccumulator directly (which is the same API run.go uses), proving
+// the per-layer pieces compose correctly.
+func TestLifecycle_FailingRowQuarantinesThenPlannerRepairUnlocksIt(t *testing.T) {
+ dir := t.TempDir()
+ progressPath := filepath.Join(dir, "progress.json")
+ writeBaseProgress(t, progressPath)
+
+ // writeBaseProgress emits rows with status=planned and no contract_status,
+ // which puts them in candidateBucketPlanned โ below the agentQueueCandidate
+ // cutoff (<= candidateBucketDraft). The lifecycle test exercises selection
+ // (R4/R5), so promote both rows into the "draft" bucket so they're eligible.
+ // This mirrors the fixture shape used in candidates_health_test.go.
+ prog, err := progress.Load(progressPath)
+ if err != nil {
+ t.Fatalf("seed load: %v", err)
+ }
+ {
+ phase := prog.Phases["2"]
+ sub := phase.Subphases["2.B"]
+ for i := range sub.Items {
+ sub.Items[i].ContractStatus = progress.ContractStatusDraft
+ }
+ phase.Subphases["2.B"] = sub
+ prog.Phases["2"] = phase
+ }
+ if err := progress.SaveProgress(progressPath, prog); err != nil {
+ t.Fatalf("seed save: %v", err)
+ }
+
+ const threshold = 3
+
+ // hashOf is the SpecHashProvider that the run loop uses at flush time.
+ // We define it as a closure that reloads progress.json so it sees the
+ // current row state (including any planner edits that happened mid-test).
+ hashOf := func(phaseID, subphaseID, itemName string) string {
+ prog, err := progress.Load(progressPath)
+ if err != nil {
+ return ""
+ }
+ phase, ok := prog.Phases[phaseID]
+ if !ok {
+ return ""
+ }
+ sub, ok := phase.Subphases[subphaseID]
+ if !ok {
+ return ""
+ }
+ for i := range sub.Items {
+ if sub.Items[i].Name == itemName {
+ return progress.ItemSpecHash(&sub.Items[i])
+ }
+ }
+ return ""
+ }
+
+ // ---- Run 1: row-1 fails once ----
+ acc := newHealthAccumulator("R1", fixedNow(), threshold)
+ acc.RecordFailure(candidateOf("2", "2.B", "row-1", "do x"), progress.FailureWorkerError, "codexu", "boom 1")
+ if err := acc.Flush(progressPath, hashOf); err != nil {
+ t.Fatalf("R1 flush: %v", err)
+ }
+ prog, _ = progress.Load(progressPath)
+ row1 := &prog.Phases["2"].Subphases["2.B"].Items[0]
+ if row1.Health == nil || row1.Health.ConsecutiveFailures != 1 {
+ t.Fatalf("R1: expected CF=1, got Health=%+v", row1.Health)
+ }
+ if row1.Health.Quarantine != nil {
+ t.Fatalf("R1: should not be quarantined yet, got %+v", row1.Health.Quarantine)
+ }
+
+ // ---- Run 2: row-1 fails again ----
+ acc = newHealthAccumulator("R2", fixedNow(), threshold)
+ acc.RecordFailure(candidateOf("2", "2.B", "row-1", "do x"), progress.FailureWorkerError, "codexu", "boom 2")
+ if err := acc.Flush(progressPath, hashOf); err != nil {
+ t.Fatalf("R2 flush: %v", err)
+ }
+ prog, _ = progress.Load(progressPath)
+ row1 = &prog.Phases["2"].Subphases["2.B"].Items[0]
+ if row1.Health.ConsecutiveFailures != 2 {
+ t.Fatalf("R2: expected CF=2, got %d", row1.Health.ConsecutiveFailures)
+ }
+ if row1.Health.Quarantine != nil {
+ t.Fatalf("R2: should not be quarantined yet at threshold-1, got %+v", row1.Health.Quarantine)
+ }
+
+ // ---- Run 3: row-1 fails again โ threshold hit, quarantine triggers ----
+ acc = newHealthAccumulator("R3", fixedNow(), threshold)
+ acc.RecordFailure(candidateOf("2", "2.B", "row-1", "do x"), progress.FailureReportValidation, "codexu", "report parse failed")
+ if err := acc.Flush(progressPath, hashOf); err != nil {
+ t.Fatalf("R3 flush: %v", err)
+ }
+ prog, _ = progress.Load(progressPath)
+ row1 = &prog.Phases["2"].Subphases["2.B"].Items[0]
+ if row1.Health.ConsecutiveFailures != 3 {
+ t.Fatalf("R3: expected CF=3, got %d", row1.Health.ConsecutiveFailures)
+ }
+ if row1.Health.Quarantine == nil {
+ t.Fatal("R3: expected quarantine to be set after threshold")
+ }
+ if row1.Health.Quarantine.SpecHash == "" {
+ t.Fatal("R3: Quarantine.SpecHash should be populated by hashOf")
+ }
+ originalHash := row1.Health.Quarantine.SpecHash
+
+ // ---- Run 4: selection excludes row-1, only row-2 surfaces ----
+ candidates, err := NormalizeCandidates(progressPath, CandidateOptions{ActiveFirst: true})
+ if err != nil {
+ t.Fatalf("R4 NormalizeCandidates: %v", err)
+ }
+ var sawRow1, sawRow2 bool
+ for _, c := range candidates {
+ if c.ItemName == "row-1" {
+ sawRow1 = true
+ }
+ if c.ItemName == "row-2" {
+ sawRow2 = true
+ }
+ }
+ if sawRow1 {
+ t.Fatal("R4: row-1 should be excluded by quarantine filter")
+ }
+ if !sawRow2 {
+ t.Fatalf("R4: row-2 should still be selectable; got %d candidates", len(candidates))
+ }
+
+ // ---- Planner edit: change row-1's contract โ spec hash will differ ----
+ prog, _ = progress.Load(progressPath)
+ phase2 := prog.Phases["2"]
+ sub2B := phase2.Subphases["2.B"]
+ sub2B.Items[0].Contract = "do x โ sharpened by planner"
+ phase2.Subphases["2.B"] = sub2B
+ prog.Phases["2"] = phase2
+ if err := progress.SaveProgress(progressPath, prog); err != nil {
+ t.Fatalf("save planner edit: %v", err)
+ }
+
+ // Verify the spec hash actually changed (else the rest of the test is moot).
+ prog, _ = progress.Load(progressPath)
+ newHash := progress.ItemSpecHash(&prog.Phases["2"].Subphases["2.B"].Items[0])
+ if newHash == originalHash {
+ t.Fatalf("planner edit did not change spec hash; got %q both times", newHash)
+ }
+
+ // ---- Run 5: selection surfaces row-1 with StaleQuarantine flag ----
+ candidates, err = NormalizeCandidates(progressPath, CandidateOptions{ActiveFirst: true})
+ if err != nil {
+ t.Fatalf("R5 NormalizeCandidates: %v", err)
+ }
+ var staleRow1 Candidate
+ var foundStale bool
+ for _, c := range candidates {
+ if c.ItemName == "row-1" {
+ staleRow1 = c
+ foundStale = true
+ break
+ }
+ }
+ if !foundStale {
+ t.Fatal("R5: row-1 should re-enter the candidate pool after spec change")
+ }
+ if !staleRow1.StaleQuarantine {
+ t.Fatal("R5: row-1 should have StaleQuarantine=true after spec change")
+ }
+
+ // Run 5 (cont.): row-1 is attempted and succeeds โ quarantine cleared, CF=0
+ acc = newHealthAccumulator("R5", fixedNow(), threshold)
+ acc.MarkStaleQuarantine(staleRow1)
+ acc.RecordSuccess(candidateOf("2", "2.B", "row-1", "do x โ sharpened by planner"))
+ if err := acc.Flush(progressPath, hashOf); err != nil {
+ t.Fatalf("R5 flush: %v", err)
+ }
+
+ prog, _ = progress.Load(progressPath)
+ row1 = &prog.Phases["2"].Subphases["2.B"].Items[0]
+ if row1.Health.Quarantine != nil {
+ t.Fatalf("R5: quarantine should be cleared, got %+v", row1.Health.Quarantine)
+ }
+ if row1.Health.ConsecutiveFailures != 0 {
+ t.Fatalf("R5: ConsecutiveFailures should reset to 0, got %d", row1.Health.ConsecutiveFailures)
+ }
+ if row1.Health.LastSuccess == "" {
+ t.Fatal("R5: LastSuccess should be set after successful run")
+ }
+}
diff --git a/internal/autoloop/report.go b/internal/autoloop/report.go
index 11e392694..d4d8ef32b 100644
--- a/internal/autoloop/report.go
+++ b/internal/autoloop/report.go
@@ -1,7 +1,12 @@
package autoloop
import (
+ "encoding/json"
+ "errors"
"fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
"regexp"
"strconv"
"strings"
@@ -235,3 +240,150 @@ var legacySectionTitles = []string{
"Commit",
"Acceptance check",
}
+
+// RepairContext bundles the secondary evidence sources TryRepairReport uses
+// to reconstruct a FinalReport when ParseFinalReport fails.
+type RepairContext struct {
+ WorkerStdout string
+ WorkerStderr string
+ WorktreePath string // git operations happen here
+ BaseBranch string // for diff range
+ AcceptanceLines []string // expected acceptance criteria from progress.json row
+}
+
+// RepairNote records one piece of evidence used during reconstruction.
+// Intended for forensic logging via writeRepairArtifact.
+type RepairNote struct {
+ Field string
+ Source string
+ Detail string
+}
+
+// TryRepairReport reconstructs a FinalReport from secondary evidence when
+// ParseFinalReport fails. Returns (nil, nil, error) when the worker did not
+// actually produce sound work โ strictly never accepts work without:
+// 1. A new commit on the worker's branch (vs BaseBranch)
+// 2. A non-empty diff
+// 3. At least one PASS token in the stdout
+// 4. Either every acceptance line appears in stdout, OR no acceptance set
+// (in which case PASS evidence alone is accepted)
+//
+// On success, returns a *FinalReport whose Acceptance field contains
+// synthesized RED/GREEN strings satisfying acceptanceEvidence().
+func TryRepairReport(ctx RepairContext) (*FinalReport, []RepairNote, error) {
+ if ctx.WorktreePath == "" {
+ return nil, nil, errors.New("repair: WorktreePath required")
+ }
+
+ notes := []RepairNote{}
+
+ commit, err := gitLastCommit(ctx.WorktreePath, ctx.BaseBranch)
+ if err != nil || commit == "" {
+ return nil, nil, errors.New("repair: no commit on worker branch")
+ }
+ notes = append(notes, RepairNote{Field: "commit", Source: "git_log", Detail: commit})
+
+ diff, err := gitDiff(ctx.WorktreePath, ctx.BaseBranch)
+ if err != nil || strings.TrimSpace(diff) == "" {
+ return nil, nil, errors.New("repair: empty diff")
+ }
+
+ if !strings.Contains(ctx.WorkerStdout, "PASS") {
+ return nil, nil, errors.New("repair: no PASS token in stdout")
+ }
+ notes = append(notes, RepairNote{Field: "evidence", Source: "stdout_grep", Detail: "found PASS token"})
+
+ if len(ctx.AcceptanceLines) > 0 {
+ for _, line := range ctx.AcceptanceLines {
+ if !strings.Contains(ctx.WorkerStdout, line) {
+ return nil, nil, errors.New("repair: acceptance line missing: " + line)
+ }
+ }
+ notes = append(notes, RepairNote{Field: "acceptance", Source: "stdout_grep", Detail: "matched all acceptance lines"})
+ } else {
+ notes = append(notes, RepairNote{Field: "acceptance", Source: "fallback", Detail: "no acceptance lines required"})
+ }
+
+ // Synthesize Acceptance entries that satisfy acceptanceEvidence().
+ // The strict parser requires at least one "red: ... exit 1" and one
+ // "green: ... exit 0" line; provide those minimally.
+ acceptance := []string{
+ "RED: repaired (no test command captured) exited with exit 1",
+ "GREEN: repaired (PASS token in worker stdout) exited with exit 0",
+ }
+ if len(ctx.AcceptanceLines) > 0 {
+ acceptance = append(acceptance, ctx.AcceptanceLines...)
+ }
+
+ return &FinalReport{
+ Commit: commit,
+ Acceptance: acceptance,
+ }, notes, nil
+}
+
+func gitLastCommit(dir, baseBranch string) (string, error) {
+ args := []string{"-C", dir, "log", "--format=%H", "-1"}
+ if baseBranch != "" {
+ args = []string{"-C", dir, "log", "--format=%H", baseBranch + "..HEAD", "-1"}
+ }
+ out, err := exec.Command("git", args...).Output()
+ if err != nil {
+ return "", err
+ }
+ return strings.TrimSpace(string(out)), nil
+}
+
+func gitDiff(dir, baseBranch string) (string, error) {
+ args := []string{"-C", dir, "diff"}
+ if baseBranch != "" {
+ args = []string{"-C", dir, "diff", baseBranch + "..HEAD"}
+ }
+ out, err := exec.Command("git", args...).Output()
+ if err != nil {
+ return "", err
+ }
+ return string(out), nil
+}
+
+// writeRepairArtifact persists a JSON record of a successful repair pass
+// for forensics. Failure to write the artifact is non-fatal โ the repair
+// itself still applies โ so callers should log but not abort on the error.
+//
+// Intended path: /state/repairs/-.json
+func writeRepairArtifact(path string, candidate Candidate, rep *FinalReport, diff string, notes []RepairNote, stdout string) error {
+ if path == "" {
+ return errors.New("repair artifact: path required")
+ }
+ dir := filepath.Dir(path)
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return fmt.Errorf("mkdir repair artifact dir: %w", err)
+ }
+
+ tail := stdout
+ if len(tail) > 4096 {
+ tail = tail[len(tail)-4096:]
+ }
+
+ body := map[string]any{
+ "candidate": candidate,
+ "commit": rep.Commit,
+ "diff_lines": countLines(diff),
+ "notes": notes,
+ "stdout_excerpt": tail,
+ }
+ data, err := json.MarshalIndent(body, "", " ")
+ if err != nil {
+ return fmt.Errorf("marshal repair artifact: %w", err)
+ }
+ return os.WriteFile(path, data, 0o644)
+}
+
+func countLines(s string) int {
+ n := 0
+ for _, c := range s {
+ if c == '\n' {
+ n++
+ }
+ }
+ return n
+}
diff --git a/internal/autoloop/report_test.go b/internal/autoloop/report_test.go
index 1771ed33d..295239387 100644
--- a/internal/autoloop/report_test.go
+++ b/internal/autoloop/report_test.go
@@ -2,6 +2,7 @@ package autoloop
import (
"os"
+ "os/exec"
"path/filepath"
"reflect"
"strconv"
@@ -265,3 +266,166 @@ func readReportFixture(t *testing.T, name string) string {
}
return string(raw)
}
+
+// setupRepoWithCommit initializes a git repo at dir with one commit on
+// `main`, then creates a `worker` branch with one additional commit checked
+// out as HEAD. Returns the worktree path (== dir) and the base branch name
+// (`main`). The worker commit is reachable from HEAD but not from main, so
+// `main..HEAD` yields exactly one commit and a non-empty diff.
+func setupRepoWithCommit(t *testing.T) (workdir string, baseBranch string) {
+ t.Helper()
+ dir := t.TempDir()
+ mustGit(t, dir, "init", "-b", "main")
+ mustGit(t, dir, "config", "user.email", "test@example.com")
+ mustGit(t, dir, "config", "user.name", "Test User")
+ if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("init\n"), 0o644); err != nil {
+ t.Fatalf("write README: %v", err)
+ }
+ mustGit(t, dir, "add", "README.md")
+ mustGit(t, dir, "commit", "-m", "init")
+ // Branch off main and add a worker commit so main..HEAD is non-empty.
+ mustGit(t, dir, "checkout", "-b", "worker")
+ if err := os.WriteFile(filepath.Join(dir, "worker.txt"), []byte("worker change\n"), 0o644); err != nil {
+ t.Fatalf("write worker.txt: %v", err)
+ }
+ mustGit(t, dir, "add", "worker.txt")
+ mustGit(t, dir, "commit", "-m", "worker change")
+ return dir, "main"
+}
+
+// setupRepoNoCommits initializes an empty git repo (no commits).
+func setupRepoNoCommits(t *testing.T) string {
+ t.Helper()
+ dir := t.TempDir()
+ mustGit(t, dir, "init", "-b", "main")
+ mustGit(t, dir, "config", "user.email", "test@example.com")
+ mustGit(t, dir, "config", "user.name", "Test User")
+ return dir
+}
+
+func mustGit(t *testing.T, dir string, args ...string) {
+ t.Helper()
+ cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
+ cmd.Env = append(os.Environ(),
+ "GIT_AUTHOR_NAME=Test", "GIT_AUTHOR_EMAIL=test@example.com",
+ "GIT_COMMITTER_NAME=Test", "GIT_COMMITTER_EMAIL=test@example.com",
+ )
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("git %v: %v\n%s", args, err, out)
+ }
+}
+
+func TestTryRepairReport_NoCommitFails(t *testing.T) {
+ dir := setupRepoNoCommits(t)
+ rep, _, err := TryRepairReport(RepairContext{
+ WorkerStdout: "PASS\nok",
+ WorktreePath: dir,
+ BaseBranch: "main",
+ })
+ if err == nil {
+ t.Fatalf("expected repair to fail with no commit; got rep=%+v", rep)
+ }
+}
+
+func TestTryRepairReport_NoPassFails(t *testing.T) {
+ dir, base := setupRepoWithCommit(t)
+ rep, _, err := TryRepairReport(RepairContext{
+ WorkerStdout: "FAIL: foo broke",
+ WorktreePath: dir,
+ BaseBranch: base,
+ })
+ if err == nil {
+ t.Fatalf("expected repair to fail without PASS token; got rep=%+v", rep)
+ }
+}
+
+func TestTryRepairReport_AcceptanceMissingFails(t *testing.T) {
+ dir, base := setupRepoWithCommit(t)
+ rep, _, err := TryRepairReport(RepairContext{
+ WorkerStdout: "ok\nPASS",
+ WorktreePath: dir,
+ BaseBranch: base,
+ AcceptanceLines: []string{"acceptance-line-A"}, // not in stdout
+ })
+ if err == nil {
+ t.Fatalf("expected repair to fail when acceptance line missing; got rep=%+v", rep)
+ }
+}
+
+func TestTryRepairReport_AcceptanceEmptyAcceptsOnPassEvidence(t *testing.T) {
+ dir, base := setupRepoWithCommit(t)
+ rep, notes, err := TryRepairReport(RepairContext{
+ WorkerStdout: "all good\nPASS\nok\n",
+ WorktreePath: dir,
+ BaseBranch: base,
+ // AcceptanceLines empty โ fallback rule: accept on PASS evidence.
+ })
+ if err != nil || rep == nil {
+ t.Fatalf("expected repair to succeed, got err=%v rep=%v", err, rep)
+ }
+ if rep.Commit == "" {
+ t.Fatal("expected reconstructed commit")
+ }
+ if len(notes) == 0 {
+ t.Fatal("expected at least one RepairNote")
+ }
+ // Synthesized acceptance must satisfy the existing acceptanceEvidence
+ // contract (one RED with exit 1, one GREEN with exit 0).
+ hasRed, hasGreen := acceptanceEvidence(rep.Acceptance)
+ if !hasRed {
+ t.Fatalf("synthesized acceptance lacks RED evidence: %v", rep.Acceptance)
+ }
+ if !hasGreen {
+ t.Fatalf("synthesized acceptance lacks GREEN evidence: %v", rep.Acceptance)
+ }
+}
+
+func TestTryRepairReport_AllAcceptanceLinesPresentAccepts(t *testing.T) {
+ dir, base := setupRepoWithCommit(t)
+ rep, _, err := TryRepairReport(RepairContext{
+ WorkerStdout: "acceptance-A done\nacceptance-B done\nPASS\nok",
+ WorktreePath: dir,
+ BaseBranch: base,
+ AcceptanceLines: []string{"acceptance-A", "acceptance-B"},
+ })
+ if err != nil || rep == nil {
+ t.Fatalf("expected repair to succeed, got err=%v", err)
+ }
+ if rep.Commit == "" {
+ t.Fatal("expected reconstructed commit")
+ }
+}
+
+func TestTryRepairReport_EmptyDiffFails(t *testing.T) {
+ dir, base := setupRepoWithCommit(t)
+ // Reset to the base branch so HEAD..base diff is empty.
+ mustGit(t, dir, "reset", "--hard", base)
+ rep, _, err := TryRepairReport(RepairContext{
+ WorkerStdout: "PASS\nok",
+ WorktreePath: dir,
+ BaseBranch: base,
+ })
+ if err == nil {
+ t.Fatalf("expected repair to fail with empty diff; got rep=%+v", rep)
+ }
+}
+
+func TestWriteRepairArtifact_WritesJSON(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "repairs", "run-1-worker-2.json")
+ rep := &FinalReport{Commit: "abc123", Acceptance: []string{"GREEN: ok"}}
+ notes := []RepairNote{{Field: "commit", Source: "git_log", Detail: "abc123"}}
+ if err := writeRepairArtifact(path, Candidate{ItemName: "row-x"}, rep, "diff body\n", notes, "PASS\nok\n"); err != nil {
+ t.Fatalf("write: %v", err)
+ }
+ body, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("read: %v", err)
+ }
+ got := string(body)
+ for _, want := range []string{`"commit": "abc123"`, `"ItemName": "row-x"`, `"PASS\nok\n"`} {
+ if !strings.Contains(got, want) {
+ t.Fatalf("artifact missing %q\n%s", want, got)
+ }
+ }
+}
diff --git a/internal/autoloop/run.go b/internal/autoloop/run.go
index 71e2e2c0b..eb2955bd2 100644
--- a/internal/autoloop/run.go
+++ b/internal/autoloop/run.go
@@ -40,9 +40,10 @@ type workerRun struct {
func RunOnce(ctx context.Context, opts RunOptions) (RunSummary, error) {
candidates, err := NormalizeCandidates(opts.Config.ProgressJSON, CandidateOptions{
- ActiveFirst: true,
- PriorityBoost: opts.Config.PriorityBoost,
- MaxPhase: opts.Config.MaxPhase,
+ ActiveFirst: true,
+ PriorityBoost: opts.Config.PriorityBoost,
+ MaxPhase: opts.Config.MaxPhase,
+ IncludeQuarantined: opts.Config.IncludeQuarantined,
})
if err != nil {
return RunSummary{}, err
@@ -79,6 +80,10 @@ func RunOnce(ctx context.Context, opts RunOptions) (RunSummary, error) {
// success / failure outcomes for each candidate; Flush at the end of the
// run mutates progress.json in one batched write.
acc := newHealthAccumulator(runID, time.Now, opts.Config.QuarantineThreshold)
+ runner := opts.Runner
+ if runner == nil {
+ runner = ExecRunner{}
+ }
chain := opts.Config.BackendFallback
if len(chain) == 0 {
chain = []string{opts.Config.Backend}
@@ -131,6 +136,16 @@ func RunOnce(ctx context.Context, opts RunOptions) (RunSummary, error) {
})
return fmt.Errorf("flush health: %w", err)
}
+ if err := commitRunHealth(ctx, opts.Config, runner); err != nil {
+ _ = appendRunLedgerEvent(opts.Config, LedgerEvent{
+ TS: time.Now().UTC(),
+ RunID: runID,
+ Event: "health_update_failed",
+ Status: "failed",
+ Detail: err.Error(),
+ })
+ return fmt.Errorf("commit health: %w", err)
+ }
_ = appendRunLedgerEvent(opts.Config, LedgerEvent{
TS: time.Now().UTC(),
RunID: runID,
@@ -143,6 +158,7 @@ func RunOnce(ctx context.Context, opts RunOptions) (RunSummary, error) {
// observeOutcome feeds the degrader and emits backend_degraded ledger
// events on switch. Called sequentially after each finishWorker so it is
// safe with no synchronization.
+ completedWork := false
observeOutcome := func(out workerOutcome) {
switched, from, to := degrader.ObserveOutcome(out)
if !switched {
@@ -157,9 +173,19 @@ func RunOnce(ctx context.Context, opts RunOptions) (RunSummary, error) {
})
}
- runner := opts.Runner
- if runner == nil {
- runner = ExecRunner{}
+ completeRun := func() error {
+ if err := runPostPromotionGate(ctx, opts.Config, runner, runID, completedWork); err != nil {
+ return err
+ }
+ if err := appendRunLedgerEvent(opts.Config, LedgerEvent{
+ TS: time.Now().UTC(),
+ RunID: runID,
+ Event: "run_completed",
+ Status: "completed",
+ }); err != nil {
+ return errors.Join(err, flushHealth())
+ }
+ return flushHealth()
}
argv, err := BuildBackendCommand(opts.Config.Backend, opts.Config.Mode)
@@ -191,16 +217,9 @@ func RunOnce(ctx context.Context, opts RunOptions) (RunSummary, error) {
if finishErr != nil {
return RunSummary{}, errors.Join(finishErr, flushHealth())
}
+ completedWork = true
}
- if err := appendRunLedgerEvent(opts.Config, LedgerEvent{
- TS: time.Now().UTC(),
- RunID: runID,
- Event: "run_completed",
- Status: "completed",
- }); err != nil {
- return RunSummary{}, errors.Join(err, flushHealth())
- }
- if err := flushHealth(); err != nil {
+ if err := completeRun(); err != nil {
return summary, err
}
@@ -279,20 +298,243 @@ func RunOnce(ctx context.Context, opts RunOptions) (RunSummary, error) {
if finishErr != nil {
return RunSummary{}, errors.Join(finishErr, flushHealth())
}
+ completedWork = true
}
- if err := appendRunLedgerEvent(opts.Config, LedgerEvent{
+ if err := completeRun(); err != nil {
+ return summary, err
+ }
+
+ return summary, nil
+}
+
+func runPostPromotionGate(ctx context.Context, cfg Config, runner Runner, runID string, promotedWork bool) error {
+ if !promotedWork || len(cfg.PostPromotionVerifyCommands) == 0 {
+ return nil
+ }
+
+ verifyErr := runPostPromotionVerification(ctx, cfg, runner, runID, 1)
+ if verifyErr == nil {
+ return nil
+ }
+
+ attempts := cfg.PostPromotionRepairAttempts
+ if !cfg.PostPromotionRepairEnabled || attempts <= 0 {
+ _ = appendRunLedgerEvent(cfg, LedgerEvent{
+ TS: time.Now().UTC(),
+ RunID: runID,
+ Event: "run_failed",
+ Status: "post_promotion_verify_failed",
+ Detail: truncateLedgerDetail(verifyErr.Error()),
+ })
+ return verifyErr
+ }
+
+ lastErr := verifyErr
+ for attempt := 1; attempt <= attempts; attempt++ {
+ if repairErr := runPostPromotionRepair(ctx, cfg, runner, runID, attempt, lastErr); repairErr != nil {
+ _ = appendRunLedgerEvent(cfg, LedgerEvent{
+ TS: time.Now().UTC(),
+ RunID: runID,
+ Event: "run_failed",
+ Status: "post_promotion_repair_failed",
+ Detail: truncateLedgerDetail(repairErr.Error()),
+ })
+ return errors.Join(lastErr, repairErr)
+ }
+ lastErr = runPostPromotionVerification(ctx, cfg, runner, runID, attempt+1)
+ if lastErr == nil {
+ return nil
+ }
+ }
+
+ _ = appendRunLedgerEvent(cfg, LedgerEvent{
TS: time.Now().UTC(),
RunID: runID,
- Event: "run_completed",
- Status: "completed",
+ Event: "run_failed",
+ Status: "post_promotion_verify_failed",
+ Detail: truncateLedgerDetail(lastErr.Error()),
+ })
+ return lastErr
+}
+
+func runPostPromotionVerification(ctx context.Context, cfg Config, runner Runner, runID string, attempt int) error {
+ if err := appendRunLedgerEvent(cfg, LedgerEvent{
+ TS: time.Now().UTC(),
+ RunID: runID,
+ Event: "post_promotion_verify_started",
+ Status: "started",
+ Detail: fmt.Sprintf("attempt=%d commands=%d", attempt, len(cfg.PostPromotionVerifyCommands)),
}); err != nil {
- return RunSummary{}, errors.Join(err, flushHealth())
+ return err
}
- if err := flushHealth(); err != nil {
- return summary, err
+
+ for i, shellCommand := range cfg.PostPromotionVerifyCommands {
+ result := runner.Run(ctx, Command{
+ Name: "sh",
+ Args: []string{"-lc", shellCommand},
+ Dir: cfg.RepoRoot,
+ Env: postPromotionCommandEnv(cfg),
+ })
+ if result.Err != nil {
+ err := postPromotionCommandError("verification", shellCommand, result)
+ ledgerErr := appendRunLedgerEvent(cfg, LedgerEvent{
+ TS: time.Now().UTC(),
+ RunID: runID,
+ Event: "post_promotion_verify_failed",
+ Status: "failed",
+ Detail: truncateLedgerDetail(fmt.Sprintf("attempt=%d command=%d/%d %q: %s", attempt, i+1, len(cfg.PostPromotionVerifyCommands), shellCommand, commandFailureDetail(result))),
+ })
+ return errors.Join(err, ledgerErr)
+ }
}
- return summary, nil
+ if err := ensureNoMergeConflicts(cfg.RepoRoot); err != nil {
+ ledgerErr := appendRunLedgerEvent(cfg, LedgerEvent{
+ TS: time.Now().UTC(),
+ RunID: runID,
+ Event: "post_promotion_verify_failed",
+ Status: "failed",
+ Detail: truncateLedgerDetail(fmt.Sprintf("attempt=%d clean-check: %s", attempt, err.Error())),
+ })
+ return errors.Join(err, ledgerErr)
+ }
+ if err := ensureWorktreeClean(cfg.RepoRoot); err != nil {
+ ledgerErr := appendRunLedgerEvent(cfg, LedgerEvent{
+ TS: time.Now().UTC(),
+ RunID: runID,
+ Event: "post_promotion_verify_failed",
+ Status: "failed",
+ Detail: truncateLedgerDetail(fmt.Sprintf("attempt=%d clean-check: %s", attempt, err.Error())),
+ })
+ return errors.Join(err, ledgerErr)
+ }
+
+ return appendRunLedgerEvent(cfg, LedgerEvent{
+ TS: time.Now().UTC(),
+ RunID: runID,
+ Event: "post_promotion_verify_succeeded",
+ Status: "ok",
+ Detail: fmt.Sprintf("attempt=%d commands=%d", attempt, len(cfg.PostPromotionVerifyCommands)),
+ })
+}
+
+func runPostPromotionRepair(ctx context.Context, cfg Config, runner Runner, runID string, attempt int, cause error) error {
+ if err := appendRunLedgerEvent(cfg, LedgerEvent{
+ TS: time.Now().UTC(),
+ RunID: runID,
+ Event: "post_promotion_repair_started",
+ Status: "started",
+ Detail: fmt.Sprintf("attempt=%d", attempt),
+ }); err != nil {
+ return err
+ }
+
+ argv, err := BuildBackendCommand(cfg.Backend, cfg.Mode)
+ if err != nil {
+ ledgerErr := appendRunLedgerEvent(cfg, LedgerEvent{
+ TS: time.Now().UTC(),
+ RunID: runID,
+ Event: "post_promotion_repair_failed",
+ Status: "failed",
+ Detail: truncateLedgerDetail(err.Error()),
+ })
+ return errors.Join(err, ledgerErr)
+ }
+
+ args := append([]string(nil), argv[1:]...)
+ args = append(args, BuildPostPromotionRepairPrompt(cfg.PostPromotionVerifyCommands, cause))
+ result := runner.Run(ctx, Command{
+ Name: argv[0],
+ Args: args,
+ Dir: cfg.RepoRoot,
+ Env: postPromotionCommandEnv(cfg),
+ })
+ if result.Err != nil {
+ err := postPromotionCommandError("repair", argv[0], result)
+ ledgerErr := appendRunLedgerEvent(cfg, LedgerEvent{
+ TS: time.Now().UTC(),
+ RunID: runID,
+ Event: "post_promotion_repair_failed",
+ Status: "failed",
+ Detail: truncateLedgerDetail(fmt.Sprintf("attempt=%d: %s", attempt, commandFailureDetail(result))),
+ })
+ return errors.Join(err, ledgerErr)
+ }
+ if err := ensureNoMergeConflicts(cfg.RepoRoot); err != nil {
+ ledgerErr := appendRunLedgerEvent(cfg, LedgerEvent{
+ TS: time.Now().UTC(),
+ RunID: runID,
+ Event: "post_promotion_repair_failed",
+ Status: "failed",
+ Detail: truncateLedgerDetail(fmt.Sprintf("attempt=%d clean-check: %s", attempt, err.Error())),
+ })
+ return errors.Join(err, ledgerErr)
+ }
+ if err := ensureWorktreeClean(cfg.RepoRoot); err != nil {
+ ledgerErr := appendRunLedgerEvent(cfg, LedgerEvent{
+ TS: time.Now().UTC(),
+ RunID: runID,
+ Event: "post_promotion_repair_failed",
+ Status: "failed",
+ Detail: truncateLedgerDetail(fmt.Sprintf("attempt=%d clean-check: %s", attempt, err.Error())),
+ })
+ return errors.Join(err, ledgerErr)
+ }
+
+ return appendRunLedgerEvent(cfg, LedgerEvent{
+ TS: time.Now().UTC(),
+ RunID: runID,
+ Event: "post_promotion_repair_succeeded",
+ Status: "ok",
+ Detail: fmt.Sprintf("attempt=%d", attempt),
+ })
+}
+
+func postPromotionCommandError(kind, command string, result Result) error {
+ output := commandFailureDetail(result)
+ if output == "" {
+ return fmt.Errorf("post-promotion %s command %q failed: %w", kind, command, result.Err)
+ }
+ return fmt.Errorf("post-promotion %s command %q failed: %w: %s", kind, command, result.Err, output)
+}
+
+func commandFailureDetail(result Result) string {
+ var parts []string
+ if result.Err != nil {
+ parts = append(parts, result.Err.Error())
+ }
+ output := strings.TrimSpace(result.Stderr)
+ if output == "" {
+ output = strings.TrimSpace(result.Stdout)
+ }
+ if output != "" {
+ parts = append(parts, output)
+ }
+ return strings.Join(parts, ": ")
+}
+
+func postPromotionCommandEnv(cfg Config) []string {
+ env := []string{
+ "PROGRESS_JSON=" + cfg.ProgressJSON,
+ "RUN_ROOT=" + cfg.RunRoot,
+ "BACKEND=" + cfg.Backend,
+ "MODE=" + cfg.Mode,
+ fmt.Sprintf("MAX_AGENTS=%d", cfg.MaxAgents),
+ fmt.Sprintf("MAX_PHASE=%d", cfg.MaxPhase),
+ }
+ if len(cfg.PriorityBoost) > 0 {
+ env = append(env, "PRIORITY_BOOST="+strings.Join(cfg.PriorityBoost, ","))
+ }
+ return env
+}
+
+func truncateLedgerDetail(value string) string {
+ value = strings.TrimSpace(value)
+ const maxDetail = 2000
+ if len(value) <= maxDetail {
+ return value
+ }
+ return value[:maxDetail] + "..."
}
// recordWorkerOutcome translates a finishWorker result into accumulator +
@@ -639,6 +881,47 @@ func promoteWorkerCommit(ctx context.Context, cfg Config, runner Runner, runID s
})
}
+func commitRunHealth(ctx context.Context, cfg Config, runner Runner) error {
+ if cfg.RepoRoot == "" || cfg.ProgressJSON == "" || !repoHasGit(cfg.RepoRoot) {
+ return nil
+ }
+ rel, err := filepath.Rel(cfg.RepoRoot, cfg.ProgressJSON)
+ if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
+ return nil
+ }
+
+ status := runner.Run(ctx, Command{
+ Name: "git",
+ Args: []string{"status", "--short", "--", rel},
+ Dir: cfg.RepoRoot,
+ })
+ if status.Err != nil {
+ return fmt.Errorf("check progress health status: %w", status.Err)
+ }
+ if strings.TrimSpace(status.Stdout) == "" {
+ return nil
+ }
+
+ add := runner.Run(ctx, Command{
+ Name: "git",
+ Args: []string{"add", "--", rel},
+ Dir: cfg.RepoRoot,
+ })
+ if add.Err != nil {
+ return fmt.Errorf("stage progress health: %w", add.Err)
+ }
+
+ commit := runner.Run(ctx, Command{
+ Name: "git",
+ Args: []string{"commit", "-m", "autoloop: record run health", "--", rel},
+ Dir: cfg.RepoRoot,
+ })
+ if commit.Err != nil {
+ return fmt.Errorf("commit progress health: %w", commit.Err)
+ }
+ return nil
+}
+
func removeCleanWorkerWorktree(repoRoot, worktreePath string) {
_ = gitRemoveWorkerWorktree(repoRoot, worktreePath)
}
@@ -802,6 +1085,38 @@ func BuildWorkerPrompt(candidate Candidate) string {
return BuildWorkerPromptWithBranch(candidate, "")
}
+func BuildPostPromotionRepairPrompt(verifyCommands []string, cause error) string {
+ var prompt strings.Builder
+
+ prompt.WriteString("Mission:\n")
+ prompt.WriteString("Repair the integrated Gormes control checkout after promoted autoloop worker changes failed the mandatory post-promotion verification gate.\n\n")
+
+ prompt.WriteString("Context:\n")
+ prompt.WriteString("- You are repairing the already-promoted integration state, not selecting new roadmap work.\n")
+ prompt.WriteString("- Keep edits minimal and directly tied to the failing verification output.\n")
+ prompt.WriteString("- The final run health must not be recorded until this full-suite gate passes.\n\n")
+
+ prompt.WriteString("Failing verification:\n")
+ if cause == nil {
+ prompt.WriteString("- (no error detail available)\n\n")
+ } else {
+ fmt.Fprintf(&prompt, "- %s\n\n", truncateLedgerDetail(cause.Error()))
+ }
+
+ prompt.WriteString("Full-suite commands to restore:\n")
+ writePromptList(&prompt, verifyCommands)
+ prompt.WriteString("\n")
+
+ prompt.WriteString("Requirements:\n")
+ prompt.WriteString("- Inspect the failure before editing.\n")
+ prompt.WriteString("- Fix code, tests, docs, or progress metadata needed for the promoted integration to pass.\n")
+ prompt.WriteString("- Run the full-suite commands above after repair.\n")
+ prompt.WriteString("- Stage and commit repair changes with a clear message before exiting.\n")
+ prompt.WriteString("- Leave the repository with no uncommitted changes or unresolved merge conflicts.\n")
+
+ return prompt.String()
+}
+
func BuildWorkerPromptWithBranch(candidate Candidate, branch string) string {
var prompt strings.Builder
diff --git a/internal/autoloop/run_health_test.go b/internal/autoloop/run_health_test.go
index 87169241e..e2f1c98cc 100644
--- a/internal/autoloop/run_health_test.go
+++ b/internal/autoloop/run_health_test.go
@@ -5,6 +5,7 @@ import (
"errors"
"os"
"path/filepath"
+ "reflect"
"strings"
"testing"
"time"
@@ -162,6 +163,223 @@ func TestRunOnce_HealthUpdatedEventEmittedOnSuccess(t *testing.T) {
}
}
+func TestRunOnce_PostPromotionVerifyFailureStopsBeforeRunHealth(t *testing.T) {
+ progressPath := writeNamedProgressJSON(t, baseNamedProgress)
+ runRoot := t.TempDir()
+ verifyErr := errors.New("exit status 1")
+ runner := &FakeRunner{Results: []Result{
+ {},
+ {Err: verifyErr, Stderr: "suite broke"},
+ }}
+
+ _, err := RunOnce(context.Background(), RunOptions{
+ Config: Config{
+ RepoRoot: t.TempDir(),
+ ProgressJSON: progressPath,
+ RunRoot: runRoot,
+ Backend: "opencode",
+ Mode: "safe",
+ MaxAgents: 1,
+ MaxPhase: 12,
+ PostPromotionVerifyCommands: []string{"go test ./... -count=1"},
+ PostPromotionRepairEnabled: false,
+ PostPromotionRepairAttempts: 0,
+ QuarantineThreshold: 3,
+ BackendDegradeThreshold: 3,
+ },
+ Runner: runner,
+ })
+ if !errors.Is(err, verifyErr) {
+ t.Fatalf("RunOnce() error = %v, want wrapped %v", err, verifyErr)
+ }
+
+ events := readLedgerEvents(t, filepath.Join(runRoot, "state", "runs.jsonl"))
+ if !ledgerContainsEvent(events, "post_promotion_verify_failed") {
+ t.Fatalf("ledger missing post_promotion_verify_failed; got=%v", ledgerEventNames(events))
+ }
+ if ledgerContainsEvent(events, "run_completed") {
+ t.Fatalf("ledger should NOT contain run_completed after failed post-promotion verification; got=%v", ledgerEventNames(events))
+ }
+ if ledgerContainsEvent(events, "health_updated") {
+ t.Fatalf("ledger should NOT contain health_updated after failed post-promotion verification; got=%v", ledgerEventNames(events))
+ }
+
+ item := loadItem(t, progressPath, "12", "12.A", "row-1")
+ if item.Health != nil && item.Health.LastSuccess != "" {
+ t.Fatalf("item.Health.LastSuccess = %q, want empty because health was not flushed", item.Health.LastSuccess)
+ }
+ if got, want := len(runner.Commands), 2; got != want {
+ t.Fatalf("Commands length = %d, want %d", got, want)
+ }
+ if runner.Commands[1].Name != "sh" || !reflect.DeepEqual(runner.Commands[1].Args, []string{"-lc", "go test ./... -count=1"}) {
+ t.Fatalf("verification command = %#v, want shell command", runner.Commands[1])
+ }
+}
+
+func TestRunOnce_PostPromotionVerifyFailureRepairsBeforeRunHealth(t *testing.T) {
+ progressPath := writeNamedProgressJSON(t, baseNamedProgress)
+ runRoot := t.TempDir()
+ verifyErr := errors.New("exit status 1")
+ runner := &FakeRunner{Results: []Result{
+ {},
+ {Err: verifyErr, Stderr: "suite broke"},
+ {},
+ {},
+ }}
+
+ _, err := RunOnce(context.Background(), RunOptions{
+ Config: Config{
+ RepoRoot: t.TempDir(),
+ ProgressJSON: progressPath,
+ RunRoot: runRoot,
+ Backend: "opencode",
+ Mode: "safe",
+ MaxAgents: 1,
+ MaxPhase: 12,
+ PostPromotionVerifyCommands: []string{"go test ./... -count=1"},
+ PostPromotionRepairEnabled: true,
+ PostPromotionRepairAttempts: 1,
+ QuarantineThreshold: 3,
+ BackendDegradeThreshold: 3,
+ },
+ Runner: runner,
+ })
+ if err != nil {
+ t.Fatalf("RunOnce() error = %v", err)
+ }
+
+ events := readLedgerEvents(t, filepath.Join(runRoot, "state", "runs.jsonl"))
+ var got []string
+ for _, event := range events {
+ got = append(got, event.Event+":"+event.Status)
+ }
+ want := []string{
+ "run_started:started",
+ "worker_claimed:claimed",
+ "worker_success:success",
+ "post_promotion_verify_started:started",
+ "post_promotion_verify_failed:failed",
+ "post_promotion_repair_started:started",
+ "post_promotion_repair_succeeded:ok",
+ "post_promotion_verify_started:started",
+ "post_promotion_verify_succeeded:ok",
+ "run_completed:completed",
+ "health_updated:ok",
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("ledger events = %#v, want %#v", got, want)
+ }
+
+ item := loadItem(t, progressPath, "12", "12.A", "row-1")
+ if item.Health == nil || item.Health.LastSuccess == "" {
+ t.Fatalf("item.Health.LastSuccess not set after repaired verification; got %+v", item.Health)
+ }
+ if got, want := len(runner.Commands), 4; got != want {
+ t.Fatalf("Commands length = %d, want %d", got, want)
+ }
+ if runner.Commands[2].Name != "opencode" {
+ t.Fatalf("repair command = %#v, want opencode backend", runner.Commands[2])
+ }
+ if runner.Commands[3].Name != "sh" || !reflect.DeepEqual(runner.Commands[3].Args, []string{"-lc", "go test ./... -count=1"}) {
+ t.Fatalf("second verification command = %#v, want shell command", runner.Commands[3])
+ }
+}
+
+func TestRunOnce_CommitsRunHealthAfterPromotedWorker(t *testing.T) {
+ repoRoot := t.TempDir()
+ initCleanRepo(t, repoRoot)
+
+ progressPath := filepath.Join(repoRoot, "docs", "content", "building-gormes", "architecture_plan", "progress.json")
+ if err := os.MkdirAll(filepath.Dir(progressPath), 0o755); err != nil {
+ t.Fatalf("mkdir progress dir: %v", err)
+ }
+ if err := os.WriteFile(progressPath, []byte(`{
+ "meta": {
+ "version": "2.0",
+ "last_updated": "2026-04-24",
+ "links": {"github_readme": "", "landing_page": "", "docs_site": "", "source_code": ""}
+ },
+ "phases": {
+ "3": {
+ "name": "P3",
+ "deliverable": "memory",
+ "subphases": {
+ "3.F": {
+ "name": "Goncho",
+ "items": [
+ {
+ "name": "health clean row",
+ "status": "planned",
+ "contract": "land worker and health",
+ "contract_status": "draft",
+ "write_scope": ["internal/goncho/"]
+ }
+ ]
+ }
+ }
+ }
+ }
+}
+`), 0o644); err != nil {
+ t.Fatalf("write progress: %v", err)
+ }
+ runGitCommand(t, repoRoot, "add", ".")
+ runGitCommand(t, repoRoot, "commit", "-m", "add progress")
+
+ runner := runnerFunc(func(ctx context.Context, command Command) Result {
+ switch command.Name {
+ case "opencode":
+ path := filepath.Join(command.Dir, "internal", "goncho", "health_clean.go")
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte("package goncho\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if result := (ExecRunner{}).Run(ctx, Command{Name: "git", Args: []string{"add", "."}, Dir: command.Dir}); result.Err != nil {
+ t.Fatalf("git add: %v\n%s", result.Err, result.Stderr)
+ }
+ if result := (ExecRunner{}).Run(ctx, Command{Name: "git", Args: []string{"commit", "-m", "worker change"}, Dir: command.Dir}); result.Err != nil {
+ t.Fatalf("git commit: %v\n%s", result.Err, result.Stderr)
+ }
+ return Result{}
+ case "git":
+ if len(command.Args) > 0 && command.Args[0] == "push" {
+ return Result{Err: errors.New("offline push")}
+ }
+ return (ExecRunner{}).Run(ctx, command)
+ default:
+ return Result{}
+ }
+ })
+
+ runRoot := t.TempDir()
+ _, err := RunOnce(context.Background(), RunOptions{
+ Config: Config{
+ RepoRoot: repoRoot,
+ ProgressJSON: progressPath,
+ RunRoot: runRoot,
+ Backend: "opencode",
+ Mode: "safe",
+ MaxAgents: 1,
+ MaxPhase: 3,
+ },
+ Runner: runner,
+ Now: time.Date(2026, 4, 25, 4, 56, 0, 0, time.UTC),
+ })
+ if err != nil {
+ t.Fatalf("RunOnce() error = %v", err)
+ }
+
+ if status := gitStatusPorcelain(t, repoRoot); status != "" {
+ t.Fatalf("base repo status = %q, want clean after worker promotion and health update", status)
+ }
+ item := loadItem(t, progressPath, "3", "3.F", "health clean row")
+ if item.Health == nil || item.Health.LastSuccess == "" {
+ t.Fatalf("item.Health.LastSuccess not set after run; got %+v", item.Health)
+ }
+}
+
func TestRunOnce_PreflightFailureSoftSkipsAndContinues(t *testing.T) {
repoRoot := t.TempDir()
initCleanRepo(t, repoRoot)
@@ -329,3 +547,59 @@ func ledgerContainsEvent(events []LedgerEvent, name string) bool {
}
return false
}
+
+// TestRunOnce_HealthUpdateFailedEventOnFlushError verifies the spec contract
+// that RunOnce must (a) emit a "health_update_failed" ledger event AND
+// (b) propagate the flush error back to the caller when the run-end Flush
+// fails. The failure is induced by chmod'ing the progress.json parent
+// directory to read-only after the runner completes its worker invocation,
+// so atomicWrite (CreateTemp + Rename) inside SaveProgress fails.
+func TestRunOnce_HealthUpdateFailedEventOnFlushError(t *testing.T) {
+ progressPath := writeNamedProgressJSON(t, baseNamedProgress)
+ progressDir := filepath.Dir(progressPath)
+ runRoot := t.TempDir()
+
+ // Restore writability so t.TempDir's RemoveAll cleanup can succeed.
+ t.Cleanup(func() {
+ _ = os.Chmod(progressDir, 0o755)
+ })
+
+ // chmodRunner records a successful worker invocation, then locks the
+ // progress.json parent directory before returning so the run-end Flush
+ // fails on the next atomicWrite. The initial NormalizeCandidates load
+ // has already happened by the time Run is called.
+ runner := runnerFunc(func(_ context.Context, _ Command) Result {
+ if err := os.Chmod(progressDir, 0o555); err != nil {
+ t.Fatalf("chmod progress dir read-only: %v", err)
+ }
+ return Result{}
+ })
+
+ _, err := RunOnce(context.Background(), RunOptions{
+ Config: Config{
+ RepoRoot: t.TempDir(),
+ ProgressJSON: progressPath,
+ RunRoot: runRoot,
+ Backend: "opencode",
+ Mode: "safe",
+ MaxAgents: 1,
+ QuarantineThreshold: 3,
+ BackendDegradeThreshold: 3,
+ },
+ Runner: runner,
+ })
+ if err == nil {
+ t.Fatal("RunOnce() error = nil, want flush error")
+ }
+ if !strings.Contains(err.Error(), "flush health") {
+ t.Fatalf("RunOnce() error = %q, want wrapped flush health error", err)
+ }
+
+ events := readLedgerEvents(t, filepath.Join(runRoot, "state", "runs.jsonl"))
+ if !ledgerContainsEvent(events, "health_update_failed") {
+ t.Fatalf("ledger missing health_update_failed; got=%v", ledgerEventNames(events))
+ }
+ if ledgerContainsEvent(events, "health_updated") {
+ t.Fatalf("ledger should NOT contain health_updated when flush failed; got=%v", ledgerEventNames(events))
+ }
+}
diff --git a/internal/cli/pty_bridge.go b/internal/cli/pty_bridge.go
new file mode 100644
index 000000000..a7a6eec3a
--- /dev/null
+++ b/internal/cli/pty_bridge.go
@@ -0,0 +1,310 @@
+package cli
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "runtime"
+ "strconv"
+ "strings"
+ "time"
+)
+
+const (
+ DefaultPtyReadTimeout = 200 * time.Millisecond
+ DefaultPtyReadChunkSize = 64 * 1024
+ MaxPtyWriteBytes = 64 * 1024
+ MaxPtyCols = 1000
+ MaxPtyRows = 1000
+)
+
+var (
+ ErrPtyUnavailable = errors.New("cli: pty unavailable")
+ ErrInvalidPtyMessage = errors.New("cli: invalid pty message")
+)
+
+type PtyUnavailableError struct {
+ GOOS string
+ Reason string
+}
+
+func (e *PtyUnavailableError) Error() string {
+ if e == nil {
+ return ErrPtyUnavailable.Error()
+ }
+ if e.Reason == "" {
+ return fmt.Sprintf("%s: %s", ErrPtyUnavailable, e.GOOS)
+ }
+ return fmt.Sprintf("%s: %s", ErrPtyUnavailable, e.Reason)
+}
+
+func (e *PtyUnavailableError) Is(target error) bool {
+ return target == ErrPtyUnavailable
+}
+
+type PtyInvalidMessageError struct {
+ Reason string
+}
+
+func (e *PtyInvalidMessageError) Error() string {
+ if e == nil || e.Reason == "" {
+ return ErrInvalidPtyMessage.Error()
+ }
+ return fmt.Sprintf("%s: %s", ErrInvalidPtyMessage, e.Reason)
+}
+
+func (e *PtyInvalidMessageError) Is(target error) bool {
+ return target == ErrInvalidPtyMessage
+}
+
+type PtySize struct {
+ Cols int
+ Rows int
+}
+
+type PtySpawnRequest struct {
+ Argv []string
+ CWD string
+ Env map[string]string
+ Cols int
+ Rows int
+}
+
+type PtySession interface {
+ Read(timeout time.Duration, maxBytes int) ([]byte, error)
+ Write(data []byte) error
+ Resize(cols, rows int) error
+ Close() error
+ IsAlive() bool
+ PID() int
+}
+
+type PtySpawnFunc func(context.Context, PtySpawnRequest) (PtySession, error)
+
+type PtyAdapterConfig struct {
+ RuntimeGOOS string
+ Spawn PtySpawnFunc
+}
+
+type PtyAdapter struct {
+ session PtySession
+}
+
+func NewPtyAdapter(ctx context.Context, req PtySpawnRequest, cfg PtyAdapterConfig) (*PtyAdapter, error) {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+
+ goos := cfg.RuntimeGOOS
+ if goos == "" {
+ goos = runtime.GOOS
+ }
+ if !ptyPlatformAvailable(goos) {
+ return nil, &PtyUnavailableError{
+ GOOS: goos,
+ Reason: ptyUnavailableReason(goos),
+ }
+ }
+
+ normalized, err := normalizePtySpawnRequest(req)
+ if err != nil {
+ return nil, err
+ }
+
+ spawn := cfg.Spawn
+ if spawn == nil {
+ spawn = spawnPtySession
+ }
+
+ session, err := spawn(ctx, normalized)
+ if err != nil {
+ return nil, err
+ }
+ if session == nil {
+ return nil, io.ErrClosedPipe
+ }
+
+ return NewPtyAdapterForSession(session), nil
+}
+
+func NewPtyAdapterForSession(session PtySession) *PtyAdapter {
+ return &PtyAdapter{session: session}
+}
+
+func PtyAvailable() bool {
+ return ptyPlatformAvailable(runtime.GOOS)
+}
+
+func (a *PtyAdapter) Read(timeout time.Duration, maxBytes int) ([]byte, error) {
+ session, err := a.requireSession()
+ if err != nil {
+ return nil, err
+ }
+ if timeout < 0 {
+ return nil, invalidPtyMessage("read timeout must be non-negative")
+ }
+ if maxBytes <= 0 {
+ return nil, invalidPtyMessage("read chunk size must be positive")
+ }
+ if maxBytes > DefaultPtyReadChunkSize {
+ maxBytes = DefaultPtyReadChunkSize
+ }
+
+ return session.Read(timeout, maxBytes)
+}
+
+func (a *PtyAdapter) Write(data []byte) error {
+ session, err := a.requireSession()
+ if err != nil {
+ return err
+ }
+ if len(data) == 0 {
+ return invalidPtyMessage("write payload must be non-empty")
+ }
+ if len(data) > MaxPtyWriteBytes {
+ return invalidPtyMessage("write payload exceeds %d bytes", MaxPtyWriteBytes)
+ }
+
+ return session.Write(append([]byte(nil), data...))
+}
+
+func (a *PtyAdapter) Resize(cols, rows int) error {
+ session, err := a.requireSession()
+ if err != nil {
+ return err
+ }
+ if err := validatePtySize(cols, rows); err != nil {
+ return err
+ }
+
+ return session.Resize(cols, rows)
+}
+
+func (a *PtyAdapter) HandleClientMessage(raw []byte) error {
+ cols, rows, resize, err := parsePtyResizeMessage(raw)
+ if err != nil {
+ return err
+ }
+ if resize {
+ return a.Resize(cols, rows)
+ }
+
+ return a.Write(raw)
+}
+
+func (a *PtyAdapter) Close() error {
+ session, err := a.requireSession()
+ if err != nil {
+ return err
+ }
+ return session.Close()
+}
+
+func (a *PtyAdapter) IsAlive() bool {
+ session, err := a.requireSession()
+ if err != nil {
+ return false
+ }
+ return session.IsAlive()
+}
+
+func (a *PtyAdapter) PID() int {
+ session, err := a.requireSession()
+ if err != nil {
+ return 0
+ }
+ return session.PID()
+}
+
+func (a *PtyAdapter) requireSession() (PtySession, error) {
+ if a == nil || a.session == nil {
+ return nil, io.EOF
+ }
+ return a.session, nil
+}
+
+func normalizePtySpawnRequest(req PtySpawnRequest) (PtySpawnRequest, error) {
+ if len(req.Argv) == 0 || strings.TrimSpace(req.Argv[0]) == "" {
+ return PtySpawnRequest{}, invalidPtyMessage("argv[0] is required")
+ }
+ if req.Cols == 0 {
+ req.Cols = 80
+ }
+ if req.Rows == 0 {
+ req.Rows = 24
+ }
+ if err := validatePtySize(req.Cols, req.Rows); err != nil {
+ return PtySpawnRequest{}, err
+ }
+
+ req.Argv = append([]string(nil), req.Argv...)
+ if req.Env != nil {
+ env := make(map[string]string, len(req.Env))
+ for k, v := range req.Env {
+ env[k] = v
+ }
+ req.Env = env
+ }
+
+ return req, nil
+}
+
+func parsePtyResizeMessage(raw []byte) (cols, rows int, resize bool, err error) {
+ const prefix = "\x1b[RESIZE:"
+ if !bytes.HasPrefix(raw, []byte(prefix)) {
+ return 0, 0, false, nil
+ }
+ if !bytes.HasSuffix(raw, []byte("]")) {
+ return 0, 0, true, invalidPtyMessage("resize message must terminate with ]")
+ }
+
+ body := string(raw[len(prefix) : len(raw)-1])
+ parts := strings.Split(body, ";")
+ if len(parts) != 2 {
+ return 0, 0, true, invalidPtyMessage("resize message must contain cols and rows")
+ }
+
+ cols, err = strconv.Atoi(parts[0])
+ if err != nil {
+ return 0, 0, true, invalidPtyMessage("resize cols must be an integer")
+ }
+ rows, err = strconv.Atoi(parts[1])
+ if err != nil {
+ return 0, 0, true, invalidPtyMessage("resize rows must be an integer")
+ }
+ if err := validatePtySize(cols, rows); err != nil {
+ return 0, 0, true, err
+ }
+
+ return cols, rows, true, nil
+}
+
+func validatePtySize(cols, rows int) error {
+ if cols < 1 || rows < 1 {
+ return invalidPtyMessage("terminal size must be positive")
+ }
+ if cols > MaxPtyCols || rows > MaxPtyRows {
+ return invalidPtyMessage("terminal size exceeds %dx%d", MaxPtyCols, MaxPtyRows)
+ }
+ return nil
+}
+
+func invalidPtyMessage(format string, args ...any) error {
+ return &PtyInvalidMessageError{Reason: fmt.Sprintf(format, args...)}
+}
+
+func ptyPlatformAvailable(goos string) bool {
+ return goos == "linux"
+}
+
+func ptyUnavailableReason(goos string) string {
+ if goos == "windows" {
+ return "pseudo-terminals are unavailable on native Windows; use WSL"
+ }
+ return fmt.Sprintf("pseudo-terminals are unavailable on %s in this adapter", goos)
+}
diff --git a/internal/cli/pty_bridge_linux.go b/internal/cli/pty_bridge_linux.go
new file mode 100644
index 000000000..d5c3bfda3
--- /dev/null
+++ b/internal/cli/pty_bridge_linux.go
@@ -0,0 +1,326 @@
+//go:build linux
+
+package cli
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "sort"
+ "strconv"
+ "sync"
+ "sync/atomic"
+ "syscall"
+ "time"
+
+ "golang.org/x/sys/unix"
+)
+
+type linuxPtySession struct {
+ master *os.File
+ cmd *exec.Cmd
+ waitDone chan struct{}
+ exited atomic.Bool
+ closed atomic.Bool
+
+ closeOnce sync.Once
+ closeErr error
+}
+
+func spawnPtySession(ctx context.Context, req PtySpawnRequest) (PtySession, error) {
+ master, slave, err := openPtyPair(req.Cols, req.Rows)
+ if err != nil {
+ return nil, err
+ }
+ defer slave.Close()
+
+ cmd := exec.CommandContext(ctx, req.Argv[0], req.Argv[1:]...)
+ cmd.Stdin = slave
+ cmd.Stdout = slave
+ cmd.Stderr = slave
+ cmd.SysProcAttr = &syscall.SysProcAttr{
+ Setsid: true,
+ Setctty: true,
+ Ctty: 0,
+ }
+ if req.CWD != "" {
+ cmd.Dir = req.CWD
+ }
+ if req.Env != nil {
+ cmd.Env = ptyEnvList(req.Env)
+ }
+
+ if err := cmd.Start(); err != nil {
+ _ = master.Close()
+ return nil, err
+ }
+
+ session := &linuxPtySession{
+ master: master,
+ cmd: cmd,
+ waitDone: make(chan struct{}, 1),
+ }
+ go func() {
+ _ = cmd.Wait()
+ session.exited.Store(true)
+ close(session.waitDone)
+ }()
+
+ return session, nil
+}
+
+func openPtyPair(cols, rows int) (*os.File, *os.File, error) {
+ masterFD, err := unix.Open("/dev/ptmx", unix.O_RDWR|unix.O_NOCTTY|unix.O_CLOEXEC, 0)
+ if err != nil {
+ return nil, nil, &PtyUnavailableError{
+ GOOS: "linux",
+ Reason: fmt.Sprintf("open /dev/ptmx: %v", err),
+ }
+ }
+ masterOpen := true
+ defer func() {
+ if masterOpen {
+ _ = unix.Close(masterFD)
+ }
+ }()
+
+ if err := unix.IoctlSetPointerInt(masterFD, unix.TIOCSPTLCK, 0); err != nil {
+ return nil, nil, fmt.Errorf("unlock pty: %w", err)
+ }
+ ptyNumber, err := unix.IoctlGetInt(masterFD, unix.TIOCGPTN)
+ if err != nil {
+ return nil, nil, fmt.Errorf("resolve pty slave: %w", err)
+ }
+
+ slavePath := "/dev/pts/" + strconv.Itoa(ptyNumber)
+ slaveFD, err := unix.Open(slavePath, unix.O_RDWR|unix.O_NOCTTY, 0)
+ if err != nil {
+ return nil, nil, fmt.Errorf("open pty slave %s: %w", slavePath, err)
+ }
+ slaveOpen := true
+ defer func() {
+ if slaveOpen {
+ _ = unix.Close(slaveFD)
+ }
+ }()
+
+ if err := setPtyWinsize(masterFD, cols, rows); err != nil {
+ return nil, nil, err
+ }
+
+ master := os.NewFile(uintptr(masterFD), "pty-master")
+ slave := os.NewFile(uintptr(slaveFD), "pty-slave")
+ masterOpen = false
+ slaveOpen = false
+
+ return master, slave, nil
+}
+
+func (s *linuxPtySession) Read(timeout time.Duration, maxBytes int) ([]byte, error) {
+ if s == nil || s.closed.Load() || s.master == nil {
+ return nil, io.EOF
+ }
+
+ fd := int(s.master.Fd())
+ pollFDs := []unix.PollFd{{
+ Fd: int32(fd),
+ Events: unix.POLLIN | unix.POLLHUP | unix.POLLERR,
+ }}
+
+ for {
+ n, err := unix.Poll(pollFDs, pollTimeoutMillis(timeout))
+ if err == unix.EINTR {
+ continue
+ }
+ if err != nil {
+ if isClosedFD(err) {
+ return nil, io.EOF
+ }
+ return nil, err
+ }
+ if n == 0 {
+ return []byte{}, nil
+ }
+ break
+ }
+
+ if pollFDs[0].Revents&unix.POLLNVAL != 0 {
+ return nil, io.EOF
+ }
+
+ buf := make([]byte, maxBytes)
+ n, err := unix.Read(fd, buf)
+ if err != nil {
+ if isClosedFD(err) {
+ return nil, io.EOF
+ }
+ return nil, err
+ }
+ if n == 0 {
+ return nil, io.EOF
+ }
+
+ return buf[:n], nil
+}
+
+func (s *linuxPtySession) Write(data []byte) error {
+ if s == nil || s.closed.Load() || s.master == nil {
+ return io.EOF
+ }
+
+ fd := int(s.master.Fd())
+ for len(data) > 0 {
+ n, err := unix.Write(fd, data)
+ if err == unix.EINTR {
+ continue
+ }
+ if err != nil {
+ if isClosedFD(err) {
+ return io.EOF
+ }
+ return err
+ }
+ if n <= 0 {
+ return io.ErrClosedPipe
+ }
+ data = data[n:]
+ }
+
+ return nil
+}
+
+func (s *linuxPtySession) Resize(cols, rows int) error {
+ if s == nil || s.closed.Load() || s.master == nil {
+ return io.EOF
+ }
+
+ if err := setPtyWinsize(int(s.master.Fd()), cols, rows); err != nil {
+ if isClosedFD(err) {
+ return io.EOF
+ }
+ return err
+ }
+
+ return nil
+}
+
+func (s *linuxPtySession) Close() error {
+ if s == nil {
+ return io.EOF
+ }
+ s.closeOnce.Do(func() {
+ s.closed.Store(true)
+ s.closeErr = s.close()
+ })
+ return s.closeErr
+}
+
+func (s *linuxPtySession) close() error {
+ var closeErr error
+
+ if s.cmd != nil && s.cmd.Process != nil && !s.exited.Load() {
+ for _, sig := range []syscall.Signal{syscall.SIGHUP, syscall.SIGTERM, syscall.SIGKILL} {
+ if s.exited.Load() {
+ break
+ }
+ signalPtyProcess(s.cmd.Process.Pid, sig)
+ if waitForPtyExit(s.waitDone, 500*time.Millisecond) {
+ break
+ }
+ }
+ }
+
+ if s.master != nil {
+ if err := s.master.Close(); err != nil && !errors.Is(err, os.ErrClosed) {
+ closeErr = err
+ }
+ }
+ waitForPtyExit(s.waitDone, time.Second)
+
+ return closeErr
+}
+
+func (s *linuxPtySession) IsAlive() bool {
+ if s == nil || s.closed.Load() || s.exited.Load() {
+ return false
+ }
+ select {
+ case <-s.waitDone:
+ return false
+ default:
+ return true
+ }
+}
+
+func (s *linuxPtySession) PID() int {
+ if s == nil || s.cmd == nil || s.cmd.Process == nil {
+ return 0
+ }
+ return s.cmd.Process.Pid
+}
+
+func setPtyWinsize(fd int, cols, rows int) error {
+ return unix.IoctlSetWinsize(fd, unix.TIOCSWINSZ, &unix.Winsize{
+ Row: uint16(rows),
+ Col: uint16(cols),
+ })
+}
+
+func signalPtyProcess(pid int, sig syscall.Signal) {
+ if pid <= 0 {
+ return
+ }
+ if err := syscall.Kill(-pid, sig); err != nil && err != syscall.ESRCH {
+ _ = syscall.Kill(pid, sig)
+ }
+}
+
+func waitForPtyExit(done <-chan struct{}, timeout time.Duration) bool {
+ if done == nil {
+ return true
+ }
+ select {
+ case <-done:
+ return true
+ case <-time.After(timeout):
+ return false
+ }
+}
+
+func pollTimeoutMillis(timeout time.Duration) int {
+ if timeout <= 0 {
+ return 0
+ }
+
+ millis := int(timeout / time.Millisecond)
+ if timeout%time.Millisecond != 0 {
+ millis++
+ }
+ if millis < 1 {
+ return 1
+ }
+ return millis
+}
+
+func isClosedFD(err error) bool {
+ return errors.Is(err, unix.EIO) ||
+ errors.Is(err, unix.EBADF) ||
+ errors.Is(err, unix.EPIPE)
+}
+
+func ptyEnvList(env map[string]string) []string {
+ keys := make([]string, 0, len(env))
+ for key := range env {
+ keys = append(keys, key)
+ }
+ sort.Strings(keys)
+
+ out := make([]string, 0, len(keys))
+ for _, key := range keys {
+ out = append(out, key+"="+env[key])
+ }
+ return out
+}
diff --git a/internal/cli/pty_bridge_test.go b/internal/cli/pty_bridge_test.go
new file mode 100644
index 000000000..7211a9a2e
--- /dev/null
+++ b/internal/cli/pty_bridge_test.go
@@ -0,0 +1,257 @@
+package cli
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "io"
+ "runtime"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestPtyAdapterRejectsUnavailablePlatformBeforeSpawn(t *testing.T) {
+ spawnCalled := false
+
+ _, err := NewPtyAdapter(context.Background(), PtySpawnRequest{
+ Argv: []string{"/bin/sh", "-c", "printf unsafe"},
+ }, PtyAdapterConfig{
+ RuntimeGOOS: "windows",
+ Spawn: func(context.Context, PtySpawnRequest) (PtySession, error) {
+ spawnCalled = true
+ return nil, nil
+ },
+ })
+
+ if !errors.Is(err, ErrPtyUnavailable) {
+ t.Fatalf("err = %v, want ErrPtyUnavailable", err)
+ }
+ if spawnCalled {
+ t.Fatal("spawn was called for an unavailable platform")
+ }
+}
+
+func TestPtyAdapterReadBoundsTimeout(t *testing.T) {
+ bridge := startTestPTY(t, PtySpawnRequest{
+ Argv: []string{"/bin/sh", "-c", "sleep 0.2; printf late"},
+ })
+
+ start := time.Now()
+ chunk, err := bridge.Read(25*time.Millisecond, 8)
+ elapsed := time.Since(start)
+
+ if err != nil {
+ t.Fatalf("Read returned err = %v, want nil timeout result", err)
+ }
+ if len(chunk) != 0 {
+ t.Fatalf("Read returned %q before child wrote anything", chunk)
+ }
+ if elapsed > 150*time.Millisecond {
+ t.Fatalf("Read took %v, want bounded near the requested timeout", elapsed)
+ }
+}
+
+func TestPtyAdapterReadBoundsChunkSize(t *testing.T) {
+ bridge := startTestPTY(t, PtySpawnRequest{
+ Argv: []string{"/bin/sh", "-c", "printf abcdef"},
+ })
+
+ chunk := readFirstChunk(t, bridge, 3)
+
+ if len(chunk) > 3 {
+ t.Fatalf("len(chunk) = %d, want <= 3", len(chunk))
+ }
+}
+
+func TestPtyAdapterWriteSendsBytesToChild(t *testing.T) {
+ bridge := startTestPTY(t, PtySpawnRequest{
+ Argv: []string{"/bin/cat"},
+ })
+
+ if err := bridge.HandleClientMessage([]byte("hello-pty\n")); err != nil {
+ t.Fatalf("HandleClientMessage(write) err = %v", err)
+ }
+
+ out := readUntil(t, bridge, []byte("hello-pty"), 2*time.Second)
+ if !bytes.Contains(out, []byte("hello-pty")) {
+ t.Fatalf("output = %q, want echoed write", out)
+ }
+}
+
+func TestPtyAdapterRejectsInvalidWriteBeforeSession(t *testing.T) {
+ session := &recordingPtySession{}
+ bridge := NewPtyAdapterForSession(session)
+
+ if err := bridge.Write(nil); !errors.Is(err, ErrInvalidPtyMessage) {
+ t.Fatalf("Write(nil) err = %v, want ErrInvalidPtyMessage", err)
+ }
+ if len(session.writes) != 0 {
+ t.Fatalf("writes reached session: %q", session.writes)
+ }
+
+ tooLarge := bytes.Repeat([]byte("x"), MaxPtyWriteBytes+1)
+ if err := bridge.Write(tooLarge); !errors.Is(err, ErrInvalidPtyMessage) {
+ t.Fatalf("Write(tooLarge) err = %v, want ErrInvalidPtyMessage", err)
+ }
+ if len(session.writes) != 0 {
+ t.Fatalf("oversized write reached session: %q", session.writes)
+ }
+}
+
+func TestPtyAdapterResizeMessageUpdatesChildWinsize(t *testing.T) {
+ bridge := startTestPTY(t, PtySpawnRequest{
+ Argv: []string{"/bin/sh", "-c", "sleep 0.1; stty size"},
+ Cols: 80,
+ Rows: 24,
+ })
+
+ if err := bridge.HandleClientMessage([]byte("\x1b[RESIZE:123;45]")); err != nil {
+ t.Fatalf("HandleClientMessage(resize) err = %v", err)
+ }
+
+ out := readUntil(t, bridge, []byte("45 123"), 2*time.Second)
+ if !bytes.Contains(out, []byte("45 123")) {
+ t.Fatalf("output = %q, want resized rows/cols", out)
+ }
+}
+
+func TestPtyAdapterRejectsInvalidResizeBeforeSession(t *testing.T) {
+ session := &recordingPtySession{}
+ bridge := NewPtyAdapterForSession(session)
+
+ if err := bridge.HandleClientMessage([]byte("\x1b[RESIZE:0;24]")); !errors.Is(err, ErrInvalidPtyMessage) {
+ t.Fatalf("HandleClientMessage(invalid resize) err = %v, want ErrInvalidPtyMessage", err)
+ }
+ if len(session.resizes) != 0 {
+ t.Fatalf("resize reached session: %+v", session.resizes)
+ }
+ if len(session.writes) != 0 {
+ t.Fatalf("invalid resize was written to PTY: %q", session.writes)
+ }
+}
+
+func TestPtyAdapterCloseTerminatesChild(t *testing.T) {
+ bridge := startTestPTY(t, PtySpawnRequest{
+ Argv: []string{"/bin/sh", "-c", "sleep 30"},
+ })
+
+ if bridge.PID() <= 0 {
+ t.Fatalf("PID() = %d, want child pid", bridge.PID())
+ }
+ if err := bridge.Close(); err != nil {
+ t.Fatalf("Close err = %v", err)
+ }
+ if err := bridge.Close(); err != nil {
+ t.Fatalf("second Close err = %v", err)
+ }
+ if bridge.IsAlive() {
+ t.Fatal("bridge reports child alive after Close")
+ }
+}
+
+func startTestPTY(t *testing.T, req PtySpawnRequest) *PtyAdapter {
+ t.Helper()
+
+ if runtime.GOOS != "linux" {
+ t.Skip("real PTY fixture is linux-only in this slice")
+ }
+
+ bridge, err := NewPtyAdapter(context.Background(), req, PtyAdapterConfig{})
+ if errors.Is(err, ErrPtyUnavailable) {
+ t.Skipf("PTY unavailable: %v", err)
+ }
+ if err != nil {
+ t.Fatalf("NewPtyAdapter err = %v", err)
+ }
+ t.Cleanup(func() {
+ if err := bridge.Close(); err != nil && !errors.Is(err, io.EOF) {
+ t.Fatalf("cleanup Close err = %v", err)
+ }
+ })
+
+ return bridge
+}
+
+func readFirstChunk(t *testing.T, bridge *PtyAdapter, maxBytes int) []byte {
+ t.Helper()
+
+ deadline := time.Now().Add(2 * time.Second)
+ for time.Now().Before(deadline) {
+ chunk, err := bridge.Read(100*time.Millisecond, maxBytes)
+ if err == nil && len(chunk) > 0 {
+ return chunk
+ }
+ if errors.Is(err, io.EOF) {
+ t.Fatal("PTY reached EOF before emitting a chunk")
+ }
+ if err != nil {
+ t.Fatalf("Read err = %v", err)
+ }
+ }
+
+ t.Fatal("timed out waiting for PTY output")
+ return nil
+}
+
+func readUntil(t *testing.T, bridge *PtyAdapter, needle []byte, timeout time.Duration) []byte {
+ t.Helper()
+
+ deadline := time.Now().Add(timeout)
+ var out []byte
+ for time.Now().Before(deadline) {
+ chunk, err := bridge.Read(100*time.Millisecond, DefaultPtyReadChunkSize)
+ if len(chunk) > 0 {
+ out = append(out, chunk...)
+ if bytes.Contains(out, needle) {
+ return out
+ }
+ }
+ if errors.Is(err, io.EOF) {
+ break
+ }
+ if err != nil {
+ t.Fatalf("Read err = %v", err)
+ }
+ }
+
+ t.Fatalf("timed out waiting for %q in %q", needle, printablePTYOutput(out))
+ return out
+}
+
+func printablePTYOutput(out []byte) string {
+ return strings.ReplaceAll(string(out), "\r", "\\r")
+}
+
+type recordingPtySession struct {
+ writes [][]byte
+ resizes []PtySize
+ closed bool
+}
+
+func (s *recordingPtySession) Read(time.Duration, int) ([]byte, error) {
+ return []byte{}, nil
+}
+
+func (s *recordingPtySession) Write(data []byte) error {
+ s.writes = append(s.writes, append([]byte(nil), data...))
+ return nil
+}
+
+func (s *recordingPtySession) Resize(cols, rows int) error {
+ s.resizes = append(s.resizes, PtySize{Cols: cols, Rows: rows})
+ return nil
+}
+
+func (s *recordingPtySession) Close() error {
+ s.closed = true
+ return nil
+}
+
+func (s *recordingPtySession) IsAlive() bool {
+ return !s.closed
+}
+
+func (s *recordingPtySession) PID() int {
+ return 123
+}
diff --git a/internal/cli/pty_bridge_unsupported.go b/internal/cli/pty_bridge_unsupported.go
new file mode 100644
index 000000000..2c3679984
--- /dev/null
+++ b/internal/cli/pty_bridge_unsupported.go
@@ -0,0 +1,15 @@
+//go:build !linux
+
+package cli
+
+import (
+ "context"
+ "runtime"
+)
+
+func spawnPtySession(context.Context, PtySpawnRequest) (PtySession, error) {
+ return nil, &PtyUnavailableError{
+ GOOS: runtime.GOOS,
+ Reason: ptyUnavailableReason(runtime.GOOS),
+ }
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index 6fb2d2081..a22d697ab 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -27,6 +27,7 @@ type Config struct {
ConfigVersion int `toml:"_config_version"`
Hermes HermesCfg `toml:"hermes"`
+ Gateway GatewayCfg `toml:"gateway"`
TUI TUICfg `toml:"tui"`
Input InputCfg `toml:"input"`
Telegram TelegramCfg `toml:"telegram"`
@@ -197,6 +198,11 @@ type HermesCfg struct {
Model string `toml:"model"`
}
+type GatewayCfg struct {
+ ProxyURL string `toml:"proxy_url"`
+ ProxyKey string `toml:"proxy_key"`
+}
+
type TUICfg struct {
Theme string `toml:"theme"`
}
@@ -359,6 +365,12 @@ func loadEnv(cfg *Config) error {
if v := os.Getenv("GORMES_API_KEY"); v != "" {
cfg.Hermes.APIKey = v
}
+ if v := strings.TrimSpace(os.Getenv("GATEWAY_PROXY_URL")); v != "" {
+ cfg.Gateway.ProxyURL = v
+ }
+ if v := strings.TrimSpace(os.Getenv("GATEWAY_PROXY_KEY")); v != "" {
+ cfg.Gateway.ProxyKey = v
+ }
if v := os.Getenv("GORMES_TELEGRAM_TOKEN"); v != "" {
cfg.Telegram.BotToken = v
}
@@ -506,6 +518,8 @@ func loadFlags(cfg *Config, args []string) error {
}
func validateConfig(cfg *Config) error {
+ cfg.Gateway.ProxyURL = normalizeGatewayProxyURL(cfg.Gateway.ProxyURL)
+ cfg.Gateway.ProxyKey = strings.TrimSpace(cfg.Gateway.ProxyKey)
cfg.Goncho.Workspace = strings.TrimSpace(cfg.Goncho.Workspace)
cfg.Goncho.ObserverPeer = strings.TrimSpace(cfg.Goncho.ObserverPeer)
cfg.Goncho.DialecticDefaultLevel = strings.ToLower(strings.TrimSpace(cfg.Goncho.DialecticDefaultLevel))
@@ -540,6 +554,10 @@ func validateConfig(cfg *Config) error {
return nil
}
+func normalizeGatewayProxyURL(raw string) string {
+ return strings.TrimRight(strings.TrimSpace(raw), "/")
+}
+
func xdgConfigHome() string {
if v := os.Getenv("XDG_CONFIG_HOME"); v != "" {
return v
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index 634231c3e..89ab88fa9 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -49,6 +49,60 @@ endpoint = "http://file:8642"
}
}
+func TestLoad_GatewayProxyURLFromConfigNormalizesTrailingSlash(t *testing.T) {
+ cfgHome := t.TempDir()
+ t.Setenv("XDG_CONFIG_HOME", cfgHome)
+ t.Setenv("GATEWAY_PROXY_URL", "")
+ dir := filepath.Join(cfgHome, "gormes")
+ _ = os.MkdirAll(dir, 0o755)
+ _ = os.WriteFile(filepath.Join(dir, "config.toml"), []byte(`
+[gateway]
+proxy_url = "http://config-proxy:8642/"
+`), 0o644)
+
+ cfg, err := Load(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cfg.Gateway.ProxyURL != "http://config-proxy:8642" {
+ t.Errorf("Gateway.ProxyURL = %q, want normalized config proxy URL", cfg.Gateway.ProxyURL)
+ }
+}
+
+func TestLoad_GatewayProxyEnvOverridesConfigAndBlankEnvIsUnset(t *testing.T) {
+ cfgHome := t.TempDir()
+ t.Setenv("XDG_CONFIG_HOME", cfgHome)
+ dir := filepath.Join(cfgHome, "gormes")
+ _ = os.MkdirAll(dir, 0o755)
+ _ = os.WriteFile(filepath.Join(dir, "config.toml"), []byte(`
+[gateway]
+proxy_url = "http://config-proxy:8642/"
+proxy_key = "config-secret"
+`), 0o644)
+
+ t.Setenv("GATEWAY_PROXY_URL", " ")
+ cfg, err := Load(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cfg.Gateway.ProxyURL != "http://config-proxy:8642" {
+ t.Fatalf("blank env Gateway.ProxyURL = %q, want config fallback", cfg.Gateway.ProxyURL)
+ }
+
+ t.Setenv("GATEWAY_PROXY_URL", "http://env-proxy:8642/")
+ t.Setenv("GATEWAY_PROXY_KEY", "env-secret")
+ cfg, err = Load(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cfg.Gateway.ProxyURL != "http://env-proxy:8642" {
+ t.Errorf("Gateway.ProxyURL = %q, want env proxy URL without trailing slash", cfg.Gateway.ProxyURL)
+ }
+ if cfg.Gateway.ProxyKey != "env-secret" {
+ t.Errorf("Gateway.ProxyKey = %q, want env secret", cfg.Gateway.ProxyKey)
+ }
+}
+
func TestLoad_FlagOverridesEnv(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("GORMES_ENDPOINT", "http://env:8642")
diff --git a/internal/gateway/commands.go b/internal/gateway/commands.go
index 763a6b291..efd91dde9 100644
--- a/internal/gateway/commands.go
+++ b/internal/gateway/commands.go
@@ -2,6 +2,7 @@ package gateway
import (
"fmt"
+ "sort"
"strings"
)
@@ -114,6 +115,23 @@ func TelegramBotCommands() []PlatformCommand {
return out
}
+// TelegramBotCommandsWith returns the canonical Telegram menu plus dynamic
+// commands. Dynamic command names are normalized for Telegram's underscore-only
+// command shape and sorted for deterministic platform registration.
+func TelegramBotCommandsWith(dynamic []PlatformCommand) []PlatformCommand {
+ out := TelegramBotCommands()
+ seen := platformCommandNameSet(out)
+ for _, cmd := range sortedPlatformCommands(dynamic) {
+ name := normalizeTelegramCommandName(cmd.Name)
+ if name == "" || seen[name] {
+ continue
+ }
+ seen[name] = true
+ out = append(out, PlatformCommand{Name: name, Description: strings.TrimSpace(cmd.Description)})
+ }
+ return out
+}
+
// SlackSubcommandMap returns the canonical slash mapping Slack should expose.
// Both canonical names and aliases resolve to their slash-prefixed entry.
func SlackSubcommandMap() map[string]string {
@@ -126,3 +144,48 @@ func SlackSubcommandMap() map[string]string {
}
return out
}
+
+// SlackSubcommandMapWith returns the canonical Slack command mapping plus
+// dynamic commands. Callers are responsible for passing only enabled commands.
+func SlackSubcommandMapWith(dynamic []PlatformCommand) map[string]string {
+ out := SlackSubcommandMap()
+ for _, cmd := range sortedPlatformCommands(dynamic) {
+ name := normalizeSlackCommandName(cmd.Name)
+ if name == "" {
+ continue
+ }
+ out[name] = "/" + name
+ }
+ return out
+}
+
+func platformCommandNameSet(commands []PlatformCommand) map[string]bool {
+ out := make(map[string]bool, len(commands))
+ for _, cmd := range commands {
+ out[cmd.Name] = true
+ }
+ return out
+}
+
+func sortedPlatformCommands(commands []PlatformCommand) []PlatformCommand {
+ out := append([]PlatformCommand(nil), commands...)
+ sort.SliceStable(out, func(i, j int) bool {
+ if out[i].Name != out[j].Name {
+ return out[i].Name < out[j].Name
+ }
+ return out[i].Description < out[j].Description
+ })
+ return out
+}
+
+func normalizeTelegramCommandName(name string) string {
+ name = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(name)), "/")
+ name = strings.ReplaceAll(name, "-", "_")
+ return strings.Trim(name, "_")
+}
+
+func normalizeSlackCommandName(name string) string {
+ name = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(name)), "/")
+ name = strings.ReplaceAll(name, "_", "-")
+ return strings.Trim(name, "-")
+}
diff --git a/internal/gateway/proxy_mode.go b/internal/gateway/proxy_mode.go
new file mode 100644
index 000000000..7af50d752
--- /dev/null
+++ b/internal/gateway/proxy_mode.go
@@ -0,0 +1,303 @@
+package gateway
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "strings"
+ "sync"
+
+ "github.com/TrebuchetDynamics/gormes-agent/internal/hermes"
+ "github.com/TrebuchetDynamics/gormes-agent/internal/kernel"
+)
+
+const (
+ proxyStateRunning = "running"
+ proxyStateDegraded = "degraded"
+)
+
+// ErrProxyBusy is returned when proxy mode is asked to start a second turn
+// before the current remote stream has reached a terminal frame.
+var ErrProxyBusy = errors.New("gateway proxy: turn already active")
+
+// ProxySubmitterConfig wires gateway proxy mode to an OpenAI-compatible
+// Gormes API server.
+type ProxySubmitterConfig struct {
+ BaseURL string
+ APIKey string
+ Model string
+ History []hermes.Message
+ Client hermes.Client
+ RuntimeStatus RuntimeStatusWriter
+}
+
+// ProxySubmitter satisfies the gateway manager's kernel submitter contract by
+// forwarding each turn to a remote /v1/chat/completions stream.
+type ProxySubmitter struct {
+ baseURL string
+ apiKey string
+ model string
+ client hermes.Client
+ status RuntimeStatusWriter
+ frames chan kernel.RenderFrame
+
+ mu sync.Mutex
+ history []hermes.Message
+ active bool
+ generation uint64
+}
+
+// NewProxySubmitter constructs a proxy-mode submitter. The default client uses
+// the same HTTP+SSE implementation as the native kernel.
+func NewProxySubmitter(cfg ProxySubmitterConfig) (*ProxySubmitter, error) {
+ baseURL := normalizeProxyBaseURL(cfg.BaseURL)
+ if baseURL == "" && cfg.Client == nil {
+ return nil, errors.New("gateway proxy: base URL is required")
+ }
+ model := strings.TrimSpace(cfg.Model)
+ if model == "" {
+ model = "gormes-agent"
+ }
+ client := cfg.Client
+ if client == nil {
+ client = hermes.NewHTTPClient(baseURL, strings.TrimSpace(cfg.APIKey))
+ }
+ return &ProxySubmitter{
+ baseURL: baseURL,
+ apiKey: strings.TrimSpace(cfg.APIKey),
+ model: model,
+ client: client,
+ status: cfg.RuntimeStatus,
+ frames: make(chan kernel.RenderFrame, 16),
+ history: append([]hermes.Message(nil), cfg.History...),
+ }, nil
+}
+
+func normalizeProxyBaseURL(raw string) string {
+ return strings.TrimRight(strings.TrimSpace(raw), "/")
+}
+
+// Submit starts a remote proxy turn. It returns after the stream goroutine is
+// admitted, matching kernel.Submit's non-blocking manager contract.
+func (p *ProxySubmitter) Submit(ev kernel.PlatformEvent) error {
+ if p == nil {
+ return nil
+ }
+ switch ev.Kind {
+ case kernel.PlatformEventSubmit:
+ p.mu.Lock()
+ if p.active {
+ p.mu.Unlock()
+ return ErrProxyBusy
+ }
+ p.active = true
+ p.generation++
+ generation := p.generation
+ history := append([]hermes.Message(nil), p.history...)
+ p.mu.Unlock()
+
+ go p.runTurn(context.Background(), generation, ev, history)
+ return nil
+ case kernel.PlatformEventCancel:
+ p.mu.Lock()
+ p.generation++
+ p.mu.Unlock()
+ return nil
+ default:
+ return nil
+ }
+}
+
+// ResetSession clears local proxy history and invalidates any in-flight remote
+// stream so stale output cannot become the current gateway reply.
+func (p *ProxySubmitter) ResetSession() error {
+ if p == nil {
+ return nil
+ }
+ p.mu.Lock()
+ p.history = nil
+ p.generation++
+ p.mu.Unlock()
+ return nil
+}
+
+// Render returns proxy-mode render frames for the manager outbound loop.
+func (p *ProxySubmitter) Render() <-chan kernel.RenderFrame {
+ if p == nil {
+ return nil
+ }
+ return p.frames
+}
+
+func (p *ProxySubmitter) runTurn(ctx context.Context, generation uint64, ev kernel.PlatformEvent, history []hermes.Message) {
+ userMessage := hermes.Message{Role: "user", Content: ev.Text}
+ safeHistory := safeProxyHistory(history)
+ historyWithUser := append(append([]hermes.Message(nil), safeHistory...), userMessage)
+
+ p.emitIfCurrent(generation, kernel.RenderFrame{
+ Phase: kernel.PhaseConnecting,
+ History: historyWithUser,
+ SessionID: ev.SessionID,
+ Model: p.model,
+ })
+
+ messages := make([]hermes.Message, 0, len(safeHistory)+2)
+ if contextPrompt := strings.TrimSpace(ev.SessionContext); contextPrompt != "" {
+ messages = append(messages, hermes.Message{Role: "system", Content: ev.SessionContext})
+ }
+ messages = append(messages, safeHistory...)
+ if strings.TrimSpace(ev.Text) != "" {
+ messages = append(messages, userMessage)
+ }
+
+ stream, err := p.client.OpenStream(ctx, hermes.ChatRequest{
+ Model: p.model,
+ SessionID: ev.SessionID,
+ Stream: true,
+ Messages: messages,
+ })
+ if err != nil {
+ p.finishProxyError(generation, ev.SessionID, historyWithUser, err)
+ return
+ }
+ defer stream.Close()
+
+ sessionID := ev.SessionID
+ if remoteSessionID := strings.TrimSpace(stream.SessionID()); remoteSessionID != "" {
+ sessionID = remoteSessionID
+ }
+
+ var draft strings.Builder
+ for {
+ if !p.isCurrent(generation) {
+ p.finishStale(generation, sessionID)
+ return
+ }
+ event, err := stream.Recv(ctx)
+ if err != nil {
+ if errors.Is(err, io.EOF) {
+ break
+ }
+ p.finishProxyError(generation, sessionID, historyWithUser, err)
+ return
+ }
+ if event.Kind != hermes.EventToken || event.Token == "" {
+ continue
+ }
+ draft.WriteString(event.Token)
+ p.emitIfCurrent(generation, kernel.RenderFrame{
+ Phase: kernel.PhaseStreaming,
+ DraftText: draft.String(),
+ History: historyWithUser,
+ SessionID: sessionID,
+ Model: p.model,
+ })
+ }
+
+ if !p.isCurrent(generation) {
+ p.finishStale(generation, sessionID)
+ return
+ }
+ finalHistory := append(historyWithUser, hermes.Message{Role: "assistant", Content: draft.String()})
+ p.mu.Lock()
+ p.history = append([]hermes.Message(nil), finalHistory...)
+ p.active = false
+ p.mu.Unlock()
+
+ p.writeProxyStatus(proxyStateRunning, "")
+ p.frames <- kernel.RenderFrame{
+ Phase: kernel.PhaseIdle,
+ History: finalHistory,
+ SessionID: sessionID,
+ Model: p.model,
+ }
+}
+
+func safeProxyHistory(history []hermes.Message) []hermes.Message {
+ out := make([]hermes.Message, 0, len(history))
+ for _, msg := range history {
+ role := strings.ToLower(strings.TrimSpace(msg.Role))
+ switch role {
+ case "system", "user", "assistant":
+ default:
+ continue
+ }
+ if strings.TrimSpace(msg.Content) == "" {
+ continue
+ }
+ out = append(out, hermes.Message{Role: role, Content: msg.Content})
+ }
+ return out
+}
+
+func (p *ProxySubmitter) finishProxyError(generation uint64, sessionID string, history []hermes.Message, err error) {
+ message := p.degradedErrorMessage(err)
+ p.mu.Lock()
+ if p.generation == generation {
+ p.history = append([]hermes.Message(nil), history...)
+ }
+ p.active = false
+ p.mu.Unlock()
+
+ p.writeProxyStatus(proxyStateDegraded, message)
+ p.frames <- kernel.RenderFrame{
+ Phase: kernel.PhaseFailed,
+ History: history,
+ SessionID: sessionID,
+ Model: p.model,
+ LastError: message,
+ }
+}
+
+func (p *ProxySubmitter) degradedErrorMessage(err error) string {
+ classification := hermes.ClassifyProviderError(err)
+ if classification.Kind == hermes.ProviderErrorAuth && strings.TrimSpace(p.apiKey) == "" {
+ return "missing proxy credentials: remote API rejected the proxy request"
+ }
+ if classification.Status > 0 {
+ return fmt.Sprintf("proxy remote error (%d): %s", classification.Status, err.Error())
+ }
+ return "proxy unreachable: " + err.Error()
+}
+
+func (p *ProxySubmitter) finishStale(generation uint64, sessionID string) {
+ message := fmt.Sprintf("stale generation: ignored proxy response for generation %d", generation)
+ p.mu.Lock()
+ p.active = false
+ history := append([]hermes.Message(nil), p.history...)
+ p.mu.Unlock()
+
+ p.writeProxyStatus(proxyStateDegraded, message)
+ p.frames <- kernel.RenderFrame{
+ Phase: kernel.PhaseFailed,
+ History: history,
+ SessionID: sessionID,
+ Model: p.model,
+ LastError: message,
+ }
+}
+
+func (p *ProxySubmitter) isCurrent(generation uint64) bool {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ return p.generation == generation
+}
+
+func (p *ProxySubmitter) emitIfCurrent(generation uint64, frame kernel.RenderFrame) {
+ if !p.isCurrent(generation) {
+ return
+ }
+ p.frames <- frame
+}
+
+func (p *ProxySubmitter) writeProxyStatus(state, message string) {
+ if p.status == nil {
+ return
+ }
+ _ = p.status.UpdateRuntimeStatus(context.Background(), RuntimeStatusUpdate{
+ ProxyState: state,
+ ProxyURL: p.baseURL,
+ ProxyErrorMessage: message,
+ })
+}
diff --git a/internal/gateway/proxy_mode_test.go b/internal/gateway/proxy_mode_test.go
new file mode 100644
index 000000000..eaa2611f3
--- /dev/null
+++ b/internal/gateway/proxy_mode_test.go
@@ -0,0 +1,269 @@
+package gateway
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/TrebuchetDynamics/gormes-agent/internal/hermes"
+ "github.com/TrebuchetDynamics/gormes-agent/internal/kernel"
+)
+
+type capturedProxyRequest struct {
+ Path string
+ Authorization string
+ SessionID string
+ Messages []map[string]any
+ Stream bool
+}
+
+func TestProxySubmitter_ForwardsSessionHeaderAndFiltersUnsafeHistory(t *testing.T) {
+ requests := make(chan capturedProxyRequest, 1)
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Messages []map[string]any `json:"messages"`
+ Stream bool `json:"stream"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ t.Errorf("decode request body: %v", err)
+ }
+ requests <- capturedProxyRequest{
+ Path: r.URL.Path,
+ Authorization: r.Header.Get("Authorization"),
+ SessionID: r.Header.Get("X-Hermes-Session-Id"),
+ Messages: body.Messages,
+ Stream: body.Stream,
+ }
+
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.Header().Set("X-Hermes-Session-Id", r.Header.Get("X-Hermes-Session-Id"))
+ fmt.Fprint(w, `data: {"choices":[{"delta":{"role":"assistant"}}]}`+"\n\n")
+ fmt.Fprint(w, `data: {"choices":[{"delta":{"content":"Hello"}}]}`+"\n\n")
+ fmt.Fprint(w, `data: {"choices":[{"delta":{"content":" world"}}]}`+"\n\n")
+ fmt.Fprint(w, `data: {"choices":[{"finish_reason":"stop","delta":{}}]}`+"\n\n")
+ fmt.Fprint(w, "data: [DONE]\n\n")
+ }))
+ defer srv.Close()
+
+ proxy, err := NewProxySubmitter(ProxySubmitterConfig{
+ BaseURL: srv.URL + "/",
+ APIKey: "secret-key",
+ Model: "gormes-agent",
+ History: []hermes.Message{
+ {Role: "user", Content: "previous user"},
+ {Role: "assistant", ToolCalls: []hermes.ToolCall{{ID: "call_1", Name: "search"}}},
+ {Role: "tool", Content: "tool result", ToolCallID: "call_1", Name: "search"},
+ {Role: "assistant", Content: " "},
+ {Role: "assistant", Content: "previous assistant", ToolCalls: []hermes.ToolCall{{ID: "call_2", Name: "ignored"}}},
+ },
+ })
+ if err != nil {
+ t.Fatalf("NewProxySubmitter: %v", err)
+ }
+
+ err = proxy.Submit(kernel.PlatformEvent{
+ Kind: kernel.PlatformEventSubmit,
+ Text: "tell me more",
+ SessionID: "sess-abc",
+ SessionContext: "## Current Session Context\nplatform: matrix",
+ })
+ if err != nil {
+ t.Fatalf("Submit: %v", err)
+ }
+
+ var req capturedProxyRequest
+ select {
+ case req = <-requests:
+ case <-time.After(500 * time.Millisecond):
+ t.Fatal("proxy request not received")
+ }
+ if req.Path != "/v1/chat/completions" {
+ t.Fatalf("path = %q, want /v1/chat/completions", req.Path)
+ }
+ if req.Authorization != "Bearer secret-key" {
+ t.Fatalf("Authorization = %q, want bearer key", req.Authorization)
+ }
+ if req.SessionID != "sess-abc" {
+ t.Fatalf("X-Hermes-Session-Id = %q, want sess-abc", req.SessionID)
+ }
+ if !req.Stream {
+ t.Fatal("stream = false, want true")
+ }
+
+ want := []map[string]string{
+ {"role": "system", "content": "## Current Session Context\nplatform: matrix"},
+ {"role": "user", "content": "previous user"},
+ {"role": "assistant", "content": "previous assistant"},
+ {"role": "user", "content": "tell me more"},
+ }
+ if len(req.Messages) != len(want) {
+ t.Fatalf("messages len = %d, want %d: %#v", len(req.Messages), len(want), req.Messages)
+ }
+ for i, msg := range req.Messages {
+ if msg["role"] != want[i]["role"] || msg["content"] != want[i]["content"] {
+ t.Fatalf("messages[%d] = %#v, want role/content %#v", i, msg, want[i])
+ }
+ if _, ok := msg["tool_calls"]; ok {
+ t.Fatalf("messages[%d] forwarded tool_calls: %#v", i, msg)
+ }
+ if _, ok := msg["tool_call_id"]; ok {
+ t.Fatalf("messages[%d] forwarded tool_call_id: %#v", i, msg)
+ }
+ }
+
+ final := readProxyTerminalFrame(t, proxy.Render())
+ if final.Phase != kernel.PhaseIdle {
+ t.Fatalf("terminal phase = %v, want idle", final.Phase)
+ }
+ if final.SessionID != "sess-abc" {
+ t.Fatalf("terminal SessionID = %q, want sess-abc", final.SessionID)
+ }
+ if got := final.History[len(final.History)-1].Content; got != "Hello world" {
+ t.Fatalf("final assistant = %q, want streamed content", got)
+ }
+}
+
+func TestProxySubmitter_StaleGenerationReportsDegradedOutput(t *testing.T) {
+ requestSeen := make(chan struct{})
+ release := make(chan struct{})
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ close(requestSeen)
+ <-release
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.Header().Set("X-Hermes-Session-Id", r.Header.Get("X-Hermes-Session-Id"))
+ fmt.Fprint(w, `data: {"choices":[{"delta":{"content":"stale answer"}}]}`+"\n\n")
+ fmt.Fprint(w, "data: [DONE]\n\n")
+ }))
+ defer srv.Close()
+
+ store := NewRuntimeStatusStore(t.TempDir() + "/gateway_state.json")
+ proxy, err := NewProxySubmitter(ProxySubmitterConfig{
+ BaseURL: srv.URL,
+ Model: "gormes-agent",
+ RuntimeStatus: store,
+ })
+ if err != nil {
+ t.Fatalf("NewProxySubmitter: %v", err)
+ }
+ if err := proxy.Submit(kernel.PlatformEvent{Kind: kernel.PlatformEventSubmit, Text: "hi", SessionID: "sess-stale"}); err != nil {
+ t.Fatalf("Submit: %v", err)
+ }
+ select {
+ case <-requestSeen:
+ case <-time.After(500 * time.Millisecond):
+ t.Fatal("proxy request not received")
+ }
+ if err := proxy.ResetSession(); err != nil {
+ t.Fatalf("ResetSession: %v", err)
+ }
+ close(release)
+
+ final := readProxyTerminalFrame(t, proxy.Render())
+ if final.Phase != kernel.PhaseFailed {
+ t.Fatalf("terminal phase = %v, want failed", final.Phase)
+ }
+ if !strings.Contains(final.LastError, "stale generation") {
+ t.Fatalf("LastError = %q, want stale generation degradation", final.LastError)
+ }
+ for _, msg := range final.History {
+ if strings.Contains(msg.Content, "stale answer") {
+ t.Fatalf("stale remote content was accepted into history: %#v", final.History)
+ }
+ }
+ assertProxyStatus(t, store, "degraded", "stale generation")
+}
+
+func TestProxySubmitter_RemoteErrorsReturnVisibleDegradedOutput(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, "Unauthorized: invalid API key", http.StatusUnauthorized)
+ }))
+ defer srv.Close()
+
+ store := NewRuntimeStatusStore(t.TempDir() + "/gateway_state.json")
+ proxy, err := NewProxySubmitter(ProxySubmitterConfig{
+ BaseURL: srv.URL,
+ Model: "gormes-agent",
+ RuntimeStatus: store,
+ })
+ if err != nil {
+ t.Fatalf("NewProxySubmitter: %v", err)
+ }
+ if err := proxy.Submit(kernel.PlatformEvent{Kind: kernel.PlatformEventSubmit, Text: "hi", SessionID: "sess-auth"}); err != nil {
+ t.Fatalf("Submit: %v", err)
+ }
+
+ final := readProxyTerminalFrame(t, proxy.Render())
+ if final.Phase != kernel.PhaseFailed {
+ t.Fatalf("terminal phase = %v, want failed", final.Phase)
+ }
+ if !strings.Contains(final.LastError, "missing proxy credentials") {
+ t.Fatalf("LastError = %q, want missing proxy credentials degradation", final.LastError)
+ }
+ assertProxyStatus(t, store, "degraded", "missing proxy credentials")
+}
+
+func TestProxySubmitter_UnreachableProxyReportsDegradedOutput(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ io.WriteString(w, "unused")
+ }))
+ baseURL := srv.URL
+ srv.Close()
+
+ store := NewRuntimeStatusStore(t.TempDir() + "/gateway_state.json")
+ proxy, err := NewProxySubmitter(ProxySubmitterConfig{
+ BaseURL: baseURL,
+ Model: "gormes-agent",
+ APIKey: "secret",
+ RuntimeStatus: store,
+ })
+ if err != nil {
+ t.Fatalf("NewProxySubmitter: %v", err)
+ }
+ if err := proxy.Submit(kernel.PlatformEvent{Kind: kernel.PlatformEventSubmit, Text: "hi", SessionID: "sess-down"}); err != nil {
+ t.Fatalf("Submit: %v", err)
+ }
+
+ final := readProxyTerminalFrame(t, proxy.Render())
+ if final.Phase != kernel.PhaseFailed {
+ t.Fatalf("terminal phase = %v, want failed", final.Phase)
+ }
+ if !strings.Contains(final.LastError, "proxy unreachable") {
+ t.Fatalf("LastError = %q, want proxy unreachable degradation", final.LastError)
+ }
+ assertProxyStatus(t, store, "degraded", "proxy unreachable")
+}
+
+func readProxyTerminalFrame(t *testing.T, frames <-chan kernel.RenderFrame) kernel.RenderFrame {
+ t.Helper()
+ timeout := time.After(2 * time.Second)
+ for {
+ select {
+ case f := <-frames:
+ if f.Phase == kernel.PhaseIdle || f.Phase == kernel.PhaseFailed || f.Phase == kernel.PhaseCancelling {
+ return f
+ }
+ case <-timeout:
+ t.Fatal("timed out waiting for proxy terminal frame")
+ }
+ }
+}
+
+func assertProxyStatus(t *testing.T, store *RuntimeStatusStore, wantState, wantMessage string) {
+ t.Helper()
+ status, err := store.ReadRuntimeStatus(context.Background())
+ if err != nil {
+ t.Fatalf("read status: %v", err)
+ }
+ if status.Proxy.State != wantState {
+ t.Fatalf("proxy status = %q, want %q", status.Proxy.State, wantState)
+ }
+ if !strings.Contains(status.Proxy.ErrorMessage, wantMessage) {
+ t.Fatalf("proxy error = %q, want %q", status.Proxy.ErrorMessage, wantMessage)
+ }
+}
diff --git a/internal/gateway/status.go b/internal/gateway/status.go
index e72ae677c..9c6319bf5 100644
--- a/internal/gateway/status.go
+++ b/internal/gateway/status.go
@@ -44,6 +44,7 @@ type RuntimeStatus struct {
ExitReason string `json:"exit_reason"`
ActiveAgents int `json:"active_agents"`
Platforms map[string]PlatformRuntimeStatus `json:"platforms"`
+ Proxy ProxyRuntimeStatus `json:"proxy"`
UpdatedAt string `json:"updated_at"`
}
@@ -55,6 +56,14 @@ type PlatformRuntimeStatus struct {
UpdatedAt string `json:"updated_at"`
}
+// ProxyRuntimeStatus reports gateway proxy mode health for operator readouts.
+type ProxyRuntimeStatus struct {
+ State string `json:"state"`
+ URL string `json:"url,omitempty"`
+ ErrorMessage string `json:"error_message"`
+ UpdatedAt string `json:"updated_at"`
+}
+
// RuntimeStatusUpdate carries a partial update to the shared runtime status.
type RuntimeStatusUpdate struct {
GatewayState GatewayState
@@ -64,6 +73,10 @@ type RuntimeStatusUpdate struct {
Platform string
PlatformState PlatformState
ErrorMessage string
+
+ ProxyState string
+ ProxyURL string
+ ProxyErrorMessage string
}
// RuntimeStatusWriter is the manager-facing seam for lifecycle status writes.
@@ -147,6 +160,16 @@ func (s *RuntimeStatusStore) merge(status *RuntimeStatus, update RuntimeStatusUp
status.ActiveAgents = *update.ActiveAgents
}
}
+ if update.ProxyState != "" || update.ProxyURL != "" || update.ProxyErrorMessage != "" {
+ if update.ProxyState != "" {
+ status.Proxy.State = update.ProxyState
+ }
+ if update.ProxyURL != "" {
+ status.Proxy.URL = update.ProxyURL
+ }
+ status.Proxy.ErrorMessage = update.ProxyErrorMessage
+ status.Proxy.UpdatedAt = status.UpdatedAt
+ }
if update.Platform == "" {
return
}
diff --git a/internal/goncho/diagnostics.go b/internal/goncho/diagnostics.go
index d62c997d0..68cad2d29 100644
--- a/internal/goncho/diagnostics.go
+++ b/internal/goncho/diagnostics.go
@@ -13,10 +13,11 @@ var QueueTaskTypes = []string{"representation", "summary", "dream"}
// QueueWorkUnitStatus mirrors Honcho's queue status count shape.
type QueueWorkUnitStatus struct {
- CompletedWorkUnits int `json:"completed_work_units"`
- InProgressWorkUnits int `json:"in_progress_work_units"`
- PendingWorkUnits int `json:"pending_work_units"`
- TotalWorkUnits int `json:"total_work_units"`
+ CompletedWorkUnits int `json:"completed_work_units"`
+ InProgressWorkUnits int `json:"in_progress_work_units"`
+ PendingWorkUnits int `json:"pending_work_units"`
+ TotalWorkUnits int `json:"total_work_units"`
+ Sessions map[string]QueueWorkUnitStatus `json:"sessions,omitempty"`
}
// QueueStatus is the local Goncho queue status read model. Until a dedicated
@@ -54,6 +55,6 @@ func ZeroQueueStatus() QueueStatus {
ObservabilityOnly: true,
WorkUnits: workUnits,
Degraded: true,
- Message: "no dedicated Goncho task queue exists yet; zero tracked work units",
+ Message: "no dedicated Goncho task queue exists yet; zero tracked work units; queue status is for observability and debugging, do not wait for an empty queue",
}
}
diff --git a/internal/goncho/file_import.go b/internal/goncho/file_import.go
new file mode 100644
index 000000000..c32c5f69e
--- /dev/null
+++ b/internal/goncho/file_import.go
@@ -0,0 +1,384 @@
+package goncho
+
+import (
+ "bytes"
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "mime"
+ "strings"
+ "time"
+ "unicode/utf16"
+ "unicode/utf8"
+)
+
+// ImportFileParams is the local Goncho equivalent of Honcho's multipart file
+// upload request body. Content is consumed in memory and is not persisted as
+// original file bytes.
+type ImportFileParams struct {
+ SessionKey string `json:"session_key"`
+ PeerID string `json:"peer_id"`
+ Filename string `json:"filename"`
+ ContentType string `json:"content_type"`
+ Content []byte `json:"-"`
+ Metadata map[string]any `json:"metadata,omitempty"`
+ Configuration map[string]any `json:"configuration,omitempty"`
+ CreatedAt *time.Time `json:"created_at,omitempty"`
+}
+
+// FileImportResult describes the ordinary session messages written from an
+// import plus degraded-mode evidence for reasoning work that cannot be queued.
+type FileImportResult struct {
+ WorkspaceID string `json:"workspace_id"`
+ SessionKey string `json:"session_key"`
+ PeerID string `json:"peer_id"`
+ FileID string `json:"file_id"`
+ Messages []ImportedFileMessage `json:"messages"`
+ Unavailable []ContextUnavailableEvidence `json:"unavailable,omitempty"`
+}
+
+// ImportedFileMessage is the stable return shape for each imported chunk.
+type ImportedFileMessage struct {
+ ID int64 `json:"id"`
+ SessionKey string `json:"session_key"`
+ PeerID string `json:"peer_id"`
+ Role string `json:"role"`
+ Content string `json:"content"`
+ CreatedAt time.Time `json:"created_at"`
+ Metadata map[string]any `json:"metadata,omitempty"`
+ Configuration map[string]any `json:"configuration,omitempty"`
+ File FileImportMetadata `json:"file"`
+}
+
+// FileImportMetadata mirrors Honcho's file-related internal metadata attached
+// to every message generated from an uploaded document.
+type FileImportMetadata struct {
+ FileID string `json:"file_id"`
+ Filename string `json:"filename"`
+ ChunkIndex int `json:"chunk_index"`
+ TotalChunks int `json:"total_chunks"`
+ OriginalFileSize int64 `json:"original_file_size"`
+ ContentType string `json:"content_type"`
+ ChunkCharacterRange [2]int `json:"chunk_character_range"`
+}
+
+type fileChunk struct {
+ content string
+ start int
+ end int
+}
+
+// ImportFile converts a text-like file into ordinary ready user turns for the
+// requested session. The original uploaded bytes are only used for extraction.
+func (s *Service) ImportFile(ctx context.Context, params ImportFileParams) (FileImportResult, error) {
+ sessionKey := strings.TrimSpace(params.SessionKey)
+ if sessionKey == "" {
+ return FileImportResult{}, fmt.Errorf("goncho: session_key is required")
+ }
+ peerID := strings.TrimSpace(params.PeerID)
+ if peerID == "" {
+ return FileImportResult{}, fmt.Errorf("goncho: peer_id is required")
+ }
+ contentType := normalizeContentType(params.ContentType)
+ if contentType == "" {
+ return FileImportResult{}, fmt.Errorf("goncho: content_type is required")
+ }
+ if s.maxFileSize > 0 && len(params.Content) > s.maxFileSize {
+ return FileImportResult{}, fmt.Errorf("goncho: file size %d exceeds maximum %d", len(params.Content), s.maxFileSize)
+ }
+
+ text, err := extractImportText(contentType, params.Content)
+ if err != nil {
+ return FileImportResult{}, err
+ }
+ maxChars := s.maxMessageSize
+ if maxChars <= 0 {
+ maxChars = DefaultMaxMessageSize
+ }
+ chunks := splitImportTextIntoChunks(text, maxChars)
+ if len(chunks) == 0 {
+ return FileImportResult{}, errors.New("goncho: file import produced no messages")
+ }
+
+ fileID, err := newImportFileID()
+ if err != nil {
+ return FileImportResult{}, err
+ }
+ createdAt := time.Now().UTC()
+ if params.CreatedAt != nil {
+ createdAt = params.CreatedAt.UTC()
+ }
+
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return FileImportResult{}, fmt.Errorf("goncho: begin file import: %w", err)
+ }
+ defer func() { _ = tx.Rollback() }()
+
+ messages := make([]ImportedFileMessage, 0, len(chunks))
+ for i, chunk := range chunks {
+ fileMeta := FileImportMetadata{
+ FileID: fileID,
+ Filename: params.Filename,
+ ChunkIndex: i,
+ TotalChunks: len(chunks),
+ OriginalFileSize: int64(len(params.Content)),
+ ContentType: contentType,
+ ChunkCharacterRange: [2]int{chunk.start, chunk.end},
+ }
+ metaJSON, err := marshalImportMeta(fileMeta, params.Metadata, params.Configuration)
+ if err != nil {
+ return FileImportResult{}, err
+ }
+ res, err := tx.ExecContext(ctx, `
+ INSERT INTO turns(session_id, role, content, ts_unix, chat_id, meta_json, memory_sync_status)
+ VALUES(?, 'user', ?, ?, ?, ?, 'ready')
+ `, sessionKey, chunk.content, createdAt.Unix(), peerID, metaJSON)
+ if err != nil {
+ return FileImportResult{}, fmt.Errorf("goncho: insert imported file message: %w", err)
+ }
+ id, err := res.LastInsertId()
+ if err != nil {
+ return FileImportResult{}, fmt.Errorf("goncho: imported file message id: %w", err)
+ }
+ messages = append(messages, ImportedFileMessage{
+ ID: id,
+ SessionKey: sessionKey,
+ PeerID: peerID,
+ Role: "user",
+ Content: chunk.content,
+ CreatedAt: time.Unix(createdAt.Unix(), 0).UTC(),
+ Metadata: cloneMap(params.Metadata),
+ Configuration: cloneMap(params.Configuration),
+ File: fileMeta,
+ })
+ }
+ if err := tx.Commit(); err != nil {
+ return FileImportResult{}, fmt.Errorf("goncho: commit file import: %w", err)
+ }
+
+ return FileImportResult{
+ WorkspaceID: s.workspaceID,
+ SessionKey: sessionKey,
+ PeerID: peerID,
+ FileID: fileID,
+ Messages: messages,
+ Unavailable: []ContextUnavailableEvidence{queueUnavailableEvidence()},
+ }, nil
+}
+
+func normalizeContentType(value string) string {
+ value = strings.TrimSpace(strings.ToLower(value))
+ if value == "" {
+ return ""
+ }
+ mediaType, _, err := mime.ParseMediaType(value)
+ if err == nil {
+ return strings.ToLower(mediaType)
+ }
+ return value
+}
+
+func extractImportText(contentType string, content []byte) (string, error) {
+ switch {
+ case contentType == "application/json":
+ return extractJSONImportText(content)
+ case strings.HasPrefix(contentType, "text/"):
+ return decodeTextImportContent(content)
+ default:
+ return "", fmt.Errorf("goncho: unsupported content type %q", contentType)
+ }
+}
+
+func extractJSONImportText(content []byte) (string, error) {
+ if !utf8.Valid(content) {
+ return "", errors.New("goncho: JSON uploads must be UTF-8 encoded")
+ }
+ trimmed := strings.TrimSpace(string(content))
+ if trimmed == "" {
+ return "", nil
+ }
+ var decoded any
+ if err := json.Unmarshal([]byte(trimmed), &decoded); err != nil {
+ return "", fmt.Errorf("goncho: uploaded JSON is invalid: %w", err)
+ }
+ raw, err := json.Marshal(decoded)
+ if err != nil {
+ return "", fmt.Errorf("goncho: encode imported JSON text: %w", err)
+ }
+ return string(raw), nil
+}
+
+func decodeTextImportContent(content []byte) (string, error) {
+ if utf8.Valid(content) {
+ return string(content), nil
+ }
+ if decoded, ok := decodeUTF16WithBOM(content); ok {
+ return decoded, nil
+ }
+ runes := make([]rune, len(content))
+ for i, b := range content {
+ runes[i] = rune(b)
+ }
+ return string(runes), nil
+}
+
+func decodeUTF16WithBOM(content []byte) (string, bool) {
+ if len(content) < 2 {
+ return "", false
+ }
+ littleEndian := false
+ switch {
+ case content[0] == 0xff && content[1] == 0xfe:
+ littleEndian = true
+ case content[0] == 0xfe && content[1] == 0xff:
+ littleEndian = false
+ default:
+ return "", false
+ }
+ body := content[2:]
+ if len(body)%2 != 0 {
+ body = body[:len(body)-1]
+ }
+ u16 := make([]uint16, 0, len(body)/2)
+ for i := 0; i < len(body); i += 2 {
+ var value uint16
+ if littleEndian {
+ value = uint16(body[i]) | uint16(body[i+1])<<8
+ } else {
+ value = uint16(body[i])<<8 | uint16(body[i+1])
+ }
+ u16 = append(u16, value)
+ }
+ return string(utf16.Decode(u16)), true
+}
+
+func splitImportTextIntoChunks(text string, maxChars int) []fileChunk {
+ runes := []rune(text)
+ if len(runes) <= maxChars {
+ return []fileChunk{{content: text, start: 0, end: len(runes)}}
+ }
+
+ var chunks []fileChunk
+ current := 0
+ for current < len(runes) {
+ end := current + maxChars
+ if end >= len(runes) {
+ chunks = append(chunks, fileChunk{
+ content: string(runes[current:]),
+ start: current,
+ end: len(runes),
+ })
+ break
+ }
+ breakPos := bestImportChunkBreak(runes, current, end)
+ chunks = append(chunks, fileChunk{
+ content: string(runes[current:breakPos]),
+ start: current,
+ end: breakPos,
+ })
+ current = breakPos
+ }
+ return chunks
+}
+
+func bestImportChunkBreak(runes []rune, start, end int) int {
+ for _, delimiter := range []string{"\n\n", "\n", ". ", " "} {
+ if pos := lastDelimiterRuneIndex(runes, delimiter, start, end); pos > start {
+ return pos + len([]rune(delimiter))
+ }
+ }
+ return end
+}
+
+func lastDelimiterRuneIndex(runes []rune, delimiter string, start, end int) int {
+ needle := []rune(delimiter)
+ if len(needle) == 0 || end-start < len(needle) {
+ return -1
+ }
+ for i := end - len(needle); i >= start; i-- {
+ if equalRunes(runes[i:i+len(needle)], needle) {
+ return i
+ }
+ }
+ return -1
+}
+
+func equalRunes(a, b []rune) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ for i := range a {
+ if a[i] != b[i] {
+ return false
+ }
+ }
+ return true
+}
+
+func marshalImportMeta(file FileImportMetadata, metadata, configuration map[string]any) (string, error) {
+ meta := map[string]any{
+ "file_id": file.FileID,
+ "filename": file.Filename,
+ "chunk_index": file.ChunkIndex,
+ "total_chunks": file.TotalChunks,
+ "original_file_size": file.OriginalFileSize,
+ "content_type": file.ContentType,
+ "chunk_character_range": []int{file.ChunkCharacterRange[0], file.ChunkCharacterRange[1]},
+ }
+ if metadata != nil {
+ meta["metadata"] = cloneMap(metadata)
+ }
+ if configuration != nil {
+ meta["configuration"] = cloneMap(configuration)
+ }
+ raw, err := json.Marshal(meta)
+ if err != nil {
+ return "", fmt.Errorf("goncho: marshal file import metadata: %w", err)
+ }
+ return string(raw), nil
+}
+
+func cloneMap(in map[string]any) map[string]any {
+ if in == nil {
+ return nil
+ }
+ raw, err := json.Marshal(in)
+ if err != nil {
+ out := make(map[string]any, len(in))
+ for k, v := range in {
+ out[k] = v
+ }
+ return out
+ }
+ var out map[string]any
+ if err := json.Unmarshal(raw, &out); err != nil {
+ out = make(map[string]any, len(in))
+ for k, v := range in {
+ out[k] = v
+ }
+ }
+ return out
+}
+
+func queueUnavailableEvidence() ContextUnavailableEvidence {
+ return ContextUnavailableEvidence{
+ Field: "queue",
+ Capability: "goncho_reasoning_queue",
+ Reason: "Goncho reasoning queue is unavailable; imported messages were written synchronously and are immediately visible as session messages",
+ }
+}
+
+func newImportFileID() (string, error) {
+ var id [16]byte
+ if _, err := rand.Read(id[:]); err != nil {
+ return "", fmt.Errorf("goncho: generate file import id: %w", err)
+ }
+ var b bytes.Buffer
+ b.WriteString("file_")
+ b.WriteString(hex.EncodeToString(id[:]))
+ return b.String(), nil
+}
diff --git a/internal/goncho/file_import_test.go b/internal/goncho/file_import_test.go
new file mode 100644
index 000000000..cdba7a6f6
--- /dev/null
+++ b/internal/goncho/file_import_test.go
@@ -0,0 +1,397 @@
+package goncho
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestService_ImportFileCreatesSessionMessagesWithFileMetadata(t *testing.T) {
+ svc, cleanup := newTestService(t)
+ defer cleanup()
+
+ createdAt := time.Unix(1_714_558_400, 0).UTC()
+ got, err := svc.ImportFile(context.Background(), ImportFileParams{
+ SessionKey: "session-import-1",
+ PeerID: "telegram:6586915095",
+ Filename: "MEMORY.md",
+ ContentType: "text/markdown",
+ Content: []byte("# Memory\n\nJuan prefers evidence-first reports."),
+ Metadata: map[string]any{
+ "source": "legacy-memory",
+ "owner": "juan",
+ },
+ Configuration: map[string]any{
+ "reasoning": map[string]any{
+ "observe": true,
+ },
+ },
+ CreatedAt: &createdAt,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got.Messages) != 1 {
+ t.Fatalf("messages len = %d, want 1", len(got.Messages))
+ }
+ if len(got.Unavailable) != 1 || got.Unavailable[0].Capability != "goncho_reasoning_queue" {
+ t.Fatalf("Unavailable = %+v, want queue-unavailable evidence", got.Unavailable)
+ }
+
+ msg := got.Messages[0]
+ if msg.SessionKey != "session-import-1" || msg.PeerID != "telegram:6586915095" || msg.Role != "user" {
+ t.Fatalf("message identity = %+v, want ordinary user session message for required peer", msg)
+ }
+ if msg.Content != "# Memory\n\nJuan prefers evidence-first reports." {
+ t.Fatalf("content = %q", msg.Content)
+ }
+ if msg.CreatedAt.Unix() != createdAt.Unix() {
+ t.Fatalf("CreatedAt = %s, want %s", msg.CreatedAt, createdAt)
+ }
+ if msg.Metadata["source"] != "legacy-memory" || msg.Metadata["owner"] != "juan" {
+ t.Fatalf("Metadata = %+v, want caller metadata preserved", msg.Metadata)
+ }
+ if !nestedBool(msg.Configuration, "reasoning", "observe") {
+ t.Fatalf("Configuration = %+v, want caller configuration preserved", msg.Configuration)
+ }
+ if msg.File.FileID == "" {
+ t.Fatal("FileID is empty")
+ }
+ wantFile := FileImportMetadata{
+ FileID: msg.File.FileID,
+ Filename: "MEMORY.md",
+ ChunkIndex: 0,
+ TotalChunks: 1,
+ OriginalFileSize: int64(len("# Memory\n\nJuan prefers evidence-first reports.")),
+ ContentType: "text/markdown",
+ ChunkCharacterRange: [2]int{
+ 0,
+ len("# Memory\n\nJuan prefers evidence-first reports."),
+ },
+ }
+ if msg.File != wantFile {
+ t.Fatalf("File metadata = %+v, want %+v", msg.File, wantFile)
+ }
+
+ rows := loadImportedTurns(t, svc.db, "session-import-1")
+ if len(rows) != 1 {
+ t.Fatalf("turn rows len = %d, want 1", len(rows))
+ }
+ if rows[0].role != "user" || rows[0].chatID != "telegram:6586915095" || rows[0].content != msg.Content {
+ t.Fatalf("turn row = %+v, want ordinary imported user turn", rows[0])
+ }
+ if rows[0].tsUnix != createdAt.Unix() {
+ t.Fatalf("ts_unix = %d, want %d", rows[0].tsUnix, createdAt.Unix())
+ }
+ assertMetaValue(t, rows[0].meta, "file_id", msg.File.FileID)
+ assertMetaValue(t, rows[0].meta, "filename", "MEMORY.md")
+ assertMetaValue(t, rows[0].meta, "chunk_index", float64(0))
+ assertMetaValue(t, rows[0].meta, "total_chunks", float64(1))
+ assertMetaValue(t, rows[0].meta, "original_file_size", float64(len("# Memory\n\nJuan prefers evidence-first reports.")))
+ assertMetaValue(t, rows[0].meta, "content_type", "text/markdown")
+ assertMetaValue(t, rows[0].meta, "metadata.source", "legacy-memory")
+ assertMetaValue(t, rows[0].meta, "configuration.reasoning.observe", true)
+
+ ctx, err := svc.Context(context.Background(), ContextParams{
+ Peer: "telegram:6586915095",
+ SessionKey: "session-import-1",
+ MaxTokens: 400,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(ctx.RecentMessages) != 1 || ctx.RecentMessages[0].Content != msg.Content {
+ t.Fatalf("RecentMessages = %+v, want imported chunk as normal session message", ctx.RecentMessages)
+ }
+}
+
+func TestService_ImportFileSupportsTextMarkdownAndJSON(t *testing.T) {
+ svc, cleanup := newTestService(t)
+ defer cleanup()
+
+ for _, tc := range []struct {
+ name string
+ filename string
+ contentType string
+ content []byte
+ assert func(t *testing.T, content string)
+ }{
+ {
+ name: "plain text",
+ filename: "USER.txt",
+ contentType: "text/plain",
+ content: []byte("Plain text memory."),
+ assert: func(t *testing.T, content string) {
+ t.Helper()
+ if content != "Plain text memory." {
+ t.Fatalf("content = %q, want decoded text", content)
+ }
+ },
+ },
+ {
+ name: "markdown",
+ filename: "SOUL.md",
+ contentType: "text/markdown",
+ content: []byte("## Soul\n\nMarkdown memory."),
+ assert: func(t *testing.T, content string) {
+ t.Helper()
+ if content != "## Soul\n\nMarkdown memory." {
+ t.Fatalf("content = %q, want decoded markdown", content)
+ }
+ },
+ },
+ {
+ name: "json",
+ filename: "memory.json",
+ contentType: "application/json",
+ content: []byte("{\n \"prefers\": [\"evidence\", \"exactness\"],\n \"active\": true\n}"),
+ assert: func(t *testing.T, content string) {
+ t.Helper()
+ var decoded map[string]any
+ if err := json.Unmarshal([]byte(content), &decoded); err != nil {
+ t.Fatalf("content = %q, want valid JSON text: %v", content, err)
+ }
+ if decoded["active"] != true {
+ t.Fatalf("decoded JSON = %+v, want active=true", decoded)
+ }
+ },
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ got, err := svc.ImportFile(context.Background(), ImportFileParams{
+ SessionKey: "session-" + strings.ReplaceAll(tc.name, " ", "-"),
+ PeerID: "telegram:6586915095",
+ Filename: tc.filename,
+ ContentType: tc.contentType,
+ Content: tc.content,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got.Messages) != 1 {
+ t.Fatalf("messages len = %d, want 1", len(got.Messages))
+ }
+ tc.assert(t, got.Messages[0].Content)
+ if got.Messages[0].File.ContentType != tc.contentType {
+ t.Fatalf("content_type metadata = %q, want %q", got.Messages[0].File.ContentType, tc.contentType)
+ }
+ })
+ }
+}
+
+func TestService_ImportFileRejectsUnsupportedTypesBeforeWrites(t *testing.T) {
+ svc, cleanup := newTestService(t)
+ defer cleanup()
+
+ _, err := svc.ImportFile(context.Background(), ImportFileParams{
+ SessionKey: "session-import-unsupported",
+ PeerID: "telegram:6586915095",
+ Filename: "scan.pdf",
+ ContentType: "application/pdf",
+ Content: []byte("%PDF original bytes"),
+ })
+ if err == nil {
+ t.Fatal("expected unsupported content type error")
+ }
+ if !strings.Contains(err.Error(), "unsupported content type") {
+ t.Fatalf("error = %v, want unsupported content type evidence", err)
+ }
+ rows := loadImportedTurns(t, svc.db, "session-import-unsupported")
+ if len(rows) != 0 {
+ t.Fatalf("turn rows = %+v, want no writes for unsupported content type", rows)
+ }
+}
+
+func TestService_ImportFileRequiresPeerIDBeforeWrites(t *testing.T) {
+ svc, cleanup := newTestService(t)
+ defer cleanup()
+
+ _, err := svc.ImportFile(context.Background(), ImportFileParams{
+ SessionKey: "session-import-missing-peer",
+ Filename: "USER.txt",
+ ContentType: "text/plain",
+ Content: []byte("memory"),
+ })
+ if err == nil {
+ t.Fatal("expected peer_id required error")
+ }
+ if !strings.Contains(err.Error(), "peer_id is required") {
+ t.Fatalf("error = %v, want peer_id validation", err)
+ }
+ rows := loadImportedTurns(t, svc.db, "session-import-missing-peer")
+ if len(rows) != 0 {
+ t.Fatalf("turn rows = %+v, want no writes without peer_id", rows)
+ }
+}
+
+func TestService_ImportFileChunksAtHonchoRuntimeLimit(t *testing.T) {
+ svc, cleanup := newTestService(t)
+ defer cleanup()
+
+ content := strings.Repeat("a", DefaultMaxMessageSize) + strings.Repeat("b", 10)
+ got, err := svc.ImportFile(context.Background(), ImportFileParams{
+ SessionKey: "session-import-chunked",
+ PeerID: "telegram:6586915095",
+ Filename: "long.txt",
+ ContentType: "text/plain",
+ Content: []byte(content),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got.Messages) != 2 {
+ t.Fatalf("messages len = %d, want 2", len(got.Messages))
+ }
+ if len(got.Messages[0].Content) != DefaultMaxMessageSize {
+ t.Fatalf("first chunk len = %d, want %d", len(got.Messages[0].Content), DefaultMaxMessageSize)
+ }
+ if got.Messages[0].File.ChunkCharacterRange != [2]int{0, DefaultMaxMessageSize} {
+ t.Fatalf("first range = %+v", got.Messages[0].File.ChunkCharacterRange)
+ }
+ if got.Messages[1].Content != strings.Repeat("b", 10) {
+ t.Fatalf("second chunk = %q", got.Messages[1].Content)
+ }
+ if got.Messages[1].File.ChunkCharacterRange != [2]int{DefaultMaxMessageSize, DefaultMaxMessageSize + 10} {
+ t.Fatalf("second range = %+v", got.Messages[1].File.ChunkCharacterRange)
+ }
+ for i, msg := range got.Messages {
+ if msg.File.ChunkIndex != i || msg.File.TotalChunks != 2 {
+ t.Fatalf("message %d file metadata = %+v, want chunk index %d of 2", i, msg.File, i)
+ }
+ }
+}
+
+func TestService_ImportFileDoesNotPersistOriginalFileBytes(t *testing.T) {
+ svc, cleanup := newTestService(t)
+ defer cleanup()
+
+ raw := "{\n \"z\": 1,\n \"legacy\": \"memory\"\n}\n"
+ got, err := svc.ImportFile(context.Background(), ImportFileParams{
+ SessionKey: "session-import-json",
+ PeerID: "telegram:6586915095",
+ Filename: "memory.json",
+ ContentType: "application/json",
+ Content: []byte(raw),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got.Messages) != 1 {
+ t.Fatalf("messages len = %d, want 1", len(got.Messages))
+ }
+ if got.Messages[0].Content == raw {
+ t.Fatalf("message content persisted raw upload bytes: %q", got.Messages[0].Content)
+ }
+
+ dump := dumpSessionRows(t, svc.db, "session-import-json")
+ if strings.Contains(dump, raw) {
+ t.Fatalf("database row dump persisted original file bytes %q in %q", raw, dump)
+ }
+ if !strings.Contains(dump, `"legacy"`) {
+ t.Fatalf("database row dump = %q, want extracted JSON message content", dump)
+ }
+}
+
+func nestedBool(root map[string]any, path ...string) bool {
+ var current any = root
+ for _, key := range path {
+ m, ok := current.(map[string]any)
+ if !ok {
+ return false
+ }
+ current = m[key]
+ }
+ got, ok := current.(bool)
+ return ok && got
+}
+
+type importedTurnRow struct {
+ role string
+ chatID string
+ content string
+ tsUnix int64
+ meta map[string]any
+}
+
+func loadImportedTurns(t *testing.T, db *sql.DB, sessionKey string) []importedTurnRow {
+ t.Helper()
+
+ rows, err := db.QueryContext(context.Background(), `
+ SELECT role, chat_id, content, ts_unix, COALESCE(meta_json, '{}')
+ FROM turns
+ WHERE session_id = ?
+ ORDER BY id ASC
+ `, sessionKey)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer rows.Close()
+
+ var out []importedTurnRow
+ for rows.Next() {
+ var row importedTurnRow
+ var rawMeta string
+ if err := rows.Scan(&row.role, &row.chatID, &row.content, &row.tsUnix, &rawMeta); err != nil {
+ t.Fatal(err)
+ }
+ if err := json.Unmarshal([]byte(rawMeta), &row.meta); err != nil {
+ t.Fatalf("meta_json = %q: %v", rawMeta, err)
+ }
+ out = append(out, row)
+ }
+ if err := rows.Err(); err != nil {
+ t.Fatal(err)
+ }
+ return out
+}
+
+func assertMetaValue(t *testing.T, meta map[string]any, dotted string, want any) {
+ t.Helper()
+
+ var got any = meta
+ for _, part := range strings.Split(dotted, ".") {
+ m, ok := got.(map[string]any)
+ if !ok {
+ t.Fatalf("meta path %q hit non-object %T in %+v", dotted, got, meta)
+ }
+ got = m[part]
+ }
+ if fmt.Sprint(got) != fmt.Sprint(want) {
+ t.Fatalf("meta[%s] = %#v (%T), want %#v (%T)", dotted, got, got, want, want)
+ }
+}
+
+func dumpSessionRows(t *testing.T, db *sql.DB, sessionKey string) string {
+ t.Helper()
+
+ rows, err := db.QueryContext(context.Background(), `
+ SELECT content, COALESCE(meta_json, '')
+ FROM turns
+ WHERE session_id = ?
+ ORDER BY id ASC
+ `, sessionKey)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer rows.Close()
+
+ var b strings.Builder
+ for rows.Next() {
+ var content, meta string
+ if err := rows.Scan(&content, &meta); err != nil {
+ t.Fatal(err)
+ }
+ b.WriteString(content)
+ b.WriteByte('\n')
+ b.WriteString(meta)
+ b.WriteByte('\n')
+ }
+ if err := rows.Err(); err != nil {
+ t.Fatal(err)
+ }
+ return b.String()
+}
diff --git a/internal/goncho/queue_status_test.go b/internal/goncho/queue_status_test.go
new file mode 100644
index 000000000..c62dc48d4
--- /dev/null
+++ b/internal/goncho/queue_status_test.go
@@ -0,0 +1,101 @@
+package goncho
+
+import (
+ "context"
+ "encoding/json"
+ "reflect"
+ "slices"
+ "strings"
+ "testing"
+)
+
+func TestContractQueueWorkUnitStatusJSONShapeIncludesSessionDetails(t *testing.T) {
+ raw, err := json.Marshal(QueueWorkUnitStatus{
+ CompletedWorkUnits: 2,
+ InProgressWorkUnits: 1,
+ PendingWorkUnits: 3,
+ TotalWorkUnits: 6,
+ Sessions: map[string]QueueWorkUnitStatus{
+ "sess-a": {
+ PendingWorkUnits: 1,
+ TotalWorkUnits: 1,
+ },
+ },
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ text := string(raw)
+ for _, want := range []string{
+ `"completed_work_units":2`,
+ `"in_progress_work_units":1`,
+ `"pending_work_units":3`,
+ `"total_work_units":6`,
+ `"sessions":{"sess-a"`,
+ } {
+ if !strings.Contains(text, want) {
+ t.Fatalf("QueueWorkUnitStatus JSON missing %s in %s", want, raw)
+ }
+ }
+}
+
+func TestReadQueueStatusZeroStateIsDeterministicObservabilityOnly(t *testing.T) {
+ svc, cleanup := newTestService(t)
+ defer cleanup()
+
+ first, err := ReadQueueStatus(context.Background(), svc.db)
+ if err != nil {
+ t.Fatal(err)
+ }
+ second, err := ReadQueueStatus(context.Background(), svc.db)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if !reflect.DeepEqual(first, second) {
+ t.Fatalf("ReadQueueStatus returned nondeterministic zero-state:\nfirst=%+v\nsecond=%+v", first, second)
+ }
+ if first.Status != "degraded" || !first.Degraded {
+ t.Fatalf("status = %q degraded=%t, want degraded zero-state", first.Status, first.Degraded)
+ }
+ if !first.ObservabilityOnly {
+ t.Fatal("ObservabilityOnly = false, want true")
+ }
+ if !strings.Contains(first.Message, "zero tracked work units") {
+ t.Fatalf("Message = %q, want zero tracked work units evidence", first.Message)
+ }
+ if !strings.Contains(first.Message, "observability") || !strings.Contains(first.Message, "do not wait") {
+ t.Fatalf("Message = %q, want explicit observability-not-synchronization warning", first.Message)
+ }
+
+ for _, taskType := range QueueTaskTypes {
+ counts, ok := first.WorkUnits[taskType]
+ if !ok {
+ t.Fatalf("WorkUnits missing task type %q: %#v", taskType, first.WorkUnits)
+ }
+ if counts.CompletedWorkUnits != 0 || counts.InProgressWorkUnits != 0 || counts.PendingWorkUnits != 0 || counts.TotalWorkUnits != 0 {
+ t.Fatalf("%s counts = %+v, want deterministic zero-state", taskType, counts)
+ }
+ if len(counts.Sessions) != 0 {
+ t.Fatalf("%s sessions = %+v, want no per-session details before a Goncho task queue exists", taskType, counts.Sessions)
+ }
+ }
+}
+
+func TestQueueStatusOnlyReportsHonchoReasoningWorkTypes(t *testing.T) {
+ want := []string{"representation", "summary", "dream"}
+ if !slices.Equal(QueueTaskTypes, want) {
+ t.Fatalf("QueueTaskTypes = %#v, want %#v", QueueTaskTypes, want)
+ }
+
+ status := ZeroQueueStatus()
+ if len(status.WorkUnits) != len(want) {
+ t.Fatalf("WorkUnits len = %d, want only %d Honcho reasoning task types: %#v", len(status.WorkUnits), len(want), status.WorkUnits)
+ }
+ for _, internalTask := range []string{"webhook", "deletion", "vector_reconciliation", "reconciler"} {
+ if _, ok := status.WorkUnits[internalTask]; ok {
+ t.Fatalf("WorkUnits included internal infrastructure task %q: %#v", internalTask, status.WorkUnits)
+ }
+ }
+}
diff --git a/internal/goncho/service.go b/internal/goncho/service.go
index 9ed7fdffa..be20aae36 100644
--- a/internal/goncho/service.go
+++ b/internal/goncho/service.go
@@ -21,12 +21,14 @@ const (
// Service is the first in-binary Goncho domain facade. It sits directly on
// top of the SQLite store used by Gormes today.
type Service struct {
- db *sql.DB
- workspaceID string
- observer string
- recentLimit int
- sessions SessionDirectory
- log *slog.Logger
+ db *sql.DB
+ workspaceID string
+ observer string
+ recentLimit int
+ maxMessageSize int
+ maxFileSize int
+ sessions SessionDirectory
+ log *slog.Logger
}
const maxPeerCardFacts = 40
@@ -56,12 +58,14 @@ func NewService(db *sql.DB, cfg Config, log *slog.Logger) *Service {
recentLimit = DefaultRecentMessages
}
return &Service{
- db: db,
- workspaceID: workspaceID,
- observer: observer,
- recentLimit: recentLimit,
- sessions: cfg.SessionDirectory,
- log: log,
+ db: db,
+ workspaceID: workspaceID,
+ observer: observer,
+ recentLimit: recentLimit,
+ maxMessageSize: cfg.MaxMessageSize,
+ maxFileSize: cfg.MaxFileSize,
+ sessions: cfg.SessionDirectory,
+ log: log,
}
}
@@ -389,8 +393,12 @@ func (s *Service) Chat(ctx context.Context, peer string, params ChatParams) (Cha
}
unavailable := chatUnavailableEvidence(params)
+ content := buildChatContent(peer, query, reasoningLevel, card, searchResult.Results, unavailable)
+ if err := insertAssistantChatTurn(ctx, s.db, params.SessionID, peer, content, ""); err != nil {
+ return ChatResult{}, err
+ }
return ChatResult{
- Content: buildChatContent(peer, query, reasoningLevel, card, searchResult.Results, unavailable),
+ Content: content,
}, nil
}
diff --git a/internal/goncho/sql.go b/internal/goncho/sql.go
index f18f1d87c..06ddf67ef 100644
--- a/internal/goncho/sql.go
+++ b/internal/goncho/sql.go
@@ -263,6 +263,22 @@ func findConclusions(ctx context.Context, db *sql.DB, workspaceID, observer, pee
return hits, nil
}
+func insertAssistantChatTurn(ctx context.Context, db *sql.DB, sessionID, peer, content, metaJSON string) error {
+ sessionID = strings.TrimSpace(sessionID)
+ peer = strings.TrimSpace(peer)
+ if sessionID == "" || peer == "" || strings.TrimSpace(content) == "" {
+ return nil
+ }
+ _, err := db.ExecContext(ctx, `
+ INSERT INTO turns(session_id, role, content, ts_unix, chat_id, meta_json, memory_sync_status)
+ VALUES(?, 'assistant', ?, ?, ?, ?, 'ready')
+ `, sessionID, content, time.Now().Unix(), peer, nullIfBlank(metaJSON))
+ if err != nil {
+ return fmt.Errorf("goncho: insert assistant chat turn: %w", err)
+ }
+ return nil
+}
+
func findTurns(ctx context.Context, db *sql.DB, query, sessionKey string, filter compiledSearchFilter, limit int) ([]SearchHit, error) {
if strings.TrimSpace(sessionKey) == "" {
return nil, nil
diff --git a/internal/goncho/streaming_chat_persistence.go b/internal/goncho/streaming_chat_persistence.go
new file mode 100644
index 000000000..e7b61bedd
--- /dev/null
+++ b/internal/goncho/streaming_chat_persistence.go
@@ -0,0 +1,108 @@
+package goncho
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "strings"
+)
+
+// ChatCompletionMetadata carries terminal stream metadata that can be attached
+// after the assistant response is complete.
+type ChatCompletionMetadata struct {
+ TokensIn int `json:"tokens_in,omitempty"`
+ TokensOut int `json:"tokens_out,omitempty"`
+}
+
+// StreamingChatPersistence buffers stream chunks until a terminal event decides
+// whether the assistant response is complete enough to become durable memory.
+type StreamingChatPersistence struct {
+ service *Service
+ peer string
+ sessionID string
+ chunks []string
+ completed bool
+ interrupted bool
+ content string
+}
+
+func (s *Service) NewStreamingChatPersistence(peer string, params ChatParams) (*StreamingChatPersistence, error) {
+ peer = strings.TrimSpace(peer)
+ if peer == "" {
+ return nil, fmt.Errorf("goncho: peer is required")
+ }
+ return &StreamingChatPersistence{
+ service: s,
+ peer: peer,
+ sessionID: strings.TrimSpace(params.SessionID),
+ }, nil
+}
+
+func (p *StreamingChatPersistence) AppendChunk(chunk string) {
+ if p == nil || p.completed || p.interrupted || chunk == "" {
+ return
+ }
+ p.chunks = append(p.chunks, chunk)
+}
+
+func (p *StreamingChatPersistence) Complete(ctx context.Context, meta ChatCompletionMetadata) (ChatResult, error) {
+ if p == nil || p.service == nil {
+ return ChatResult{}, fmt.Errorf("goncho: streaming chat persistence is unavailable")
+ }
+ if p.interrupted {
+ return ChatResult{}, fmt.Errorf("goncho: streaming chat was interrupted")
+ }
+ if p.completed {
+ return ChatResult{Content: p.content}, nil
+ }
+
+ content := strings.Join(p.chunks, "")
+ metaJSON, err := completionMetadataJSON(meta)
+ if err != nil {
+ return ChatResult{}, err
+ }
+ if err := insertAssistantChatTurn(ctx, p.service.db, p.sessionID, p.peer, content, metaJSON); err != nil {
+ return ChatResult{}, err
+ }
+ p.content = content
+ p.completed = true
+ return ChatResult{Content: content}, nil
+}
+
+func (p *StreamingChatPersistence) Interrupt(reason string) ChatResult {
+ if p == nil {
+ return ChatResult{}
+ }
+ if p.completed {
+ return ChatResult{Content: p.content}
+ }
+ p.interrupted = true
+ p.chunks = nil
+ return ChatResult{Content: streamingInterruptedContent(reason)}
+}
+
+func completionMetadataJSON(meta ChatCompletionMetadata) (string, error) {
+ if meta.TokensIn <= 0 && meta.TokensOut <= 0 {
+ return "", nil
+ }
+ payload := map[string]int{}
+ if meta.TokensIn > 0 {
+ payload["tokens_in"] = meta.TokensIn
+ }
+ if meta.TokensOut > 0 {
+ payload["tokens_out"] = meta.TokensOut
+ }
+ raw, err := json.Marshal(payload)
+ if err != nil {
+ return "", fmt.Errorf("goncho: marshal chat completion metadata: %w", err)
+ }
+ return string(raw), nil
+}
+
+func streamingInterruptedContent(reason string) string {
+ reason = strings.TrimSpace(reason)
+ if reason == "" {
+ reason = "interrupted"
+ }
+ return "Unsupported evidence:\n- field=stream capability=streaming_chat_interrupted reason=" + reason + "; partial assistant content was discarded"
+}
diff --git a/internal/goncho/streaming_chat_persistence_test.go b/internal/goncho/streaming_chat_persistence_test.go
new file mode 100644
index 000000000..4e5357511
--- /dev/null
+++ b/internal/goncho/streaming_chat_persistence_test.go
@@ -0,0 +1,190 @@
+package goncho
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "strings"
+ "testing"
+)
+
+func TestService_ChatStreamDegradedPersistsFinalAssistantResponseOnce(t *testing.T) {
+ svc, cleanup := newTestService(t)
+ defer cleanup()
+
+ ctx := context.Background()
+ peer := "telegram:6586915095"
+ sessionID := "sess-stream-chat"
+
+ got, err := svc.Chat(ctx, peer, ChatParams{
+ Query: "What should the assistant remember?",
+ SessionID: sessionID,
+ Stream: true,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(got.Content, "field=stream") {
+ t.Fatalf("Chat content missing streaming degradation evidence: %q", got.Content)
+ }
+
+ row := readAssistantTurn(t, svc.db, sessionID)
+ if row.Count != 1 {
+ t.Fatalf("assistant turns for session = %d, want exactly 1", row.Count)
+ }
+ if row.Role != "assistant" {
+ t.Fatalf("role = %q, want assistant", row.Role)
+ }
+ if row.Content != got.Content {
+ t.Fatalf("stored content = %q, want final response %q", row.Content, got.Content)
+ }
+ if row.ChatID != peer {
+ t.Fatalf("chat_id = %q, want assistant peer %q", row.ChatID, peer)
+ }
+ if row.MemorySyncStatus != "ready" {
+ t.Fatalf("memory_sync_status = %q, want ready", row.MemorySyncStatus)
+ }
+}
+
+func TestStreamingChatPersistenceAccumulatesChunksBeforeSingleWrite(t *testing.T) {
+ svc, cleanup := newTestService(t)
+ defer cleanup()
+
+ ctx := context.Background()
+ peer := "telegram:6586915095"
+ sessionID := "sess-stream-complete"
+
+ stream, err := svc.NewStreamingChatPersistence(peer, ChatParams{SessionID: sessionID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ stream.AppendChunk("First chunk ")
+ stream.AppendChunk("second chunk")
+
+ if row := readAssistantTurn(t, svc.db, sessionID); row.Count != 0 {
+ t.Fatalf("assistant turns before completion = %d, want 0", row.Count)
+ }
+ if countTurnsWithContent(t, svc.db, "First chunk ") != 0 {
+ t.Fatal("partial stream chunk was written before completion")
+ }
+
+ got, err := stream.Complete(ctx, ChatCompletionMetadata{
+ TokensIn: 7,
+ TokensOut: 11,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Content != "First chunk second chunk" {
+ t.Fatalf("completed content = %q, want accumulated chunks", got.Content)
+ }
+
+ row := readAssistantTurn(t, svc.db, sessionID)
+ if row.Count != 1 {
+ t.Fatalf("assistant turns after completion = %d, want exactly 1", row.Count)
+ }
+ if row.Content != got.Content {
+ t.Fatalf("stored content = %q, want %q", row.Content, got.Content)
+ }
+ if row.ChatID != peer {
+ t.Fatalf("chat_id = %q, want %q", row.ChatID, peer)
+ }
+
+ var meta map[string]int
+ if err := json.Unmarshal([]byte(row.MetaJSON), &meta); err != nil {
+ t.Fatalf("meta_json should contain token metadata: %q: %v", row.MetaJSON, err)
+ }
+ if meta["tokens_in"] != 7 || meta["tokens_out"] != 11 {
+ t.Fatalf("token metadata = %+v, want tokens_in=7 tokens_out=11", meta)
+ }
+
+ again, err := stream.Complete(ctx, ChatCompletionMetadata{TokensIn: 99, TokensOut: 99})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if again.Content != got.Content {
+ t.Fatalf("second complete content = %q, want original %q", again.Content, got.Content)
+ }
+ if row := readAssistantTurn(t, svc.db, sessionID); row.Count != 1 {
+ t.Fatalf("assistant turns after second completion = %d, want still exactly 1", row.Count)
+ }
+}
+
+func TestStreamingChatPersistenceInterruptRecordsEvidenceWithoutFlushingPartial(t *testing.T) {
+ svc, cleanup := newTestService(t)
+ defer cleanup()
+
+ ctx := context.Background()
+ sessionID := "sess-stream-interrupted"
+
+ stream, err := svc.NewStreamingChatPersistence("telegram:6586915095", ChatParams{SessionID: sessionID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ stream.AppendChunk("partial assistant draft")
+
+ got := stream.Interrupt("client_disconnect")
+ for _, want := range []string{
+ "Unsupported evidence:",
+ "field=stream",
+ "capability=streaming_chat_interrupted",
+ "client_disconnect",
+ } {
+ if !strings.Contains(got.Content, want) {
+ t.Fatalf("interruption result missing %q in %q", want, got.Content)
+ }
+ }
+
+ if _, err := stream.Complete(ctx, ChatCompletionMetadata{}); err == nil {
+ t.Fatal("expected interrupted stream completion to fail")
+ }
+ if row := readAssistantTurn(t, svc.db, sessionID); row.Count != 0 {
+ t.Fatalf("assistant turns after interruption = %d, want 0", row.Count)
+ }
+ if countTurnsWithContent(t, svc.db, "partial assistant draft") != 0 {
+ t.Fatal("partial interrupted stream content was written to memory")
+ }
+}
+
+type assistantTurnRow struct {
+ Count int
+ Role string
+ Content string
+ ChatID string
+ MemorySyncStatus string
+ MetaJSON string
+}
+
+func readAssistantTurn(t *testing.T, db *sql.DB, sessionID string) assistantTurnRow {
+ t.Helper()
+
+ var row assistantTurnRow
+ err := db.QueryRow(`
+ SELECT COUNT(*), COALESCE(MAX(role), ''), COALESCE(MAX(content), ''),
+ COALESCE(MAX(chat_id), ''), COALESCE(MAX(memory_sync_status), ''),
+ COALESCE(MAX(meta_json), '')
+ FROM turns
+ WHERE session_id = ? AND role = 'assistant'
+ `, sessionID).Scan(
+ &row.Count,
+ &row.Role,
+ &row.Content,
+ &row.ChatID,
+ &row.MemorySyncStatus,
+ &row.MetaJSON,
+ )
+ if err != nil {
+ t.Fatalf("read assistant turn: %v", err)
+ }
+ return row
+}
+
+func countTurnsWithContent(t *testing.T, db *sql.DB, content string) int {
+ t.Helper()
+
+ var count int
+ if err := db.QueryRow(`SELECT COUNT(*) FROM turns WHERE content = ?`, content).Scan(&count); err != nil {
+ t.Fatalf("count turns with content: %v", err)
+ }
+ return count
+}
diff --git a/internal/hermes/anthropic_client.go b/internal/hermes/anthropic_client.go
index 378d05941..597afa2d9 100644
--- a/internal/hermes/anthropic_client.go
+++ b/internal/hermes/anthropic_client.go
@@ -75,7 +75,13 @@ func NewAnthropicClient(baseURL, apiKey string) Client {
}
}
+func (c *anthropicClient) ProviderStatus() ProviderStatus {
+ return anthropicProviderStatus()
+}
+
func (c *anthropicClient) OpenStream(ctx context.Context, req ChatRequest) (Stream, error) {
+ descriptors := SanitizeToolDescriptors(req.Tools)
+ req.Tools = descriptors
payload, err := buildAnthropicRequest(req)
if err != nil {
return nil, err
@@ -102,7 +108,7 @@ func (c *anthropicClient) OpenStream(ctx context.Context, req ChatRequest) (Stre
_ = resp.Body.Close()
return nil, newHTTPError(resp.StatusCode, string(raw), resp.Header)
}
- return newAnthropicStream(resp.Body), nil
+ return newAnthropicStream(resp.Body, descriptors), nil
}
func (c *anthropicClient) OpenRunEvents(context.Context, string) (RunEventStream, error) {
@@ -145,7 +151,7 @@ func buildAnthropicRequest(req ChatRequest) (anthropicRequest, error) {
return anthropicRequest{}, err
}
tools := make([]anthropicTool, 0, len(req.Tools))
- for _, tool := range req.Tools {
+ for _, tool := range SanitizeToolDescriptors(req.Tools) {
tools = append(tools, anthropicTool{
Name: tool.Name,
Description: tool.Description,
diff --git a/internal/hermes/anthropic_client_test.go b/internal/hermes/anthropic_client_test.go
index 8b6e6fa66..1f9c2d7d5 100644
--- a/internal/hermes/anthropic_client_test.go
+++ b/internal/hermes/anthropic_client_test.go
@@ -196,6 +196,11 @@ func TestAnthropicStream_AccumulatesToolUseDeltasAndMapsStopReason(t *testing.T)
Model: "claude-sonnet-4-5-20250929",
MaxTokens: 256,
Messages: []Message{{Role: "user", Content: "weather in Monterrey"}},
+ Tools: []ToolDescriptor{{
+ Name: "get_weather",
+ Description: "Returns current weather.",
+ Schema: json.RawMessage(`{"type":"object","properties":{"location":{"type":"string"}},"required":["location"]}`),
+ }},
})
if err != nil {
t.Fatalf("OpenStream() error = %v", err)
diff --git a/internal/hermes/anthropic_stream.go b/internal/hermes/anthropic_stream.go
index 73be257c0..44ae08bed 100644
--- a/internal/hermes/anthropic_stream.go
+++ b/internal/hermes/anthropic_stream.go
@@ -17,6 +17,7 @@ type anthropicStream struct {
pending []Event
inputTokens int
toolCalls map[int]*anthropicPendingToolCall
+ tools []ToolDescriptor
}
type anthropicPendingToolCall struct {
@@ -70,11 +71,12 @@ type anthropicErrorPayload struct {
} `json:"error"`
}
-func newAnthropicStream(body io.ReadCloser) *anthropicStream {
+func newAnthropicStream(body io.ReadCloser, tools []ToolDescriptor) *anthropicStream {
return &anthropicStream{
body: body,
sse: newSSEReader(body),
toolCalls: make(map[int]*anthropicPendingToolCall),
+ tools: SanitizeToolDescriptors(tools),
}
}
@@ -105,7 +107,7 @@ func (s *anthropicStream) Recv(ctx context.Context) (Event, error) {
frame, err := s.sse.Next(ctx)
if err != nil {
if err == io.EOF && len(s.toolCalls) > 0 {
- return s.flushToolCallsOnEOF(), nil
+ return s.flushToolCallsOnEOF()
}
return Event{}, err
}
@@ -162,8 +164,12 @@ func (s *anthropicStream) Recv(ctx context.Context) (Event, error) {
Raw: raw,
}
if done.FinishReason == "tool_calls" && len(s.toolCalls) > 0 {
- done.ToolCalls = flushAnthropicToolCalls(s.toolCalls)
+ toolCalls, err := RepairToolCalls(flushAnthropicToolCalls(s.toolCalls), s.tools)
s.toolCalls = make(map[int]*anthropicPendingToolCall)
+ if err != nil {
+ return Event{}, err
+ }
+ done.ToolCalls = toolCalls
}
return done, nil
case "error":
@@ -180,15 +186,19 @@ func (s *anthropicStream) Recv(ctx context.Context) (Event, error) {
}
}
-func (s *anthropicStream) flushToolCallsOnEOF() Event {
+func (s *anthropicStream) flushToolCallsOnEOF() (Event, error) {
+ toolCalls, err := RepairToolCalls(flushAnthropicToolCalls(s.toolCalls), s.tools)
+ s.toolCalls = make(map[int]*anthropicPendingToolCall)
+ if err != nil {
+ return Event{}, err
+ }
ev := Event{
Kind: EventDone,
FinishReason: "tool_calls",
TokensIn: s.inputTokens,
- ToolCalls: flushAnthropicToolCalls(s.toolCalls),
+ ToolCalls: toolCalls,
}
- s.toolCalls = make(map[int]*anthropicPendingToolCall)
- return ev
+ return ev, nil
}
func anthropicFrameType(frame *sseFrame) string {
diff --git a/internal/hermes/bedrock_converse.go b/internal/hermes/bedrock_converse.go
new file mode 100644
index 000000000..a69393eaf
--- /dev/null
+++ b/internal/hermes/bedrock_converse.go
@@ -0,0 +1,266 @@
+package hermes
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+)
+
+const defaultBedrockMaxTokens = 4096
+
+type bedrockConversePayload struct {
+ Messages []bedrockMessage `json:"messages"`
+ System []bedrockContentBlock `json:"system,omitempty"`
+ InferenceConfig bedrockInferenceConfig `json:"inferenceConfig"`
+ ToolConfig *bedrockToolConfig `json:"toolConfig,omitempty"`
+}
+
+type bedrockInferenceConfig struct {
+ MaxTokens int `json:"maxTokens"`
+ Temperature *float64 `json:"temperature,omitempty"`
+}
+
+type bedrockMessage struct {
+ Role string `json:"role"`
+ Content []bedrockContentBlock `json:"content"`
+}
+
+type bedrockContentBlock struct {
+ Text string `json:"text,omitempty"`
+ ReasoningContent *bedrockReasoningContent `json:"reasoningContent,omitempty"`
+ ToolUse *bedrockToolUse `json:"toolUse,omitempty"`
+ ToolResult *bedrockToolResult `json:"toolResult,omitempty"`
+ CachePoint *bedrockCachePoint `json:"cachePoint,omitempty"`
+}
+
+type bedrockReasoningContent struct {
+ ReasoningText *bedrockReasoningText `json:"reasoningText,omitempty"`
+ RedactedContent string `json:"redactedContent,omitempty"`
+}
+
+type bedrockReasoningText struct {
+ Text string `json:"text"`
+ Signature string `json:"signature,omitempty"`
+}
+
+type bedrockToolUse struct {
+ ToolUseID string `json:"toolUseId"`
+ Name string `json:"name"`
+ Input json.RawMessage `json:"input"`
+}
+
+type bedrockToolResult struct {
+ ToolUseID string `json:"toolUseId"`
+ Content []bedrockToolResultContent `json:"content"`
+ Status string `json:"status,omitempty"`
+}
+
+type bedrockToolResultContent struct {
+ Text string `json:"text,omitempty"`
+}
+
+type bedrockCachePoint struct {
+ Type string `json:"type"`
+ TTL string `json:"ttl,omitempty"`
+}
+
+type bedrockToolConfig struct {
+ Tools []bedrockTool `json:"tools"`
+}
+
+type bedrockTool struct {
+ ToolSpec bedrockToolSpec `json:"toolSpec"`
+}
+
+type bedrockToolSpec struct {
+ Name string `json:"name"`
+ Description string `json:"description"`
+ InputSchema bedrockToolInputSchema `json:"inputSchema"`
+}
+
+type bedrockToolInputSchema struct {
+ JSON json.RawMessage `json:"json"`
+}
+
+func buildBedrockConversePayload(req ChatRequest) (bedrockConversePayload, error) {
+ system, messages, err := convertBedrockMessages(req.Messages)
+ if err != nil {
+ return bedrockConversePayload{}, err
+ }
+ tools, err := convertBedrockTools(req.Tools)
+ if err != nil {
+ return bedrockConversePayload{}, err
+ }
+ maxTokens := req.MaxTokens
+ if maxTokens <= 0 {
+ maxTokens = defaultBedrockMaxTokens
+ }
+
+ payload := bedrockConversePayload{
+ Messages: messages,
+ System: system,
+ InferenceConfig: bedrockInferenceConfig{
+ MaxTokens: maxTokens,
+ Temperature: req.Temperature,
+ },
+ }
+ if len(tools) > 0 {
+ payload.ToolConfig = &bedrockToolConfig{Tools: tools}
+ }
+ return payload, nil
+}
+
+func convertBedrockMessages(messages []Message) ([]bedrockContentBlock, []bedrockMessage, error) {
+ var (
+ system []bedrockContentBlock
+ out []bedrockMessage
+ )
+ for _, msg := range messages {
+ switch msg.Role {
+ case "system":
+ if strings.TrimSpace(msg.Content) != "" {
+ system = append(system, bedrockContentBlock{Text: msg.Content})
+ }
+ system = appendBedrockCachePoint(system, msg.CacheControl)
+ case "assistant":
+ blocks, err := bedrockAssistantContentBlocks(msg)
+ if err != nil {
+ return nil, nil, err
+ }
+ out = appendOrMergeBedrockMessage(out, "assistant", blocks)
+ case "tool":
+ blocks := []bedrockContentBlock{{
+ ToolResult: &bedrockToolResult{
+ ToolUseID: msg.ToolCallID,
+ Content: []bedrockToolResultContent{{
+ Text: nonEmptyBedrockText(msg.Content),
+ }},
+ },
+ }}
+ blocks = appendBedrockCachePoint(blocks, msg.CacheControl)
+ out = appendOrMergeBedrockMessage(out, "user", blocks)
+ default:
+ blocks := []bedrockContentBlock{{Text: nonEmptyBedrockText(msg.Content)}}
+ blocks = appendBedrockCachePoint(blocks, msg.CacheControl)
+ out = appendOrMergeBedrockMessage(out, "user", blocks)
+ }
+ }
+ if len(out) == 0 {
+ out = append(out, bedrockMessage{Role: "user", Content: []bedrockContentBlock{{Text: " "}}})
+ }
+ if out[0].Role != "user" {
+ out = append([]bedrockMessage{{Role: "user", Content: []bedrockContentBlock{{Text: " "}}}}, out...)
+ }
+ if out[len(out)-1].Role != "user" {
+ out = append(out, bedrockMessage{Role: "user", Content: []bedrockContentBlock{{Text: " "}}})
+ }
+ return system, out, nil
+}
+
+func bedrockAssistantContentBlocks(msg Message) ([]bedrockContentBlock, error) {
+ blocks := make([]bedrockContentBlock, 0, 1+len(msg.ToolCalls))
+ if strings.TrimSpace(msg.Content) != "" {
+ blocks = append(blocks, bedrockContentBlock{Text: msg.Content})
+ }
+ if msg.Reasoning != nil {
+ if block, ok := bedrockReasoningBlock(msg.Reasoning); ok {
+ blocks = append(blocks, block)
+ }
+ }
+ for _, tc := range msg.ToolCalls {
+ input := json.RawMessage(`{}`)
+ if len(tc.Arguments) > 0 {
+ if !json.Valid(tc.Arguments) {
+ return nil, fmt.Errorf("bedrock tool call %q arguments are invalid JSON", tc.ID)
+ }
+ input = append(json.RawMessage(nil), tc.Arguments...)
+ }
+ blocks = append(blocks, bedrockContentBlock{
+ ToolUse: &bedrockToolUse{
+ ToolUseID: tc.ID,
+ Name: tc.Name,
+ Input: input,
+ },
+ })
+ }
+ if len(blocks) == 0 {
+ blocks = append(blocks, bedrockContentBlock{Text: " "})
+ }
+ return blocks, nil
+}
+
+func bedrockReasoningBlock(reasoning *ReasoningContent) (bedrockContentBlock, bool) {
+ if reasoning.Text != "" || reasoning.Signature != "" {
+ return bedrockContentBlock{
+ ReasoningContent: &bedrockReasoningContent{
+ ReasoningText: &bedrockReasoningText{
+ Text: nonEmptyBedrockText(reasoning.Text),
+ Signature: reasoning.Signature,
+ },
+ },
+ }, true
+ }
+ if reasoning.RedactedContent != "" {
+ return bedrockContentBlock{
+ ReasoningContent: &bedrockReasoningContent{
+ RedactedContent: reasoning.RedactedContent,
+ },
+ }, true
+ }
+ return bedrockContentBlock{}, false
+}
+
+func appendOrMergeBedrockMessage(out []bedrockMessage, role string, blocks []bedrockContentBlock) []bedrockMessage {
+ if len(out) > 0 && out[len(out)-1].Role == role {
+ out[len(out)-1].Content = append(out[len(out)-1].Content, blocks...)
+ return out
+ }
+ return append(out, bedrockMessage{Role: role, Content: blocks})
+}
+
+func appendBedrockCachePoint(blocks []bedrockContentBlock, cache *CacheControl) []bedrockContentBlock {
+ if cache == nil {
+ return blocks
+ }
+ cacheType := cache.Type
+ if cacheType == "" || cacheType == "ephemeral" {
+ cacheType = "default"
+ }
+ return append(blocks, bedrockContentBlock{
+ CachePoint: &bedrockCachePoint{
+ Type: cacheType,
+ TTL: cache.TTL,
+ },
+ })
+}
+
+func convertBedrockTools(tools []ToolDescriptor) ([]bedrockTool, error) {
+ descriptors := SanitizeToolDescriptors(tools)
+ out := make([]bedrockTool, 0, len(descriptors))
+ for _, tool := range descriptors {
+ schema := tool.Schema
+ if len(schema) == 0 {
+ schema = json.RawMessage(`{"type":"object","properties":{}}`)
+ }
+ if !json.Valid(schema) {
+ return nil, fmt.Errorf("bedrock tool %q schema is invalid JSON", tool.Name)
+ }
+ out = append(out, bedrockTool{
+ ToolSpec: bedrockToolSpec{
+ Name: tool.Name,
+ Description: tool.Description,
+ InputSchema: bedrockToolInputSchema{
+ JSON: append(json.RawMessage(nil), schema...),
+ },
+ },
+ })
+ }
+ return out, nil
+}
+
+func nonEmptyBedrockText(text string) string {
+ if strings.TrimSpace(text) == "" {
+ return " "
+ }
+ return text
+}
diff --git a/internal/hermes/bedrock_converse_mapping_test.go b/internal/hermes/bedrock_converse_mapping_test.go
new file mode 100644
index 000000000..9424baf02
--- /dev/null
+++ b/internal/hermes/bedrock_converse_mapping_test.go
@@ -0,0 +1,72 @@
+package hermes
+
+import (
+ "bytes"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestBuildBedrockConversePayload_GoldenMapsSharedProviderContract(t *testing.T) {
+ payload, err := buildBedrockConversePayload(ChatRequest{
+ Model: "anthropic.claude-3-5-sonnet-20241022-v2:0",
+ MaxTokens: 2048,
+ Temperature: ptrFloat64(0.35),
+ Messages: []Message{
+ {Role: "system", Content: "Follow ops policy.", CacheControl: &CacheControl{Type: "default", TTL: "1h"}},
+ {Role: "user", Content: "look up weather"},
+ {
+ Role: "assistant",
+ Content: "Checking the weather.",
+ Reasoning: &ReasoningContent{
+ Text: "Need current weather.",
+ Signature: "sig-bedrock",
+ },
+ ToolCalls: []ToolCall{{
+ ID: "toolu_weather",
+ Name: "get_weather",
+ Arguments: json.RawMessage(`{"location":"Monterrey","unit":"f"}`),
+ }},
+ },
+ {
+ Role: "tool",
+ ToolCallID: "toolu_weather",
+ Name: "get_weather",
+ Content: `{"temperature":"72F","condition":"sunny"}`,
+ CacheControl: &CacheControl{Type: "default"},
+ },
+ {Role: "assistant", Content: ""},
+ },
+ Tools: []ToolDescriptor{{
+ Name: "get_weather",
+ Description: "Returns current weather.",
+ Schema: json.RawMessage(`{"type":"object","properties":{"location":{"type":"string","description":"City name"},"unit":{"type":"string","enum":["c","f"]}},"required":["location","unit"],"additionalProperties":false}`),
+ }},
+ })
+ if err != nil {
+ t.Fatalf("buildBedrockConversePayload() error = %v", err)
+ }
+
+ got := mustMarshalIndent(t, payload)
+ want, err := os.ReadFile(filepath.Join("testdata", "bedrock_converse", "request_body.golden.json"))
+ if err != nil {
+ t.Fatalf("read golden: %v", err)
+ }
+ if !bytes.Equal(got, want) {
+ t.Fatalf("Bedrock Converse payload mismatch\n--- got ---\n%s\n--- want ---\n%s", got, want)
+ }
+}
+
+func ptrFloat64(v float64) *float64 {
+ return &v
+}
+
+func mustMarshalIndent(t *testing.T, v any) []byte {
+ t.Helper()
+ raw, err := json.MarshalIndent(v, "", " ")
+ if err != nil {
+ t.Fatalf("marshal payload: %v", err)
+ }
+ return append(raw, '\n')
+}
diff --git a/internal/hermes/client.go b/internal/hermes/client.go
index 7454f23a4..3ed4119d2 100644
--- a/internal/hermes/client.go
+++ b/internal/hermes/client.go
@@ -37,12 +37,13 @@ type RunEventStream interface {
}
type ChatRequest struct {
- Model string
- MaxTokens int
- Messages []Message
- SessionID string
- Stream bool
- Tools []ToolDescriptor // omitempty at wire time via the Marshal path in http_client
+ Model string
+ MaxTokens int
+ Temperature *float64
+ Messages []Message
+ SessionID string
+ Stream bool
+ Tools []ToolDescriptor // omitempty at wire time via the Marshal path in http_client
}
// ToolDescriptor mirrors tools.ToolDescriptor so hermes stays
@@ -62,7 +63,7 @@ func (d ToolDescriptor) MarshalJSON() ([]byte, error) {
Name string `json:"name"`
Description string `json:"description"`
Parameters json.RawMessage `json:"parameters"`
- }{Name: d.Name, Description: d.Description, Parameters: d.Schema}
+ }{Name: d.Name, Description: d.Description, Parameters: sanitizeToolSchema(d.Schema)}
wrap := struct {
Type string `json:"type"`
Function any `json:"function"`
@@ -71,18 +72,37 @@ func (d ToolDescriptor) MarshalJSON() ([]byte, error) {
}
type Message struct {
- Role string `json:"role"`
- Content string `json:"content"`
- CacheControl *CacheControl `json:"cache_control,omitempty"`
- ToolCalls []ToolCall `json:"tool_calls,omitempty"` // set only on assistant messages that requested tools
- ToolCallID string `json:"tool_call_id,omitempty"` // set only on "tool" role messages replying to a call
- Name string `json:"name,omitempty"` // set only on "tool" role messages; echoes the tool name
+ Role string `json:"role"`
+ Content string `json:"content"`
+ ContentParts []MessageContentPart `json:"content_parts,omitempty"`
+ CacheControl *CacheControl `json:"cache_control,omitempty"`
+ Reasoning *ReasoningContent `json:"reasoning,omitempty"`
+ ReasoningContent *string `json:"reasoning_content,omitempty"`
+ ToolCalls []ToolCall `json:"tool_calls,omitempty"` // set only on assistant messages that requested tools
+ ToolCallID string `json:"tool_call_id,omitempty"` // set only on "tool" role messages replying to a call
+ Name string `json:"name,omitempty"` // set only on "tool" role messages; echoes the tool name
+}
+
+type MessageContentPart struct {
+ Type string `json:"type"`
+ Text string `json:"text,omitempty"`
+ ImageURL string `json:"image_url,omitempty"`
+ Detail string `json:"detail,omitempty"`
}
// CacheControl carries provider-specific prompt-caching hints on content
// blocks. Providers that do not support cache markers ignore it.
type CacheControl struct {
Type string `json:"type"`
+ TTL string `json:"ttl,omitempty"`
+}
+
+// ReasoningContent carries provider-native reasoning echoes that must be
+// replayed alongside assistant turns for providers that require them.
+type ReasoningContent struct {
+ Text string `json:"text,omitempty"`
+ Signature string `json:"signature,omitempty"`
+ RedactedContent string `json:"redacted_content,omitempty"`
}
// ToolCall is one function-call request made by the LLM.
diff --git a/internal/hermes/codex_responses_adapter.go b/internal/hermes/codex_responses_adapter.go
new file mode 100644
index 000000000..8534116c3
--- /dev/null
+++ b/internal/hermes/codex_responses_adapter.go
@@ -0,0 +1,397 @@
+package hermes
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strconv"
+ "strings"
+)
+
+const defaultCodexResponsesInstructions = "You are Gormes."
+
+type codexResponsesPayload struct {
+ Model string `json:"model"`
+ Instructions string `json:"instructions"`
+ Input []any `json:"input"`
+ Tools []codexResponsesTool `json:"tools,omitempty"`
+ Store bool `json:"store"`
+ MaxOutputTokens int `json:"max_output_tokens,omitempty"`
+ ToolChoice string `json:"tool_choice,omitempty"`
+ ParallelToolCalls bool `json:"parallel_tool_calls,omitempty"`
+}
+
+type codexResponsesMessageItem struct {
+ Role string `json:"role"`
+ Content any `json:"content"`
+}
+
+type codexResponsesContentPart struct {
+ Type string `json:"type"`
+ Text string `json:"text,omitempty"`
+ ImageURL string `json:"image_url,omitempty"`
+ Detail string `json:"detail,omitempty"`
+}
+
+type codexResponsesFunctionCallItem struct {
+ Type string `json:"type"`
+ CallID string `json:"call_id"`
+ Name string `json:"name"`
+ Arguments string `json:"arguments"`
+}
+
+type codexResponsesFunctionCallOutputItem struct {
+ Type string `json:"type"`
+ CallID string `json:"call_id"`
+ Output string `json:"output"`
+}
+
+type codexResponsesTool struct {
+ Type string `json:"type"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Strict bool `json:"strict"`
+ Parameters json.RawMessage `json:"parameters"`
+}
+
+type codexResponsesResponse struct {
+ Status string `json:"status"`
+ Output []codexResponsesOutputItem `json:"output"`
+ OutputText string `json:"output_text,omitempty"`
+ Usage codexResponsesUsage `json:"usage"`
+}
+
+type codexResponsesOutputItem struct {
+ Type string `json:"type"`
+ ID string `json:"id,omitempty"`
+ Status string `json:"status,omitempty"`
+ Role string `json:"role,omitempty"`
+ Content []codexResponsesOutputContent `json:"content,omitempty"`
+ Summary []codexResponsesOutputContent `json:"summary,omitempty"`
+ EncryptedContent string `json:"encrypted_content,omitempty"`
+ CallID string `json:"call_id,omitempty"`
+ Name string `json:"name,omitempty"`
+ Arguments json.RawMessage `json:"arguments,omitempty"`
+ Input json.RawMessage `json:"input,omitempty"`
+}
+
+type codexResponsesOutputContent struct {
+ Type string `json:"type"`
+ Text string `json:"text,omitempty"`
+}
+
+type codexResponsesUsage struct {
+ InputTokens int `json:"input_tokens"`
+ OutputTokens int `json:"output_tokens"`
+ TotalTokens int `json:"total_tokens"`
+}
+
+type codexResponsesNormalized struct {
+ Message Message
+ Events []Event
+ Usage codexResponsesUsage
+ FinishReason string
+}
+
+func buildCodexResponsesPayload(req ChatRequest) (codexResponsesPayload, error) {
+ input := make([]any, 0, len(req.Messages))
+ instructions := ""
+ for _, msg := range req.Messages {
+ switch msg.Role {
+ case "system":
+ if strings.TrimSpace(msg.Content) == "" {
+ continue
+ }
+ if instructions != "" {
+ instructions += "\n\n"
+ }
+ instructions += msg.Content
+ case "user", "assistant":
+ if len(msg.ContentParts) > 0 {
+ input = append(input, codexResponsesMessageItem{
+ Role: msg.Role,
+ Content: codexResponsesContentParts(msg.ContentParts),
+ })
+ } else if msg.Content != "" || msg.Role == "user" {
+ input = append(input, codexResponsesMessageItem{Role: msg.Role, Content: msg.Content})
+ }
+ if msg.Role == "assistant" {
+ for _, call := range msg.ToolCalls {
+ name := strings.TrimSpace(call.Name)
+ if name == "" {
+ continue
+ }
+ args := codexResponsesArguments(call.Arguments)
+ callID, _ := splitCodexResponsesToolID(call.ID)
+ if callID == "" {
+ callID = deterministicCodexResponsesCallID(name, args, len(input))
+ }
+ input = append(input, codexResponsesFunctionCallItem{
+ Type: "function_call",
+ CallID: callID,
+ Name: name,
+ Arguments: args,
+ })
+ }
+ }
+ case "tool":
+ callID, _ := splitCodexResponsesToolID(msg.ToolCallID)
+ if callID == "" {
+ callID = strings.TrimSpace(msg.ToolCallID)
+ }
+ if callID == "" {
+ continue
+ }
+ input = append(input, codexResponsesFunctionCallOutputItem{
+ Type: "function_call_output",
+ CallID: callID,
+ Output: msg.Content,
+ })
+ }
+ }
+ if instructions == "" {
+ instructions = defaultCodexResponsesInstructions
+ }
+
+ payload := codexResponsesPayload{
+ Model: req.Model,
+ Instructions: instructions,
+ Input: input,
+ Tools: codexResponsesTools(req.Tools),
+ Store: false,
+ MaxOutputTokens: req.MaxTokens,
+ ToolChoice: "auto",
+ ParallelToolCalls: true,
+ }
+ return payload, nil
+}
+
+func normalizeCodexResponsesResponse(response codexResponsesResponse) (codexResponsesNormalized, error) {
+ status := strings.ToLower(strings.TrimSpace(response.Status))
+ if status == "failed" || status == "cancelled" {
+ return codexResponsesNormalized{}, fmt.Errorf("codex responses status %q", response.Status)
+ }
+
+ output := response.Output
+ if len(output) == 0 {
+ if strings.TrimSpace(response.OutputText) == "" {
+ return codexResponsesNormalized{}, errors.New("responses API returned no output items")
+ }
+ output = []codexResponsesOutputItem{{
+ Type: "message",
+ Role: "assistant",
+ Status: "completed",
+ Content: []codexResponsesOutputContent{{
+ Type: "output_text",
+ Text: strings.TrimSpace(response.OutputText),
+ }},
+ }}
+ }
+
+ var textParts []string
+ var reasoningParts []string
+ var toolCalls []ToolCall
+ events := make([]Event, 0, len(output)+1)
+ incomplete := status == "queued" || status == "in_progress" || status == "incomplete"
+
+ for _, item := range output {
+ itemStatus := strings.ToLower(strings.TrimSpace(item.Status))
+ if itemStatus == "queued" || itemStatus == "in_progress" || itemStatus == "incomplete" {
+ incomplete = true
+ }
+ switch item.Type {
+ case "reasoning":
+ text := codexResponsesReasoningText(item)
+ if text == "" {
+ continue
+ }
+ reasoningParts = append(reasoningParts, text)
+ events = append(events, Event{Kind: EventReasoning, Reasoning: text})
+ case "message":
+ text := codexResponsesMessageText(item)
+ if text != "" {
+ textParts = append(textParts, text)
+ }
+ case "function_call", "custom_tool_call":
+ if itemStatus == "queued" || itemStatus == "in_progress" || itemStatus == "incomplete" {
+ continue
+ }
+ name := strings.TrimSpace(item.Name)
+ if name == "" {
+ continue
+ }
+ args := codexResponsesOutputArguments(item)
+ callID, _ := splitCodexResponsesToolID(item.CallID)
+ if callID == "" {
+ callID, _ = splitCodexResponsesToolID(item.ID)
+ }
+ if callID == "" {
+ callID = deterministicCodexResponsesCallID(name, args, len(toolCalls))
+ }
+ toolCalls = append(toolCalls, ToolCall{
+ ID: callID,
+ Name: name,
+ Arguments: json.RawMessage(args),
+ })
+ }
+ }
+
+ content := strings.TrimSpace(strings.Join(textParts, "\n"))
+ if content == "" && strings.TrimSpace(response.OutputText) != "" {
+ content = strings.TrimSpace(response.OutputText)
+ }
+ if content != "" {
+ events = append(events, Event{Kind: EventToken, Token: content})
+ }
+
+ reasoning := strings.TrimSpace(strings.Join(reasoningParts, "\n\n"))
+ message := Message{
+ Role: "assistant",
+ Content: content,
+ ToolCalls: toolCalls,
+ }
+ if reasoning != "" {
+ message.Reasoning = &ReasoningContent{Text: reasoning}
+ }
+
+ finishReason := "stop"
+ if len(toolCalls) > 0 {
+ finishReason = "tool_calls"
+ } else if incomplete {
+ finishReason = "incomplete"
+ }
+ events = append(events, Event{
+ Kind: EventDone,
+ FinishReason: finishReason,
+ TokensIn: response.Usage.InputTokens,
+ TokensOut: response.Usage.OutputTokens,
+ ToolCalls: toolCalls,
+ })
+
+ return codexResponsesNormalized{
+ Message: message,
+ Events: events,
+ Usage: response.Usage,
+ FinishReason: finishReason,
+ }, nil
+}
+
+func codexResponsesContentParts(parts []MessageContentPart) []codexResponsesContentPart {
+ out := make([]codexResponsesContentPart, 0, len(parts))
+ for _, part := range parts {
+ partType := strings.ToLower(strings.TrimSpace(part.Type))
+ switch partType {
+ case "text", "input_text", "output_text":
+ if part.Text == "" {
+ continue
+ }
+ out = append(out, codexResponsesContentPart{Type: "input_text", Text: part.Text})
+ case "image_url", "input_image":
+ if part.ImageURL == "" {
+ continue
+ }
+ image := codexResponsesContentPart{Type: "input_image", ImageURL: part.ImageURL}
+ if strings.TrimSpace(part.Detail) != "" {
+ image.Detail = strings.TrimSpace(part.Detail)
+ }
+ out = append(out, image)
+ }
+ }
+ return out
+}
+
+func codexResponsesTools(tools []ToolDescriptor) []codexResponsesTool {
+ if len(tools) == 0 {
+ return nil
+ }
+ out := make([]codexResponsesTool, 0, len(tools))
+ for _, tool := range tools {
+ name := strings.TrimSpace(tool.Name)
+ if name == "" {
+ continue
+ }
+ params := tool.Schema
+ if len(params) == 0 {
+ params = json.RawMessage(`{"type":"object","properties":{}}`)
+ }
+ out = append(out, codexResponsesTool{
+ Type: "function",
+ Name: name,
+ Description: tool.Description,
+ Strict: false,
+ Parameters: params,
+ })
+ }
+ return out
+}
+
+func codexResponsesArguments(raw json.RawMessage) string {
+ args := strings.TrimSpace(string(raw))
+ if args == "" {
+ return "{}"
+ }
+ return args
+}
+
+func codexResponsesMessageText(item codexResponsesOutputItem) string {
+ parts := make([]string, 0, len(item.Content))
+ for _, part := range item.Content {
+ switch part.Type {
+ case "output_text", "text":
+ if part.Text != "" {
+ parts = append(parts, part.Text)
+ }
+ }
+ }
+ return strings.TrimSpace(strings.Join(parts, ""))
+}
+
+func codexResponsesReasoningText(item codexResponsesOutputItem) string {
+ parts := make([]string, 0, len(item.Summary))
+ for _, part := range item.Summary {
+ if part.Text != "" {
+ parts = append(parts, part.Text)
+ }
+ }
+ return strings.TrimSpace(strings.Join(parts, "\n"))
+}
+
+func codexResponsesOutputArguments(item codexResponsesOutputItem) string {
+ raw := item.Arguments
+ if item.Type == "custom_tool_call" && len(raw) == 0 {
+ raw = item.Input
+ }
+ args := strings.TrimSpace(string(raw))
+ if args == "" {
+ return "{}"
+ }
+ var decoded string
+ if err := json.Unmarshal(raw, &decoded); err == nil {
+ args = strings.TrimSpace(decoded)
+ if args == "" {
+ return "{}"
+ }
+ }
+ return args
+}
+
+func deterministicCodexResponsesCallID(name, arguments string, index int) string {
+ sum := sha256.Sum256([]byte(name + ":" + arguments + ":" + strconv.Itoa(index)))
+ return "call_" + hex.EncodeToString(sum[:])[:12]
+}
+
+func splitCodexResponsesToolID(raw string) (callID string, responseItemID string) {
+ value := strings.TrimSpace(raw)
+ if value == "" {
+ return "", ""
+ }
+ if before, after, ok := strings.Cut(value, "|"); ok {
+ return strings.TrimSpace(before), strings.TrimSpace(after)
+ }
+ if strings.HasPrefix(value, "fc_") {
+ return "", value
+ }
+ return value, ""
+}
diff --git a/internal/hermes/codex_responses_adapter_test.go b/internal/hermes/codex_responses_adapter_test.go
new file mode 100644
index 000000000..c27f7acd3
--- /dev/null
+++ b/internal/hermes/codex_responses_adapter_test.go
@@ -0,0 +1,183 @@
+package hermes
+
+import (
+ "bytes"
+ "encoding/json"
+ "testing"
+)
+
+func TestBuildCodexResponsesPayload_ConvertsChatInputToolsAndCallIDs(t *testing.T) {
+ payload, err := buildCodexResponsesPayload(ChatRequest{
+ Model: "gpt-5-codex",
+ MaxTokens: 2048,
+ Messages: []Message{
+ {Role: "system", Content: "You are Gormes."},
+ {Role: "user", Content: "Plain text request."},
+ {
+ Role: "user",
+ ContentParts: []MessageContentPart{
+ {Type: "text", Text: "Inspect this screenshot."},
+ {Type: "image_url", ImageURL: "data:image/png;base64,abc123", Detail: "high"},
+ },
+ },
+ {
+ Role: "assistant",
+ Content: "Checking status.",
+ ToolCalls: []ToolCall{{
+ Name: "lookup",
+ Arguments: json.RawMessage(`{"query":"status"}`),
+ }},
+ },
+ {Role: "tool", ToolCallID: "call_existing|fc_existing", Content: `{"ok":true}`},
+ },
+ Tools: []ToolDescriptor{{
+ Name: "lookup",
+ Description: "Looks up fixture status.",
+ Schema: json.RawMessage(`{"type":"object","properties":{"query":{"type":"string"}},"required":["query"],"additionalProperties":false}`),
+ }},
+ })
+ if err != nil {
+ t.Fatalf("buildCodexResponsesPayload() error = %v", err)
+ }
+
+ got := mustMarshalIndent(t, payload)
+ want := []byte(`{
+ "model": "gpt-5-codex",
+ "instructions": "You are Gormes.",
+ "input": [
+ {
+ "role": "user",
+ "content": "Plain text request."
+ },
+ {
+ "role": "user",
+ "content": [
+ {
+ "type": "input_text",
+ "text": "Inspect this screenshot."
+ },
+ {
+ "type": "input_image",
+ "image_url": "data:image/png;base64,abc123",
+ "detail": "high"
+ }
+ ]
+ },
+ {
+ "role": "assistant",
+ "content": "Checking status."
+ },
+ {
+ "type": "function_call",
+ "call_id": "call_7685ce46427f",
+ "name": "lookup",
+ "arguments": "{\"query\":\"status\"}"
+ },
+ {
+ "type": "function_call_output",
+ "call_id": "call_existing",
+ "output": "{\"ok\":true}"
+ }
+ ],
+ "tools": [
+ {
+ "type": "function",
+ "name": "lookup",
+ "description": "Looks up fixture status.",
+ "strict": false,
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "query": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "query"
+ ],
+ "additionalProperties": false
+ }
+ }
+ ],
+ "store": false,
+ "max_output_tokens": 2048,
+ "tool_choice": "auto",
+ "parallel_tool_calls": true
+}
+`)
+ if !bytes.Equal(got, want) {
+ t.Fatalf("Codex Responses payload mismatch\n--- got ---\n%s\n--- want ---\n%s", got, want)
+ }
+}
+
+func TestNormalizeCodexResponsesResponse_MapsOutputItemsUsageAndToolCalls(t *testing.T) {
+ got, err := normalizeCodexResponsesResponse(codexResponsesResponse{
+ Status: "completed",
+ Output: []codexResponsesOutputItem{
+ {
+ Type: "reasoning",
+ ID: "rs_1",
+ EncryptedContent: "enc_opaque",
+ Summary: []codexResponsesOutputContent{
+ {Type: "summary_text", Text: "Need status lookup."},
+ },
+ },
+ {
+ Type: "message",
+ Status: "completed",
+ Content: []codexResponsesOutputContent{
+ {Type: "output_text", Text: "Checking status."},
+ },
+ },
+ {
+ Type: "function_call",
+ ID: "fc_lookup",
+ CallID: "call_lookup",
+ Name: "lookup",
+ Arguments: json.RawMessage(`{"query":"status"}`),
+ },
+ },
+ Usage: codexResponsesUsage{
+ InputTokens: 21,
+ OutputTokens: 8,
+ TotalTokens: 29,
+ },
+ })
+ if err != nil {
+ t.Fatalf("normalizeCodexResponsesResponse() error = %v", err)
+ }
+
+ if got.Message.Role != "assistant" || got.Message.Content != "Checking status." {
+ t.Fatalf("message = %+v, want assistant content", got.Message)
+ }
+ if got.Message.Reasoning == nil || got.Message.Reasoning.Text != "Need status lookup." {
+ t.Fatalf("message reasoning = %+v, want summary text", got.Message.Reasoning)
+ }
+ if len(got.Message.ToolCalls) != 1 {
+ t.Fatalf("message tool calls len = %d, want 1", len(got.Message.ToolCalls))
+ }
+ call := got.Message.ToolCalls[0]
+ if call.ID != "call_lookup" || call.Name != "lookup" || string(call.Arguments) != `{"query":"status"}` {
+ t.Fatalf("message tool call = %+v, want lookup call", call)
+ }
+ if got.Usage.InputTokens != 21 || got.Usage.OutputTokens != 8 || got.Usage.TotalTokens != 29 {
+ t.Fatalf("usage = %+v, want 21/8/29", got.Usage)
+ }
+
+ if len(got.Events) != 3 {
+ t.Fatalf("events len = %d, want reasoning/token/done: %+v", len(got.Events), got.Events)
+ }
+ if got.Events[0].Kind != EventReasoning || got.Events[0].Reasoning != "Need status lookup." {
+ t.Fatalf("event[0] = %+v, want reasoning event", got.Events[0])
+ }
+ if got.Events[1].Kind != EventToken || got.Events[1].Token != "Checking status." {
+ t.Fatalf("event[1] = %+v, want token event", got.Events[1])
+ }
+ final := got.Events[2]
+ if final.Kind != EventDone || final.FinishReason != "tool_calls" || final.TokensIn != 21 || final.TokensOut != 8 {
+ t.Fatalf("final event = %+v, want tool_calls with usage", final)
+ }
+ if len(final.ToolCalls) != 1 || final.ToolCalls[0].ID != "call_lookup" {
+ t.Fatalf("final tool calls = %+v, want call_lookup", final.ToolCalls)
+ }
+}
diff --git a/internal/hermes/context_engine.go b/internal/hermes/context_engine.go
new file mode 100644
index 000000000..30b6c6b89
--- /dev/null
+++ b/internal/hermes/context_engine.go
@@ -0,0 +1,333 @@
+package hermes
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "math"
+ "sync"
+)
+
+const ContextStatusToolName = "context_status"
+
+var (
+ ErrUnknownContextTool = errors.New("hermes: unknown context engine tool")
+ ErrCompressionDisabled = errors.New("hermes: context compression disabled")
+)
+
+type ContextEngine interface {
+ Name() string
+ UpdateFromResponse(ContextUsage)
+ ShouldCompress(promptTokens int) bool
+ Compress(ctx context.Context, messages []Message, req CompressionRequest) ([]Message, CompressionReport, error)
+ ShouldCompressPreflight(messages []Message) bool
+ HasContentToCompress(messages []Message) bool
+ OnSessionStart(ctx context.Context, sessionID string, meta ContextSessionMeta) error
+ OnSessionEnd(ctx context.Context, sessionID string, messages []Message) error
+ OnSessionReset()
+ ToolDescriptors() []ToolDescriptor
+ HandleToolCall(ctx context.Context, name string, args json.RawMessage, opts ContextToolCallOptions) (json.RawMessage, error)
+ Status() ContextStatus
+ UpdateModelContext(ContextModelContext)
+}
+
+type ContextUsage struct {
+ PromptTokens int
+ CompletionTokens int
+ TotalTokens int
+}
+
+type ContextModelContext struct {
+ Model string
+ ContextLength int
+ ThresholdPercent float64
+ ThresholdTokens int
+ BaseURL string
+ Provider string
+}
+
+type CompressionRequest struct {
+ CurrentTokens int
+ FocusTopic string
+}
+
+type CompressionReport struct {
+ State string `json:"state"`
+ BeforeMessages int `json:"before_messages"`
+ AfterMessages int `json:"after_messages"`
+ CurrentTokens int `json:"current_tokens,omitempty"`
+ FocusTopic string `json:"focus_topic,omitempty"`
+}
+
+type ContextSessionMeta struct {
+ Model string
+ ContextLength int
+ Platform string
+}
+
+type ContextToolCallOptions struct {
+ Messages []Message
+}
+
+type ContextStatus struct {
+ Engine string `json:"engine"`
+ Model string `json:"model"`
+ ContextLength int `json:"context_length"`
+ ThresholdTokens int `json:"threshold_tokens"`
+ ThresholdPercent float64 `json:"threshold_percent"`
+ LastPromptTokens int `json:"last_prompt_tokens"`
+ LastCompletionTokens int `json:"last_completion_tokens"`
+ LastTotalTokens int `json:"last_total_tokens"`
+ UsagePercent float64 `json:"usage_percent"`
+ CompressionCount int `json:"compression_count"`
+ Budget ContextBudgetStatus `json:"budget"`
+ Compression ContextCompressionStatus `json:"compression"`
+ Tools ContextToolStatus `json:"tools"`
+ Replay ContextReplayStatus `json:"replay"`
+}
+
+type ContextBudgetStatus struct {
+ State string `json:"state"`
+ RemainingTokens int `json:"remaining_tokens"`
+ Pressure bool `json:"pressure"`
+}
+
+type ContextCompressionStatus struct {
+ Enabled bool `json:"enabled"`
+ ShouldCompress bool `json:"should_compress"`
+ CooldownSeconds int `json:"cooldown_seconds"`
+ DisabledReason string `json:"disabled_reason,omitempty"`
+ LastError string `json:"last_error,omitempty"`
+}
+
+type ContextToolStatus struct {
+ StatusTool string `json:"status_tool"`
+ UnknownToolErrors []ContextToolError `json:"unknown_tool_errors,omitempty"`
+}
+
+type ContextToolError struct {
+ Type string `json:"type"`
+ Tool string `json:"tool"`
+ Message string `json:"message"`
+}
+
+func (e ContextToolError) Error() string { return e.Message }
+
+type ContextReplayStatus struct {
+ Gaps []ContextReplayGap `json:"gaps,omitempty"`
+}
+
+type ContextReplayGap struct {
+ Kind string `json:"kind"`
+ Message string `json:"message"`
+}
+
+type DisabledContextEngine struct {
+ mu sync.Mutex
+ status ContextStatus
+}
+
+var _ ContextEngine = (*DisabledContextEngine)(nil)
+
+func NewDisabledContextEngine(reason string) *DisabledContextEngine {
+ if reason == "" {
+ reason = "context compression disabled"
+ }
+ return &DisabledContextEngine{
+ status: ContextStatus{
+ Engine: "disabled",
+ ThresholdPercent: 0.75,
+ Compression: ContextCompressionStatus{
+ Enabled: false,
+ ShouldCompress: false,
+ DisabledReason: reason,
+ },
+ Tools: ContextToolStatus{
+ StatusTool: ContextStatusToolName,
+ },
+ },
+ }
+}
+
+func (e *DisabledContextEngine) Name() string { return "disabled" }
+
+func (e *DisabledContextEngine) UpdateFromResponse(usage ContextUsage) {
+ e.mu.Lock()
+ defer e.mu.Unlock()
+ e.status.LastPromptTokens = usage.PromptTokens
+ e.status.LastCompletionTokens = usage.CompletionTokens
+ if usage.TotalTokens > 0 {
+ e.status.LastTotalTokens = usage.TotalTokens
+ } else {
+ e.status.LastTotalTokens = usage.PromptTokens + usage.CompletionTokens
+ }
+ e.refreshLocked()
+}
+
+func (e *DisabledContextEngine) ShouldCompress(int) bool { return false }
+
+func (e *DisabledContextEngine) Compress(_ context.Context, messages []Message, req CompressionRequest) ([]Message, CompressionReport, error) {
+ out := append([]Message(nil), messages...)
+ return out, CompressionReport{
+ State: "disabled",
+ BeforeMessages: len(messages),
+ AfterMessages: len(messages),
+ CurrentTokens: req.CurrentTokens,
+ FocusTopic: req.FocusTopic,
+ }, ErrCompressionDisabled
+}
+
+func (e *DisabledContextEngine) ShouldCompressPreflight([]Message) bool { return false }
+
+func (e *DisabledContextEngine) HasContentToCompress([]Message) bool { return false }
+
+func (e *DisabledContextEngine) OnSessionStart(context.Context, string, ContextSessionMeta) error {
+ return nil
+}
+
+func (e *DisabledContextEngine) OnSessionEnd(context.Context, string, []Message) error {
+ return nil
+}
+
+func (e *DisabledContextEngine) OnSessionReset() {
+ e.mu.Lock()
+ defer e.mu.Unlock()
+ e.status.LastPromptTokens = 0
+ e.status.LastCompletionTokens = 0
+ e.status.LastTotalTokens = 0
+ e.status.CompressionCount = 0
+ e.status.Tools.UnknownToolErrors = nil
+ e.refreshLocked()
+}
+
+func (e *DisabledContextEngine) ToolDescriptors() []ToolDescriptor {
+ return []ToolDescriptor{ContextStatusToolDescriptor()}
+}
+
+func ContextStatusToolDescriptor() ToolDescriptor {
+ return ToolDescriptor{
+ Name: ContextStatusToolName,
+ Description: "Reports context-window budget, compression state, and context-engine degraded modes.",
+ Schema: json.RawMessage(`{"type":"object","properties":{},"additionalProperties":false}`),
+ }
+}
+
+func (e *DisabledContextEngine) HandleToolCall(_ context.Context, name string, _ json.RawMessage, _ ContextToolCallOptions) (json.RawMessage, error) {
+ if name == ContextStatusToolName {
+ e.mu.Lock()
+ e.refreshLocked()
+ status := e.status
+ e.mu.Unlock()
+ payload, err := json.Marshal(status)
+ return payload, err
+ }
+
+ toolErr := unknownContextToolError(name)
+ e.mu.Lock()
+ e.status.Tools.UnknownToolErrors = append(e.status.Tools.UnknownToolErrors, toolErr)
+ e.refreshLocked()
+ e.mu.Unlock()
+ payload, err := json.Marshal(struct {
+ Error ContextToolError `json:"error"`
+ }{Error: toolErr})
+ if err != nil {
+ return nil, err
+ }
+ return payload, fmt.Errorf("%w: %s", ErrUnknownContextTool, name)
+}
+
+func (e *DisabledContextEngine) Status() ContextStatus {
+ e.mu.Lock()
+ defer e.mu.Unlock()
+ e.refreshLocked()
+ return e.status
+}
+
+func (e *DisabledContextEngine) UpdateModelContext(update ContextModelContext) {
+ e.mu.Lock()
+ defer e.mu.Unlock()
+ if update.Model != "" {
+ e.status.Model = update.Model
+ }
+ if update.ContextLength > 0 {
+ e.status.ContextLength = update.ContextLength
+ }
+ if update.ThresholdPercent > 0 {
+ e.status.ThresholdPercent = update.ThresholdPercent
+ } else if e.status.ThresholdPercent <= 0 {
+ e.status.ThresholdPercent = 0.75
+ }
+ if update.ThresholdTokens > 0 {
+ e.status.ThresholdTokens = update.ThresholdTokens
+ } else if e.status.ContextLength > 0 {
+ e.status.ThresholdTokens = int(float64(e.status.ContextLength) * e.status.ThresholdPercent)
+ }
+ e.refreshLocked()
+}
+
+func (e *DisabledContextEngine) SetCompressionCooldown(seconds int, lastError string) {
+ if seconds < 0 {
+ seconds = 0
+ }
+ e.mu.Lock()
+ defer e.mu.Unlock()
+ e.status.Compression.CooldownSeconds = seconds
+ e.status.Compression.LastError = lastError
+ e.refreshLocked()
+}
+
+func (e *DisabledContextEngine) RecordReplayGap(gap ContextReplayGap) {
+ e.mu.Lock()
+ defer e.mu.Unlock()
+ e.status.Replay.Gaps = append(e.status.Replay.Gaps, gap)
+ e.refreshLocked()
+}
+
+func (e *DisabledContextEngine) refreshLocked() {
+ if e.status.ContextLength > 0 {
+ usage := float64(e.status.LastPromptTokens) / float64(e.status.ContextLength) * 100
+ e.status.UsagePercent = math.Min(100, roundPercent(usage))
+ } else {
+ e.status.UsagePercent = 0
+ }
+ e.status.Budget = classifyContextBudget(e.status.LastPromptTokens, e.status.ThresholdTokens, e.status.ContextLength)
+ e.status.Compression.Enabled = false
+ e.status.Compression.ShouldCompress = false
+ e.status.Tools.StatusTool = ContextStatusToolName
+}
+
+func classifyContextBudget(promptTokens, thresholdTokens, contextLength int) ContextBudgetStatus {
+ if thresholdTokens <= 0 || contextLength <= 0 {
+ return ContextBudgetStatus{State: "unknown", RemainingTokens: 0, Pressure: false}
+ }
+ remaining := thresholdTokens - promptTokens
+ if remaining < 0 {
+ remaining = 0
+ }
+ state := "ok"
+ pressure := false
+ if promptTokens >= contextLength {
+ state = "over_window"
+ pressure = true
+ } else if promptTokens >= thresholdTokens {
+ state = "over_threshold"
+ pressure = true
+ } else if promptTokens >= int(float64(thresholdTokens)*0.90) {
+ state = "pressure"
+ pressure = true
+ }
+ return ContextBudgetStatus{State: state, RemainingTokens: remaining, Pressure: pressure}
+}
+
+func unknownContextToolError(name string) ContextToolError {
+ return ContextToolError{
+ Type: "unknown_context_tool",
+ Tool: name,
+ Message: "Unknown context engine tool: " + name,
+ }
+}
+
+func roundPercent(v float64) float64 {
+ return math.Round(v*100) / 100
+}
diff --git a/internal/hermes/context_engine_test.go b/internal/hermes/context_engine_test.go
new file mode 100644
index 000000000..5a9155057
--- /dev/null
+++ b/internal/hermes/context_engine_test.go
@@ -0,0 +1,112 @@
+package hermes
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "reflect"
+ "testing"
+)
+
+func TestDisabledContextEngine_StatusToolFixture(t *testing.T) {
+ engine := NewDisabledContextEngine("compression disabled by config")
+ engine.UpdateModelContext(ContextModelContext{
+ Model: "fixture-model",
+ ContextLength: 8000,
+ ThresholdPercent: 0.75,
+ })
+ engine.UpdateFromResponse(ContextUsage{
+ PromptTokens: 5800,
+ CompletionTokens: 120,
+ TotalTokens: 5920,
+ })
+ engine.SetCompressionCooldown(90, "summary provider unavailable")
+ engine.RecordReplayGap(ContextReplayGap{
+ Kind: "missing_fixture",
+ Message: "no compression replay fixture for fixture-model",
+ })
+
+ unknownPayload, err := engine.HandleToolCall(context.Background(), "missing_context_tool", json.RawMessage(`{"query":"x"}`), ContextToolCallOptions{})
+ if !errors.Is(err, ErrUnknownContextTool) {
+ t.Fatalf("unknown tool err = %v, want ErrUnknownContextTool", err)
+ }
+ assertJSONEqual(t, unknownPayload, []byte(`{
+ "error": {
+ "type": "unknown_context_tool",
+ "tool": "missing_context_tool",
+ "message": "Unknown context engine tool: missing_context_tool"
+ }
+ }`))
+
+ statusPayload, err := engine.HandleToolCall(context.Background(), ContextStatusToolName, json.RawMessage(`{}`), ContextToolCallOptions{})
+ if err != nil {
+ t.Fatalf("context status tool returned error: %v", err)
+ }
+
+ want, err := os.ReadFile(filepath.Join("testdata", "context_status", "disabled_pressure_unknown_tool.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ assertJSONEqual(t, statusPayload, want)
+}
+
+func TestDisabledContextEngine_UpdateModelContextRecalculatesThreshold(t *testing.T) {
+ engine := NewDisabledContextEngine("disabled")
+
+ engine.UpdateModelContext(ContextModelContext{
+ Model: "small",
+ ContextLength: 4096,
+ ThresholdPercent: 0.5,
+ })
+ status := engine.Status()
+ if status.Model != "small" || status.ContextLength != 4096 || status.ThresholdTokens != 2048 {
+ t.Fatalf("status after first update = %#v, want model small context 4096 threshold 2048", status)
+ }
+
+ engine.UpdateModelContext(ContextModelContext{
+ Model: "larger",
+ ContextLength: 10000,
+ })
+ status = engine.Status()
+ if status.Model != "larger" || status.ContextLength != 10000 || status.ThresholdPercent != 0.5 || status.ThresholdTokens != 5000 {
+ t.Fatalf("status after preserving threshold percent = %#v, want larger context with 50%% threshold", status)
+ }
+}
+
+func TestDisabledContextEngine_CompressIsExplicitDisabledBoundary(t *testing.T) {
+ engine := NewDisabledContextEngine("compression disabled by config")
+ messages := []Message{{Role: "user", Content: "hello"}}
+
+ got, report, err := engine.Compress(context.Background(), messages, CompressionRequest{CurrentTokens: 9000})
+ if !errors.Is(err, ErrCompressionDisabled) {
+ t.Fatalf("Compress err = %v, want ErrCompressionDisabled", err)
+ }
+ if !reflect.DeepEqual(got, messages) {
+ t.Fatalf("Compress messages = %#v, want original messages unchanged", got)
+ }
+ if report.State != "disabled" || report.BeforeMessages != 1 || report.AfterMessages != 1 {
+ t.Fatalf("Compression report = %#v, want disabled no-op boundary report", report)
+ }
+ if engine.Status().CompressionCount != 0 {
+ t.Fatalf("compression_count = %d, want 0 for disabled no-op", engine.Status().CompressionCount)
+ }
+}
+
+func assertJSONEqual(t *testing.T, got, want []byte) {
+ t.Helper()
+ var gotAny any
+ if err := json.Unmarshal(got, &gotAny); err != nil {
+ t.Fatalf("decode got JSON: %v\n%s", err, got)
+ }
+ var wantAny any
+ if err := json.Unmarshal(want, &wantAny); err != nil {
+ t.Fatalf("decode want JSON: %v\n%s", err, want)
+ }
+ if !reflect.DeepEqual(gotAny, wantAny) {
+ gotPretty, _ := json.MarshalIndent(gotAny, "", " ")
+ wantPretty, _ := json.MarshalIndent(wantAny, "", " ")
+ t.Fatalf("JSON mismatch\n got: %s\nwant: %s", gotPretty, wantPretty)
+ }
+}
diff --git a/internal/hermes/http_client.go b/internal/hermes/http_client.go
index 333933a2e..df5440c07 100644
--- a/internal/hermes/http_client.go
+++ b/internal/hermes/http_client.go
@@ -7,6 +7,8 @@ import (
"fmt"
"io"
"net/http"
+ "net/url"
+ "strings"
"time"
)
@@ -14,9 +16,10 @@ const defaultChatCompletionsPath = "/v1/chat/completions"
const defaultHealthPath = "/health"
type httpClient struct {
- baseURL string
- apiKey string
- http *http.Client
+ baseURL string
+ apiKey string
+ provider string
+ http *http.Client
}
// NewHTTPClient returns a Client that talks HTTP+SSE to a Hermes-compatible
@@ -24,6 +27,13 @@ type httpClient struct {
// The returned client streams without a global timeout so long turns
// (minutes, with tool use) are not truncated; see per-phase timeouts inside.
func NewHTTPClient(baseURL, apiKey string) Client {
+ return NewHTTPClientWithProvider(baseURL, apiKey, "")
+}
+
+// NewHTTPClientWithProvider returns an OpenAI-compatible HTTP client with a
+// provider identity hint for providers whose replay rules differ from the
+// generic Chat Completions shape.
+func NewHTTPClientWithProvider(baseURL, apiKey, provider string) Client {
// Clone the default transport and enforce the header-phase budget via
// ResponseHeaderTimeout. This caps time-to-first-byte WITHOUT affecting
// the streaming body read afterwards โ unlike wrapping the request
@@ -31,12 +41,17 @@ func NewHTTPClient(baseURL, apiKey string) Client {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.ResponseHeaderTimeout = 5 * time.Second
return &httpClient{
- baseURL: baseURL,
- apiKey: apiKey,
- http: &http.Client{Timeout: 0, Transport: transport},
+ baseURL: baseURL,
+ apiKey: apiKey,
+ provider: strings.TrimSpace(provider),
+ http: &http.Client{Timeout: 0, Transport: transport},
}
}
+func (c *httpClient) ProviderStatus() ProviderStatus {
+ return openAICompatibleProviderStatus(c.provider, c.baseURL)
+}
+
func (c *httpClient) Health(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+defaultHealthPath, nil)
if err != nil {
@@ -55,11 +70,12 @@ func (c *httpClient) Health(ctx context.Context) error {
}
type orMessage struct {
- Role string `json:"role"`
- Content string `json:"content"`
- ToolCalls []orToolCall `json:"tool_calls,omitempty"`
- ToolCallID string `json:"tool_call_id,omitempty"`
- Name string `json:"name,omitempty"`
+ Role string `json:"role"`
+ Content string `json:"content"`
+ ReasoningContent *string `json:"reasoning_content,omitempty"`
+ ToolCalls []orToolCall `json:"tool_calls,omitempty"`
+ ToolCallID string `json:"tool_call_id,omitempty"`
+ Name string `json:"name,omitempty"`
}
type orToolCall struct {
@@ -90,9 +106,10 @@ type orChatRequest struct {
}
func (c *httpClient) OpenStream(ctx context.Context, req ChatRequest) (Stream, error) {
- msgs := makeOpenAICompatibleMessages(req.Messages)
- tools := make([]orToolDescriptor, len(req.Tools))
- for i, t := range req.Tools {
+ msgs := makeOpenAICompatibleMessages(req.Messages, c.provider, req.Model, c.baseURL)
+ descriptors := SanitizeToolDescriptors(req.Tools)
+ tools := make([]orToolDescriptor, len(descriptors))
+ for i, t := range descriptors {
tools[i] = orToolDescriptor{
Type: "function",
Function: struct {
@@ -133,10 +150,10 @@ func (c *httpClient) OpenStream(ctx context.Context, req ChatRequest) (Stream, e
return nil, newHTTPError(resp.StatusCode, string(raw), resp.Header)
}
// The body stays open for streaming; chatStream owns the Close.
- return newChatStream(resp.Body, resp.Header.Get("X-Hermes-Session-Id")), nil
+ return newChatStream(resp.Body, resp.Header.Get("X-Hermes-Session-Id"), descriptors), nil
}
-func makeOpenAICompatibleMessages(messages []Message) []orMessage {
+func makeOpenAICompatibleMessages(messages []Message, provider, model, baseURL string) []orMessage {
out := make([]orMessage, 0, len(messages))
for _, msg := range messages {
wire := orMessage{
@@ -145,6 +162,9 @@ func makeOpenAICompatibleMessages(messages []Message) []orMessage {
ToolCallID: msg.ToolCallID,
Name: msg.Name,
}
+ if msg.Role == "assistant" {
+ wire.ReasoningContent = openAICompatibleReasoningContent(msg, provider, model, baseURL)
+ }
if len(msg.ToolCalls) > 0 {
wire.ToolCalls = make([]orToolCall, 0, len(msg.ToolCalls))
for _, call := range msg.ToolCalls {
@@ -167,6 +187,70 @@ func makeOpenAICompatibleMessages(messages []Message) []orMessage {
return out
}
+func openAICompatibleReasoningContent(msg Message, provider, model, baseURL string) *string {
+ if len(msg.ToolCalls) == 0 || !openAICompatibleRequiresReasoningEcho(provider, model, baseURL) {
+ return nil
+ }
+ if msg.ReasoningContent != nil {
+ return msg.ReasoningContent
+ }
+ if msg.Reasoning != nil && msg.Reasoning.Text != "" {
+ text := msg.Reasoning.Text
+ return &text
+ }
+ empty := ""
+ return &empty
+}
+
+func openAICompatibleRequiresReasoningEcho(provider, model, baseURL string) bool {
+ return openAICompatibleNeedsDeepSeekToolReasoning(provider, model, baseURL) ||
+ openAICompatibleNeedsKimiToolReasoning(provider, baseURL)
+}
+
+func openAICompatibleNeedsDeepSeekToolReasoning(provider, model, baseURL string) bool {
+ provider = strings.ToLower(strings.TrimSpace(provider))
+ model = strings.ToLower(strings.TrimSpace(model))
+ return provider == "deepseek" ||
+ strings.Contains(model, "deepseek") ||
+ baseURLHostMatches(baseURL, "api.deepseek.com")
+}
+
+func openAICompatibleNeedsKimiToolReasoning(provider, baseURL string) bool {
+ provider = strings.ToLower(strings.TrimSpace(provider))
+ return provider == "kimi-coding" ||
+ provider == "kimi-coding-cn" ||
+ baseURLHostMatches(baseURL, "api.kimi.com") ||
+ baseURLHostMatches(baseURL, "moonshot.ai") ||
+ baseURLHostMatches(baseURL, "moonshot.cn")
+}
+
+func baseURLHostMatches(rawBaseURL, domain string) bool {
+ host := baseURLHostname(rawBaseURL)
+ if host == "" {
+ return false
+ }
+ domain = strings.ToLower(strings.TrimSpace(strings.TrimSuffix(domain, ".")))
+ if domain == "" {
+ return false
+ }
+ return host == domain || strings.HasSuffix(host, "."+domain)
+}
+
+func baseURLHostname(rawBaseURL string) string {
+ rawBaseURL = strings.TrimSpace(rawBaseURL)
+ if rawBaseURL == "" {
+ return ""
+ }
+ parsed, err := url.Parse(rawBaseURL)
+ if err != nil || parsed.Host == "" {
+ parsed, err = url.Parse("https://" + rawBaseURL)
+ if err != nil {
+ return ""
+ }
+ }
+ return strings.ToLower(strings.TrimSuffix(parsed.Hostname(), "."))
+}
+
// OpenRunEvents subscribes to SSE stream for a run's events.
// 404 returns ErrRunEventsNotSupported for non-Hermes servers.
func (c *httpClient) OpenRunEvents(ctx context.Context, runID string) (RunEventStream, error) {
diff --git a/internal/hermes/mock.go b/internal/hermes/mock.go
index d2bcfa792..c86838e2b 100644
--- a/internal/hermes/mock.go
+++ b/internal/hermes/mock.go
@@ -19,11 +19,12 @@ var (
// via Script / ScriptRunEvents; each OpenStream / OpenRunEvents call dequeues
// the next scripted sequence and returns a Stream / RunEventStream backed by it.
type MockClient struct {
- mu sync.Mutex
- streams []*MockStream
- runStreams []*MockRunEventStream
- healthErr error
- requests []ChatRequest
+ mu sync.Mutex
+ streams []*MockStream
+ runStreams []*MockRunEventStream
+ healthErr error
+ requests []ChatRequest
+ status ProviderStatus
}
func NewMockClient() *MockClient { return &MockClient{} }
@@ -31,6 +32,26 @@ func NewMockClient() *MockClient { return &MockClient{} }
// SetHealth makes Health return err for the life of this MockClient.
func (m *MockClient) SetHealth(err error) { m.healthErr = err }
+// SetProviderStatus overrides the default mock provider status for kernel
+// tests that assert degraded provider capability rows.
+func (m *MockClient) SetProviderStatus(status ProviderStatus) { m.status = status }
+
+func (m *MockClient) ProviderStatus() ProviderStatus {
+ if m.status.Provider != "" || m.status.Runtime != "" {
+ return normalizeProviderStatus(m.status)
+ }
+ return ProviderStatus{
+ Provider: "mock",
+ Runtime: "test_harness",
+ Capabilities: ProviderCapabilities{
+ PromptCache: unavailableCapability("mock provider status not configured"),
+ ReasoningEcho: unavailableCapability("mock provider status not configured"),
+ RateGuard: unavailableCapability("mock provider status not configured"),
+ BudgetTelemetry: unavailableCapability("mock provider status not configured"),
+ },
+ }
+}
+
// Script queues a Stream emitting the given Events for the next OpenStream call.
// sessionID is what Stream.SessionID() returns.
func (m *MockClient) Script(events []Event, sessionID string) *MockStream {
diff --git a/internal/hermes/model_context_resolver.go b/internal/hermes/model_context_resolver.go
new file mode 100644
index 000000000..0ace31325
--- /dev/null
+++ b/internal/hermes/model_context_resolver.go
@@ -0,0 +1,173 @@
+package hermes
+
+import (
+ "sort"
+ "strings"
+)
+
+type ModelContextSource string
+
+const (
+ ModelContextSourceProviderCap ModelContextSource = "provider_cap"
+ ModelContextSourceModelsDev ModelContextSource = "models_dev"
+ ModelContextSourceUnknown ModelContextSource = "unknown"
+)
+
+type ModelContextMetadata struct {
+ ContextWindow int
+}
+
+type ModelContextQuery struct {
+ Provider string
+ Model string
+ BaseURL string
+ ModelInfo ModelContextMetadata
+}
+
+type ModelContextResolution struct {
+ Provider string
+ Model string
+ ContextLength int
+ Source ModelContextSource
+ ProviderLookupError string
+}
+
+func (r ModelContextResolution) Known() bool {
+ return r.ContextLength > 0 && r.Source != ModelContextSourceUnknown
+}
+
+type ModelContextLookup interface {
+ LookupModelContext(ModelContextQuery) (int, bool, error)
+}
+
+type ModelContextLookupFunc func(ModelContextQuery) (int, bool, error)
+
+func (fn ModelContextLookupFunc) LookupModelContext(query ModelContextQuery) (int, bool, error) {
+ return fn(query)
+}
+
+type ModelContextKey struct {
+ Provider string
+ Model string
+}
+
+type StaticModelContextCaps map[ModelContextKey]int
+
+func (caps StaticModelContextCaps) LookupModelContext(query ModelContextQuery) (int, bool, error) {
+ if len(caps) == 0 {
+ return 0, false, nil
+ }
+
+ provider := normalizeModelContextProvider(query.Provider)
+ model := normalizeModelContextText(query.Model)
+ if provider == "" || model == "" {
+ return 0, false, nil
+ }
+
+ type candidate struct {
+ model string
+ value int
+ }
+ var candidates []candidate
+ for key, value := range caps {
+ if value <= 0 || normalizeModelContextProvider(key.Provider) != provider {
+ continue
+ }
+ keyModel := normalizeModelContextText(key.Model)
+ if keyModel == "" {
+ continue
+ }
+ if model == keyModel {
+ return value, true, nil
+ }
+ if strings.Contains(model, keyModel) {
+ candidates = append(candidates, candidate{model: keyModel, value: value})
+ }
+ }
+ if len(candidates) == 0 {
+ return 0, false, nil
+ }
+ sort.Slice(candidates, func(i, j int) bool {
+ return len(candidates[i].model) > len(candidates[j].model)
+ })
+ return candidates[0].value, true, nil
+}
+
+type ModelContextResolver struct {
+ providerCaps ModelContextLookup
+}
+
+func NewModelContextResolver(providerCaps ModelContextLookup) ModelContextResolver {
+ return ModelContextResolver{providerCaps: providerCaps}
+}
+
+func DefaultModelContextResolver() ModelContextResolver {
+ return NewModelContextResolver(defaultModelContextCaps)
+}
+
+func ResolveDisplayContextLength(query ModelContextQuery) ModelContextResolution {
+ return DefaultModelContextResolver().Resolve(query)
+}
+
+func (r ModelContextResolver) Resolve(query ModelContextQuery) ModelContextResolution {
+ result := ModelContextResolution{
+ Provider: query.Provider,
+ Model: query.Model,
+ Source: ModelContextSourceUnknown,
+ }
+
+ if r.providerCaps != nil {
+ length, ok, err := r.providerCaps.LookupModelContext(query)
+ if err != nil {
+ result.ProviderLookupError = err.Error()
+ }
+ if ok && length > 0 {
+ result.ContextLength = length
+ result.Source = ModelContextSourceProviderCap
+ return result
+ }
+ }
+
+ if query.ModelInfo.ContextWindow > 0 {
+ result.ContextLength = query.ModelInfo.ContextWindow
+ result.Source = ModelContextSourceModelsDev
+ return result
+ }
+
+ return result
+}
+
+var defaultModelContextCaps = StaticModelContextCaps{
+ // ChatGPT Codex OAuth caps these slugs below the raw OpenAI API windows.
+ ModelContextKey{Provider: "openai-codex", Model: "gpt-5.1-codex-max"}: 272_000,
+ ModelContextKey{Provider: "openai-codex", Model: "gpt-5.1-codex-mini"}: 272_000,
+ ModelContextKey{Provider: "openai-codex", Model: "gpt-5.3-codex"}: 272_000,
+ ModelContextKey{Provider: "openai-codex", Model: "gpt-5.2-codex"}: 272_000,
+ ModelContextKey{Provider: "openai-codex", Model: "gpt-5.4-mini"}: 272_000,
+ ModelContextKey{Provider: "openai-codex", Model: "gpt-5.5"}: 272_000,
+ ModelContextKey{Provider: "openai-codex", Model: "gpt-5.4"}: 272_000,
+ ModelContextKey{Provider: "openai-codex", Model: "gpt-5.2"}: 272_000,
+ ModelContextKey{Provider: "openai-codex", Model: "gpt-5"}: 272_000,
+
+ // Provider-enforced fixture caps for model families whose raw vendor
+ // metadata is larger than the context actually usable through the provider.
+ ModelContextKey{Provider: "copilot", Model: "claude-opus-4.6"}: 128_000,
+ ModelContextKey{Provider: "copilot", Model: "claude-sonnet-4.6"}: 128_000,
+ ModelContextKey{Provider: "nous", Model: "claude-opus-4-6"}: 200_000,
+ ModelContextKey{Provider: "nous", Model: "claude-opus-4.6"}: 200_000,
+}
+
+func normalizeModelContextProvider(provider string) string {
+ switch normalizeModelContextText(provider) {
+ case "codex", "openai-codex":
+ return "openai-codex"
+ case "copilot", "copilot-acp", "github", "github-copilot", "github-models":
+ return "copilot"
+ default:
+ return normalizeModelContextText(provider)
+ }
+}
+
+func normalizeModelContextText(value string) string {
+ return strings.ToLower(strings.TrimSpace(value))
+}
diff --git a/internal/hermes/model_context_resolver_test.go b/internal/hermes/model_context_resolver_test.go
new file mode 100644
index 000000000..c1e6aec4d
--- /dev/null
+++ b/internal/hermes/model_context_resolver_test.go
@@ -0,0 +1,136 @@
+package hermes
+
+import (
+ "errors"
+ "testing"
+)
+
+func TestResolveDisplayContextLengthUsesDefaultProviderCaps(t *testing.T) {
+ got := ResolveDisplayContextLength(ModelContextQuery{
+ Provider: "openai-codex",
+ Model: "gpt-5.5",
+ ModelInfo: ModelContextMetadata{
+ ContextWindow: 1_050_000,
+ },
+ })
+
+ if got.ContextLength != 272_000 {
+ t.Fatalf("ContextLength = %d, want Codex OAuth cap 272000", got.ContextLength)
+ }
+ if got.Source != ModelContextSourceProviderCap {
+ t.Fatalf("Source = %q, want %q", got.Source, ModelContextSourceProviderCap)
+ }
+ if !got.Known() {
+ t.Fatal("Known() = false, want true")
+ }
+}
+
+func TestModelContextResolverPrefersProviderCapsOverModelInfo(t *testing.T) {
+ resolver := NewModelContextResolver(StaticModelContextCaps{
+ ModelContextKey{Provider: "openai-codex", Model: "gpt-5.5"}: 272_000,
+ ModelContextKey{Provider: "copilot", Model: "claude-opus-4.6"}: 128_000,
+ ModelContextKey{Provider: "github-copilot", Model: "claude-opus-4.6"}: 128_000,
+ ModelContextKey{Provider: "nous", Model: "claude-opus-4-6"}: 200_000,
+ ModelContextKey{Provider: "nous", Model: "anthropic/claude-opus-4.6"}: 200_000,
+ ModelContextKey{Provider: "openai-codex", Model: "gpt-5.4-mini"}: 272_000,
+ ModelContextKey{Provider: "copilot-acp", Model: "claude-sonnet-4.6"}: 128_000,
+ ModelContextKey{Provider: "github-copilot", Model: "claude-sonnet-4.6"}: 128_000,
+ })
+
+ cases := []struct {
+ name string
+ provider string
+ model string
+ modelInfo int
+ want int
+ }{
+ {
+ name: "codex oauth cap beats raw openai window",
+ provider: "openai-codex",
+ model: "gpt-5.5",
+ modelInfo: 1_050_000,
+ want: 272_000,
+ },
+ {
+ name: "copilot cap beats claude models.dev fallback",
+ provider: "copilot",
+ model: "claude-opus-4.6",
+ modelInfo: 1_000_000,
+ want: 128_000,
+ },
+ {
+ name: "nous cap beats direct anthropic fallback",
+ provider: "nous",
+ model: "claude-opus-4-6",
+ modelInfo: 1_000_000,
+ want: 200_000,
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got := resolver.Resolve(ModelContextQuery{
+ Provider: tc.provider,
+ Model: tc.model,
+ ModelInfo: ModelContextMetadata{
+ ContextWindow: tc.modelInfo,
+ },
+ })
+
+ if got.ContextLength != tc.want {
+ t.Fatalf("ContextLength = %d, want %d", got.ContextLength, tc.want)
+ }
+ if got.Source != ModelContextSourceProviderCap {
+ t.Fatalf("Source = %q, want %q", got.Source, ModelContextSourceProviderCap)
+ }
+ if !got.Known() {
+ t.Fatal("Known() = false, want true")
+ }
+ })
+ }
+}
+
+func TestModelContextResolverFallsBackToModelInfo(t *testing.T) {
+ resolver := NewModelContextResolver(ModelContextLookupFunc(func(ModelContextQuery) (int, bool, error) {
+ return 0, false, errors.New("provider metadata unavailable")
+ }))
+
+ got := resolver.Resolve(ModelContextQuery{
+ Provider: "some-provider",
+ Model: "some-model",
+ ModelInfo: ModelContextMetadata{
+ ContextWindow: 1_048_576,
+ },
+ })
+
+ if got.ContextLength != 1_048_576 {
+ t.Fatalf("ContextLength = %d, want model metadata fallback 1048576", got.ContextLength)
+ }
+ if got.Source != ModelContextSourceModelsDev {
+ t.Fatalf("Source = %q, want %q", got.Source, ModelContextSourceModelsDev)
+ }
+ if !got.Known() {
+ t.Fatal("Known() = false, want true")
+ }
+}
+
+func TestModelContextResolverReportsUnknownWhenNoSourcesHaveContext(t *testing.T) {
+ resolver := NewModelContextResolver(ModelContextLookupFunc(func(ModelContextQuery) (int, bool, error) {
+ return 0, false, errors.New("provider metadata unavailable")
+ }))
+
+ got := resolver.Resolve(ModelContextQuery{
+ Provider: "unknown-provider",
+ Model: "unknown-model",
+ })
+
+ if got.ContextLength != 0 {
+ t.Fatalf("ContextLength = %d, want 0", got.ContextLength)
+ }
+ if got.Source != ModelContextSourceUnknown {
+ t.Fatalf("Source = %q, want %q", got.Source, ModelContextSourceUnknown)
+ }
+ if got.Known() {
+ t.Fatal("Known() = true, want false")
+ }
+}
diff --git a/internal/hermes/model_registry.go b/internal/hermes/model_registry.go
new file mode 100644
index 000000000..afb85c10f
--- /dev/null
+++ b/internal/hermes/model_registry.go
@@ -0,0 +1,319 @@
+package hermes
+
+type ModelFactStatus string
+
+const (
+ ModelFactKnown ModelFactStatus = "known"
+ ModelFactUnknown ModelFactStatus = "unknown"
+)
+
+type ModelCapabilityFlag string
+
+const (
+ ModelCapabilitySupported ModelCapabilityFlag = "supported"
+ ModelCapabilityUnsupported ModelCapabilityFlag = "unsupported"
+ ModelCapabilityUnknown ModelCapabilityFlag = "unknown"
+)
+
+type ModelPricingSource string
+
+const (
+ ModelPricingSourceNone ModelPricingSource = "none"
+ ModelPricingSourceOfficialDocsSnapshot ModelPricingSource = "official_docs_snapshot"
+ ModelPricingSourceModelsDevSnapshot ModelPricingSource = "models_dev_snapshot"
+)
+
+type ModelRegistrySource string
+
+const (
+ ModelRegistrySourceEmbedded ModelRegistrySource = "embedded"
+ ModelRegistrySourceTestdata ModelRegistrySource = "testdata"
+)
+
+type ModelRegistryFreshness string
+
+const (
+ ModelRegistryFreshnessCurrent ModelRegistryFreshness = "current"
+ ModelRegistryFreshnessStale ModelRegistryFreshness = "stale"
+)
+
+type ModelPricing struct {
+ Status ModelFactStatus
+ InputUSDPerMillion float64
+ OutputUSDPerMillion float64
+ CacheReadUSDPerMillion float64
+ CacheWriteUSDPerMillion float64
+ Source ModelPricingSource
+ Version string
+}
+
+func (p ModelPricing) Known() bool {
+ return p.Status == ModelFactKnown
+}
+
+type ModelCapabilityFlags struct {
+ Status ModelFactStatus
+ Tools ModelCapabilityFlag
+ Vision ModelCapabilityFlag
+ Reasoning ModelCapabilityFlag
+ PDF ModelCapabilityFlag
+ AudioInput ModelCapabilityFlag
+ StructuredOutput ModelCapabilityFlag
+ OpenWeights ModelCapabilityFlag
+}
+
+func (c ModelCapabilityFlags) Known() bool {
+ return c.Status == ModelFactKnown
+}
+
+type ModelRegistrySnapshot struct {
+ Source ModelRegistrySource
+ Freshness ModelRegistryFreshness
+ Version string
+ Reason string
+}
+
+type ModelRegistryQuery struct {
+ Provider string
+ Model string
+}
+
+type ModelRegistryKey struct {
+ Provider string
+ Model string
+}
+
+type ModelRegistryEntry struct {
+ Provider string
+ Model string
+ ProviderFamily string
+ ModelFamily string
+ RawContextWindow int
+ MaxOutputTokens int
+ Pricing ModelPricing
+ Capabilities ModelCapabilityFlags
+}
+
+type ModelMetadataResult struct {
+ Found bool
+ ModelRegistryEntry
+ Registry ModelRegistrySnapshot
+}
+
+type ModelRegistry struct {
+ snapshot ModelRegistrySnapshot
+ entries map[ModelRegistryKey]ModelRegistryEntry
+}
+
+func NewStaticModelRegistry(snapshot ModelRegistrySnapshot, entries []ModelRegistryEntry) ModelRegistry {
+ registry := ModelRegistry{
+ snapshot: normalizeModelRegistrySnapshot(snapshot),
+ entries: make(map[ModelRegistryKey]ModelRegistryEntry, len(entries)),
+ }
+ for _, entry := range entries {
+ entry = normalizeModelRegistryEntry(entry)
+ key := ModelRegistryKey{Provider: entry.Provider, Model: entry.Model}
+ if key.Provider == "" || key.Model == "" {
+ continue
+ }
+ registry.entries[key] = entry
+ }
+ return registry
+}
+
+func DefaultModelRegistry() ModelRegistry {
+ return defaultModelRegistry
+}
+
+func LookupModelMetadata(query ModelRegistryQuery) ModelMetadataResult {
+ return DefaultModelRegistry().Lookup(query)
+}
+
+func (r ModelRegistry) Lookup(query ModelRegistryQuery) ModelMetadataResult {
+ result := ModelMetadataResult{
+ Registry: r.Snapshot(),
+ Found: false,
+ ModelRegistryEntry: ModelRegistryEntry{
+ Pricing: unknownModelPricing(),
+ Capabilities: unknownModelCapabilities(),
+ },
+ }
+ key := ModelRegistryKey{
+ Provider: normalizeModelContextProvider(query.Provider),
+ Model: normalizeModelContextText(query.Model),
+ }
+ if key.Provider == "" || key.Model == "" {
+ return result
+ }
+ entry, ok := r.entries[key]
+ if !ok {
+ return result
+ }
+ result.Found = true
+ result.ModelRegistryEntry = entry
+ return result
+}
+
+func (r ModelRegistry) Snapshot() ModelRegistrySnapshot {
+ return normalizeModelRegistrySnapshot(r.snapshot)
+}
+
+func normalizeModelRegistrySnapshot(snapshot ModelRegistrySnapshot) ModelRegistrySnapshot {
+ if snapshot.Source == "" {
+ snapshot.Source = ModelRegistrySourceEmbedded
+ }
+ if snapshot.Freshness == "" {
+ snapshot.Freshness = ModelRegistryFreshnessCurrent
+ }
+ return snapshot
+}
+
+func normalizeModelRegistryEntry(entry ModelRegistryEntry) ModelRegistryEntry {
+ entry.Provider = normalizeModelContextProvider(entry.Provider)
+ entry.Model = normalizeModelContextText(entry.Model)
+ if entry.ProviderFamily == "" {
+ entry.ProviderFamily = entry.Provider
+ }
+ entry.Pricing = normalizeModelPricing(entry.Pricing)
+ entry.Capabilities = normalizeModelCapabilities(entry.Capabilities)
+ return entry
+}
+
+func normalizeModelPricing(pricing ModelPricing) ModelPricing {
+ if pricing.Status == "" {
+ pricing.Status = ModelFactUnknown
+ }
+ if pricing.Source == "" {
+ pricing.Source = ModelPricingSourceNone
+ }
+ return pricing
+}
+
+func normalizeModelCapabilities(capabilities ModelCapabilityFlags) ModelCapabilityFlags {
+ if capabilities.Status == "" {
+ capabilities.Status = ModelFactUnknown
+ }
+ capabilities.Tools = normalizeModelCapabilityFlag(capabilities.Tools)
+ capabilities.Vision = normalizeModelCapabilityFlag(capabilities.Vision)
+ capabilities.Reasoning = normalizeModelCapabilityFlag(capabilities.Reasoning)
+ capabilities.PDF = normalizeModelCapabilityFlag(capabilities.PDF)
+ capabilities.AudioInput = normalizeModelCapabilityFlag(capabilities.AudioInput)
+ capabilities.StructuredOutput = normalizeModelCapabilityFlag(capabilities.StructuredOutput)
+ capabilities.OpenWeights = normalizeModelCapabilityFlag(capabilities.OpenWeights)
+ return capabilities
+}
+
+func normalizeModelCapabilityFlag(flag ModelCapabilityFlag) ModelCapabilityFlag {
+ if flag == "" {
+ return ModelCapabilityUnknown
+ }
+ return flag
+}
+
+func unknownModelPricing() ModelPricing {
+ return normalizeModelPricing(ModelPricing{Status: ModelFactUnknown})
+}
+
+func unknownModelCapabilities() ModelCapabilityFlags {
+ return normalizeModelCapabilities(ModelCapabilityFlags{Status: ModelFactUnknown})
+}
+
+func knownModelPricing(input, output, cacheRead, cacheWrite float64, source ModelPricingSource, version string) ModelPricing {
+ return ModelPricing{
+ Status: ModelFactKnown,
+ InputUSDPerMillion: input,
+ OutputUSDPerMillion: output,
+ CacheReadUSDPerMillion: cacheRead,
+ CacheWriteUSDPerMillion: cacheWrite,
+ Source: source,
+ Version: version,
+ }
+}
+
+func knownModelCapabilities(tools, vision, reasoning, pdf, audioInput, structuredOutput, openWeights ModelCapabilityFlag) ModelCapabilityFlags {
+ return normalizeModelCapabilities(ModelCapabilityFlags{
+ Status: ModelFactKnown,
+ Tools: tools,
+ Vision: vision,
+ Reasoning: reasoning,
+ PDF: pdf,
+ AudioInput: audioInput,
+ StructuredOutput: structuredOutput,
+ OpenWeights: openWeights,
+ })
+}
+
+var defaultModelRegistry = NewStaticModelRegistry(ModelRegistrySnapshot{
+ Source: ModelRegistrySourceEmbedded,
+ Freshness: ModelRegistryFreshnessCurrent,
+ Version: "models.dev-fixture-2026-04-25",
+}, []ModelRegistryEntry{
+ {
+ Provider: "openai",
+ Model: "gpt-4o-mini",
+ ProviderFamily: "openai",
+ ModelFamily: "gpt-4o",
+ RawContextWindow: 128_000,
+ MaxOutputTokens: 16_384,
+ Pricing: knownModelPricing(
+ 0.15,
+ 0.60,
+ 0.075,
+ 0,
+ ModelPricingSourceOfficialDocsSnapshot,
+ "openai-pricing-2026-03-16",
+ ),
+ Capabilities: knownModelCapabilities(
+ ModelCapabilitySupported,
+ ModelCapabilitySupported,
+ ModelCapabilityUnsupported,
+ ModelCapabilityUnsupported,
+ ModelCapabilityUnsupported,
+ ModelCapabilitySupported,
+ ModelCapabilityUnsupported,
+ ),
+ },
+ {
+ Provider: "anthropic",
+ Model: "claude-opus-4-20250514",
+ ProviderFamily: "anthropic",
+ ModelFamily: "claude-opus-4",
+ RawContextWindow: 200_000,
+ MaxOutputTokens: 32_000,
+ Pricing: knownModelPricing(
+ 15.00,
+ 75.00,
+ 1.50,
+ 18.75,
+ ModelPricingSourceOfficialDocsSnapshot,
+ "anthropic-prompt-caching-2026-03-16",
+ ),
+ Capabilities: knownModelCapabilities(
+ ModelCapabilitySupported,
+ ModelCapabilitySupported,
+ ModelCapabilitySupported,
+ ModelCapabilityUnsupported,
+ ModelCapabilityUnsupported,
+ ModelCapabilityUnsupported,
+ ModelCapabilityUnsupported,
+ ),
+ },
+ {
+ Provider: "openai-codex",
+ Model: "gpt-5.5",
+ ProviderFamily: "openai",
+ ModelFamily: "gpt-5",
+ RawContextWindow: 1_050_000,
+ MaxOutputTokens: 128_000,
+ Pricing: unknownModelPricing(),
+ Capabilities: knownModelCapabilities(
+ ModelCapabilitySupported,
+ ModelCapabilitySupported,
+ ModelCapabilitySupported,
+ ModelCapabilityUnsupported,
+ ModelCapabilityUnsupported,
+ ModelCapabilitySupported,
+ ModelCapabilityUnsupported,
+ ),
+ },
+})
diff --git a/internal/hermes/model_registry_test.go b/internal/hermes/model_registry_test.go
new file mode 100644
index 000000000..9a1ecabb7
--- /dev/null
+++ b/internal/hermes/model_registry_test.go
@@ -0,0 +1,146 @@
+package hermes
+
+import "testing"
+
+func TestDefaultModelRegistryExposesPricingCapabilitiesAndRawContext(t *testing.T) {
+ got := LookupModelMetadata(ModelRegistryQuery{
+ Provider: "openai",
+ Model: "gpt-4o-mini",
+ })
+
+ if !got.Found {
+ t.Fatal("Found = false, want true")
+ }
+ if got.ProviderFamily != "openai" {
+ t.Fatalf("ProviderFamily = %q, want openai", got.ProviderFamily)
+ }
+ if got.ModelFamily != "gpt-4o" {
+ t.Fatalf("ModelFamily = %q, want gpt-4o", got.ModelFamily)
+ }
+ if got.RawContextWindow != 128_000 {
+ t.Fatalf("RawContextWindow = %d, want 128000", got.RawContextWindow)
+ }
+ if got.MaxOutputTokens != 16_384 {
+ t.Fatalf("MaxOutputTokens = %d, want 16384", got.MaxOutputTokens)
+ }
+
+ if got.Pricing.Status != ModelFactKnown {
+ t.Fatalf("Pricing.Status = %q, want %q", got.Pricing.Status, ModelFactKnown)
+ }
+ if got.Pricing.InputUSDPerMillion != 0.15 {
+ t.Fatalf("Pricing.InputUSDPerMillion = %v, want 0.15", got.Pricing.InputUSDPerMillion)
+ }
+ if got.Pricing.OutputUSDPerMillion != 0.60 {
+ t.Fatalf("Pricing.OutputUSDPerMillion = %v, want 0.60", got.Pricing.OutputUSDPerMillion)
+ }
+ if got.Pricing.CacheReadUSDPerMillion != 0.075 {
+ t.Fatalf("Pricing.CacheReadUSDPerMillion = %v, want 0.075", got.Pricing.CacheReadUSDPerMillion)
+ }
+ if got.Pricing.Source != ModelPricingSourceOfficialDocsSnapshot {
+ t.Fatalf("Pricing.Source = %q, want %q", got.Pricing.Source, ModelPricingSourceOfficialDocsSnapshot)
+ }
+
+ if got.Capabilities.Status != ModelFactKnown {
+ t.Fatalf("Capabilities.Status = %q, want %q", got.Capabilities.Status, ModelFactKnown)
+ }
+ if got.Capabilities.Tools != ModelCapabilitySupported {
+ t.Fatalf("Capabilities.Tools = %q, want %q", got.Capabilities.Tools, ModelCapabilitySupported)
+ }
+ if got.Capabilities.Vision != ModelCapabilitySupported {
+ t.Fatalf("Capabilities.Vision = %q, want %q", got.Capabilities.Vision, ModelCapabilitySupported)
+ }
+ if got.Capabilities.Reasoning != ModelCapabilityUnsupported {
+ t.Fatalf("Capabilities.Reasoning = %q, want %q", got.Capabilities.Reasoning, ModelCapabilityUnsupported)
+ }
+ if got.Capabilities.StructuredOutput != ModelCapabilitySupported {
+ t.Fatalf("Capabilities.StructuredOutput = %q, want %q", got.Capabilities.StructuredOutput, ModelCapabilitySupported)
+ }
+ if got.Registry.Source != ModelRegistrySourceEmbedded {
+ t.Fatalf("Registry.Source = %q, want %q", got.Registry.Source, ModelRegistrySourceEmbedded)
+ }
+ if got.Registry.Freshness != ModelRegistryFreshnessCurrent {
+ t.Fatalf("Registry.Freshness = %q, want %q", got.Registry.Freshness, ModelRegistryFreshnessCurrent)
+ }
+}
+
+func TestModelRegistryKeepsMissingPricingAndCapabilitiesUnknown(t *testing.T) {
+ registry := NewStaticModelRegistry(ModelRegistrySnapshot{
+ Source: ModelRegistrySourceEmbedded,
+ Freshness: ModelRegistryFreshnessCurrent,
+ Version: "test-fixture",
+ }, []ModelRegistryEntry{
+ {
+ Provider: "fixture-provider",
+ Model: "bare-model",
+ ProviderFamily: "fixture",
+ ModelFamily: "bare",
+ RawContextWindow: 64_000,
+ MaxOutputTokens: 4_096,
+ },
+ })
+
+ got := registry.Lookup(ModelRegistryQuery{
+ Provider: "fixture-provider",
+ Model: "bare-model",
+ })
+
+ if !got.Found {
+ t.Fatal("Found = false, want true")
+ }
+ if got.Pricing.Status != ModelFactUnknown {
+ t.Fatalf("Pricing.Status = %q, want %q", got.Pricing.Status, ModelFactUnknown)
+ }
+ if got.Pricing.Known() {
+ t.Fatal("Pricing.Known() = true, want false")
+ }
+ if got.Capabilities.Status != ModelFactUnknown {
+ t.Fatalf("Capabilities.Status = %q, want %q", got.Capabilities.Status, ModelFactUnknown)
+ }
+ if got.Capabilities.Known() {
+ t.Fatal("Capabilities.Known() = true, want false")
+ }
+ if got.Capabilities.Tools != ModelCapabilityUnknown {
+ t.Fatalf("Capabilities.Tools = %q, want %q", got.Capabilities.Tools, ModelCapabilityUnknown)
+ }
+ if got.Capabilities.Vision != ModelCapabilityUnknown {
+ t.Fatalf("Capabilities.Vision = %q, want %q", got.Capabilities.Vision, ModelCapabilityUnknown)
+ }
+ if got.Capabilities.Reasoning != ModelCapabilityUnknown {
+ t.Fatalf("Capabilities.Reasoning = %q, want %q", got.Capabilities.Reasoning, ModelCapabilityUnknown)
+ }
+}
+
+func TestModelRegistryReportsUnknownModelAndStaleEmbeddedSnapshot(t *testing.T) {
+ registry := NewStaticModelRegistry(ModelRegistrySnapshot{
+ Source: ModelRegistrySourceEmbedded,
+ Freshness: ModelRegistryFreshnessStale,
+ Version: "models.dev-2026-02-01",
+ Reason: "embedded registry snapshot is older than the operator freshness policy",
+ }, []ModelRegistryEntry{
+ {
+ Provider: "openai",
+ Model: "fixture-model",
+ },
+ })
+
+ got := registry.Lookup(ModelRegistryQuery{
+ Provider: "openai",
+ Model: "not-in-fixture",
+ })
+
+ if got.Found {
+ t.Fatal("Found = true, want false")
+ }
+ if got.Pricing.Status != ModelFactUnknown {
+ t.Fatalf("Pricing.Status = %q, want %q", got.Pricing.Status, ModelFactUnknown)
+ }
+ if got.Capabilities.Status != ModelFactUnknown {
+ t.Fatalf("Capabilities.Status = %q, want %q", got.Capabilities.Status, ModelFactUnknown)
+ }
+ if got.Registry.Freshness != ModelRegistryFreshnessStale {
+ t.Fatalf("Registry.Freshness = %q, want %q", got.Registry.Freshness, ModelRegistryFreshnessStale)
+ }
+ if got.Registry.Reason == "" {
+ t.Fatal("Registry.Reason is empty, want stale-data reason")
+ }
+}
diff --git a/internal/hermes/provider_status_test.go b/internal/hermes/provider_status_test.go
new file mode 100644
index 000000000..1ddedc5d3
--- /dev/null
+++ b/internal/hermes/provider_status_test.go
@@ -0,0 +1,132 @@
+package hermes
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestProviderStatusOfReportsCacheRateAndBudgetCapabilities(t *testing.T) {
+ tests := []struct {
+ name string
+ client Client
+ wantProvider string
+ wantRuntime string
+ wantPromptCache bool
+ wantPromptCacheCause string
+ }{
+ {
+ name: "openai compatible",
+ client: NewHTTPClient("http://example.test", ""),
+ wantProvider: "openai_compatible",
+ wantRuntime: "chat_completions",
+ wantPromptCache: false,
+ wantPromptCacheCause: "cache_control stripped",
+ },
+ {
+ name: "anthropic",
+ client: NewAnthropicClient("http://example.test", "sk-ant-api-test"),
+ wantProvider: "anthropic",
+ wantRuntime: "anthropic_messages",
+ wantPromptCache: true,
+ wantPromptCacheCause: "cache_control supported",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := ProviderStatusOf(tt.client)
+ if got.Provider != tt.wantProvider {
+ t.Fatalf("Provider = %q, want %q", got.Provider, tt.wantProvider)
+ }
+ if got.Runtime != tt.wantRuntime {
+ t.Fatalf("Runtime = %q, want %q", got.Runtime, tt.wantRuntime)
+ }
+ if got.Capabilities.PromptCache.Available != tt.wantPromptCache {
+ t.Fatalf("PromptCache.Available = %v, want %v", got.Capabilities.PromptCache.Available, tt.wantPromptCache)
+ }
+ if !strings.Contains(got.Capabilities.PromptCache.Reason, tt.wantPromptCacheCause) {
+ t.Fatalf("PromptCache.Reason = %q, want it to mention %q", got.Capabilities.PromptCache.Reason, tt.wantPromptCacheCause)
+ }
+ assertUnavailableCapability(t, "RateGuard", got.Capabilities.RateGuard)
+ assertUnavailableCapability(t, "BudgetTelemetry", got.Capabilities.BudgetTelemetry)
+ })
+ }
+}
+
+func TestCodexProviderStatusReportsUnavailableUntilAuthWiring(t *testing.T) {
+ got := codexResponsesProviderStatus()
+ if got.Provider != "openai-codex" {
+ t.Fatalf("Provider = %q, want openai-codex", got.Provider)
+ }
+ if got.Runtime != "responses_unavailable" {
+ t.Fatalf("Runtime = %q, want responses_unavailable", got.Runtime)
+ }
+ if got.Capabilities.PromptCache.Available {
+ t.Fatal("PromptCache.Available = true, want unavailable until Codex auth wiring lands")
+ }
+ if !strings.Contains(got.Capabilities.PromptCache.Reason, "auth wiring not configured") {
+ t.Fatalf("PromptCache.Reason = %q, want auth wiring degradation", got.Capabilities.PromptCache.Reason)
+ }
+ assertUnavailableCapability(t, "RateGuard", got.Capabilities.RateGuard)
+ assertUnavailableCapability(t, "BudgetTelemetry", got.Capabilities.BudgetTelemetry)
+}
+
+func TestOpenAICompatibleCacheControlUnsupportedIsVisibleAndStripped(t *testing.T) {
+ var captured []byte
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != defaultChatCompletionsPath {
+ t.Fatalf("path = %q, want %q", r.URL.Path, defaultChatCompletionsPath)
+ }
+ raw, err := io.ReadAll(r.Body)
+ if err != nil {
+ t.Fatalf("read request body: %v", err)
+ }
+ captured = raw
+ w.Header().Set("Content-Type", "text/event-stream")
+ fmt.Fprint(w, "data: {\"choices\":[{\"finish_reason\":\"stop\"}]}\n\n")
+ fmt.Fprint(w, "data: [DONE]\n\n")
+ }))
+ defer srv.Close()
+
+ client := NewHTTPClient(srv.URL, "")
+ status := ProviderStatusOf(client)
+ if status.Capabilities.PromptCache.Available {
+ t.Fatal("PromptCache.Available = true, want unsupported for OpenAI-compatible adapter")
+ }
+ if !strings.Contains(status.Capabilities.PromptCache.Reason, "cache_control stripped") {
+ t.Fatalf("PromptCache.Reason = %q, want visible stripped-cache path", status.Capabilities.PromptCache.Reason)
+ }
+
+ stream, err := client.OpenStream(context.Background(), ChatRequest{
+ Model: "fixture-model",
+ Stream: true,
+ Messages: []Message{
+ {Role: "system", Content: "stable system", CacheControl: &CacheControl{Type: "ephemeral"}},
+ {Role: "user", Content: "hello", CacheControl: &CacheControl{Type: "ephemeral"}},
+ },
+ })
+ if err != nil {
+ t.Fatalf("OpenStream() error = %v", err)
+ }
+ defer stream.Close()
+
+ if bytes.Contains(captured, []byte("cache_control")) {
+ t.Fatalf("request body contains unsupported cache_control metadata: %s", captured)
+ }
+}
+
+func assertUnavailableCapability(t *testing.T, name string, got CapabilityStatus) {
+ t.Helper()
+ if got.Available {
+ t.Fatalf("%s.Available = true, want unavailable until implementation lands", name)
+ }
+ if got.Reason == "" {
+ t.Fatalf("%s.Reason is empty, want visible degradation reason", name)
+ }
+}
diff --git a/internal/hermes/reasoning_content_echo_test.go b/internal/hermes/reasoning_content_echo_test.go
new file mode 100644
index 000000000..807a05d21
--- /dev/null
+++ b/internal/hermes/reasoning_content_echo_test.go
@@ -0,0 +1,348 @@
+package hermes
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+)
+
+func TestReasoningContentEchoDetectsThinkingProviders(t *testing.T) {
+ tests := []struct {
+ name string
+ provider string
+ model string
+ baseURL string
+ want bool
+ }{
+ {
+ name: "deepseek provider name",
+ provider: "DeepSeek",
+ model: "fixture-model",
+ baseURL: "https://proxy.example.test",
+ want: true,
+ },
+ {
+ name: "deepseek model substring",
+ provider: "custom",
+ model: "accounts/fireworks/models/deepseek-v4-flash",
+ baseURL: "https://proxy.example.test",
+ want: true,
+ },
+ {
+ name: "deepseek api host",
+ provider: "custom",
+ model: "aliased-thinking-model",
+ baseURL: "https://api.deepseek.com/v1",
+ want: true,
+ },
+ {
+ name: "kimi provider name",
+ provider: "kimi-coding",
+ model: "kimi-k2",
+ baseURL: "https://proxy.example.test",
+ want: true,
+ },
+ {
+ name: "moonshot api host",
+ provider: "custom",
+ model: "kimi-k2",
+ baseURL: "https://api.moonshot.ai/v1",
+ want: true,
+ },
+ {
+ name: "moonshot model routed through openrouter is not direct moonshot",
+ provider: "openrouter",
+ model: "moonshotai/kimi-k2",
+ baseURL: "https://openrouter.ai/api/v1",
+ want: false,
+ },
+ {
+ name: "plain openai-compatible provider",
+ provider: "openrouter",
+ model: "anthropic/claude-sonnet-4.6",
+ baseURL: "https://openrouter.ai/api/v1",
+ want: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := openAICompatibleRequiresReasoningEcho(tt.provider, tt.model, tt.baseURL)
+ if got != tt.want {
+ t.Fatalf("openAICompatibleRequiresReasoningEcho(%q, %q, %q) = %v, want %v", tt.provider, tt.model, tt.baseURL, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestReasoningContentEchoPadsAssistantToolCallReplayForThinkingProviders(t *testing.T) {
+ tests := []struct {
+ name string
+ provider string
+ model string
+ baseURL string
+ }{
+ {
+ name: "deepseek provider name",
+ provider: "deepseek",
+ model: "fixture-model",
+ baseURL: "https://proxy.example.test",
+ },
+ {
+ name: "deepseek model substring",
+ provider: "custom",
+ model: "deepseek-v4-pro",
+ baseURL: "https://proxy.example.test",
+ },
+ {
+ name: "deepseek host",
+ provider: "custom",
+ model: "aliased-thinking-model",
+ baseURL: "https://api.deepseek.com",
+ },
+ {
+ name: "kimi provider name",
+ provider: "kimi-coding",
+ model: "kimi-k2",
+ baseURL: "https://proxy.example.test",
+ },
+ {
+ name: "moonshot host",
+ provider: "custom",
+ model: "kimi-k2",
+ baseURL: "https://api.moonshot.ai",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ messages := captureOpenAICompatibleMessages(t, &httpClient{
+ baseURL: tt.baseURL,
+ provider: tt.provider,
+ http: captureRequestHTTPClient(t),
+ }, ChatRequest{
+ Model: tt.model,
+ Stream: true,
+ Messages: []Message{
+ {Role: "user", Content: "run terminal"},
+ {
+ Role: "assistant",
+ Content: "Calling terminal.",
+ ToolCalls: []ToolCall{{
+ ID: "call_terminal",
+ Name: "terminal",
+ Arguments: json.RawMessage(`{"cmd":"pwd"}`),
+ }},
+ },
+ {Role: "assistant", Content: "No tool call here."},
+ },
+ })
+
+ toolAssistant := messages[1]
+ if got, ok := toolAssistant["reasoning_content"].(string); !ok || got != "" {
+ t.Fatalf("assistant tool-call reasoning_content = %v (present=%v), want empty string", toolAssistant["reasoning_content"], ok)
+ }
+ if got := toolAssistant["content"]; got != "Calling terminal." {
+ t.Fatalf("assistant content = %v, want ordinary content preserved", got)
+ }
+ if _, ok := messages[2]["reasoning_content"]; ok {
+ t.Fatalf("non-tool assistant got reasoning_content padding: %+v", messages[2])
+ }
+ })
+ }
+}
+
+func TestReasoningContentEchoPreservesExplicitReasoningFields(t *testing.T) {
+ tests := []struct {
+ name string
+ message Message
+ want string
+ }{
+ {
+ name: "explicit reasoning_content",
+ message: Message{
+ Role: "assistant",
+ Content: "Calling terminal.",
+ ReasoningContent: stringPtr("vendor trace"),
+ ToolCalls: []ToolCall{{
+ ID: "call_terminal",
+ Name: "terminal",
+ Arguments: json.RawMessage(`{"cmd":"pwd"}`),
+ }},
+ },
+ want: "vendor trace",
+ },
+ {
+ name: "normalized reasoning",
+ message: Message{
+ Role: "assistant",
+ Content: "Calling terminal.",
+ Reasoning: &ReasoningContent{
+ Text: "normalized trace",
+ },
+ ToolCalls: []ToolCall{{
+ ID: "call_terminal",
+ Name: "terminal",
+ Arguments: json.RawMessage(`{"cmd":"pwd"}`),
+ }},
+ },
+ want: "normalized trace",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ messages := captureOpenAICompatibleMessages(t, &httpClient{
+ baseURL: "https://proxy.example.test",
+ provider: "deepseek",
+ http: captureRequestHTTPClient(t),
+ }, ChatRequest{
+ Model: "deepseek-v4-flash",
+ Stream: true,
+ Messages: []Message{{Role: "user", Content: "run terminal"}, tt.message},
+ })
+
+ assistant := messages[1]
+ if got := assistant["reasoning_content"]; got != tt.want {
+ t.Fatalf("reasoning_content = %v, want %q", got, tt.want)
+ }
+ if got := assistant["content"]; got != "Calling terminal." {
+ t.Fatalf("assistant content = %v, want ordinary content preserved", got)
+ }
+ if _, ok := assistant["reasoning"]; ok {
+ t.Fatalf("assistant request leaked storage-only reasoning field: %+v", assistant)
+ }
+ })
+ }
+}
+
+func TestReasoningContentEchoLeavesNonThinkingProvidersUntouched(t *testing.T) {
+ messages := captureOpenAICompatibleMessages(t, &httpClient{
+ baseURL: "https://openrouter.ai",
+ provider: "openrouter",
+ http: captureRequestHTTPClient(t),
+ }, ChatRequest{
+ Model: "anthropic/claude-sonnet-4.6",
+ Stream: true,
+ Messages: []Message{
+ {Role: "user", Content: "run terminal"},
+ {
+ Role: "assistant",
+ Content: "Calling terminal.",
+ ReasoningContent: stringPtr("stored trace for another provider"),
+ ToolCalls: []ToolCall{{
+ ID: "call_terminal",
+ Name: "terminal",
+ Arguments: json.RawMessage(`{"cmd":"pwd"}`),
+ }},
+ },
+ },
+ })
+
+ if _, ok := messages[1]["reasoning_content"]; ok {
+ t.Fatalf("non-thinking provider got reasoning_content padding: %+v", messages[1])
+ }
+ if _, ok := messages[1]["reasoning"]; ok {
+ t.Fatalf("non-thinking provider got storage-only reasoning field: %+v", messages[1])
+ }
+}
+
+func TestReasoningContentEchoProviderStatusExplainsPaddingAndRepair(t *testing.T) {
+ thinking := ProviderStatusOf(&httpClient{
+ baseURL: "https://api.deepseek.com",
+ provider: "custom",
+ http: http.DefaultClient,
+ })
+ if !thinking.Capabilities.ReasoningEcho.Available {
+ t.Fatalf("ReasoningEcho.Available = false, want true for DeepSeek host")
+ }
+ reason := thinking.Capabilities.ReasoningEcho.Reason
+ for _, want := range []string{"reasoning_content", "assistant tool-call", "repaired"} {
+ if !strings.Contains(reason, want) {
+ t.Fatalf("ReasoningEcho.Reason = %q, want it to mention %q", reason, want)
+ }
+ }
+
+ generic := ProviderStatusOf(NewHTTPClient("https://openrouter.ai", ""))
+ if generic.Capabilities.ReasoningEcho.Available {
+ t.Fatalf("generic ReasoningEcho.Available = true, want false")
+ }
+ if generic.Capabilities.ReasoningEcho.Reason == "" {
+ t.Fatal("generic ReasoningEcho.Reason is empty, want visible status")
+ }
+}
+
+func captureOpenAICompatibleMessages(t *testing.T, client Client, req ChatRequest) []map[string]any {
+ t.Helper()
+ stream, err := client.OpenStream(context.Background(), req)
+ if err != nil {
+ t.Fatalf("OpenStream() error = %v", err)
+ }
+ defer stream.Close()
+
+ capture, ok := client.(*httpClient)
+ if !ok {
+ t.Fatalf("client type = %T, want *httpClient", client)
+ }
+ raw := capturedRequestBody(t, capture.http)
+ var body struct {
+ Messages []map[string]any `json:"messages"`
+ }
+ if err := json.Unmarshal(raw, &body); err != nil {
+ t.Fatalf("decode request body: %v\n%s", err, raw)
+ }
+ return body.Messages
+}
+
+func captureRequestHTTPClient(t *testing.T) *http.Client {
+ t.Helper()
+ transport := &captureRoundTripper{}
+ transport.roundTrip = func(req *http.Request) (*http.Response, error) {
+ raw, err := io.ReadAll(req.Body)
+ if err != nil {
+ t.Fatalf("read request body: %v", err)
+ }
+ transport.captured = raw
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"text/event-stream"}},
+ Body: io.NopCloser(strings.NewReader("data: {\"choices\":[{\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n")),
+ Request: req,
+ }, nil
+ }
+ return &http.Client{Transport: transport}
+}
+
+func capturedRequestBody(t *testing.T, client *http.Client) []byte {
+ t.Helper()
+ transport, ok := client.Transport.(capturingTransport)
+ if !ok {
+ t.Fatalf("transport type = %T, want capturingTransport", client.Transport)
+ }
+ return transport.CapturedBody()
+}
+
+type capturingTransport interface {
+ http.RoundTripper
+ CapturedBody() []byte
+}
+
+type captureRoundTripper struct {
+ roundTrip func(*http.Request) (*http.Response, error)
+ captured []byte
+}
+
+func (c *captureRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
+ return c.roundTrip(req)
+}
+
+func (c *captureRoundTripper) CapturedBody() []byte {
+ return c.captured
+}
+
+func stringPtr(value string) *string {
+ return &value
+}
diff --git a/internal/hermes/status.go b/internal/hermes/status.go
new file mode 100644
index 000000000..58bee9308
--- /dev/null
+++ b/internal/hermes/status.go
@@ -0,0 +1,131 @@
+package hermes
+
+// CapabilityStatus reports whether a provider-side resilience capability can
+// be relied on by routing and status callers.
+type CapabilityStatus struct {
+ Available bool
+ Reason string
+}
+
+// ProviderCapabilities are intentionally small: Phase 4.H only needs the
+// provider to make cache/rate/budget availability visible before routing
+// decisions depend on those surfaces.
+type ProviderCapabilities struct {
+ PromptCache CapabilityStatus
+ ReasoningEcho CapabilityStatus
+ RateGuard CapabilityStatus
+ BudgetTelemetry CapabilityStatus
+}
+
+// ProviderStatus is the provider-owned status snapshot the kernel can attach
+// to render frames without knowing adapter-specific behavior.
+type ProviderStatus struct {
+ Provider string
+ Runtime string
+ Capabilities ProviderCapabilities
+}
+
+type providerStatusReporter interface {
+ ProviderStatus() ProviderStatus
+}
+
+// ProviderStatusOf returns a normalized provider status snapshot. Adapters
+// that do not implement ProviderStatus are visible as unknown/degraded rather
+// than silently assumed to support optional resilience features.
+func ProviderStatusOf(client Client) ProviderStatus {
+ reporter, ok := client.(providerStatusReporter)
+ if !ok || reporter == nil {
+ return unknownProviderStatus()
+ }
+ return normalizeProviderStatus(reporter.ProviderStatus())
+}
+
+func openAICompatibleProviderStatus(provider, baseURL string) ProviderStatus {
+ providerName := provider
+ if providerName == "" {
+ providerName = "openai_compatible"
+ }
+ return ProviderStatus{
+ Provider: providerName,
+ Runtime: "chat_completions",
+ Capabilities: ProviderCapabilities{
+ PromptCache: unavailableCapability("cache_control stripped by openai_compatible request mapping"),
+ ReasoningEcho: openAICompatibleReasoningEchoStatus(provider, baseURL),
+ RateGuard: unavailableCapability("provider rate guard not implemented"),
+ BudgetTelemetry: unavailableCapability("budget telemetry not implemented"),
+ },
+ }
+}
+
+func anthropicProviderStatus() ProviderStatus {
+ return ProviderStatus{
+ Provider: "anthropic",
+ Runtime: "anthropic_messages",
+ Capabilities: ProviderCapabilities{
+ PromptCache: CapabilityStatus{Available: true, Reason: "cache_control supported by anthropic messages content blocks"},
+ ReasoningEcho: unavailableCapability("reasoning_content echo padding is not required by anthropic messages"),
+ RateGuard: unavailableCapability("provider rate guard not implemented"),
+ BudgetTelemetry: unavailableCapability("budget telemetry not implemented"),
+ },
+ }
+}
+
+func codexResponsesProviderStatus() ProviderStatus {
+ return ProviderStatus{
+ Provider: "openai-codex",
+ Runtime: "responses_unavailable",
+ Capabilities: ProviderCapabilities{
+ PromptCache: unavailableCapability("Codex Responses auth wiring not configured"),
+ RateGuard: unavailableCapability("Codex provider rate guard not implemented"),
+ BudgetTelemetry: unavailableCapability("Codex budget telemetry not implemented"),
+ },
+ }
+}
+
+func unknownProviderStatus() ProviderStatus {
+ return ProviderStatus{
+ Provider: "unknown",
+ Runtime: "unknown",
+ Capabilities: ProviderCapabilities{
+ PromptCache: unavailableCapability("provider status unavailable"),
+ ReasoningEcho: unavailableCapability("provider status unavailable"),
+ RateGuard: unavailableCapability("provider status unavailable"),
+ BudgetTelemetry: unavailableCapability("provider status unavailable"),
+ },
+ }
+}
+
+func normalizeProviderStatus(status ProviderStatus) ProviderStatus {
+ if status.Provider == "" {
+ status.Provider = "unknown"
+ }
+ if status.Runtime == "" {
+ status.Runtime = "unknown"
+ }
+ status.Capabilities.PromptCache = normalizeCapability(status.Capabilities.PromptCache, "prompt cache status unavailable")
+ status.Capabilities.ReasoningEcho = normalizeCapability(status.Capabilities.ReasoningEcho, "reasoning echo status unavailable")
+ status.Capabilities.RateGuard = normalizeCapability(status.Capabilities.RateGuard, "provider rate guard status unavailable")
+ status.Capabilities.BudgetTelemetry = normalizeCapability(status.Capabilities.BudgetTelemetry, "budget telemetry status unavailable")
+ return status
+}
+
+func normalizeCapability(status CapabilityStatus, fallback string) CapabilityStatus {
+ if status.Reason == "" {
+ status.Reason = fallback
+ }
+ return status
+}
+
+func unavailableCapability(reason string) CapabilityStatus {
+ return CapabilityStatus{Available: false, Reason: reason}
+}
+
+func openAICompatibleReasoningEchoStatus(provider, baseURL string) CapabilityStatus {
+ if openAICompatibleRequiresReasoningEcho(provider, "", baseURL) {
+ return CapabilityStatus{
+ Available: true,
+ Reason: "thinking-mode provider requires reasoning_content on assistant tool-call replay; stored transcripts without reasoning are repaired with empty reasoning_content padding",
+ }
+ }
+ return unavailableCapability("reasoning_content echo padding is not required for this openai-compatible provider")
+}
diff --git a/internal/hermes/stream.go b/internal/hermes/stream.go
index 3f2ec67a0..bc88a063f 100644
--- a/internal/hermes/stream.go
+++ b/internal/hermes/stream.go
@@ -23,6 +23,7 @@ type chatStream struct {
// Pending tool-call accumulator, keyed by upstream index field.
// Populated across partial tool_calls deltas; flushed on finish_reason=="tool_calls".
pendingCalls map[int]*pendingToolCall
+ tools []ToolDescriptor
}
type pendingToolCall struct {
@@ -31,12 +32,13 @@ type pendingToolCall struct {
arguments strings.Builder
}
-func newChatStream(body io.ReadCloser, sessionID string) *chatStream {
+func newChatStream(body io.ReadCloser, sessionID string, tools []ToolDescriptor) *chatStream {
return &chatStream{
body: body,
sse: newSSEReader(body),
sessionID: sessionID,
pendingCalls: make(map[int]*pendingToolCall),
+ tools: SanitizeToolDescriptors(tools),
}
}
@@ -108,7 +110,7 @@ func (s *chatStream) Recv(ctx context.Context) (Event, error) {
f, err := s.sse.Next(ctx)
if err != nil {
if err == io.EOF && len(s.pendingCalls) > 0 {
- return s.flushPendingCallsOnEOF(), nil
+ return s.flushPendingCallsOnEOF()
}
return Event{}, err
}
@@ -159,8 +161,12 @@ func (s *chatStream) Recv(ctx context.Context) (Event, error) {
ev.TokensOut = chunk.Usage.CompletionTokens
}
if c.FinishReason == "tool_calls" && len(s.pendingCalls) > 0 {
- ev.ToolCalls = flushPending(s.pendingCalls)
+ toolCalls, err := RepairToolCalls(flushPending(s.pendingCalls), s.tools)
s.pendingCalls = make(map[int]*pendingToolCall) // reset for possible reuse
+ if err != nil {
+ return Event{}, err
+ }
+ ev.ToolCalls = toolCalls
}
events = append(events, ev)
}
@@ -175,14 +181,18 @@ func (s *chatStream) Recv(ctx context.Context) (Event, error) {
}
}
-func (s *chatStream) flushPendingCallsOnEOF() Event {
+func (s *chatStream) flushPendingCallsOnEOF() (Event, error) {
+ toolCalls, err := RepairToolCalls(flushPending(s.pendingCalls), s.tools)
+ s.pendingCalls = make(map[int]*pendingToolCall)
+ if err != nil {
+ return Event{}, err
+ }
ev := Event{
Kind: EventDone,
FinishReason: "tool_calls",
- ToolCalls: flushPending(s.pendingCalls),
+ ToolCalls: toolCalls,
}
- s.pendingCalls = make(map[int]*pendingToolCall)
- return ev
+ return ev, nil
}
// flushPending converts the accumulator map into a sorted, finalised ToolCall slice.
diff --git a/internal/hermes/stream_tools_test.go b/internal/hermes/stream_tools_test.go
index a66e5f21d..229e03a18 100644
--- a/internal/hermes/stream_tools_test.go
+++ b/internal/hermes/stream_tools_test.go
@@ -3,6 +3,7 @@ package hermes
import (
"bufio"
"context"
+ "encoding/json"
"fmt"
"io"
"net/http"
@@ -37,6 +38,11 @@ func TestStream_ToolCallDeltasAccumulate(t *testing.T) {
s, err := c.OpenStream(context.Background(), ChatRequest{
Model: "x",
Messages: []Message{{Role: "user", Content: "echo hi"}},
+ Tools: []ToolDescriptor{{
+ Name: "echo",
+ Description: "echo text",
+ Schema: json.RawMessage(`{"type":"object","properties":{"text":{"type":"string"}},"required":["text"]}`),
+ }},
})
if err != nil {
t.Fatal(err)
diff --git a/internal/hermes/testdata/bedrock_converse/request_body.golden.json b/internal/hermes/testdata/bedrock_converse/request_body.golden.json
new file mode 100644
index 000000000..aadb97b14
--- /dev/null
+++ b/internal/hermes/testdata/bedrock_converse/request_body.golden.json
@@ -0,0 +1,122 @@
+{
+ "messages": [
+ {
+ "role": "user",
+ "content": [
+ {
+ "text": "look up weather"
+ }
+ ]
+ },
+ {
+ "role": "assistant",
+ "content": [
+ {
+ "text": "Checking the weather."
+ },
+ {
+ "reasoningContent": {
+ "reasoningText": {
+ "text": "Need current weather.",
+ "signature": "sig-bedrock"
+ }
+ }
+ },
+ {
+ "toolUse": {
+ "toolUseId": "toolu_weather",
+ "name": "get_weather",
+ "input": {
+ "location": "Monterrey",
+ "unit": "f"
+ }
+ }
+ }
+ ]
+ },
+ {
+ "role": "user",
+ "content": [
+ {
+ "toolResult": {
+ "toolUseId": "toolu_weather",
+ "content": [
+ {
+ "text": "{\"temperature\":\"72F\",\"condition\":\"sunny\"}"
+ }
+ ]
+ }
+ },
+ {
+ "cachePoint": {
+ "type": "default"
+ }
+ }
+ ]
+ },
+ {
+ "role": "assistant",
+ "content": [
+ {
+ "text": " "
+ }
+ ]
+ },
+ {
+ "role": "user",
+ "content": [
+ {
+ "text": " "
+ }
+ ]
+ }
+ ],
+ "system": [
+ {
+ "text": "Follow ops policy."
+ },
+ {
+ "cachePoint": {
+ "type": "default",
+ "ttl": "1h"
+ }
+ }
+ ],
+ "inferenceConfig": {
+ "maxTokens": 2048,
+ "temperature": 0.35
+ },
+ "toolConfig": {
+ "tools": [
+ {
+ "toolSpec": {
+ "name": "get_weather",
+ "description": "Returns current weather.",
+ "inputSchema": {
+ "json": {
+ "type": "object",
+ "properties": {
+ "location": {
+ "type": "string",
+ "description": "City name"
+ },
+ "unit": {
+ "type": "string",
+ "enum": [
+ "c",
+ "f"
+ ]
+ }
+ },
+ "required": [
+ "location",
+ "unit"
+ ],
+ "additionalProperties": false
+ }
+ }
+ }
+ }
+ ]
+ }
+}
diff --git a/internal/hermes/testdata/context_status/disabled_pressure_unknown_tool.json b/internal/hermes/testdata/context_status/disabled_pressure_unknown_tool.json
new file mode 100644
index 000000000..9997f6e61
--- /dev/null
+++ b/internal/hermes/testdata/context_status/disabled_pressure_unknown_tool.json
@@ -0,0 +1,42 @@
+{
+ "engine": "disabled",
+ "model": "fixture-model",
+ "context_length": 8000,
+ "threshold_tokens": 6000,
+ "threshold_percent": 0.75,
+ "last_prompt_tokens": 5800,
+ "last_completion_tokens": 120,
+ "last_total_tokens": 5920,
+ "usage_percent": 72.5,
+ "compression_count": 0,
+ "budget": {
+ "state": "pressure",
+ "remaining_tokens": 200,
+ "pressure": true
+ },
+ "compression": {
+ "enabled": false,
+ "should_compress": false,
+ "cooldown_seconds": 90,
+ "disabled_reason": "compression disabled by config",
+ "last_error": "summary provider unavailable"
+ },
+ "tools": {
+ "status_tool": "context_status",
+ "unknown_tool_errors": [
+ {
+ "type": "unknown_context_tool",
+ "tool": "missing_context_tool",
+ "message": "Unknown context engine tool: missing_context_tool"
+ }
+ ]
+ },
+ "replay": {
+ "gaps": [
+ {
+ "kind": "missing_fixture",
+ "message": "no compression replay fixture for fixture-model"
+ }
+ ]
+ }
+}
diff --git a/internal/hermes/tool_call_argument_repair_test.go b/internal/hermes/tool_call_argument_repair_test.go
new file mode 100644
index 000000000..7c6289fad
--- /dev/null
+++ b/internal/hermes/tool_call_argument_repair_test.go
@@ -0,0 +1,185 @@
+package hermes
+
+import (
+ "bufio"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+var echoToolDescriptor = ToolDescriptor{
+ Name: "echo",
+ Description: "echo text",
+ Schema: json.RawMessage(`{"type":"object","properties":{"text":{"type":"string"}},"required":["text"],"additionalProperties":false}`),
+}
+
+func TestStream_ToolCallArgumentsRepairDeterministicAgainstAdvertisedSchema(t *testing.T) {
+ final, err := runToolCallRepairStream(t, []ToolDescriptor{echoToolDescriptor}, `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_echo","type":"function","function":{"name":"echo","arguments":"{\"text\":\"hi\","}}]}}]}
+
+data: {"choices":[{"finish_reason":"tool_calls"}]}
+
+data: [DONE]
+
+`)
+ if err != nil {
+ t.Fatalf("stream returned error: %v", err)
+ }
+ if len(final.ToolCalls) != 1 {
+ t.Fatalf("tool calls len = %d, want 1", len(final.ToolCalls))
+ }
+ call := final.ToolCalls[0]
+ if call.Name != "echo" || call.ID != "call_echo" {
+ t.Fatalf("tool call = %+v, want call_echo/echo", call)
+ }
+ var got map[string]string
+ if err := json.Unmarshal(call.Arguments, &got); err != nil {
+ t.Fatalf("repaired arguments are invalid JSON: %v: %s", err, call.Arguments)
+ }
+ if got["text"] != "hi" {
+ t.Fatalf("repaired arguments = %s, want text=hi", call.Arguments)
+ }
+}
+
+func TestStream_ToolCallArgumentsRejectImpossibleRepairBeforeExecution(t *testing.T) {
+ _, err := runToolCallRepairStream(t, []ToolDescriptor{echoToolDescriptor}, `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_echo","type":"function","function":{"name":"echo","arguments":"{\"text\":"}}]}}]}
+
+data: {"choices":[{"finish_reason":"tool_calls"}]}
+
+data: [DONE]
+
+`)
+ if err == nil {
+ t.Fatal("stream error = nil, want tool-call repair error")
+ }
+ var repairErr *ToolCallRepairError
+ if !errors.As(err, &repairErr) {
+ t.Fatalf("stream error = %T %v, want ToolCallRepairError", err, err)
+ }
+ if repairErr.ToolName != "echo" || repairErr.ToolCallID != "call_echo" {
+ t.Fatalf("repair error = %+v, want call_echo/echo", repairErr)
+ }
+}
+
+func TestStream_ToolCallArgumentsRejectUnavailableTool(t *testing.T) {
+ _, err := runToolCallRepairStream(t, []ToolDescriptor{echoToolDescriptor}, `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_missing","type":"function","function":{"name":"missing","arguments":"{}"}}]}}]}
+
+data: {"choices":[{"finish_reason":"tool_calls"}]}
+
+data: [DONE]
+
+`)
+ if err == nil {
+ t.Fatal("stream error = nil, want unavailable-tool repair error")
+ }
+ var repairErr *ToolCallRepairError
+ if !errors.As(err, &repairErr) {
+ t.Fatalf("stream error = %T %v, want ToolCallRepairError", err, err)
+ }
+ if !strings.Contains(repairErr.Error(), "not advertised") {
+ t.Fatalf("repair error = %q, want not advertised", repairErr.Error())
+ }
+}
+
+func TestStream_ToolCallArgumentsRejectMissingRequiredAfterRepair(t *testing.T) {
+ _, err := runToolCallRepairStream(t, []ToolDescriptor{echoToolDescriptor}, `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_echo","type":"function","function":{"name":"echo","arguments":"None"}}]}}]}
+
+data: {"choices":[{"finish_reason":"tool_calls"}]}
+
+data: [DONE]
+
+`)
+ if err == nil {
+ t.Fatal("stream error = nil, want missing-required repair error")
+ }
+ if !strings.Contains(err.Error(), `missing required argument "text"`) {
+ t.Fatalf("stream error = %q, want missing required text", err.Error())
+ }
+}
+
+func TestSanitizeToolDescriptorsUsesProviderSafeSchemasWithoutMutatingInput(t *testing.T) {
+ descriptors := []ToolDescriptor{{
+ Name: "read_file",
+ Description: "read a file",
+ Schema: json.RawMessage(`{
+ "type": "object",
+ "properties": {
+ "path": {"type": ["string", "null"]},
+ "metadata": "object"
+ },
+ "required": ["path", "missing"]
+ }`),
+ }}
+ original := append(json.RawMessage(nil), descriptors[0].Schema...)
+
+ sanitized := SanitizeToolDescriptors(descriptors)
+
+ if string(descriptors[0].Schema) != string(original) {
+ t.Fatalf("SanitizeToolDescriptors mutated input schema:\n got %s\nwant %s", descriptors[0].Schema, original)
+ }
+ var schema struct {
+ Type string `json:"type"`
+ Required []string
+ Properties map[string]struct {
+ Type string `json:"type"`
+ Nullable bool `json:"nullable,omitempty"`
+ Properties map[string]any `json:"properties,omitempty"`
+ } `json:"properties"`
+ }
+ if err := json.Unmarshal(sanitized[0].Schema, &schema); err != nil {
+ t.Fatalf("sanitized schema invalid JSON: %v: %s", err, sanitized[0].Schema)
+ }
+ if schema.Type != "object" {
+ t.Fatalf("top-level type = %q, want object", schema.Type)
+ }
+ if len(schema.Required) != 1 || schema.Required[0] != "path" {
+ t.Fatalf("required = %v, want [path]", schema.Required)
+ }
+ if got := schema.Properties["path"]; got.Type != "string" || !got.Nullable {
+ t.Fatalf("path schema = %+v, want nullable string", got)
+ }
+ if got := schema.Properties["metadata"]; got.Type != "object" || got.Properties == nil {
+ t.Fatalf("metadata schema = %+v, want object with properties", got)
+ }
+}
+
+func runToolCallRepairStream(t *testing.T, tools []ToolDescriptor, fixture string) (Event, error) {
+ t.Helper()
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.WriteHeader(200)
+ bw := bufio.NewWriter(w)
+ fmt.Fprint(bw, fixture)
+ bw.Flush()
+ }))
+ defer srv.Close()
+
+ c := NewHTTPClient(srv.URL, "")
+ s, err := c.OpenStream(context.Background(), ChatRequest{
+ Model: "x",
+ Messages: []Message{{Role: "user", Content: "echo hi"}},
+ Tools: tools,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer s.Close()
+
+ for {
+ e, err := s.Recv(context.Background())
+ if err == io.EOF {
+ return Event{}, io.EOF
+ }
+ if err != nil {
+ return Event{}, err
+ }
+ if e.Kind == EventDone {
+ return e, nil
+ }
+ }
+}
diff --git a/internal/hermes/tool_call_repair.go b/internal/hermes/tool_call_repair.go
new file mode 100644
index 000000000..de0db63b1
--- /dev/null
+++ b/internal/hermes/tool_call_repair.go
@@ -0,0 +1,575 @@
+package hermes
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "reflect"
+ "sort"
+ "strconv"
+ "strings"
+)
+
+// ToolCallRepairError reports a provider-emitted tool call that could not be
+// safely reconciled with the tool schemas advertised on the current request.
+type ToolCallRepairError struct {
+ ToolCallID string
+ ToolName string
+ Reason string
+}
+
+func (e *ToolCallRepairError) Error() string {
+ if e == nil {
+ return "hermes: tool-call argument repair failed"
+ }
+ name := e.ToolName
+ if name == "" {
+ name = ""
+ }
+ if e.ToolCallID == "" {
+ return fmt.Sprintf("hermes: tool-call argument repair failed for %s: %s", name, e.Reason)
+ }
+ return fmt.Sprintf("hermes: tool-call argument repair failed for %s (%s): %s", name, e.ToolCallID, e.Reason)
+}
+
+// SanitizeToolDescriptors returns a deep copy of descriptors with schemas
+// normalized to the conservative object-shaped subset accepted by provider
+// tool parsers.
+func SanitizeToolDescriptors(descriptors []ToolDescriptor) []ToolDescriptor {
+ if len(descriptors) == 0 {
+ return nil
+ }
+ out := make([]ToolDescriptor, 0, len(descriptors))
+ for _, d := range descriptors {
+ out = append(out, ToolDescriptor{
+ Name: d.Name,
+ Description: d.Description,
+ Schema: sanitizeToolSchema(d.Schema),
+ })
+ }
+ return out
+}
+
+// RepairToolCalls repairs deterministic JSON malformations and validates the
+// final arguments against the currently advertised tool descriptors.
+func RepairToolCalls(calls []ToolCall, descriptors []ToolDescriptor) ([]ToolCall, error) {
+ if len(calls) == 0 {
+ return nil, nil
+ }
+ schemas, err := advertisedToolSchemas(descriptors)
+ if err != nil {
+ return nil, err
+ }
+ out := make([]ToolCall, 0, len(calls))
+ for _, call := range calls {
+ schema, ok := schemas[call.Name]
+ if !ok {
+ return nil, &ToolCallRepairError{
+ ToolCallID: call.ID,
+ ToolName: call.Name,
+ Reason: "tool was not advertised on the current request",
+ }
+ }
+ args, values, err := repairToolCallArguments(call.Arguments)
+ if err != nil {
+ return nil, &ToolCallRepairError{ToolCallID: call.ID, ToolName: call.Name, Reason: err.Error()}
+ }
+ if err := validateArgumentsAgainstSchema(values, schema); err != nil {
+ return nil, &ToolCallRepairError{ToolCallID: call.ID, ToolName: call.Name, Reason: err.Error()}
+ }
+ call.Arguments = args
+ out = append(out, call)
+ }
+ return out, nil
+}
+
+func sanitizeToolSchema(raw json.RawMessage) json.RawMessage {
+ var node any
+ if len(bytes.TrimSpace(raw)) == 0 || json.Unmarshal(raw, &node) != nil {
+ return json.RawMessage(`{"type":"object","properties":{}}`)
+ }
+ sanitized := sanitizeSchemaNode(node)
+ top, ok := sanitized.(map[string]any)
+ if !ok {
+ top = map[string]any{}
+ }
+ if typ, _ := top["type"].(string); typ != "object" {
+ top["type"] = "object"
+ }
+ if _, ok := top["properties"].(map[string]any); !ok {
+ top["properties"] = map[string]any{}
+ }
+ pruneRequired(top)
+ if reflect.DeepEqual(node, top) {
+ return append(json.RawMessage(nil), raw...)
+ }
+ out, err := json.Marshal(top)
+ if err != nil {
+ return json.RawMessage(`{"type":"object","properties":{}}`)
+ }
+ return out
+}
+
+func sanitizeSchemaNode(node any) any {
+ switch v := node.(type) {
+ case string:
+ if isJSONSchemaType(v) {
+ if v == "object" {
+ return map[string]any{"type": "object", "properties": map[string]any{}}
+ }
+ return map[string]any{"type": v}
+ }
+ return map[string]any{"type": "object", "properties": map[string]any{}}
+ case []any:
+ out := make([]any, 0, len(v))
+ for _, item := range v {
+ out = append(out, sanitizeSchemaNode(item))
+ }
+ return out
+ case map[string]any:
+ out := make(map[string]any, len(v)+1)
+ for key, value := range v {
+ switch key {
+ case "type":
+ out[key] = sanitizeSchemaType(value, out)
+ case "properties", "$defs", "definitions":
+ if children, ok := value.(map[string]any); ok {
+ clean := make(map[string]any, len(children))
+ for name, child := range children {
+ clean[name] = sanitizeSchemaNode(child)
+ }
+ out[key] = clean
+ } else if key == "properties" {
+ out[key] = map[string]any{}
+ } else {
+ out[key] = value
+ }
+ case "items", "additionalProperties":
+ if _, ok := value.(bool); ok {
+ out[key] = value
+ } else {
+ out[key] = sanitizeSchemaNode(value)
+ }
+ case "anyOf", "oneOf", "allOf":
+ if list, ok := value.([]any); ok {
+ clean := make([]any, 0, len(list))
+ for _, item := range list {
+ clean = append(clean, sanitizeSchemaNode(item))
+ }
+ out[key] = clean
+ } else {
+ out[key] = value
+ }
+ case "required", "enum", "examples":
+ out[key] = value
+ default:
+ switch value.(type) {
+ case map[string]any, []any:
+ out[key] = sanitizeSchemaNode(value)
+ default:
+ out[key] = value
+ }
+ }
+ }
+ if typ, _ := out["type"].(string); typ == "object" {
+ if _, ok := out["properties"].(map[string]any); !ok {
+ out["properties"] = map[string]any{}
+ }
+ pruneRequired(out)
+ }
+ return out
+ default:
+ return node
+ }
+}
+
+func sanitizeSchemaType(value any, out map[string]any) any {
+ if list, ok := value.([]any); ok {
+ first := ""
+ for _, item := range list {
+ s, ok := item.(string)
+ if !ok {
+ continue
+ }
+ if s == "null" {
+ out["nullable"] = true
+ continue
+ }
+ if first == "" {
+ first = s
+ }
+ }
+ if first == "" {
+ return "object"
+ }
+ return first
+ }
+ return value
+}
+
+func pruneRequired(schema map[string]any) {
+ required, ok := schema["required"].([]any)
+ if !ok {
+ return
+ }
+ props, _ := schema["properties"].(map[string]any)
+ valid := make([]any, 0, len(required))
+ for _, item := range required {
+ name, ok := item.(string)
+ if !ok {
+ continue
+ }
+ if _, exists := props[name]; exists {
+ valid = append(valid, name)
+ }
+ }
+ if len(valid) == 0 {
+ delete(schema, "required")
+ return
+ }
+ schema["required"] = valid
+}
+
+func isJSONSchemaType(t string) bool {
+ switch t {
+ case "object", "string", "number", "integer", "boolean", "array", "null":
+ return true
+ default:
+ return false
+ }
+}
+
+func advertisedToolSchemas(descriptors []ToolDescriptor) (map[string]map[string]any, error) {
+ out := make(map[string]map[string]any, len(descriptors))
+ for _, d := range SanitizeToolDescriptors(descriptors) {
+ name := strings.TrimSpace(d.Name)
+ if name == "" {
+ continue
+ }
+ var schema map[string]any
+ dec := json.NewDecoder(bytes.NewReader(d.Schema))
+ dec.UseNumber()
+ if err := dec.Decode(&schema); err != nil {
+ return nil, &ToolCallRepairError{ToolName: name, Reason: "advertised schema is invalid JSON: " + err.Error()}
+ }
+ out[name] = schema
+ }
+ return out, nil
+}
+
+func repairToolCallArguments(raw json.RawMessage) (json.RawMessage, map[string]any, error) {
+ trimmed := strings.TrimSpace(string(raw))
+ if trimmed == "" || trimmed == "None" {
+ trimmed = "{}"
+ }
+ if canonical, values, err := parseJSONObject(trimmed); err == nil {
+ return canonical, values, nil
+ }
+ candidate, ok := normalizeJSONDelimiters(trimmed)
+ if !ok {
+ return nil, nil, fmt.Errorf("arguments are not deterministically repairable")
+ }
+ candidate = stripTrailingCommas(candidate)
+ if canonical, values, err := parseJSONObject(candidate); err == nil {
+ return canonical, values, nil
+ }
+ return nil, nil, fmt.Errorf("arguments are not deterministically repairable")
+}
+
+func parseJSONObject(raw string) (json.RawMessage, map[string]any, error) {
+ dec := json.NewDecoder(strings.NewReader(raw))
+ dec.UseNumber()
+ var value any
+ if err := dec.Decode(&value); err != nil {
+ return nil, nil, err
+ }
+ var extra any
+ if err := dec.Decode(&extra); err != io.EOF {
+ return nil, nil, fmt.Errorf("arguments contain trailing data")
+ }
+ obj, ok := value.(map[string]any)
+ if !ok {
+ return nil, nil, fmt.Errorf("arguments must be a JSON object")
+ }
+ canonical, err := json.Marshal(value)
+ if err != nil {
+ return nil, nil, err
+ }
+ return canonical, obj, nil
+}
+
+func normalizeJSONDelimiters(raw string) (string, bool) {
+ var out strings.Builder
+ stack := make([]byte, 0, 4)
+ inString := false
+ escaped := false
+
+ for i := 0; i < len(raw); i++ {
+ ch := raw[i]
+ if inString {
+ switch {
+ case escaped:
+ out.WriteByte(ch)
+ escaped = false
+ case ch == '\\':
+ out.WriteByte(ch)
+ escaped = true
+ case ch == '"':
+ out.WriteByte(ch)
+ inString = false
+ case ch < 0x20:
+ writeEscapedControl(&out, ch)
+ default:
+ out.WriteByte(ch)
+ }
+ continue
+ }
+
+ switch ch {
+ case '"':
+ inString = true
+ out.WriteByte(ch)
+ case '{':
+ stack = append(stack, '}')
+ out.WriteByte(ch)
+ case '[':
+ stack = append(stack, ']')
+ out.WriteByte(ch)
+ case '}', ']':
+ if len(stack) == 0 || stack[len(stack)-1] != ch {
+ continue
+ }
+ stack = stack[:len(stack)-1]
+ out.WriteByte(ch)
+ default:
+ out.WriteByte(ch)
+ }
+ }
+ if inString || escaped {
+ return "", false
+ }
+
+ candidate := strings.TrimSpace(out.String())
+ for strings.HasSuffix(candidate, ",") {
+ candidate = strings.TrimSpace(strings.TrimSuffix(candidate, ","))
+ }
+ if strings.HasSuffix(candidate, ":") {
+ return "", false
+ }
+ for i := len(stack) - 1; i >= 0; i-- {
+ candidate += string(stack[i])
+ }
+ return candidate, true
+}
+
+func writeEscapedControl(out *strings.Builder, ch byte) {
+ switch ch {
+ case '\n':
+ out.WriteString(`\n`)
+ case '\r':
+ out.WriteString(`\r`)
+ case '\t':
+ out.WriteString(`\t`)
+ default:
+ out.WriteString(fmt.Sprintf(`\u%04x`, ch))
+ }
+}
+
+func stripTrailingCommas(raw string) string {
+ var out strings.Builder
+ inString := false
+ escaped := false
+ for i := 0; i < len(raw); i++ {
+ ch := raw[i]
+ if inString {
+ out.WriteByte(ch)
+ if escaped {
+ escaped = false
+ continue
+ }
+ if ch == '\\' {
+ escaped = true
+ } else if ch == '"' {
+ inString = false
+ }
+ continue
+ }
+ if ch == '"' {
+ inString = true
+ out.WriteByte(ch)
+ continue
+ }
+ if ch == ',' {
+ j := i + 1
+ for j < len(raw) && isJSONWhitespace(raw[j]) {
+ j++
+ }
+ if j < len(raw) && (raw[j] == '}' || raw[j] == ']') {
+ continue
+ }
+ }
+ out.WriteByte(ch)
+ }
+ return out.String()
+}
+
+func isJSONWhitespace(ch byte) bool {
+ return ch == ' ' || ch == '\n' || ch == '\r' || ch == '\t'
+}
+
+func validateArgumentsAgainstSchema(args map[string]any, schema map[string]any) error {
+ for _, name := range schemaRequired(schema) {
+ if _, ok := args[name]; !ok {
+ return fmt.Errorf("missing required argument %q", name)
+ }
+ }
+
+ props, _ := schema["properties"].(map[string]any)
+ for name, value := range args {
+ prop, ok := props[name]
+ if !ok {
+ if allowsAdditionalProperties(schema, name, value) {
+ continue
+ }
+ return fmt.Errorf("unexpected argument %q", name)
+ }
+ propSchema, _ := prop.(map[string]any)
+ if err := validateValueAgainstSchema(name, value, propSchema); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func schemaRequired(schema map[string]any) []string {
+ raw, ok := schema["required"].([]any)
+ if !ok {
+ return nil
+ }
+ out := make([]string, 0, len(raw))
+ for _, item := range raw {
+ if s, ok := item.(string); ok {
+ out = append(out, s)
+ }
+ }
+ sort.Strings(out)
+ return out
+}
+
+func allowsAdditionalProperties(schema map[string]any, name string, value any) bool {
+ additional, ok := schema["additionalProperties"]
+ if !ok {
+ return true
+ }
+ if allowed, ok := additional.(bool); ok {
+ return allowed
+ }
+ additionalSchema, ok := additional.(map[string]any)
+ if !ok {
+ return true
+ }
+ return validateValueAgainstSchema(name, value, additionalSchema) == nil
+}
+
+func validateValueAgainstSchema(path string, value any, schema map[string]any) error {
+ if len(schema) == 0 {
+ return nil
+ }
+ if value == nil {
+ if nullable, _ := schema["nullable"].(bool); nullable {
+ return nil
+ }
+ if typ, _ := schema["type"].(string); typ == "null" || typ == "" {
+ return nil
+ }
+ return fmt.Errorf("argument %q must not be null", path)
+ }
+ if enum, ok := schema["enum"].([]any); ok && !enumContains(enum, value) {
+ return fmt.Errorf("argument %q is not an allowed value", path)
+ }
+ typ, _ := schema["type"].(string)
+ switch typ {
+ case "", "null":
+ return nil
+ case "string":
+ if _, ok := value.(string); !ok {
+ return fmt.Errorf("argument %q must be a string", path)
+ }
+ case "boolean":
+ if _, ok := value.(bool); !ok {
+ return fmt.Errorf("argument %q must be a boolean", path)
+ }
+ case "number":
+ if !isJSONNumber(value) {
+ return fmt.Errorf("argument %q must be a number", path)
+ }
+ case "integer":
+ if !isJSONInteger(value) {
+ return fmt.Errorf("argument %q must be an integer", path)
+ }
+ case "object":
+ child, ok := value.(map[string]any)
+ if !ok {
+ return fmt.Errorf("argument %q must be an object", path)
+ }
+ if err := validateArgumentsAgainstSchema(child, schema); err != nil {
+ return err
+ }
+ case "array":
+ items, ok := value.([]any)
+ if !ok {
+ return fmt.Errorf("argument %q must be an array", path)
+ }
+ itemSchema, _ := schema["items"].(map[string]any)
+ for i, item := range items {
+ if err := validateValueAgainstSchema(fmt.Sprintf("%s[%d]", path, i), item, itemSchema); err != nil {
+ return err
+ }
+ }
+ default:
+ return nil
+ }
+ return nil
+}
+
+func enumContains(enum []any, value any) bool {
+ valueJSON, err := json.Marshal(value)
+ if err != nil {
+ return false
+ }
+ for _, item := range enum {
+ itemJSON, err := json.Marshal(item)
+ if err == nil && bytes.Equal(itemJSON, valueJSON) {
+ return true
+ }
+ }
+ return false
+}
+
+func isJSONNumber(value any) bool {
+ switch n := value.(type) {
+ case json.Number:
+ _, err := n.Float64()
+ return err == nil
+ case float64:
+ return true
+ default:
+ return false
+ }
+}
+
+func isJSONInteger(value any) bool {
+ switch n := value.(type) {
+ case json.Number:
+ if _, err := n.Int64(); err == nil {
+ return true
+ }
+ _, err := strconv.ParseInt(n.String(), 10, 64)
+ return err == nil
+ case float64:
+ return n == float64(int64(n))
+ default:
+ return false
+ }
+}
diff --git a/internal/kernel/contextengine_test.go b/internal/kernel/contextengine_test.go
new file mode 100644
index 000000000..d9a84457a
--- /dev/null
+++ b/internal/kernel/contextengine_test.go
@@ -0,0 +1,212 @@
+package kernel
+
+import (
+ "context"
+ "encoding/json"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/TrebuchetDynamics/gormes-agent/internal/hermes"
+ "github.com/TrebuchetDynamics/gormes-agent/internal/store"
+ "github.com/TrebuchetDynamics/gormes-agent/internal/telemetry"
+)
+
+func TestKernel_ContextStatusUpdatesFromStreamWithoutHiddenCompression(t *testing.T) {
+ engine := &compressSpyContextEngine{
+ DisabledContextEngine: hermes.NewDisabledContextEngine("compression disabled by config"),
+ }
+ engine.UpdateModelContext(hermes.ContextModelContext{
+ Model: "hermes-agent",
+ ContextLength: 1000,
+ ThresholdPercent: 0.75,
+ })
+
+ mc := hermes.NewMockClient()
+ mc.Script([]hermes.Event{
+ {Kind: hermes.EventToken, Token: "ok", TokensOut: 1},
+ {Kind: hermes.EventDone, FinishReason: "stop", TokensIn: 740, TokensOut: 8},
+ }, "sess-context")
+ k := New(Config{
+ Model: "hermes-agent",
+ Endpoint: "http://mock",
+ Admission: Admission{MaxBytes: 200_000, MaxLines: 10_000},
+ ContextEngine: engine,
+ }, mc, store.NewNoop(), telemetry.New(), nil)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+ go k.Run(ctx)
+ initial := <-k.Render()
+
+ if err := k.Submit(PlatformEvent{Kind: PlatformEventSubmit, Text: "hi"}); err != nil {
+ t.Fatal(err)
+ }
+ _, final := drainUntilIdle(t, k.Render(), initial.Seq, 2*time.Second)
+
+ if engine.compressCalls != 0 {
+ t.Fatalf("Compress was called %d times; compression must stay an explicit engine boundary", engine.compressCalls)
+ }
+ if final.ContextStatus == nil {
+ t.Fatal("final.ContextStatus is nil, want status snapshot")
+ }
+ if final.ContextStatus.LastPromptTokens != 740 || final.ContextStatus.LastCompletionTokens != 8 {
+ t.Fatalf("context status usage = %#v, want prompt=740 completion=8", final.ContextStatus)
+ }
+ if final.ContextStatus.Budget.State != "pressure" {
+ t.Fatalf("budget state = %q, want pressure", final.ContextStatus.Budget.State)
+ }
+ if final.ContextStatus.Compression.Enabled {
+ t.Fatalf("compression status = %#v, want disabled", final.ContextStatus.Compression)
+ }
+}
+
+func TestKernel_ContextStatusToolReplaysThroughMockClient(t *testing.T) {
+ engine := hermes.NewDisabledContextEngine("compression disabled by config")
+ engine.UpdateModelContext(hermes.ContextModelContext{
+ Model: "hermes-agent",
+ ContextLength: 8000,
+ ThresholdPercent: 0.75,
+ })
+
+ mc := hermes.NewMockClient()
+ mc.Script([]hermes.Event{{
+ Kind: hermes.EventDone,
+ FinishReason: "tool_calls",
+ ToolCalls: []hermes.ToolCall{{
+ ID: "call_context_status",
+ Name: hermes.ContextStatusToolName,
+ Arguments: json.RawMessage(`{}`),
+ }},
+ }}, "sess-context")
+ mc.Script([]hermes.Event{{
+ Kind: hermes.EventDone,
+ FinishReason: "stop",
+ TokensIn: 120,
+ TokensOut: 4,
+ }}, "sess-context")
+
+ k := New(Config{
+ Model: "hermes-agent",
+ Endpoint: "http://mock",
+ Admission: Admission{MaxBytes: 200_000, MaxLines: 10_000},
+ ContextEngine: engine,
+ }, mc, store.NewNoop(), telemetry.New(), nil)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+ go k.Run(ctx)
+ initial := <-k.Render()
+
+ if err := k.Submit(PlatformEvent{Kind: PlatformEventSubmit, Text: "status"}); err != nil {
+ t.Fatal(err)
+ }
+ drainUntilIdle(t, k.Render(), initial.Seq, 2*time.Second)
+
+ requests := mc.Requests()
+ if len(requests) != 2 {
+ t.Fatalf("OpenStream calls = %d, want 2", len(requests))
+ }
+ if !hasToolDescriptor(requests[0].Tools, hermes.ContextStatusToolName) {
+ t.Fatalf("first request tools = %#v, want context status tool descriptor", requests[0].Tools)
+ }
+ var toolMsg *hermes.Message
+ for i := range requests[1].Messages {
+ if requests[1].Messages[i].Role == "tool" && requests[1].Messages[i].ToolCallID == "call_context_status" {
+ toolMsg = &requests[1].Messages[i]
+ break
+ }
+ }
+ if toolMsg == nil {
+ t.Fatalf("second request messages = %#v, want context status tool result", requests[1].Messages)
+ }
+ var status hermes.ContextStatus
+ if err := json.Unmarshal([]byte(toolMsg.Content), &status); err != nil {
+ t.Fatalf("decode context status tool result: %v\n%s", err, toolMsg.Content)
+ }
+ if status.ContextLength != 8000 || status.Compression.DisabledReason != "compression disabled by config" {
+ t.Fatalf("status tool payload = %#v, want disabled context status", status)
+ }
+}
+
+func TestKernel_UnknownContextToolReturnsStructuredErrorAndStatus(t *testing.T) {
+ engine := hermes.NewDisabledContextEngine("compression disabled by config")
+ mc := hermes.NewMockClient()
+ mc.Script([]hermes.Event{{
+ Kind: hermes.EventDone,
+ FinishReason: "tool_calls",
+ ToolCalls: []hermes.ToolCall{{
+ ID: "call_missing",
+ Name: "missing_context_tool",
+ Arguments: json.RawMessage(`{"query":"x"}`),
+ }},
+ }}, "sess-context")
+ mc.Script([]hermes.Event{{
+ Kind: hermes.EventDone,
+ FinishReason: "stop",
+ TokensIn: 120,
+ TokensOut: 4,
+ }}, "sess-context")
+
+ k := New(Config{
+ Model: "hermes-agent",
+ Endpoint: "http://mock",
+ Admission: Admission{MaxBytes: 200_000, MaxLines: 10_000},
+ ContextEngine: engine,
+ }, mc, store.NewNoop(), telemetry.New(), nil)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+ go k.Run(ctx)
+ initial := <-k.Render()
+
+ if err := k.Submit(PlatformEvent{Kind: PlatformEventSubmit, Text: "status"}); err != nil {
+ t.Fatal(err)
+ }
+ drainUntilIdle(t, k.Render(), initial.Seq, 2*time.Second)
+
+ requests := mc.Requests()
+ if len(requests) != 2 {
+ t.Fatalf("OpenStream calls = %d, want 2", len(requests))
+ }
+ var toolPayload string
+ for i := range requests[1].Messages {
+ if requests[1].Messages[i].Role == "tool" && requests[1].Messages[i].ToolCallID == "call_missing" {
+ toolPayload = requests[1].Messages[i].Content
+ break
+ }
+ }
+ if toolPayload == "" {
+ t.Fatalf("second request messages = %#v, want missing context tool result", requests[1].Messages)
+ }
+ if !strings.Contains(toolPayload, `"type":"unknown_context_tool"`) || !strings.Contains(toolPayload, `"tool":"missing_context_tool"`) {
+ t.Fatalf("unknown context tool payload = %s, want structured unknown_context_tool error", toolPayload)
+ }
+ status := engine.Status()
+ if len(status.Tools.UnknownToolErrors) != 1 || status.Tools.UnknownToolErrors[0].Tool != "missing_context_tool" {
+ t.Fatalf("status unknown tool errors = %#v, want missing_context_tool", status.Tools.UnknownToolErrors)
+ }
+}
+
+type compressSpyContextEngine struct {
+ *hermes.DisabledContextEngine
+ compressCalls int
+}
+
+func (s *compressSpyContextEngine) ShouldCompress(int) bool {
+ return true
+}
+
+func (s *compressSpyContextEngine) Compress(ctx context.Context, messages []hermes.Message, req hermes.CompressionRequest) ([]hermes.Message, hermes.CompressionReport, error) {
+ s.compressCalls++
+ return s.DisabledContextEngine.Compress(ctx, messages, req)
+}
+
+func hasToolDescriptor(tools []hermes.ToolDescriptor, name string) bool {
+ for _, tool := range tools {
+ if tool.Name == name {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/kernel/frame.go b/internal/kernel/frame.go
index 588bbe524..a3de4ae56 100644
--- a/internal/kernel/frame.go
+++ b/internal/kernel/frame.go
@@ -38,16 +38,21 @@ func (p Phase) String() string {
// RenderFrame is the only TUI input. The TUI never assembles assistant text
// from raw provider events; it renders this frame, full stop.
type RenderFrame struct {
- Seq uint64
- Phase Phase
- DraftText string
- History []hermes.Message
- Telemetry telemetry.Snapshot
- StatusText string
- SessionID string
- Model string
- LastError string
- SoulEvents []SoulEntry
+ Seq uint64
+ Phase Phase
+ DraftText string
+ History []hermes.Message
+ Telemetry telemetry.Snapshot
+ StatusText string
+ SessionID string
+ Model string
+ ProviderStatus hermes.ProviderStatus
+ RetryStatus RetryStatus
+ LastError string
+ SoulEvents []SoulEntry
+ // ContextStatus snapshots the active ContextEngine status, when one is
+ // configured. Nil means no context engine has been wired for this kernel.
+ ContextStatus *hermes.ContextStatus
}
type SoulEntry struct {
diff --git a/internal/kernel/kernel.go b/internal/kernel/kernel.go
index f4b1f2871..de3ba5960 100644
--- a/internal/kernel/kernel.go
+++ b/internal/kernel/kernel.go
@@ -57,6 +57,10 @@ type Config struct {
SkillUsage SkillUsageRecorder
// ToolAudit records append-only JSONL tool execution events when non-nil.
ToolAudit audit.Recorder
+ // ContextEngine owns context-window status, context-engine tools, and the
+ // explicit compression boundary. The kernel may update usage and dispatch
+ // engine tools, but it must not call Compress as an implicit side effect.
+ ContextEngine hermes.ContextEngine
}
type SkillProvider interface {
@@ -87,12 +91,13 @@ type Kernel struct {
// All fields below this line are OWNED EXCLUSIVELY by the Run goroutine.
// No other goroutine may read or write them without a channel-based
// handshake. Violating this invariant is a race.
- phase Phase
- draft string
- history []hermes.Message
- soul []SoulEntry
- sessionID string
- lastError string
+ phase Phase
+ draft string
+ history []hermes.Message
+ soul []SoulEntry
+ sessionID string
+ lastError string
+ retryStatus RetryStatus
}
func New(cfg Config, c hermes.Client, s store.Store, tm telemetry.Telemetry, log *slog.Logger) *Kernel {
@@ -100,15 +105,19 @@ func New(cfg Config, c hermes.Client, s store.Store, tm telemetry.Telemetry, log
log = slog.Default()
}
tm.SetModel(cfg.Model)
+ if cfg.ContextEngine != nil {
+ cfg.ContextEngine.UpdateModelContext(hermes.ContextModelContext{Model: cfg.Model})
+ }
return &Kernel{
- cfg: cfg,
- client: c,
- store: s,
- tm: tm,
- log: log,
- render: make(chan RenderFrame, RenderMailboxCap),
- events: make(chan PlatformEvent, PlatformEventMailboxCap),
- sessionID: cfg.InitialSessionID,
+ cfg: cfg,
+ client: c,
+ store: s,
+ tm: tm,
+ log: log,
+ render: make(chan RenderFrame, RenderMailboxCap),
+ events: make(chan PlatformEvent, PlatformEventMailboxCap),
+ sessionID: cfg.InitialSessionID,
+ retryStatus: NewRetryStatus(),
}
}
@@ -195,6 +204,9 @@ func (k *Kernel) Run(ctx context.Context) error {
k.history = nil
k.sessionID = ""
k.lastError = ""
+ if k.cfg.ContextEngine != nil {
+ k.cfg.ContextEngine.OnSessionReset()
+ }
k.phase = PhaseIdle
k.emitFrame("session reset")
if e.ack != nil {
@@ -251,6 +263,7 @@ func (k *Kernel) runTurn(ctx context.Context, text, sessionContext, cronJobID st
k.history = append(k.history, hermes.Message{Role: "user", Content: text})
k.draft = ""
k.lastError = ""
+ k.retryStatus = NewRetryStatus()
k.phase = PhaseConnecting
k.emitFrame("connecting")
prov.LogPOSTSent(k.log)
@@ -313,6 +326,9 @@ func (k *Kernel) runTurn(ctx context.Context, text, sessionContext, cronJobID st
}
request.Tools = wireDescs
}
+ if k.cfg.ContextEngine != nil {
+ request.Tools = append(request.Tools, k.cfg.ContextEngine.ToolDescriptors()...)
+ }
maxIter := k.cfg.MaxToolIterations
if maxIter <= 0 {
maxIter = 10
@@ -345,12 +361,14 @@ toolLoop:
stream, err := k.client.OpenStream(runCtx, request)
if err != nil {
cancelRun()
- if hermes.Classify(err) == hermes.ClassRetryable && !retryBudget.Exhausted() {
+ classification := hermes.ClassifyProviderError(err)
+ if classification.Class == hermes.ClassRetryable && !retryBudget.Exhausted() {
+ decision := retryBudget.NextDelayDecision(err)
+ k.retryStatus = retryStatusWithDecision(k.retryStatus, decision, classification)
k.phase = PhaseReconnecting
k.lastError = "reconnecting: " + err.Error()
k.emitFrame("reconnecting")
- delay := retryBudget.NextDelayFor(err)
- if werr := Wait(ctx, delay); werr != nil {
+ if werr := Wait(ctx, decision.Delay); werr != nil {
cancelled = true
break toolLoop
}
@@ -385,15 +403,21 @@ toolLoop:
break toolLoop
case streamOutcomeRetryable:
if retryBudget.Exhausted() {
+ k.retryStatus.LastDecision = RetryDecisionBudgetExhaust
k.phase = PhaseFailed
k.lastError = "reconnect budget exhausted"
k.emitFrame("reconnect budget exhausted")
return
}
+ decision := retryBudget.NextDelayDecision(nil)
+ k.retryStatus = retryStatusWithDecision(k.retryStatus, decision, hermes.ProviderErrorClassification{
+ Kind: hermes.ProviderErrorRetryable,
+ Class: hermes.ClassRetryable,
+ Retryable: true,
+ })
k.phase = PhaseReconnecting
k.emitFrame("reconnecting")
- delay := retryBudget.NextDelay()
- if werr := Wait(ctx, delay); werr != nil {
+ if werr := Wait(ctx, decision.Delay); werr != nil {
cancelled = true
break toolLoop
}
@@ -407,6 +431,7 @@ toolLoop:
fatalErr = fmt.Errorf("stream closed without finish_reason")
break toolLoop
}
+ k.updateContextEngineUsage(finalDelta)
if finalDelta.FinishReason != "tool_calls" {
// Normal end of turn. Exit the tool loop to finalise.
@@ -481,6 +506,9 @@ toolLoop:
if finalDelta.TokensIn > 0 {
k.tm.SetTokensIn(finalDelta.TokensIn)
}
+ if finalDelta.TokensOut > 0 {
+ k.tm.Tick(finalDelta.TokensOut)
+ }
}
if latestSessionID != "" {
@@ -521,6 +549,18 @@ toolLoop:
k.emitFrame("idle")
}
+func (k *Kernel) updateContextEngineUsage(ev hermes.Event) {
+ if k.cfg.ContextEngine == nil {
+ return
+ }
+ total := ev.TokensIn + ev.TokensOut
+ k.cfg.ContextEngine.UpdateFromResponse(hermes.ContextUsage{
+ PromptTokens: ev.TokensIn,
+ CompletionTokens: ev.TokensOut,
+ TotalTokens: total,
+ })
+}
+
type streamOutcome int
const (
@@ -698,17 +738,25 @@ func (k *Kernel) addSoul(text string) {
// in the capacity-1 buffer, drain it and drop it before enqueueing the new
// one. This is what keeps a slow TUI from backpressuring the kernel.
func (k *Kernel) emitFrame(status string) {
+ var contextStatus *hermes.ContextStatus
+ if k.cfg.ContextEngine != nil {
+ snapshot := k.cfg.ContextEngine.Status()
+ contextStatus = &snapshot
+ }
frame := RenderFrame{
- Seq: k.seq.Add(1),
- Phase: k.phase,
- DraftText: k.draft,
- History: append([]hermes.Message(nil), k.history...),
- Telemetry: k.tm.Snapshot(),
- StatusText: status,
- SessionID: k.sessionID,
- Model: k.cfg.Model,
- LastError: k.lastError,
- SoulEvents: append([]SoulEntry(nil), k.soul...),
+ Seq: k.seq.Add(1),
+ Phase: k.phase,
+ DraftText: k.draft,
+ History: append([]hermes.Message(nil), k.history...),
+ Telemetry: k.tm.Snapshot(),
+ StatusText: status,
+ SessionID: k.sessionID,
+ Model: k.cfg.Model,
+ ProviderStatus: hermes.ProviderStatusOf(k.client),
+ RetryStatus: k.retryStatus.snapshot(),
+ LastError: k.lastError,
+ SoulEvents: append([]SoulEntry(nil), k.soul...),
+ ContextStatus: contextStatus,
}
// Drain old frame if present, then enqueue new.
select {
diff --git a/internal/kernel/retry.go b/internal/kernel/retry.go
index 8f1efd6d5..1bf550049 100644
--- a/internal/kernel/retry.go
+++ b/internal/kernel/retry.go
@@ -19,9 +19,54 @@ type RetryBudget struct {
const maxRetryAttempts = 5
const maxProviderRetryAfter = 16 * time.Second
+const (
+ RetryDecisionScheduled = "scheduled_backoff"
+ RetryDecisionProviderHint = "provider_retry_after"
+ RetryDecisionBudgetExhaust = "budget_exhausted"
+)
+
+type RetryStatus struct {
+ Schedule []time.Duration
+ MaxAttempts int
+ MaxProviderRetryAfter time.Duration
+ AttemptsUsed int
+ LastScheduledDelay time.Duration
+ LastDelay time.Duration
+ LastProviderRetryAfter time.Duration
+ LastDecision string
+ LastErrorClass string
+ LastErrorKind string
+}
+
+type RetryDelayDecision struct {
+ Attempt int
+ ScheduledDelay time.Duration
+ Delay time.Duration
+ ProviderRetryAfter time.Duration
+ Decision string
+}
+
// NewRetryBudget returns a fresh budget โ 5 attempts remaining.
func NewRetryBudget() *RetryBudget { return &RetryBudget{} }
+func NewRetryStatus() RetryStatus {
+ return RetryStatus{
+ Schedule: RetrySchedule(),
+ MaxAttempts: maxRetryAttempts,
+ MaxProviderRetryAfter: maxProviderRetryAfter,
+ }
+}
+
+func RetrySchedule() []time.Duration {
+ return []time.Duration{
+ 1 * time.Second,
+ 2 * time.Second,
+ 4 * time.Second,
+ 8 * time.Second,
+ 16 * time.Second,
+ }
+}
+
// NextDelay returns the jittered backoff for the next attempt, or -1 if the
// budget is exhausted. Advances the internal attempt counter on each call.
func (b *RetryBudget) NextDelay() time.Duration {
@@ -39,18 +84,34 @@ func (b *RetryBudget) NextDelay() time.Duration {
// reconnect budget's maximum base delay so a provider cannot stall the kernel
// beyond the bounded Route-B recovery window.
func (b *RetryBudget) NextDelayFor(err error) time.Duration {
+ return b.NextDelayDecision(err).Delay
+}
+
+// NextDelayDecision advances the retry budget and returns both the jittered
+// schedule result and any provider hint decision made for status reporting.
+func (b *RetryBudget) NextDelayDecision(err error) RetryDelayDecision {
scheduled := b.NextDelay()
+ decision := RetryDelayDecision{
+ Attempt: b.attempt,
+ ScheduledDelay: scheduled,
+ Delay: scheduled,
+ Decision: RetryDecisionScheduled,
+ }
if scheduled < 0 {
- return scheduled
+ decision.Decision = RetryDecisionBudgetExhaust
+ return decision
}
hint := providerRetryAfter(err)
if hint <= 0 {
- return scheduled
+ return decision
}
if hint > maxProviderRetryAfter {
- return maxProviderRetryAfter
+ hint = maxProviderRetryAfter
}
- return hint
+ decision.ProviderRetryAfter = hint
+ decision.Delay = hint
+ decision.Decision = RetryDecisionProviderHint
+ return decision
}
// Exhausted returns true if NextDelay has been called maxRetryAttempts times.
@@ -79,3 +140,22 @@ func providerRetryAfter(err error) time.Duration {
}
return 0
}
+
+func retryStatusWithDecision(status RetryStatus, decision RetryDelayDecision, classification hermes.ProviderErrorClassification) RetryStatus {
+ if len(status.Schedule) == 0 {
+ status = NewRetryStatus()
+ }
+ status.AttemptsUsed = decision.Attempt
+ status.LastScheduledDelay = decision.ScheduledDelay
+ status.LastDelay = decision.Delay
+ status.LastProviderRetryAfter = decision.ProviderRetryAfter
+ status.LastDecision = decision.Decision
+ status.LastErrorClass = classification.Class.String()
+ status.LastErrorKind = classification.Kind.String()
+ return status
+}
+
+func (s RetryStatus) snapshot() RetryStatus {
+ s.Schedule = append([]time.Duration(nil), s.Schedule...)
+ return s
+}
diff --git a/internal/kernel/retry_test.go b/internal/kernel/retry_test.go
index a5dfa9d20..cf754bace 100644
--- a/internal/kernel/retry_test.go
+++ b/internal/kernel/retry_test.go
@@ -4,6 +4,7 @@ import (
"context"
"io"
"net/http"
+ "reflect"
"testing"
"time"
@@ -116,6 +117,99 @@ func TestKernel_OpenStreamRetryUsesProviderRetryAfterHint(t *testing.T) {
}
}
+func TestKernel_InitialFrameExposesProviderCapabilitiesAndRetrySchedule(t *testing.T) {
+ client := &statusClient{status: hermes.ProviderStatus{
+ Provider: "fixture-provider",
+ Runtime: "fixture-runtime",
+ Capabilities: hermes.ProviderCapabilities{
+ PromptCache: hermes.CapabilityStatus{Available: false, Reason: "fixture cache disabled"},
+ RateGuard: hermes.CapabilityStatus{Available: false, Reason: "fixture rate guard unavailable"},
+ BudgetTelemetry: hermes.CapabilityStatus{Available: false, Reason: "fixture budget telemetry unavailable"},
+ },
+ }}
+ k := New(Config{
+ Model: "hermes-agent",
+ Endpoint: "http://mock",
+ Admission: Admission{MaxBytes: 200_000, MaxLines: 10_000},
+ }, client, store.NewNoop(), telemetry.New(), nil)
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ go k.Run(ctx)
+
+ frame := <-k.Render()
+ if frame.ProviderStatus.Provider != "fixture-provider" {
+ t.Fatalf("ProviderStatus.Provider = %q, want fixture-provider", frame.ProviderStatus.Provider)
+ }
+ if frame.ProviderStatus.Capabilities.PromptCache.Reason != "fixture cache disabled" {
+ t.Fatalf("PromptCache.Reason = %q, want fixture cache disabled", frame.ProviderStatus.Capabilities.PromptCache.Reason)
+ }
+ wantSchedule := []time.Duration{
+ 1 * time.Second,
+ 2 * time.Second,
+ 4 * time.Second,
+ 8 * time.Second,
+ 16 * time.Second,
+ }
+ if !reflect.DeepEqual(frame.RetryStatus.Schedule, wantSchedule) {
+ t.Fatalf("RetryStatus.Schedule = %v, want %v", frame.RetryStatus.Schedule, wantSchedule)
+ }
+ if frame.RetryStatus.MaxProviderRetryAfter != 16*time.Second {
+ t.Fatalf("RetryStatus.MaxProviderRetryAfter = %v, want 16s", frame.RetryStatus.MaxProviderRetryAfter)
+ }
+}
+
+func TestKernel_ReconnectingFrameReportsProviderRetryAfterDecision(t *testing.T) {
+ client := &retryAfterClient{
+ firstErr: &hermes.HTTPError{
+ Status: http.StatusTooManyRequests,
+ Body: `{"error":{"message":"slow down","code":"rate_limit"}}`,
+ RetryAfter: 25 * time.Millisecond,
+ },
+ }
+ k := New(Config{
+ Model: "hermes-agent",
+ Endpoint: "http://mock",
+ Admission: Admission{MaxBytes: 200_000, MaxLines: 10_000},
+ }, client, store.NewNoop(), telemetry.New(), nil)
+
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ go k.Run(ctx)
+
+ initial := <-k.Render()
+ if initial.Phase != PhaseIdle {
+ t.Fatalf("initial phase = %v, want Idle", initial.Phase)
+ }
+ if err := k.Submit(PlatformEvent{Kind: PlatformEventSubmit, Text: "hi"}); err != nil {
+ t.Fatal(err)
+ }
+
+ reconnecting := waitForFrameMatching(t, k.Render(), func(f RenderFrame) bool {
+ return f.Phase == PhaseReconnecting && f.RetryStatus.LastDecision == RetryDecisionProviderHint
+ }, 300*time.Millisecond)
+
+ if reconnecting.RetryStatus.AttemptsUsed != 1 {
+ t.Fatalf("AttemptsUsed = %d, want 1", reconnecting.RetryStatus.AttemptsUsed)
+ }
+ if reconnecting.RetryStatus.LastProviderRetryAfter != 25*time.Millisecond {
+ t.Fatalf("LastProviderRetryAfter = %v, want 25ms", reconnecting.RetryStatus.LastProviderRetryAfter)
+ }
+ if reconnecting.RetryStatus.LastDelay != 25*time.Millisecond {
+ t.Fatalf("LastDelay = %v, want provider hint 25ms", reconnecting.RetryStatus.LastDelay)
+ }
+ if reconnecting.RetryStatus.LastScheduledDelay < 800*time.Millisecond ||
+ reconnecting.RetryStatus.LastScheduledDelay > 1200*time.Millisecond {
+ t.Fatalf("LastScheduledDelay = %v, want first jittered backoff envelope", reconnecting.RetryStatus.LastScheduledDelay)
+ }
+ if reconnecting.RetryStatus.LastErrorKind != hermes.ProviderErrorRateLimit.String() {
+ t.Fatalf("LastErrorKind = %q, want %q", reconnecting.RetryStatus.LastErrorKind, hermes.ProviderErrorRateLimit)
+ }
+ if reconnecting.RetryStatus.LastErrorClass != hermes.ClassRetryable.String() {
+ t.Fatalf("LastErrorClass = %q, want retryable", reconnecting.RetryStatus.LastErrorClass)
+ }
+}
+
type retryAfterClient struct {
firstErr error
calls int
@@ -140,6 +234,22 @@ func (c *retryAfterClient) OpenRunEvents(context.Context, string) (hermes.RunEve
func (c *retryAfterClient) Health(context.Context) error { return nil }
+type statusClient struct {
+ status hermes.ProviderStatus
+}
+
+func (c *statusClient) OpenStream(context.Context, hermes.ChatRequest) (hermes.Stream, error) {
+ return nil, io.EOF
+}
+
+func (c *statusClient) OpenRunEvents(context.Context, string) (hermes.RunEventStream, error) {
+ return nil, hermes.ErrRunEventsNotSupported
+}
+
+func (c *statusClient) Health(context.Context) error { return nil }
+
+func (c *statusClient) ProviderStatus() hermes.ProviderStatus { return c.status }
+
type retryAfterStream struct {
events []hermes.Event
pos int
diff --git a/internal/kernel/toolexec.go b/internal/kernel/toolexec.go
index 805f28298..f78bb1462 100644
--- a/internal/kernel/toolexec.go
+++ b/internal/kernel/toolexec.go
@@ -157,27 +157,54 @@ func (k *Kernel) executeOneToolCall(ctx context.Context, index int, call hermes.
default:
}
- if k.cfg.Tools == nil {
- err := errors.New("no tool registry configured")
- result := toolResult{
- ID: call.ID, Name: call.Name,
- Content: `{"error":"no tool registry configured"}`,
+ executeContextEngineTool := func() indexedToolResult {
+ payload, err := k.cfg.ContextEngine.HandleToolCall(ctx, call.Name, call.Arguments, hermes.ContextToolCallOptions{})
+ if len(payload) == 0 && err != nil {
+ payload = json.RawMessage(fmt.Sprintf(`{"error":%q}`, err.Error()))
}
+ status := "completed"
+ if err != nil {
+ status = "failed"
+ }
+ result := toolResult{ID: call.ID, Name: call.Name, Content: string(payload)}
return indexedToolResult{
Index: index,
Result: result,
- Status: "failed",
+ Status: status,
Err: err,
- Audit: buildAudit("failed", nil, err),
+ Audit: buildAudit(status, payload, err),
}
}
- tool, ok := k.cfg.Tools.Get(call.Name)
- if !ok {
- err := fmt.Errorf("unknown tool: %q", call.Name)
+ var tool tools.Tool
+ if k.cfg.Tools != nil {
+ var ok bool
+ tool, ok = k.cfg.Tools.Get(call.Name)
+ if !ok && k.cfg.ContextEngine != nil {
+ return executeContextEngineTool()
+ }
+ if !ok {
+ err := fmt.Errorf("unknown tool: %q", call.Name)
+ result := toolResult{
+ ID: call.ID, Name: call.Name,
+ Content: fmt.Sprintf(`{"error":"unknown tool: %q"}`, call.Name),
+ }
+ return indexedToolResult{
+ Index: index,
+ Result: result,
+ Status: "failed",
+ Err: err,
+ Audit: buildAudit("failed", nil, err),
+ }
+ }
+ } else {
+ if k.cfg.ContextEngine != nil {
+ return executeContextEngineTool()
+ }
+ err := errors.New("no tool registry configured")
result := toolResult{
ID: call.ID, Name: call.Name,
- Content: fmt.Sprintf(`{"error":"unknown tool: %q"}`, call.Name),
+ Content: `{"error":"no tool registry configured"}`,
}
return indexedToolResult{
Index: index,
diff --git a/internal/progress/health.go b/internal/progress/health.go
index 31763160f..ac9d89a13 100644
--- a/internal/progress/health.go
+++ b/internal/progress/health.go
@@ -56,6 +56,23 @@ type Quarantine struct {
LastCategory FailureCategory `json:"last_category"`
}
+// PlannerVerdict is execution-history metadata about one progress.json item,
+// OWNED by the architecture-planner runtime. Autoloop READS it (to skip rows
+// escalated for human review) and MUST preserve it verbatim across writes
+// (structural via typed JSON round-trip).
+//
+// Symmetric to RowHealth (autoloop-owned + planner-preserved).
+type PlannerVerdict struct {
+ // NeedsHuman is sticky: once true, only a human edit can clear it.
+ // Planner runtime never auto-unsets it.
+ NeedsHuman bool `json:"needs_human,omitempty"`
+ Reason string `json:"reason,omitempty"`
+ Since string `json:"since,omitempty"` // RFC3339; set when NeedsHuman first triggers
+ ReshapeCount int `json:"reshape_count,omitempty"` // monotonic; total times planner reshaped this row
+ LastReshape string `json:"last_reshape,omitempty"` // RFC3339 of most recent reshape
+ LastOutcome string `json:"last_outcome,omitempty"` // "unstuck" | "still_failing" | "no_attempts_yet"
+}
+
// ItemSpecHash returns a stable SHA-256 hex digest of the row's spec fields
// used for quarantine auto-clear detection. Excludes Name, Status, Health,
// and other run-state metadata so a quarantine survives cosmetic edits but
diff --git a/internal/progress/health_compat_test.go b/internal/progress/health_compat_test.go
index e9a7b3edc..0ab102d70 100644
--- a/internal/progress/health_compat_test.go
+++ b/internal/progress/health_compat_test.go
@@ -181,3 +181,53 @@ func TestSaveProgress_IdempotentOnRealCheckedInFile(t *testing.T) {
}
t.Fatalf("SaveProgress not idempotent: lengths differ pass1=%d pass2=%d", len(pass1), len(pass2))
}
+
+func TestSaveProgress_IdempotentWithBothHealthAndVerdict(t *testing.T) {
+ src := filepath.Join("..", "..", "docs", "content", "building-gormes", "architecture_plan", "progress.json")
+ original, err := os.ReadFile(src)
+ if err != nil {
+ t.Skipf("checked-in progress.json not found, skipping: %v", err)
+ }
+
+ tmp1 := filepath.Join(t.TempDir(), "progress.json")
+ if err := os.WriteFile(tmp1, original, 0o644); err != nil {
+ t.Fatalf("write tmp1: %v", err)
+ }
+
+ // Mutation that touches BOTH blocks on the same row.
+ if err := ApplyHealthUpdates(tmp1, []HealthUpdate{{
+ PhaseID: "1",
+ SubphaseID: "1.A",
+ ItemName: "Bubble Tea shell",
+ Mutate: func(h *RowHealth) {
+ h.AttemptCount = 1
+ },
+ }}); err != nil {
+ t.Fatalf("first ApplyHealthUpdates: %v", err)
+ }
+ // Now stamp a PlannerVerdict on the same row via direct Load+Save.
+ prog, _ := Load(tmp1)
+ prog.Phases["1"].Subphases["1.A"].Items[0].PlannerVerdict = &PlannerVerdict{
+ ReshapeCount: 2,
+ LastOutcome: "still_failing",
+ }
+ if err := SaveProgress(tmp1, prog); err != nil {
+ t.Fatalf("SaveProgress 1: %v", err)
+ }
+ pass1, _ := os.ReadFile(tmp1)
+
+ // Round-trip 2: Load + SaveProgress with no mutation. Must be byte-equal.
+ tmp2 := filepath.Join(t.TempDir(), "progress.json")
+ if err := os.WriteFile(tmp2, pass1, 0o644); err != nil {
+ t.Fatalf("write tmp2: %v", err)
+ }
+ prog2, _ := Load(tmp2)
+ if err := SaveProgress(tmp2, prog2); err != nil {
+ t.Fatalf("SaveProgress 2: %v", err)
+ }
+ pass2, _ := os.ReadFile(tmp2)
+
+ if !bytes.Equal(pass1, pass2) {
+ t.Fatalf("SaveProgress not idempotent with both blocks; len pass1=%d pass2=%d", len(pass1), len(pass2))
+ }
+}
diff --git a/internal/progress/health_test.go b/internal/progress/health_test.go
index 28ff206eb..8b5f5cada 100644
--- a/internal/progress/health_test.go
+++ b/internal/progress/health_test.go
@@ -230,3 +230,55 @@ func TestApplyHealthUpdates_UnknownRowReturnsError(t *testing.T) {
t.Fatal("expected error when target row does not exist")
}
}
+
+func TestPlannerVerdict_RoundTrip(t *testing.T) {
+ verdict := &PlannerVerdict{
+ NeedsHuman: true,
+ Reason: "auto: 3 reshapes without unsticking; last category report_validation_failed",
+ Since: "2026-04-24T12:00:00Z",
+ ReshapeCount: 3,
+ LastReshape: "2026-04-24T11:00:00Z",
+ LastOutcome: "still_failing",
+ }
+
+ data, err := json.Marshal(verdict)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+
+ var got PlannerVerdict
+ if err := json.Unmarshal(data, &got); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if !got.NeedsHuman {
+ t.Fatal("NeedsHuman should round-trip true")
+ }
+ if got.ReshapeCount != 3 {
+ t.Fatalf("ReshapeCount = %d, want 3", got.ReshapeCount)
+ }
+ if got.LastOutcome != "still_failing" {
+ t.Fatalf("LastOutcome = %q, want still_failing", got.LastOutcome)
+ }
+}
+
+func TestPlannerVerdict_OmitemptyKeepsZeroFieldsOut(t *testing.T) {
+ v := &PlannerVerdict{}
+ data, err := json.Marshal(v)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ if string(data) != "{}" {
+ t.Fatalf("zero-value PlannerVerdict should marshal to {}, got %s", data)
+ }
+}
+
+func TestItem_PlannerVerdictOmitemptyByDefault(t *testing.T) {
+ item := &Item{Name: "x", Status: StatusPlanned}
+ data, err := json.Marshal(item)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ if strings.Contains(string(data), "planner_verdict") {
+ t.Fatalf("Item with no verdict should not emit planner_verdict key, got %s", data)
+ }
+}
diff --git a/internal/progress/preservation_test.go b/internal/progress/preservation_test.go
new file mode 100644
index 000000000..61f9c93f5
--- /dev/null
+++ b/internal/progress/preservation_test.go
@@ -0,0 +1,184 @@
+package progress
+
+import (
+ "os"
+ "path/filepath"
+ "reflect"
+ "testing"
+)
+
+// TestSymmetricPreservation_AutoloopWritesPreserveVerdict verifies that
+// autoloop's ApplyHealthUpdates does not erase Item.PlannerVerdict, which
+// the planner owns. The preservation is structural via typed JSON round-trip.
+func TestSymmetricPreservation_AutoloopWritesPreserveVerdict(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "progress.json")
+ body := `{
+ "version": "1",
+ "phases": {
+ "1": {
+ "name": "P",
+ "subphases": {
+ "1.A": {
+ "name": "S",
+ "items": [
+ {"name": "row-1", "status": "planned", "contract": "do x",
+ "health": {"attempt_count": 2, "consecutive_failures": 2},
+ "planner_verdict": {"reshape_count": 1, "last_outcome": "still_failing"}}
+ ]
+ }
+ }
+ }
+ }
+}
+`
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+ t.Fatalf("write: %v", err)
+ }
+
+ // Autoloop-side write: increment Health.AttemptCount.
+ err := ApplyHealthUpdates(path, []HealthUpdate{{
+ PhaseID: "1", SubphaseID: "1.A", ItemName: "row-1",
+ Mutate: func(h *RowHealth) {
+ h.AttemptCount = 3
+ h.ConsecutiveFailures = 3
+ },
+ }})
+ if err != nil {
+ t.Fatalf("ApplyHealthUpdates: %v", err)
+ }
+
+ prog, err := Load(path)
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ row := &prog.Phases["1"].Subphases["1.A"].Items[0]
+ if row.PlannerVerdict == nil {
+ t.Fatal("PlannerVerdict was erased by autoloop's write")
+ }
+ if row.PlannerVerdict.ReshapeCount != 1 {
+ t.Fatalf("PlannerVerdict.ReshapeCount = %d, want 1 (preserved)", row.PlannerVerdict.ReshapeCount)
+ }
+ if row.PlannerVerdict.LastOutcome != "still_failing" {
+ t.Fatalf("PlannerVerdict.LastOutcome = %q, want still_failing (preserved)", row.PlannerVerdict.LastOutcome)
+ }
+ // The Health update did land:
+ if row.Health.AttemptCount != 3 {
+ t.Fatalf("Health.AttemptCount = %d, want 3", row.Health.AttemptCount)
+ }
+}
+
+// TestSymmetricPreservation_PlannerWritesPreserveHealth verifies that a
+// SaveProgress call with verdict-only changes preserves Health.
+func TestSymmetricPreservation_PlannerWritesPreserveHealth(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "progress.json")
+ body := `{
+ "version": "1",
+ "phases": {
+ "1": {
+ "name": "P",
+ "subphases": {
+ "1.A": {
+ "name": "S",
+ "items": [
+ {"name": "row-1", "status": "planned", "contract": "do x",
+ "health": {"attempt_count": 2, "consecutive_failures": 2,
+ "quarantine": {"reason": "auto", "threshold": 3, "spec_hash": "abc"}}}
+ ]
+ }
+ }
+ }
+ }
+}
+`
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+ t.Fatalf("write: %v", err)
+ }
+
+ prog, err := Load(path)
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ originalHealth := *prog.Phases["1"].Subphases["1.A"].Items[0].Health
+
+ // Planner-side write: stamp PlannerVerdict (mimics StampVerdicts).
+ prog.Phases["1"].Subphases["1.A"].Items[0].PlannerVerdict = &PlannerVerdict{
+ ReshapeCount: 1,
+ LastReshape: "2026-04-24T12:00:00Z",
+ LastOutcome: "still_failing",
+ }
+ if err := SaveProgress(path, prog); err != nil {
+ t.Fatalf("SaveProgress: %v", err)
+ }
+
+ // Reload and verify Health survived byte-equal.
+ prog2, err := Load(path)
+ if err != nil {
+ t.Fatalf("Load 2: %v", err)
+ }
+ row := &prog2.Phases["1"].Subphases["1.A"].Items[0]
+ if !reflect.DeepEqual(*row.Health, originalHealth) {
+ t.Fatalf("Health was modified by planner's write\nbefore: %+v\nafter: %+v", originalHealth, *row.Health)
+ }
+ if row.PlannerVerdict == nil || row.PlannerVerdict.ReshapeCount != 1 {
+ t.Fatal("PlannerVerdict was not persisted")
+ }
+}
+
+// TestSymmetricPreservation_BothBlocksRoundTrip combines both directions
+// and asserts the spec hash is stable after a full round-trip with both
+// blocks populated.
+func TestSymmetricPreservation_BothBlocksRoundTrip(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "progress.json")
+ body := `{
+ "version": "1",
+ "phases": {
+ "1": {
+ "name": "P",
+ "subphases": {
+ "1.A": {
+ "name": "S",
+ "items": [
+ {"name": "row-1", "status": "planned", "contract": "do x", "blocked_by": ["dep-a"],
+ "health": {"attempt_count": 1},
+ "planner_verdict": {"needs_human": true, "reason": "auto", "reshape_count": 4}}
+ ]
+ }
+ }
+ }
+ }
+}
+`
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+ t.Fatalf("write: %v", err)
+ }
+
+ prog, err := Load(path)
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ hashBefore := ItemSpecHash(&prog.Phases["1"].Subphases["1.A"].Items[0])
+
+ if err := SaveProgress(path, prog); err != nil {
+ t.Fatalf("SaveProgress: %v", err)
+ }
+
+ prog2, err := Load(path)
+ if err != nil {
+ t.Fatalf("Load 2: %v", err)
+ }
+ row := &prog2.Phases["1"].Subphases["1.A"].Items[0]
+ hashAfter := ItemSpecHash(row)
+
+ if hashBefore != hashAfter {
+ t.Fatalf("spec hash changed across round-trip:\nbefore: %s\nafter: %s", hashBefore, hashAfter)
+ }
+ if row.Health == nil || row.PlannerVerdict == nil {
+ t.Fatal("one of the blocks went missing across round-trip")
+ }
+ if !row.PlannerVerdict.NeedsHuman {
+ t.Fatal("PlannerVerdict.NeedsHuman flipped across round-trip")
+ }
+}
diff --git a/internal/progress/progress.go b/internal/progress/progress.go
index de112b889..155012396 100644
--- a/internal/progress/progress.go
+++ b/internal/progress/progress.go
@@ -102,6 +102,11 @@ type Item struct {
// must preserve this block verbatim across regenerations (see
// docs/superpowers/specs/2026-04-24-reactive-autoloop-design.md).
Health *RowHealth `json:"health,omitempty"`
+ // PlannerVerdict is execution-history metadata owned by the planner
+ // runtime. Autoloop reads it (to skip human-escalated rows) and must
+ // preserve it verbatim across writes (see
+ // docs/superpowers/specs/2026-04-24-planner-self-healing-design.md).
+ PlannerVerdict *PlannerVerdict `json:"planner_verdict,omitempty"`
}
type Subphase struct {
diff --git a/internal/progress/progress_marshal.go b/internal/progress/progress_marshal.go
index 536091675..5304b9105 100644
--- a/internal/progress/progress_marshal.go
+++ b/internal/progress/progress_marshal.go
@@ -23,7 +23,7 @@ func (p Progress) MarshalJSON() ([]byte, error) {
Meta: p.Meta,
Phases: phases,
}
- return json.Marshal(aux)
+ return marshalNoEscape(aux)
}
// MarshalJSON emits Phase with subphase keys in natural-numeric order so
@@ -46,7 +46,7 @@ func (ph Phase) MarshalJSON() ([]byte, error) {
DependencyNote: ph.DependencyNote,
Subphases: subphases,
}
- return json.Marshal(aux)
+ return marshalNoEscape(aux)
}
// marshalOrderedPhases emits a JSON object whose keys are the phase IDs of
@@ -69,7 +69,7 @@ func marshalOrderedPhases(m map[string]Phase) (json.RawMessage, error) {
buf.Write(k)
buf.WriteByte(':')
v := m[key]
- body, err := json.Marshal(v)
+ body, err := marshalNoEscape(v)
if err != nil {
return nil, fmt.Errorf("marshal phase %q: %w", key, err)
}
@@ -97,7 +97,7 @@ func marshalOrderedSubphases(m map[string]Subphase) (json.RawMessage, error) {
buf.Write(k)
buf.WriteByte(':')
v := m[key]
- body, err := json.Marshal(v)
+ body, err := marshalNoEscape(v)
if err != nil {
return nil, fmt.Errorf("marshal subphase %q: %w", key, err)
}
@@ -106,3 +106,13 @@ func marshalOrderedSubphases(m map[string]Subphase) (json.RawMessage, error) {
buf.WriteByte('}')
return buf.Bytes(), nil
}
+
+func marshalNoEscape(v any) ([]byte, error) {
+ var buf bytes.Buffer
+ enc := json.NewEncoder(&buf)
+ enc.SetEscapeHTML(false)
+ if err := enc.Encode(v); err != nil {
+ return nil, err
+ }
+ return bytes.TrimSuffix(buf.Bytes(), []byte("\n")), nil
+}
diff --git a/internal/progress/progress_marshal_test.go b/internal/progress/progress_marshal_test.go
new file mode 100644
index 000000000..baa9547ba
--- /dev/null
+++ b/internal/progress/progress_marshal_test.go
@@ -0,0 +1,49 @@
+package progress
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestSaveProgressDoesNotHTMLEscapeNestedProgressText(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "progress.json")
+ prog := &Progress{
+ Phases: map[string]Phase{
+ "3": {
+ Name: "Memory",
+ Deliverable: "SQLite -> graph & recall",
+ Subphases: map[string]Subphase{
+ "3.F": {
+ Name: "Goncho",
+ Items: []Item{{
+ Name: "gormes session export --format=markdown",
+ Status: StatusPlanned,
+ Contract: "Helper ports keep `A -> B` and `` text readable & diff-stable.",
+ }},
+ },
+ },
+ },
+ },
+ }
+
+ if err := SaveProgress(path, prog); err != nil {
+ t.Fatalf("SaveProgress: %v", err)
+ }
+ body, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("ReadFile: %v", err)
+ }
+ text := string(body)
+ for _, escaped := range []string{`\u003c`, `\u003e`, `\u0026`} {
+ if strings.Contains(text, escaped) {
+ t.Fatalf("SaveProgress output contains HTML escape %s:\n%s", escaped, text)
+ }
+ }
+ for _, want := range []string{"SQLite -> graph & recall", "gormes session export --format=markdown", "`A -> B` and ``"} {
+ if !strings.Contains(text, want) {
+ t.Fatalf("SaveProgress output missing literal %q:\n%s", want, text)
+ }
+ }
+}
diff --git a/internal/skills/commands.go b/internal/skills/commands.go
new file mode 100644
index 000000000..33624d6c1
--- /dev/null
+++ b/internal/skills/commands.go
@@ -0,0 +1,144 @@
+package skills
+
+import (
+ "context"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strings"
+)
+
+var (
+ invalidCommandCharsRE = regexp.MustCompile(`[^a-z0-9-]`)
+ multiHyphenRE = regexp.MustCompile(`-{2,}`)
+)
+
+type SkillSlashCommand struct {
+ Command string
+ Name string
+ Description string
+ SkillDir string
+ Skill Skill
+}
+
+type SlashMessageOptions struct {
+ RuntimeNote string
+}
+
+func (r *Runtime) SkillSlashCommands(ctx context.Context, opts RuntimeOptions) ([]SkillSlashCommand, []SkillStatus, error) {
+ if r == nil || r.store == nil {
+ return nil, nil, nil
+ }
+ snapshot, err := r.store.SnapshotActive()
+ if err != nil {
+ return nil, nil, err
+ }
+ prepared, statuses := prepareSkills(ctx, snapshot.Skills, opts)
+ return BuildSkillSlashCommands(prepared), statuses, nil
+}
+
+func BuildSkillSlashCommands(skills []Skill) []SkillSlashCommand {
+ if len(skills) == 0 {
+ return nil
+ }
+ commands := make([]SkillSlashCommand, 0, len(skills))
+ seen := map[string]bool{}
+ for _, skill := range skills {
+ commandName := normalizeSkillCommandName(skill.Name)
+ if commandName == "" {
+ continue
+ }
+ command := "/" + commandName
+ if seen[command] {
+ continue
+ }
+ seen[command] = true
+ commands = append(commands, SkillSlashCommand{
+ Command: command,
+ Name: skill.Name,
+ Description: slashCommandDescription(skill),
+ SkillDir: skillDir(skill),
+ Skill: skill,
+ })
+ }
+ sort.SliceStable(commands, func(i, j int) bool {
+ if commands[i].Command != commands[j].Command {
+ return commands[i].Command < commands[j].Command
+ }
+ return commands[i].Name < commands[j].Name
+ })
+ return commands
+}
+
+func ResolveSkillSlashCommand(commands []SkillSlashCommand, raw string) (SkillSlashCommand, bool) {
+ key := strings.ToLower(strings.TrimSpace(raw))
+ key = strings.TrimPrefix(key, "/")
+ if i := strings.IndexAny(key, " \t\r\n"); i >= 0 {
+ key = key[:i]
+ }
+ key = strings.ReplaceAll(key, "_", "-")
+ if key == "" {
+ return SkillSlashCommand{}, false
+ }
+ key = "/" + key
+ for _, command := range commands {
+ if command.Command == key {
+ return command, true
+ }
+ }
+ return SkillSlashCommand{}, false
+}
+
+func BuildSkillSlashCommandMessage(command SkillSlashCommand, userInstruction string, opts SlashMessageOptions) string {
+ parts := []string{
+ `[SYSTEM: The user has invoked the "` + command.Name + `" skill, indicating they want you to follow its instructions. The full skill content is loaded below.]`,
+ "",
+ strings.TrimSpace(command.Skill.Body),
+ }
+
+ if command.SkillDir != "" {
+ parts = append(parts,
+ "",
+ "[Skill directory: "+command.SkillDir+"]",
+ "Resolve any relative paths in this skill against that directory before reading files or running scripts.",
+ )
+ }
+
+ if instruction := strings.TrimSpace(userInstruction); instruction != "" {
+ parts = append(parts,
+ "",
+ "The user has provided the following instruction alongside the skill invocation: "+instruction,
+ )
+ }
+ if note := strings.TrimSpace(opts.RuntimeNote); note != "" {
+ parts = append(parts,
+ "",
+ "[Runtime note: "+note+"]",
+ )
+ }
+
+ return strings.Join(parts, "\n")
+}
+
+func normalizeSkillCommandName(name string) string {
+ name = strings.ToLower(strings.TrimSpace(name))
+ name = strings.ReplaceAll(name, "_", "-")
+ name = strings.ReplaceAll(name, " ", "-")
+ name = invalidCommandCharsRE.ReplaceAllString(name, "")
+ name = multiHyphenRE.ReplaceAllString(name, "-")
+ return strings.Trim(name, "-")
+}
+
+func slashCommandDescription(skill Skill) string {
+ if description := strings.TrimSpace(skill.Description); description != "" {
+ return description
+ }
+ return "Invoke the " + skill.Name + " skill"
+}
+
+func skillDir(skill Skill) string {
+ if skill.Path == "" {
+ return ""
+ }
+ return filepath.Dir(skill.Path)
+}
diff --git a/internal/skills/parser.go b/internal/skills/parser.go
index 039e1cd27..72543cfe5 100644
--- a/internal/skills/parser.go
+++ b/internal/skills/parser.go
@@ -3,6 +3,8 @@ package skills
import (
"fmt"
"strings"
+
+ "gopkg.in/yaml.v3"
)
// Parse converts a SKILL.md document into a typed Skill.
@@ -34,23 +36,11 @@ func Parse(raw []byte, maxBytes int) (Skill, error) {
var skill Skill
skill.RawBytes = len(raw)
- for _, line := range lines[1:end] {
- if line == "" || line[0] == ' ' || line[0] == '\t' {
- continue
- }
- key, value, ok := strings.Cut(line, ":")
- if !ok {
- continue
- }
- key = strings.TrimSpace(key)
- value = trimScalar(value)
- switch key {
- case "name":
- skill.Name = value
- case "description":
- skill.Description = value
- }
- }
+ frontmatter := parseFrontmatter(strings.Join(lines[1:end], "\n"))
+ skill.Name = frontmatterString(frontmatter, "name")
+ skill.Description = frontmatterString(frontmatter, "description")
+ skill.Platforms = frontmatterStringList(frontmatter["platforms"])
+ skill.RequiredEnvVars = requiredEnvVars(frontmatter)
skill.Body = strings.Trim(strings.Join(lines[end+1:], "\n"), "\n")
if err := skill.Validate(maxBytes); err != nil {
@@ -71,3 +61,116 @@ func trimScalar(value string) string {
}
return value
}
+
+func parseFrontmatter(raw string) map[string]any {
+ out := map[string]any{}
+ if strings.TrimSpace(raw) == "" {
+ return out
+ }
+ if err := yaml.Unmarshal([]byte(raw), &out); err == nil {
+ return out
+ }
+
+ for _, line := range strings.Split(raw, "\n") {
+ if line == "" || line[0] == ' ' || line[0] == '\t' {
+ continue
+ }
+ key, value, ok := strings.Cut(line, ":")
+ if !ok {
+ continue
+ }
+ out[strings.TrimSpace(key)] = trimScalar(value)
+ }
+ return out
+}
+
+func frontmatterString(frontmatter map[string]any, key string) string {
+ value, ok := frontmatter[key]
+ if !ok || value == nil {
+ return ""
+ }
+ return strings.TrimSpace(fmt.Sprint(value))
+}
+
+func frontmatterStringList(value any) []string {
+ switch v := value.(type) {
+ case nil:
+ return nil
+ case []any:
+ out := make([]string, 0, len(v))
+ for _, item := range v {
+ out = appendStringValue(out, item)
+ }
+ return dedupeStrings(out)
+ case []string:
+ return dedupeStrings(v)
+ case string:
+ return parseInlineStringList(v)
+ default:
+ return appendStringValue(nil, v)
+ }
+}
+
+func requiredEnvVars(frontmatter map[string]any) []string {
+ var out []string
+ out = append(out, frontmatterStringList(frontmatter["required_environment_variables"])...)
+ if prereqs, ok := frontmatter["prerequisites"].(map[string]any); ok {
+ out = append(out, frontmatterStringList(prereqs["env_vars"])...)
+ }
+ return dedupeStrings(out)
+}
+
+func appendStringValue(out []string, value any) []string {
+ switch v := value.(type) {
+ case nil:
+ return out
+ case map[string]any:
+ if name := frontmatterString(v, "name"); name != "" {
+ return append(out, name)
+ }
+ if envVar := frontmatterString(v, "env_var"); envVar != "" {
+ return append(out, envVar)
+ }
+ return out
+ default:
+ if s := strings.TrimSpace(fmt.Sprint(v)); s != "" {
+ return append(out, s)
+ }
+ return out
+ }
+}
+
+func parseInlineStringList(raw string) []string {
+ raw = strings.TrimSpace(raw)
+ raw = strings.TrimPrefix(raw, "[")
+ raw = strings.TrimSuffix(raw, "]")
+ if raw == "" {
+ return nil
+ }
+ parts := strings.Split(raw, ",")
+ out := make([]string, 0, len(parts))
+ for _, part := range parts {
+ part = strings.Trim(strings.TrimSpace(part), "\"'")
+ if part != "" {
+ out = append(out, part)
+ }
+ }
+ return dedupeStrings(out)
+}
+
+func dedupeStrings(in []string) []string {
+ if len(in) == 0 {
+ return nil
+ }
+ seen := map[string]bool{}
+ out := make([]string, 0, len(in))
+ for _, value := range in {
+ value = strings.TrimSpace(value)
+ if value == "" || seen[value] {
+ continue
+ }
+ seen[value] = true
+ out = append(out, value)
+ }
+ return out
+}
diff --git a/internal/skills/preprocess.go b/internal/skills/preprocess.go
new file mode 100644
index 000000000..ee52a031a
--- /dev/null
+++ b/internal/skills/preprocess.go
@@ -0,0 +1,143 @@
+package skills
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "os/exec"
+ "regexp"
+ "strings"
+ "time"
+)
+
+const (
+ defaultInlineShellTimeout = 10 * time.Second
+ defaultInlineShellMaxOutput = 4000
+)
+
+var (
+ templateVarRE = regexp.MustCompile(`\$\{(HERMES_SKILL_DIR|HERMES_SESSION_ID|GORMES_SKILL_DIR|GORMES_SESSION_ID)\}`)
+ inlineShellRE = regexp.MustCompile("!`([^`\n]+)`")
+ errBashMissing = errors.New("bash not found")
+)
+
+// PreprocessOptions controls deterministic SKILL.md preprocessing. The zero
+// value substitutes template variables and leaves inline shell snippets literal.
+type PreprocessOptions struct {
+ SkillDir string
+ SessionID string
+
+ DisableTemplateVars bool
+ InlineShell bool
+ InlineShellTimeout time.Duration
+ InlineShellMaxOutput int
+}
+
+// PreprocessSkillContent applies prompt-safe SKILL.md preprocessing. Inline
+// shell snippets only run when explicitly enabled by the caller.
+func PreprocessSkillContent(ctx context.Context, content string, opts PreprocessOptions) (string, error) {
+ if content == "" {
+ return content, nil
+ }
+ if !opts.DisableTemplateVars {
+ content = substituteTemplateVars(content, opts)
+ }
+ if !opts.InlineShell || !strings.Contains(content, "!`") {
+ return content, nil
+ }
+ return expandInlineShell(ctx, content, opts)
+}
+
+func substituteTemplateVars(content string, opts PreprocessOptions) string {
+ return templateVarRE.ReplaceAllStringFunc(content, func(token string) string {
+ match := templateVarRE.FindStringSubmatch(token)
+ if len(match) != 2 {
+ return token
+ }
+ switch match[1] {
+ case "HERMES_SKILL_DIR", "GORMES_SKILL_DIR":
+ if opts.SkillDir != "" {
+ return opts.SkillDir
+ }
+ case "HERMES_SESSION_ID", "GORMES_SESSION_ID":
+ if opts.SessionID != "" {
+ return opts.SessionID
+ }
+ }
+ return token
+ })
+}
+
+func expandInlineShell(ctx context.Context, content string, opts PreprocessOptions) (string, error) {
+ var firstErr error
+ rendered := inlineShellRE.ReplaceAllStringFunc(content, func(match string) string {
+ if firstErr != nil {
+ return match
+ }
+ submatch := inlineShellRE.FindStringSubmatch(match)
+ if len(submatch) != 2 {
+ return match
+ }
+ command := strings.TrimSpace(submatch[1])
+ if command == "" {
+ return ""
+ }
+ output, err := runInlineShell(ctx, command, opts)
+ if err != nil {
+ firstErr = err
+ return match
+ }
+ return output
+ })
+ if firstErr != nil {
+ return "", firstErr
+ }
+ return rendered, nil
+}
+
+func runInlineShell(ctx context.Context, command string, opts PreprocessOptions) (string, error) {
+ timeout := opts.InlineShellTimeout
+ if timeout <= 0 {
+ timeout = defaultInlineShellTimeout
+ }
+ maxOutput := opts.InlineShellMaxOutput
+ if maxOutput <= 0 {
+ maxOutput = defaultInlineShellMaxOutput
+ }
+
+ shellCtx, cancel := context.WithTimeout(ctx, timeout)
+ defer cancel()
+
+ cmd := exec.CommandContext(shellCtx, "bash", "-c", command)
+ if opts.SkillDir != "" {
+ cmd.Dir = opts.SkillDir
+ }
+ var stdout bytes.Buffer
+ var stderr bytes.Buffer
+ cmd.Stdout = &stdout
+ cmd.Stderr = &stderr
+
+ err := cmd.Run()
+ if shellCtx.Err() == context.DeadlineExceeded {
+ return "", fmt.Errorf("inline-shell timeout after %s: %s", timeout, command)
+ }
+ if errors.Is(err, exec.ErrNotFound) {
+ return "", errBashMissing
+ }
+ if err != nil {
+ if detail := strings.TrimSpace(stderr.String()); detail != "" {
+ return "", fmt.Errorf("inline-shell error: %s", detail)
+ }
+ return "", fmt.Errorf("inline-shell error: %w", err)
+ }
+
+ output := strings.TrimRight(stdout.String(), "\n")
+ if output == "" {
+ output = strings.TrimRight(stderr.String(), "\n")
+ }
+ if len(output) > maxOutput {
+ output = output[:maxOutput] + "...[truncated]"
+ }
+ return output, nil
+}
diff --git a/internal/skills/preprocessing_commands_test.go b/internal/skills/preprocessing_commands_test.go
new file mode 100644
index 000000000..58332b765
--- /dev/null
+++ b/internal/skills/preprocessing_commands_test.go
@@ -0,0 +1,240 @@
+package skills_test
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/TrebuchetDynamics/gormes-agent/internal/gateway"
+ "github.com/TrebuchetDynamics/gormes-agent/internal/skills"
+)
+
+func TestPreprocessSkillContentSubstitutesTemplatesAndKeepsShellLiteralByDefault(t *testing.T) {
+ dir := t.TempDir()
+ content := "Run ${HERMES_SKILL_DIR}/scripts/do.sh in ${HERMES_SESSION_ID}; shell !`printf SHOULD_NOT_RUN`"
+
+ got, err := skills.PreprocessSkillContent(context.Background(), content, skills.PreprocessOptions{
+ SkillDir: dir,
+ SessionID: "session-123",
+ })
+ if err != nil {
+ t.Fatalf("PreprocessSkillContent() error = %v", err)
+ }
+
+ want := "Run " + dir + "/scripts/do.sh in session-123; shell !`printf SHOULD_NOT_RUN`"
+ if got != want {
+ t.Fatalf("PreprocessSkillContent() = %q, want %q", got, want)
+ }
+ if strings.Contains(got, "shell SHOULD_NOT_RUN") {
+ t.Fatalf("inline shell ran with default options: %q", got)
+ }
+}
+
+func TestPreprocessSkillContentRunsBoundedInlineShellWhenEnabled(t *testing.T) {
+ dir := t.TempDir()
+
+ got, err := skills.PreprocessSkillContent(context.Background(), "Value: !`printf 123456789`", skills.PreprocessOptions{
+ SkillDir: dir,
+ InlineShell: true,
+ InlineShellTimeout: time.Second,
+ InlineShellMaxOutput: 4,
+ })
+ if err != nil {
+ t.Fatalf("PreprocessSkillContent() error = %v", err)
+ }
+ if got != "Value: 1234...[truncated]" {
+ t.Fatalf("PreprocessSkillContent() = %q, want bounded output", got)
+ }
+}
+
+func TestRuntimeBuildSkillBlockReportsUnavailableSkillsWithoutPromptInjection(t *testing.T) {
+ root := t.TempDir()
+ writeSkill(t, root, "active/enabled", `---
+name: enabled-skill
+description: Use deterministic vars
+---
+
+Use ${HERMES_SESSION_ID} from ${HERMES_SKILL_DIR}.`)
+ writeSkill(t, root, "active/disabled", `---
+name: disabled-skill
+description: Must not load
+---
+
+disabled body`)
+ writeSkill(t, root, "active/mac-only", `---
+name: mac-only
+description: Darwin only
+platforms: [macos]
+---
+
+mac body`)
+ writeSkill(t, root, "active/needs-key", `---
+name: needs-key
+description: Requires setup
+required_environment_variables: [TENOR_API_KEY]
+---
+
+secret body`)
+ writeSkill(t, root, "active/bad-shell", `---
+name: bad-shell
+description: Fails preprocessing
+---
+
+Bad !`+"`exit 7`"+``)
+
+ runtime := skills.NewRuntime(root, 8*1024, 5, "")
+ block, names, statuses, err := runtime.BuildSkillBlockWithOptions(context.Background(), "enabled disabled mac needs key bad shell", skills.RuntimeOptions{
+ DisabledSkillNames: map[string]bool{"disabled-skill": true},
+ Platform: "linux",
+ Env: map[string]string{},
+ Preprocess: skills.PreprocessOptions{
+ SessionID: "session-xyz",
+ InlineShell: true,
+ InlineShellTimeout: time.Second,
+ },
+ })
+ if err != nil {
+ t.Fatalf("BuildSkillBlockWithOptions() error = %v", err)
+ }
+
+ if !reflect.DeepEqual(names, []string{"enabled-skill"}) {
+ t.Fatalf("names = %#v, want enabled skill only", names)
+ }
+ for _, forbidden := range []string{"disabled body", "mac body", "secret body", "Bad !`exit 7`"} {
+ if strings.Contains(block, forbidden) {
+ t.Fatalf("block injected unavailable skill content %q:\n%s", forbidden, block)
+ }
+ }
+ if !strings.Contains(block, "Use session-xyz from "+filepath.Join(root, "active", "enabled")+".") {
+ t.Fatalf("block did not contain preprocessed enabled skill:\n%s", block)
+ }
+
+ gotStatuses := statusByName(statuses)
+ wantStatuses := map[string]skills.SkillStatusCode{
+ "enabled-skill": skills.SkillStatusAvailable,
+ "disabled-skill": skills.SkillStatusDisabled,
+ "mac-only": skills.SkillStatusUnsupported,
+ "needs-key": skills.SkillStatusMissingPrerequisite,
+ "bad-shell": skills.SkillStatusPreprocessingFailed,
+ }
+ if !reflect.DeepEqual(gotStatuses, wantStatuses) {
+ t.Fatalf("statuses = %#v, want %#v", gotStatuses, wantStatuses)
+ }
+}
+
+func TestSkillSlashCommandsSkipUnavailableSkillsAndBuildStableMessage(t *testing.T) {
+ root := t.TempDir()
+ mediaDir := writeSkill(t, root, "active/media", `---
+name: Jellyfin + Jellystat 24h Summary
+description: Summarize media usage
+---
+
+Run ${HERMES_SKILL_DIR}/scripts/report.sh.`)
+ writeSkill(t, root, "active/disabled", `---
+name: disabled-skill
+description: Must not be invokable
+---
+
+disabled body`)
+ writeSkill(t, root, "active/mac-only", `---
+name: mac-only
+description: Darwin only
+platforms: [macos]
+---
+
+mac body`)
+
+ runtime := skills.NewRuntime(root, 8*1024, 5, "")
+ commands, statuses, err := runtime.SkillSlashCommands(context.Background(), skills.RuntimeOptions{
+ DisabledSkillNames: map[string]bool{"disabled-skill": true},
+ Platform: "linux",
+ })
+ if err != nil {
+ t.Fatalf("SkillSlashCommands() error = %v", err)
+ }
+
+ if len(commands) != 1 {
+ t.Fatalf("len(commands) = %d, want 1: %#v", len(commands), commands)
+ }
+ cmd := commands[0]
+ if cmd.Command != "/jellyfin-jellystat-24h-summary" {
+ t.Fatalf("command key = %q, want sanitized skill command", cmd.Command)
+ }
+ if statusByName(statuses)["disabled-skill"] != skills.SkillStatusDisabled {
+ t.Fatalf("disabled skill status not reported: %#v", statuses)
+ }
+ if _, ok := skills.ResolveSkillSlashCommand(commands, "jellyfin_jellystat_24h_summary"); !ok {
+ t.Fatalf("underscore command form did not resolve")
+ }
+ if _, ok := skills.ResolveSkillSlashCommand(commands, "disabled-skill"); ok {
+ t.Fatalf("disabled skill resolved as a slash command")
+ }
+
+ message := skills.BuildSkillSlashCommandMessage(cmd, "compose now", skills.SlashMessageOptions{
+ RuntimeNote: "telegram",
+ })
+ for _, want := range []string{
+ `[SYSTEM: The user has invoked the "Jellyfin + Jellystat 24h Summary" skill`,
+ "Run " + mediaDir + "/scripts/report.sh.",
+ "[Skill directory: " + mediaDir + "]",
+ "The user has provided the following instruction alongside the skill invocation: compose now",
+ "[Runtime note: telegram]",
+ } {
+ if !strings.Contains(message, want) {
+ t.Fatalf("message missing %q:\n%s", want, message)
+ }
+ }
+ if strings.Contains(message, "disabled body") || strings.Contains(message, "mac body") {
+ t.Fatalf("message injected unavailable skill content:\n%s", message)
+ }
+
+ extras := []gateway.PlatformCommand{{Name: strings.TrimPrefix(cmd.Command, "/"), Description: cmd.Description}}
+ tg1 := gateway.TelegramBotCommandsWith(extras)
+ tg2 := gateway.TelegramBotCommandsWith(extras)
+ if !reflect.DeepEqual(tg1, tg2) {
+ t.Fatalf("TelegramBotCommandsWith unstable:\n%#v\n%#v", tg1, tg2)
+ }
+ if !platformCommandsContain(tg1, "jellyfin_jellystat_24h_summary") {
+ t.Fatalf("TelegramBotCommandsWith missing sanitized skill command: %#v", tg1)
+ }
+ slack := gateway.SlackSubcommandMapWith(extras)
+ if slack["jellyfin-jellystat-24h-summary"] != "/jellyfin-jellystat-24h-summary" {
+ t.Fatalf("SlackSubcommandMapWith missing skill command: %#v", slack)
+ }
+ if _, ok := slack["disabled-skill"]; ok {
+ t.Fatalf("SlackSubcommandMapWith exposed disabled skill: %#v", slack)
+ }
+}
+
+func writeSkill(t *testing.T, root, rel, raw string) string {
+ t.Helper()
+ dir := filepath.Join(root, rel)
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatalf("MkdirAll(%q): %v", dir, err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(raw), 0o644); err != nil {
+ t.Fatalf("WriteFile(%q): %v", dir, err)
+ }
+ return dir
+}
+
+func statusByName(statuses []skills.SkillStatus) map[string]skills.SkillStatusCode {
+ out := make(map[string]skills.SkillStatusCode, len(statuses))
+ for _, status := range statuses {
+ out[status.Name] = status.Status
+ }
+ return out
+}
+
+func platformCommandsContain(commands []gateway.PlatformCommand, name string) bool {
+ for _, cmd := range commands {
+ if cmd.Name == name {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/skills/status.go b/internal/skills/status.go
new file mode 100644
index 000000000..b3e6db72a
--- /dev/null
+++ b/internal/skills/status.go
@@ -0,0 +1,143 @@
+package skills
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+)
+
+type SkillStatusCode string
+
+const (
+ SkillStatusAvailable SkillStatusCode = "available"
+ SkillStatusDisabled SkillStatusCode = "disabled"
+ SkillStatusUnsupported SkillStatusCode = "unsupported"
+ SkillStatusMissingPrerequisite SkillStatusCode = "missing-prerequisite"
+ SkillStatusPreprocessingFailed SkillStatusCode = "preprocessing-failed"
+)
+
+type SkillStatus struct {
+ Name string
+ Path string
+ Status SkillStatusCode
+ Reason string
+}
+
+type RuntimeOptions struct {
+ DisabledSkillNames map[string]bool
+ Platform string
+ Env map[string]string
+ Preprocess PreprocessOptions
+}
+
+func prepareSkills(ctx context.Context, in []Skill, opts RuntimeOptions) ([]Skill, []SkillStatus) {
+ prepared := make([]Skill, 0, len(in))
+ statuses := make([]SkillStatus, 0, len(in))
+ for _, skill := range in {
+ status := SkillStatus{Name: skill.Name, Path: skill.Path, Status: SkillStatusAvailable}
+
+ switch {
+ case isSkillDisabled(skill, opts.DisabledSkillNames):
+ status.Status = SkillStatusDisabled
+ status.Reason = "skill disabled"
+ case !skillMatchesPlatform(skill, opts.Platform):
+ status.Status = SkillStatusUnsupported
+ status.Reason = "skill unsupported on platform " + resolvedPlatform(opts.Platform)
+ case len(missingRequiredEnv(skill, opts.Env)) > 0:
+ missing := missingRequiredEnv(skill, opts.Env)
+ status.Status = SkillStatusMissingPrerequisite
+ status.Reason = "missing environment variables: " + strings.Join(missing, ", ")
+ default:
+ preprocessOpts := opts.Preprocess
+ if preprocessOpts.SkillDir == "" && skill.Path != "" {
+ preprocessOpts.SkillDir = filepath.Dir(skill.Path)
+ }
+ body, err := PreprocessSkillContent(ctx, skill.Body, preprocessOpts)
+ if err != nil {
+ status.Status = SkillStatusPreprocessingFailed
+ status.Reason = err.Error()
+ } else {
+ skill.Body = body
+ prepared = append(prepared, skill)
+ }
+ }
+ statuses = append(statuses, status)
+ }
+ return prepared, statuses
+}
+
+func isSkillDisabled(skill Skill, disabled map[string]bool) bool {
+ if len(disabled) == 0 {
+ return false
+ }
+ name := strings.TrimSpace(skill.Name)
+ return disabled[name] || disabled[strings.ToLower(name)]
+}
+
+func skillMatchesPlatform(skill Skill, platform string) bool {
+ if len(skill.Platforms) == 0 {
+ return true
+ }
+ current := resolvedPlatform(platform)
+ for _, allowed := range skill.Platforms {
+ if platformMatches(current, allowed) {
+ return true
+ }
+ }
+ return false
+}
+
+func resolvedPlatform(platform string) string {
+ platform = strings.ToLower(strings.TrimSpace(platform))
+ if platform == "" {
+ platform = runtime.GOOS
+ }
+ return platform
+}
+
+func platformMatches(current, allowed string) bool {
+ current = normalizePlatform(current)
+ allowed = normalizePlatform(allowed)
+ return current != "" && current == allowed
+}
+
+func normalizePlatform(platform string) string {
+ switch strings.ToLower(strings.TrimSpace(platform)) {
+ case "darwin", "mac", "macos", "osx":
+ return "macos"
+ case "linux":
+ return "linux"
+ case "windows", "win", "win32":
+ return "windows"
+ default:
+ return strings.ToLower(strings.TrimSpace(platform))
+ }
+}
+
+func missingRequiredEnv(skill Skill, env map[string]string) []string {
+ if len(skill.RequiredEnvVars) == 0 {
+ return nil
+ }
+ missing := make([]string, 0, len(skill.RequiredEnvVars))
+ for _, name := range skill.RequiredEnvVars {
+ name = strings.TrimSpace(name)
+ if name == "" {
+ continue
+ }
+ value, ok := lookupEnv(name, env)
+ if !ok || strings.TrimSpace(value) == "" {
+ missing = append(missing, name)
+ }
+ }
+ return missing
+}
+
+func lookupEnv(name string, env map[string]string) (string, bool) {
+ if env != nil {
+ value, ok := env[name]
+ return value, ok
+ }
+ return os.LookupEnv(name)
+}
diff --git a/internal/skills/store.go b/internal/skills/store.go
index 86ec4fc5c..0afc0f710 100644
--- a/internal/skills/store.go
+++ b/internal/skills/store.go
@@ -98,16 +98,22 @@ func NewRuntime(root string, maxBytes, selectionCap int, usageLogPath string) *R
}
}
-func (r *Runtime) BuildSkillBlock(_ context.Context, userMessage string) (string, []string, error) {
+func (r *Runtime) BuildSkillBlock(ctx context.Context, userMessage string) (string, []string, error) {
+ block, names, _, err := r.BuildSkillBlockWithOptions(ctx, userMessage, RuntimeOptions{})
+ return block, names, err
+}
+
+func (r *Runtime) BuildSkillBlockWithOptions(ctx context.Context, userMessage string, opts RuntimeOptions) (string, []string, []SkillStatus, error) {
if r == nil || r.store == nil {
- return "", nil, nil
+ return "", nil, nil, nil
}
snapshot, err := r.store.SnapshotActive()
if err != nil {
- return "", nil, err
+ return "", nil, nil, err
}
- selected := Select(snapshot.Skills, userMessage, r.selectionCap)
- return RenderBlock(selected), skillNames(selected), nil
+ prepared, statuses := prepareSkills(ctx, snapshot.Skills, opts)
+ selected := Select(prepared, userMessage, r.selectionCap)
+ return RenderBlock(selected), skillNames(selected), statuses, nil
}
func (r *Runtime) RecordSkillUsage(ctx context.Context, skillNames []string) error {
diff --git a/internal/skills/types.go b/internal/skills/types.go
index 2da626c98..60e7b10b4 100644
--- a/internal/skills/types.go
+++ b/internal/skills/types.go
@@ -17,6 +17,9 @@ type Skill struct {
Body string
Path string
RawBytes int
+
+ Platforms []string
+ RequiredEnvVars []string
}
// Validate enforces the minimal Phase 2.G0 contract for a parsed skill.
diff --git a/internal/tools/parity.go b/internal/tools/parity.go
new file mode 100644
index 000000000..92e89c854
--- /dev/null
+++ b/internal/tools/parity.go
@@ -0,0 +1,293 @@
+package tools
+
+import (
+ "bytes"
+ _ "embed"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "sort"
+ "strings"
+)
+
+//go:embed testdata/upstream_tool_parity_manifest.json
+var upstreamToolParityManifestJSON []byte
+
+// ErrMissingToolParityRow is returned when a handler port is marked complete
+// before its upstream descriptor has a parity fixture row.
+var ErrMissingToolParityRow = errors.New("tools: missing upstream tool parity row")
+
+// ToolParityIssueKind identifies a degraded-mode doctor finding.
+type ToolParityIssueKind string
+
+const (
+ ToolParityIssueDisabledTool ToolParityIssueKind = "disabled_tool"
+ ToolParityIssueMissingDependency ToolParityIssueKind = "missing_dependency"
+ ToolParityIssueSchemaDrift ToolParityIssueKind = "schema_drift"
+ ToolParityIssueUnavailableProviderPath ToolParityIssueKind = "unavailable_provider_path"
+)
+
+// UpstreamToolParityManifest is the frozen donor descriptor inventory used to
+// gate later handler ports.
+type UpstreamToolParityManifest struct {
+ GeneratedAt string `json:"generated_at"`
+ TrustClasses []string `json:"trust_classes"`
+ Source ToolParitySource `json:"source"`
+ Tools []UpstreamToolParityRow `json:"tools"`
+ Toolsets []UpstreamToolsetRow `json:"toolsets"`
+}
+
+// ToolParitySource records the donor files used to capture the fixture.
+type ToolParitySource struct {
+ Registry string `json:"registry"`
+ Toolsets string `json:"toolsets"`
+}
+
+// UpstreamToolParityRow captures the model-visible descriptor plus the
+// operational metadata that must exist before porting a handler.
+type UpstreamToolParityRow struct {
+ Name string `json:"name"`
+ Toolset string `json:"toolset"`
+ SourceModule string `json:"source_module"`
+ Description string `json:"description"`
+ RequiredEnv []string `json:"required_env"`
+ RequiredEnvMode string `json:"required_env_mode"`
+ Dependencies []string `json:"dependencies"`
+ ProviderPaths []ToolProviderPath `json:"provider_paths"`
+ Schema json.RawMessage `json:"schema"`
+ ResultEnvelope ToolResultEnvelope `json:"result_envelope"`
+ TrustClasses []string `json:"trust_classes"`
+ DegradedStatus ToolDegradedModeStatus `json:"degraded_status"`
+}
+
+// ToolProviderPath captures optional provider-specific availability gates.
+type ToolProviderPath struct {
+ ID string `json:"id"`
+ Description string `json:"description"`
+ RequiredEnv []string `json:"required_env"`
+ RequiredEnvMode string `json:"required_env_mode"`
+ RequiredBinaries []string `json:"required_binaries"`
+}
+
+// ToolResultEnvelope captures the JSON fields the donor returns on success or
+// failure. Handler ports can refine these rows before they claim completion.
+type ToolResultEnvelope struct {
+ Encoding string `json:"encoding"`
+ SuccessFields []string `json:"success_fields"`
+ ErrorFields []string `json:"error_fields"`
+}
+
+// ToolDegradedModeStatus captures how doctor should report degraded tools.
+type ToolDegradedModeStatus struct {
+ StatusField string `json:"status_field"`
+ Statuses []string `json:"statuses"`
+}
+
+// UpstreamToolsetRow captures static and resolved donor toolset membership.
+type UpstreamToolsetRow struct {
+ Name string `json:"name"`
+ Description string `json:"description"`
+ DirectTools []string `json:"direct_tools"`
+ Includes []string `json:"includes"`
+ ResolvedTools []string `json:"resolved_tools"`
+ Source string `json:"source"`
+}
+
+// ToolParityDoctorOptions controls degraded-mode inventory checks.
+type ToolParityDoctorOptions struct {
+ Env map[string]string
+ DisabledTools map[string]string
+ LocalSchemas map[string]json.RawMessage
+ AvailableProviderPaths map[string]bool
+}
+
+// ToolParityDoctorReport is the aggregate doctor output for descriptor parity.
+type ToolParityDoctorReport struct {
+ Issues []ToolParityIssue
+}
+
+// ToolParityIssue is one degraded-mode doctor finding.
+type ToolParityIssue struct {
+ Kind ToolParityIssueKind `json:"kind"`
+ Tool string `json:"tool"`
+ Toolset string `json:"toolset,omitempty"`
+ Detail string `json:"detail"`
+}
+
+// LoadUpstreamToolParityManifest returns the embedded upstream descriptor
+// inventory fixture.
+func LoadUpstreamToolParityManifest() (UpstreamToolParityManifest, error) {
+ var manifest UpstreamToolParityManifest
+ if err := json.Unmarshal(upstreamToolParityManifestJSON, &manifest); err != nil {
+ return UpstreamToolParityManifest{}, fmt.Errorf("load upstream tool parity manifest: %w", err)
+ }
+ if err := manifest.validate(); err != nil {
+ return UpstreamToolParityManifest{}, err
+ }
+ return manifest, nil
+}
+
+// Tool returns a tool descriptor row by name.
+func (m UpstreamToolParityManifest) Tool(name string) (UpstreamToolParityRow, bool) {
+ for _, row := range m.Tools {
+ if row.Name == name {
+ return row, true
+ }
+ }
+ return UpstreamToolParityRow{}, false
+}
+
+// Toolset returns a toolset row by name.
+func (m UpstreamToolParityManifest) Toolset(name string) (UpstreamToolsetRow, bool) {
+ for _, row := range m.Toolsets {
+ if row.Name == name {
+ return row, true
+ }
+ }
+ return UpstreamToolsetRow{}, false
+}
+
+// AssertHandlerPortAllowed enforces descriptor-first handler migration.
+func (m UpstreamToolParityManifest) AssertHandlerPortAllowed(name string) error {
+ if _, ok := m.Tool(name); ok {
+ return nil
+ }
+ return fmt.Errorf("%w: %s", ErrMissingToolParityRow, name)
+}
+
+// HasProviderPath reports whether a row captures a provider-specific path.
+func (r UpstreamToolParityRow) HasProviderPath(id string) bool {
+ for _, path := range r.ProviderPaths {
+ if path.ID == id {
+ return true
+ }
+ }
+ return false
+}
+
+// Doctor reports disabled tools, missing dependencies, schema drift, and
+// unavailable provider-specific paths from the frozen descriptor inventory.
+func (m UpstreamToolParityManifest) Doctor(opts ToolParityDoctorOptions) ToolParityDoctorReport {
+ var issues []ToolParityIssue
+ for _, row := range m.Tools {
+ if reason, disabled := opts.DisabledTools[row.Name]; disabled {
+ issues = append(issues, ToolParityIssue{
+ Kind: ToolParityIssueDisabledTool,
+ Tool: row.Name,
+ Toolset: row.Toolset,
+ Detail: reason,
+ })
+ }
+ if missing := missingRequiredEnv(row.RequiredEnv, row.RequiredEnvMode, opts.Env); len(missing) > 0 {
+ issues = append(issues, ToolParityIssue{
+ Kind: ToolParityIssueMissingDependency,
+ Tool: row.Name,
+ Toolset: row.Toolset,
+ Detail: "missing env: " + strings.Join(missing, ", "),
+ })
+ }
+ if local, ok := opts.LocalSchemas[row.Name]; ok && !sameJSON(local, row.Schema) {
+ issues = append(issues, ToolParityIssue{
+ Kind: ToolParityIssueSchemaDrift,
+ Tool: row.Name,
+ Toolset: row.Toolset,
+ Detail: "local schema differs from upstream parity fixture",
+ })
+ }
+ for _, path := range row.ProviderPaths {
+ if path.ID == "" || opts.AvailableProviderPaths[path.ID] {
+ continue
+ }
+ if pathAvailable(path, opts.Env) {
+ continue
+ }
+ issues = append(issues, ToolParityIssue{
+ Kind: ToolParityIssueUnavailableProviderPath,
+ Tool: row.Name,
+ Toolset: row.Toolset,
+ Detail: path.ID + ": " + path.Description,
+ })
+ }
+ }
+ sort.SliceStable(issues, func(i, j int) bool {
+ if issues[i].Kind != issues[j].Kind {
+ return issues[i].Kind < issues[j].Kind
+ }
+ return issues[i].Tool < issues[j].Tool
+ })
+ return ToolParityDoctorReport{Issues: issues}
+}
+
+func (m UpstreamToolParityManifest) validate() error {
+ seen := make(map[string]struct{}, len(m.Tools))
+ for _, row := range m.Tools {
+ if row.Name == "" {
+ return errors.New("upstream tool parity manifest: empty tool name")
+ }
+ if _, ok := seen[row.Name]; ok {
+ return fmt.Errorf("upstream tool parity manifest: duplicate tool %s", row.Name)
+ }
+ seen[row.Name] = struct{}{}
+ if row.Toolset == "" {
+ return fmt.Errorf("upstream tool parity manifest: %s has empty toolset", row.Name)
+ }
+ if !json.Valid(row.Schema) {
+ return fmt.Errorf("upstream tool parity manifest: %s has invalid schema JSON", row.Name)
+ }
+ if row.ResultEnvelope.Encoding == "" {
+ return fmt.Errorf("upstream tool parity manifest: %s has empty result envelope", row.Name)
+ }
+ if row.DegradedStatus.StatusField == "" {
+ return fmt.Errorf("upstream tool parity manifest: %s has empty degraded status field", row.Name)
+ }
+ }
+ return nil
+}
+
+func pathAvailable(path ToolProviderPath, env map[string]string) bool {
+ if len(path.RequiredEnv) > 0 && len(missingRequiredEnv(path.RequiredEnv, path.RequiredEnvMode, env)) == 0 {
+ return true
+ }
+ return false
+}
+
+func missingRequiredEnv(required []string, mode string, env map[string]string) []string {
+ if len(required) == 0 {
+ return nil
+ }
+ if mode == "any" {
+ for _, key := range required {
+ if env[key] != "" {
+ return nil
+ }
+ }
+ return append([]string(nil), required...)
+ }
+ var missing []string
+ for _, key := range required {
+ if env[key] == "" {
+ missing = append(missing, key)
+ }
+ }
+ return missing
+}
+
+func sameJSON(a, b json.RawMessage) bool {
+ ca, err := canonicalJSON(a)
+ if err != nil {
+ return false
+ }
+ cb, err := canonicalJSON(b)
+ if err != nil {
+ return false
+ }
+ return bytes.Equal(ca, cb)
+}
+
+func canonicalJSON(raw json.RawMessage) ([]byte, error) {
+ var v any
+ if err := json.Unmarshal(raw, &v); err != nil {
+ return nil, err
+ }
+ return json.Marshal(v)
+}
diff --git a/internal/tools/parity_test.go b/internal/tools/parity_test.go
new file mode 100644
index 000000000..29c2e2930
--- /dev/null
+++ b/internal/tools/parity_test.go
@@ -0,0 +1,164 @@
+package tools
+
+import (
+ "encoding/json"
+ "errors"
+ "testing"
+)
+
+func TestUpstreamToolParityManifestCapturesRegistryInventory(t *testing.T) {
+ manifest, err := LoadUpstreamToolParityManifest()
+ if err != nil {
+ t.Fatalf("LoadUpstreamToolParityManifest: %v", err)
+ }
+
+ if got, want := len(manifest.Tools), 55; got != want {
+ t.Fatalf("tool rows = %d, want %d", got, want)
+ }
+ if got, want := manifest.Source.Registry, "tools/registry.py"; got != want {
+ t.Fatalf("registry source = %q, want %q", got, want)
+ }
+ if got, want := manifest.Source.Toolsets, "toolsets.py"; got != want {
+ t.Fatalf("toolsets source = %q, want %q", got, want)
+ }
+
+ for _, name := range []string{
+ "browser_cdp",
+ "browser_dialog",
+ "browser_navigate",
+ "discord_server",
+ "execute_code",
+ "image_generate",
+ "mixture_of_agents",
+ "rl_start_training",
+ "text_to_speech",
+ "web_search",
+ } {
+ row, ok := manifest.Tool(name)
+ if !ok {
+ t.Fatalf("missing tool parity row for %s", name)
+ }
+ if row.Toolset == "" {
+ t.Fatalf("%s: empty toolset", name)
+ }
+ if len(row.Schema) == 0 || !json.Valid(row.Schema) {
+ t.Fatalf("%s: invalid schema JSON: %s", name, row.Schema)
+ }
+ if row.ResultEnvelope.Encoding != "json-string" {
+ t.Fatalf("%s: result envelope encoding = %q, want json-string", name, row.ResultEnvelope.Encoding)
+ }
+ if len(row.ResultEnvelope.ErrorFields) == 0 {
+ t.Fatalf("%s: missing result error fields", name)
+ }
+ if len(row.TrustClasses) == 0 {
+ t.Fatalf("%s: missing trust classes", name)
+ }
+ if row.DegradedStatus.StatusField == "" {
+ t.Fatalf("%s: missing degraded-mode status field", name)
+ }
+ }
+
+ moa := mustTool(t, manifest, "mixture_of_agents")
+ assertContains(t, moa.RequiredEnv, "OPENROUTER_API_KEY")
+
+ rl := mustTool(t, manifest, "rl_start_training")
+ assertContains(t, rl.RequiredEnv, "TINKER_API_KEY")
+ assertContains(t, rl.RequiredEnv, "WANDB_API_KEY")
+
+ web := mustTool(t, manifest, "web_search")
+ assertContains(t, web.RequiredEnv, "FIRECRAWL_API_KEY")
+ assertContains(t, web.RequiredEnv, "TAVILY_API_KEY")
+
+ image := mustTool(t, manifest, "image_generate")
+ if !image.HasProviderPath("fal") {
+ t.Fatalf("image_generate should capture the FAL provider path")
+ }
+
+ cdp := mustTool(t, manifest, "browser_cdp")
+ if !cdp.HasProviderPath("cdp") {
+ t.Fatalf("browser_cdp should capture the CDP provider-specific path")
+ }
+
+ executeCode := mustTool(t, manifest, "execute_code")
+ assertContains(t, executeCode.ResultEnvelope.SuccessFields, "status")
+ assertContains(t, executeCode.ResultEnvelope.SuccessFields, "output")
+
+ cli, ok := manifest.Toolset("hermes-cli")
+ if !ok {
+ t.Fatal("missing hermes-cli toolset parity row")
+ }
+ assertContains(t, cli.ResolvedTools, "browser_cdp")
+ assertContains(t, cli.ResolvedTools, "send_message")
+
+ gateway, ok := manifest.Toolset("hermes-gateway")
+ if !ok {
+ t.Fatal("missing hermes-gateway toolset parity row")
+ }
+ assertContains(t, gateway.Includes, "hermes-discord")
+ assertContains(t, gateway.ResolvedTools, "discord_server")
+}
+
+func TestToolParityDoctorReportsDisabledDependenciesSchemaDriftAndProviderPaths(t *testing.T) {
+ manifest, err := LoadUpstreamToolParityManifest()
+ if err != nil {
+ t.Fatalf("LoadUpstreamToolParityManifest: %v", err)
+ }
+
+ report := manifest.Doctor(ToolParityDoctorOptions{
+ Env: map[string]string{},
+ DisabledTools: map[string]string{
+ "web_extract": "disabled by platform config",
+ },
+ LocalSchemas: map[string]json.RawMessage{
+ "web_search": json.RawMessage(`{"name":"web_search","parameters":{"type":"object","properties":{},"required":[]}}`),
+ },
+ })
+
+ assertIssue(t, report, ToolParityIssueDisabledTool, "web_extract")
+ assertIssue(t, report, ToolParityIssueMissingDependency, "web_search")
+ assertIssue(t, report, ToolParityIssueSchemaDrift, "web_search")
+ assertIssue(t, report, ToolParityIssueUnavailableProviderPath, "browser_cdp")
+}
+
+func TestHandlerPortRequiresParityRow(t *testing.T) {
+ manifest, err := LoadUpstreamToolParityManifest()
+ if err != nil {
+ t.Fatalf("LoadUpstreamToolParityManifest: %v", err)
+ }
+
+ if err := manifest.AssertHandlerPortAllowed("todo"); err != nil {
+ t.Fatalf("known tool should be port-eligible after parity row exists: %v", err)
+ }
+ if err := manifest.AssertHandlerPortAllowed("future_tool_without_descriptor"); !errors.Is(err, ErrMissingToolParityRow) {
+ t.Fatalf("unknown tool error = %v, want ErrMissingToolParityRow", err)
+ }
+}
+
+func mustTool(t *testing.T, manifest UpstreamToolParityManifest, name string) UpstreamToolParityRow {
+ t.Helper()
+ row, ok := manifest.Tool(name)
+ if !ok {
+ t.Fatalf("missing tool parity row for %s", name)
+ }
+ return row
+}
+
+func assertContains(t *testing.T, values []string, want string) {
+ t.Helper()
+ for _, value := range values {
+ if value == want {
+ return
+ }
+ }
+ t.Fatalf("%v does not contain %q", values, want)
+}
+
+func assertIssue(t *testing.T, report ToolParityDoctorReport, kind ToolParityIssueKind, tool string) {
+ t.Helper()
+ for _, issue := range report.Issues {
+ if issue.Kind == kind && issue.Tool == tool {
+ return
+ }
+ }
+ t.Fatalf("missing issue kind=%s tool=%s in %#v", kind, tool, report.Issues)
+}
diff --git a/internal/tools/testdata/upstream_tool_parity_manifest.json b/internal/tools/testdata/upstream_tool_parity_manifest.json
new file mode 100644
index 000000000..ae6c05531
--- /dev/null
+++ b/internal/tools/testdata/upstream_tool_parity_manifest.json
@@ -0,0 +1,6561 @@
+{
+ "generated_at": "2026-04-25T00:00:00Z",
+ "source": {
+ "registry": "tools/registry.py",
+ "toolsets": "toolsets.py"
+ },
+ "tools": [
+ {
+ "degraded_status": {
+ "status_field": "degraded_status",
+ "statuses": [
+ "available",
+ "disabled",
+ "missing_dependency",
+ "schema_drift",
+ "unavailable_provider_path"
+ ]
+ },
+ "dependencies": [
+ "agent-browser CLI or configured Camofox/cloud browser provider"
+ ],
+ "description": "Navigate back to the previous page in browser history. Requires browser_navigate to be called first.",
+ "name": "browser_back",
+ "provider_paths": [
+ {
+ "description": "Local agent-browser CLI path",
+ "id": "browser-local",
+ "required_binaries": [
+ "agent-browser"
+ ],
+ "required_env": [],
+ "required_env_mode": "all"
+ },
+ {
+ "description": "Camofox REST backend path",
+ "id": "browser-camofox",
+ "required_env": [
+ "CAMOFOX_URL"
+ ],
+ "required_env_mode": "all"
+ },
+ {
+ "description": "Browserbase cloud browser path",
+ "id": "browserbase",
+ "required_env": [
+ "BROWSERBASE_API_KEY",
+ "BROWSERBASE_PROJECT_ID"
+ ],
+ "required_env_mode": "all"
+ },
+ {
+ "description": "Browser Use cloud browser path",
+ "id": "browser-use",
+ "required_env": [
+ "BROWSER_USE_API_KEY"
+ ],
+ "required_env_mode": "all"
+ },
+ {
+ "description": "Firecrawl browser cloud path",
+ "id": "browser-firecrawl",
+ "required_env": [
+ "FIRECRAWL_API_KEY"
+ ],
+ "required_env_mode": "all"
+ }
+ ],
+ "required_env": [],
+ "required_env_mode": "all",
+ "result_envelope": {
+ "encoding": "json-string",
+ "error_fields": [
+ "error"
+ ],
+ "success_fields": [
+ "success",
+ "snapshot",
+ "result"
+ ]
+ },
+ "schema": {
+ "description": "Navigate back to the previous page in browser history. Requires browser_navigate to be called first.",
+ "name": "browser_back",
+ "parameters": {
+ "properties": {},
+ "required": [],
+ "type": "object"
+ }
+ },
+ "source_module": "browser_tool.py",
+ "toolset": "browser",
+ "trust_classes": [
+ "operator",
+ "gateway",
+ "child-agent",
+ "system"
+ ]
+ },
+ {
+ "degraded_status": {
+ "status_field": "degraded_status",
+ "statuses": [
+ "available",
+ "disabled",
+ "missing_dependency",
+ "schema_drift",
+ "unavailable_provider_path"
+ ]
+ },
+ "dependencies": [
+ "reachable CDP endpoint"
+ ],
+ "description": "Send a raw Chrome DevTools Protocol (CDP) command. Escape hatch for browser operations not covered by browser_navigate, browser_click, browser_console, etc.\n\n**Requires a reachable CDP endpoint.** Available when the user has run '/browser connect' to attach to a running Chrome, or when 'browser.cdp_url' is set in config.yaml. Not currently wired up for cloud backends (Browserbase, Browser Use, Firecrawl) \u2014 those expose CDP per session but live-session routing is a follow-up. Camofox is REST-only and will never support CDP. If the tool is in your toolset at all, a CDP endpoint is already reachable.\n\n**CDP method reference:** https://chromedevtools.github.io/devtools-protocol/ \u2014 use web_extract on a method's URL (e.g. '/tot/Page/#method-handleJavaScriptDialog') to look up parameters and return shape.\n\n**Common patterns:**\n- List tabs: method='Target.getTargets', params={}\n- Handle a native JS dialog: method='Page.handleJavaScriptDialog', params={'accept': true, 'promptText': ''}, target_id=\n- Get all cookies: method='Network.getAllCookies', params={}\n- Eval in a specific tab: method='Runtime.evaluate', params={'expression': '...', 'returnByValue': true}, target_id=\n- Set viewport for a tab: method='Emulation.setDeviceMetricsOverride', params={'width': 1280, 'height': 720, 'deviceScaleFactor': 1, 'mobile': false}, target_id=\n\n**Usage rules:**\n- Browser-level methods (Target.*, Browser.*, Storage.*): omit target_id and frame_id.\n- Page-level methods (Page.*, Runtime.*, DOM.*, Emulation.*, Network.* scoped to a tab): pass target_id from Target.getTargets.\n- **Cross-origin iframe scope** (Runtime.evaluate inside an OOPIF, Page.* targeting a frame target, etc.): pass frame_id from the browser_snapshot frame_tree output. This routes through the CDP supervisor's live connection \u2014 the only reliable way on Browserbase where stateless CDP calls hit signed-URL expiry.\n- Each stateless call (without frame_id) is independent \u2014 sessions and event subscriptions do not persist between calls. For stateful workflows, prefer the dedicated browser tools or use frame_id routing.",
+ "name": "browser_cdp",
+ "provider_paths": [
+ {
+ "description": "Live Chrome DevTools Protocol endpoint via /browser connect or browser.cdp_url",
+ "id": "cdp",
+ "required_env": [
+ "BROWSER_CDP_URL"
+ ],
+ "required_env_mode": "all"
+ }
+ ],
+ "required_env": [],
+ "required_env_mode": "all",
+ "result_envelope": {
+ "encoding": "json-string",
+ "error_fields": [
+ "error"
+ ],
+ "success_fields": [
+ "success",
+ "snapshot",
+ "result"
+ ]
+ },
+ "schema": {
+ "description": "Send a raw Chrome DevTools Protocol (CDP) command. Escape hatch for browser operations not covered by browser_navigate, browser_click, browser_console, etc.\n\n**Requires a reachable CDP endpoint.** Available when the user has run '/browser connect' to attach to a running Chrome, or when 'browser.cdp_url' is set in config.yaml. Not currently wired up for cloud backends (Browserbase, Browser Use, Firecrawl) \u2014 those expose CDP per session but live-session routing is a follow-up. Camofox is REST-only and will never support CDP. If the tool is in your toolset at all, a CDP endpoint is already reachable.\n\n**CDP method reference:** https://chromedevtools.github.io/devtools-protocol/ \u2014 use web_extract on a method's URL (e.g. '/tot/Page/#method-handleJavaScriptDialog') to look up parameters and return shape.\n\n**Common patterns:**\n- List tabs: method='Target.getTargets', params={}\n- Handle a native JS dialog: method='Page.handleJavaScriptDialog', params={'accept': true, 'promptText': ''}, target_id=\n- Get all cookies: method='Network.getAllCookies', params={}\n- Eval in a specific tab: method='Runtime.evaluate', params={'expression': '...', 'returnByValue': true}, target_id=\n- Set viewport for a tab: method='Emulation.setDeviceMetricsOverride', params={'width': 1280, 'height': 720, 'deviceScaleFactor': 1, 'mobile': false}, target_id=\n\n**Usage rules:**\n- Browser-level methods (Target.*, Browser.*, Storage.*): omit target_id and frame_id.\n- Page-level methods (Page.*, Runtime.*, DOM.*, Emulation.*, Network.* scoped to a tab): pass target_id from Target.getTargets.\n- **Cross-origin iframe scope** (Runtime.evaluate inside an OOPIF, Page.* targeting a frame target, etc.): pass frame_id from the browser_snapshot frame_tree output. This routes through the CDP supervisor's live connection \u2014 the only reliable way on Browserbase where stateless CDP calls hit signed-URL expiry.\n- Each stateless call (without frame_id) is independent \u2014 sessions and event subscriptions do not persist between calls. For stateful workflows, prefer the dedicated browser tools or use frame_id routing.",
+ "name": "browser_cdp",
+ "parameters": {
+ "properties": {
+ "frame_id": {
+ "description": "Optional. Out-of-process iframe (OOPIF) frame_id from browser_snapshot.frame_tree.children[] where is_oopif=true. When set, routes the call through the CDP supervisor's live session for that iframe. Essential for Runtime.evaluate inside cross-origin iframes, especially on Browserbase where fresh per-call CDP connections can't keep up with signed URL rotation. For same-origin iframes, use parent contentWindow/contentDocument from Runtime.evaluate at the top-level page instead.",
+ "type": "string"
+ },
+ "method": {
+ "description": "CDP method name, e.g. 'Target.getTargets', 'Runtime.evaluate', 'Page.handleJavaScriptDialog'.",
+ "type": "string"
+ },
+ "params": {
+ "additionalProperties": true,
+ "description": "Method-specific parameters as a JSON object. Omit or pass {} for methods that take no parameters.",
+ "properties": {},
+ "type": "object"
+ },
+ "target_id": {
+ "description": "Optional. Target/tab ID from Target.getTargets result (each entry's 'targetId'). Use for page-level methods at the top-level tab scope. Mutually exclusive with frame_id.",
+ "type": "string"
+ },
+ "timeout": {
+ "default": 30,
+ "description": "Timeout in seconds (default 30, max 300).",
+ "type": "number"
+ }
+ },
+ "required": [
+ "method"
+ ],
+ "type": "object"
+ }
+ },
+ "source_module": "browser_cdp_tool.py",
+ "toolset": "browser-cdp",
+ "trust_classes": [
+ "operator",
+ "gateway",
+ "child-agent",
+ "system"
+ ]
+ },
+ {
+ "degraded_status": {
+ "status_field": "degraded_status",
+ "statuses": [
+ "available",
+ "disabled",
+ "missing_dependency",
+ "schema_drift",
+ "unavailable_provider_path"
+ ]
+ },
+ "dependencies": [
+ "agent-browser CLI or configured Camofox/cloud browser provider"
+ ],
+ "description": "Click on an element identified by its ref ID from the snapshot (e.g., '@e5'). The ref IDs are shown in square brackets in the snapshot output. Requires browser_navigate and browser_snapshot to be called first.",
+ "name": "browser_click",
+ "provider_paths": [
+ {
+ "description": "Local agent-browser CLI path",
+ "id": "browser-local",
+ "required_binaries": [
+ "agent-browser"
+ ],
+ "required_env": [],
+ "required_env_mode": "all"
+ },
+ {
+ "description": "Camofox REST backend path",
+ "id": "browser-camofox",
+ "required_env": [
+ "CAMOFOX_URL"
+ ],
+ "required_env_mode": "all"
+ },
+ {
+ "description": "Browserbase cloud browser path",
+ "id": "browserbase",
+ "required_env": [
+ "BROWSERBASE_API_KEY",
+ "BROWSERBASE_PROJECT_ID"
+ ],
+ "required_env_mode": "all"
+ },
+ {
+ "description": "Browser Use cloud browser path",
+ "id": "browser-use",
+ "required_env": [
+ "BROWSER_USE_API_KEY"
+ ],
+ "required_env_mode": "all"
+ },
+ {
+ "description": "Firecrawl browser cloud path",
+ "id": "browser-firecrawl",
+ "required_env": [
+ "FIRECRAWL_API_KEY"
+ ],
+ "required_env_mode": "all"
+ }
+ ],
+ "required_env": [],
+ "required_env_mode": "all",
+ "result_envelope": {
+ "encoding": "json-string",
+ "error_fields": [
+ "error"
+ ],
+ "success_fields": [
+ "success",
+ "snapshot",
+ "result"
+ ]
+ },
+ "schema": {
+ "description": "Click on an element identified by its ref ID from the snapshot (e.g., '@e5'). The ref IDs are shown in square brackets in the snapshot output. Requires browser_navigate and browser_snapshot to be called first.",
+ "name": "browser_click",
+ "parameters": {
+ "properties": {
+ "ref": {
+ "description": "The element reference from the snapshot (e.g., '@e5', '@e12')",
+ "type": "string"
+ }
+ },
+ "required": [
+ "ref"
+ ],
+ "type": "object"
+ }
+ },
+ "source_module": "browser_tool.py",
+ "toolset": "browser",
+ "trust_classes": [
+ "operator",
+ "gateway",
+ "child-agent",
+ "system"
+ ]
+ },
+ {
+ "degraded_status": {
+ "status_field": "degraded_status",
+ "statuses": [
+ "available",
+ "disabled",
+ "missing_dependency",
+ "schema_drift",
+ "unavailable_provider_path"
+ ]
+ },
+ "dependencies": [
+ "agent-browser CLI or configured Camofox/cloud browser provider"
+ ],
+ "description": "Get browser console output and JavaScript errors from the current page. Returns console.log/warn/error/info messages and uncaught JS exceptions. Use this to detect silent JavaScript errors, failed API calls, and application warnings. Requires browser_navigate to be called first. When 'expression' is provided, evaluates JavaScript in the page context and returns the result \u2014 use this for DOM inspection, reading page state, or extracting data programmatically.",
+ "name": "browser_console",
+ "provider_paths": [
+ {
+ "description": "Local agent-browser CLI path",
+ "id": "browser-local",
+ "required_binaries": [
+ "agent-browser"
+ ],
+ "required_env": [],
+ "required_env_mode": "all"
+ },
+ {
+ "description": "Camofox REST backend path",
+ "id": "browser-camofox",
+ "required_env": [
+ "CAMOFOX_URL"
+ ],
+ "required_env_mode": "all"
+ },
+ {
+ "description": "Browserbase cloud browser path",
+ "id": "browserbase",
+ "required_env": [
+ "BROWSERBASE_API_KEY",
+ "BROWSERBASE_PROJECT_ID"
+ ],
+ "required_env_mode": "all"
+ },
+ {
+ "description": "Browser Use cloud browser path",
+ "id": "browser-use",
+ "required_env": [
+ "BROWSER_USE_API_KEY"
+ ],
+ "required_env_mode": "all"
+ },
+ {
+ "description": "Firecrawl browser cloud path",
+ "id": "browser-firecrawl",
+ "required_env": [
+ "FIRECRAWL_API_KEY"
+ ],
+ "required_env_mode": "all"
+ }
+ ],
+ "required_env": [],
+ "required_env_mode": "all",
+ "result_envelope": {
+ "encoding": "json-string",
+ "error_fields": [
+ "error"
+ ],
+ "success_fields": [
+ "success",
+ "snapshot",
+ "result"
+ ]
+ },
+ "schema": {
+ "description": "Get browser console output and JavaScript errors from the current page. Returns console.log/warn/error/info messages and uncaught JS exceptions. Use this to detect silent JavaScript errors, failed API calls, and application warnings. Requires browser_navigate to be called first. When 'expression' is provided, evaluates JavaScript in the page context and returns the result \u2014 use this for DOM inspection, reading page state, or extracting data programmatically.",
+ "name": "browser_console",
+ "parameters": {
+ "properties": {
+ "clear": {
+ "default": false,
+ "description": "If true, clear the message buffers after reading",
+ "type": "boolean"
+ },
+ "expression": {
+ "description": "JavaScript expression to evaluate in the page context. Runs in the browser like DevTools console \u2014 full access to DOM, window, document. Return values are serialized to JSON. Example: 'document.title' or 'document.querySelectorAll(\"a\").length'",
+ "type": "string"
+ }
+ },
+ "required": [],
+ "type": "object"
+ }
+ },
+ "source_module": "browser_tool.py",
+ "toolset": "browser",
+ "trust_classes": [
+ "operator",
+ "gateway",
+ "child-agent",
+ "system"
+ ]
+ },
+ {
+ "degraded_status": {
+ "status_field": "degraded_status",
+ "statuses": [
+ "available",
+ "disabled",
+ "missing_dependency",
+ "schema_drift",
+ "unavailable_provider_path"
+ ]
+ },
+ "dependencies": [
+ "reachable CDP endpoint"
+ ],
+ "description": "Respond to a native JavaScript dialog (alert / confirm / prompt / beforeunload) that is currently blocking the page.\n\n**Workflow:** call ``browser_snapshot`` first \u2014 if a dialog is open, it appears in the ``pending_dialogs`` field with ``id``, ``type``, and ``message``. Then call this tool with ``action='accept'`` or ``action='dismiss'``.\n\n**Prompt dialogs:** pass ``prompt_text`` to supply the response string. Ignored for alert/confirm/beforeunload.\n\n**Multiple dialogs:** if more than one dialog is queued (rare \u2014 happens when a second dialog fires while the first is still open), pass ``dialog_id`` from the snapshot to disambiguate.\n\n**Availability:** only present when a CDP-capable backend is attached \u2014 Browserbase sessions, local Chrome via ``/browser connect``, or ``browser.cdp_url`` in config.yaml. Not available on Camofox (REST-only) or the default Playwright local browser (CDP port is hidden).",
+ "name": "browser_dialog",
+ "provider_paths": [
+ {
+ "description": "Live Chrome DevTools Protocol endpoint via /browser connect or browser.cdp_url",
+ "id": "cdp",
+ "required_env": [
+ "BROWSER_CDP_URL"
+ ],
+ "required_env_mode": "all"
+ }
+ ],
+ "required_env": [],
+ "required_env_mode": "all",
+ "result_envelope": {
+ "encoding": "json-string",
+ "error_fields": [
+ "error"
+ ],
+ "success_fields": [
+ "success",
+ "snapshot",
+ "result"
+ ]
+ },
+ "schema": {
+ "description": "Respond to a native JavaScript dialog (alert / confirm / prompt / beforeunload) that is currently blocking the page.\n\n**Workflow:** call ``browser_snapshot`` first \u2014 if a dialog is open, it appears in the ``pending_dialogs`` field with ``id``, ``type``, and ``message``. Then call this tool with ``action='accept'`` or ``action='dismiss'``.\n\n**Prompt dialogs:** pass ``prompt_text`` to supply the response string. Ignored for alert/confirm/beforeunload.\n\n**Multiple dialogs:** if more than one dialog is queued (rare \u2014 happens when a second dialog fires while the first is still open), pass ``dialog_id`` from the snapshot to disambiguate.\n\n**Availability:** only present when a CDP-capable backend is attached \u2014 Browserbase sessions, local Chrome via ``/browser connect``, or ``browser.cdp_url`` in config.yaml. Not available on Camofox (REST-only) or the default Playwright local browser (CDP port is hidden).",
+ "name": "browser_dialog",
+ "parameters": {
+ "properties": {
+ "action": {
+ "description": "'accept' clicks OK / returns the prompt text. 'dismiss' clicks Cancel / returns null from prompt(). For ``beforeunload`` dialogs: 'accept' allows the navigation, 'dismiss' keeps the page.",
+ "enum": [
+ "accept",
+ "dismiss"
+ ],
+ "type": "string"
+ },
+ "dialog_id": {
+ "description": "Specific dialog to respond to, from ``browser_snapshot.pending_dialogs[].id``. Required only when multiple dialogs are queued.",
+ "type": "string"
+ },
+ "prompt_text": {
+ "description": "Response string for a ``prompt()`` dialog. Ignored for other dialog types. Defaults to empty string.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "action"
+ ],
+ "type": "object"
+ }
+ },
+ "source_module": "browser_dialog_tool.py",
+ "toolset": "browser-cdp",
+ "trust_classes": [
+ "operator",
+ "gateway",
+ "child-agent",
+ "system"
+ ]
+ },
+ {
+ "degraded_status": {
+ "status_field": "degraded_status",
+ "statuses": [
+ "available",
+ "disabled",
+ "missing_dependency",
+ "schema_drift",
+ "unavailable_provider_path"
+ ]
+ },
+ "dependencies": [
+ "agent-browser CLI or configured Camofox/cloud browser provider"
+ ],
+ "description": "Get a list of all images on the current page with their URLs and alt text. Useful for finding images to analyze with the vision tool. Requires browser_navigate to be called first.",
+ "name": "browser_get_images",
+ "provider_paths": [
+ {
+ "description": "Local agent-browser CLI path",
+ "id": "browser-local",
+ "required_binaries": [
+ "agent-browser"
+ ],
+ "required_env": [],
+ "required_env_mode": "all"
+ },
+ {
+ "description": "Camofox REST backend path",
+ "id": "browser-camofox",
+ "required_env": [
+ "CAMOFOX_URL"
+ ],
+ "required_env_mode": "all"
+ },
+ {
+ "description": "Browserbase cloud browser path",
+ "id": "browserbase",
+ "required_env": [
+ "BROWSERBASE_API_KEY",
+ "BROWSERBASE_PROJECT_ID"
+ ],
+ "required_env_mode": "all"
+ },
+ {
+ "description": "Browser Use cloud browser path",
+ "id": "browser-use",
+ "required_env": [
+ "BROWSER_USE_API_KEY"
+ ],
+ "required_env_mode": "all"
+ },
+ {
+ "description": "Firecrawl browser cloud path",
+ "id": "browser-firecrawl",
+ "required_env": [
+ "FIRECRAWL_API_KEY"
+ ],
+ "required_env_mode": "all"
+ }
+ ],
+ "required_env": [],
+ "required_env_mode": "all",
+ "result_envelope": {
+ "encoding": "json-string",
+ "error_fields": [
+ "error"
+ ],
+ "success_fields": [
+ "success",
+ "snapshot",
+ "result"
+ ]
+ },
+ "schema": {
+ "description": "Get a list of all images on the current page with their URLs and alt text. Useful for finding images to analyze with the vision tool. Requires browser_navigate to be called first.",
+ "name": "browser_get_images",
+ "parameters": {
+ "properties": {},
+ "required": [],
+ "type": "object"
+ }
+ },
+ "source_module": "browser_tool.py",
+ "toolset": "browser",
+ "trust_classes": [
+ "operator",
+ "gateway",
+ "child-agent",
+ "system"
+ ]
+ },
+ {
+ "degraded_status": {
+ "status_field": "degraded_status",
+ "statuses": [
+ "available",
+ "disabled",
+ "missing_dependency",
+ "schema_drift",
+ "unavailable_provider_path"
+ ]
+ },
+ "dependencies": [
+ "agent-browser CLI or configured Camofox/cloud browser provider"
+ ],
+ "description": "Navigate to a URL in the browser. Initializes the session and loads the page. Must be called before other browser tools. For simple information retrieval, prefer web_search or web_extract (faster, cheaper). Use browser tools when you need to interact with a page (click, fill forms, dynamic content). Returns a compact page snapshot with interactive elements and ref IDs \u2014 no need to call browser_snapshot separately after navigating.",
+ "name": "browser_navigate",
+ "provider_paths": [
+ {
+ "description": "Local agent-browser CLI path",
+ "id": "browser-local",
+ "required_binaries": [
+ "agent-browser"
+ ],
+ "required_env": [],
+ "required_env_mode": "all"
+ },
+ {
+ "description": "Camofox REST backend path",
+ "id": "browser-camofox",
+ "required_env": [
+ "CAMOFOX_URL"
+ ],
+ "required_env_mode": "all"
+ },
+ {
+ "description": "Browserbase cloud browser path",
+ "id": "browserbase",
+ "required_env": [
+ "BROWSERBASE_API_KEY",
+ "BROWSERBASE_PROJECT_ID"
+ ],
+ "required_env_mode": "all"
+ },
+ {
+ "description": "Browser Use cloud browser path",
+ "id": "browser-use",
+ "required_env": [
+ "BROWSER_USE_API_KEY"
+ ],
+ "required_env_mode": "all"
+ },
+ {
+ "description": "Firecrawl browser cloud path",
+ "id": "browser-firecrawl",
+ "required_env": [
+ "FIRECRAWL_API_KEY"
+ ],
+ "required_env_mode": "all"
+ }
+ ],
+ "required_env": [],
+ "required_env_mode": "all",
+ "result_envelope": {
+ "encoding": "json-string",
+ "error_fields": [
+ "error"
+ ],
+ "success_fields": [
+ "success",
+ "snapshot",
+ "result"
+ ]
+ },
+ "schema": {
+ "description": "Navigate to a URL in the browser. Initializes the session and loads the page. Must be called before other browser tools. For simple information retrieval, prefer web_search or web_extract (faster, cheaper). Use browser tools when you need to interact with a page (click, fill forms, dynamic content). Returns a compact page snapshot with interactive elements and ref IDs \u2014 no need to call browser_snapshot separately after navigating.",
+ "name": "browser_navigate",
+ "parameters": {
+ "properties": {
+ "url": {
+ "description": "The URL to navigate to (e.g., 'https://example.com')",
+ "type": "string"
+ }
+ },
+ "required": [
+ "url"
+ ],
+ "type": "object"
+ }
+ },
+ "source_module": "browser_tool.py",
+ "toolset": "browser",
+ "trust_classes": [
+ "operator",
+ "gateway",
+ "child-agent",
+ "system"
+ ]
+ },
+ {
+ "degraded_status": {
+ "status_field": "degraded_status",
+ "statuses": [
+ "available",
+ "disabled",
+ "missing_dependency",
+ "schema_drift",
+ "unavailable_provider_path"
+ ]
+ },
+ "dependencies": [
+ "agent-browser CLI or configured Camofox/cloud browser provider"
+ ],
+ "description": "Press a keyboard key. Useful for submitting forms (Enter), navigating (Tab), or keyboard shortcuts. Requires browser_navigate to be called first.",
+ "name": "browser_press",
+ "provider_paths": [
+ {
+ "description": "Local agent-browser CLI path",
+ "id": "browser-local",
+ "required_binaries": [
+ "agent-browser"
+ ],
+ "required_env": [],
+ "required_env_mode": "all"
+ },
+ {
+ "description": "Camofox REST backend path",
+ "id": "browser-camofox",
+ "required_env": [
+ "CAMOFOX_URL"
+ ],
+ "required_env_mode": "all"
+ },
+ {
+ "description": "Browserbase cloud browser path",
+ "id": "browserbase",
+ "required_env": [
+ "BROWSERBASE_API_KEY",
+ "BROWSERBASE_PROJECT_ID"
+ ],
+ "required_env_mode": "all"
+ },
+ {
+ "description": "Browser Use cloud browser path",
+ "id": "browser-use",
+ "required_env": [
+ "BROWSER_USE_API_KEY"
+ ],
+ "required_env_mode": "all"
+ },
+ {
+ "description": "Firecrawl browser cloud path",
+ "id": "browser-firecrawl",
+ "required_env": [
+ "FIRECRAWL_API_KEY"
+ ],
+ "required_env_mode": "all"
+ }
+ ],
+ "required_env": [],
+ "required_env_mode": "all",
+ "result_envelope": {
+ "encoding": "json-string",
+ "error_fields": [
+ "error"
+ ],
+ "success_fields": [
+ "success",
+ "snapshot",
+ "result"
+ ]
+ },
+ "schema": {
+ "description": "Press a keyboard key. Useful for submitting forms (Enter), navigating (Tab), or keyboard shortcuts. Requires browser_navigate to be called first.",
+ "name": "browser_press",
+ "parameters": {
+ "properties": {
+ "key": {
+ "description": "Key to press (e.g., 'Enter', 'Tab', 'Escape', 'ArrowDown')",
+ "type": "string"
+ }
+ },
+ "required": [
+ "key"
+ ],
+ "type": "object"
+ }
+ },
+ "source_module": "browser_tool.py",
+ "toolset": "browser",
+ "trust_classes": [
+ "operator",
+ "gateway",
+ "child-agent",
+ "system"
+ ]
+ },
+ {
+ "degraded_status": {
+ "status_field": "degraded_status",
+ "statuses": [
+ "available",
+ "disabled",
+ "missing_dependency",
+ "schema_drift",
+ "unavailable_provider_path"
+ ]
+ },
+ "dependencies": [
+ "agent-browser CLI or configured Camofox/cloud browser provider"
+ ],
+ "description": "Scroll the page in a direction. Use this to reveal more content that may be below or above the current viewport. Requires browser_navigate to be called first.",
+ "name": "browser_scroll",
+ "provider_paths": [
+ {
+ "description": "Local agent-browser CLI path",
+ "id": "browser-local",
+ "required_binaries": [
+ "agent-browser"
+ ],
+ "required_env": [],
+ "required_env_mode": "all"
+ },
+ {
+ "description": "Camofox REST backend path",
+ "id": "browser-camofox",
+ "required_env": [
+ "CAMOFOX_URL"
+ ],
+ "required_env_mode": "all"
+ },
+ {
+ "description": "Browserbase cloud browser path",
+ "id": "browserbase",
+ "required_env": [
+ "BROWSERBASE_API_KEY",
+ "BROWSERBASE_PROJECT_ID"
+ ],
+ "required_env_mode": "all"
+ },
+ {
+ "description": "Browser Use cloud browser path",
+ "id": "browser-use",
+ "required_env": [
+ "BROWSER_USE_API_KEY"
+ ],
+ "required_env_mode": "all"
+ },
+ {
+ "description": "Firecrawl browser cloud path",
+ "id": "browser-firecrawl",
+ "required_env": [
+ "FIRECRAWL_API_KEY"
+ ],
+ "required_env_mode": "all"
+ }
+ ],
+ "required_env": [],
+ "required_env_mode": "all",
+ "result_envelope": {
+ "encoding": "json-string",
+ "error_fields": [
+ "error"
+ ],
+ "success_fields": [
+ "success",
+ "snapshot",
+ "result"
+ ]
+ },
+ "schema": {
+ "description": "Scroll the page in a direction. Use this to reveal more content that may be below or above the current viewport. Requires browser_navigate to be called first.",
+ "name": "browser_scroll",
+ "parameters": {
+ "properties": {
+ "direction": {
+ "description": "Direction to scroll",
+ "enum": [
+ "up",
+ "down"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "direction"
+ ],
+ "type": "object"
+ }
+ },
+ "source_module": "browser_tool.py",
+ "toolset": "browser",
+ "trust_classes": [
+ "operator",
+ "gateway",
+ "child-agent",
+ "system"
+ ]
+ },
+ {
+ "degraded_status": {
+ "status_field": "degraded_status",
+ "statuses": [
+ "available",
+ "disabled",
+ "missing_dependency",
+ "schema_drift",
+ "unavailable_provider_path"
+ ]
+ },
+ "dependencies": [
+ "agent-browser CLI or configured Camofox/cloud browser provider"
+ ],
+ "description": "Get a text-based snapshot of the current page's accessibility tree. Returns interactive elements with ref IDs (like @e1, @e2) for browser_click and browser_type. full=false (default): compact view with interactive elements. full=true: complete page content. Snapshots over 8000 chars are truncated or LLM-summarized. Requires browser_navigate first. Note: browser_navigate already returns a compact snapshot \u2014 use this to refresh after interactions that change the page, or with full=true for complete content.",
+ "name": "browser_snapshot",
+ "provider_paths": [
+ {
+ "description": "Local agent-browser CLI path",
+ "id": "browser-local",
+ "required_binaries": [
+ "agent-browser"
+ ],
+ "required_env": [],
+ "required_env_mode": "all"
+ },
+ {
+ "description": "Camofox REST backend path",
+ "id": "browser-camofox",
+ "required_env": [
+ "CAMOFOX_URL"
+ ],
+ "required_env_mode": "all"
+ },
+ {
+ "description": "Browserbase cloud browser path",
+ "id": "browserbase",
+ "required_env": [
+ "BROWSERBASE_API_KEY",
+ "BROWSERBASE_PROJECT_ID"
+ ],
+ "required_env_mode": "all"
+ },
+ {
+ "description": "Browser Use cloud browser path",
+ "id": "browser-use",
+ "required_env": [
+ "BROWSER_USE_API_KEY"
+ ],
+ "required_env_mode": "all"
+ },
+ {
+ "description": "Firecrawl browser cloud path",
+ "id": "browser-firecrawl",
+ "required_env": [
+ "FIRECRAWL_API_KEY"
+ ],
+ "required_env_mode": "all"
+ }
+ ],
+ "required_env": [],
+ "required_env_mode": "all",
+ "result_envelope": {
+ "encoding": "json-string",
+ "error_fields": [
+ "error"
+ ],
+ "success_fields": [
+ "success",
+ "snapshot",
+ "result"
+ ]
+ },
+ "schema": {
+ "description": "Get a text-based snapshot of the current page's accessibility tree. Returns interactive elements with ref IDs (like @e1, @e2) for browser_click and browser_type. full=false (default): compact view with interactive elements. full=true: complete page content. Snapshots over 8000 chars are truncated or LLM-summarized. Requires browser_navigate first. Note: browser_navigate already returns a compact snapshot \u2014 use this to refresh after interactions that change the page, or with full=true for complete content.",
+ "name": "browser_snapshot",
+ "parameters": {
+ "properties": {
+ "full": {
+ "default": false,
+ "description": "If true, returns complete page content. If false (default), returns compact view with interactive elements only.",
+ "type": "boolean"
+ }
+ },
+ "required": [],
+ "type": "object"
+ }
+ },
+ "source_module": "browser_tool.py",
+ "toolset": "browser",
+ "trust_classes": [
+ "operator",
+ "gateway",
+ "child-agent",
+ "system"
+ ]
+ },
+ {
+ "degraded_status": {
+ "status_field": "degraded_status",
+ "statuses": [
+ "available",
+ "disabled",
+ "missing_dependency",
+ "schema_drift",
+ "unavailable_provider_path"
+ ]
+ },
+ "dependencies": [
+ "agent-browser CLI or configured Camofox/cloud browser provider"
+ ],
+ "description": "Type text into an input field identified by its ref ID. Clears the field first, then types the new text. Requires browser_navigate and browser_snapshot to be called first.",
+ "name": "browser_type",
+ "provider_paths": [
+ {
+ "description": "Local agent-browser CLI path",
+ "id": "browser-local",
+ "required_binaries": [
+ "agent-browser"
+ ],
+ "required_env": [],
+ "required_env_mode": "all"
+ },
+ {
+ "description": "Camofox REST backend path",
+ "id": "browser-camofox",
+ "required_env": [
+ "CAMOFOX_URL"
+ ],
+ "required_env_mode": "all"
+ },
+ {
+ "description": "Browserbase cloud browser path",
+ "id": "browserbase",
+ "required_env": [
+ "BROWSERBASE_API_KEY",
+ "BROWSERBASE_PROJECT_ID"
+ ],
+ "required_env_mode": "all"
+ },
+ {
+ "description": "Browser Use cloud browser path",
+ "id": "browser-use",
+ "required_env": [
+ "BROWSER_USE_API_KEY"
+ ],
+ "required_env_mode": "all"
+ },
+ {
+ "description": "Firecrawl browser cloud path",
+ "id": "browser-firecrawl",
+ "required_env": [
+ "FIRECRAWL_API_KEY"
+ ],
+ "required_env_mode": "all"
+ }
+ ],
+ "required_env": [],
+ "required_env_mode": "all",
+ "result_envelope": {
+ "encoding": "json-string",
+ "error_fields": [
+ "error"
+ ],
+ "success_fields": [
+ "success",
+ "snapshot",
+ "result"
+ ]
+ },
+ "schema": {
+ "description": "Type text into an input field identified by its ref ID. Clears the field first, then types the new text. Requires browser_navigate and browser_snapshot to be called first.",
+ "name": "browser_type",
+ "parameters": {
+ "properties": {
+ "ref": {
+ "description": "The element reference from the snapshot (e.g., '@e3')",
+ "type": "string"
+ },
+ "text": {
+ "description": "The text to type into the field",
+ "type": "string"
+ }
+ },
+ "required": [
+ "ref",
+ "text"
+ ],
+ "type": "object"
+ }
+ },
+ "source_module": "browser_tool.py",
+ "toolset": "browser",
+ "trust_classes": [
+ "operator",
+ "gateway",
+ "child-agent",
+ "system"
+ ]
+ },
+ {
+ "degraded_status": {
+ "status_field": "degraded_status",
+ "statuses": [
+ "available",
+ "disabled",
+ "missing_dependency",
+ "schema_drift",
+ "unavailable_provider_path"
+ ]
+ },
+ "dependencies": [
+ "agent-browser CLI or configured Camofox/cloud browser provider"
+ ],
+ "description": "Take a screenshot of the current page and analyze it with vision AI. Use this when you need to visually understand what's on the page - especially useful for CAPTCHAs, visual verification challenges, complex layouts, or when the text snapshot doesn't capture important visual information. Returns both the AI analysis and a screenshot_path that you can share with the user by including MEDIA: in your response. Requires browser_navigate to be called first.",
+ "name": "browser_vision",
+ "provider_paths": [
+ {
+ "description": "Local agent-browser CLI path",
+ "id": "browser-local",
+ "required_binaries": [
+ "agent-browser"
+ ],
+ "required_env": [],
+ "required_env_mode": "all"
+ },
+ {
+ "description": "Camofox REST backend path",
+ "id": "browser-camofox",
+ "required_env": [
+ "CAMOFOX_URL"
+ ],
+ "required_env_mode": "all"
+ },
+ {
+ "description": "Browserbase cloud browser path",
+ "id": "browserbase",
+ "required_env": [
+ "BROWSERBASE_API_KEY",
+ "BROWSERBASE_PROJECT_ID"
+ ],
+ "required_env_mode": "all"
+ },
+ {
+ "description": "Browser Use cloud browser path",
+ "id": "browser-use",
+ "required_env": [
+ "BROWSER_USE_API_KEY"
+ ],
+ "required_env_mode": "all"
+ },
+ {
+ "description": "Firecrawl browser cloud path",
+ "id": "browser-firecrawl",
+ "required_env": [
+ "FIRECRAWL_API_KEY"
+ ],
+ "required_env_mode": "all"
+ }
+ ],
+ "required_env": [],
+ "required_env_mode": "all",
+ "result_envelope": {
+ "encoding": "json-string",
+ "error_fields": [
+ "error"
+ ],
+ "success_fields": [
+ "success",
+ "snapshot",
+ "result"
+ ]
+ },
+ "schema": {
+ "description": "Take a screenshot of the current page and analyze it with vision AI. Use this when you need to visually understand what's on the page - especially useful for CAPTCHAs, visual verification challenges, complex layouts, or when the text snapshot doesn't capture important visual information. Returns both the AI analysis and a screenshot_path that you can share with the user by including MEDIA: in your response. Requires browser_navigate to be called first.",
+ "name": "browser_vision",
+ "parameters": {
+ "properties": {
+ "annotate": {
+ "default": false,
+ "description": "If true, overlay numbered [N] labels on interactive elements. Each [N] maps to ref @eN for subsequent browser commands. Useful for QA and spatial reasoning about page layout.",
+ "type": "boolean"
+ },
+ "question": {
+ "description": "What you want to know about the page visually. Be specific about what you're looking for.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "question"
+ ],
+ "type": "object"
+ }
+ },
+ "source_module": "browser_tool.py",
+ "toolset": "browser",
+ "trust_classes": [
+ "operator",
+ "gateway",
+ "child-agent",
+ "system"
+ ]
+ },
+ {
+ "degraded_status": {
+ "status_field": "degraded_status",
+ "statuses": [
+ "available",
+ "disabled",
+ "missing_dependency",
+ "schema_drift",
+ "unavailable_provider_path"
+ ]
+ },
+ "dependencies": [],
+ "description": "Ask the user a question when you need clarification, feedback, or a decision before proceeding. Supports two modes:\n\n1. **Multiple choice** \u2014 provide up to 4 choices. The user picks one or types their own answer via a 5th 'Other' option.\n2. **Open-ended** \u2014 omit choices entirely. The user types a free-form response.\n\nUse this tool when:\n- The task is ambiguous and you need the user to choose an approach\n- You want post-task feedback ('How did that work out?')\n- You want to offer to save a skill or update memory\n- A decision has meaningful trade-offs the user should weigh in on\n\nDo NOT use this tool for simple yes/no confirmation of dangerous commands (the terminal tool handles that). Prefer making a reasonable default choice yourself when the decision is low-stakes.",
+ "name": "clarify",
+ "provider_paths": [],
+ "required_env": [],
+ "required_env_mode": "all",
+ "result_envelope": {
+ "encoding": "json-string",
+ "error_fields": [
+ "error"
+ ],
+ "success_fields": [
+ "success"
+ ]
+ },
+ "schema": {
+ "description": "Ask the user a question when you need clarification, feedback, or a decision before proceeding. Supports two modes:\n\n1. **Multiple choice** \u2014 provide up to 4 choices. The user picks one or types their own answer via a 5th 'Other' option.\n2. **Open-ended** \u2014 omit choices entirely. The user types a free-form response.\n\nUse this tool when:\n- The task is ambiguous and you need the user to choose an approach\n- You want post-task feedback ('How did that work out?')\n- You want to offer to save a skill or update memory\n- A decision has meaningful trade-offs the user should weigh in on\n\nDo NOT use this tool for simple yes/no confirmation of dangerous commands (the terminal tool handles that). Prefer making a reasonable default choice yourself when the decision is low-stakes.",
+ "name": "clarify",
+ "parameters": {
+ "properties": {
+ "choices": {
+ "description": "Up to 4 answer choices. Omit this parameter entirely to ask an open-ended question. When provided, the UI automatically appends an 'Other (type your answer)' option.",
+ "items": {
+ "type": "string"
+ },
+ "maxItems": 4,
+ "type": "array"
+ },
+ "question": {
+ "description": "The question to present to the user.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "question"
+ ],
+ "type": "object"
+ }
+ },
+ "source_module": "clarify_tool.py",
+ "toolset": "clarify",
+ "trust_classes": [
+ "operator",
+ "gateway",
+ "child-agent",
+ "system"
+ ]
+ },
+ {
+ "degraded_status": {
+ "status_field": "degraded_status",
+ "statuses": [
+ "available",
+ "disabled",
+ "missing_dependency",
+ "schema_drift",
+ "unavailable_provider_path"
+ ]
+ },
+ "dependencies": [],
+ "description": "Manage scheduled cron jobs with a single compressed tool.\n\nUse action='create' to schedule a new job from a prompt or one or more skills.\nUse action='list' to inspect jobs.\nUse action='update', 'pause', 'resume', 'remove', or 'run' to manage an existing job.\n\nTo stop a job the user no longer wants: first action='list' to find the job_id, then action='remove' with that job_id. Never guess job IDs \u2014 always list first.\n\nJobs run in a fresh session with no current-chat context, so prompts must be self-contained.\nIf skills are provided on create, the future cron run loads those skills in order, then follows the prompt as the task instruction.\nOn update, passing skills=[] clears attached skills.\n\nNOTE: The agent's final response is auto-delivered to the target. Put the primary\nuser-facing content in the final response. Cron jobs run autonomously with no user\npresent \u2014 they cannot ask questions or request clarification.\n\nImportant safety rule: cron-run sessions should not recursively schedule more cron jobs.",
+ "name": "cronjob",
+ "provider_paths": [],
+ "required_env": [],
+ "required_env_mode": "all",
+ "result_envelope": {
+ "encoding": "json-string",
+ "error_fields": [
+ "error"
+ ],
+ "success_fields": [
+ "success"
+ ]
+ },
+ "schema": {
+ "description": "Manage scheduled cron jobs with a single compressed tool.\n\nUse action='create' to schedule a new job from a prompt or one or more skills.\nUse action='list' to inspect jobs.\nUse action='update', 'pause', 'resume', 'remove', or 'run' to manage an existing job.\n\nTo stop a job the user no longer wants: first action='list' to find the job_id, then action='remove' with that job_id. Never guess job IDs \u2014 always list first.\n\nJobs run in a fresh session with no current-chat context, so prompts must be self-contained.\nIf skills are provided on create, the future cron run loads those skills in order, then follows the prompt as the task instruction.\nOn update, passing skills=[] clears attached skills.\n\nNOTE: The agent's final response is auto-delivered to the target. Put the primary\nuser-facing content in the final response. Cron jobs run autonomously with no user\npresent \u2014 they cannot ask questions or request clarification.\n\nImportant safety rule: cron-run sessions should not recursively schedule more cron jobs.",
+ "name": "cronjob",
+ "parameters": {
+ "properties": {
+ "action": {
+ "description": "One of: create, list, update, pause, resume, remove, run",
+ "type": "string"
+ },
+ "deliver": {
+ "description": "Omit this parameter to auto-deliver back to the current chat and topic (recommended). Auto-detection preserves thread/topic context. Only set explicitly when the user asks to deliver somewhere OTHER than the current conversation. Values: 'origin' (same as omitting), 'local' (no delivery, save only), or platform:chat_id:thread_id for a specific destination. Examples: 'telegram:-1001234567890:17585', 'discord:#engineering', 'sms:+15551234567'. WARNING: 'platform:chat_id' without :thread_id loses topic targeting.",
+ "type": "string"
+ },
+ "enabled_toolsets": {
+ "description": "Optional list of toolset names to restrict the job's agent to (e.g. [\"web\", \"terminal\", \"file\", \"delegation\"]). When set, only tools from these toolsets are loaded, significantly reducing input token overhead. When omitted, all default tools are loaded. Infer from the job's prompt \u2014 e.g. use \"web\" if it calls web_search, \"terminal\" if it runs scripts, \"file\" if it reads files, \"delegation\" if it calls delegate_task. On update, pass an empty array to clear.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "job_id": {
+ "description": "Required for update/pause/resume/remove/run",
+ "type": "string"
+ },
+ "model": {
+ "description": "Optional per-job model override. If provider is omitted, the current main provider is pinned at creation time so the job stays stable.",
+ "properties": {
+ "model": {
+ "description": "Model name (e.g. 'anthropic/claude-sonnet-4', 'claude-sonnet-4')",
+ "type": "string"
+ },
+ "provider": {
+ "description": "Provider name (e.g. 'openrouter', 'anthropic'). Omit to use and pin the current provider.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "model"
+ ],
+ "type": "object"
+ },
+ "name": {
+ "description": "Optional human-friendly name",
+ "type": "string"
+ },
+ "prompt": {
+ "description": "For create: the full self-contained prompt. If skills are also provided, this becomes the task instruction paired with those skills.",
+ "type": "string"
+ },
+ "repeat": {
+ "description": "Optional repeat count. Omit for defaults (once for one-shot, forever for recurring).",
+ "type": "integer"
+ },
+ "schedule": {
+ "description": "For create/update: '30m', 'every 2h', '0 9 * * *', or ISO timestamp",
+ "type": "string"
+ },
+ "script": {
+ "description": "Optional path to a Python script that runs before each cron job execution. Its stdout is injected into the prompt as context. Use for data collection and change detection. Relative paths resolve under ~/.hermes/scripts/. On update, pass empty string to clear.",
+ "type": "string"
+ },
+ "skills": {
+ "description": "Optional ordered list of skill names to load before executing the cron prompt. On update, pass an empty array to clear attached skills.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "workdir": {
+ "description": "Optional absolute path to run the job from. When set, AGENTS.md / CLAUDE.md / .cursorrules from that directory are injected into the system prompt, and the terminal/file/code_exec tools use it as their working directory \u2014 useful for running a job inside a specific project repo. Must be an absolute path that exists. When unset (default), preserves the original behaviour: no project context files, tools use the scheduler's cwd. On update, pass an empty string to clear. Jobs with workdir run sequentially (not parallel) to keep per-job directories isolated.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "action"
+ ],
+ "type": "object"
+ }
+ },
+ "source_module": "cronjob_tools.py",
+ "toolset": "cronjob",
+ "trust_classes": [
+ "operator",
+ "gateway",
+ "child-agent",
+ "system"
+ ]
+ },
+ {
+ "degraded_status": {
+ "status_field": "degraded_status",
+ "statuses": [
+ "available",
+ "disabled",
+ "missing_dependency",
+ "schema_drift",
+ "unavailable_provider_path"
+ ]
+ },
+ "dependencies": [],
+ "description": "Spawn one or more subagents to work on tasks in isolated contexts. Each subagent gets its own conversation, terminal session, and toolset. Only the final summary is returned -- intermediate tool results never enter your context window.\n\nTWO MODES (one of 'goal' or 'tasks' is required):\n1. Single task: provide 'goal' (+ optional context, toolsets)\n2. Batch (parallel): provide 'tasks' array with up to delegation.max_concurrent_children items (default 3). All run concurrently and results are returned together.\n\nWHEN TO USE delegate_task:\n- Reasoning-heavy subtasks (debugging, code review, research synthesis)\n- Tasks that would flood your context with intermediate data\n- Parallel independent workstreams (research A and B simultaneously)\n\nWHEN NOT TO USE (use these instead):\n- Mechanical multi-step work with no reasoning needed -> use execute_code\n- Single tool call -> just call the tool directly\n- Tasks needing user interaction -> subagents cannot use clarify\n\nIMPORTANT:\n- Subagents have NO memory of your conversation. Pass all relevant info (file paths, error messages, constraints) via the 'context' field.\n- Leaf subagents (role='leaf', the default) CANNOT call: delegate_task, clarify, memory, send_message, execute_code.\n- Orchestrator subagents (role='orchestrator') retain delegate_task so they can spawn their own workers, but still cannot use clarify, memory, send_message, or execute_code. Orchestrators are bounded by delegation.max_spawn_depth (default 2) and can be disabled globally via delegation.orchestrator_enabled=false.\n- Each subagent gets its own terminal session (separate working directory and state).\n- Results are always returned as an array, one entry per task.",
+ "name": "delegate_task",
+ "provider_paths": [],
+ "required_env": [],
+ "required_env_mode": "all",
+ "result_envelope": {
+ "encoding": "json-string",
+ "error_fields": [
+ "error"
+ ],
+ "success_fields": [
+ "success"
+ ]
+ },
+ "schema": {
+ "description": "Spawn one or more subagents to work on tasks in isolated contexts. Each subagent gets its own conversation, terminal session, and toolset. Only the final summary is returned -- intermediate tool results never enter your context window.\n\nTWO MODES (one of 'goal' or 'tasks' is required):\n1. Single task: provide 'goal' (+ optional context, toolsets)\n2. Batch (parallel): provide 'tasks' array with up to delegation.max_concurrent_children items (default 3). All run concurrently and results are returned together.\n\nWHEN TO USE delegate_task:\n- Reasoning-heavy subtasks (debugging, code review, research synthesis)\n- Tasks that would flood your context with intermediate data\n- Parallel independent workstreams (research A and B simultaneously)\n\nWHEN NOT TO USE (use these instead):\n- Mechanical multi-step work with no reasoning needed -> use execute_code\n- Single tool call -> just call the tool directly\n- Tasks needing user interaction -> subagents cannot use clarify\n\nIMPORTANT:\n- Subagents have NO memory of your conversation. Pass all relevant info (file paths, error messages, constraints) via the 'context' field.\n- Leaf subagents (role='leaf', the default) CANNOT call: delegate_task, clarify, memory, send_message, execute_code.\n- Orchestrator subagents (role='orchestrator') retain delegate_task so they can spawn their own workers, but still cannot use clarify, memory, send_message, or execute_code. Orchestrators are bounded by delegation.max_spawn_depth (default 2) and can be disabled globally via delegation.orchestrator_enabled=false.\n- Each subagent gets its own terminal session (separate working directory and state).\n- Results are always returned as an array, one entry per task.",
+ "name": "delegate_task",
+ "parameters": {
+ "properties": {
+ "acp_args": {
+ "description": "Arguments for the ACP command (default: ['--acp', '--stdio']). Only used when acp_command is set. Example: ['--acp', '--stdio', '--model', 'claude-opus-4-6']",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "acp_command": {
+ "description": "Override ACP command for child agents (e.g. 'claude', 'copilot'). When set, children use ACP subprocess transport instead of inheriting the parent's transport. Enables spawning Claude Code (claude --acp --stdio) or other ACP-capable agents from any parent, including Discord/Telegram/CLI.",
+ "type": "string"
+ },
+ "context": {
+ "description": "Background information the subagent needs: file paths, error messages, project structure, constraints. The more specific you are, the better the subagent performs.",
+ "type": "string"
+ },
+ "goal": {
+ "description": "What the subagent should accomplish. Be specific and self-contained -- the subagent knows nothing about your conversation history.",
+ "type": "string"
+ },
+ "role": {
+ "description": "Role of the child agent. 'leaf' (default) = focused worker, cannot delegate further. 'orchestrator' = can use delegate_task to spawn its own workers. Requires delegation.max_spawn_depth >= 2 in config; ignored (treated as 'leaf') when the child would exceed max_spawn_depth or when delegation.orchestrator_enabled=false.",
+ "enum": [
+ "leaf",
+ "orchestrator"
+ ],
+ "type": "string"
+ },
+ "tasks": {
+ "description": "Batch mode: tasks to run in parallel (limit configurable via delegation.max_concurrent_children, default 3). Each gets its own subagent with isolated context and terminal session. When provided, top-level goal/context/toolsets are ignored.",
+ "items": {
+ "properties": {
+ "acp_args": {
+ "description": "Per-task ACP args override.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "acp_command": {
+ "description": "Per-task ACP command override (e.g. 'claude'). Overrides the top-level acp_command for this task only.",
+ "type": "string"
+ },
+ "context": {
+ "description": "Task-specific context",
+ "type": "string"
+ },
+ "goal": {
+ "description": "Task goal",
+ "type": "string"
+ },
+ "role": {
+ "description": "Per-task role override. See top-level 'role' for semantics.",
+ "enum": [
+ "leaf",
+ "orchestrator"
+ ],
+ "type": "string"
+ },
+ "toolsets": {
+ "description": "Toolsets for this specific task. Available: . Use 'web' for network access, 'terminal' for shell, 'browser' for web interaction.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ }
+ },
+ "required": [
+ "goal"
+ ],
+ "type": "object"
+ },
+ "type": "array"
+ },
+ "toolsets": {
+ "description": "Toolsets to enable for this subagent. Default: inherits your enabled toolsets. Available toolsets: . Common patterns: ['terminal', 'file'] for code work, ['web'] for research, ['browser'] for web interaction, ['terminal', 'file', 'web'] for full-stack tasks.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ }
+ },
+ "required": [],
+ "type": "object"
+ }
+ },
+ "source_module": "delegate_tool.py",
+ "toolset": "delegation",
+ "trust_classes": [
+ "operator",
+ "gateway",
+ "child-agent",
+ "system"
+ ]
+ },
+ {
+ "degraded_status": {
+ "status_field": "degraded_status",
+ "statuses": [
+ "available",
+ "disabled",
+ "missing_dependency",
+ "schema_drift",
+ "unavailable_provider_path"
+ ]
+ },
+ "dependencies": [],
+ "description": "Query and manage a Discord server via the REST API.\n\nAvailable actions:\n list_guilds() \u2014 list servers the bot is in\n server_info(guild_id) \u2014 server details + member counts\n list_channels(guild_id) \u2014 all channels grouped by category\n channel_info(channel_id) \u2014 single channel details\n list_roles(guild_id) \u2014 roles sorted by position\n member_info(guild_id, user_id) \u2014 lookup a specific member\n search_members(guild_id, query) \u2014 find members by name prefix\n fetch_messages(channel_id) \u2014 recent messages; optional before/after snowflakes\n list_pins(channel_id) \u2014 pinned messages in a channel\n pin_message(channel_id, message_id) \u2014 pin a message\n unpin_message(channel_id, message_id) \u2014 unpin a message\n create_thread(channel_id, name) \u2014 create a public thread; optional message_id anchor\n add_role(guild_id, user_id, role_id) \u2014 assign a role\n remove_role(guild_id, user_id, role_id) \u2014 remove a role\n\nCall list_guilds first to discover guild_ids, then list_channels for channel_ids. Runtime errors will tell you if the bot lacks a specific per-guild permission (e.g. MANAGE_ROLES for add_role).",
+ "name": "discord_server",
+ "provider_paths": [],
+ "required_env": [
+ "DISCORD_BOT_TOKEN"
+ ],
+ "required_env_mode": "all",
+ "result_envelope": {
+ "encoding": "json-string",
+ "error_fields": [
+ "error"
+ ],
+ "success_fields": [
+ "success"
+ ]
+ },
+ "schema": {
+ "description": "Query and manage a Discord server via the REST API.\n\nAvailable actions:\n list_guilds() \u2014 list servers the bot is in\n server_info(guild_id) \u2014 server details + member counts\n list_channels(guild_id) \u2014 all channels grouped by category\n channel_info(channel_id) \u2014 single channel details\n list_roles(guild_id) \u2014 roles sorted by position\n member_info(guild_id, user_id) \u2014 lookup a specific member\n search_members(guild_id, query) \u2014 find members by name prefix\n fetch_messages(channel_id) \u2014 recent messages; optional before/after snowflakes\n list_pins(channel_id) \u2014 pinned messages in a channel\n pin_message(channel_id, message_id) \u2014 pin a message\n unpin_message(channel_id, message_id) \u2014 unpin a message\n create_thread(channel_id, name) \u2014 create a public thread; optional message_id anchor\n add_role(guild_id, user_id, role_id) \u2014 assign a role\n remove_role(guild_id, user_id, role_id) \u2014 remove a role\n\nCall list_guilds first to discover guild_ids, then list_channels for channel_ids. Runtime errors will tell you if the bot lacks a specific per-guild permission (e.g. MANAGE_ROLES for add_role).",
+ "name": "discord_server",
+ "parameters": {
+ "properties": {
+ "action": {
+ "enum": [
+ "list_guilds",
+ "server_info",
+ "list_channels",
+ "channel_info",
+ "list_roles",
+ "member_info",
+ "search_members",
+ "fetch_messages",
+ "list_pins",
+ "pin_message",
+ "unpin_message",
+ "create_thread",
+ "add_role",
+ "remove_role"
+ ],
+ "type": "string"
+ },
+ "after": {
+ "description": "Snowflake ID for forward pagination (fetch_messages).",
+ "type": "string"
+ },
+ "auto_archive_duration": {
+ "description": "Thread archive duration in minutes (create_thread, default 1440).",
+ "enum": [
+ 60,
+ 1440,
+ 4320,
+ 10080
+ ],
+ "type": "integer"
+ },
+ "before": {
+ "description": "Snowflake ID for reverse pagination (fetch_messages).",
+ "type": "string"
+ },
+ "channel_id": {
+ "description": "Discord channel ID.",
+ "type": "string"
+ },
+ "guild_id": {
+ "description": "Discord server (guild) ID.",
+ "type": "string"
+ },
+ "limit": {
+ "description": "Max results (default 50). Applies to fetch_messages, search_members.",
+ "maximum": 100,
+ "minimum": 1,
+ "type": "integer"
+ },
+ "message_id": {
+ "description": "Discord message ID.",
+ "type": "string"
+ },
+ "name": {
+ "description": "New thread name (create_thread).",
+ "type": "string"
+ },
+ "query": {
+ "description": "Member name prefix to search for (search_members).",
+ "type": "string"
+ },
+ "role_id": {
+ "description": "Discord role ID.",
+ "type": "string"
+ },
+ "user_id": {
+ "description": "Discord user ID.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "action"
+ ],
+ "type": "object"
+ }
+ },
+ "source_module": "discord_tool.py",
+ "toolset": "discord",
+ "trust_classes": [
+ "operator",
+ "gateway",
+ "child-agent",
+ "system"
+ ]
+ },
+ {
+ "degraded_status": {
+ "status_field": "degraded_status",
+ "statuses": [
+ "available",
+ "disabled",
+ "missing_dependency",
+ "schema_drift",
+ "unavailable_provider_path"
+ ]
+ },
+ "dependencies": [
+ "Python runtime in selected sandbox environment"
+ ],
+ "description": "Run a Python script that can call Hermes tools programmatically. Use this when you need 3+ tool calls with processing logic between them, need to filter/reduce large tool outputs before they enter your context, need conditional branching (if X then Y else Z), or need to loop (fetch N pages, process N files, retry on failure).\n\nUse normal tool calls instead when: single tool call with no processing, you need to see the full result and apply complex reasoning, or the task requires interactive user input.\n\nAvailable via `from hermes_tools import ...`:\n\n web_search(query: str, limit: int = 5) -> dict\n Returns {\"data\": {\"web\": [{\"url\", \"title\", \"description\"}, ...]}}\n web_extract(urls: list[str]) -> dict\n Returns {\"results\": [{\"url\", \"title\", \"content\", \"error\"}, ...]} where content is markdown\n read_file(path: str, offset: int = 1, limit: int = 500) -> dict\n Lines are 1-indexed. Returns {\"content\": \"...\", \"total_lines\": N}\n write_file(path: str, content: str) -> dict\n Always overwrites the entire file.\n search_files(pattern: str, target=\"content\", path=\".\", file_glob=None, limit=50) -> dict\n target: \"content\" (search inside files) or \"files\" (find files by name). Returns {\"matches\": [...]}\n patch(path: str, old_string: str, new_string: str, replace_all: bool = False) -> dict\n Replaces old_string with new_string in the file.\n terminal(command: str, timeout=None, workdir=None) -> dict\n Foreground only (no background/pty). Returns {\"output\": \"...\", \"exit_code\": N}\n\nLimits: 5-minute timeout, 50KB stdout cap, max 50 tool calls per script. terminal() is foreground-only (no background or pty).\n\nScripts run in the session's working directory with the active venv's python, so project deps (pandas, etc.) and relative paths work like in terminal().\n\nPrint your final result to stdout. Use Python stdlib (json, re, math, csv, datetime, collections, etc.) for processing between tool calls.\n\nAlso available (no import needed \u2014 built into hermes_tools):\n json_parse(text: str) \u2014 json.loads with strict=False; use for terminal() output with control chars\n shell_quote(s: str) \u2014 shlex.quote(); use when interpolating dynamic strings into shell commands\n retry(fn, max_attempts=3, delay=2) \u2014 retry with exponential backoff for transient failures",
+ "name": "execute_code",
+ "provider_paths": [],
+ "required_env": [],
+ "required_env_mode": "all",
+ "result_envelope": {
+ "encoding": "json-string",
+ "error_fields": [
+ "error"
+ ],
+ "success_fields": [
+ "status",
+ "output",
+ "tool_calls_made",
+ "duration_seconds"
+ ]
+ },
+ "schema": {
+ "description": "Run a Python script that can call Hermes tools programmatically. Use this when you need 3+ tool calls with processing logic between them, need to filter/reduce large tool outputs before they enter your context, need conditional branching (if X then Y else Z), or need to loop (fetch N pages, process N files, retry on failure).\n\nUse normal tool calls instead when: single tool call with no processing, you need to see the full result and apply complex reasoning, or the task requires interactive user input.\n\nAvailable via `from hermes_tools import ...`:\n\n web_search(query: str, limit: int = 5) -> dict\n Returns {\"data\": {\"web\": [{\"url\", \"title\", \"description\"}, ...]}}\n web_extract(urls: list[str]) -> dict\n Returns {\"results\": [{\"url\", \"title\", \"content\", \"error\"}, ...]} where content is markdown\n read_file(path: str, offset: int = 1, limit: int = 500) -> dict\n Lines are 1-indexed. Returns {\"content\": \"...\", \"total_lines\": N}\n write_file(path: str, content: str) -> dict\n Always overwrites the entire file.\n search_files(pattern: str, target=\"content\", path=\".\", file_glob=None, limit=50) -> dict\n target: \"content\" (search inside files) or \"files\" (find files by name). Returns {\"matches\": [...]}\n patch(path: str, old_string: str, new_string: str, replace_all: bool = False) -> dict\n Replaces old_string with new_string in the file.\n terminal(command: str, timeout=None, workdir=None) -> dict\n Foreground only (no background/pty). Returns {\"output\": \"...\", \"exit_code\": N}\n\nLimits: 5-minute timeout, 50KB stdout cap, max 50 tool calls per script. terminal() is foreground-only (no background or pty).\n\nScripts run in the session's working directory with the active venv's python, so project deps (pandas, etc.) and relative paths work like in terminal().\n\nPrint your final result to stdout. Use Python stdlib (json, re, math, csv, datetime, collections, etc.) for processing between tool calls.\n\nAlso available (no import needed \u2014 built into hermes_tools):\n json_parse(text: str) \u2014 json.loads with strict=False; use for terminal() output with control chars\n shell_quote(s: str) \u2014 shlex.quote(); use when interpolating dynamic strings into shell commands\n retry(fn, max_attempts=3, delay=2) \u2014 retry with exponential backoff for transient failures",
+ "name": "execute_code",
+ "parameters": {
+ "properties": {
+ "code": {
+ "description": "Python code to execute. Import tools with `from hermes_tools import web_search, terminal, ...` and print your final result to stdout.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "code"
+ ],
+ "type": "object"
+ }
+ },
+ "source_module": "code_execution_tool.py",
+ "toolset": "code_execution",
+ "trust_classes": [
+ "operator",
+ "gateway",
+ "child-agent",
+ "system"
+ ]
+ },
+ {
+ "degraded_status": {
+ "status_field": "degraded_status",
+ "statuses": [
+ "available",
+ "disabled",
+ "missing_dependency",
+ "schema_drift",
+ "unavailable_provider_path"
+ ]
+ },
+ "dependencies": [
+ "Feishu document-comment context client"
+ ],
+ "description": "Read the full content of a Feishu/Lark document as plain text. Useful when you need more context beyond the quoted text in a comment.",
+ "name": "feishu_doc_read",
+ "provider_paths": [],
+ "required_env": [],
+ "required_env_mode": "all",
+ "result_envelope": {
+ "encoding": "json-string",
+ "error_fields": [
+ "error"
+ ],
+ "success_fields": [
+ "success"
+ ]
+ },
+ "schema": {
+ "description": "Read the full content of a Feishu/Lark document as plain text. Useful when you need more context beyond the quoted text in a comment.",
+ "name": "feishu_doc_read",
+ "parameters": {
+ "properties": {
+ "doc_token": {
+ "description": "The document token (from the document URL or comment context).",
+ "type": "string"
+ }
+ },
+ "required": [
+ "doc_token"
+ ],
+ "type": "object"
+ }
+ },
+ "source_module": "feishu_doc_tool.py",
+ "toolset": "feishu_doc",
+ "trust_classes": [
+ "operator",
+ "gateway",
+ "child-agent",
+ "system"
+ ]
+ },
+ {
+ "degraded_status": {
+ "status_field": "degraded_status",
+ "statuses": [
+ "available",
+ "disabled",
+ "missing_dependency",
+ "schema_drift",
+ "unavailable_provider_path"
+ ]
+ },
+ "dependencies": [
+ "Feishu document-comment context client"
+ ],
+ "description": "Add a new whole-document comment on a Feishu document. Use this for whole-document comments or as a fallback when reply_comment fails with code 1069302.",
+ "name": "feishu_drive_add_comment",
+ "provider_paths": [],
+ "required_env": [],
+ "required_env_mode": "all",
+ "result_envelope": {
+ "encoding": "json-string",
+ "error_fields": [
+ "error"
+ ],
+ "success_fields": [
+ "success"
+ ]
+ },
+ "schema": {
+ "description": "Add a new whole-document comment on a Feishu document. Use this for whole-document comments or as a fallback when reply_comment fails with code 1069302.",
+ "name": "feishu_drive_add_comment",
+ "parameters": {
+ "properties": {
+ "content": {
+ "description": "The comment text content (plain text only, no markdown).",
+ "type": "string"
+ },
+ "file_token": {
+ "description": "The document file token.",
+ "type": "string"
+ },
+ "file_type": {
+ "default": "docx",
+ "description": "File type (default: docx).",
+ "type": "string"
+ }
+ },
+ "required": [
+ "file_token",
+ "content"
+ ],
+ "type": "object"
+ }
+ },
+ "source_module": "feishu_drive_tool.py",
+ "toolset": "feishu_drive",
+ "trust_classes": [
+ "operator",
+ "gateway",
+ "child-agent",
+ "system"
+ ]
+ },
+ {
+ "degraded_status": {
+ "status_field": "degraded_status",
+ "statuses": [
+ "available",
+ "disabled",
+ "missing_dependency",
+ "schema_drift",
+ "unavailable_provider_path"
+ ]
+ },
+ "dependencies": [
+ "Feishu document-comment context client"
+ ],
+ "description": "List all replies in a comment thread on a Feishu document.",
+ "name": "feishu_drive_list_comment_replies",
+ "provider_paths": [],
+ "required_env": [],
+ "required_env_mode": "all",
+ "result_envelope": {
+ "encoding": "json-string",
+ "error_fields": [
+ "error"
+ ],
+ "success_fields": [
+ "success"
+ ]
+ },
+ "schema": {
+ "description": "List all replies in a comment thread on a Feishu document.",
+ "name": "feishu_drive_list_comment_replies",
+ "parameters": {
+ "properties": {
+ "comment_id": {
+ "description": "The comment ID to list replies for.",
+ "type": "string"
+ },
+ "file_token": {
+ "description": "The document file token.",
+ "type": "string"
+ },
+ "file_type": {
+ "default": "docx",
+ "description": "File type (default: docx).",
+ "type": "string"
+ },
+ "page_size": {
+ "default": 100,
+ "description": "Number of replies per page (max 100).",
+ "type": "integer"
+ },
+ "page_token": {
+ "description": "Pagination token for next page.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "file_token",
+ "comment_id"
+ ],
+ "type": "object"
+ }
+ },
+ "source_module": "feishu_drive_tool.py",
+ "toolset": "feishu_drive",
+ "trust_classes": [
+ "operator",
+ "gateway",
+ "child-agent",
+ "system"
+ ]
+ },
+ {
+ "degraded_status": {
+ "status_field": "degraded_status",
+ "statuses": [
+ "available",
+ "disabled",
+ "missing_dependency",
+ "schema_drift",
+ "unavailable_provider_path"
+ ]
+ },
+ "dependencies": [
+ "Feishu document-comment context client"
+ ],
+ "description": "List comments on a Feishu document. Use is_whole=true to list whole-document comments only.",
+ "name": "feishu_drive_list_comments",
+ "provider_paths": [],
+ "required_env": [],
+ "required_env_mode": "all",
+ "result_envelope": {
+ "encoding": "json-string",
+ "error_fields": [
+ "error"
+ ],
+ "success_fields": [
+ "success"
+ ]
+ },
+ "schema": {
+ "description": "List comments on a Feishu document. Use is_whole=true to list whole-document comments only.",
+ "name": "feishu_drive_list_comments",
+ "parameters": {
+ "properties": {
+ "file_token": {
+ "description": "The document file token.",
+ "type": "string"
+ },
+ "file_type": {
+ "default": "docx",
+ "description": "File type (default: docx).",
+ "type": "string"
+ },
+ "is_whole": {
+ "default": false,
+ "description": "If true, only return whole-document comments.",
+ "type": "boolean"
+ },
+ "page_size": {
+ "default": 100,
+ "description": "Number of comments per page (max 100).",
+ "type": "integer"
+ },
+ "page_token": {
+ "description": "Pagination token for next page.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "file_token"
+ ],
+ "type": "object"
+ }
+ },
+ "source_module": "feishu_drive_tool.py",
+ "toolset": "feishu_drive",
+ "trust_classes": [
+ "operator",
+ "gateway",
+ "child-agent",
+ "system"
+ ]
+ },
+ {
+ "degraded_status": {
+ "status_field": "degraded_status",
+ "statuses": [
+ "available",
+ "disabled",
+ "missing_dependency",
+ "schema_drift",
+ "unavailable_provider_path"
+ ]
+ },
+ "dependencies": [
+ "Feishu document-comment context client"
+ ],
+ "description": "Reply to a local comment thread on a Feishu document. Use this for local (quoted-text) comments. For whole-document comments, use feishu_drive_add_comment instead.",
+ "name": "feishu_drive_reply_comment",
+ "provider_paths": [],
+ "required_env": [],
+ "required_env_mode": "all",
+ "result_envelope": {
+ "encoding": "json-string",
+ "error_fields": [
+ "error"
+ ],
+ "success_fields": [
+ "success"
+ ]
+ },
+ "schema": {
+ "description": "Reply to a local comment thread on a Feishu document. Use this for local (quoted-text) comments. For whole-document comments, use feishu_drive_add_comment instead.",
+ "name": "feishu_drive_reply_comment",
+ "parameters": {
+ "properties": {
+ "comment_id": {
+ "description": "The comment ID to reply to.",
+ "type": "string"
+ },
+ "content": {
+ "description": "The reply text content (plain text only, no markdown).",
+ "type": "string"
+ },
+ "file_token": {
+ "description": "The document file token.",
+ "type": "string"
+ },
+ "file_type": {
+ "default": "docx",
+ "description": "File type (default: docx).",
+ "type": "string"
+ }
+ },
+ "required": [
+ "file_token",
+ "comment_id",
+ "content"
+ ],
+ "type": "object"
+ }
+ },
+ "source_module": "feishu_drive_tool.py",
+ "toolset": "feishu_drive",
+ "trust_classes": [
+ "operator",
+ "gateway",
+ "child-agent",
+ "system"
+ ]
+ },
+ {
+ "degraded_status": {
+ "status_field": "degraded_status",
+ "statuses": [
+ "available",
+ "disabled",
+ "missing_dependency",
+ "schema_drift",
+ "unavailable_provider_path"
+ ]
+ },
+ "dependencies": [
+ "Home Assistant long-lived token"
+ ],
+ "description": "Call a Home Assistant service to control a device. Use ha_list_services to discover available services and their parameters for each domain.",
+ "name": "ha_call_service",
+ "provider_paths": [],
+ "required_env": [
+ "HASS_TOKEN"
+ ],
+ "required_env_mode": "all",
+ "result_envelope": {
+ "encoding": "json-string",
+ "error_fields": [
+ "error"
+ ],
+ "success_fields": [
+ "success"
+ ]
+ },
+ "schema": {
+ "description": "Call a Home Assistant service to control a device. Use ha_list_services to discover available services and their parameters for each domain.",
+ "name": "ha_call_service",
+ "parameters": {
+ "properties": {
+ "data": {
+ "description": "Additional service data as a JSON string. Examples: {\"brightness\": 255, \"color_name\": \"blue\"} for lights, {\"temperature\": 22, \"hvac_mode\": \"heat\"} for climate, {\"volume_level\": 0.5} for media players.",
+ "type": "string"
+ },
+ "domain": {
+ "description": "Service domain (e.g. 'light', 'switch', 'climate', 'cover', 'media_player', 'fan', 'scene', 'script').",
+ "type": "string"
+ },
+ "entity_id": {
+ "description": "Target entity ID (e.g. 'light.living_room'). Some services (like scene.turn_on) may not need this.",
+ "type": "string"
+ },
+ "service": {
+ "description": "Service name (e.g. 'turn_on', 'turn_off', 'toggle', 'set_temperature', 'set_hvac_mode', 'open_cover', 'close_cover', 'set_volume_level').",
+ "type": "string"
+ }
+ },
+ "required": [
+ "domain",
+ "service"
+ ],
+ "type": "object"
+ }
+ },
+ "source_module": "homeassistant_tool.py",
+ "toolset": "homeassistant",
+ "trust_classes": [
+ "operator",
+ "gateway",
+ "child-agent",
+ "system"
+ ]
+ },
+ {
+ "degraded_status": {
+ "status_field": "degraded_status",
+ "statuses": [
+ "available",
+ "disabled",
+ "missing_dependency",
+ "schema_drift",
+ "unavailable_provider_path"
+ ]
+ },
+ "dependencies": [
+ "Home Assistant long-lived token"
+ ],
+ "description": "Get the detailed state of a single Home Assistant entity, including all attributes (brightness, color, temperature setpoint, sensor readings, etc.).",
+ "name": "ha_get_state",
+ "provider_paths": [],
+ "required_env": [
+ "HASS_TOKEN"
+ ],
+ "required_env_mode": "all",
+ "result_envelope": {
+ "encoding": "json-string",
+ "error_fields": [
+ "error"
+ ],
+ "success_fields": [
+ "success"
+ ]
+ },
+ "schema": {
+ "description": "Get the detailed state of a single Home Assistant entity, including all attributes (brightness, color, temperature setpoint, sensor readings, etc.).",
+ "name": "ha_get_state",
+ "parameters": {
+ "properties": {
+ "entity_id": {
+ "description": "The entity ID to query (e.g. 'light.living_room', 'climate.thermostat', 'sensor.temperature').",
+ "type": "string"
+ }
+ },
+ "required": [
+ "entity_id"
+ ],
+ "type": "object"
+ }
+ },
+ "source_module": "homeassistant_tool.py",
+ "toolset": "homeassistant",
+ "trust_classes": [
+ "operator",
+ "gateway",
+ "child-agent",
+ "system"
+ ]
+ },
+ {
+ "degraded_status": {
+ "status_field": "degraded_status",
+ "statuses": [
+ "available",
+ "disabled",
+ "missing_dependency",
+ "schema_drift",
+ "unavailable_provider_path"
+ ]
+ },
+ "dependencies": [
+ "Home Assistant long-lived token"
+ ],
+ "description": "List Home Assistant entities. Optionally filter by domain (light, switch, climate, sensor, binary_sensor, cover, fan, etc.) or by area name (living room, kitchen, bedroom, etc.).",
+ "name": "ha_list_entities",
+ "provider_paths": [],
+ "required_env": [
+ "HASS_TOKEN"
+ ],
+ "required_env_mode": "all",
+ "result_envelope": {
+ "encoding": "json-string",
+ "error_fields": [
+ "error"
+ ],
+ "success_fields": [
+ "success"
+ ]
+ },
+ "schema": {
+ "description": "List Home Assistant entities. Optionally filter by domain (light, switch, climate, sensor, binary_sensor, cover, fan, etc.) or by area name (living room, kitchen, bedroom, etc.).",
+ "name": "ha_list_entities",
+ "parameters": {
+ "properties": {
+ "area": {
+ "description": "Area/room name to filter by (e.g. 'living room', 'kitchen'). Matches against entity friendly names. Omit to list all.",
+ "type": "string"
+ },
+ "domain": {
+ "description": "Entity domain to filter by (e.g. 'light', 'switch', 'climate', 'sensor', 'binary_sensor', 'cover', 'fan', 'media_player'). Omit to list all entities.",
+ "type": "string"
+ }
+ },
+ "required": [],
+ "type": "object"
+ }
+ },
+ "source_module": "homeassistant_tool.py",
+ "toolset": "homeassistant",
+ "trust_classes": [
+ "operator",
+ "gateway",
+ "child-agent",
+ "system"
+ ]
+ },
+ {
+ "degraded_status": {
+ "status_field": "degraded_status",
+ "statuses": [
+ "available",
+ "disabled",
+ "missing_dependency",
+ "schema_drift",
+ "unavailable_provider_path"
+ ]
+ },
+ "dependencies": [
+ "Home Assistant long-lived token"
+ ],
+ "description": "List available Home Assistant services (actions) for device control. Shows what actions can be performed on each device type and what parameters they accept. Use this to discover how to control devices found via ha_list_entities.",
+ "name": "ha_list_services",
+ "provider_paths": [],
+ "required_env": [
+ "HASS_TOKEN"
+ ],
+ "required_env_mode": "all",
+ "result_envelope": {
+ "encoding": "json-string",
+ "error_fields": [
+ "error"
+ ],
+ "success_fields": [
+ "success"
+ ]
+ },
+ "schema": {
+ "description": "List available Home Assistant services (actions) for device control. Shows what actions can be performed on each device type and what parameters they accept. Use this to discover how to control devices found via ha_list_entities.",
+ "name": "ha_list_services",
+ "parameters": {
+ "properties": {
+ "domain": {
+ "description": "Filter by domain (e.g. 'light', 'climate', 'switch'). Omit to list services for all domains.",
+ "type": "string"
+ }
+ },
+ "required": [],
+ "type": "object"
+ }
+ },
+ "source_module": "homeassistant_tool.py",
+ "toolset": "homeassistant",
+ "trust_classes": [
+ "operator",
+ "gateway",
+ "child-agent",
+ "system"
+ ]
+ },
+ {
+ "degraded_status": {
+ "status_field": "degraded_status",
+ "statuses": [
+ "available",
+ "disabled",
+ "missing_dependency",
+ "schema_drift",
+ "unavailable_provider_path"
+ ]
+ },
+ "dependencies": [
+ "FAL SDK/key, managed gateway, or image provider plugin"
+ ],
+ "description": "Generate high-quality images from text prompts. The underlying backend (FAL, OpenAI, etc.) and model are user-configured and not selectable by the agent. Returns either a URL or an absolute file path in the `image` field; display it with markdown  and the gateway will deliver it.",
+ "name": "image_generate",
+ "provider_paths": [
+ {
+ "description": "Direct FAL.ai image generation path",
+ "id": "fal",
+ "required_env": [
+ "FAL_KEY"
+ ],
+ "required_env_mode": "all"
+ },
+ {
+ "description": "Nous managed FAL gateway path",
+ "id": "fal-managed-gateway",
+ "required_env": [
+ "FAL_GATEWAY_URL",
+ "TOOL_GATEWAY_DOMAIN",
+ "TOOL_GATEWAY_USER_TOKEN"
+ ],
+ "required_env_mode": "any"
+ }
+ ],
+ "required_env": [
+ "FAL_KEY",
+ "FAL_GATEWAY_URL",
+ "TOOL_GATEWAY_DOMAIN",
+ "TOOL_GATEWAY_USER_TOKEN"
+ ],
+ "required_env_mode": "any",
+ "result_envelope": {
+ "encoding": "json-string",
+ "error_fields": [
+ "error"
+ ],
+ "success_fields": [
+ "success",
+ "image",
+ "error_type"
+ ]
+ },
+ "schema": {
+ "description": "Generate high-quality images from text prompts. The underlying backend (FAL, OpenAI, etc.) and model are user-configured and not selectable by the agent. Returns either a URL or an absolute file path in the `image` field; display it with markdown  and the gateway will deliver it.",
+ "name": "image_generate",
+ "parameters": {
+ "properties": {
+ "aspect_ratio": {
+ "default": "landscape",
+ "description": "The aspect ratio of the generated image. 'landscape' is 16:9 wide, 'portrait' is 16:9 tall, 'square' is 1:1.",
+ "enum": [
+ "landscape",
+ "square",
+ "portrait"
+ ],
+ "type": "string"
+ },
+ "prompt": {
+ "description": "The text prompt describing the desired image. Be detailed and descriptive.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "prompt"
+ ],
+ "type": "object"
+ }
+ },
+ "source_module": "image_generation_tool.py",
+ "toolset": "image_gen",
+ "trust_classes": [
+ "operator",
+ "gateway",
+ "child-agent",
+ "system"
+ ]
+ },
+ {
+ "degraded_status": {
+ "status_field": "degraded_status",
+ "statuses": [
+ "available",
+ "disabled",
+ "missing_dependency",
+ "schema_drift",
+ "unavailable_provider_path"
+ ]
+ },
+ "dependencies": [],
+ "description": "Save durable information to persistent memory that survives across sessions. Memory is injected into future turns, so keep it compact and focused on facts that will still matter later.\n\nWHEN TO SAVE (do this proactively, don't wait to be asked):\n- User corrects you or says 'remember this' / 'don't do that again'\n- User shares a preference, habit, or personal detail (name, role, timezone, coding style)\n- You discover something about the environment (OS, installed tools, project structure)\n- You learn a convention, API quirk, or workflow specific to this user's setup\n- You identify a stable fact that will be useful again in future sessions\n\nPRIORITY: User preferences and corrections > environment facts > procedural knowledge. The most valuable memory prevents the user from having to repeat themselves.\n\nDo NOT save task progress, session outcomes, completed-work logs, or temporary TODO state to memory; use session_search to recall those from past transcripts.\nIf you've discovered a new way to do something, solved a problem that could be necessary later, save it as a skill with the skill tool.\n\nTWO TARGETS:\n- 'user': who the user is -- name, role, preferences, communication style, pet peeves\n- 'memory': your notes -- environment facts, project conventions, tool quirks, lessons learned\n\nACTIONS: add (new entry), replace (update existing -- old_text identifies it), remove (delete -- old_text identifies it).\n\nSKIP: trivial/obvious info, things easily re-discovered, raw data dumps, and temporary task state.",
+ "name": "memory",
+ "provider_paths": [],
+ "required_env": [],
+ "required_env_mode": "all",
+ "result_envelope": {
+ "encoding": "json-string",
+ "error_fields": [
+ "error"
+ ],
+ "success_fields": [
+ "success"
+ ]
+ },
+ "schema": {
+ "description": "Save durable information to persistent memory that survives across sessions. Memory is injected into future turns, so keep it compact and focused on facts that will still matter later.\n\nWHEN TO SAVE (do this proactively, don't wait to be asked):\n- User corrects you or says 'remember this' / 'don't do that again'\n- User shares a preference, habit, or personal detail (name, role, timezone, coding style)\n- You discover something about the environment (OS, installed tools, project structure)\n- You learn a convention, API quirk, or workflow specific to this user's setup\n- You identify a stable fact that will be useful again in future sessions\n\nPRIORITY: User preferences and corrections > environment facts > procedural knowledge. The most valuable memory prevents the user from having to repeat themselves.\n\nDo NOT save task progress, session outcomes, completed-work logs, or temporary TODO state to memory; use session_search to recall those from past transcripts.\nIf you've discovered a new way to do something, solved a problem that could be necessary later, save it as a skill with the skill tool.\n\nTWO TARGETS:\n- 'user': who the user is -- name, role, preferences, communication style, pet peeves\n- 'memory': your notes -- environment facts, project conventions, tool quirks, lessons learned\n\nACTIONS: add (new entry), replace (update existing -- old_text identifies it), remove (delete -- old_text identifies it).\n\nSKIP: trivial/obvious info, things easily re-discovered, raw data dumps, and temporary task state.",
+ "name": "memory",
+ "parameters": {
+ "properties": {
+ "action": {
+ "description": "The action to perform.",
+ "enum": [
+ "add",
+ "replace",
+ "remove"
+ ],
+ "type": "string"
+ },
+ "content": {
+ "description": "The entry content. Required for 'add' and 'replace'.",
+ "type": "string"
+ },
+ "old_text": {
+ "description": "Short unique substring identifying the entry to replace or remove.",
+ "type": "string"
+ },
+ "target": {
+ "description": "Which memory store: 'memory' for personal notes, 'user' for user profile.",
+ "enum": [
+ "memory",
+ "user"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "action",
+ "target"
+ ],
+ "type": "object"
+ }
+ },
+ "source_module": "memory_tool.py",
+ "toolset": "memory",
+ "trust_classes": [
+ "operator",
+ "gateway",
+ "child-agent",
+ "system"
+ ]
+ },
+ {
+ "degraded_status": {
+ "status_field": "degraded_status",
+ "statuses": [
+ "available",
+ "disabled",
+ "missing_dependency",
+ "schema_drift",
+ "unavailable_provider_path"
+ ]
+ },
+ "dependencies": [],
+ "description": "Route a hard problem through multiple frontier LLMs collaboratively. Makes 5 API calls (4 reference models + 1 aggregator) with maximum reasoning effort \u2014 use sparingly for genuinely difficult problems. Best for: complex math, advanced algorithms, multi-step analytical reasoning, problems benefiting from diverse perspectives.",
+ "name": "mixture_of_agents",
+ "provider_paths": [],
+ "required_env": [
+ "OPENROUTER_API_KEY"
+ ],
+ "required_env_mode": "all",
+ "result_envelope": {
+ "encoding": "json-string",
+ "error_fields": [
+ "error"
+ ],
+ "success_fields": [
+ "success"
+ ]
+ },
+ "schema": {
+ "description": "Route a hard problem through multiple frontier LLMs collaboratively. Makes 5 API calls (4 reference models + 1 aggregator) with maximum reasoning effort \u2014 use sparingly for genuinely difficult problems. Best for: complex math, advanced algorithms, multi-step analytical reasoning, problems benefiting from diverse perspectives.",
+ "name": "mixture_of_agents",
+ "parameters": {
+ "properties": {
+ "user_prompt": {
+ "description": "The complex query or problem to solve using multiple AI models. Should be a challenging problem that benefits from diverse perspectives and collaborative reasoning.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "user_prompt"
+ ],
+ "type": "object"
+ }
+ },
+ "source_module": "mixture_of_agents_tool.py",
+ "toolset": "moa",
+ "trust_classes": [
+ "operator",
+ "gateway",
+ "child-agent",
+ "system"
+ ]
+ },
+ {
+ "degraded_status": {
+ "status_field": "degraded_status",
+ "statuses": [
+ "available",
+ "disabled",
+ "missing_dependency",
+ "schema_drift",
+ "unavailable_provider_path"
+ ]
+ },
+ "dependencies": [
+ "session filesystem environment"
+ ],
+ "description": "Targeted find-and-replace edits in files. Use this instead of sed/awk in terminal. Uses fuzzy matching (9 strategies) so minor whitespace/indentation differences won't break it. Returns a unified diff. Auto-runs syntax checks after editing.\n\nReplace mode (default): find a unique string and replace it.\nPatch mode: apply V4A multi-file patches for bulk changes.",
+ "name": "patch",
+ "provider_paths": [],
+ "required_env": [],
+ "required_env_mode": "all",
+ "result_envelope": {
+ "encoding": "json-string",
+ "error_fields": [
+ "error"
+ ],
+ "success_fields": [
+ "success",
+ "content",
+ "diff"
+ ]
+ },
+ "schema": {
+ "description": "Targeted find-and-replace edits in files. Use this instead of sed/awk in terminal. Uses fuzzy matching (9 strategies) so minor whitespace/indentation differences won't break it. Returns a unified diff. Auto-runs syntax checks after editing.\n\nReplace mode (default): find a unique string and replace it.\nPatch mode: apply V4A multi-file patches for bulk changes.",
+ "name": "patch",
+ "parameters": {
+ "properties": {
+ "mode": {
+ "default": "replace",
+ "description": "Edit mode: 'replace' for targeted find-and-replace, 'patch' for V4A multi-file patches",
+ "enum": [
+ "replace",
+ "patch"
+ ],
+ "type": "string"
+ },
+ "new_string": {
+ "description": "Replacement text (required for 'replace' mode). Can be empty string to delete the matched text.",
+ "type": "string"
+ },
+ "old_string": {
+ "description": "Text to find in the file (required for 'replace' mode). Must be unique in the file unless replace_all=true. Include enough surrounding context to ensure uniqueness.",
+ "type": "string"
+ },
+ "patch": {
+ "description": "V4A format patch content (required for 'patch' mode). Format:\n*** Begin Patch\n*** Update File: path/to/file\n@@ context hint @@\n context line\n-removed line\n+added line\n*** End Patch",
+ "type": "string"
+ },
+ "path": {
+ "description": "File path to edit (required for 'replace' mode)",
+ "type": "string"
+ },
+ "replace_all": {
+ "default": false,
+ "description": "Replace all occurrences instead of requiring a unique match (default: false)",
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "mode"
+ ],
+ "type": "object"
+ }
+ },
+ "source_module": "file_tools.py",
+ "toolset": "file",
+ "trust_classes": [
+ "operator",
+ "gateway",
+ "child-agent",
+ "system"
+ ]
+ },
+ {
+ "degraded_status": {
+ "status_field": "degraded_status",
+ "statuses": [
+ "available",
+ "disabled",
+ "missing_dependency",
+ "schema_drift",
+ "unavailable_provider_path"
+ ]
+ },
+ "dependencies": [
+ "terminal backend"
+ ],
+ "description": "Manage background processes started with terminal(background=true). Actions: 'list' (show all), 'poll' (check status + new output), 'log' (full output with pagination), 'wait' (block until done or timeout), 'kill' (terminate), 'write' (send raw stdin data without newline), 'submit' (send data + Enter, for answering prompts), 'close' (close stdin/send EOF).",
+ "name": "process",
+ "provider_paths": [],
+ "required_env": [],
+ "required_env_mode": "all",
+ "result_envelope": {
+ "encoding": "json-string",
+ "error_fields": [
+ "error"
+ ],
+ "success_fields": [
+ "output",
+ "exit_code"
+ ]
+ },
+ "schema": {
+ "description": "Manage background processes started with terminal(background=true). Actions: 'list' (show all), 'poll' (check status + new output), 'log' (full output with pagination), 'wait' (block until done or timeout), 'kill' (terminate), 'write' (send raw stdin data without newline), 'submit' (send data + Enter, for answering prompts), 'close' (close stdin/send EOF).",
+ "name": "process",
+ "parameters": {
+ "properties": {
+ "action": {
+ "description": "Action to perform on background processes",
+ "enum": [
+ "list",
+ "poll",
+ "log",
+ "wait",
+ "kill",
+ "write",
+ "submit",
+ "close"
+ ],
+ "type": "string"
+ },
+ "data": {
+ "description": "Text to send to process stdin (for 'write' and 'submit' actions)",
+ "type": "string"
+ },
+ "limit": {
+ "description": "Max lines to return for 'log' action",
+ "minimum": 1,
+ "type": "integer"
+ },
+ "offset": {
+ "description": "Line offset for 'log' action (default: last 200 lines)",
+ "type": "integer"
+ },
+ "session_id": {
+ "description": "Process session ID (from terminal background output). Required for all actions except 'list'.",
+ "type": "string"
+ },
+ "timeout": {
+ "description": "Max seconds to block for 'wait' action. Returns partial output on timeout.",
+ "minimum": 1,
+ "type": "integer"
+ }
+ },
+ "required": [
+ "action"
+ ],
+ "type": "object"
+ }
+ },
+ "source_module": "process_registry.py",
+ "toolset": "terminal",
+ "trust_classes": [
+ "operator",
+ "gateway",
+ "child-agent",
+ "system"
+ ]
+ },
+ {
+ "degraded_status": {
+ "status_field": "degraded_status",
+ "statuses": [
+ "available",
+ "disabled",
+ "missing_dependency",
+ "schema_drift",
+ "unavailable_provider_path"
+ ]
+ },
+ "dependencies": [
+ "session filesystem environment"
+ ],
+ "description": "Read a text file with line numbers and pagination. Use this instead of cat/head/tail in terminal. Output format: 'LINE_NUM|CONTENT'. Suggests similar filenames if not found. Use offset and limit for large files. Reads exceeding ~100K characters are rejected; use offset and limit to read specific sections of large files. NOTE: Cannot read images or binary files \u2014 use vision_analyze for images.",
+ "name": "read_file",
+ "provider_paths": [],
+ "required_env": [],
+ "required_env_mode": "all",
+ "result_envelope": {
+ "encoding": "json-string",
+ "error_fields": [
+ "error"
+ ],
+ "success_fields": [
+ "success",
+ "content",
+ "diff"
+ ]
+ },
+ "schema": {
+ "description": "Read a text file with line numbers and pagination. Use this instead of cat/head/tail in terminal. Output format: 'LINE_NUM|CONTENT'. Suggests similar filenames if not found. Use offset and limit for large files. Reads exceeding ~100K characters are rejected; use offset and limit to read specific sections of large files. NOTE: Cannot read images or binary files \u2014 use vision_analyze for images.",
+ "name": "read_file",
+ "parameters": {
+ "properties": {
+ "limit": {
+ "default": 500,
+ "description": "Maximum number of lines to read (default: 500, max: 2000)",
+ "maximum": 2000,
+ "type": "integer"
+ },
+ "offset": {
+ "default": 1,
+ "description": "Line number to start reading from (1-indexed, default: 1)",
+ "minimum": 1,
+ "type": "integer"
+ },
+ "path": {
+ "description": "Path to the file to read (absolute, relative, or ~/path)",
+ "type": "string"
+ }
+ },
+ "required": [
+ "path"
+ ],
+ "type": "object"
+ }
+ },
+ "source_module": "file_tools.py",
+ "toolset": "file",
+ "trust_classes": [
+ "operator",
+ "gateway",
+ "child-agent",
+ "system"
+ ]
+ },
+ {
+ "degraded_status": {
+ "status_field": "degraded_status",
+ "statuses": [
+ "available",
+ "disabled",
+ "missing_dependency",
+ "schema_drift",
+ "unavailable_provider_path"
+ ]
+ },
+ "dependencies": [],
+ "description": "Get status and metrics for a training run. RATE LIMITED: enforces 30-minute minimum between checks for the same run. Returns WandB metrics: step, state, reward_mean, loss, percent_correct.",
+ "name": "rl_check_status",
+ "provider_paths": [],
+ "required_env": [
+ "TINKER_API_KEY",
+ "WANDB_API_KEY"
+ ],
+ "required_env_mode": "all",
+ "result_envelope": {
+ "encoding": "json-string",
+ "error_fields": [
+ "error"
+ ],
+ "success_fields": [
+ "success",
+ "status",
+ "data"
+ ]
+ },
+ "schema": {
+ "description": "Get status and metrics for a training run. RATE LIMITED: enforces 30-minute minimum between checks for the same run. Returns WandB metrics: step, state, reward_mean, loss, percent_correct.",
+ "name": "rl_check_status",
+ "parameters": {
+ "properties": {
+ "run_id": {
+ "description": "The run ID from rl_start_training()",
+ "type": "string"
+ }
+ },
+ "required": [
+ "run_id"
+ ],
+ "type": "object"
+ }
+ },
+ "source_module": "rl_training_tool.py",
+ "toolset": "rl",
+ "trust_classes": [
+ "operator",
+ "gateway",
+ "child-agent",
+ "system"
+ ]
+ },
+ {
+ "degraded_status": {
+ "status_field": "degraded_status",
+ "statuses": [
+ "available",
+ "disabled",
+ "missing_dependency",
+ "schema_drift",
+ "unavailable_provider_path"
+ ]
+ },
+ "dependencies": [],
+ "description": "Update a configuration field. Use rl_get_current_config() first to see all available fields for the selected environment. Each environment has different configurable options. Infrastructure settings (tokenizer, URLs, lora_rank, learning_rate) are locked.",
+ "name": "rl_edit_config",
+ "provider_paths": [],
+ "required_env": [
+ "TINKER_API_KEY",
+ "WANDB_API_KEY"
+ ],
+ "required_env_mode": "all",
+ "result_envelope": {
+ "encoding": "json-string",
+ "error_fields": [
+ "error"
+ ],
+ "success_fields": [
+ "success",
+ "status",
+ "data"
+ ]
+ },
+ "schema": {
+ "description": "Update a configuration field. Use rl_get_current_config() first to see all available fields for the selected environment. Each environment has different configurable options. Infrastructure settings (tokenizer, URLs, lora_rank, learning_rate) are locked.",
+ "name": "rl_edit_config",
+ "parameters": {
+ "properties": {
+ "field": {
+ "description": "Name of the field to update (get available fields from rl_get_current_config)",
+ "type": "string"
+ },
+ "value": {
+ "description": "New value for the field"
+ }
+ },
+ "required": [
+ "field",
+ "value"
+ ],
+ "type": "object"
+ }
+ },
+ "source_module": "rl_training_tool.py",
+ "toolset": "rl",
+ "trust_classes": [
+ "operator",
+ "gateway",
+ "child-agent",
+ "system"
+ ]
+ },
+ {
+ "degraded_status": {
+ "status_field": "degraded_status",
+ "statuses": [
+ "available",
+ "disabled",
+ "missing_dependency",
+ "schema_drift",
+ "unavailable_provider_path"
+ ]
+ },
+ "dependencies": [],
+ "description": "Get the current environment configuration. Returns only fields that can be modified: group_size, max_token_length, total_steps, steps_per_eval, use_wandb, wandb_name, max_num_workers.",
+ "name": "rl_get_current_config",
+ "provider_paths": [],
+ "required_env": [
+ "TINKER_API_KEY",
+ "WANDB_API_KEY"
+ ],
+ "required_env_mode": "all",
+ "result_envelope": {
+ "encoding": "json-string",
+ "error_fields": [
+ "error"
+ ],
+ "success_fields": [
+ "success",
+ "status",
+ "data"
+ ]
+ },
+ "schema": {
+ "description": "Get the current environment configuration. Returns only fields that can be modified: group_size, max_token_length, total_steps, steps_per_eval, use_wandb, wandb_name, max_num_workers.",
+ "name": "rl_get_current_config",
+ "parameters": {
+ "properties": {},
+ "required": [],
+ "type": "object"
+ }
+ },
+ "source_module": "rl_training_tool.py",
+ "toolset": "rl",
+ "trust_classes": [
+ "operator",
+ "gateway",
+ "child-agent",
+ "system"
+ ]
+ },
+ {
+ "degraded_status": {
+ "status_field": "degraded_status",
+ "statuses": [
+ "available",
+ "disabled",
+ "missing_dependency",
+ "schema_drift",
+ "unavailable_provider_path"
+ ]
+ },
+ "dependencies": [],
+ "description": "Get final results and metrics for a completed training run. Returns final metrics and path to trained weights.",
+ "name": "rl_get_results",
+ "provider_paths": [],
+ "required_env": [
+ "TINKER_API_KEY",
+ "WANDB_API_KEY"
+ ],
+ "required_env_mode": "all",
+ "result_envelope": {
+ "encoding": "json-string",
+ "error_fields": [
+ "error"
+ ],
+ "success_fields": [
+ "success",
+ "status",
+ "data"
+ ]
+ },
+ "schema": {
+ "description": "Get final results and metrics for a completed training run. Returns final metrics and path to trained weights.",
+ "name": "rl_get_results",
+ "parameters": {
+ "properties": {
+ "run_id": {
+ "description": "The run ID to get results for",
+ "type": "string"
+ }
+ },
+ "required": [
+ "run_id"
+ ],
+ "type": "object"
+ }
+ },
+ "source_module": "rl_training_tool.py",
+ "toolset": "rl",
+ "trust_classes": [
+ "operator",
+ "gateway",
+ "child-agent",
+ "system"
+ ]
+ },
+ {
+ "degraded_status": {
+ "status_field": "degraded_status",
+ "statuses": [
+ "available",
+ "disabled",
+ "missing_dependency",
+ "schema_drift",
+ "unavailable_provider_path"
+ ]
+ },
+ "dependencies": [],
+ "description": "List all available RL environments. Returns environment names, paths, and descriptions. TIP: Read the file_path with file tools to understand how each environment works (verifiers, data loading, rewards).",
+ "name": "rl_list_environments",
+ "provider_paths": [],
+ "required_env": [
+ "TINKER_API_KEY",
+ "WANDB_API_KEY"
+ ],
+ "required_env_mode": "all",
+ "result_envelope": {
+ "encoding": "json-string",
+ "error_fields": [
+ "error"
+ ],
+ "success_fields": [
+ "success",
+ "status",
+ "data"
+ ]
+ },
+ "schema": {
+ "description": "List all available RL environments. Returns environment names, paths, and descriptions. TIP: Read the file_path with file tools to understand how each environment works (verifiers, data loading, rewards).",
+ "name": "rl_list_environments",
+ "parameters": {
+ "properties": {},
+ "required": [],
+ "type": "object"
+ }
+ },
+ "source_module": "rl_training_tool.py",
+ "toolset": "rl",
+ "trust_classes": [
+ "operator",
+ "gateway",
+ "child-agent",
+ "system"
+ ]
+ },
+ {
+ "degraded_status": {
+ "status_field": "degraded_status",
+ "statuses": [
+ "available",
+ "disabled",
+ "missing_dependency",
+ "schema_drift",
+ "unavailable_provider_path"
+ ]
+ },
+ "dependencies": [],
+ "description": "List all training runs (active and completed) with their status.",
+ "name": "rl_list_runs",
+ "provider_paths": [],
+ "required_env": [
+ "TINKER_API_KEY",
+ "WANDB_API_KEY"
+ ],
+ "required_env_mode": "all",
+ "result_envelope": {
+ "encoding": "json-string",
+ "error_fields": [
+ "error"
+ ],
+ "success_fields": [
+ "success",
+ "status",
+ "data"
+ ]
+ },
+ "schema": {
+ "description": "List all training runs (active and completed) with their status.",
+ "name": "rl_list_runs",
+ "parameters": {
+ "properties": {},
+ "required": [],
+ "type": "object"
+ }
+ },
+ "source_module": "rl_training_tool.py",
+ "toolset": "rl",
+ "trust_classes": [
+ "operator",
+ "gateway",
+ "child-agent",
+ "system"
+ ]
+ },
+ {
+ "degraded_status": {
+ "status_field": "degraded_status",
+ "statuses": [
+ "available",
+ "disabled",
+ "missing_dependency",
+ "schema_drift",
+ "unavailable_provider_path"
+ ]
+ },
+ "dependencies": [],
+ "description": "Select an RL environment for training. Loads the environment's default configuration. After selecting, use rl_get_current_config() to see settings and rl_edit_config() to modify them.",
+ "name": "rl_select_environment",
+ "provider_paths": [],
+ "required_env": [
+ "TINKER_API_KEY",
+ "WANDB_API_KEY"
+ ],
+ "required_env_mode": "all",
+ "result_envelope": {
+ "encoding": "json-string",
+ "error_fields": [
+ "error"
+ ],
+ "success_fields": [
+ "success",
+ "status",
+ "data"
+ ]
+ },
+ "schema": {
+ "description": "Select an RL environment for training. Loads the environment's default configuration. After selecting, use rl_get_current_config() to see settings and rl_edit_config() to modify them.",
+ "name": "rl_select_environment",
+ "parameters": {
+ "properties": {
+ "name": {
+ "description": "Name of the environment to select (from rl_list_environments)",
+ "type": "string"
+ }
+ },
+ "required": [
+ "name"
+ ],
+ "type": "object"
+ }
+ },
+ "source_module": "rl_training_tool.py",
+ "toolset": "rl",
+ "trust_classes": [
+ "operator",
+ "gateway",
+ "child-agent",
+ "system"
+ ]
+ },
+ {
+ "degraded_status": {
+ "status_field": "degraded_status",
+ "statuses": [
+ "available",
+ "disabled",
+ "missing_dependency",
+ "schema_drift",
+ "unavailable_provider_path"
+ ]
+ },
+ "dependencies": [],
+ "description": "Start a new RL training run with the current environment and config. Most training parameters (lora_rank, learning_rate, etc.) are fixed. Use rl_edit_config() to set group_size, batch_size, wandb_project before starting. WARNING: Training takes hours.",
+ "name": "rl_start_training",
+ "provider_paths": [],
+ "required_env": [
+ "TINKER_API_KEY",
+ "WANDB_API_KEY"
+ ],
+ "required_env_mode": "all",
+ "result_envelope": {
+ "encoding": "json-string",
+ "error_fields": [
+ "error"
+ ],
+ "success_fields": [
+ "success",
+ "status",
+ "data"
+ ]
+ },
+ "schema": {
+ "description": "Start a new RL training run with the current environment and config. Most training parameters (lora_rank, learning_rate, etc.) are fixed. Use rl_edit_config() to set group_size, batch_size, wandb_project before starting. WARNING: Training takes hours.",
+ "name": "rl_start_training",
+ "parameters": {
+ "properties": {},
+ "required": [],
+ "type": "object"
+ }
+ },
+ "source_module": "rl_training_tool.py",
+ "toolset": "rl",
+ "trust_classes": [
+ "operator",
+ "gateway",
+ "child-agent",
+ "system"
+ ]
+ },
+ {
+ "degraded_status": {
+ "status_field": "degraded_status",
+ "statuses": [
+ "available",
+ "disabled",
+ "missing_dependency",
+ "schema_drift",
+ "unavailable_provider_path"
+ ]
+ },
+ "dependencies": [],
+ "description": "Stop a running training job. Use if metrics look bad, training is stagnant, or you want to try different settings.",
+ "name": "rl_stop_training",
+ "provider_paths": [],
+ "required_env": [
+ "TINKER_API_KEY",
+ "WANDB_API_KEY"
+ ],
+ "required_env_mode": "all",
+ "result_envelope": {
+ "encoding": "json-string",
+ "error_fields": [
+ "error"
+ ],
+ "success_fields": [
+ "success",
+ "status",
+ "data"
+ ]
+ },
+ "schema": {
+ "description": "Stop a running training job. Use if metrics look bad, training is stagnant, or you want to try different settings.",
+ "name": "rl_stop_training",
+ "parameters": {
+ "properties": {
+ "run_id": {
+ "description": "The run ID to stop",
+ "type": "string"
+ }
+ },
+ "required": [
+ "run_id"
+ ],
+ "type": "object"
+ }
+ },
+ "source_module": "rl_training_tool.py",
+ "toolset": "rl",
+ "trust_classes": [
+ "operator",
+ "gateway",
+ "child-agent",
+ "system"
+ ]
+ },
+ {
+ "degraded_status": {
+ "status_field": "degraded_status",
+ "statuses": [
+ "available",
+ "disabled",
+ "missing_dependency",
+ "schema_drift",
+ "unavailable_provider_path"
+ ]
+ },
+ "dependencies": [],
+ "description": "Quick inference test for any environment. Runs a few steps of inference + scoring using OpenRouter. Default: 3 steps x 16 completions = 48 rollouts per model, testing 3 models = 144 total. Tests environment loading, prompt construction, inference parsing, and verifier logic. Use BEFORE training to catch issues.",
+ "name": "rl_test_inference",
+ "provider_paths": [],
+ "required_env": [
+ "TINKER_API_KEY",
+ "WANDB_API_KEY"
+ ],
+ "required_env_mode": "all",
+ "result_envelope": {
+ "encoding": "json-string",
+ "error_fields": [
+ "error"
+ ],
+ "success_fields": [
+ "success",
+ "status",
+ "data"
+ ]
+ },
+ "schema": {
+ "description": "Quick inference test for any environment. Runs a few steps of inference + scoring using OpenRouter. Default: 3 steps x 16 completions = 48 rollouts per model, testing 3 models = 144 total. Tests environment loading, prompt construction, inference parsing, and verifier logic. Use BEFORE training to catch issues.",
+ "name": "rl_test_inference",
+ "parameters": {
+ "properties": {
+ "group_size": {
+ "default": 16,
+ "description": "Completions per step (default: 16, like training)",
+ "type": "integer"
+ },
+ "models": {
+ "description": "Optional list of OpenRouter model IDs. Default: qwen/qwen3-8b, z-ai/glm-4.7-flash, minimax/minimax-m2.7",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "num_steps": {
+ "default": 3,
+ "description": "Number of steps to run (default: 3, recommended max for testing)",
+ "type": "integer"
+ }
+ },
+ "required": [],
+ "type": "object"
+ }
+ },
+ "source_module": "rl_training_tool.py",
+ "toolset": "rl",
+ "trust_classes": [
+ "operator",
+ "gateway",
+ "child-agent",
+ "system"
+ ]
+ },
+ {
+ "degraded_status": {
+ "status_field": "degraded_status",
+ "statuses": [
+ "available",
+ "disabled",
+ "missing_dependency",
+ "schema_drift",
+ "unavailable_provider_path"
+ ]
+ },
+ "dependencies": [
+ "session filesystem environment"
+ ],
+ "description": "Search file contents or find files by name. Use this instead of grep/rg/find/ls in terminal. Ripgrep-backed, faster than shell equivalents.\n\nContent search (target='content'): Regex search inside files. Output modes: full matches with line numbers, file paths only, or match counts.\n\nFile search (target='files'): Find files by glob pattern (e.g., '*.py', '*config*'). Also use this instead of ls \u2014 results sorted by modification time.",
+ "name": "search_files",
+ "provider_paths": [],
+ "required_env": [],
+ "required_env_mode": "all",
+ "result_envelope": {
+ "encoding": "json-string",
+ "error_fields": [
+ "error"
+ ],
+ "success_fields": [
+ "success",
+ "content",
+ "diff"
+ ]
+ },
+ "schema": {
+ "description": "Search file contents or find files by name. Use this instead of grep/rg/find/ls in terminal. Ripgrep-backed, faster than shell equivalents.\n\nContent search (target='content'): Regex search inside files. Output modes: full matches with line numbers, file paths only, or match counts.\n\nFile search (target='files'): Find files by glob pattern (e.g., '*.py', '*config*'). Also use this instead of ls \u2014 results sorted by modification time.",
+ "name": "search_files",
+ "parameters": {
+ "properties": {
+ "context": {
+ "default": 0,
+ "description": "Number of context lines before and after each match (grep mode only)",
+ "type": "integer"
+ },
+ "file_glob": {
+ "description": "Filter files by pattern in grep mode (e.g., '*.py' to only search Python files)",
+ "type": "string"
+ },
+ "limit": {
+ "default": 50,
+ "description": "Maximum number of results to return (default: 50)",
+ "type": "integer"
+ },
+ "offset": {
+ "default": 0,
+ "description": "Skip first N results for pagination (default: 0)",
+ "type": "integer"
+ },
+ "output_mode": {
+ "default": "content",
+ "description": "Output format for grep mode: 'content' shows matching lines with line numbers, 'files_only' lists file paths, 'count' shows match counts per file",
+ "enum": [
+ "content",
+ "files_only",
+ "count"
+ ],
+ "type": "string"
+ },
+ "path": {
+ "default": ".",
+ "description": "Directory or file to search in (default: current working directory)",
+ "type": "string"
+ },
+ "pattern": {
+ "description": "Regex pattern for content search, or glob pattern (e.g., '*.py') for file search",
+ "type": "string"
+ },
+ "target": {
+ "default": "content",
+ "description": "'content' searches inside file contents, 'files' searches for files by name",
+ "enum": [
+ "content",
+ "files"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "pattern"
+ ],
+ "type": "object"
+ }
+ },
+ "source_module": "file_tools.py",
+ "toolset": "file",
+ "trust_classes": [
+ "operator",
+ "gateway",
+ "child-agent",
+ "system"
+ ]
+ },
+ {
+ "degraded_status": {
+ "status_field": "degraded_status",
+ "statuses": [
+ "available",
+ "disabled",
+ "missing_dependency",
+ "schema_drift",
+ "unavailable_provider_path"
+ ]
+ },
+ "dependencies": [],
+ "description": "Send a message to a connected messaging platform, or list available targets.\n\nIMPORTANT: When the user asks to send to a specific channel or person (not just a bare platform name), call send_message(action='list') FIRST to see available targets, then send to the correct one.\nIf the user just says a platform name like 'send to telegram', send directly to the home channel without listing first.",
+ "name": "send_message",
+ "provider_paths": [],
+ "required_env": [],
+ "required_env_mode": "all",
+ "result_envelope": {
+ "encoding": "json-string",
+ "error_fields": [
+ "error"
+ ],
+ "success_fields": [
+ "success"
+ ]
+ },
+ "schema": {
+ "description": "Send a message to a connected messaging platform, or list available targets.\n\nIMPORTANT: When the user asks to send to a specific channel or person (not just a bare platform name), call send_message(action='list') FIRST to see available targets, then send to the correct one.\nIf the user just says a platform name like 'send to telegram', send directly to the home channel without listing first.",
+ "name": "send_message",
+ "parameters": {
+ "properties": {
+ "action": {
+ "description": "Action to perform. 'send' (default) sends a message. 'list' returns all available channels/contacts across connected platforms.",
+ "enum": [
+ "send",
+ "list"
+ ],
+ "type": "string"
+ },
+ "message": {
+ "description": "The message text to send",
+ "type": "string"
+ },
+ "target": {
+ "description": "Delivery target. Format: 'platform' (uses home channel), 'platform:#channel-name', 'platform:chat_id', or 'platform:chat_id:thread_id' for Telegram topics and Discord threads. Examples: 'telegram', 'telegram:-1001234567890:17585', 'discord:999888777:555444333', 'discord:#bot-home', 'slack:#engineering', 'signal:+155****4567', 'matrix:!roomid:server.org', 'matrix:@user:server.org'",
+ "type": "string"
+ }
+ },
+ "required": [],
+ "type": "object"
+ }
+ },
+ "source_module": "send_message_tool.py",
+ "toolset": "messaging",
+ "trust_classes": [
+ "operator",
+ "gateway",
+ "child-agent",
+ "system"
+ ]
+ },
+ {
+ "degraded_status": {
+ "status_field": "degraded_status",
+ "statuses": [
+ "available",
+ "disabled",
+ "missing_dependency",
+ "schema_drift",
+ "unavailable_provider_path"
+ ]
+ },
+ "dependencies": [],
+ "description": "Search your long-term memory of past conversations, or browse recent sessions. This is your recall -- every past session is searchable, and this tool summarizes what happened.\n\nTWO MODES:\n1. Recent sessions (no query): Call with no arguments to see what was worked on recently. Returns titles, previews, and timestamps. Zero LLM cost, instant. Start here when the user asks what were we working on or what did we do recently.\n2. Keyword search (with query): Search for specific topics across all past sessions. Returns LLM-generated summaries of matching sessions.\n\nUSE THIS PROACTIVELY when:\n- The user says 'we did this before', 'remember when', 'last time', 'as I mentioned'\n- The user asks about a topic you worked on before but don't have in current context\n- The user references a project, person, or concept that seems familiar but isn't in memory\n- You want to check if you've solved a similar problem before\n- The user asks 'what did we do about X?' or 'how did we fix Y?'\n\nDon't hesitate to search when it is actually cross-session -- it's fast and cheap. Better to search and confirm than to guess or ask the user to repeat themselves.\n\nSearch syntax: keywords joined with OR for broad recall (elevenlabs OR baseten OR funding), phrases for exact match (\"docker networking\"), boolean (python NOT java), prefix (deploy*). IMPORTANT: Use OR between keywords for best results \u2014 FTS5 defaults to AND which misses sessions that only mention some terms. If a broad OR query returns nothing, try individual keyword searches in parallel. Returns summaries of the top matching sessions.",
+ "name": "session_search",
+ "provider_paths": [],
+ "required_env": [],
+ "required_env_mode": "all",
+ "result_envelope": {
+ "encoding": "json-string",
+ "error_fields": [
+ "error"
+ ],
+ "success_fields": [
+ "success"
+ ]
+ },
+ "schema": {
+ "description": "Search your long-term memory of past conversations, or browse recent sessions. This is your recall -- every past session is searchable, and this tool summarizes what happened.\n\nTWO MODES:\n1. Recent sessions (no query): Call with no arguments to see what was worked on recently. Returns titles, previews, and timestamps. Zero LLM cost, instant. Start here when the user asks what were we working on or what did we do recently.\n2. Keyword search (with query): Search for specific topics across all past sessions. Returns LLM-generated summaries of matching sessions.\n\nUSE THIS PROACTIVELY when:\n- The user says 'we did this before', 'remember when', 'last time', 'as I mentioned'\n- The user asks about a topic you worked on before but don't have in current context\n- The user references a project, person, or concept that seems familiar but isn't in memory\n- You want to check if you've solved a similar problem before\n- The user asks 'what did we do about X?' or 'how did we fix Y?'\n\nDon't hesitate to search when it is actually cross-session -- it's fast and cheap. Better to search and confirm than to guess or ask the user to repeat themselves.\n\nSearch syntax: keywords joined with OR for broad recall (elevenlabs OR baseten OR funding), phrases for exact match (\"docker networking\"), boolean (python NOT java), prefix (deploy*). IMPORTANT: Use OR between keywords for best results \u2014 FTS5 defaults to AND which misses sessions that only mention some terms. If a broad OR query returns nothing, try individual keyword searches in parallel. Returns summaries of the top matching sessions.",
+ "name": "session_search",
+ "parameters": {
+ "properties": {
+ "limit": {
+ "default": 3,
+ "description": "Max sessions to summarize (default: 3, max: 5).",
+ "type": "integer"
+ },
+ "query": {
+ "description": "Search query \u2014 keywords, phrases, or boolean expressions to find in past sessions. Omit this parameter entirely to browse recent sessions instead (returns titles, previews, timestamps with no LLM cost).",
+ "type": "string"
+ },
+ "role_filter": {
+ "description": "Optional: only search messages from specific roles (comma-separated). E.g. 'user,assistant' to skip tool outputs.",
+ "type": "string"
+ }
+ },
+ "required": [],
+ "type": "object"
+ }
+ },
+ "source_module": "session_search_tool.py",
+ "toolset": "session_search",
+ "trust_classes": [
+ "operator",
+ "gateway",
+ "child-agent",
+ "system"
+ ]
+ },
+ {
+ "degraded_status": {
+ "status_field": "degraded_status",
+ "statuses": [
+ "available",
+ "disabled",
+ "missing_dependency",
+ "schema_drift",
+ "unavailable_provider_path"
+ ]
+ },
+ "dependencies": [],
+ "description": "Manage skills (create, update, delete). Skills are your procedural memory \u2014 reusable approaches for recurring task types. New skills go to ~/.hermes/skills/; existing skills can be modified wherever they live.\n\nActions: create (full SKILL.md + optional category), patch (old_string/new_string \u2014 preferred for fixes), edit (full SKILL.md rewrite \u2014 major overhauls only), delete, write_file, remove_file.\n\nCreate when: complex task succeeded (5+ calls), errors overcome, user-corrected approach worked, non-trivial workflow discovered, or user asks you to remember a procedure.\nUpdate when: instructions stale/wrong, OS-specific failures, missing steps or pitfalls found during use. If you used a skill and hit issues not covered by it, patch it immediately.\n\nAfter difficult/iterative tasks, offer to save as a skill. Skip for simple one-offs. Confirm with user before creating/deleting.\n\nGood skills: trigger conditions, numbered steps with exact commands, pitfalls section, verification steps. Use skill_view() to see format examples.",
+ "name": "skill_manage",
+ "provider_paths": [],
+ "required_env": [],
+ "required_env_mode": "all",
+ "result_envelope": {
+ "encoding": "json-string",
+ "error_fields": [
+ "error"
+ ],
+ "success_fields": [
+ "success"
+ ]
+ },
+ "schema": {
+ "description": "Manage skills (create, update, delete). Skills are your procedural memory \u2014 reusable approaches for recurring task types. New skills go to ~/.hermes/skills/; existing skills can be modified wherever they live.\n\nActions: create (full SKILL.md + optional category), patch (old_string/new_string \u2014 preferred for fixes), edit (full SKILL.md rewrite \u2014 major overhauls only), delete, write_file, remove_file.\n\nCreate when: complex task succeeded (5+ calls), errors overcome, user-corrected approach worked, non-trivial workflow discovered, or user asks you to remember a procedure.\nUpdate when: instructions stale/wrong, OS-specific failures, missing steps or pitfalls found during use. If you used a skill and hit issues not covered by it, patch it immediately.\n\nAfter difficult/iterative tasks, offer to save as a skill. Skip for simple one-offs. Confirm with user before creating/deleting.\n\nGood skills: trigger conditions, numbered steps with exact commands, pitfalls section, verification steps. Use skill_view() to see format examples.",
+ "name": "skill_manage",
+ "parameters": {
+ "properties": {
+ "action": {
+ "description": "The action to perform.",
+ "enum": [
+ "create",
+ "patch",
+ "edit",
+ "delete",
+ "write_file",
+ "remove_file"
+ ],
+ "type": "string"
+ },
+ "category": {
+ "description": "Optional category/domain for organizing the skill (e.g., 'devops', 'data-science', 'mlops'). Creates a subdirectory grouping. Only used with 'create'.",
+ "type": "string"
+ },
+ "content": {
+ "description": "Full SKILL.md content (YAML frontmatter + markdown body). Required for 'create' and 'edit'. For 'edit', read the skill first with skill_view() and provide the complete updated text.",
+ "type": "string"
+ },
+ "file_content": {
+ "description": "Content for the file. Required for 'write_file'.",
+ "type": "string"
+ },
+ "file_path": {
+ "description": "Path to a supporting file within the skill directory. For 'write_file'/'remove_file': required, must be under references/, templates/, scripts/, or assets/. For 'patch': optional, defaults to SKILL.md if omitted.",
+ "type": "string"
+ },
+ "name": {
+ "description": "Skill name (lowercase, hyphens/underscores, max 64 chars). Must match an existing skill for patch/edit/delete/write_file/remove_file.",
+ "type": "string"
+ },
+ "new_string": {
+ "description": "Replacement text (required for 'patch'). Can be empty string to delete the matched text.",
+ "type": "string"
+ },
+ "old_string": {
+ "description": "Text to find in the file (required for 'patch'). Must be unique unless replace_all=true. Include enough surrounding context to ensure uniqueness.",
+ "type": "string"
+ },
+ "replace_all": {
+ "description": "For 'patch': replace all occurrences instead of requiring a unique match (default: false).",
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "action",
+ "name"
+ ],
+ "type": "object"
+ }
+ },
+ "source_module": "skill_manager_tool.py",
+ "toolset": "skills",
+ "trust_classes": [
+ "operator",
+ "gateway",
+ "child-agent",
+ "system"
+ ]
+ },
+ {
+ "degraded_status": {
+ "status_field": "degraded_status",
+ "statuses": [
+ "available",
+ "disabled",
+ "missing_dependency",
+ "schema_drift",
+ "unavailable_provider_path"
+ ]
+ },
+ "dependencies": [],
+ "description": "Skills allow for loading information about specific tasks and workflows, as well as scripts and templates. Load a skill's full content or access its linked files (references, templates, scripts). First call returns SKILL.md content plus a 'linked_files' dict showing available references/templates/scripts. To access those, call again with file_path parameter.",
+ "name": "skill_view",
+ "provider_paths": [],
+ "required_env": [],
+ "required_env_mode": "all",
+ "result_envelope": {
+ "encoding": "json-string",
+ "error_fields": [
+ "error"
+ ],
+ "success_fields": [
+ "success"
+ ]
+ },
+ "schema": {
+ "description": "Skills allow for loading information about specific tasks and workflows, as well as scripts and templates. Load a skill's full content or access its linked files (references, templates, scripts). First call returns SKILL.md content plus a 'linked_files' dict showing available references/templates/scripts. To access those, call again with file_path parameter.",
+ "name": "skill_view",
+ "parameters": {
+ "properties": {
+ "file_path": {
+ "description": "OPTIONAL: Path to a linked file within the skill (e.g., 'references/api.md', 'templates/config.yaml', 'scripts/validate.py'). Omit to get the main SKILL.md content.",
+ "type": "string"
+ },
+ "name": {
+ "description": "The skill name (use skills_list to see available skills). For plugin-provided skills, use the qualified form 'plugin:skill' (e.g. 'superpowers:writing-plans').",
+ "type": "string"
+ }
+ },
+ "required": [
+ "name"
+ ],
+ "type": "object"
+ }
+ },
+ "source_module": "skills_tool.py",
+ "toolset": "skills",
+ "trust_classes": [
+ "operator",
+ "gateway",
+ "child-agent",
+ "system"
+ ]
+ },
+ {
+ "degraded_status": {
+ "status_field": "degraded_status",
+ "statuses": [
+ "available",
+ "disabled",
+ "missing_dependency",
+ "schema_drift",
+ "unavailable_provider_path"
+ ]
+ },
+ "dependencies": [],
+ "description": "List available skills (name + description). Use skill_view(name) to load full content.",
+ "name": "skills_list",
+ "provider_paths": [],
+ "required_env": [],
+ "required_env_mode": "all",
+ "result_envelope": {
+ "encoding": "json-string",
+ "error_fields": [
+ "error"
+ ],
+ "success_fields": [
+ "success"
+ ]
+ },
+ "schema": {
+ "description": "List available skills (name + description). Use skill_view(name) to load full content.",
+ "name": "skills_list",
+ "parameters": {
+ "properties": {
+ "category": {
+ "description": "Optional category filter to narrow results",
+ "type": "string"
+ }
+ },
+ "required": [],
+ "type": "object"
+ }
+ },
+ "source_module": "skills_tool.py",
+ "toolset": "skills",
+ "trust_classes": [
+ "operator",
+ "gateway",
+ "child-agent",
+ "system"
+ ]
+ },
+ {
+ "degraded_status": {
+ "status_field": "degraded_status",
+ "statuses": [
+ "available",
+ "disabled",
+ "missing_dependency",
+ "schema_drift",
+ "unavailable_provider_path"
+ ]
+ },
+ "dependencies": [
+ "terminal backend"
+ ],
+ "description": "Execute shell commands on a Linux environment. Filesystem usually persists between calls.\n\nDo NOT use cat/head/tail to read files \u2014 use read_file instead.\nDo NOT use grep/rg/find to search \u2014 use search_files instead.\nDo NOT use ls to list directories \u2014 use search_files(target='files') instead.\nDo NOT use sed/awk to edit files \u2014 use patch instead.\nDo NOT use echo/cat heredoc to create files \u2014 use write_file instead.\nReserve terminal for: builds, installs, git, processes, scripts, network, package managers, and anything that needs a shell.\n\nForeground (default): Commands return INSTANTLY when done, even if the timeout is high. Set timeout=300 for long builds/scripts \u2014 you'll still get the result in seconds if it's fast. Prefer foreground for short commands.\nBackground: Set background=true to get a session_id. Two patterns:\n (1) Long-lived processes that never exit (servers, watchers).\n (2) Long-running tasks with notify_on_complete=true \u2014 you can keep working on other things and the system auto-notifies you when the task finishes. Great for test suites, builds, deployments, or anything that takes more than a minute.\nFor servers/watchers, do NOT use shell-level background wrappers (nohup/disown/setsid/trailing '&') in foreground mode. Use background=true so Hermes can track lifecycle and output.\nAfter starting a server, verify readiness with a health check or log signal, then run tests in a separate terminal() call. Avoid blind sleep loops.\nUse process(action=\"poll\") for progress checks, process(action=\"wait\") to block until done.\nWorking directory: Use 'workdir' for per-command cwd.\nPTY mode: Set pty=true for interactive CLI tools (Codex, Claude Code, Python REPL).\n\nDo NOT use vim/nano/interactive tools without pty=true \u2014 they hang without a pseudo-terminal. Pipe git output to cat if it might page.\n",
+ "name": "terminal",
+ "provider_paths": [],
+ "required_env": [],
+ "required_env_mode": "all",
+ "result_envelope": {
+ "encoding": "json-string",
+ "error_fields": [
+ "error"
+ ],
+ "success_fields": [
+ "output",
+ "exit_code"
+ ]
+ },
+ "schema": {
+ "description": "Execute shell commands on a Linux environment. Filesystem usually persists between calls.\n\nDo NOT use cat/head/tail to read files \u2014 use read_file instead.\nDo NOT use grep/rg/find to search \u2014 use search_files instead.\nDo NOT use ls to list directories \u2014 use search_files(target='files') instead.\nDo NOT use sed/awk to edit files \u2014 use patch instead.\nDo NOT use echo/cat heredoc to create files \u2014 use write_file instead.\nReserve terminal for: builds, installs, git, processes, scripts, network, package managers, and anything that needs a shell.\n\nForeground (default): Commands return INSTANTLY when done, even if the timeout is high. Set timeout=300 for long builds/scripts \u2014 you'll still get the result in seconds if it's fast. Prefer foreground for short commands.\nBackground: Set background=true to get a session_id. Two patterns:\n (1) Long-lived processes that never exit (servers, watchers).\n (2) Long-running tasks with notify_on_complete=true \u2014 you can keep working on other things and the system auto-notifies you when the task finishes. Great for test suites, builds, deployments, or anything that takes more than a minute.\nFor servers/watchers, do NOT use shell-level background wrappers (nohup/disown/setsid/trailing '&') in foreground mode. Use background=true so Hermes can track lifecycle and output.\nAfter starting a server, verify readiness with a health check or log signal, then run tests in a separate terminal() call. Avoid blind sleep loops.\nUse process(action=\"poll\") for progress checks, process(action=\"wait\") to block until done.\nWorking directory: Use 'workdir' for per-command cwd.\nPTY mode: Set pty=true for interactive CLI tools (Codex, Claude Code, Python REPL).\n\nDo NOT use vim/nano/interactive tools without pty=true \u2014 they hang without a pseudo-terminal. Pipe git output to cat if it might page.\n",
+ "name": "terminal",
+ "parameters": {
+ "properties": {
+ "background": {
+ "default": false,
+ "description": "Run the command in the background. Two patterns: (1) Long-lived processes that never exit (servers, watchers). (2) Long-running tasks paired with notify_on_complete=true \u2014 you can keep working and get notified when the task finishes. For short commands, prefer foreground with a generous timeout instead.",
+ "type": "boolean"
+ },
+ "command": {
+ "description": "The command to execute on the VM",
+ "type": "string"
+ },
+ "notify_on_complete": {
+ "default": false,
+ "description": "When true (and background=true), you'll be automatically notified when the process finishes \u2014 no polling needed. Use this for tasks that take a while (tests, builds, deployments) so you can keep working on other things in the meantime.",
+ "type": "boolean"
+ },
+ "pty": {
+ "default": false,
+ "description": "Run in pseudo-terminal (PTY) mode for interactive CLI tools like Codex, Claude Code, or Python REPL. Only works with local and SSH backends. Default: false.",
+ "type": "boolean"
+ },
+ "timeout": {
+ "description": "Max seconds to wait (default: 180, foreground max: 180). Returns INSTANTLY when command finishes \u2014 set high for long tasks, you won't wait unnecessarily. Foreground timeout above 180s is rejected; use background=true for longer commands.",
+ "minimum": 1,
+ "type": "integer"
+ },
+ "watch_patterns": {
+ "description": "Strings to watch for in background process output. Fires a notification the first time each pattern matches a line of output. **Use ONLY for mid-process signals** you want to react to before the process exits \u2014 errors, readiness markers, intermediate step markers (e.g. [\"ERROR\", \"Traceback\", \"listening on port\"]). Do NOT use for end-of-run markers (summary headers, 'DONE', 'PASS' printed right before exit) \u2014 use `notify_on_complete` for that instead. Stacking end-of-run patterns on top of `notify_on_complete` produces duplicate, delayed notifications that arrive after you've already moved on, since delivery is asynchronous and continues after the process exits.",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "workdir": {
+ "description": "Working directory for this command (absolute path). Defaults to the session working directory.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "command"
+ ],
+ "type": "object"
+ }
+ },
+ "source_module": "terminal_tool.py",
+ "toolset": "terminal",
+ "trust_classes": [
+ "operator",
+ "gateway",
+ "child-agent",
+ "system"
+ ]
+ },
+ {
+ "degraded_status": {
+ "status_field": "degraded_status",
+ "statuses": [
+ "available",
+ "disabled",
+ "missing_dependency",
+ "schema_drift",
+ "unavailable_provider_path"
+ ]
+ },
+ "dependencies": [
+ "at least one TTS provider package or API credential"
+ ],
+ "description": "Convert text to speech audio. Returns a MEDIA: path that the platform delivers as a voice message. On Telegram it plays as a voice bubble, on Discord/WhatsApp as an audio attachment. In CLI mode, saves to ~/voice-memos/. Voice and provider are user-configured, not model-selected.",
+ "name": "text_to_speech",
+ "provider_paths": [
+ {
+ "description": "ElevenLabs TTS backend",
+ "id": "elevenlabs-tts",
+ "required_env": [
+ "ELEVENLABS_API_KEY"
+ ],
+ "required_env_mode": "all"
+ },
+ {
+ "description": "OpenAI-compatible TTS backend",
+ "id": "openai-tts",
+ "required_env": [
+ "VOICE_TOOLS_OPENAI_KEY",
+ "OPENAI_API_KEY"
+ ],
+ "required_env_mode": "any"
+ },
+ {
+ "description": "MiniMax TTS backend",
+ "id": "minimax-tts",
+ "required_env": [
+ "MINIMAX_API_KEY"
+ ],
+ "required_env_mode": "all"
+ },
+ {
+ "description": "Mistral Voxtral TTS backend",
+ "id": "mistral-tts",
+ "required_env": [
+ "MISTRAL_API_KEY"
+ ],
+ "required_env_mode": "all"
+ },
+ {
+ "description": "xAI TTS backend",
+ "id": "xai-tts",
+ "required_env": [
+ "XAI_API_KEY"
+ ],
+ "required_env_mode": "all"
+ },
+ {
+ "description": "Gemini TTS backend",
+ "id": "gemini-tts",
+ "required_env": [
+ "GEMINI_API_KEY",
+ "GOOGLE_API_KEY"
+ ],
+ "required_env_mode": "any"
+ }
+ ],
+ "required_env": [],
+ "required_env_mode": "all",
+ "result_envelope": {
+ "encoding": "json-string",
+ "error_fields": [
+ "error"
+ ],
+ "success_fields": [
+ "success",
+ "media_path"
+ ]
+ },
+ "schema": {
+ "description": "Convert text to speech audio. Returns a MEDIA: path that the platform delivers as a voice message. On Telegram it plays as a voice bubble, on Discord/WhatsApp as an audio attachment. In CLI mode, saves to ~/voice-memos/. Voice and provider are user-configured, not model-selected.",
+ "name": "text_to_speech",
+ "parameters": {
+ "properties": {
+ "output_path": {
+ "description": "Optional custom file path to save the audio. Defaults to ~/.hermes/audio_cache/.mp3",
+ "type": "string"
+ },
+ "text": {
+ "description": "The text to convert to speech. Provider-specific character caps apply and are enforced automatically (OpenAI 4096, xAI 15000, MiniMax 10000, ElevenLabs 5k-40k depending on model); over-long input is truncated.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "text"
+ ],
+ "type": "object"
+ }
+ },
+ "source_module": "tts_tool.py",
+ "toolset": "tts",
+ "trust_classes": [
+ "operator",
+ "gateway",
+ "child-agent",
+ "system"
+ ]
+ },
+ {
+ "degraded_status": {
+ "status_field": "degraded_status",
+ "statuses": [
+ "available",
+ "disabled",
+ "missing_dependency",
+ "schema_drift",
+ "unavailable_provider_path"
+ ]
+ },
+ "dependencies": [],
+ "description": "Manage your task list for the current session. Use for complex tasks with 3+ steps or when the user provides multiple tasks. Call with no parameters to read the current list.\n\nWriting:\n- Provide 'todos' array to create/update items\n- merge=false (default): replace the entire list with a fresh plan\n- merge=true: update existing items by id, add any new ones\n\nEach item: {id: string, content: string, status: pending|in_progress|completed|cancelled}\nList order is priority. Only ONE item in_progress at a time.\nMark items completed immediately when done. If something fails, cancel it and add a revised item.\n\nAlways returns the full current list.",
+ "name": "todo",
+ "provider_paths": [],
+ "required_env": [],
+ "required_env_mode": "all",
+ "result_envelope": {
+ "encoding": "json-string",
+ "error_fields": [
+ "error"
+ ],
+ "success_fields": [
+ "success"
+ ]
+ },
+ "schema": {
+ "description": "Manage your task list for the current session. Use for complex tasks with 3+ steps or when the user provides multiple tasks. Call with no parameters to read the current list.\n\nWriting:\n- Provide 'todos' array to create/update items\n- merge=false (default): replace the entire list with a fresh plan\n- merge=true: update existing items by id, add any new ones\n\nEach item: {id: string, content: string, status: pending|in_progress|completed|cancelled}\nList order is priority. Only ONE item in_progress at a time.\nMark items completed immediately when done. If something fails, cancel it and add a revised item.\n\nAlways returns the full current list.",
+ "name": "todo",
+ "parameters": {
+ "properties": {
+ "merge": {
+ "default": false,
+ "description": "true: update existing items by id, add new ones. false (default): replace the entire list.",
+ "type": "boolean"
+ },
+ "todos": {
+ "description": "Task items to write. Omit to read current list.",
+ "items": {
+ "properties": {
+ "content": {
+ "description": "Task description",
+ "type": "string"
+ },
+ "id": {
+ "description": "Unique item identifier",
+ "type": "string"
+ },
+ "status": {
+ "description": "Current status",
+ "enum": [
+ "pending",
+ "in_progress",
+ "completed",
+ "cancelled"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "content",
+ "status"
+ ],
+ "type": "object"
+ },
+ "type": "array"
+ }
+ },
+ "required": [],
+ "type": "object"
+ }
+ },
+ "source_module": "todo_tool.py",
+ "toolset": "todo",
+ "trust_classes": [
+ "operator",
+ "gateway",
+ "child-agent",
+ "system"
+ ]
+ },
+ {
+ "degraded_status": {
+ "status_field": "degraded_status",
+ "statuses": [
+ "available",
+ "disabled",
+ "missing_dependency",
+ "schema_drift",
+ "unavailable_provider_path"
+ ]
+ },
+ "dependencies": [],
+ "description": "Analyze images using AI vision. Provides a comprehensive description and answers a specific question about the image content.",
+ "name": "vision_analyze",
+ "provider_paths": [],
+ "required_env": [],
+ "required_env_mode": "all",
+ "result_envelope": {
+ "encoding": "json-string",
+ "error_fields": [
+ "error"
+ ],
+ "success_fields": [
+ "success"
+ ]
+ },
+ "schema": {
+ "description": "Analyze images using AI vision. Provides a comprehensive description and answers a specific question about the image content.",
+ "name": "vision_analyze",
+ "parameters": {
+ "properties": {
+ "image_url": {
+ "description": "Image URL (http/https) or local file path to analyze.",
+ "type": "string"
+ },
+ "question": {
+ "description": "Your specific question or request about the image to resolve. The AI will automatically provide a complete image description AND answer your specific question.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "image_url",
+ "question"
+ ],
+ "type": "object"
+ }
+ },
+ "source_module": "vision_tools.py",
+ "toolset": "vision",
+ "trust_classes": [
+ "operator",
+ "gateway",
+ "child-agent",
+ "system"
+ ]
+ },
+ {
+ "degraded_status": {
+ "status_field": "degraded_status",
+ "statuses": [
+ "available",
+ "disabled",
+ "missing_dependency",
+ "schema_drift",
+ "unavailable_provider_path"
+ ]
+ },
+ "dependencies": [],
+ "description": "Extract content from web page URLs. Returns page content in markdown format. Also works with PDF URLs (arxiv papers, documents, etc.) \u2014 pass the PDF link directly and it converts to markdown text. Pages under 5000 chars return full markdown; larger pages are LLM-summarized and capped at ~5000 chars per page. Pages over 2M chars are refused. If a URL fails or times out, use the browser tool to access it instead.",
+ "name": "web_extract",
+ "provider_paths": [
+ {
+ "description": "Exa web-search backend",
+ "id": "web-exa",
+ "required_env": [
+ "EXA_API_KEY"
+ ],
+ "required_env_mode": "all"
+ },
+ {
+ "description": "Parallel web-search backend",
+ "id": "web-parallel",
+ "required_env": [
+ "PARALLEL_API_KEY"
+ ],
+ "required_env_mode": "all"
+ },
+ {
+ "description": "Tavily web-search backend",
+ "id": "web-tavily",
+ "required_env": [
+ "TAVILY_API_KEY"
+ ],
+ "required_env_mode": "all"
+ },
+ {
+ "description": "Firecrawl direct or self-hosted backend",
+ "id": "web-firecrawl",
+ "required_env": [
+ "FIRECRAWL_API_KEY",
+ "FIRECRAWL_API_URL"
+ ],
+ "required_env_mode": "any"
+ },
+ {
+ "description": "Nous managed Firecrawl gateway path",
+ "id": "web-managed-gateway",
+ "required_env": [
+ "FIRECRAWL_GATEWAY_URL",
+ "TOOL_GATEWAY_DOMAIN",
+ "TOOL_GATEWAY_USER_TOKEN"
+ ],
+ "required_env_mode": "any"
+ }
+ ],
+ "required_env": [
+ "EXA_API_KEY",
+ "PARALLEL_API_KEY",
+ "TAVILY_API_KEY",
+ "FIRECRAWL_API_KEY",
+ "FIRECRAWL_API_URL"
+ ],
+ "required_env_mode": "any",
+ "result_envelope": {
+ "encoding": "json-string",
+ "error_fields": [
+ "error"
+ ],
+ "success_fields": [
+ "results",
+ "content"
+ ]
+ },
+ "schema": {
+ "description": "Extract content from web page URLs. Returns page content in markdown format. Also works with PDF URLs (arxiv papers, documents, etc.) \u2014 pass the PDF link directly and it converts to markdown text. Pages under 5000 chars return full markdown; larger pages are LLM-summarized and capped at ~5000 chars per page. Pages over 2M chars are refused. If a URL fails or times out, use the browser tool to access it instead.",
+ "name": "web_extract",
+ "parameters": {
+ "properties": {
+ "urls": {
+ "description": "List of URLs to extract content from (max 5 URLs per call)",
+ "items": {
+ "type": "string"
+ },
+ "maxItems": 5,
+ "type": "array"
+ }
+ },
+ "required": [
+ "urls"
+ ],
+ "type": "object"
+ }
+ },
+ "source_module": "web_tools.py",
+ "toolset": "web",
+ "trust_classes": [
+ "operator",
+ "gateway",
+ "child-agent",
+ "system"
+ ]
+ },
+ {
+ "degraded_status": {
+ "status_field": "degraded_status",
+ "statuses": [
+ "available",
+ "disabled",
+ "missing_dependency",
+ "schema_drift",
+ "unavailable_provider_path"
+ ]
+ },
+ "dependencies": [],
+ "description": "Search the web for information on any topic. Returns up to 5 relevant results with titles, URLs, and descriptions.",
+ "name": "web_search",
+ "provider_paths": [
+ {
+ "description": "Exa web-search backend",
+ "id": "web-exa",
+ "required_env": [
+ "EXA_API_KEY"
+ ],
+ "required_env_mode": "all"
+ },
+ {
+ "description": "Parallel web-search backend",
+ "id": "web-parallel",
+ "required_env": [
+ "PARALLEL_API_KEY"
+ ],
+ "required_env_mode": "all"
+ },
+ {
+ "description": "Tavily web-search backend",
+ "id": "web-tavily",
+ "required_env": [
+ "TAVILY_API_KEY"
+ ],
+ "required_env_mode": "all"
+ },
+ {
+ "description": "Firecrawl direct or self-hosted backend",
+ "id": "web-firecrawl",
+ "required_env": [
+ "FIRECRAWL_API_KEY",
+ "FIRECRAWL_API_URL"
+ ],
+ "required_env_mode": "any"
+ },
+ {
+ "description": "Nous managed Firecrawl gateway path",
+ "id": "web-managed-gateway",
+ "required_env": [
+ "FIRECRAWL_GATEWAY_URL",
+ "TOOL_GATEWAY_DOMAIN",
+ "TOOL_GATEWAY_USER_TOKEN"
+ ],
+ "required_env_mode": "any"
+ }
+ ],
+ "required_env": [
+ "EXA_API_KEY",
+ "PARALLEL_API_KEY",
+ "TAVILY_API_KEY",
+ "FIRECRAWL_API_KEY",
+ "FIRECRAWL_API_URL"
+ ],
+ "required_env_mode": "any",
+ "result_envelope": {
+ "encoding": "json-string",
+ "error_fields": [
+ "error"
+ ],
+ "success_fields": [
+ "results",
+ "content"
+ ]
+ },
+ "schema": {
+ "description": "Search the web for information on any topic. Returns up to 5 relevant results with titles, URLs, and descriptions.",
+ "name": "web_search",
+ "parameters": {
+ "properties": {
+ "query": {
+ "description": "The search query to look up on the web",
+ "type": "string"
+ }
+ },
+ "required": [
+ "query"
+ ],
+ "type": "object"
+ }
+ },
+ "source_module": "web_tools.py",
+ "toolset": "web",
+ "trust_classes": [
+ "operator",
+ "gateway",
+ "child-agent",
+ "system"
+ ]
+ },
+ {
+ "degraded_status": {
+ "status_field": "degraded_status",
+ "statuses": [
+ "available",
+ "disabled",
+ "missing_dependency",
+ "schema_drift",
+ "unavailable_provider_path"
+ ]
+ },
+ "dependencies": [
+ "session filesystem environment"
+ ],
+ "description": "Write content to a file, completely replacing existing content. Use this instead of echo/cat heredoc in terminal. Creates parent directories automatically. OVERWRITES the entire file \u2014 use 'patch' for targeted edits.",
+ "name": "write_file",
+ "provider_paths": [],
+ "required_env": [],
+ "required_env_mode": "all",
+ "result_envelope": {
+ "encoding": "json-string",
+ "error_fields": [
+ "error"
+ ],
+ "success_fields": [
+ "success",
+ "content",
+ "diff"
+ ]
+ },
+ "schema": {
+ "description": "Write content to a file, completely replacing existing content. Use this instead of echo/cat heredoc in terminal. Creates parent directories automatically. OVERWRITES the entire file \u2014 use 'patch' for targeted edits.",
+ "name": "write_file",
+ "parameters": {
+ "properties": {
+ "content": {
+ "description": "Complete content to write to the file",
+ "type": "string"
+ },
+ "path": {
+ "description": "Path to the file to write (will be created if it doesn't exist, overwritten if it does)",
+ "type": "string"
+ }
+ },
+ "required": [
+ "path",
+ "content"
+ ],
+ "type": "object"
+ }
+ },
+ "source_module": "file_tools.py",
+ "toolset": "file",
+ "trust_classes": [
+ "operator",
+ "gateway",
+ "child-agent",
+ "system"
+ ]
+ }
+ ],
+ "toolsets": [
+ {
+ "description": "Browser automation for web interaction (navigate, click, type, scroll, iframes, hold-click) with web search for finding URLs",
+ "direct_tools": [
+ "browser_navigate",
+ "browser_snapshot",
+ "browser_click",
+ "browser_type",
+ "browser_scroll",
+ "browser_back",
+ "browser_press",
+ "browser_get_images",
+ "browser_vision",
+ "browser_console",
+ "browser_cdp",
+ "browser_dialog",
+ "web_search"
+ ],
+ "includes": [],
+ "name": "browser",
+ "resolved_tools": [
+ "browser_back",
+ "browser_cdp",
+ "browser_click",
+ "browser_console",
+ "browser_dialog",
+ "browser_get_images",
+ "browser_navigate",
+ "browser_press",
+ "browser_scroll",
+ "browser_snapshot",
+ "browser_type",
+ "browser_vision",
+ "web_search"
+ ],
+ "source": "toolsets.py"
+ },
+ {
+ "description": "Registry-only upstream toolset browser-cdp",
+ "direct_tools": [
+ "browser_cdp",
+ "browser_dialog"
+ ],
+ "includes": [],
+ "name": "browser-cdp",
+ "resolved_tools": [
+ "browser_cdp",
+ "browser_dialog"
+ ],
+ "source": "tools/registry.py"
+ },
+ {
+ "description": "Ask the user clarifying questions (multiple-choice or open-ended)",
+ "direct_tools": [
+ "clarify"
+ ],
+ "includes": [],
+ "name": "clarify",
+ "resolved_tools": [
+ "clarify"
+ ],
+ "source": "toolsets.py"
+ },
+ {
+ "description": "Run Python scripts that call tools programmatically (reduces LLM round trips)",
+ "direct_tools": [
+ "execute_code"
+ ],
+ "includes": [],
+ "name": "code_execution",
+ "resolved_tools": [
+ "execute_code"
+ ],
+ "source": "toolsets.py"
+ },
+ {
+ "description": "Cronjob management tool - create, list, update, pause, resume, remove, and trigger scheduled tasks",
+ "direct_tools": [
+ "cronjob"
+ ],
+ "includes": [],
+ "name": "cronjob",
+ "resolved_tools": [
+ "cronjob"
+ ],
+ "source": "toolsets.py"
+ },
+ {
+ "description": "Debugging and troubleshooting toolkit",
+ "direct_tools": [
+ "terminal",
+ "process"
+ ],
+ "includes": [
+ "web",
+ "file"
+ ],
+ "name": "debugging",
+ "resolved_tools": [
+ "patch",
+ "process",
+ "read_file",
+ "search_files",
+ "terminal",
+ "web_extract",
+ "web_search",
+ "write_file"
+ ],
+ "source": "toolsets.py"
+ },
+ {
+ "description": "Spawn subagents with isolated context for complex subtasks",
+ "direct_tools": [
+ "delegate_task"
+ ],
+ "includes": [],
+ "name": "delegation",
+ "resolved_tools": [
+ "delegate_task"
+ ],
+ "source": "toolsets.py"
+ },
+ {
+ "description": "Registry-only upstream toolset discord",
+ "direct_tools": [
+ "discord_server"
+ ],
+ "includes": [],
+ "name": "discord",
+ "resolved_tools": [
+ "discord_server"
+ ],
+ "source": "tools/registry.py"
+ },
+ {
+ "description": "Read Feishu/Lark document content",
+ "direct_tools": [
+ "feishu_doc_read"
+ ],
+ "includes": [],
+ "name": "feishu_doc",
+ "resolved_tools": [
+ "feishu_doc_read"
+ ],
+ "source": "toolsets.py"
+ },
+ {
+ "description": "Feishu/Lark document comment operations (list, reply, add)",
+ "direct_tools": [
+ "feishu_drive_list_comments",
+ "feishu_drive_list_comment_replies",
+ "feishu_drive_reply_comment",
+ "feishu_drive_add_comment"
+ ],
+ "includes": [],
+ "name": "feishu_drive",
+ "resolved_tools": [
+ "feishu_drive_add_comment",
+ "feishu_drive_list_comment_replies",
+ "feishu_drive_list_comments",
+ "feishu_drive_reply_comment"
+ ],
+ "source": "toolsets.py"
+ },
+ {
+ "description": "File manipulation tools: read, write, patch (with fuzzy matching), and search (content + files)",
+ "direct_tools": [
+ "read_file",
+ "write_file",
+ "patch",
+ "search_files"
+ ],
+ "includes": [],
+ "name": "file",
+ "resolved_tools": [
+ "patch",
+ "read_file",
+ "search_files",
+ "write_file"
+ ],
+ "source": "toolsets.py"
+ },
+ {
+ "description": "Editor integration (VS Code, Zed, JetBrains) \u2014 coding-focused tools without messaging, audio, or clarify UI",
+ "direct_tools": [
+ "web_search",
+ "web_extract",
+ "terminal",
+ "process",
+ "read_file",
+ "write_file",
+ "patch",
+ "search_files",
+ "vision_analyze",
+ "skills_list",
+ "skill_view",
+ "skill_manage",
+ "browser_navigate",
+ "browser_snapshot",
+ "browser_click",
+ "browser_type",
+ "browser_scroll",
+ "browser_back",
+ "browser_press",
+ "browser_get_images",
+ "browser_vision",
+ "browser_console",
+ "browser_cdp",
+ "browser_dialog",
+ "todo",
+ "memory",
+ "session_search",
+ "execute_code",
+ "delegate_task"
+ ],
+ "includes": [],
+ "name": "hermes-acp",
+ "resolved_tools": [
+ "browser_back",
+ "browser_cdp",
+ "browser_click",
+ "browser_console",
+ "browser_dialog",
+ "browser_get_images",
+ "browser_navigate",
+ "browser_press",
+ "browser_scroll",
+ "browser_snapshot",
+ "browser_type",
+ "browser_vision",
+ "delegate_task",
+ "execute_code",
+ "memory",
+ "patch",
+ "process",
+ "read_file",
+ "search_files",
+ "session_search",
+ "skill_manage",
+ "skill_view",
+ "skills_list",
+ "terminal",
+ "todo",
+ "vision_analyze",
+ "web_extract",
+ "web_search",
+ "write_file"
+ ],
+ "source": "toolsets.py"
+ },
+ {
+ "description": "OpenAI-compatible API server \u2014 full agent tools accessible via HTTP (no interactive UI tools like clarify or send_message)",
+ "direct_tools": [
+ "web_search",
+ "web_extract",
+ "terminal",
+ "process",
+ "read_file",
+ "write_file",
+ "patch",
+ "search_files",
+ "vision_analyze",
+ "image_generate",
+ "skills_list",
+ "skill_view",
+ "skill_manage",
+ "browser_navigate",
+ "browser_snapshot",
+ "browser_click",
+ "browser_type",
+ "browser_scroll",
+ "browser_back",
+ "browser_press",
+ "browser_get_images",
+ "browser_vision",
+ "browser_console",
+ "browser_cdp",
+ "browser_dialog",
+ "todo",
+ "memory",
+ "session_search",
+ "execute_code",
+ "delegate_task",
+ "cronjob",
+ "ha_list_entities",
+ "ha_get_state",
+ "ha_list_services",
+ "ha_call_service"
+ ],
+ "includes": [],
+ "name": "hermes-api-server",
+ "resolved_tools": [
+ "browser_back",
+ "browser_cdp",
+ "browser_click",
+ "browser_console",
+ "browser_dialog",
+ "browser_get_images",
+ "browser_navigate",
+ "browser_press",
+ "browser_scroll",
+ "browser_snapshot",
+ "browser_type",
+ "browser_vision",
+ "cronjob",
+ "delegate_task",
+ "execute_code",
+ "ha_call_service",
+ "ha_get_state",
+ "ha_list_entities",
+ "ha_list_services",
+ "image_generate",
+ "memory",
+ "patch",
+ "process",
+ "read_file",
+ "search_files",
+ "session_search",
+ "skill_manage",
+ "skill_view",
+ "skills_list",
+ "terminal",
+ "todo",
+ "vision_analyze",
+ "web_extract",
+ "web_search",
+ "write_file"
+ ],
+ "source": "toolsets.py"
+ },
+ {
+ "description": "BlueBubbles iMessage bot toolset - Apple iMessage via local BlueBubbles server",
+ "direct_tools": [
+ "web_search",
+ "web_extract",
+ "terminal",
+ "process",
+ "read_file",
+ "write_file",
+ "patch",
+ "search_files",
+ "vision_analyze",
+ "image_generate",
+ "skills_list",
+ "skill_view",
+ "skill_manage",
+ "browser_navigate",
+ "browser_snapshot",
+ "browser_click",
+ "browser_type",
+ "browser_scroll",
+ "browser_back",
+ "browser_press",
+ "browser_get_images",
+ "browser_vision",
+ "browser_console",
+ "browser_cdp",
+ "browser_dialog",
+ "text_to_speech",
+ "todo",
+ "memory",
+ "session_search",
+ "clarify",
+ "execute_code",
+ "delegate_task",
+ "cronjob",
+ "send_message",
+ "ha_list_entities",
+ "ha_get_state",
+ "ha_list_services",
+ "ha_call_service"
+ ],
+ "includes": [],
+ "name": "hermes-bluebubbles",
+ "resolved_tools": [
+ "browser_back",
+ "browser_cdp",
+ "browser_click",
+ "browser_console",
+ "browser_dialog",
+ "browser_get_images",
+ "browser_navigate",
+ "browser_press",
+ "browser_scroll",
+ "browser_snapshot",
+ "browser_type",
+ "browser_vision",
+ "clarify",
+ "cronjob",
+ "delegate_task",
+ "execute_code",
+ "ha_call_service",
+ "ha_get_state",
+ "ha_list_entities",
+ "ha_list_services",
+ "image_generate",
+ "memory",
+ "patch",
+ "process",
+ "read_file",
+ "search_files",
+ "send_message",
+ "session_search",
+ "skill_manage",
+ "skill_view",
+ "skills_list",
+ "terminal",
+ "text_to_speech",
+ "todo",
+ "vision_analyze",
+ "web_extract",
+ "web_search",
+ "write_file"
+ ],
+ "source": "toolsets.py"
+ },
+ {
+ "description": "Full interactive CLI toolset - all default tools plus cronjob management",
+ "direct_tools": [
+ "web_search",
+ "web_extract",
+ "terminal",
+ "process",
+ "read_file",
+ "write_file",
+ "patch",
+ "search_files",
+ "vision_analyze",
+ "image_generate",
+ "skills_list",
+ "skill_view",
+ "skill_manage",
+ "browser_navigate",
+ "browser_snapshot",
+ "browser_click",
+ "browser_type",
+ "browser_scroll",
+ "browser_back",
+ "browser_press",
+ "browser_get_images",
+ "browser_vision",
+ "browser_console",
+ "browser_cdp",
+ "browser_dialog",
+ "text_to_speech",
+ "todo",
+ "memory",
+ "session_search",
+ "clarify",
+ "execute_code",
+ "delegate_task",
+ "cronjob",
+ "send_message",
+ "ha_list_entities",
+ "ha_get_state",
+ "ha_list_services",
+ "ha_call_service"
+ ],
+ "includes": [],
+ "name": "hermes-cli",
+ "resolved_tools": [
+ "browser_back",
+ "browser_cdp",
+ "browser_click",
+ "browser_console",
+ "browser_dialog",
+ "browser_get_images",
+ "browser_navigate",
+ "browser_press",
+ "browser_scroll",
+ "browser_snapshot",
+ "browser_type",
+ "browser_vision",
+ "clarify",
+ "cronjob",
+ "delegate_task",
+ "execute_code",
+ "ha_call_service",
+ "ha_get_state",
+ "ha_list_entities",
+ "ha_list_services",
+ "image_generate",
+ "memory",
+ "patch",
+ "process",
+ "read_file",
+ "search_files",
+ "send_message",
+ "session_search",
+ "skill_manage",
+ "skill_view",
+ "skills_list",
+ "terminal",
+ "text_to_speech",
+ "todo",
+ "vision_analyze",
+ "web_extract",
+ "web_search",
+ "write_file"
+ ],
+ "source": "toolsets.py"
+ },
+ {
+ "description": "Default cron toolset - same core tools as hermes-cli; gated by `hermes tools`",
+ "direct_tools": [
+ "web_search",
+ "web_extract",
+ "terminal",
+ "process",
+ "read_file",
+ "write_file",
+ "patch",
+ "search_files",
+ "vision_analyze",
+ "image_generate",
+ "skills_list",
+ "skill_view",
+ "skill_manage",
+ "browser_navigate",
+ "browser_snapshot",
+ "browser_click",
+ "browser_type",
+ "browser_scroll",
+ "browser_back",
+ "browser_press",
+ "browser_get_images",
+ "browser_vision",
+ "browser_console",
+ "browser_cdp",
+ "browser_dialog",
+ "text_to_speech",
+ "todo",
+ "memory",
+ "session_search",
+ "clarify",
+ "execute_code",
+ "delegate_task",
+ "cronjob",
+ "send_message",
+ "ha_list_entities",
+ "ha_get_state",
+ "ha_list_services",
+ "ha_call_service"
+ ],
+ "includes": [],
+ "name": "hermes-cron",
+ "resolved_tools": [
+ "browser_back",
+ "browser_cdp",
+ "browser_click",
+ "browser_console",
+ "browser_dialog",
+ "browser_get_images",
+ "browser_navigate",
+ "browser_press",
+ "browser_scroll",
+ "browser_snapshot",
+ "browser_type",
+ "browser_vision",
+ "clarify",
+ "cronjob",
+ "delegate_task",
+ "execute_code",
+ "ha_call_service",
+ "ha_get_state",
+ "ha_list_entities",
+ "ha_list_services",
+ "image_generate",
+ "memory",
+ "patch",
+ "process",
+ "read_file",
+ "search_files",
+ "send_message",
+ "session_search",
+ "skill_manage",
+ "skill_view",
+ "skills_list",
+ "terminal",
+ "text_to_speech",
+ "todo",
+ "vision_analyze",
+ "web_extract",
+ "web_search",
+ "write_file"
+ ],
+ "source": "toolsets.py"
+ },
+ {
+ "description": "DingTalk bot toolset - enterprise messaging platform (full access)",
+ "direct_tools": [
+ "web_search",
+ "web_extract",
+ "terminal",
+ "process",
+ "read_file",
+ "write_file",
+ "patch",
+ "search_files",
+ "vision_analyze",
+ "image_generate",
+ "skills_list",
+ "skill_view",
+ "skill_manage",
+ "browser_navigate",
+ "browser_snapshot",
+ "browser_click",
+ "browser_type",
+ "browser_scroll",
+ "browser_back",
+ "browser_press",
+ "browser_get_images",
+ "browser_vision",
+ "browser_console",
+ "browser_cdp",
+ "browser_dialog",
+ "text_to_speech",
+ "todo",
+ "memory",
+ "session_search",
+ "clarify",
+ "execute_code",
+ "delegate_task",
+ "cronjob",
+ "send_message",
+ "ha_list_entities",
+ "ha_get_state",
+ "ha_list_services",
+ "ha_call_service"
+ ],
+ "includes": [],
+ "name": "hermes-dingtalk",
+ "resolved_tools": [
+ "browser_back",
+ "browser_cdp",
+ "browser_click",
+ "browser_console",
+ "browser_dialog",
+ "browser_get_images",
+ "browser_navigate",
+ "browser_press",
+ "browser_scroll",
+ "browser_snapshot",
+ "browser_type",
+ "browser_vision",
+ "clarify",
+ "cronjob",
+ "delegate_task",
+ "execute_code",
+ "ha_call_service",
+ "ha_get_state",
+ "ha_list_entities",
+ "ha_list_services",
+ "image_generate",
+ "memory",
+ "patch",
+ "process",
+ "read_file",
+ "search_files",
+ "send_message",
+ "session_search",
+ "skill_manage",
+ "skill_view",
+ "skills_list",
+ "terminal",
+ "text_to_speech",
+ "todo",
+ "vision_analyze",
+ "web_extract",
+ "web_search",
+ "write_file"
+ ],
+ "source": "toolsets.py"
+ },
+ {
+ "description": "Discord bot toolset - full access (terminal has safety checks via dangerous command approval)",
+ "direct_tools": [
+ "web_search",
+ "web_extract",
+ "terminal",
+ "process",
+ "read_file",
+ "write_file",
+ "patch",
+ "search_files",
+ "vision_analyze",
+ "image_generate",
+ "skills_list",
+ "skill_view",
+ "skill_manage",
+ "browser_navigate",
+ "browser_snapshot",
+ "browser_click",
+ "browser_type",
+ "browser_scroll",
+ "browser_back",
+ "browser_press",
+ "browser_get_images",
+ "browser_vision",
+ "browser_console",
+ "browser_cdp",
+ "browser_dialog",
+ "text_to_speech",
+ "todo",
+ "memory",
+ "session_search",
+ "clarify",
+ "execute_code",
+ "delegate_task",
+ "cronjob",
+ "send_message",
+ "ha_list_entities",
+ "ha_get_state",
+ "ha_list_services",
+ "ha_call_service",
+ "discord_server"
+ ],
+ "includes": [],
+ "name": "hermes-discord",
+ "resolved_tools": [
+ "browser_back",
+ "browser_cdp",
+ "browser_click",
+ "browser_console",
+ "browser_dialog",
+ "browser_get_images",
+ "browser_navigate",
+ "browser_press",
+ "browser_scroll",
+ "browser_snapshot",
+ "browser_type",
+ "browser_vision",
+ "clarify",
+ "cronjob",
+ "delegate_task",
+ "discord_server",
+ "execute_code",
+ "ha_call_service",
+ "ha_get_state",
+ "ha_list_entities",
+ "ha_list_services",
+ "image_generate",
+ "memory",
+ "patch",
+ "process",
+ "read_file",
+ "search_files",
+ "send_message",
+ "session_search",
+ "skill_manage",
+ "skill_view",
+ "skills_list",
+ "terminal",
+ "text_to_speech",
+ "todo",
+ "vision_analyze",
+ "web_extract",
+ "web_search",
+ "write_file"
+ ],
+ "source": "toolsets.py"
+ },
+ {
+ "description": "Email bot toolset - interact with Hermes via email (IMAP/SMTP)",
+ "direct_tools": [
+ "web_search",
+ "web_extract",
+ "terminal",
+ "process",
+ "read_file",
+ "write_file",
+ "patch",
+ "search_files",
+ "vision_analyze",
+ "image_generate",
+ "skills_list",
+ "skill_view",
+ "skill_manage",
+ "browser_navigate",
+ "browser_snapshot",
+ "browser_click",
+ "browser_type",
+ "browser_scroll",
+ "browser_back",
+ "browser_press",
+ "browser_get_images",
+ "browser_vision",
+ "browser_console",
+ "browser_cdp",
+ "browser_dialog",
+ "text_to_speech",
+ "todo",
+ "memory",
+ "session_search",
+ "clarify",
+ "execute_code",
+ "delegate_task",
+ "cronjob",
+ "send_message",
+ "ha_list_entities",
+ "ha_get_state",
+ "ha_list_services",
+ "ha_call_service"
+ ],
+ "includes": [],
+ "name": "hermes-email",
+ "resolved_tools": [
+ "browser_back",
+ "browser_cdp",
+ "browser_click",
+ "browser_console",
+ "browser_dialog",
+ "browser_get_images",
+ "browser_navigate",
+ "browser_press",
+ "browser_scroll",
+ "browser_snapshot",
+ "browser_type",
+ "browser_vision",
+ "clarify",
+ "cronjob",
+ "delegate_task",
+ "execute_code",
+ "ha_call_service",
+ "ha_get_state",
+ "ha_list_entities",
+ "ha_list_services",
+ "image_generate",
+ "memory",
+ "patch",
+ "process",
+ "read_file",
+ "search_files",
+ "send_message",
+ "session_search",
+ "skill_manage",
+ "skill_view",
+ "skills_list",
+ "terminal",
+ "text_to_speech",
+ "todo",
+ "vision_analyze",
+ "web_extract",
+ "web_search",
+ "write_file"
+ ],
+ "source": "toolsets.py"
+ },
+ {
+ "description": "Feishu/Lark bot toolset - enterprise messaging via Feishu/Lark (full access)",
+ "direct_tools": [
+ "web_search",
+ "web_extract",
+ "terminal",
+ "process",
+ "read_file",
+ "write_file",
+ "patch",
+ "search_files",
+ "vision_analyze",
+ "image_generate",
+ "skills_list",
+ "skill_view",
+ "skill_manage",
+ "browser_navigate",
+ "browser_snapshot",
+ "browser_click",
+ "browser_type",
+ "browser_scroll",
+ "browser_back",
+ "browser_press",
+ "browser_get_images",
+ "browser_vision",
+ "browser_console",
+ "browser_cdp",
+ "browser_dialog",
+ "text_to_speech",
+ "todo",
+ "memory",
+ "session_search",
+ "clarify",
+ "execute_code",
+ "delegate_task",
+ "cronjob",
+ "send_message",
+ "ha_list_entities",
+ "ha_get_state",
+ "ha_list_services",
+ "ha_call_service"
+ ],
+ "includes": [],
+ "name": "hermes-feishu",
+ "resolved_tools": [
+ "browser_back",
+ "browser_cdp",
+ "browser_click",
+ "browser_console",
+ "browser_dialog",
+ "browser_get_images",
+ "browser_navigate",
+ "browser_press",
+ "browser_scroll",
+ "browser_snapshot",
+ "browser_type",
+ "browser_vision",
+ "clarify",
+ "cronjob",
+ "delegate_task",
+ "execute_code",
+ "ha_call_service",
+ "ha_get_state",
+ "ha_list_entities",
+ "ha_list_services",
+ "image_generate",
+ "memory",
+ "patch",
+ "process",
+ "read_file",
+ "search_files",
+ "send_message",
+ "session_search",
+ "skill_manage",
+ "skill_view",
+ "skills_list",
+ "terminal",
+ "text_to_speech",
+ "todo",
+ "vision_analyze",
+ "web_extract",
+ "web_search",
+ "write_file"
+ ],
+ "source": "toolsets.py"
+ },
+ {
+ "description": "Gateway toolset - union of all messaging platform tools",
+ "direct_tools": [],
+ "includes": [
+ "hermes-telegram",
+ "hermes-discord",
+ "hermes-whatsapp",
+ "hermes-slack",
+ "hermes-signal",
+ "hermes-bluebubbles",
+ "hermes-homeassistant",
+ "hermes-email",
+ "hermes-sms",
+ "hermes-mattermost",
+ "hermes-matrix",
+ "hermes-dingtalk",
+ "hermes-feishu",
+ "hermes-wecom",
+ "hermes-wecom-callback",
+ "hermes-weixin",
+ "hermes-qqbot",
+ "hermes-webhook"
+ ],
+ "name": "hermes-gateway",
+ "resolved_tools": [
+ "browser_back",
+ "browser_cdp",
+ "browser_click",
+ "browser_console",
+ "browser_dialog",
+ "browser_get_images",
+ "browser_navigate",
+ "browser_press",
+ "browser_scroll",
+ "browser_snapshot",
+ "browser_type",
+ "browser_vision",
+ "clarify",
+ "cronjob",
+ "delegate_task",
+ "discord_server",
+ "execute_code",
+ "ha_call_service",
+ "ha_get_state",
+ "ha_list_entities",
+ "ha_list_services",
+ "image_generate",
+ "memory",
+ "patch",
+ "process",
+ "read_file",
+ "search_files",
+ "send_message",
+ "session_search",
+ "skill_manage",
+ "skill_view",
+ "skills_list",
+ "terminal",
+ "text_to_speech",
+ "todo",
+ "vision_analyze",
+ "web_extract",
+ "web_search",
+ "write_file"
+ ],
+ "source": "toolsets.py"
+ },
+ {
+ "description": "Home Assistant bot toolset - smart home event monitoring and control",
+ "direct_tools": [
+ "web_search",
+ "web_extract",
+ "terminal",
+ "process",
+ "read_file",
+ "write_file",
+ "patch",
+ "search_files",
+ "vision_analyze",
+ "image_generate",
+ "skills_list",
+ "skill_view",
+ "skill_manage",
+ "browser_navigate",
+ "browser_snapshot",
+ "browser_click",
+ "browser_type",
+ "browser_scroll",
+ "browser_back",
+ "browser_press",
+ "browser_get_images",
+ "browser_vision",
+ "browser_console",
+ "browser_cdp",
+ "browser_dialog",
+ "text_to_speech",
+ "todo",
+ "memory",
+ "session_search",
+ "clarify",
+ "execute_code",
+ "delegate_task",
+ "cronjob",
+ "send_message",
+ "ha_list_entities",
+ "ha_get_state",
+ "ha_list_services",
+ "ha_call_service"
+ ],
+ "includes": [],
+ "name": "hermes-homeassistant",
+ "resolved_tools": [
+ "browser_back",
+ "browser_cdp",
+ "browser_click",
+ "browser_console",
+ "browser_dialog",
+ "browser_get_images",
+ "browser_navigate",
+ "browser_press",
+ "browser_scroll",
+ "browser_snapshot",
+ "browser_type",
+ "browser_vision",
+ "clarify",
+ "cronjob",
+ "delegate_task",
+ "execute_code",
+ "ha_call_service",
+ "ha_get_state",
+ "ha_list_entities",
+ "ha_list_services",
+ "image_generate",
+ "memory",
+ "patch",
+ "process",
+ "read_file",
+ "search_files",
+ "send_message",
+ "session_search",
+ "skill_manage",
+ "skill_view",
+ "skills_list",
+ "terminal",
+ "text_to_speech",
+ "todo",
+ "vision_analyze",
+ "web_extract",
+ "web_search",
+ "write_file"
+ ],
+ "source": "toolsets.py"
+ },
+ {
+ "description": "Matrix bot toolset - decentralized encrypted messaging (full access)",
+ "direct_tools": [
+ "web_search",
+ "web_extract",
+ "terminal",
+ "process",
+ "read_file",
+ "write_file",
+ "patch",
+ "search_files",
+ "vision_analyze",
+ "image_generate",
+ "skills_list",
+ "skill_view",
+ "skill_manage",
+ "browser_navigate",
+ "browser_snapshot",
+ "browser_click",
+ "browser_type",
+ "browser_scroll",
+ "browser_back",
+ "browser_press",
+ "browser_get_images",
+ "browser_vision",
+ "browser_console",
+ "browser_cdp",
+ "browser_dialog",
+ "text_to_speech",
+ "todo",
+ "memory",
+ "session_search",
+ "clarify",
+ "execute_code",
+ "delegate_task",
+ "cronjob",
+ "send_message",
+ "ha_list_entities",
+ "ha_get_state",
+ "ha_list_services",
+ "ha_call_service"
+ ],
+ "includes": [],
+ "name": "hermes-matrix",
+ "resolved_tools": [
+ "browser_back",
+ "browser_cdp",
+ "browser_click",
+ "browser_console",
+ "browser_dialog",
+ "browser_get_images",
+ "browser_navigate",
+ "browser_press",
+ "browser_scroll",
+ "browser_snapshot",
+ "browser_type",
+ "browser_vision",
+ "clarify",
+ "cronjob",
+ "delegate_task",
+ "execute_code",
+ "ha_call_service",
+ "ha_get_state",
+ "ha_list_entities",
+ "ha_list_services",
+ "image_generate",
+ "memory",
+ "patch",
+ "process",
+ "read_file",
+ "search_files",
+ "send_message",
+ "session_search",
+ "skill_manage",
+ "skill_view",
+ "skills_list",
+ "terminal",
+ "text_to_speech",
+ "todo",
+ "vision_analyze",
+ "web_extract",
+ "web_search",
+ "write_file"
+ ],
+ "source": "toolsets.py"
+ },
+ {
+ "description": "Mattermost bot toolset - self-hosted team messaging (full access)",
+ "direct_tools": [
+ "web_search",
+ "web_extract",
+ "terminal",
+ "process",
+ "read_file",
+ "write_file",
+ "patch",
+ "search_files",
+ "vision_analyze",
+ "image_generate",
+ "skills_list",
+ "skill_view",
+ "skill_manage",
+ "browser_navigate",
+ "browser_snapshot",
+ "browser_click",
+ "browser_type",
+ "browser_scroll",
+ "browser_back",
+ "browser_press",
+ "browser_get_images",
+ "browser_vision",
+ "browser_console",
+ "browser_cdp",
+ "browser_dialog",
+ "text_to_speech",
+ "todo",
+ "memory",
+ "session_search",
+ "clarify",
+ "execute_code",
+ "delegate_task",
+ "cronjob",
+ "send_message",
+ "ha_list_entities",
+ "ha_get_state",
+ "ha_list_services",
+ "ha_call_service"
+ ],
+ "includes": [],
+ "name": "hermes-mattermost",
+ "resolved_tools": [
+ "browser_back",
+ "browser_cdp",
+ "browser_click",
+ "browser_console",
+ "browser_dialog",
+ "browser_get_images",
+ "browser_navigate",
+ "browser_press",
+ "browser_scroll",
+ "browser_snapshot",
+ "browser_type",
+ "browser_vision",
+ "clarify",
+ "cronjob",
+ "delegate_task",
+ "execute_code",
+ "ha_call_service",
+ "ha_get_state",
+ "ha_list_entities",
+ "ha_list_services",
+ "image_generate",
+ "memory",
+ "patch",
+ "process",
+ "read_file",
+ "search_files",
+ "send_message",
+ "session_search",
+ "skill_manage",
+ "skill_view",
+ "skills_list",
+ "terminal",
+ "text_to_speech",
+ "todo",
+ "vision_analyze",
+ "web_extract",
+ "web_search",
+ "write_file"
+ ],
+ "source": "toolsets.py"
+ },
+ {
+ "description": "QQBot toolset - QQ messaging via Official Bot API v2 (full access)",
+ "direct_tools": [
+ "web_search",
+ "web_extract",
+ "terminal",
+ "process",
+ "read_file",
+ "write_file",
+ "patch",
+ "search_files",
+ "vision_analyze",
+ "image_generate",
+ "skills_list",
+ "skill_view",
+ "skill_manage",
+ "browser_navigate",
+ "browser_snapshot",
+ "browser_click",
+ "browser_type",
+ "browser_scroll",
+ "browser_back",
+ "browser_press",
+ "browser_get_images",
+ "browser_vision",
+ "browser_console",
+ "browser_cdp",
+ "browser_dialog",
+ "text_to_speech",
+ "todo",
+ "memory",
+ "session_search",
+ "clarify",
+ "execute_code",
+ "delegate_task",
+ "cronjob",
+ "send_message",
+ "ha_list_entities",
+ "ha_get_state",
+ "ha_list_services",
+ "ha_call_service"
+ ],
+ "includes": [],
+ "name": "hermes-qqbot",
+ "resolved_tools": [
+ "browser_back",
+ "browser_cdp",
+ "browser_click",
+ "browser_console",
+ "browser_dialog",
+ "browser_get_images",
+ "browser_navigate",
+ "browser_press",
+ "browser_scroll",
+ "browser_snapshot",
+ "browser_type",
+ "browser_vision",
+ "clarify",
+ "cronjob",
+ "delegate_task",
+ "execute_code",
+ "ha_call_service",
+ "ha_get_state",
+ "ha_list_entities",
+ "ha_list_services",
+ "image_generate",
+ "memory",
+ "patch",
+ "process",
+ "read_file",
+ "search_files",
+ "send_message",
+ "session_search",
+ "skill_manage",
+ "skill_view",
+ "skills_list",
+ "terminal",
+ "text_to_speech",
+ "todo",
+ "vision_analyze",
+ "web_extract",
+ "web_search",
+ "write_file"
+ ],
+ "source": "toolsets.py"
+ },
+ {
+ "description": "Signal bot toolset - encrypted messaging platform (full access)",
+ "direct_tools": [
+ "web_search",
+ "web_extract",
+ "terminal",
+ "process",
+ "read_file",
+ "write_file",
+ "patch",
+ "search_files",
+ "vision_analyze",
+ "image_generate",
+ "skills_list",
+ "skill_view",
+ "skill_manage",
+ "browser_navigate",
+ "browser_snapshot",
+ "browser_click",
+ "browser_type",
+ "browser_scroll",
+ "browser_back",
+ "browser_press",
+ "browser_get_images",
+ "browser_vision",
+ "browser_console",
+ "browser_cdp",
+ "browser_dialog",
+ "text_to_speech",
+ "todo",
+ "memory",
+ "session_search",
+ "clarify",
+ "execute_code",
+ "delegate_task",
+ "cronjob",
+ "send_message",
+ "ha_list_entities",
+ "ha_get_state",
+ "ha_list_services",
+ "ha_call_service"
+ ],
+ "includes": [],
+ "name": "hermes-signal",
+ "resolved_tools": [
+ "browser_back",
+ "browser_cdp",
+ "browser_click",
+ "browser_console",
+ "browser_dialog",
+ "browser_get_images",
+ "browser_navigate",
+ "browser_press",
+ "browser_scroll",
+ "browser_snapshot",
+ "browser_type",
+ "browser_vision",
+ "clarify",
+ "cronjob",
+ "delegate_task",
+ "execute_code",
+ "ha_call_service",
+ "ha_get_state",
+ "ha_list_entities",
+ "ha_list_services",
+ "image_generate",
+ "memory",
+ "patch",
+ "process",
+ "read_file",
+ "search_files",
+ "send_message",
+ "session_search",
+ "skill_manage",
+ "skill_view",
+ "skills_list",
+ "terminal",
+ "text_to_speech",
+ "todo",
+ "vision_analyze",
+ "web_extract",
+ "web_search",
+ "write_file"
+ ],
+ "source": "toolsets.py"
+ },
+ {
+ "description": "Slack bot toolset - full access for workspace use (terminal has safety checks)",
+ "direct_tools": [
+ "web_search",
+ "web_extract",
+ "terminal",
+ "process",
+ "read_file",
+ "write_file",
+ "patch",
+ "search_files",
+ "vision_analyze",
+ "image_generate",
+ "skills_list",
+ "skill_view",
+ "skill_manage",
+ "browser_navigate",
+ "browser_snapshot",
+ "browser_click",
+ "browser_type",
+ "browser_scroll",
+ "browser_back",
+ "browser_press",
+ "browser_get_images",
+ "browser_vision",
+ "browser_console",
+ "browser_cdp",
+ "browser_dialog",
+ "text_to_speech",
+ "todo",
+ "memory",
+ "session_search",
+ "clarify",
+ "execute_code",
+ "delegate_task",
+ "cronjob",
+ "send_message",
+ "ha_list_entities",
+ "ha_get_state",
+ "ha_list_services",
+ "ha_call_service"
+ ],
+ "includes": [],
+ "name": "hermes-slack",
+ "resolved_tools": [
+ "browser_back",
+ "browser_cdp",
+ "browser_click",
+ "browser_console",
+ "browser_dialog",
+ "browser_get_images",
+ "browser_navigate",
+ "browser_press",
+ "browser_scroll",
+ "browser_snapshot",
+ "browser_type",
+ "browser_vision",
+ "clarify",
+ "cronjob",
+ "delegate_task",
+ "execute_code",
+ "ha_call_service",
+ "ha_get_state",
+ "ha_list_entities",
+ "ha_list_services",
+ "image_generate",
+ "memory",
+ "patch",
+ "process",
+ "read_file",
+ "search_files",
+ "send_message",
+ "session_search",
+ "skill_manage",
+ "skill_view",
+ "skills_list",
+ "terminal",
+ "text_to_speech",
+ "todo",
+ "vision_analyze",
+ "web_extract",
+ "web_search",
+ "write_file"
+ ],
+ "source": "toolsets.py"
+ },
+ {
+ "description": "SMS bot toolset - interact with Hermes via SMS (Twilio)",
+ "direct_tools": [
+ "web_search",
+ "web_extract",
+ "terminal",
+ "process",
+ "read_file",
+ "write_file",
+ "patch",
+ "search_files",
+ "vision_analyze",
+ "image_generate",
+ "skills_list",
+ "skill_view",
+ "skill_manage",
+ "browser_navigate",
+ "browser_snapshot",
+ "browser_click",
+ "browser_type",
+ "browser_scroll",
+ "browser_back",
+ "browser_press",
+ "browser_get_images",
+ "browser_vision",
+ "browser_console",
+ "browser_cdp",
+ "browser_dialog",
+ "text_to_speech",
+ "todo",
+ "memory",
+ "session_search",
+ "clarify",
+ "execute_code",
+ "delegate_task",
+ "cronjob",
+ "send_message",
+ "ha_list_entities",
+ "ha_get_state",
+ "ha_list_services",
+ "ha_call_service"
+ ],
+ "includes": [],
+ "name": "hermes-sms",
+ "resolved_tools": [
+ "browser_back",
+ "browser_cdp",
+ "browser_click",
+ "browser_console",
+ "browser_dialog",
+ "browser_get_images",
+ "browser_navigate",
+ "browser_press",
+ "browser_scroll",
+ "browser_snapshot",
+ "browser_type",
+ "browser_vision",
+ "clarify",
+ "cronjob",
+ "delegate_task",
+ "execute_code",
+ "ha_call_service",
+ "ha_get_state",
+ "ha_list_entities",
+ "ha_list_services",
+ "image_generate",
+ "memory",
+ "patch",
+ "process",
+ "read_file",
+ "search_files",
+ "send_message",
+ "session_search",
+ "skill_manage",
+ "skill_view",
+ "skills_list",
+ "terminal",
+ "text_to_speech",
+ "todo",
+ "vision_analyze",
+ "web_extract",
+ "web_search",
+ "write_file"
+ ],
+ "source": "toolsets.py"
+ },
+ {
+ "description": "Telegram bot toolset - full access for personal use (terminal has safety checks)",
+ "direct_tools": [
+ "web_search",
+ "web_extract",
+ "terminal",
+ "process",
+ "read_file",
+ "write_file",
+ "patch",
+ "search_files",
+ "vision_analyze",
+ "image_generate",
+ "skills_list",
+ "skill_view",
+ "skill_manage",
+ "browser_navigate",
+ "browser_snapshot",
+ "browser_click",
+ "browser_type",
+ "browser_scroll",
+ "browser_back",
+ "browser_press",
+ "browser_get_images",
+ "browser_vision",
+ "browser_console",
+ "browser_cdp",
+ "browser_dialog",
+ "text_to_speech",
+ "todo",
+ "memory",
+ "session_search",
+ "clarify",
+ "execute_code",
+ "delegate_task",
+ "cronjob",
+ "send_message",
+ "ha_list_entities",
+ "ha_get_state",
+ "ha_list_services",
+ "ha_call_service"
+ ],
+ "includes": [],
+ "name": "hermes-telegram",
+ "resolved_tools": [
+ "browser_back",
+ "browser_cdp",
+ "browser_click",
+ "browser_console",
+ "browser_dialog",
+ "browser_get_images",
+ "browser_navigate",
+ "browser_press",
+ "browser_scroll",
+ "browser_snapshot",
+ "browser_type",
+ "browser_vision",
+ "clarify",
+ "cronjob",
+ "delegate_task",
+ "execute_code",
+ "ha_call_service",
+ "ha_get_state",
+ "ha_list_entities",
+ "ha_list_services",
+ "image_generate",
+ "memory",
+ "patch",
+ "process",
+ "read_file",
+ "search_files",
+ "send_message",
+ "session_search",
+ "skill_manage",
+ "skill_view",
+ "skills_list",
+ "terminal",
+ "text_to_speech",
+ "todo",
+ "vision_analyze",
+ "web_extract",
+ "web_search",
+ "write_file"
+ ],
+ "source": "toolsets.py"
+ },
+ {
+ "description": "Webhook toolset - receive and process external webhook events",
+ "direct_tools": [
+ "web_search",
+ "web_extract",
+ "terminal",
+ "process",
+ "read_file",
+ "write_file",
+ "patch",
+ "search_files",
+ "vision_analyze",
+ "image_generate",
+ "skills_list",
+ "skill_view",
+ "skill_manage",
+ "browser_navigate",
+ "browser_snapshot",
+ "browser_click",
+ "browser_type",
+ "browser_scroll",
+ "browser_back",
+ "browser_press",
+ "browser_get_images",
+ "browser_vision",
+ "browser_console",
+ "browser_cdp",
+ "browser_dialog",
+ "text_to_speech",
+ "todo",
+ "memory",
+ "session_search",
+ "clarify",
+ "execute_code",
+ "delegate_task",
+ "cronjob",
+ "send_message",
+ "ha_list_entities",
+ "ha_get_state",
+ "ha_list_services",
+ "ha_call_service"
+ ],
+ "includes": [],
+ "name": "hermes-webhook",
+ "resolved_tools": [
+ "browser_back",
+ "browser_cdp",
+ "browser_click",
+ "browser_console",
+ "browser_dialog",
+ "browser_get_images",
+ "browser_navigate",
+ "browser_press",
+ "browser_scroll",
+ "browser_snapshot",
+ "browser_type",
+ "browser_vision",
+ "clarify",
+ "cronjob",
+ "delegate_task",
+ "execute_code",
+ "ha_call_service",
+ "ha_get_state",
+ "ha_list_entities",
+ "ha_list_services",
+ "image_generate",
+ "memory",
+ "patch",
+ "process",
+ "read_file",
+ "search_files",
+ "send_message",
+ "session_search",
+ "skill_manage",
+ "skill_view",
+ "skills_list",
+ "terminal",
+ "text_to_speech",
+ "todo",
+ "vision_analyze",
+ "web_extract",
+ "web_search",
+ "write_file"
+ ],
+ "source": "toolsets.py"
+ },
+ {
+ "description": "WeCom bot toolset - enterprise WeChat messaging (full access)",
+ "direct_tools": [
+ "web_search",
+ "web_extract",
+ "terminal",
+ "process",
+ "read_file",
+ "write_file",
+ "patch",
+ "search_files",
+ "vision_analyze",
+ "image_generate",
+ "skills_list",
+ "skill_view",
+ "skill_manage",
+ "browser_navigate",
+ "browser_snapshot",
+ "browser_click",
+ "browser_type",
+ "browser_scroll",
+ "browser_back",
+ "browser_press",
+ "browser_get_images",
+ "browser_vision",
+ "browser_console",
+ "browser_cdp",
+ "browser_dialog",
+ "text_to_speech",
+ "todo",
+ "memory",
+ "session_search",
+ "clarify",
+ "execute_code",
+ "delegate_task",
+ "cronjob",
+ "send_message",
+ "ha_list_entities",
+ "ha_get_state",
+ "ha_list_services",
+ "ha_call_service"
+ ],
+ "includes": [],
+ "name": "hermes-wecom",
+ "resolved_tools": [
+ "browser_back",
+ "browser_cdp",
+ "browser_click",
+ "browser_console",
+ "browser_dialog",
+ "browser_get_images",
+ "browser_navigate",
+ "browser_press",
+ "browser_scroll",
+ "browser_snapshot",
+ "browser_type",
+ "browser_vision",
+ "clarify",
+ "cronjob",
+ "delegate_task",
+ "execute_code",
+ "ha_call_service",
+ "ha_get_state",
+ "ha_list_entities",
+ "ha_list_services",
+ "image_generate",
+ "memory",
+ "patch",
+ "process",
+ "read_file",
+ "search_files",
+ "send_message",
+ "session_search",
+ "skill_manage",
+ "skill_view",
+ "skills_list",
+ "terminal",
+ "text_to_speech",
+ "todo",
+ "vision_analyze",
+ "web_extract",
+ "web_search",
+ "write_file"
+ ],
+ "source": "toolsets.py"
+ },
+ {
+ "description": "WeCom callback toolset - enterprise self-built app messaging (full access)",
+ "direct_tools": [
+ "web_search",
+ "web_extract",
+ "terminal",
+ "process",
+ "read_file",
+ "write_file",
+ "patch",
+ "search_files",
+ "vision_analyze",
+ "image_generate",
+ "skills_list",
+ "skill_view",
+ "skill_manage",
+ "browser_navigate",
+ "browser_snapshot",
+ "browser_click",
+ "browser_type",
+ "browser_scroll",
+ "browser_back",
+ "browser_press",
+ "browser_get_images",
+ "browser_vision",
+ "browser_console",
+ "browser_cdp",
+ "browser_dialog",
+ "text_to_speech",
+ "todo",
+ "memory",
+ "session_search",
+ "clarify",
+ "execute_code",
+ "delegate_task",
+ "cronjob",
+ "send_message",
+ "ha_list_entities",
+ "ha_get_state",
+ "ha_list_services",
+ "ha_call_service"
+ ],
+ "includes": [],
+ "name": "hermes-wecom-callback",
+ "resolved_tools": [
+ "browser_back",
+ "browser_cdp",
+ "browser_click",
+ "browser_console",
+ "browser_dialog",
+ "browser_get_images",
+ "browser_navigate",
+ "browser_press",
+ "browser_scroll",
+ "browser_snapshot",
+ "browser_type",
+ "browser_vision",
+ "clarify",
+ "cronjob",
+ "delegate_task",
+ "execute_code",
+ "ha_call_service",
+ "ha_get_state",
+ "ha_list_entities",
+ "ha_list_services",
+ "image_generate",
+ "memory",
+ "patch",
+ "process",
+ "read_file",
+ "search_files",
+ "send_message",
+ "session_search",
+ "skill_manage",
+ "skill_view",
+ "skills_list",
+ "terminal",
+ "text_to_speech",
+ "todo",
+ "vision_analyze",
+ "web_extract",
+ "web_search",
+ "write_file"
+ ],
+ "source": "toolsets.py"
+ },
+ {
+ "description": "Weixin bot toolset - personal WeChat messaging via iLink (full access)",
+ "direct_tools": [
+ "web_search",
+ "web_extract",
+ "terminal",
+ "process",
+ "read_file",
+ "write_file",
+ "patch",
+ "search_files",
+ "vision_analyze",
+ "image_generate",
+ "skills_list",
+ "skill_view",
+ "skill_manage",
+ "browser_navigate",
+ "browser_snapshot",
+ "browser_click",
+ "browser_type",
+ "browser_scroll",
+ "browser_back",
+ "browser_press",
+ "browser_get_images",
+ "browser_vision",
+ "browser_console",
+ "browser_cdp",
+ "browser_dialog",
+ "text_to_speech",
+ "todo",
+ "memory",
+ "session_search",
+ "clarify",
+ "execute_code",
+ "delegate_task",
+ "cronjob",
+ "send_message",
+ "ha_list_entities",
+ "ha_get_state",
+ "ha_list_services",
+ "ha_call_service"
+ ],
+ "includes": [],
+ "name": "hermes-weixin",
+ "resolved_tools": [
+ "browser_back",
+ "browser_cdp",
+ "browser_click",
+ "browser_console",
+ "browser_dialog",
+ "browser_get_images",
+ "browser_navigate",
+ "browser_press",
+ "browser_scroll",
+ "browser_snapshot",
+ "browser_type",
+ "browser_vision",
+ "clarify",
+ "cronjob",
+ "delegate_task",
+ "execute_code",
+ "ha_call_service",
+ "ha_get_state",
+ "ha_list_entities",
+ "ha_list_services",
+ "image_generate",
+ "memory",
+ "patch",
+ "process",
+ "read_file",
+ "search_files",
+ "send_message",
+ "session_search",
+ "skill_manage",
+ "skill_view",
+ "skills_list",
+ "terminal",
+ "text_to_speech",
+ "todo",
+ "vision_analyze",
+ "web_extract",
+ "web_search",
+ "write_file"
+ ],
+ "source": "toolsets.py"
+ },
+ {
+ "description": "WhatsApp bot toolset - similar to Telegram (personal messaging, more trusted)",
+ "direct_tools": [
+ "web_search",
+ "web_extract",
+ "terminal",
+ "process",
+ "read_file",
+ "write_file",
+ "patch",
+ "search_files",
+ "vision_analyze",
+ "image_generate",
+ "skills_list",
+ "skill_view",
+ "skill_manage",
+ "browser_navigate",
+ "browser_snapshot",
+ "browser_click",
+ "browser_type",
+ "browser_scroll",
+ "browser_back",
+ "browser_press",
+ "browser_get_images",
+ "browser_vision",
+ "browser_console",
+ "browser_cdp",
+ "browser_dialog",
+ "text_to_speech",
+ "todo",
+ "memory",
+ "session_search",
+ "clarify",
+ "execute_code",
+ "delegate_task",
+ "cronjob",
+ "send_message",
+ "ha_list_entities",
+ "ha_get_state",
+ "ha_list_services",
+ "ha_call_service"
+ ],
+ "includes": [],
+ "name": "hermes-whatsapp",
+ "resolved_tools": [
+ "browser_back",
+ "browser_cdp",
+ "browser_click",
+ "browser_console",
+ "browser_dialog",
+ "browser_get_images",
+ "browser_navigate",
+ "browser_press",
+ "browser_scroll",
+ "browser_snapshot",
+ "browser_type",
+ "browser_vision",
+ "clarify",
+ "cronjob",
+ "delegate_task",
+ "execute_code",
+ "ha_call_service",
+ "ha_get_state",
+ "ha_list_entities",
+ "ha_list_services",
+ "image_generate",
+ "memory",
+ "patch",
+ "process",
+ "read_file",
+ "search_files",
+ "send_message",
+ "session_search",
+ "skill_manage",
+ "skill_view",
+ "skills_list",
+ "terminal",
+ "text_to_speech",
+ "todo",
+ "vision_analyze",
+ "web_extract",
+ "web_search",
+ "write_file"
+ ],
+ "source": "toolsets.py"
+ },
+ {
+ "description": "Home Assistant smart home control and monitoring",
+ "direct_tools": [
+ "ha_list_entities",
+ "ha_get_state",
+ "ha_list_services",
+ "ha_call_service"
+ ],
+ "includes": [],
+ "name": "homeassistant",
+ "resolved_tools": [
+ "ha_call_service",
+ "ha_get_state",
+ "ha_list_entities",
+ "ha_list_services"
+ ],
+ "source": "toolsets.py"
+ },
+ {
+ "description": "Creative generation tools (images)",
+ "direct_tools": [
+ "image_generate"
+ ],
+ "includes": [],
+ "name": "image_gen",
+ "resolved_tools": [
+ "image_generate"
+ ],
+ "source": "toolsets.py"
+ },
+ {
+ "description": "Persistent memory across sessions (personal notes + user profile)",
+ "direct_tools": [
+ "memory"
+ ],
+ "includes": [],
+ "name": "memory",
+ "resolved_tools": [
+ "memory"
+ ],
+ "source": "toolsets.py"
+ },
+ {
+ "description": "Cross-platform messaging: send messages to Telegram, Discord, Slack, SMS, etc.",
+ "direct_tools": [
+ "send_message"
+ ],
+ "includes": [],
+ "name": "messaging",
+ "resolved_tools": [
+ "send_message"
+ ],
+ "source": "toolsets.py"
+ },
+ {
+ "description": "Advanced reasoning and problem-solving tools",
+ "direct_tools": [
+ "mixture_of_agents"
+ ],
+ "includes": [],
+ "name": "moa",
+ "resolved_tools": [
+ "mixture_of_agents"
+ ],
+ "source": "toolsets.py"
+ },
+ {
+ "description": "RL training tools for running reinforcement learning on Tinker-Atropos",
+ "direct_tools": [
+ "rl_list_environments",
+ "rl_select_environment",
+ "rl_get_current_config",
+ "rl_edit_config",
+ "rl_start_training",
+ "rl_check_status",
+ "rl_stop_training",
+ "rl_get_results",
+ "rl_list_runs",
+ "rl_test_inference"
+ ],
+ "includes": [],
+ "name": "rl",
+ "resolved_tools": [
+ "rl_check_status",
+ "rl_edit_config",
+ "rl_get_current_config",
+ "rl_get_results",
+ "rl_list_environments",
+ "rl_list_runs",
+ "rl_select_environment",
+ "rl_start_training",
+ "rl_stop_training",
+ "rl_test_inference"
+ ],
+ "source": "toolsets.py"
+ },
+ {
+ "description": "Safe toolkit without terminal access",
+ "direct_tools": [],
+ "includes": [
+ "web",
+ "vision",
+ "image_gen"
+ ],
+ "name": "safe",
+ "resolved_tools": [
+ "image_generate",
+ "vision_analyze",
+ "web_extract",
+ "web_search"
+ ],
+ "source": "toolsets.py"
+ },
+ {
+ "description": "Web search only (no content extraction/scraping)",
+ "direct_tools": [
+ "web_search"
+ ],
+ "includes": [],
+ "name": "search",
+ "resolved_tools": [
+ "web_search"
+ ],
+ "source": "toolsets.py"
+ },
+ {
+ "description": "Search and recall past conversations with summarization",
+ "direct_tools": [
+ "session_search"
+ ],
+ "includes": [],
+ "name": "session_search",
+ "resolved_tools": [
+ "session_search"
+ ],
+ "source": "toolsets.py"
+ },
+ {
+ "description": "Access, create, edit, and manage skill documents with specialized instructions and knowledge",
+ "direct_tools": [
+ "skills_list",
+ "skill_view",
+ "skill_manage"
+ ],
+ "includes": [],
+ "name": "skills",
+ "resolved_tools": [
+ "skill_manage",
+ "skill_view",
+ "skills_list"
+ ],
+ "source": "toolsets.py"
+ },
+ {
+ "description": "Native Spotify playback, search, playlist, album, and library tools",
+ "direct_tools": [
+ "spotify_playback",
+ "spotify_devices",
+ "spotify_queue",
+ "spotify_search",
+ "spotify_playlists",
+ "spotify_albums",
+ "spotify_library"
+ ],
+ "includes": [],
+ "name": "spotify",
+ "resolved_tools": [
+ "spotify_albums",
+ "spotify_devices",
+ "spotify_library",
+ "spotify_playback",
+ "spotify_playlists",
+ "spotify_queue",
+ "spotify_search"
+ ],
+ "source": "toolsets.py"
+ },
+ {
+ "description": "Terminal/command execution and process management tools",
+ "direct_tools": [
+ "terminal",
+ "process"
+ ],
+ "includes": [],
+ "name": "terminal",
+ "resolved_tools": [
+ "process",
+ "terminal"
+ ],
+ "source": "toolsets.py"
+ },
+ {
+ "description": "Task planning and tracking for multi-step work",
+ "direct_tools": [
+ "todo"
+ ],
+ "includes": [],
+ "name": "todo",
+ "resolved_tools": [
+ "todo"
+ ],
+ "source": "toolsets.py"
+ },
+ {
+ "description": "Text-to-speech: convert text to audio with Edge TTS (free), ElevenLabs, OpenAI, or xAI",
+ "direct_tools": [
+ "text_to_speech"
+ ],
+ "includes": [],
+ "name": "tts",
+ "resolved_tools": [
+ "text_to_speech"
+ ],
+ "source": "toolsets.py"
+ },
+ {
+ "description": "Image analysis and vision tools",
+ "direct_tools": [
+ "vision_analyze"
+ ],
+ "includes": [],
+ "name": "vision",
+ "resolved_tools": [
+ "vision_analyze"
+ ],
+ "source": "toolsets.py"
+ },
+ {
+ "description": "Web research and content extraction tools",
+ "direct_tools": [
+ "web_search",
+ "web_extract"
+ ],
+ "includes": [],
+ "name": "web",
+ "resolved_tools": [
+ "web_extract",
+ "web_search"
+ ],
+ "source": "toolsets.py"
+ }
+ ],
+ "trust_classes": [
+ "operator",
+ "gateway",
+ "child-agent",
+ "system"
+ ]
+}
diff --git a/www.gormes.ai/internal/site/data/progress.json b/www.gormes.ai/internal/site/data/progress.json
index 005296724..4020c1a46 100644
--- a/www.gormes.ai/internal/site/data/progress.json
+++ b/www.gormes.ai/internal/site/data/progress.json
@@ -22,6 +22,8 @@
"MAX_AGENTS is a safety cap: if fewer metadata-ready rows are available, run fewer workers instead of selecting filler or random work.",
"Each worker runs in an isolated git worktree under RUN_ROOT/worktrees and promotion rejects committed paths outside the selected row's write_scope.",
"When git worktrees are available and MAX_AGENTS is greater than 1, cmd/autoloop launches selected workers concurrently, then validates and promotes each branch through the same ledgered safety gates.",
+ "After all promotions, cmd/autoloop runs the mandatory post-promotion full-suite gate before emitting run_completed or health_updated.",
+ "On post-promotion gate failure, cmd/autoloop starts one backend repair attempt by default, requires the checkout to be clean, reruns the suite, and records final health only if the gate passes.",
"Prefer contract rows with write_scope, test_commands, and done_signal.",
"Inject selected progress metadata into the worker prompt instead of asking workers to rescan the whole roadmap."
]
@@ -79,16 +81,16 @@
"scripts/orchestrator/lib/worktree.sh",
"scripts/gormes-auto-codexu-orchestrator.sh"
],
- "unblocks": [
- "Soft-success-nonzero bats coverage",
- "Planner wrapper/test consistency closeout"
- ],
"ready_when": [
"Failure taxonomy and soft-success recovery behavior are covered by direct orchestrator unit fixtures."
],
"not_ready_when": [
"The row is treated as complete before direct try_soft_success_nonzero coverage lands."
],
+ "unblocks": [
+ "Soft-success-nonzero bats coverage",
+ "Planner wrapper/test consistency closeout"
+ ],
"acceptance": [
"Failure rows emit a granular reason instead of contract_or_test_failure.",
"Non-timeout/non-OOM codex exits can become soft_success_nonzero only after final-report and commit verification pass.",
@@ -288,23 +290,23 @@
{
"name": "Slack gateway.Channel adapter shim",
"status": "planned",
- "blocked_by": [
- "Slack CommandRegistry parser wiring"
- ],
"ready_when": [
"Slack ingress uses gateway.ParseInboundText and shared CommandRegistry fixtures are green"
],
+ "blocked_by": [
+ "Slack CommandRegistry parser wiring"
+ ],
"note": "TDD: adapt internal/slack onto the gateway.Channel interface and Manager lifecycle without rewriting the existing Socket Mode client or coalesced reply tests."
},
{
"name": "Slack config + cmd/gormes gateway registration",
"status": "planned",
- "blocked_by": [
- "Slack gateway.Channel adapter shim"
- ],
"ready_when": [
"Slack gateway.Channel adapter shim runs through the shared Manager lifecycle in tests"
],
+ "blocked_by": [
+ "Slack gateway.Channel adapter shim"
+ ],
"note": "TDD: add Slack config loading, doctor coverage, and cmd/gormes gateway registration only after the Channel shim is green; current evidence shows only Telegram and Discord are registered there."
}
]
@@ -337,15 +339,15 @@
"../hermes-agent/tests/gateway/test_session.py",
"docs/content/building-gormes/architecture_plan/phase-2-gateway.md"
],
- "blocked_by": [
- "Bridge-vs-native runtime decision"
- ],
"ready_when": [
"The bridge-vs-native runtime decision identifies which identity source owns the bot/self peer for a session."
],
"not_ready_when": [
"Identity rules are hidden inside send/reconnect code instead of fixture-tested before transport wiring."
],
+ "blocked_by": [
+ "Bridge-vs-native runtime decision"
+ ],
"acceptance": [
"Bridge and native identity inputs produce stable gateway peer IDs.",
"Messages from the bot's own identity are ignored or surfaced as self-chat suppression, not routed back into the kernel.",
@@ -371,12 +373,12 @@
{
"name": "Pairing, reconnect, and send contract",
"status": "planned",
- "blocked_by": [
- "Bridge-vs-native runtime decision"
- ],
"ready_when": [
"WhatsApp runtime-selection contract freezes bridge-first versus native-first startup behavior"
],
+ "blocked_by": [
+ "Bridge-vs-native runtime decision"
+ ],
"note": "TDD: add a transport-neutral outbound lifecycle contract that gates sends on pairing state, retries reconnects with bounded backoff, and maps normalized gateway chat IDs back to raw WhatsApp DM/group peers with reply metadata preservation."
}
]
@@ -397,8 +399,8 @@
},
{
"name": "BlueBubbles iMessage session-context prompt guidance",
- "status": "planned",
"priority": "P3",
+ "status": "planned",
"contract": "Gateway session-context prompts tell the agent when the origin is BlueBubbles/iMessage and ask for short, blank-line-separated message bubbles",
"contract_status": "fixture_ready",
"slice_size": "small",
@@ -415,15 +417,15 @@
"internal/gateway/session_context.go",
"internal/channels/bluebubbles/bot.go"
],
- "blocked_by": [
- "BlueBubbles iMessage bubble formatting parity"
- ],
"ready_when": [
"BlueBubbles outbound formatting splits blank-line paragraphs into separate iMessage sends, so prompt guidance has a matching delivery contract."
],
"not_ready_when": [
"The slice changes general session-context ordering or adds provider/runtime behavior instead of only adding the platform-specific BlueBubbles note."
],
+ "blocked_by": [
+ "BlueBubbles iMessage bubble formatting parity"
+ ],
"acceptance": [
"BuildSessionContextPrompt includes an iMessage/BlueBubbles platform note for source platform `bluebubbles`.",
"The note asks for short conversational replies and blank-line-separated blocks that map to separate bubbles.",
@@ -454,8 +456,8 @@
},
{
"name": "Non-editable gateway progress/commentary send fallback",
- "status": "complete",
"priority": "P3",
+ "status": "complete",
"contract": "Channels without placeholder/edit capabilities receive progress-safe interim or final assistant messages through the plain Send path without EditMessage calls",
"contract_status": "validated",
"slice_size": "small",
@@ -503,6 +505,22 @@
}
]
},
+ "2.B.10": {
+ "name": "WeChat Adapter",
+ "priority": "P1",
+ "items": [
+ {
+ "name": "WeCom + WeiXin shared-chassis bot seam",
+ "status": "complete",
+ "note": "TDD landed: internal/channels/wecom and internal/channels/weixin now pin policy-gated ingress, request/reply routing, and per-platform reply-path contracts before any SDK wiring."
+ },
+ {
+ "name": "WeCom + WeiXin transport/bootstrap layer",
+ "status": "complete",
+ "note": "TDD landed: internal/channels/wecom/runtime.go and internal/channels/weixin/runtime.go now freeze WeCom WebSocket/callback bootstrap, credential validation, WeCom reply-vs-active-push decisions, and Weixin long-poll/context-token lifecycle seams before any real transport binding."
+ }
+ ]
+ },
"2.B.11": {
"name": "Discord Forum Channels",
"priority": "P3",
@@ -515,12 +533,12 @@
{
"name": "Discord forum media + polish parity",
"status": "planned",
- "blocked_by": [
- "Discord forum channel ingress + thread lifecycle"
- ],
"ready_when": [
"Discord forum ingress and thread lifecycle fixtures are green on top of the shipped Discord adapter"
],
+ "blocked_by": [
+ "Discord forum channel ingress + thread lifecycle"
+ ],
"note": "TDD: port upstream PR #607be54a (forum channel media + polish) after the ingress slice is green โ attachment flow for forum posts, initial-post vs reply differences, and deterministic outbound routing to forum threads. Keep the shared-chassis send contract intact so non-forum Discord behavior cannot regress."
}
]
@@ -607,8 +625,8 @@
},
{
"name": "GBrain minion-orchestrator routing policy",
- "status": "complete",
"priority": "P2",
+ "status": "complete",
"contract": "Durable-job routing separates deterministic restart-survivable work from live LLM subagents, following GBrain's unified minion-orchestrator skill while keeping Gormes Go-native subagent APIs",
"contract_status": "validated",
"slice_size": "small",
@@ -657,8 +675,8 @@
},
{
"name": "Durable subagent/job ledger",
- "status": "complete",
"priority": "P2",
+ "status": "complete",
"contract": "SQLite-first job ledger records restartable subagent and deterministic work state with claim, progress, result, error, parent-child, and cancellation fields",
"contract_status": "validated",
"slice_size": "medium",
@@ -678,15 +696,15 @@
"internal/subagent/runlog.go",
"internal/cron/executor.go"
],
- "blocked_by": [
- "GBrain minion-orchestrator routing policy"
- ],
"ready_when": [
"Routing policy fixtures define which work may enter durable orchestration and which callers are allowed to submit or observe each lane."
],
"not_ready_when": [
"The slice tries to implement every GBrain Minions status, Postgres/PGLite compatibility, supervisor process management, or arbitrary shell-job submission."
],
+ "blocked_by": [
+ "GBrain minion-orchestrator routing policy"
+ ],
"acceptance": [
"A SQLite-backed ledger records job id, job kind, status, parent id, depth, progress JSON, result JSON, error text, timestamps, and cancellation intent.",
"Subagent and cron/deterministic job fixtures use the same ledger contract without changing existing public delegate_task behavior.",
@@ -810,15 +828,15 @@
"../hermes-agent/gateway/config.py",
"docs/content/building-gormes/architecture_plan/phase-2-gateway.md"
],
- "blocked_by": [
- "Pairing approval + rate-limit semantics"
- ],
"ready_when": [
"Pairing approval, rate limiting, and allowlist checks are fixture-locked."
],
"not_ready_when": [
"Unknown DMs fall through to normal agent execution or share session state with authorized users."
],
+ "blocked_by": [
+ "Pairing approval + rate-limit semantics"
+ ],
"acceptance": [
"Configured deny mode sends a deterministic denial without creating a session.",
"Configured pair mode sends one bounded pairing prompt and records pending state.",
@@ -920,15 +938,15 @@
"docs/content/upstream-hermes/source-study.md",
"docs/content/building-gormes/upstream-lessons.md"
],
- "blocked_by": [
- "2.E.2"
- ],
"ready_when": [
"2.E.2 is complete and the shared CommandDef registry is stable for gateway commands."
],
"not_ready_when": [
"The implementation tries to inject mid-run prompts instead of only registering /steer and queue fallback behavior."
],
+ "blocked_by": [
+ "2.E.2"
+ ],
"unblocks": [
"Mid-run steer injection between tool calls",
"Gateway-handled slash commands bypass active-session guard"
@@ -988,22 +1006,6 @@
"status": "complete"
}
]
- },
- "2.B.10": {
- "name": "WeChat Adapter",
- "priority": "P1",
- "items": [
- {
- "name": "WeCom + WeiXin shared-chassis bot seam",
- "status": "complete",
- "note": "TDD landed: internal/channels/wecom and internal/channels/weixin now pin policy-gated ingress, request/reply routing, and per-platform reply-path contracts before any SDK wiring."
- },
- {
- "name": "WeCom + WeiXin transport/bootstrap layer",
- "status": "complete",
- "note": "TDD landed: internal/channels/wecom/runtime.go and internal/channels/weixin/runtime.go now freeze WeCom WebSocket/callback bootstrap, credential validation, WeCom reply-vs-active-push decisions, and Weixin long-poll/context-token lifecycle seams before any real transport binding."
- }
- ]
}
}
},
@@ -1379,15 +1381,15 @@
"docs/content/upstream-gbrain/architecture.md",
"docs/content/building-gormes/architecture_plan/phase-3-memory.md"
],
- "blocked_by": [
- "Honcho-compatible scope/source tool schema"
- ],
"ready_when": [
"Honcho-compatible scope/source tool schema is complete and exposes source allowlist semantics."
],
"not_ready_when": [
"Deny-path fixtures are mixed with operator evidence rendering in the same slice."
],
+ "blocked_by": [
+ "Honcho-compatible scope/source tool schema"
+ ],
"unblocks": [
"Cross-chat operator evidence",
"parent_session_id lineage for compression splits"
@@ -1488,15 +1490,15 @@
"../hermes-agent/tests/gateway/test_resume_command.py",
"../hermes-agent/docs/user-guide/sessions.md"
],
- "blocked_by": [
- "parent_session_id lineage for compression splits"
- ],
"ready_when": [
"Session lineage metadata can resolve root -> child chains and distinguish ended compression roots from live descendants."
],
"not_ready_when": [
"The slice changes context compression behavior or loads transcripts from a separate store instead of reusing the native session read model."
],
+ "blocked_by": [
+ "parent_session_id lineage for compression splits"
+ ],
"unblocks": [
"Context compression"
],
@@ -1658,15 +1660,15 @@
"internal/memory/session_catalog.go",
"internal/goncho/types.go"
],
- "blocked_by": [
- "Cross-chat deny-path fixtures"
- ],
"ready_when": [
"Same-chat and user-scope deny paths are fixture-locked so filter failures cannot accidentally widen recall."
],
"not_ready_when": [
"The slice adds an HTTP surface or full SDK compatibility before the internal filter AST is tested."
],
+ "blocked_by": [
+ "Cross-chat deny-path fixtures"
+ ],
"acceptance": [
"Filter AST fixtures cover AND, OR, NOT, gt, gte, lt, lte, ne, in, contains, icontains, metadata, and wildcard parsing.",
"The first executable implementation supports a documented subset and returns unsupported-filter evidence for the rest.",
@@ -1707,15 +1709,15 @@
"internal/memory/schema.go",
"internal/goncho/sql.go"
],
- "blocked_by": [
- "Goncho context representation options"
- ],
"ready_when": [
"Context options expose observer/target fields and current peer-card replacement behavior is fixture-locked."
],
"not_ready_when": [
"The slice tries to port observe_others scheduling before the storage key and card semantics are stable."
],
+ "blocked_by": [
+ "Goncho context representation options"
+ ],
"acceptance": [
"Peer cards enforce Honcho's max-40-facts cap.",
"Manual set_card behavior replaces the full card instead of merging.",
@@ -1739,9 +1741,9 @@
{
"name": "Goncho queue status read model",
"priority": "P3",
- "status": "planned",
+ "status": "complete",
"contract": "Gormes exposes Honcho-style representation, summary, and dream work-unit queue status as observability, not synchronization",
- "contract_status": "draft",
+ "contract_status": "validated",
"slice_size": "small",
"execution_owner": "memory",
"trust_class": [
@@ -1757,21 +1759,21 @@
"docs/content/building-gormes/goncho_honcho_memory/04-agent-work-packets.md",
"internal/memory/status.go"
],
- "blocked_by": [
- "Directional peer cards and representation scopes"
- ],
"ready_when": [
"At least one Goncho-owned task type or a zero-state read model is available to report deterministically."
],
"not_ready_when": [
"The slice waits for the queue to drain or treats queue empty as an application synchronization condition."
],
+ "blocked_by": [
+ "Directional peer cards and representation scopes"
+ ],
"acceptance": [
"Status fields include completed_work_units, in_progress_work_units, pending_work_units, total_work_units, and optional per-session details.",
"Only representation, summary, and dream task types count toward Honcho-style queue status.",
"Docs and CLI output state that queue status is for observability and debugging, not waiting for completion."
],
- "note": "Honcho docs explicitly warn not to wait for an empty queue. Goncho should expose this as operator evidence alongside existing memory status without making queue drain part of turn correctness.",
+ "note": "TDD landed: Goncho exposes a Honcho-style zero-state queue status read model for representation, summary, and dream work units with completed_work_units, in_progress_work_units, pending_work_units, total_work_units, and optional per-session details. Memory status and Goncho doctor output include extractor queue status alongside Goncho work-unit counts and explicitly frame queue status as observability/debugging evidence, not a synchronization contract or queue-drain wait condition.",
"write_scope": [
"internal/goncho/",
"internal/memory/",
@@ -1783,7 +1785,12 @@
],
"done_signal": [
"Queue status fixtures prove Honcho-style counts and document that queue empty is not a synchronization contract."
- ]
+ ],
+ "health": {
+ "attempt_count": 1,
+ "last_attempt": "2026-04-25T04:56:45Z",
+ "last_success": "2026-04-25T04:56:45Z"
+ }
},
{
"name": "Goncho summary context budget",
@@ -1807,15 +1814,15 @@
"internal/goncho/service.go",
"internal/memory/schema.go"
],
- "blocked_by": [
- "Goncho context representation options"
- ],
"ready_when": [
"Context options are schema-visible and the memory store can add a session_summaries table via migration."
],
"not_ready_when": [
"The slice rewrites RecallProvider.GetContext or merges summaries into the existing memory-context fence instead of adding a separate Goncho context component."
],
+ "blocked_by": [
+ "Goncho context representation options"
+ ],
"acceptance": [
"Schema stores one short and one long summary slot per session with last-covered message and token count.",
"Short summaries trigger every 20 messages and long summaries every 60 messages by default.",
@@ -1865,15 +1872,15 @@
"internal/gonchotools/honcho_tools.go",
"internal/goncho/service.go"
],
- "blocked_by": [
- "Goncho context representation options"
- ],
"ready_when": [
"Context options are schema-visible and manual conclusions can be queried through the existing Goncho service."
],
"not_ready_when": [
"The slice replaces honcho_context or removes honcho_reasoning instead of adding the host-compatible honcho_chat contract."
],
+ "blocked_by": [
+ "Goncho context representation options"
+ ],
"acceptance": [
"Chat params accept query, session_id, target, reasoning_level, and stream.",
"The default reasoning level is low and invalid reasoning levels are rejected.",
@@ -1897,9 +1904,9 @@
{
"name": "Goncho file upload import ingestion",
"priority": "P4",
- "status": "planned",
+ "status": "complete",
"contract": "Goncho can import text-like memory files as session messages without persisting original file bytes, matching Honcho's file-upload migration model",
- "contract_status": "draft",
+ "contract_status": "validated",
"slice_size": "medium",
"execution_owner": "memory",
"trust_class": [
@@ -1921,15 +1928,15 @@
"internal/goncho/service.go",
"internal/memory/schema.go"
],
- "blocked_by": [
- "Goncho queue status read model"
- ],
"ready_when": [
"Goncho has a deterministic session-message write path or a documented queue-unavailable degraded path for imported messages."
],
"not_ready_when": [
"The slice stores original uploaded file bytes, silently accepts unsupported content types, or attempts PDF/OCR extraction before text and JSON imports are fixture-locked."
],
+ "blocked_by": [
+ "Goncho queue status read model"
+ ],
"acceptance": [
"Text, Markdown, and JSON imports create normal session messages with required peer_id.",
"Imported chunks persist file_id, filename, chunk_index, total_chunks, original_file_size, content_type, and chunk_character_range metadata.",
@@ -1937,7 +1944,7 @@
"created_at, metadata, and configuration are preserved when provided.",
"Runtime chunk size follows Honcho source settings.MAX_MESSAGE_SIZE at 25000 characters unless upstream changes that setting."
],
- "note": "Honcho docs and the OpenClaw integration use file upload as the non-destructive path for legacy USER.md, MEMORY.md, SOUL.md, memory/, and similar files. Gormes should port the import semantics before adding a managed API client or web upload surface.",
+ "note": "TDD landed: internal/goncho/file_import_test.go covers text, Markdown, and JSON imports as ordinary session messages, file metadata in meta_json, required peer_id, unsupported content-type rejection before writes, no raw JSON file-byte persistence, created_at/metadata/configuration preservation, Honcho MAX_MESSAGE_SIZE chunking at 25000 characters, and queue-unavailable evidence. Verified with go test ./internal/goncho ./internal/memory ./cmd/gormes -count=1.",
"write_scope": [
"internal/goncho/",
"internal/memory/",
@@ -1949,7 +1956,12 @@
],
"done_signal": [
"File import fixtures prove supported formats become ordinary messages, unsupported formats fail before writes, and original files are not persisted."
- ]
+ ],
+ "health": {
+ "attempt_count": 1,
+ "last_attempt": "2026-04-25T05:34:12Z",
+ "last_success": "2026-04-25T05:34:12Z"
+ }
},
{
"name": "Goncho topology design fixtures",
@@ -1979,7 +1991,6 @@
"internal/goncho/service.go",
"internal/gonchotools/honcho_tools.go"
],
- "blocked_by": [],
"ready_when": [
"The current session directory, Goncho service types, and Honcho tool schemas are readable in the repo."
],
@@ -2041,15 +2052,15 @@
"internal/goncho/service.go",
"internal/config/config.go"
],
- "blocked_by": [
- "Goncho topology design fixtures"
- ],
"ready_when": [
"Topology rules define the expected workspace, peer, session, and observation defaults."
],
"not_ready_when": [
"The slice reaches out to upstream Honcho, external network services, or hosted LLMs by default."
],
+ "blocked_by": [
+ "Goncho topology design fixtures"
+ ],
"unblocks": [
"Long-running architecture-planner-loop health reporting",
"Goncho queue status read model"
@@ -2079,9 +2090,9 @@
{
"name": "Goncho streaming chat persistence contract",
"priority": "P3",
- "status": "planned",
+ "status": "complete",
"contract": "Goncho streaming chat stores the final assistant response once and never turns partial stream chunks into memory",
- "contract_status": "draft",
+ "contract_status": "validated",
"slice_size": "small",
"execution_owner": "memory",
"trust_class": [
@@ -2100,15 +2111,15 @@
"internal/gonchotools/honcho_tools.go",
"internal/memory/schema.go"
],
- "blocked_by": [
- "Goncho dialectic chat contract"
- ],
"ready_when": [
"honcho_chat or equivalent dialectic chat params and response shape are fixture-locked."
],
"not_ready_when": [
"The slice stores stream chunks as messages, creates synthetic assistant turns before completion, or changes honcho_context behavior."
],
+ "blocked_by": [
+ "Goncho dialectic chat contract"
+ ],
"unblocks": [
"Internal agent chat transport",
"Hugo docs examples for streaming memory behavior"
@@ -2120,7 +2131,7 @@
"Successful streamed assistant responses are stored exactly once with the same session and assistant peer as non-streaming chat.",
"Token/counting metadata can be attached after completion without affecting the stored text."
],
- "note": "Honcho docs make streaming a chat-response transport detail. Goncho should preserve memory quality by treating only completed assistant messages as durable facts.",
+ "note": "Complete: TDD landed internal/goncho/streaming_chat_persistence_test.go. The fixture proves stream=true degraded chat persists the final assistant response once, streaming handlers buffer chunks until completion, token metadata attaches after completion without mutating stored text, and interrupted streams return evidence without flushing partial assistant content to memory.",
"write_scope": [
"internal/goncho/",
"internal/gonchotools/",
@@ -2132,7 +2143,12 @@
],
"done_signal": [
"Streaming fixtures prove completed responses are persisted once and interrupted or partial chunks cannot pollute memory."
- ]
+ ],
+ "health": {
+ "attempt_count": 1,
+ "last_attempt": "2026-04-25T04:56:45Z",
+ "last_success": "2026-04-25T04:56:45Z"
+ }
},
{
"name": "Goncho configuration namespace",
@@ -2158,15 +2174,15 @@
"internal/goncho/types.go",
"cmd/gormes/doctor.go"
],
- "blocked_by": [
- "Goncho topology design fixtures"
- ],
"ready_when": [
"The existing Gormes config loader and doctor output can be extended without changing unrelated agent settings."
],
"not_ready_when": [
"The slice copies Honcho Python environment variables directly or requires provider credentials before Goncho can run in zero-state mode."
],
+ "blocked_by": [
+ "Goncho topology design fixtures"
+ ],
"unblocks": [
"Goncho operator diagnostics contract",
"Goncho file upload import ingestion",
@@ -2221,18 +2237,18 @@
"docs/content/upstream-hermes/source-study.md",
"docs/content/building-gormes/architecture_plan/phase-4-brain-transplant.md"
],
- "unblocks": [
- "Bedrock Converse payload mapping (no AWS SDK)",
- "Gemini",
- "OpenRouter",
- "Codex"
- ],
"ready_when": [
"Anthropic transcript fixtures replay request, stream, finish reason, and usage data without live credentials."
],
"not_ready_when": [
"A provider-specific adapter lands before shared transcript fixtures prove the contract."
],
+ "unblocks": [
+ "Bedrock Converse payload mapping (no AWS SDK)",
+ "Gemini",
+ "OpenRouter",
+ "Codex"
+ ],
"acceptance": [
"Provider transcripts replay request, stream, finish reason, and usage data without live credentials.",
"EOF after partial tool_call surfaces pending calls instead of dropping them.",
@@ -2266,15 +2282,15 @@
"docs/content/upstream-hermes/source-study.md",
"docs/content/building-gormes/architecture_plan/phase-4-brain-transplant.md"
],
- "blocked_by": [
- "Provider interface + stream fixture harness"
- ],
"ready_when": [
"Provider interface + stream fixture harness is available for cross-provider tool continuation fixtures."
],
"not_ready_when": [
"Continuation mapping is implemented inside one provider adapter instead of the shared event model."
],
+ "blocked_by": [
+ "Provider interface + stream fixture harness"
+ ],
"unblocks": [
"DeepSeek/Kimi reasoning_content echo for tool-call replay",
"Bedrock stream event decoding (SSE fixtures)",
@@ -2356,9 +2372,9 @@
},
{
"name": "Bedrock Converse payload mapping (no AWS SDK)",
- "status": "planned",
+ "status": "complete",
"contract": "Pure Bedrock Converse request mapping over the shared provider message/tool contract",
- "contract_status": "fixture_ready",
+ "contract_status": "validated",
"slice_size": "small",
"execution_owner": "provider",
"trust_class": [
@@ -2386,7 +2402,7 @@
"Tool definitions map to Bedrock toolSpec inputSchema without dropping required fields.",
"Golden request fixtures pin max_tokens, temperature, cache/reasoning passthrough, and empty-content placeholders."
],
- "note": "TDD first slice: port Bedrock Converse request-payload shaping plus canonical Message->Bedrock tool-aware mapping with pure fixtures and no AWS SDK dependency. Land alongside a request-body golden file that pins role/tool-result block order, reasoning/cache-control passthrough, and max_tokens/temperature translation. Gates the next two slices.",
+ "note": "Complete: TDD added pure Bedrock Converse request-payload shaping in `internal/hermes` plus a request-body golden fixture. The mapper converts shared system/user/assistant/tool-result messages into Converse roles and content blocks, preserves assistant reasoning blocks and Bedrock cachePoint hints, maps tool definitions to `toolSpec.inputSchema.json` without dropping required fields, and pins `inferenceConfig.maxTokens`/`temperature` plus empty-content placeholders without importing AWS SDK clients or signing live requests. Bedrock remains unavailable until stream decoding and credential wiring land.",
"write_scope": [
"internal/hermes/",
"docs/content/building-gormes/architecture_plan/progress.json"
@@ -2396,7 +2412,12 @@
],
"done_signal": [
"Bedrock request-body golden fixtures prove Converse mapping without AWS credentials or SDK clients."
- ]
+ ],
+ "health": {
+ "attempt_count": 1,
+ "last_attempt": "2026-04-25T05:56:06Z",
+ "last_success": "2026-04-25T05:56:06Z"
+ }
},
{
"name": "Bedrock stream event decoding (SSE fixtures)",
@@ -2425,15 +2446,15 @@
"../hermes-agent/tests/agent/test_bedrock_adapter.py",
"../hermes-agent/tests/agent/test_bedrock_integration.py"
],
- "blocked_by": [
- "Bedrock SigV4 + credential seam"
- ],
"ready_when": [
"A Bedrock client/cache seam exists behind the provider adapter and can be exercised without live AWS credentials."
],
"not_ready_when": [
"Non-stale validation/auth failures are retried or evicted as if they were transport-pool corruption."
],
+ "blocked_by": [
+ "Bedrock SigV4 + credential seam"
+ ],
"acceptance": [
"ConnectionClosed/ProtocolError-style failures evict only the affected region client.",
"Library-internal assertion failures from transport stacks are classified as stale, while application assertions are not.",
@@ -2544,17 +2565,17 @@
"../hermes-agent/tests/hermes_cli/test_anthropic_model_flow_stale_oauth.py",
"../hermes-agent/tests/run_agent/test_run_agent_codex_responses.py"
],
- "blocked_by": [
- "Token vault",
- "Multi-account auth",
- "Codex Responses pure conversion harness"
- ],
"ready_when": [
"Gormes has an XDG-scoped token vault and account-selection seam for provider credentials."
],
"not_ready_when": [
"The slice reads or writes ~/.codex/auth.json as the primary state store."
],
+ "blocked_by": [
+ "Token vault",
+ "Multi-account auth",
+ "Codex Responses pure conversion harness"
+ ],
"acceptance": [
"Codex tokens persist under Gormes home with provider/account metadata.",
"401/403 refresh failures return relogin-required status and do not silently retry stale tokens.",
@@ -2591,15 +2612,15 @@
"../hermes-agent/tests/run_agent/test_repair_tool_call_arguments.py",
"../hermes-agent/tests/run_agent/test_tool_call_args_sanitizer.py"
],
- "blocked_by": [
- "Codex Responses pure conversion harness"
- ],
"ready_when": [
"Codex Responses conversion fixtures can replay streamed and non-streamed output without live credentials."
],
"not_ready_when": [
"Malformed tool calls are stored in assistant history as ordinary text or a repair path hides unsupported API features."
],
+ "blocked_by": [
+ "Codex Responses pure conversion harness"
+ ],
"acceptance": [
"Empty response.output with streamed output_text backfills final assistant content.",
"Leaked to=functions.* text is rejected or repaired before it reaches parent history.",
@@ -2697,9 +2718,9 @@
},
{
"name": "ContextEngine interface + status tool contract",
- "status": "planned",
+ "status": "complete",
"contract": "Stable context engine status and compression boundary",
- "contract_status": "draft",
+ "contract_status": "validated",
"slice_size": "medium",
"execution_owner": "provider",
"trust_class": [
@@ -2707,14 +2728,11 @@
"system"
],
"degraded_mode": "Context status reports disabled compression, cooldowns, unknown tools, token-budget pressure, and replay gaps.",
- "fixture": "internal/contextengine status and compression replay fixtures",
+ "fixture": "internal/hermes/testdata/context_status and internal/kernel context-engine replay fixtures",
"source_refs": [
"docs/content/upstream-hermes/source-study.md",
"docs/content/building-gormes/architecture_plan/phase-4-brain-transplant.md"
],
- "blocked_by": [
- "Provider interface + stream fixture harness"
- ],
"ready_when": [
"Provider interface + stream fixture harness can replay context status without live provider calls."
],
@@ -2730,7 +2748,7 @@
"Compression remains an explicit engine boundary, not a hidden kernel side effect.",
"Fixtures replay context status without live provider calls."
],
- "note": "TDD: port the `agent/context_engine.py` interface, `get_status` payload, update_model_context behavior, and unknown tool error shape before any compressor implementation is wired into the agent loop.",
+ "note": "Complete: TDD landed a provider-owned Go ContextEngine contract in internal/hermes, a disabled engine with context_status payload fixtures for window, budget pressure, compression disabled/cooldown state, replay gaps, and structured unknown-context-tool errors. The kernel now snapshots context status, updates usage from provider EventDone, advertises context-engine tools, and dispatches them through the explicit engine boundary without calling Compress as a hidden side effect.",
"write_scope": [
"internal/kernel/",
"internal/hermes/",
@@ -2741,7 +2759,12 @@
],
"done_signal": [
"Context status fixtures expose budget, compression state, and unknown-tool errors without live provider calls."
- ]
+ ],
+ "health": {
+ "attempt_count": 1,
+ "last_attempt": "2026-04-25T05:56:06Z",
+ "last_success": "2026-04-25T05:56:06Z"
+ }
},
{
"name": "Compression token-budget trigger + summary sizing",
@@ -2810,9 +2833,9 @@
},
{
"name": "Provider-enforced context-length resolver",
- "status": "planned",
+ "status": "complete",
"contract": "Displayed and budgeted context windows prefer provider-enforced limits over raw models.dev metadata",
- "contract_status": "fixture_ready",
+ "contract_status": "validated",
"slice_size": "small",
"execution_owner": "provider",
"trust_class": [
@@ -2843,7 +2866,7 @@
"Provider-specific caps for Codex OAuth, Copilot, and Nous win over model-info fallbacks when present.",
"Unknown resolver failures fall back to fixture model metadata and report unknown when both sources are empty."
],
- "note": "Hermes now routes /model display through resolve_display_context_length so provider-enforced caps win over raw models.dev entries. Gormes should port the resolver as a pure metadata fixture first; CLI/gateway display wiring can consume it later without duplicating cap logic.",
+ "note": "TDD landed: internal/hermes/model_context_resolver_test.go proves ResolveDisplayContextLength and ModelContextResolver deterministically prefer provider-enforced caps over raw models.dev metadata, fall back to fixture model metadata when provider metadata is unavailable, and report unknown when both sources are empty. The pure resolver has no live provider credentials, disk cache, routing policy, or network dependency.",
"write_scope": [
"internal/hermes/",
"docs/content/building-gormes/architecture_plan/progress.json"
@@ -2853,7 +2876,12 @@
],
"done_signal": [
"Context resolver fixtures prove provider caps, models.dev fallback, and unknown-model reporting are deterministic without network calls."
- ]
+ ],
+ "health": {
+ "attempt_count": 1,
+ "last_attempt": "2026-04-25T05:56:06Z",
+ "last_success": "2026-04-25T05:56:06Z"
+ }
},
{
"name": "Model pricing/capability registry fixtures",
@@ -2874,15 +2902,15 @@
"../hermes-agent/agent/usage_pricing.py",
"docs/content/building-gormes/architecture_plan/subsystem-inventory.md"
],
- "blocked_by": [
- "Provider-enforced context-length resolver"
- ],
"ready_when": [
"Provider-enforced context resolver fixtures establish the metadata package shape and fallback semantics."
],
"not_ready_when": [
"The slice implements smart routing decisions or provider calls instead of read-only metadata fixtures."
],
+ "blocked_by": [
+ "Provider-enforced context-length resolver"
+ ],
"unblocks": [
"Routing policy and fallback selector"
],
@@ -2922,16 +2950,16 @@
"../hermes-agent/hermes_cli/runtime_provider.py",
"docs/content/building-gormes/architecture_plan/phase-4-brain-transplant.md"
],
- "blocked_by": [
- "Provider-enforced context-length resolver",
- "Model pricing/capability registry fixtures"
- ],
"ready_when": [
"Context limits, pricing, capabilities, and provider-family metadata are fixture-backed."
],
"not_ready_when": [
"The selector mutates kernel turn state, opens provider network calls, or hides operator-specified model overrides."
],
+ "blocked_by": [
+ "Provider-enforced context-length resolver",
+ "Model pricing/capability registry fixtures"
+ ],
"acceptance": [
"Explicit per-turn or config overrides win over automatic routing unless invalid.",
"Fallback choices are deterministic from fixture provider availability and model metadata.",
@@ -3014,15 +3042,15 @@
"../hermes-agent/hermes_cli/auth.py",
"../hermes-agent/tests/hermes_cli/test_anthropic_model_flow_stale_oauth.py"
],
- "blocked_by": [
- "Token vault"
- ],
"ready_when": [
"Token vault owns XDG-scoped credential files and can expose provider auth status without live credentials."
],
"not_ready_when": [
"The slice silently resets corrupt auth state or reads platform keychains during ordinary unit tests."
],
+ "blocked_by": [
+ "Token vault"
+ ],
"acceptance": [
"Fake keychain entries take precedence over JSON auth files when valid.",
"Malformed auth JSON is preserved to a recoverable backup and surfaces a warning.",
@@ -3058,9 +3086,9 @@
"items": [
{
"name": "Provider-side resilience",
- "status": "in_progress",
+ "status": "complete",
"contract": "Provider resilience umbrella over retry, cache, rate, and budget behavior",
- "contract_status": "draft",
+ "contract_status": "validated",
"slice_size": "large",
"execution_owner": "provider",
"trust_class": [
@@ -3072,15 +3100,15 @@
"docs/content/upstream-hermes/source-study.md",
"docs/content/building-gormes/architecture_plan/phase-4-brain-transplant.md"
],
- "blocked_by": [
- "Provider interface + stream fixture harness"
- ],
"ready_when": [
"Provider interface + stream fixture harness is available for resilience fixture coverage."
],
"not_ready_when": [
"The row is used to port every retry, cache, rate, and budget behavior as one monolithic implementation."
],
+ "blocked_by": [
+ "Provider interface + stream fixture harness"
+ ],
"unblocks": [
"Retry-After header parsing + HTTPError hint",
"Kernel retry honors Retry-After hint",
@@ -3091,7 +3119,7 @@
"Kernel retry policy reports schedule and provider hint decisions.",
"Unavailable cache/rate/budget capability is visible before routing relies on it."
],
- "note": "Umbrella tracker for 4.H closeout. Already shipped: structured provider-error taxonomy in `internal/hermes/errors.go`, Retry-After header/body parsing on `HTTPError`, and `internal/kernel/retry.go` 1s/2s/4s/8s/16s +/-20% reconnect budget with provider Retry-After hints preferred and capped during open-stream retries. Remaining work is owned by the sibling slices below โ `Prompt-cache capability guard` and `Provider rate guard + budget telemetry`.",
+ "note": "Complete: TDD landed the 4.H umbrella status contract without porting the sibling slices monolithically. `internal/hermes.ProviderStatusOf` now exposes prompt-cache, rate-guard, and budget-telemetry capability rows for OpenAI-compatible, Anthropic, mock, and unknown providers; OpenAI-compatible request fixtures prove unsupported `cache_control` metadata is stripped with a visible disabled path, while Anthropic reports cache-control support. `internal/kernel.RenderFrame` now carries provider status plus retry status, including the 1s/2s/4s/8s/16s schedule, max Retry-After cap, attempts used, provider hint vs scheduled-backoff decision, and retryable provider-error class/kind. Rate-guard and budget-telemetry implementations remain planned in their sibling slices, but their unavailable state is visible before routing can rely on them. Verified with `go test ./internal/hermes ./internal/kernel -count=1`.",
"write_scope": [
"internal/hermes/",
"internal/kernel/",
@@ -3102,7 +3130,12 @@
],
"done_signal": [
"Retry, cache, rate, budget, and provider-hint behavior is fixture-covered and visible in provider/kernel status."
- ]
+ ],
+ "health": {
+ "attempt_count": 1,
+ "last_attempt": "2026-04-25T05:56:06Z",
+ "last_success": "2026-04-25T05:56:06Z"
+ }
},
{
"name": "Classified provider-error taxonomy",
@@ -3120,15 +3153,15 @@
"docs/content/upstream-hermes/source-study.md",
"docs/content/building-gormes/architecture_plan/phase-4-brain-transplant.md"
],
- "blocked_by": [
- "Provider-side resilience"
- ],
"ready_when": [
"Provider-side resilience remains active and error taxonomy fixtures can be split from retry-policy changes."
],
"not_ready_when": [
"The slice changes kernel retry timing instead of only defining structured error classes and fixtures."
],
+ "blocked_by": [
+ "Provider-side resilience"
+ ],
"unblocks": [
"Retry-After header parsing + HTTPError hint",
"Provider rate guard + budget telemetry"
@@ -3205,9 +3238,9 @@
},
{
"name": "Tool registry inventory + schema parity harness",
- "status": "planned",
+ "status": "complete",
"contract": "Operation and tool descriptor parity before handler ports",
- "contract_status": "draft",
+ "contract_status": "validated",
"slice_size": "medium",
"execution_owner": "tools",
"trust_class": [
@@ -3238,7 +3271,7 @@
"No handler port can mark complete until its descriptor parity row exists.",
"Doctor can report missing dependencies or disabled provider-specific paths."
],
- "note": "TDD: snapshot upstream `tools/registry.py`, `toolsets.py`, and discovered schemas into a Go parity fixture that proves names, toolsets, required env vars, and JSON result shapes before porting handlers.",
+ "note": "Complete: TDD added an embedded upstream tool parity manifest generated from `tools/registry.py`, `toolsets.py`, and discovered schemas. The fixture captures 55 tool rows plus static/resolved toolsets with required env vars, provider paths, JSON schemas, result envelopes, trust classes, dependencies, and degraded status metadata. `LoadUpstreamToolParityManifest`, descriptor-row port gating, and the parity doctor now report disabled tools, missing dependencies, schema drift, and unavailable provider-specific paths before any handler port can claim completion.",
"write_scope": [
"internal/tools/",
"docs/content/building-gormes/architecture_plan/progress.json"
@@ -3248,7 +3281,12 @@
],
"done_signal": [
"Tool descriptor parity fixtures capture names, schemas, trust classes, dependencies, and degraded status before handler ports."
- ]
+ ],
+ "health": {
+ "attempt_count": 1,
+ "last_attempt": "2026-04-25T05:56:06Z",
+ "last_success": "2026-04-25T05:56:06Z"
+ }
},
{
"name": "Pure core tools first",
@@ -3372,9 +3410,9 @@
},
{
"name": "Skill preprocessing + dynamic slash commands",
- "status": "planned",
+ "status": "complete",
"contract": "Skill content preprocessing and skill-backed slash commands are deterministic, disabled-skill aware, and prompt-safe",
- "contract_status": "fixture_ready",
+ "contract_status": "validated",
"slice_size": "small",
"execution_owner": "skills",
"trust_class": [
@@ -3406,7 +3444,7 @@
"Inline shell preprocessing is disabled by default and bounded when explicitly enabled.",
"Skill slash commands skip disabled/incompatible skills and build stable user-message content."
],
- "note": "Hermes split shared SKILL.md preprocessing into `agent/skill_preprocessing.py` and expanded skill slash command scanning. Gormes has the core Phase 2.G store; this slice adds the remaining prompt/command preprocessing contract without widening automatic skill execution.",
+ "note": "TDD landed: internal/skills/preprocessing_commands_test.go covers deterministic template preprocessing, inline shell remaining disabled by default and output-bounded when explicitly enabled, status reporting for disabled/unsupported/missing-prerequisite/preprocessing-failed skills without prompt injection, and skill slash-command generation that exposes only available skills through stable gateway command surfaces.",
"write_scope": [
"internal/skills/",
"internal/gateway/",
@@ -3417,7 +3455,12 @@
],
"done_signal": [
"Skill preprocessing and slash-command fixtures prove disabled/incompatible skills do not enter prompt or command surfaces."
- ]
+ ],
+ "health": {
+ "attempt_count": 1,
+ "last_attempt": "2026-04-25T05:56:06Z",
+ "last_success": "2026-04-25T05:56:06Z"
+ }
}
]
},
@@ -3484,15 +3527,15 @@
"../hermes-agent/tests/tools/test_spotify_client.py",
"../hermes-agent/website/docs/user-guide/skills/bundled/media/media-spotify.md"
],
- "blocked_by": [
- "Plugin SDK"
- ],
"ready_when": [
"Plugin manifest loading and capability registration are fixture-locked by the Plugin SDK slice."
],
"not_ready_when": [
"Spotify is ported as a built-in core tool instead of a plugin-backed capability."
],
+ "blocked_by": [
+ "Plugin SDK"
+ ],
"acceptance": [
"The Spotify manifest declares required env/auth and tool capabilities before handlers load.",
"Missing credentials keep Spotify disabled with visible status.",
@@ -3583,8 +3626,8 @@
},
{
"name": "Clarify",
- "note": "TDD: port `tools/clarify_tool.py` as an interruptible prompt contract that preserves gateway/TUI response routing and times out safely in non-interactive cron turns.",
- "status": "planned"
+ "status": "planned",
+ "note": "TDD: port `tools/clarify_tool.py` as an interruptible prompt contract that preserves gateway/TUI response routing and times out safely in non-interactive cron turns."
},
{
"name": "Session search",
@@ -3640,9 +3683,9 @@
},
{
"name": "PTY bridge protocol adapter",
- "status": "planned",
+ "status": "complete",
"contract": "Dashboard/TUI PTY sessions expose bounded read, write, resize, close, and unavailable-state behavior through a testable adapter",
- "contract_status": "fixture_ready",
+ "contract_status": "validated",
"slice_size": "small",
"execution_owner": "tools",
"trust_class": [
@@ -3670,7 +3713,7 @@
"Writes and resize messages validate input before reaching the PTY.",
"Unsupported platforms return PtyUnavailable-style errors without starting a shell."
],
- "note": "Upstream added `hermes_cli/pty_bridge.py` plus dashboard `/api/pty` wiring. Port the PTY adapter as a small protocol slice before any dashboard or remote TUI transport work consumes it.",
+ "note": "Complete: TDD landed internal/cli/pty_bridge_test.go plus a small internal/cli PTY adapter. Fixtures prove bounded read timeout/chunk behavior, write forwarding with pre-session validation, resize escape validation and winsize forwarding, idempotent close/child termination, and ErrPtyUnavailable degradation before spawn on unsupported platforms without dashboard, network, or live TUI dependencies.",
"write_scope": [
"internal/cli/",
"docs/content/building-gormes/architecture_plan/progress.json"
@@ -3680,7 +3723,12 @@
],
"done_signal": [
"PTY bridge fixtures prove read/write/resize/close/unavailable behavior without network or live dashboard dependencies."
- ]
+ ],
+ "health": {
+ "attempt_count": 1,
+ "last_attempt": "2026-04-25T05:56:06Z",
+ "last_success": "2026-04-25T05:56:06Z"
+ }
},
{
"name": "CLI command registry parity + active-turn busy policy",
@@ -3704,15 +3752,15 @@
"../hermes-agent/tests/cli/test_busy_input_mode_command.py",
"../hermes-agent/hermes_cli/commands.py"
],
- "blocked_by": [
- "CLI command registry parity + active-turn busy policy"
- ],
"ready_when": [
"The CLI command registry has a shared active-turn/busy policy surface."
],
"not_ready_when": [
"Busy state is implemented only for /compress or only in the visual TUI without a command-layer invariant."
],
+ "blocked_by": [
+ "CLI command registry parity + active-turn busy policy"
+ ],
"acceptance": [
"/compress and other long-running command handlers set and clear busy state even on error.",
"User input during busy command execution returns a visible busy response.",
@@ -3793,9 +3841,9 @@
},
{
"name": "OpenAI-compatible chat-completions API server",
- "status": "planned",
+ "status": "complete",
"contract": "OpenAI-compatible chat.completions HTTP surface over the native Gormes turn loop",
- "contract_status": "fixture_ready",
+ "contract_status": "validated",
"slice_size": "medium",
"execution_owner": "gateway",
"trust_class": [
@@ -3838,7 +3886,12 @@
],
"done_signal": [
"Chat-completions HTTP fixtures prove auth, body limits, content normalization, streaming envelopes, and session continuity over native Gormes state."
- ]
+ ],
+ "health": {
+ "attempt_count": 1,
+ "last_attempt": "2026-04-25T05:56:06Z",
+ "last_success": "2026-04-25T05:56:06Z"
+ }
},
{
"name": "Responses API store + run event stream",
@@ -3858,15 +3911,15 @@
"../hermes-agent/tests/gateway/test_api_server.py",
"docs/content/upstream-hermes/user-guide/features/api-server.md"
],
- "blocked_by": [
- "OpenAI-compatible chat-completions API server"
- ],
"ready_when": [
"Chat-completions HTTP surface is native and response storage can reuse its auth, session, and error-envelope contracts."
],
"not_ready_when": [
"Responses history chains use a separate session model from gateway/TUI sessions."
],
+ "blocked_by": [
+ "OpenAI-compatible chat-completions API server"
+ ],
"unblocks": [
"API server disconnect snapshot persistence",
"Dashboard API client contract"
@@ -3908,15 +3961,15 @@
"../hermes-agent/tests/gateway/test_api_server.py",
"docs/content/upstream-hermes/user-guide/features/api-server.md"
],
- "blocked_by": [
- "Responses API store + run event stream"
- ],
"ready_when": [
"Responses store and run event stream can persist terminal and non-terminal snapshots."
],
"not_ready_when": [
"Client disconnects lose response IDs or previous_response_id chains."
],
+ "blocked_by": [
+ "Responses API store + run event stream"
+ ],
"acceptance": [
"Connection reset during stream interrupts the agent and stores an incomplete response snapshot when store=true.",
"async cancellation stores the same incomplete snapshot before returning cancellation.",
@@ -3953,15 +4006,15 @@
"../hermes-agent/gateway/platforms/base.py",
"docs/content/upstream-hermes/user-guide/features/api-server.md"
],
- "blocked_by": [
- "OpenAI-compatible chat-completions API server"
- ],
"ready_when": [
"Native chat-completions API server accepts X-Hermes-Session-Id and streaming SSE fixtures."
],
"not_ready_when": [
"Proxy mode forwards tool-result messages with empty content or accepts stale run generations as current output."
],
+ "blocked_by": [
+ "OpenAI-compatible chat-completions API server"
+ ],
"acceptance": [
"GATEWAY_PROXY_URL and config proxy_url resolve with env precedence and trailing-slash normalization.",
"Forwarded requests preserve X-Hermes-Session-Id and filter unsafe empty/tool-only history entries.",
@@ -3999,16 +4052,16 @@
"../hermes-agent/web/src/components/ModelPickerDialog.tsx",
"../hermes-agent/hermes_cli/web_server.py"
],
- "blocked_by": [
- "OpenAI-compatible chat-completions API server",
- "Responses API store + run event stream"
- ],
"ready_when": [
"Native API server exposes stable chat/Responses/session endpoints that dashboard contracts can call."
],
"not_ready_when": [
"The slice ports the upstream React app wholesale or adds Node/TypeScript to the Gormes runtime."
],
+ "blocked_by": [
+ "OpenAI-compatible chat-completions API server",
+ "Responses API store + run event stream"
+ ],
"acceptance": [
"Contract fixtures cover chat send/stream, session list/delete, model picker data, OAuth status, and tool-progress events.",
"Missing optional providers or plugins render disabled/degraded states.",
@@ -4044,16 +4097,16 @@
"../hermes-agent/tui_gateway/event_publisher.py",
"../hermes-agent/tui_gateway/ws.py"
],
- "blocked_by": [
- "PTY bridge protocol adapter",
- "SSE streaming to Bubble Tea TUI"
- ],
"ready_when": [
"PTY bridge behavior and TUI gateway event streaming are each fixture-locked."
],
"not_ready_when": [
"PTY bytes become the source of truth for sessions or tool events instead of a sidecar view."
],
+ "blocked_by": [
+ "PTY bridge protocol adapter",
+ "SSE streaming to Bubble Tea TUI"
+ ],
"acceptance": [
"PTY read/write/resize messages stay separate from structured tool/event publication.",
"Sidecar publish failures do not kill the PTY session.",
@@ -4150,15 +4203,15 @@
"docs/content/upstream-gbrain/gormes-takeaways.md",
"docs/content/building-gormes/architecture_plan/phase-6-learning-loop.md"
],
- "blocked_by": [
- "Phase 2.G skills runtime"
- ],
"ready_when": [
"Phase 2.G skills runtime is complete and the parser/store seam is stable enough for versioned metadata."
],
"not_ready_when": [
"Generated drafts are allowed into prompt injection without explicit review metadata."
],
+ "blocked_by": [
+ "Phase 2.G skills runtime"
+ ],
"unblocks": [
"LLM-assisted pattern distillation",
"Hybrid lexical + semantic lookup",
@@ -4314,8 +4367,8 @@
},
{
"name": "BlueBubbles iMessage bubble formatting parity",
- "status": "planned",
"priority": "P3",
+ "status": "planned",
"contract": "BlueBubbles outbound iMessage sends are non-editable, markdown-stripped, paragraph-split bubbles without pagination suffixes",
"contract_status": "fixture_ready",
"slice_size": "small",