v0.38.1.0 feat(agents): provider-agnostic subagent loop + remote MCP dispatch + budget meter (#1289)

* feat(agents): v0.38 Slice 1 foundation — migration v81 + capabilities module

Adds the storage substrate for the gateway-native subagent tool loop:

  - migration v81 adds subagent_tool_executions.ordinal + .gbrain_tool_use_id
    + UNIQUE(job_id, message_idx, ordinal). NULL-tolerant so legacy rows
    survive untouched; the v0.38 read-time D5 shim recomputes the stable
    key for pre-v81 rows from (job_id, message_idx, content_blocks index,
    tool_name) without a data migration. Engine-aware via sqlFor.pglite.
  - src/core/ai/capabilities.ts reads ChatTouchpoint fields from each
    recipe and exposes getProviderCapabilities() + classifyCapabilities()
    with a 5-state verdict (ok / degraded:no_caching / degraded:no_parallel
    / unusable:no_tools / unknown). This is what enforceSubagentCapable
    (D7, S1.8) will gate on once the queue.ts pin removal (S1.7) lands.
  - 12 unit cases in test/ai/capabilities.test.ts pin the verdict matrix
    across Anthropic, OpenAI, Google, voyage (no chat → unknown), unknown
    provider, missing-colon malformed input.

Plan: ~/.claude/plans/system-instruction-you-are-working-shimmying-breeze.md
Wave: v0.38 (Agents+Minions cathedral; CEO + Eng + 2x Codex cleared).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(agents): v0.38 Slice 1 — gateway.toolLoop() provider-agnostic loop control

Adds `gateway.toolLoop(opts)` as the provider-neutral loop wrapper over the
already-provider-neutral `gateway.chat()`. The Vercel AI SDK abstraction does
all the per-provider tool-def normalization, tool-call parsing, and tool-result
framing; this helper just sequences the assistant→tool-dispatch→tool-result
cycle with:

  - D11 stable-ID callbacks (onToolCallStart returns the gbrain-owned UUID v7
    that the caller persists at first observation; reread on replay)
  - Write-ordering invariant (persist assistant → persist pending tool row →
    execute side effect → settle complete/failed)
  - Crash-replay reconciliation via `replayState.priorTools` keyed by
    gbrainToolUseId (NOT provider IDs)
  - Capability-driven cache_control (Anthropic only, via cacheSystem flag)
  - Stop-reason mapping for refusal / content_filter / max_turns / aborted

The loop is stateless beyond the optional replay state — testable via the
existing `__setChatTransportForTests` seam without any DB.

This is the substrate Slice 1's `subagent.ts` rewire (S1.5) consumes.

Plan: ~/.claude/plans/system-instruction-you-are-working-shimmying-breeze.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(agents): v0.38 Slice 1 — kill the Anthropic pin, route through gateway.toolLoop

Closes the three-layer Anthropic-only enforcement (queue gate / model-config
runtime fallback / doctor check) with a capability-based gate driven by the
recipe registry. Any provider that supports native tool calling can now
run the subagent loop.

Three layers reworked:

  - queue.ts:87-106 (S1.7) — drop isAnthropicProvider hard-reject. Replace
    with classifyCapabilities() check: refuse only when verdict is
    'unusable:no_tools' or 'unknown'. Degraded providers (no caching, no
    parallel tools) pass through; the gateway prints once-per-(source, model)
    cost warnings at first dispatch.
  - model-config.ts:205 (S1.8) — rename enforceSubagentAnthropic →
    enforceSubagentCapable. Keeps the once-per-(source, model) warn seam
    from v0.31.12 and inherits the same suppression Set so doctor + first-
    call surfaces stay in sync. Legacy name kept as a thin wrapper for
    external callers.
  - doctor.ts:1189 (S1.9) — rename subagent_provider check →
    subagent_capability. The check now surfaces three states: 'unusable',
    'unknown', and 'degraded:no_caching' (the cost-regression warn). Paste-
    ready fix hints point at `gbrain config set models.tier.subagent`.

Subagent handler routing (S1.5 + S1.10):

  - New `agent.use_gateway_loop` config flag (default off). When enabled,
    the handler routes through gateway.toolLoop() — provider-agnostic via
    the Vercel AI SDK. When disabled, the legacy Anthropic-direct path
    stays unchanged.
  - Handler-entry capability check refuses tool-unsupported / unknown
    providers loudly. With flag OFF + non-Anthropic model, refuses with a
    paste-ready hint.
  - runSubagentViaGateway() (new helper) bridges the existing ToolDef
    registry to gateway's ChatToolDef + ToolHandler shapes. Persists to
    the v0.38 stable-ID columns (ordinal + gbrain_tool_use_id) at first
    observation; settles complete/failed on tool exit.
  - D5 read-time shim (S1.6) — loadPriorToolsV2 + adaptContentBlocksToChatBlocks
    handle v1 Anthropic-shaped legacy rows alongside v2 gateway-shaped writes
    so crash-replay reconciles across the upgrade boundary.

Tests:

  - test/agent-cli.test.ts Layer 1/2/3 cases flipped from "rejects non-
    Anthropic" to "any tool-supporting provider accepted; refuses unknown
    and embedding-only providers". 4 new cases covering openai, google,
    unknown provider, embedding-only.
  - All 27 cases pass; typecheck clean.

Plan: ~/.claude/plans/system-instruction-you-are-working-shimmying-breeze.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(agents): v0.38 Slice 2 — budget meter (reserve-then-settle) + migrations v82/v83

Foundation for per-OAuth-client daily budget caps. The reserve-then-settle
pattern (D3) closes the race window where two concurrent agents from the
same client both pre-flight pass at the cap boundary and bust it. Mirrors
the rate-leases.ts shape (lock-bounded check-then-insert + TTL-based
crash reclamation).

Changes:

  - Migration v82 (`mcp_spend_reservations`) — UUID primary key per
    reservation, status enum {pending,settled,expired}, partial index on
    (status, expires_at) WHERE status='pending' for cheap sweeps.
  - Migration v83 (`oauth_clients.budget_usd_per_day`) — first-class
    daily cap column on registered clients. NULL = no cap (legacy
    behavior for pre-v83 clients).
  - `src/core/minions/budget-meter.ts` — new module:
      • `reserve()` atomic check-and-reserve: sweep expired → SUM
        committed + pending → refuse if over cap → INSERT pending row
      • `settle()` idempotent close-out: UPDATE reservation + mirror
        into mcp_spend_log so the next reserve sees the committed spend
      • `sweepExpiredReservations()` standalone sweeper for worker
        startup / test harness
      • `getClientDailyCapCents()` reads oauth_clients.budget_usd_per_day
      • `clientLockKey()` FNV-1a hash (deterministic, no deps) for
        pg_advisory_xact_lock keying
  - Reuses the existing `BudgetExceededError` class from `spend-log.ts`
    so callers (search_by_image + subagent dispatch + future surfaces)
    catch on the same tagged error.

All 130 migration tests green; budget-meter module typecheck clean.

The Slice 3 work (`submit_agent` MCP op) wires this meter into the
remote-dispatch path: serve-http.ts threads `client_id` through the
operation context, the subagent handler's gateway path calls
`reserve()` before the loop and `settle()` after.

Plan: ~/.claude/plans/system-instruction-you-are-working-shimmying-breeze.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(agents): v0.38 Slice 3 — submit_agent MCP op + agent scope + bound_* migration

The remote-dispatch unlock. Cursor / Claude Code / ChatGPT can now launch
gbrain agent jobs over MCP with explicit per-OAuth-client capability
binding (D13). The trust boundary lives in oauth_clients.bound_* fields,
not in ad-hoc protected-name checks.

Schema:

  - Migration v84 (`oauth_clients_agent_binding`) — adds bound_tools,
    bound_source_id (FK sources.id ON DELETE SET NULL), bound_brain_id,
    bound_slug_prefixes, bound_max_concurrent columns. NULL on pre-v84
    clients (which therefore can't be granted the `agent` scope without
    re-registration — opt-in only).
  - `agent` scope added to `src/core/scope.ts`. NOT implied by admin
    (D13 sibling) — existing admin clients must explicitly re-register
    with --scopes agent to gain dispatch capability.

New MCP op `submit_agent`:

  - scope: `agent`, mutating, remote-callable
  - Required params: prompt. Optional: model, allowed_tools,
    allowed_slug_prefixes, max_turns (capped at 100), queue.
  - Per-dispatch binding enforcement:
      * client must have a binding row (refuse with paste-ready
        re-registration hint when bound_tools is NULL)
      * requested allowed_tools must be ⊆ bound_tools
      * requested slug_prefixes must each match a bound prefix
      * source_id auto-set from bound_source_id (client can't escape)
      * in-flight job count vs bound_max_concurrent
  - Internally enqueues a `subagent` job with allowProtectedSubmit;
    the gateway path (S1.5) is auto-on for remote-dispatched agents.
  - Writes a JSONL audit row via the new `agent-audit.ts` module:
    client_id + tools + source + slug_prefixes + max_concurrent +
    budget_remaining_cents + prompt byte count (NOT prompt text).

New `src/core/minions/agent-audit.ts`:

  - Mirrors shell-audit.ts (weekly ISO-week JSONL rotation, GBRAIN_AUDIT_DIR
    override, best-effort writes).
  - File: ~/.gbrain/audit/agent-jobs-YYYY-Www.jsonl
  - `logAgentSubmission` + `readRecentAgentEvents` exported for the
    doctor follow-up.

Tests: typecheck clean; capabilities + agent-cli suites green (39/39).

Plan: ~/.claude/plans/system-instruction-you-are-working-shimmying-breeze.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(agents): v0.38 Slice 4 — admin per-client agent spend endpoint

Read-side `/admin/api/agents/spend` endpoint returning per-OAuth-client
today's spend (committed + pending reservations), cap, and inflight job
count. The Agents.tsx page in admin/src/pages/ consumes this to render a
"$X / $Y today" cell next to each client.

Stub-style server endpoint lands now; the full Agents.tsx UI extension
can ship in a follow-up patch without blocking the Slices 1-3 functionality.
Pre-v0.38 brains where mcp_spend_log / mcp_spend_reservations may not
yet exist fall back to an empty array (graceful UI degrade).

Plan: ~/.claude/plans/system-instruction-you-are-working-shimmying-breeze.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(agents): v0.38 — gateway.toolLoop + budget-meter + agent-audit + scope flips

Test gap fills surfacing the load-bearing invariants of Slices 1-3:

Gateway tool loop (test/ai/gateway-tool-loop.test.ts, 7 cases):
  - end stop_reason exits cleanly with no tools
  - single tool call dispatches + result feeds next turn
  - persistence callbacks fire in order: onAssistantTurn → onToolCallStart
    → execute → onToolCallComplete (write-ordering invariant pinned)
  - replay short-circuit when prior tool execution is complete
  - non-idempotent pending replay throws unrecoverable
  - max_turns budget capped
  - refusal short-circuits without tool dispatch

Budget meter (test/minions/budget-meter.test.ts, 15 cases):
  - clientLockKey FNV-1a determinism + collision-rarity + INT32 fit
  - reserve under cap / over cap / two-sequential / pending-pushes-over
  - settle marks settled + mirrors to mcp_spend_log
  - settle idempotency (second call no-op)
  - sweep expired pending rows; leaves fresh ones
  - getClientDailyCapCents with set/unset/unknown clients
  - integration: settled spend feeds next reserve

Agent audit (test/minions/agent-audit.test.ts, 7 cases):
  - ISO-week filename rotation (incl. year-boundary edge)
  - JSONL line shape + multi-event appending
  - regression guard: NEVER logs prompt content (only byte count)
  - readRecentAgentEvents newest-first + empty-dir graceful fallback

Pre-existing test fixes for v0.38 semantics:
  - test/scope.test.ts: `agent` scope added (size 5 → 6)
  - test/oauth.test.ts: operations registry allows scope='agent' for
    submit_agent (mutating, contained by client bindings)
  - test/model-config.serial.test.ts: enforceSubagentCapable returns
    non-Anthropic tool-supporting models unchanged (with cost warn) and
    falls back to TIER_DEFAULTS.subagent only on unknown providers

Schema parity:
  - pglite-schema.ts + schema.sql get the v83 (budget_usd_per_day) +
    v84 (bound_tools, bound_source_id, bound_brain_id,
    bound_slug_prefixes, bound_max_concurrent) columns in CREATE TABLE
    so fresh installs land in post-migration shape AND the
    schema-bootstrap-coverage CI guard sees full coverage.

Pre-existing hybrid-reranker / cross-modal-hybrid integration test
failures are on master before any of this wave — out of scope.

Plan: ~/.claude/plans/system-instruction-you-are-working-shimmying-breeze.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: quarantine 4 cross-file-contended hybrid tests + withEnv-ize agent-audit

12 pre-existing flakes (hybrid-reranker / cross-modal-hybrid / unified-multimodal
/ llm-intent-hybrid-integration / doctor-report-remote) all collapsed to
zero after this wave. Root cause: shared module-level state in
src/core/ai/gateway.ts (configureGateway / __setEmbedTransportForTests /
_chatTransport) leaks across files in the same bun test process. Files
that touch the gateway state must run under --max-concurrency=1 (the
serial pass).

Renamed (R2 quarantine — gateway-state contention):
  - test/search/hybrid-reranker-integration.test.ts → .serial.test.ts
  - test/cross-modal-hybrid-integration.test.ts → .serial.test.ts
  - test/unified-multimodal.test.ts → .serial.test.ts
  - test/llm-intent-hybrid-integration.test.ts → .serial.test.ts

doctor-report-remote.serial.test.ts was already serial in v0.37.10.0; its
single failure in the v0.38 PR test log was downstream pollution from the
above four files leaking gateway transports across shard 3.

Also fixed test/minions/agent-audit.test.ts (R1 violation: raw
process.env.GBRAIN_AUDIT_DIR mutation) by wrapping each test body through
withEnv() via a withAuditDir() helper. check-test-isolation now passes
clean (526 non-serial unit files scanned, 0 violations).

Post-fix unit suite: 7/8 shards pass with zero failures; serial pass
29/29 clean; full run exit 0. Background task reported exit code 0.
The wedge on shard 4 (migrate.test.ts) is a separate slow-test scoping
concern, not a v0.38 regression.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(admin): mirror v0.38 agent scope into admin SPA + rebuild dist

CI failure on PR #1289: scripts/check-admin-scope-drift.sh caught the
hand-maintained mirror at admin/src/lib/scope-constants.ts had not been
updated when I added the new `agent` scope to src/core/scope.ts in Slice 3.
CLAUDE.md flagged this exact CI guard for the file.

Mirrored: added `agent` to both the Scope union type and the alphabetically-
sorted ALLOWED_SCOPES_LIST. Rebuilt the admin SPA dist (vite build, 36
modules, 228KB) so the bundled scope-aware UI matches the new server-side
list. check-admin-scope-drift passes (6 scopes match); full `bun run verify`
chain passes end-to-end including typecheck (0 errors).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(admin): regenerate src/admin-embedded.ts after dist rebuild

CI failure on PR #1289 serial pass: test/admin-embed-spawn.serial.test.ts
4/4 fail with "Cannot find module '../admin/dist/assets/index-CWq369vO.js'"
when spawning gbrain serve --http.

Root cause: the prior commit (f270e6c7) rebuilt the admin SPA dist after
adding the v0.38 `agent` scope to admin/src/lib/scope-constants.ts, which
produced a new content-hashed bundle filename (index-CWq369vO.js →
index-DFgMZhBE.js). The auto-generated `src/admin-embedded.ts` manifest
still hardcoded the OLD filename, so `import ... with { type: 'file' }`
threw at module-load time inside the spawned server, the server never
became ready, and the e2e harness timed out at 30s × 4 tests = ~2min.

Fix: re-ran `bun run build:admin-embedded` (scripts/build-admin-embedded.ts)
which regenerates src/admin-embedded.ts from the current dist/ contents.
Manifest now references index-DFgMZhBE.js. All 4 admin-embed-spawn.serial
tests pass locally.

Forward-looking note: the build:admin npm script chains
`cd admin && bun run build && cd .. && bun run scripts/build-admin-embedded.ts`
so regenerating both together is the standard path — the prior commit
manually invoked `vite build` inside admin/ and skipped the second step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: re-bump v0.38.2.0 → v0.38.1.0 (correct patch sequence)

User direction: this is v0.38.1.0 — the first patch on v0.38.0.0, not 0.38.2.0.
v0.38.2.0 was mid-wave when master merged in d0d0e2a6 and I chose the next
slot up; in retrospect 0.38.1.0 is the correct next-patch number since
nothing actually shipped at 0.38.0.0 yet (the PR's been re-iterating through
CI green and the wave is one continuous ship).

Updated:
  - VERSION: 0.38.2.0 → 0.38.1.0
  - package.json: 0.38.2.0 → 0.38.1.0
  - CHANGELOG.md: header rewritten

Trio audit: all three say 0.38.1.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: fill v0.38 gap inventory — submit_agent + scope isolation + D5 shim + admin spend

61 new test cases across 4 files closing the load-bearing gaps from the
v0.38 Agents+Minions wave. Also extracts /admin/api/agents/spend SQL into
a named helper so the endpoint and its test share a single source of truth.

Gap inventory + coverage delta:

  | Surface                                    | Before | After  |
  |--------------------------------------------|--------|--------|
  | submit_agent op (binding enforcement)      | 0      | 17     |
  | agent scope NOT implied by admin           | 0      | 9      |
  | D5 v1→v2 read-time shim                    | 0      | 16     |
  | /admin/api/agents/spend endpoint SQL       | 0      | 19     |

test/submit-agent.test.ts (17 cases):
  - Op surface (scope=agent, mutating, required prompt param)
  - Local CLI bypass (ctx.remote=false → invalid_request)
  - OAuth client requirement (missing clientId, unknown client_id)
  - Binding requirement: refuse when agent scope but bound_tools NULL
  - allowed_tools subset enforcement (passes ⊆, refuses outside)
  - allowed_slug_prefixes prefix-match against bound_slug_prefixes
  - bound_max_concurrent cap (refuse at cap, allow below, exclude
    terminal-state jobs, isolate inflight count by client_id)
  - Happy-path: job inserted + audit row written + prompt NEVER logged
  - max_turns capped at 100

test/scope-agent-isolation.test.ts (9 cases) — D13 regression guard:
  - admin does NOT imply agent (the load-bearing security check)
  - admin still implies sources_admin/users_admin/write/read
  - agent does NOT imply anything else (no reverse inheritance)
  - read+write does NOT imply agent (the common legacy shape)
  - explicit admin+agent compound grant satisfies both
  - ALLOWED_SCOPES_LIST sort order pinned (agent between admin and read)

test/subagent-v1-v2-shim.test.ts (16 cases) — D5 crash-replay correctness:
  - adaptContentBlocksToChatBlocks: string passthrough, defensive nulls,
    v1 Anthropic {type:tool_use,id,name,input} → v2 {type:tool-call,...},
    v2 passthrough, v1 tool_result → v2 tool-result with __legacy__
    toolName sentinel, is_error mapping, mixed v1+v2 in same message
    array (mid-upgrade scenario), malformed-block skip
  - loadPriorToolsV2: empty, gbrain_tool_use_id as stable key for v2,
    legacy-prefixed key for v1 rows, status+error preservation, mixed
    v1+v2 side-by-side with both shapes resolving, ORDER BY stability
  - Exposed both helpers on the existing __testing export from subagent.ts

test/admin-agents-spend.test.ts (19 cases) — Slice 4 SQL pinning:
  - Empty results: no clients / clients without agent scope or bindings
  - Include: scope=agent (with or without bindings), bound_tools set
    (with or without scope=agent — covers partial-migration state)
  - Exclude: soft-deleted (deleted_at IS NOT NULL) clients
  - cap_usd_per_day: null when unset, numeric when set
  - spent_cents_today: zero baseline, sum of today, exclude yesterday
    (UTC-day-aligned), client-id isolation
  - pending_cents: sum of pending+non-expired, exclude expired, exclude
    settled
  - inflight_count: only active/waiting/waiting-children subagent jobs;
    exclude shell jobs; client-id isolated
  - ORDER BY client_name ASC pinned for deterministic UI rendering
  - Multi-word scope strings ('read write agent') handled correctly via
    string_to_array
  - End-to-end happy path: all fields populated together

Refactor: extracted the spend SQL from src/commands/serve-http.ts into a
new exported `queryAgentClientSpend(engine)` helper + `AgentClientSpend`
type. The Express handler now delegates (5 lines). Same query, same
result shape, but a single source of truth that both the endpoint and
the test exercise.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(agents): v0.38 e2e — gateway path + crash-replay across 5 providers

Two new e2e suites driving the v0.38 runSubagentViaGateway path end-to-end
against PGLite. Both filed in TODOS as v0.38.x follow-ups during the cathedral
ship; building them out caught two real load-bearing bugs in subagent.ts that
would have silently broken crash-replay in production.

Bug 1 — messageIdx collision on fresh runs.
runSubagentViaGateway only passed replayState when priorChatMessages.length > 0,
so on a fresh run the gateway loop's messageIdx counter defaulted to 0. The
seed user message already occupies (job_id, message_idx=0), so the first
onAssistantTurn write at idx 0 hit the unique-constraint and the whole job
failed before any tool call. Fix: always pass replayState with nextMessageIdx
set to 1 on fresh runs (after the seed write). Pinned by
test/e2e/subagent-gateway-path.test.ts ("happy path 1-turn" + "write-ordering
invariant").

Bug 2 — onToolCallStart returned the wrong UUID on crash-replay.
The callback generated a fresh candidateId, INSERTed with ON CONFLICT DO
UPDATE, and returned the local candidateId. On replay, the pre-crash row
survives intact with its ORIGINAL gbrain_tool_use_id, so the local candidateId
was wrong. The gateway loop's replayState.priorTools is keyed by the original
UUID; returning the new one made the short-circuit miss and re-execute every
tool call. Fix: RETURNING gbrain_tool_use_id::text AS gbrain_tool_use_id and
read it back; fall through to candidateId only if RETURNING is empty. Pinned
by test/e2e/subagent-crash-replay-multi-provider.test.ts.

Coverage:
- test/e2e/subagent-gateway-path.test.ts: 7 cases. Happy path 1-turn,
  multi-turn with parallel tool calls, write-ordering invariant
  (persist-before-side-effect), gateway returns malformed tool_call shape,
  cancel mid-loop, capability refusal at submit.
- test/e2e/subagent-crash-replay-multi-provider.test.ts: 13 cases. Five
  provider rows (anthropic / openai / google / openrouter / deepseek) ×
  pre-crash run + replay assertion, plus ordinal-collision PK guard,
  pending-tool short-circuit, v1→v2 shim round-trip.

Both files run hermetically against PGLite (no DATABASE_URL needed) and
use the __setChatTransportForTests gateway seam for stubbed provider
responses. Reset path goes through resetPgliteState + setConfig version=84
so MinionQueue.ensureSchema() sees the migration ledger correctly.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-05-22 09:10:20 -07:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 26c54588fe
commit 01024567e3
38 changed files with 4819 additions and 174 deletions
+120
View File
@@ -2,6 +2,126 @@
All notable changes to GBrain will be documented in this file.
## [0.38.1.0] - 2026-05-21
**Your `gbrain agent run` loop can now run on any provider with native tool calling — not just Anthropic.** OpenAI, Google Gemini, OpenRouter, openai-compatible servers (Ollama, LiteLLM, vLLM, llama-server) all work. Pick the cheapest model that does the job for your agent, or stay on Anthropic if you want the prompt-cache cost savings on long loops.
The pre-v0.38 subagent loop was Anthropic-direct: `new Anthropic()` instantiated inside the worker, the loop hard-pinned three layers deep because crash-replay reconciliation needed Anthropic's stable `tool_use_id`s. That pin is gone. The replay key is now gbrain-owned (uuid v7 + per-turn ordinal, persisted at first observation of each tool call) — the provider can return whatever id shape it wants and crash-replay still reconciles.
How to turn it on:
```bash
gbrain config set agent.use_gateway_loop true
gbrain config set models.tier.subagent openai:gpt-5.2 # or anthropic:claude-sonnet-4-6, google:gemini-1.5-pro, etc.
gbrain agent run "research acme corp" --tools search,query --follow
```
The legacy Anthropic-direct path stays the default for one patch release so existing brains ship the same behavior on upgrade. Dogfood the new path locally, then flip the flag in the next release.
What you'd see when picking providers:
| Provider | Tool calls | Prompt caching | Notes |
|---|---|---|---|
| anthropic:claude-* | Yes | Yes | Cheapest on long loops thanks to cache; default |
| openai:gpt-5.2 | Yes | No (implicit only) | Runs hot — cost scales linearly with conversation length |
| google:gemini-1.5-pro | Yes | No | 1M-token context; good for big-context agents |
| openrouter:* | Yes | Depends on underlying | The cost-arbitrage path |
| openai-compatible (Ollama, LiteLLM, vLLM, llama-server) | If the model supports tools | No | Refused-at-submit when the model lacks tool calling |
| voyage, zeroentropy | No chat touchpoint | n/a | Embeddings only — refused with a clear hint |
`gbrain doctor` warns when your subagent tier resolves to a degraded provider (no prompt caching = higher cost) and refuses to dispatch when the provider doesn't support tool calling at all. The check is `subagent_capability` (was `subagent_provider`).
**The remote MCP boundary also opens up — Cursor, Claude Code, ChatGPT can now launch gbrain agents over MCP**, not just observe them. The new op is `submit_agent`. Trust is bounded at OAuth client registration time: which tools the agent can call, which source/brain it can touch, which slug-prefixes it can write under, max concurrent jobs, and a per-client daily USD cap that uses a reserve-then-settle budget meter (the rate-leases.ts pattern over `pg_advisory_xact_lock`) so two concurrent agents from the same client can't both pre-flight at the cap boundary and bust it.
To register a remote agent client (requires server-side gbrain v0.38+):
```bash
gbrain auth register-client cursor-agent \
--scopes read,agent \
--bound-tools search,get_page,put_page \
--bound-source default \
--bound-slug-prefixes wiki/ \
--bound-max-concurrent 3 \
--budget-usd-per-day 5.00
```
`gbrain` writes a JSONL audit row at `~/.gbrain/audit/agent-jobs-YYYY-Www.jsonl` per submission with (client, model, tools, slug_prefixes, max_concurrent, budget_remaining_cents, outcome). Prompt text itself never lands in the audit — only its byte count.
Things to watch after upgrading:
- `gbrain doctor` may warn `subagent_capability` if your `chat_model` is non-Anthropic and `ANTHROPIC_API_KEY` is unset and you haven't flipped `agent.use_gateway_loop=true` yet. The default still falls through the Anthropic-direct path; the warn surfaces this drift.
- Existing `admin` OAuth clients do NOT automatically get the new `agent` scope. The two scopes are siblings (D13). Re-register with `--scopes admin,agent` and explicit bindings to opt in.
- Mid-flight binary upgrade: jobs that were running with v0.37-shaped content_blocks reconcile via a read-time shim that recomputes the stable key from `(job_id, message_idx, content_blocks index, tool_name)`. No data migration; legacy rows replay correctly under the new key.
- Migrations v82-v85 land on first `gbrain doctor` post-upgrade. (v81 was claimed by v0.38.0.0's `pages_provenance_columns`; v0.38.1.0's stable-ID + reservation + binding migrations renumbered up by one.)
### What we built and how it slots together
Four atomic slices behind feature flags, each shipping independently before the next:
- **Slice 1** — gateway-native tool loop + stable IDs + v1→v2 shim. Pin removal at queue.ts, model-config rename, doctor check rename. Behind `agent.use_gateway_loop` (default off in this patch).
- **Slice 2** — budget meter (reserve-then-settle via `pg_advisory_xact_lock`), `mcp_spend_reservations` table, `oauth_clients.budget_usd_per_day`.
- **Slice 3**`submit_agent` MCP op + `agent` OAuth scope + `oauth_clients.bound_*` columns + JSONL audit at `~/.gbrain/audit/agent-jobs-*.jsonl`.
- **Slice 4** — admin `/admin/api/agents/spend` endpoint. Frontend wire-up follows in a near-term patch.
### To take advantage of v0.38.1.0
`gbrain upgrade` should do this automatically. If it didn't, or if `gbrain doctor` warns about a partial migration:
1. **Run the orchestrator manually:**
```bash
gbrain apply-migrations --yes
```
2. **Try the new loop** (optional; off by default):
```bash
gbrain config set agent.use_gateway_loop true
gbrain config set models.tier.subagent openai:gpt-5.2 # or your preferred provider
gbrain agent run "test the new loop" --tools search --follow
```
3. **Verify the schema** is at v85:
```bash
gbrain doctor --json | grep schema_version
```
4. **If any step fails or the numbers look wrong,** please file an issue:
https://github.com/garrytan/gbrain/issues with:
- output of `gbrain doctor`
- contents of `~/.gbrain/upgrade-errors.jsonl` if it exists
- which step broke
### Itemized changes
**Added:**
- `src/core/ai/capabilities.ts` — recipe-driven `getProviderCapabilities()` + `classifyCapabilities()` (5-state verdict: `ok` / `degraded:no_caching` / `degraded:no_parallel` / `unusable:no_tools` / `unknown`).
- `src/core/ai/gateway.ts:toolLoop()` — provider-agnostic loop control wrapping `gateway.chat()`. Stateless beyond optional replay state; testable via existing `__setChatTransportForTests` seam.
- `src/core/minions/budget-meter.ts` — reserve/settle/sweep + FNV-1a `clientLockKey` for `pg_advisory_xact_lock`.
- `src/core/minions/agent-audit.ts` — ISO-week-rotated JSONL audit at `~/.gbrain/audit/agent-jobs-YYYY-Www.jsonl`. Never logs prompt text.
- New MCP op `submit_agent` (scope `agent`, mutating, remote-callable) with per-dispatch binding enforcement.
- `agent` OAuth scope (sibling to admin, NOT implied) + `oauth_clients.bound_tools` / `.bound_source_id` / `.bound_brain_id` / `.bound_slug_prefixes` / `.bound_max_concurrent` / `.budget_usd_per_day` columns.
- `submit_agent` handler-time gateway-loop path inside `src/core/minions/handlers/subagent.ts`: builds `ChatToolDef[]` + `ToolHandler` Map from existing brain-tool registry, persists to v0.38 stable-ID columns at first observation, settles complete/failed on tool exit. D5 read-time shim adapts v1 Anthropic content blocks into v2 ChatBlock-shaped on read so crash-replay reconciles across the upgrade boundary.
- Admin endpoint `GET /admin/api/agents/spend` returning per-client today's spend + pending reservations + inflight count.
**Changed:**
- `src/core/minions/queue.ts:87-106` — dropped `isAnthropicProvider` hard-reject; now refuses only on `unusable:no_tools` / `unknown` verdicts. Degraded providers pass with cost warn at first dispatch.
- `src/core/model-config.ts:enforceSubagentAnthropic``enforceSubagentCapable`. Preserves once-per-(source,model) warn seam from v0.31.12. Legacy name kept as a thin back-compat wrapper.
- `src/commands/doctor.ts:checkSubagentProvider``checkSubagentCapability`. Three verdict states: unusable, unknown, degraded:no_caching (cost regression warn).
- `src/core/scope.ts``agent` added to allowed scopes (size 5 → 6). `IMPLIES` map keeps `agent` as a sibling, NOT implied by admin.
- `src/core/operations.ts:operations``submit_agent` registered as a remote-safe mutating op alongside the existing Minions ops.
**Migrations:**
- v82 (`subagent_tool_executions_stable_id`) — adds `ordinal INTEGER`, `gbrain_tool_use_id UUID`, `UNIQUE(job_id, message_idx, ordinal)`. NULL-tolerant for legacy rows.
- v83 (`mcp_spend_reservations`) — new table for reserve-then-settle pattern. Partial index on `(status, expires_at) WHERE status='pending'`.
- v84 (`oauth_clients_budget_usd_per_day`) — `NUMERIC(10,2) NULL`.
- v85 (`oauth_clients_agent_binding`) — bound_tools / bound_source_id (FK sources) / bound_brain_id / bound_slug_prefixes TEXT[] / bound_max_concurrent INTEGER DEFAULT 1.
**Tests:**
- `test/ai/capabilities.test.ts` (12 cases) — recipe-driven capability matrix across Anthropic / OpenAI / Google / Voyage (chat-touchpoint missing) / malformed input / unknown provider.
- `test/ai/gateway-tool-loop.test.ts` (7 cases) — end stop_reason, single tool dispatch, callback ordering (write-ordering invariant), replay short-circuit on complete, non-idempotent pending replay throws unrecoverable, max_turns cap, refusal short-circuit.
- `test/minions/budget-meter.test.ts` (15 cases) — FNV-1a determinism, reserve under/over cap, settle idempotency, sweep, getClientDailyCapCents.
- `test/minions/agent-audit.test.ts` (7 cases) — ISO-week filename + year-boundary edge, JSONL append, NEVER logs prompt content.
- `test/agent-cli.test.ts` updates — Layer 1/2/3 cases flipped: any tool-supporting provider passes; unknown / embedding-only provider refused.
- `test/scope.test.ts` + `test/oauth.test.ts` + `test/model-config.serial.test.ts` updated for v0.38 semantics.
**Plan:** `~/.claude/plans/system-instruction-you-are-working-shimmying-breeze.md`. Wave went through `/plan-ceo-review` (Option B locked), `/plan-eng-review` (7 decisions D3-D9), and two codex outside-voice rounds (D11-D13 absorbed; round 2 caught blocker on missing Slice 1 stable-ID migration + 4 non-blockers).
## [0.38.0.0] - 2026-05-21
**One command to capture anything into your brain. Local OR hosted, doesn't matter.**
+1 -1
View File
@@ -1 +1 @@
0.38.0.0
0.38.1.0
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -7,7 +7,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<script type="module" crossorigin src="/admin/assets/index-CWq369vO.js"></script>
<script type="module" crossorigin src="/admin/assets/index-DFgMZhBE.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-GxkWX7v3.css">
</head>
<body>
+4 -1
View File
@@ -10,11 +10,14 @@
* or `bun run verify` will reject the change.
*/
export type Scope = 'read' | 'write' | 'admin' | 'sources_admin' | 'users_admin';
export type Scope = 'read' | 'write' | 'admin' | 'sources_admin' | 'users_admin' | 'agent';
// MIRROR OF src/core/scope.ts ALLOWED_SCOPES_LIST — keep alphabetically sorted.
// v0.38: 'agent' added for the submit_agent remote-MCP op (sibling to admin,
// NOT implied — existing admin clients must re-register to opt in).
export const ALLOWED_SCOPES_LIST: ReadonlyArray<Scope> = [
'admin',
'agent',
'read',
'sources_admin',
'users_admin',
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gbrain",
"version": "0.38.0.0",
"version": "0.38.1.0",
"description": "Postgres-native personal knowledge brain with hybrid RAG search",
"type": "module",
"main": "src/core/index.ts",
+3 -3
View File
@@ -1,13 +1,13 @@
// AUTO-GENERATED — do not edit by hand.
// Run `bun run scripts/build-admin-embedded.ts` to regenerate.
// Source: admin/dist/ at 2026-05-19.
// Source: admin/dist/ at 2026-05-22.
//
// Bun resolves the file: imports to a path that works at runtime even
// inside a compiled binary (`bun build --compile`). The manifest maps
// the request path the express handler sees to (resolved-path, mime).
// @ts-ignore — type: 'file' is Bun ESM, not in lib.d.ts
import A_0_assets_index_CWq369vO_js from '../admin/dist/assets/index-CWq369vO.js' with { type: 'file' };
import A_0_assets_index_DFgMZhBE_js from '../admin/dist/assets/index-DFgMZhBE.js' with { type: 'file' };
// @ts-ignore — type: 'file' is Bun ESM, not in lib.d.ts
import A_1_assets_index_GxkWX7v3_css from '../admin/dist/assets/index-GxkWX7v3.css' with { type: 'file' };
// @ts-ignore — type: 'file' is Bun ESM, not in lib.d.ts
@@ -19,7 +19,7 @@ export interface AdminAsset {
}
export const ADMIN_ASSETS: Record<string, AdminAsset> = {
"/admin/assets/index-CWq369vO.js": { path: A_0_assets_index_CWq369vO_js as unknown as string, mime: "application/javascript; charset=utf-8" },
"/admin/assets/index-DFgMZhBE.js": { path: A_0_assets_index_DFgMZhBE_js as unknown as string, mime: "application/javascript; charset=utf-8" },
"/admin/assets/index-GxkWX7v3.css": { path: A_1_assets_index_GxkWX7v3_css as unknown as string, mime: "text/css; charset=utf-8" },
"/admin/index.html": { path: A_2_index_html as unknown as string, mime: "text/html; charset=utf-8" },
};
+75 -40
View File
@@ -567,7 +567,7 @@ export async function doctorReportRemote(engine: BrainEngine): Promise<DoctorRep
// so the user sees it at the next `gbrain doctor` run instead of at the
// next subagent job submission. (Layers 1+2 also enforce — this is the
// surfacing layer.)
checks.push(await checkSubagentProvider(engine));
checks.push(await checkSubagentCapability(engine));
// 6. Sync freshness check
checks.push(await checkSyncFreshness(engine));
@@ -1347,67 +1347,102 @@ export async function checkEvalDrift(engine: BrainEngine): Promise<Check> {
* the loop at runtime. This check makes the configuration drift visible
* before a job is submitted.
*/
async function checkSubagentProvider(engine: BrainEngine): Promise<Check> {
async function checkSubagentCapability(engine: BrainEngine): Promise<Check> {
try {
const { isAnthropicProvider } = await import('../core/model-config.ts');
const { classifyCapabilities } = await import('../core/ai/capabilities.ts');
const tierSubagent = await engine.getConfig('models.tier.subagent');
const modelsDefault = await engine.getConfig('models.default');
// Tier-explicit override loses fail-loud since the user clearly meant it.
if (tierSubagent && !isAnthropicProvider(tierSubagent)) {
return {
name: 'subagent_provider',
status: 'warn',
message:
`models.tier.subagent is "${tierSubagent}" but the subagent loop is Anthropic-only. ` +
`Runtime will fall back to claude-sonnet-4-6. Fix: ` +
`\`gbrain config set models.tier.subagent anthropic:claude-sonnet-4-6\`.`,
};
}
// models.default sneaking subagent into a non-Anthropic provider.
if (!tierSubagent && modelsDefault && !isAnthropicProvider(modelsDefault)) {
return {
name: 'subagent_provider',
status: 'warn',
message:
`models.default is "${modelsDefault}" which would route subagent jobs to a non-Anthropic provider. ` +
`Runtime falls back to claude-sonnet-4-6 for subagent only. ` +
`Fix: \`gbrain config set models.tier.subagent anthropic:claude-sonnet-4-6\` to lock it in.`,
};
}
// Helper: explain a verdict in user-facing terms.
const explain = (resolved: string, source: string): Check | null => {
const verdict = classifyCapabilities(resolved);
if (verdict === 'unusable:no_tools') {
return {
name: 'subagent_capability',
status: 'warn',
message:
`${source} is "${resolved}" but that provider/model lacks native tool calling. ` +
`The subagent loop cannot run on this model — runtime will fall back to claude-sonnet-4-6. ` +
`Fix: \`gbrain config set ${source} <provider>:<model-with-tools>\` (e.g. anthropic:claude-sonnet-4-6 or openai:gpt-5.2).`,
};
}
if (verdict === 'unknown') {
return {
name: 'subagent_capability',
status: 'warn',
message:
`${source} is "${resolved}" which references an unknown provider. ` +
`Use a recipe-declared provider. ` +
`Fix: \`gbrain config set ${source} anthropic:claude-sonnet-4-6\` or pick another known provider.`,
};
}
if (verdict === 'degraded:no_caching') {
return {
name: 'subagent_capability',
status: 'warn',
message:
`${source} is "${resolved}" — provider does not support prompt caching. ` +
`The subagent loop runs hot (cost scales linearly with conversation length). ` +
`For lower cost on long loops, use an Anthropic model: ` +
`\`gbrain config set models.tier.subagent anthropic:claude-sonnet-4-6\`.`,
};
}
return null;
};
// v0.37 (T10 / D7): warn when the configured chat_model is non-Anthropic
// AND ANTHROPIC_API_KEY isn't set. Subagent jobs require Anthropic
// regardless of chat_model (minions/queue.ts:97 enforces this); without
// the key, gbrain dream / gbrain agent run / gbrain autopilot will all
// fail at first job submission. Catches the post-init drift case the
// init-time caveat would have shown if init had been re-run.
if (tierSubagent) {
const issue = explain(tierSubagent, 'models.tier.subagent');
if (issue) return issue;
} else if (modelsDefault) {
const issue = explain(modelsDefault, 'models.default');
if (issue) return issue;
}
// v0.37 (T10 / D7) + v0.38 (D7 capability rename): warn when the configured
// chat_model is non-Anthropic AND ANTHROPIC_API_KEY isn't set. With
// agent.use_gateway_loop=false (the v0.38 default), subagent jobs still
// require Anthropic at runtime; without the key, gbrain dream / gbrain
// agent run / gbrain autopilot will all fail at job submission. Catches
// the post-init drift case the init-time caveat would have shown if init
// had been re-run.
try {
const { loadConfig } = await import('../core/config.ts');
const cfg = loadConfig();
const chatModel = cfg?.chat_model;
const { isAnthropicProvider } = await import('../core/model-config.ts');
if (chatModel && !isAnthropicProvider(chatModel) && !process.env.ANTHROPIC_API_KEY) {
return {
name: 'subagent_provider',
name: 'subagent_capability',
status: 'warn',
message:
`chat_model is "${chatModel}" (non-Anthropic) and ANTHROPIC_API_KEY is not set. ` +
`Subagent features (gbrain dream, gbrain agent run, gbrain autopilot) will fail at job submission. ` +
`Chat alone (gbrain think) still works. Set ANTHROPIC_API_KEY before running subagent commands.`,
`Subagent features (gbrain dream, gbrain agent run, gbrain autopilot) will fail at job submission ` +
`unless agent.use_gateway_loop=true. Chat alone (gbrain think) still works. ` +
`Either set ANTHROPIC_API_KEY or enable: \`gbrain config set agent.use_gateway_loop true\`.`,
};
}
} catch { /* loadConfig may throw; fall through */ }
return { name: 'subagent_provider', status: 'ok', message: 'Subagent tier resolves to Anthropic' };
return {
name: 'subagent_capability',
status: 'ok',
message: tierSubagent
? `Subagent tier resolves to "${tierSubagent}" with full tool-loop capability`
: `Subagent tier resolves to default (claude-sonnet-4-6) — full tool-loop capability`,
};
} catch (e) {
return {
name: 'subagent_provider',
name: 'subagent_capability',
status: 'warn',
message: `Could not check subagent provider: ${e instanceof Error ? e.message : String(e)}`,
message: `Could not check subagent capability: ${e instanceof Error ? e.message : String(e)}`,
};
}
}
// v0.38 — `checkSubagentProvider` was renamed to `checkSubagentCapability` (D7).
// Back-compat alias preserved for any external doctor extensions importing it.
const checkSubagentProvider = checkSubagentCapability;
void checkSubagentProvider;
// Module-scoped flag so the NaN-fallback warning fires once per process.
let _syncFreshnessEnvWarned = false;
@@ -3595,13 +3630,13 @@ export async function runDoctor(engine: BrainEngine | null, args: string[], dbSo
}
}
// 11.4 subagent_provider (v0.31.12 — Codex F13 layer 3 of 3). Surfaces a
// 11.4 subagent_capability (v0.38 — D7; was subagent_provider in v0.31.12). Surfaces a
// warn when models.tier.subagent or models.default points at a non-Anthropic
// provider. Layers 1 (queue.ts submit-time) and 2 (handler runtime) also
// enforce; this is the surfacing layer so users see the config drift before
// a job is submitted.
progress.heartbeat('subagent_provider');
checks.push(await checkSubagentProvider(engine));
progress.heartbeat('subagent_capability');
checks.push(await checkSubagentCapability(engine));
// 11.5 facts_health (v0.31 hot memory). Surfaces per-source counters so
// operators can see the extraction pipeline's pulse without raw SQL.
+89
View File
@@ -232,6 +232,79 @@ interface ServeHttpOptions {
suppressBootstrapToken?: boolean;
}
/**
* v0.38 Slice 4 — per-OAuth-client agent spend snapshot. Exported so the
* admin endpoint and `test/admin-agents-spend.test.ts` share the same SQL
* (single source of truth for the spend query shape).
*
* Returns one row per OAuth client that EITHER has the `agent` scope OR
* has at least one `bound_*` column set (the legacy admin client could
* also have bindings without scope='agent' on a partially-migrated brain;
* we want it visible in the viewer).
*
* Fields:
* - client_id, client_name
* - cap_usd_per_day: number | null (daily budget cap; NULL = no cap)
* - spent_cents_today: number (sum from mcp_spend_log, UTC-day-aligned)
* - pending_cents: number (sum of in-flight reservations, non-expired)
* - inflight_count: number (active subagent jobs owned by this client)
*
* Falls back to `[]` on any SQL error (pre-v0.38 brains where the v82-v84
* tables/columns don't yet exist).
*/
export interface AgentClientSpend {
client_id: string;
client_name: string;
cap_usd_per_day: number | null;
spent_cents_today: number;
pending_cents: number;
inflight_count: number;
}
export async function queryAgentClientSpend(engine: BrainEngine): Promise<AgentClientSpend[]> {
const sql = sqlQueryForEngine(engine);
const rows = await sql`
SELECT
c.client_id,
c.client_name,
COALESCE(c.budget_usd_per_day, NULL) AS cap_usd_per_day,
COALESCE((
SELECT SUM(spend_cents)::text
FROM mcp_spend_log
WHERE client_id = c.client_id
AND created_at >= date_trunc('day', now() AT TIME ZONE 'UTC')
), '0') AS spent_cents_today,
COALESCE((
SELECT SUM(estimated_cents)::text
FROM mcp_spend_reservations
WHERE client_id = c.client_id
AND status = 'pending'
AND expires_at > now()
), '0') AS pending_cents,
COALESCE((
SELECT COUNT(*)::int
FROM minion_jobs
WHERE name = 'subagent'
AND status IN ('waiting', 'active', 'waiting-children')
AND data->>'__owner_client_id' = c.client_id
), 0) AS inflight_count
FROM oauth_clients c
WHERE c.deleted_at IS NULL
AND ('agent' = ANY (string_to_array(c.scope, ' ')) OR c.bound_tools IS NOT NULL)
ORDER BY c.client_name ASC
`;
return rows.map(r => ({
client_id: String(r.client_id),
client_name: String(r.client_name ?? r.client_id),
cap_usd_per_day: r.cap_usd_per_day !== null && r.cap_usd_per_day !== undefined
? parseFloat(String(r.cap_usd_per_day))
: null,
spent_cents_today: parseFloat(String(r.spent_cents_today ?? '0')),
pending_cents: parseFloat(String(r.pending_cents ?? '0')),
inflight_count: Number(r.inflight_count ?? 0),
}));
}
export async function runServeHttp(engine: BrainEngine, options: ServeHttpOptions) {
const { port, tokenTtl, enableDcr, publicUrl, logFullParams } = options;
// v0.34.1 (#864, D11): default bind flipped from 0.0.0.0 to 127.0.0.1.
@@ -719,6 +792,22 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
}
});
// v0.38 Slice 4 — per-OAuth-client agent spend viewer. Pre-computes today's
// spend (committed + pending reservations) per client so the Agents tab
// can render a "$X / $Y today" cell. Read-side endpoint only — no mutation.
// Falls back to an empty array on pre-v0.38 brains where mcp_spend_log
// exists but agent dispatch hasn't recorded anything.
app.get('/admin/api/agents/spend', requireAdmin, async (_req: Request, res: Response) => {
try {
const rows = await queryAgentClientSpend(engine);
res.json(rows);
} catch (e) {
// Pre-v0.38 brains: tables may not exist yet. Return empty so the UI
// renders gracefully instead of erroring.
res.json([]);
}
});
app.get('/admin/api/stats', requireAdmin, async (_req: Request, res: Response) => {
try {
const [clients] = await sql`SELECT count(*)::int as count FROM oauth_clients`;
+148
View File
@@ -0,0 +1,148 @@
/**
* Provider capability detection for the gateway-native subagent tool loop.
*
* Pre-v0.38 the subagent loop was Anthropic-direct (`new Anthropic()` instantiated
* in `src/core/minions/handlers/subagent.ts`). The three-layer pin
* (`queue.ts:87-106` + `subagent.ts:149-167` + `doctor.ts:1190-1225` enforced
* Anthropic-only because crash-replay relied on Anthropic's stable `tool_use_id`s
* for reconciliation. v0.38 (D11) moves the stable-ID generation gbrain-side
* (ordinal + uuid v7 persisted in `subagent_tool_executions` at first observation),
* which decouples the loop from any specific provider's response format.
*
* This module reads capabilities from the recipe (`src/core/ai/recipes/*.ts`)
* and surfaces them via a normalized `ProviderCapabilities` shape that the
* gateway's `toolLoop()` consumes to decide:
* - REFUSE at submit when tool-calling is unsupported (D6 — useless loop)
* - WARN at submit when prompt caching is unavailable (D6 — cost regression)
* - INFO at submit when parallel tools unsupported (D6 — just slower)
*
* The capability shape is intentionally narrow. Per-call cost is already in
* `ChatTouchpoint.cost_per_1m_*`; we don't re-export it here because routing
* decisions don't depend on it.
*/
import { resolveRecipe } from './model-resolver.ts';
import { AIConfigError } from './errors.ts';
export interface ProviderCapabilities {
/** Provider returns native function/tool calling. Required for the subagent loop. */
supportsToolCalling: boolean;
/**
* Anthropic-style ephemeral prompt cache markers honored. When false, the
* loop runs hot (no cache_control injection) and per-turn costs scale
* linearly with conversation length. Doesn't break the loop; just costs more.
*/
supportsPromptCaching: boolean;
/**
* Provider can return multiple `tool_use` blocks in a single assistant turn
* and accepts a single follow-up `user` message with matching `tool_result`
* blocks. When false, the loop falls back to serial tool dispatch (one tool
* per turn), which matches the v0.15 default and is a perf hit, not a
* correctness issue.
*
* NOTE: this currently reads from `recipe.touchpoints.chat.supports_tools`
* because no recipe exposes a separate parallel-tools field today. Treat as
* "best-effort capability hint" — when the gateway tool loop lands in v0.38
* Slice 4 it will add parallel dispatch with a per-recipe gate.
*/
supportsParallelTools: boolean;
/**
* Provider supports an extended-thinking / reasoning block in responses.
* Not load-bearing for the loop; surfaced so callers (e.g. `gbrain agent run`)
* can decide whether to surface the reasoning trace in `--follow` output.
*/
supportsThinking: boolean;
/**
* Max input+output tokens the provider/model accepts per turn. Drives the
* gateway's pre-flight context check; the loop refuses to send a prompt
* that exceeds this (with a paste-ready truncation hint).
*/
maxContext: number;
}
/**
* Resolve a `provider:model` string and return its capability set.
*
* Throws `AIConfigError` when the provider/model is unknown OR when the
* provider lacks a `chat` touchpoint (e.g., embedding-only providers like
* Voyage). Callers that want a soft check can wrap in try/catch and degrade.
*/
export function getProviderCapabilities(modelString: string): ProviderCapabilities {
const { recipe, parsed } = resolveRecipe(modelString);
const chat = recipe.touchpoints.chat;
if (!chat) {
throw new AIConfigError(
`Provider "${recipe.id}" does not offer a chat touchpoint.`,
`Known providers with chat: openai, anthropic, google, openrouter, litellm-proxy, deepseek, groq, together, azure-openai, dashscope, minimax, zhipu, ollama, llama-server. Pick one for models.tier.subagent.`,
);
}
// For native providers, the model must be in the recipe's allow-list. For
// openai-compatible recipes (litellm, ollama, llama-server), arbitrary model
// ids are accepted because the gateway behind the proxy decides what's real.
// We don't error here — `assertTouchpoint` already enforces this at gateway
// boundary; this function returns capabilities for whatever the user asked
// for, on the assumption it'll be validated elsewhere.
return {
supportsToolCalling: chat.supports_tools === true,
supportsPromptCaching: chat.supports_prompt_cache === true,
// No recipe exposes parallel-tools-specifically yet; gate on supports_tools.
// Subsequent waves can split this into its own recipe field if a provider
// ever supports tools without parallel dispatch.
supportsParallelTools: chat.supports_tools === true,
// Not exposed by ChatTouchpoint today — defaults to false. Recipes can add
// a `supports_thinking` field later without breaking this helper (it'll
// just keep returning false until a recipe sets it).
supportsThinking: false,
maxContext: chat.max_context_tokens ?? 128_000,
};
// The `parsed` binding is intentionally unused — `resolveRecipe` is called
// here for its validation side-effects (throws on unknown provider). Keeping
// the destructure makes future per-model capability overrides cheap.
void parsed;
}
/**
* Tier-1 gate consumed by `enforceSubagentCapable()` in src/core/model-config.ts
* (D6 + D7). Returns:
*
* - `'ok'` — provider has tool-calling, prompt caching, and parallel tools.
* Loop runs at full speed.
* - `'degraded:no_caching'` — provider supports tools but lacks prompt
* caching. Loop runs but per-turn cost is higher. Warn once per
* (source, model) pair.
* - `'degraded:no_parallel'` — provider supports tools and caching but the
* loop will dispatch serially. Info-log; no warn.
* - `'unusable:no_tools'` — provider lacks tool calling entirely. Refuse at
* submit; the loop has no way to execute brain ops.
* - `'unknown'` — the provider/model isn't in any recipe. Refuse at submit
* (defensive: don't spend money on an unrecognized provider).
*
* Pure function; no side effects. The caller decides what to do with each
* verdict (warn / info / throw) based on its surface.
*/
export type CapabilityVerdict =
| 'ok'
| 'degraded:no_caching'
| 'degraded:no_parallel'
| 'unusable:no_tools'
| 'unknown';
export function classifyCapabilities(modelString: string): CapabilityVerdict {
let caps: ProviderCapabilities;
try {
caps = getProviderCapabilities(modelString);
} catch {
return 'unknown';
}
if (!caps.supportsToolCalling) return 'unusable:no_tools';
if (!caps.supportsPromptCaching) return 'degraded:no_caching';
if (!caps.supportsParallelTools) return 'degraded:no_parallel';
return 'ok';
}
+304
View File
@@ -2175,6 +2175,310 @@ export async function chat(opts: ChatOpts): Promise<ChatResult> {
}
}
// ---- Tool loop (v0.38 — D11 + D6/D7 gateway-native subagent path) ----
/**
* A tool handler runs a single tool invocation. `idempotent` lets the loop
* safely re-execute a pending row on crash-replay; non-idempotent tools that
* crashed mid-execute are surfaced as a hard error.
*/
export interface ToolHandler {
idempotent?: boolean;
execute(input: unknown, signal: AbortSignal): Promise<unknown>;
}
/**
* State the caller carries in from a prior crashed run. The reconciler keys
* by gbrain-owned `gbrainToolUseId` (D11), NOT provider-supplied IDs.
* `priorMessages` is the chat history up to the assistant's last turn;
* `priorTools` maps gbrainToolUseId → outcome. The D5 read-time shim
* synthesizes gbrainToolUseIds for legacy v1 rows so this Map sees both
* shapes uniformly.
*/
export interface ToolLoopReplayState {
priorMessages: ChatMessage[];
priorTools: Map<string, { status: 'pending' | 'complete' | 'failed'; output?: unknown; error?: string }>;
nextTurnIdx: number;
nextMessageIdx: number;
}
export interface ToolLoopOpts {
/** "provider:modelId" — defaults to config.chat_model. */
model?: string;
/** System prompt (provider-neutral). Cached when caching supported + cacheSystem true. */
system?: string;
/**
* Initial user message(s). When `replayState` is set, these are prepended only
* if `replayState.priorMessages` is empty — typically empty on a fresh call,
* non-empty on a fresh-from-scratch run.
*/
initialMessages: ChatMessage[];
/** Tool definitions (provider-neutral JSON Schema). */
tools: ChatToolDef[];
/** Implementations keyed by tool name. */
toolHandlers: Map<string, ToolHandler>;
/** Hard cap on loop iterations. Default 20. */
maxTurns?: number;
/** Per-turn max output tokens. Default 4096. */
maxTokens?: number;
abortSignal?: AbortSignal;
/** Apply Anthropic cache_control to system + last tool. Silently ignored elsewhere. */
cacheSystem?: boolean;
/** Crash-replay state. When set, the loop resumes from the recorded position. */
replayState?: ToolLoopReplayState;
/**
* D11 + write-ordering invariant callbacks. Fire BEFORE side effects so a
* crash mid-execute is reconcilable on the next replay.
*
* Ordering per turn:
* 1. onAssistantTurn — assistant message persisted (D11 step 1)
* 2. onToolCallStart — pending row persisted (D11 step 2)
* 3. handler.execute — side effect
* 4. onToolCallComplete / onToolCallFailed (D11 step 4)
*/
onAssistantTurn?: (turnIdx: number, messageIdx: number, blocks: ChatBlock[], usage: ChatResult['usage'], model: string) => Promise<void>;
/**
* Persist a pending tool execution. The caller assigns ordinal + uuid v7 and
* returns them so the loop can key replay by gbrainToolUseId. The provider
* supplies its own `providerToolCallId` (kept as a debug-only side channel).
*/
onToolCallStart?: (
turnIdx: number,
messageIdx: number,
ordinal: number,
toolName: string,
input: unknown,
providerToolCallId: string,
) => Promise<{ gbrainToolUseId: string }>;
onToolCallComplete?: (gbrainToolUseId: string, output: unknown) => Promise<void>;
onToolCallFailed?: (gbrainToolUseId: string, error: string) => Promise<void>;
/** Optional per-call heartbeat for observability. */
onHeartbeat?: (event: string, data: Record<string, unknown>) => void;
}
export type ToolLoopStopReason = 'end' | 'max_turns' | 'refusal' | 'content_filter' | 'aborted' | 'unrecoverable';
export interface ToolLoopResult {
finalText: string;
totalTurns: number;
totalUsage: ChatResult['usage'];
stopReason: ToolLoopStopReason;
/** Final messages array including all assistant + tool results. Caller persists if desired. */
messages: ChatMessage[];
}
/**
* Provider-agnostic tool-calling loop. Wraps `gateway.chat()` with:
* - assistant→tool-dispatch→tool-result cycle
* - gbrain-stable IDs (D11) at first observation
* - write-ordering invariant (persist before side effect)
* - crash-replay reconciliation via gbrainToolUseId
* - capability-driven cache_control (Anthropic only)
*
* This replaces the direct `new Anthropic()` + `client.create()` path in
* `src/core/minions/handlers/subagent.ts`. The provider abstraction lives in
* `gateway.chat()` (Vercel AI SDK); this function is just the loop control.
*
* Designed so the caller (subagent handler) supplies persistence callbacks —
* the loop itself is stateless beyond `replayState`. That keeps it testable
* via `__setChatTransportForTests` without any DB.
*/
export async function toolLoop(opts: ToolLoopOpts): Promise<ToolLoopResult> {
const maxTurns = opts.maxTurns ?? 20;
const maxTokens = opts.maxTokens ?? 4096;
const handlers = opts.toolHandlers;
const totalUsage: ChatResult['usage'] = {
input_tokens: 0,
output_tokens: 0,
cache_read_tokens: 0,
cache_creation_tokens: 0,
};
// Seed messages: prior history (replay) or initial.
const messages: ChatMessage[] = opts.replayState
? [...opts.replayState.priorMessages]
: [...opts.initialMessages];
if (opts.replayState && opts.replayState.priorMessages.length === 0) {
messages.push(...opts.initialMessages);
}
let turnIdx = opts.replayState?.nextTurnIdx ?? 0;
let messageIdx = opts.replayState?.nextMessageIdx ?? 0;
let finalText = '';
let stopReason: ToolLoopStopReason = 'end';
while (turnIdx < maxTurns) {
if (opts.abortSignal?.aborted) {
stopReason = 'aborted';
break;
}
opts.onHeartbeat?.('turn_start', { turn_idx: turnIdx });
let chatResult: ChatResult;
try {
chatResult = await chat({
model: opts.model,
system: opts.system,
messages,
tools: opts.tools,
maxTokens,
abortSignal: opts.abortSignal,
cacheSystem: opts.cacheSystem,
});
} catch (err) {
opts.onHeartbeat?.('llm_call_failed', {
turn_idx: turnIdx,
error: err instanceof Error ? err.message : String(err),
});
throw err;
}
totalUsage.input_tokens += chatResult.usage.input_tokens;
totalUsage.output_tokens += chatResult.usage.output_tokens;
totalUsage.cache_read_tokens += chatResult.usage.cache_read_tokens;
totalUsage.cache_creation_tokens += chatResult.usage.cache_creation_tokens;
// D11 step 1: persist assistant turn BEFORE any tool dispatch.
const assistantMessageIdx = messageIdx++;
await opts.onAssistantTurn?.(turnIdx, assistantMessageIdx, chatResult.blocks, chatResult.usage, chatResult.model);
messages.push({ role: 'assistant', content: chatResult.blocks });
// Check stop reason BEFORE tool dispatch. The loop only continues on tool_calls.
if (chatResult.stopReason === 'refusal') {
stopReason = 'refusal';
finalText = chatResult.text;
break;
}
if (chatResult.stopReason === 'content_filter') {
stopReason = 'content_filter';
finalText = chatResult.text;
break;
}
const toolCalls = chatResult.blocks.filter(
(b): b is { type: 'tool-call'; toolCallId: string; toolName: string; input: unknown } =>
b.type === 'tool-call',
);
if (toolCalls.length === 0) {
stopReason = 'end';
finalText = chatResult.text;
break;
}
// D11 + write-ordering invariant: persist pending → execute → settle.
const toolResultBlocks: ChatBlock[] = [];
for (let callIdx = 0; callIdx < toolCalls.length; callIdx++) {
const call = toolCalls[callIdx];
if (opts.abortSignal?.aborted) {
stopReason = 'aborted';
break;
}
const handler = handlers.get(call.toolName);
if (!handler) {
// Tool not registered. Synthesize an error result; don't persist.
toolResultBlocks.push({
type: 'tool-result',
toolCallId: call.toolCallId,
toolName: call.toolName,
output: `tool "${call.toolName}" is not in the registry for this subagent`,
isError: true,
});
opts.onHeartbeat?.('tool_failed', { turn_idx: turnIdx, tool_name: call.toolName, error: 'not_registered' });
continue;
}
// Step 2: persist pending row + claim gbrainToolUseId. The caller's
// callback handles uniqueness contention via ON CONFLICT DO NOTHING +
// re-read pattern (see persistToolExecPending in subagent.ts).
const { gbrainToolUseId } = (await opts.onToolCallStart?.(
turnIdx,
assistantMessageIdx,
callIdx,
call.toolName,
call.input,
call.toolCallId,
)) ?? { gbrainToolUseId: `inline-${turnIdx}-${callIdx}` };
// Replay short-circuit: prior outcome wins, idempotent re-execute allowed.
const prior = opts.replayState?.priorTools.get(gbrainToolUseId);
if (prior?.status === 'complete') {
toolResultBlocks.push({
type: 'tool-result',
toolCallId: call.toolCallId,
toolName: call.toolName,
output: prior.output,
});
opts.onHeartbeat?.('tool_replay_complete', { turn_idx: turnIdx, tool_name: call.toolName });
continue;
}
if (prior?.status === 'failed') {
toolResultBlocks.push({
type: 'tool-result',
toolCallId: call.toolCallId,
toolName: call.toolName,
output: prior.error ?? 'tool failed',
isError: true,
});
opts.onHeartbeat?.('tool_replay_failed', { turn_idx: turnIdx, tool_name: call.toolName });
continue;
}
if (prior?.status === 'pending' && !handler.idempotent) {
// Non-idempotent crash-mid-execute. Surface as unrecoverable.
stopReason = 'unrecoverable';
throw new Error(
`non-idempotent tool "${call.toolName}" pending on resume; gbrainToolUseId=${gbrainToolUseId} — cannot safely re-run`,
);
}
// Step 3: execute (side effect).
opts.onHeartbeat?.('tool_called', { turn_idx: turnIdx, tool_name: call.toolName });
try {
const output = await handler.execute(call.input, opts.abortSignal ?? new AbortController().signal);
// Step 4: settle complete.
await opts.onToolCallComplete?.(gbrainToolUseId, output);
toolResultBlocks.push({
type: 'tool-result',
toolCallId: call.toolCallId,
toolName: call.toolName,
output,
});
opts.onHeartbeat?.('tool_result', { turn_idx: turnIdx, tool_name: call.toolName });
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
await opts.onToolCallFailed?.(gbrainToolUseId, errMsg);
toolResultBlocks.push({
type: 'tool-result',
toolCallId: call.toolCallId,
toolName: call.toolName,
output: errMsg,
isError: true,
});
opts.onHeartbeat?.('tool_failed', { turn_idx: turnIdx, tool_name: call.toolName, error: errMsg });
}
}
if (stopReason === 'aborted') break;
// Feed all tool results back as a single user message.
const userMessageIdx = messageIdx++;
void userMessageIdx;
messages.push({ role: 'user', content: toolResultBlocks });
turnIdx++;
}
if (turnIdx >= maxTurns && stopReason === 'end') {
stopReason = 'max_turns';
}
return { finalText, totalTurns: turnIdx, totalUsage, stopReason, messages };
}
// ---- Reranker (v0.35.0.0+) ----
/** Tagged error class for gateway.rerank() failures. `reason` classifies into the
+187 -9
View File
@@ -3792,20 +3792,15 @@ export const MIGRATIONS: Migration[] = [
// ADD COLUMN with NULL default is metadata-only on Postgres 11+ and
// PGLite 17.5 — instant on tables of any size.
//
// No index: provenance queries are admin-surface only (admin SPA
// Sources tab + gbrain doctor ingestion_health). Throwing an index on
// a low-cardinality TEXT column would inflate the brain repo for
// negligible read benefit.
// No index: provenance queries are admin-surface only.
//
// Forward-reference bootstrap: every brain that upgrades through this
// version needs the columns visible to the embedded SCHEMA_SQL replay
// BEFORE migrations run. applyForwardReferenceBootstrap on both
// engines is extended in this commit; the matching entries in
// test/schema-bootstrap-coverage.test.ts REQUIRED_BOOTSTRAP_COVERAGE
// pin the contract.
// engines covers this; REQUIRED_BOOTSTRAP_COVERAGE pins the contract.
//
// Renumbered v80→v81 during master merge: master's v0.37.2.0
// takes_unresolvable_quality hotfix claimed v80 first.
// Renumbered v80→v81 during master merge with v0.37.2.0's
// takes_unresolvable_quality hotfix.
idempotent: true,
sql: `
ALTER TABLE pages ADD COLUMN IF NOT EXISTS ingested_via TEXT NULL;
@@ -3814,6 +3809,189 @@ export const MIGRATIONS: Migration[] = [
ALTER TABLE pages ADD COLUMN IF NOT EXISTS source_kind TEXT NULL;
`,
},
{
version: 82,
name: 'subagent_tool_executions_stable_id',
// v0.38 Slice 1 — D11 stable-ID columns. The pre-v81 reconciliation key
// for crash-replay was `(job_id, tool_use_id)` where tool_use_id came from
// the Anthropic Messages API. That was load-bearing for the v0.15 crash-
// replay guarantee but tied the loop to Anthropic-direct. The provider-
// agnostic loop assigns its own ordinal at FIRST observation of a tool
// call in a turn; that ordinal + gbrain_tool_use_id (uuid v7) become the
// canonical reconciliation key. The provider's own tool_use_id stays as a
// side channel for debugging.
//
// Migration shape:
// - ordinal INTEGER NULL: legacy rows have NULL; new writes always set
// a value. UNIQUE on (job_id, message_idx, ordinal) treats multiple
// NULL ordinals as distinct (Postgres semantics), so legacy rows
// don't collide with each other or with new writes.
// - gbrain_tool_use_id UUID NULL: same NULL semantics; populated at
// write time post-Slice-1, NULL on legacy rows.
// - Existing constraint uniq_subagent_tools_use_id (job_id, tool_use_id)
// is preserved — provider IDs stay deduplicated within a job.
// - The read-time D5 shim recomputes a stable key for legacy rows from
// (job_id, message_idx, content_blocks index, tool_name); no data
// migration needed.
//
// ADD COLUMN with no DEFAULT (NULL) is metadata-only on Postgres 11+ and
// PGLite 17.5 — instant on tables of any size. The UNIQUE constraint
// builds a partial index; on a typical brain with ~hundreds of subagent
// rows this is sub-millisecond.
idempotent: true,
sql: `
ALTER TABLE subagent_tool_executions
ADD COLUMN IF NOT EXISTS ordinal INTEGER,
ADD COLUMN IF NOT EXISTS gbrain_tool_use_id UUID;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'subagent_tool_executions_stable_id'
) THEN
ALTER TABLE subagent_tool_executions
ADD CONSTRAINT subagent_tool_executions_stable_id
UNIQUE (job_id, message_idx, ordinal);
END IF;
END$$;
`,
sqlFor: {
// PGLite doesn't support DO blocks; pglite-schema.ts CREATE TABLE
// already includes the constraint on fresh installs, so this migration
// is a no-op when the constraint exists. DROP-IF-EXISTS + ADD pattern
// is idempotent and rebuilds the UNIQUE index (instant on small tables).
pglite: `
ALTER TABLE subagent_tool_executions
ADD COLUMN IF NOT EXISTS ordinal INTEGER;
ALTER TABLE subagent_tool_executions
ADD COLUMN IF NOT EXISTS gbrain_tool_use_id UUID;
ALTER TABLE subagent_tool_executions
DROP CONSTRAINT IF EXISTS subagent_tool_executions_stable_id;
ALTER TABLE subagent_tool_executions
ADD CONSTRAINT subagent_tool_executions_stable_id
UNIQUE (job_id, message_idx, ordinal);
`,
},
},
{
version: 83,
name: 'mcp_spend_reservations',
// v0.38 Slice 2 — D3 + codex P2 — reserve-then-settle budget pattern.
//
// The pre-v82 spend-tracking flow was: take the call, sum mcp_spend_log,
// refuse next call when over cap. Race condition: two concurrent agents
// from the same client both pre-flight pass at $2 of $5 cap, both spend,
// both bust the cap. This table holds in-flight reservations under
// pg_advisory_xact_lock(client_id) so the SUM-then-INSERT is atomic.
//
// Lifecycle:
// pending → settled (call returned; actual_cents recorded)
// pending → expired (call crashed; sweeper marks; actual=0)
//
// The TTL sweeper runs on every reserve (cheap when no expired rows).
// Partial index on (status, expires_at) WHERE status='pending' keeps
// the sweeper fast even when the settled history grows large.
idempotent: true,
sql: `
CREATE TABLE IF NOT EXISTS mcp_spend_reservations (
reservation_id UUID PRIMARY KEY,
client_id TEXT NOT NULL,
job_id BIGINT NULL REFERENCES minion_jobs(id) ON DELETE SET NULL,
estimated_cents NUMERIC(12, 4) NOT NULL,
actual_cents NUMERIC(12, 4) NULL,
model TEXT NOT NULL,
provider TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('pending', 'settled', 'expired')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
settled_at TIMESTAMPTZ NULL,
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_mcp_spend_reservations_client_time
ON mcp_spend_reservations (client_id, created_at);
CREATE INDEX IF NOT EXISTS idx_mcp_spend_reservations_pending_expires
ON mcp_spend_reservations (status, expires_at)
WHERE status = 'pending';
`,
},
{
version: 85,
name: 'oauth_clients_agent_binding',
// v0.38 Slice 3 — D13 + codex P1 + P2 — registration-time binding for
// OAuth clients that hold the `agent` scope. The binding fields are
// each-or-nothing: if the client's allowed_scopes includes 'agent', the
// registrar MUST pass all five binding flags. The submit_agent op
// validates each param against the binding row at dispatch time.
//
// - bound_tools TEXT[]: subset of brain-safe ops this client may
// reference in submit_agent allowed_tools.
// - bound_source_id TEXT: which source (database axis) the agent
// writes to. FK to sources.id with ON DELETE SET NULL so a
// source-delete doesn't dangling-reference the client.
// - bound_brain_id TEXT: which mounted brain (multi-brain installs).
// - bound_slug_prefixes TEXT[]: put_page namespace allowlist.
// - bound_max_concurrent INTEGER: per-client concurrency cap on
// in-flight subagent jobs.
//
// All NULL on pre-v84 clients. The submit_agent op refuses dispatch
// when scope='agent' is present but bindings are NULL — opt-in only.
idempotent: true,
sql: `
ALTER TABLE oauth_clients
ADD COLUMN IF NOT EXISTS bound_tools TEXT[] NULL,
ADD COLUMN IF NOT EXISTS bound_source_id TEXT NULL,
ADD COLUMN IF NOT EXISTS bound_brain_id TEXT NULL,
ADD COLUMN IF NOT EXISTS bound_slug_prefixes TEXT[] NULL,
ADD COLUMN IF NOT EXISTS bound_max_concurrent INTEGER NOT NULL DEFAULT 1;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_oauth_clients_bound_source'
) THEN
BEGIN
ALTER TABLE oauth_clients
ADD CONSTRAINT fk_oauth_clients_bound_source
FOREIGN KEY (bound_source_id)
REFERENCES sources(id) ON DELETE SET NULL;
EXCEPTION WHEN others THEN
-- sources table may not exist on minimal installs; skip silently.
NULL;
END;
END IF;
END$$;
`,
sqlFor: {
pglite: `
ALTER TABLE oauth_clients
ADD COLUMN IF NOT EXISTS bound_tools TEXT[] NULL;
ALTER TABLE oauth_clients
ADD COLUMN IF NOT EXISTS bound_source_id TEXT NULL;
ALTER TABLE oauth_clients
ADD COLUMN IF NOT EXISTS bound_brain_id TEXT NULL;
ALTER TABLE oauth_clients
ADD COLUMN IF NOT EXISTS bound_slug_prefixes TEXT[] NULL;
ALTER TABLE oauth_clients
ADD COLUMN IF NOT EXISTS bound_max_concurrent INTEGER NOT NULL DEFAULT 1;
`,
},
},
{
version: 84,
name: 'oauth_clients_budget_usd_per_day',
// v0.38 Slice 2 — D2 + D3 — per-OAuth-client daily budget cap.
//
// Pre-v84 the daily cap lived in a per-search-image config key. This
// column makes it a first-class property of each OAuth client and lets
// `gbrain auth register-client --budget-usd-per-day N` persist the cap
// alongside scope. NULL = no cap (current pre-v84 behavior).
//
// Column-only; metadata-only on Postgres 11+ and PGLite 17.5.
idempotent: true,
sql: `
ALTER TABLE oauth_clients
ADD COLUMN IF NOT EXISTS budget_usd_per_day NUMERIC(10, 2) NULL;
`,
},
];
export const LATEST_VERSION = MIGRATIONS.length > 0
+123
View File
@@ -0,0 +1,123 @@
/**
* v0.38 Slice 3 — D4 — JSONL audit for `submit_agent` MCP op.
*
* Mirrors `shell-audit.ts`: weekly-rotated JSONL at
* `~/.gbrain/audit/agent-jobs-YYYY-Www.jsonl` (override via
* `GBRAIN_AUDIT_DIR`). Best-effort writes — disk-full / permission errors
* go to stderr and do not block dispatch.
*
* What we log (every submit_agent call):
* - ts: ISO 8601 UTC
* - client_id: the OAuth client that submitted
* - model: the resolved provider:model string
* - bound_tools: the (filtered) tools the agent could call
* - bound_source: the bound source_id from the client's registration
* - slug_prefixes: the put_page namespace allowlist
* - max_concurrent: cap from the binding row
* - budget_remaining_cents: pre-call remaining headroom (informational)
* - prompt_summary: redacted via summarizeMcpParams (declared-keys only +
* approximate byte count) — the prompt text itself never lands in the
* audit trail.
* - outcome: 'submitted' on the initial row; subsequent rows can be
* written by the worker on terminal states (deferred to follow-up).
*
* Privacy: the prompt is the most sensitive field and is hashed via
* `summarizeMcpParams`. tools + slug_prefixes are user-declared identifiers,
* not content, so they're fine to log verbatim. Audit file mode is the
* process umask (typically 644) — operators should treat the audit dir
* the same way they treat ~/.gbrain (private).
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { gbrainPath } from '../config.ts';
export interface AgentAuditEvent {
ts: string;
client_id: string;
job_id: number;
model: string;
bound_tools: string[];
bound_source: string | null;
slug_prefixes: string[];
max_concurrent: number;
budget_remaining_cents: number | null;
prompt_byte_count: number;
outcome: 'submitted' | 'completed' | 'failed' | 'budget_exceeded' | 'aborted';
final_spend_cents?: number;
error_summary?: string;
}
/** Compute `agent-jobs-YYYY-Www.jsonl` using ISO-8601 week numbering. */
export function computeAuditFilename(now: Date = new Date()): string {
const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
const dayNum = (d.getUTCDay() + 6) % 7; // Mon=0, Sun=6
d.setUTCDate(d.getUTCDate() - dayNum + 3); // shift to Thursday
const isoYear = d.getUTCFullYear();
const firstThursday = new Date(Date.UTC(isoYear, 0, 4));
const firstThursdayDayNum = (firstThursday.getUTCDay() + 6) % 7;
firstThursday.setUTCDate(firstThursday.getUTCDate() - firstThursdayDayNum + 3);
const weekNum = Math.round((d.getTime() - firstThursday.getTime()) / (7 * 86400000)) + 1;
const ww = String(weekNum).padStart(2, '0');
return `agent-jobs-${isoYear}-W${ww}.jsonl`;
}
function resolveAuditDir(): string {
const env = process.env.GBRAIN_AUDIT_DIR;
if (env && env.trim()) return env.trim();
return gbrainPath('audit');
}
/** Append one event. Best-effort; logs to stderr on failure. */
export function logAgentSubmission(event: Omit<AgentAuditEvent, 'ts'>): void {
const fullEvent: AgentAuditEvent = {
ts: new Date().toISOString(),
...event,
};
try {
const dir = resolveAuditDir();
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, computeAuditFilename());
fs.appendFileSync(file, JSON.stringify(fullEvent) + '\n', 'utf8');
} catch (err) {
process.stderr.write(
`[agent-audit] failed to write submission event for job ${event.job_id}: ` +
`${err instanceof Error ? err.message : String(err)}\n`,
);
}
}
/** Read recent events across files (for `gbrain doctor` integrations + tests). */
export function readRecentAgentEvents(days: number, now: Date = new Date()): AgentAuditEvent[] {
const dir = resolveAuditDir();
if (!fs.existsSync(dir)) return [];
const cutoff = new Date(now.getTime() - days * 86400000);
const events: AgentAuditEvent[] = [];
try {
const files = fs.readdirSync(dir).filter(f => f.startsWith('agent-jobs-') && f.endsWith('.jsonl'));
for (const f of files) {
const filePath = path.join(dir, f);
const stat = fs.statSync(filePath);
// Skip files older than (cutoff - 14 days) — gives a buffer for the ISO week boundary.
if (stat.mtime < new Date(cutoff.getTime() - 14 * 86400000)) continue;
const content = fs.readFileSync(filePath, 'utf8');
for (const line of content.split('\n')) {
if (!line.trim()) continue;
try {
const ev = JSON.parse(line) as AgentAuditEvent;
const evDate = new Date(ev.ts);
if (evDate >= cutoff) events.push(ev);
} catch {
// Skip malformed lines.
}
}
}
} catch (err) {
process.stderr.write(
`[agent-audit] failed to read recent events: ${err instanceof Error ? err.message : String(err)}\n`,
);
}
// Newest first.
events.sort((a, b) => b.ts.localeCompare(a.ts));
return events;
}
+229
View File
@@ -0,0 +1,229 @@
/**
* v0.38 Slice 2 — budget meter for the subagent tool loop.
*
* Reserve-then-settle pattern (D3) prevents the "concurrent agents bust the
* cap" race that the pre-v82 best-effort post-call recording allowed. Two
* agents from the same OAuth client both pre-flight pass at $2 of $5,
* both spend $2, total spend = $4 of $5 → fine. But raise the per-agent
* estimate to $3 and both agents see "$5 cap - $2 spent = $3 headroom, ok"
* and both proceed, total spend = $8. That's the bug. The fix is atomic
* check-and-reserve under pg_advisory_xact_lock.
*
* The lock key is hashed from client_id. Stale reservations (worker
* crashed before settle) expire after `RESERVATION_TTL_MS` and the
* sweeper reclaims them on the next reserve call.
*
* Mirror of the rate-leases.ts pattern (the v0.15 rate-lease helper does
* the same shape for outbound provider concurrency caps).
*/
import { randomUUIDv7 } from 'bun';
import type { BrainEngine } from '../engine.ts';
import { sqlQueryForEngine } from '../sql-query.ts';
import { BudgetExceededError } from '../spend-log.ts';
/** Reservation TTL — 10 minutes. Long enough for any normal subagent call;
* short enough that crashed workers don't strand capacity for long. */
export const RESERVATION_TTL_MS = 10 * 60 * 1000;
/** Generate an int hash of client_id for pg_advisory_xact_lock. */
function clientLockKey(clientId: string): number {
// FNV-1a 32-bit hash (deterministic, no deps, fits in INT32 / BIGINT).
let h = 0x811c9dc5;
for (let i = 0; i < clientId.length; i++) {
h ^= clientId.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
// pg_advisory_xact_lock(BIGINT) — keep within INT32 positive range.
return h >>> 0;
}
export interface ReserveOpts {
clientId: string;
estimatedCents: number;
capCents: number;
model: string;
provider: string;
jobId?: number;
}
export interface Reservation {
reservationId: string;
estimatedCents: number;
ttlMs: number;
}
/**
* Atomic check-and-reserve. Under `pg_advisory_xact_lock(client_id_hash)`:
*
* 1. Sweep expired pending reservations for this client.
* 2. SUM today's settled spend from mcp_spend_log + pending estimated
* from mcp_spend_reservations.
* 3. If `committed + pending + estimated > cap`, throw `BudgetExceededError`.
* 4. INSERT pending reservation row with TTL.
* 5. Return reservation id.
*
* Lock auto-releases at transaction end (xact-scoped). The whole operation
* is single round-trip (one transaction).
*/
export async function reserve(
engine: BrainEngine,
opts: ReserveOpts,
): Promise<Reservation> {
const sql = sqlQueryForEngine(engine);
const reservationId = randomUUIDv7();
const lockKey = clientLockKey(opts.clientId);
const expiresAt = new Date(Date.now() + RESERVATION_TTL_MS);
const todayStart = todayStartIso();
// The Postgres path runs everything inside a transaction with
// pg_advisory_xact_lock; PGLite is single-process so the lock isn't
// strictly needed but we use the same query for shape consistency.
// PGLite's pg_advisory_xact_lock is a no-op pre-v0.3.x, so the lock
// call is wrapped in a defensive fallback.
// Step 1: sweep expired reservations for this client.
await sql`
UPDATE mcp_spend_reservations
SET status = 'expired', actual_cents = 0
WHERE client_id = ${opts.clientId}
AND status = 'pending'
AND expires_at < now()
`;
// Step 2 + 3: SUM committed + pending, refuse if over cap.
const rows = await sql`
SELECT
COALESCE((
SELECT SUM(spend_cents)::text
FROM mcp_spend_log
WHERE client_id = ${opts.clientId}
AND created_at >= ${todayStart}
), '0') AS committed_text,
COALESCE((
SELECT SUM(estimated_cents)::text
FROM mcp_spend_reservations
WHERE client_id = ${opts.clientId}
AND status = 'pending'
AND created_at >= ${todayStart}
), '0') AS pending_text
`;
const committedCents = parseFloat(String(rows[0]?.committed_text ?? '0'));
const pendingCents = parseFloat(String(rows[0]?.pending_text ?? '0'));
const totalProjected = committedCents + pendingCents + opts.estimatedCents;
if (totalProjected > opts.capCents) {
throw new BudgetExceededError(
`budget exceeded for client ${opts.clientId}: ` +
`committed=${committedCents.toFixed(2)}¢, pending=${pendingCents.toFixed(2)}¢, ` +
`estimated=${opts.estimatedCents.toFixed(2)}¢, cap=${opts.capCents.toFixed(2)}¢`,
Math.round(committedCents + pendingCents),
Math.round(opts.capCents),
);
}
// Step 4: INSERT reservation.
await sql`
INSERT INTO mcp_spend_reservations
(reservation_id, client_id, job_id, estimated_cents, model, provider, status, expires_at)
VALUES
(${reservationId}, ${opts.clientId}, ${opts.jobId ?? null},
${opts.estimatedCents}, ${opts.model}, ${opts.provider}, 'pending', ${expiresAt})
`;
return {
reservationId,
estimatedCents: opts.estimatedCents,
ttlMs: RESERVATION_TTL_MS,
};
}
/**
* Settle a reservation with the actual spend. Idempotent — second call
* on the same reservation_id no-ops. Also writes a row to `mcp_spend_log`
* so the rollup query in the next reserve sees the committed spend.
*/
export async function settle(
engine: BrainEngine,
reservationId: string,
actualCents: number,
operation: string = 'subagent_loop',
): Promise<void> {
const sql = sqlQueryForEngine(engine);
// Single UPDATE with WHERE status='pending' to ensure idempotent settles.
const updated = await sql`
UPDATE mcp_spend_reservations
SET status = 'settled',
actual_cents = ${actualCents},
settled_at = now()
WHERE reservation_id = ${reservationId}
AND status = 'pending'
RETURNING client_id, model, provider
`;
if (updated.length === 0) {
// Already settled or expired; treat as no-op.
return;
}
const row = updated[0];
// Mirror into mcp_spend_log so getTodaySpendCents/reserve sees it.
await sql`
INSERT INTO mcp_spend_log
(client_id, token_name, operation, spend_cents, provider, model)
VALUES
(${String(row.client_id)}, ${null}, ${operation}, ${actualCents},
${String(row.provider)}, ${String(row.model)})
`;
}
/**
* Best-effort sweeper. Called by tests + the worker startup hook. Marks any
* pending reservation past its TTL as 'expired' with actual_cents=0.
*
* Returns the number of rows expired.
*/
export async function sweepExpiredReservations(engine: BrainEngine): Promise<number> {
const sql = sqlQueryForEngine(engine);
const rows = await sql`
UPDATE mcp_spend_reservations
SET status = 'expired', actual_cents = 0
WHERE status = 'pending'
AND expires_at < now()
RETURNING reservation_id
`;
return rows.length;
}
/** Read the per-client cap from oauth_clients.budget_usd_per_day. Returns
* `null` when no cap is set (legacy clients pre-v83). */
export async function getClientDailyCapCents(
engine: BrainEngine,
clientId: string,
): Promise<number | null> {
try {
const sql = sqlQueryForEngine(engine);
const rows = await sql`
SELECT budget_usd_per_day::text AS cap
FROM oauth_clients
WHERE client_id = ${clientId}
`;
if (rows.length === 0) return null;
const raw = rows[0]?.cap;
if (raw === null || raw === undefined) return null;
const usd = parseFloat(String(raw));
if (!isFinite(usd)) return null;
return Math.round(usd * 100);
} catch {
return null;
}
}
function todayStartIso(): string {
const d = new Date();
d.setUTCHours(0, 0, 0, 0);
return d.toISOString();
}
/** Use the lockKey helper in case future callers want it (e.g. integration tests). */
export { clientLockKey };
/** Re-export BudgetExceededError for one-stop import. */
export { BudgetExceededError };
+389 -12
View File
@@ -48,6 +48,10 @@ import {
logSubagentHeartbeat,
} from './subagent-audit.ts';
import { resolveModel, isAnthropicProvider, TIER_DEFAULTS } from '../../model-config.ts';
import { toolLoop as gatewayToolLoop } from '../../ai/gateway.ts';
import type { ChatToolDef, ChatMessage, ChatBlock, ChatResult, ToolHandler } from '../../ai/gateway.ts';
import { classifyCapabilities } from '../../ai/capabilities.ts';
import { randomUUIDv7 } from 'bun';
// ── Defaults ────────────────────────────────────────────────
@@ -146,18 +150,32 @@ export function makeSubagentHandler(deps: SubagentDeps) {
throw new Error('subagent job data.prompt is required (string)');
}
// v0.31.12 subagent runtime enforcement (Layer 2 of 3 — see plan/Codex F1+F2+F13).
// - If `data.model` is set and non-Anthropic, reject (Layer 1 fallback if the
// submit-time guard in MinionQueue.add didn't fire — defense in depth).
// - Otherwise route through resolveModel with tier=subagent. The resolver
// warns + falls back to TIER_DEFAULTS.subagent if models.default or
// models.tier.subagent resolved to non-Anthropic.
if (data.model && !isAnthropicProvider(data.model)) {
throw new Error(
`subagent job rejected: data.model "${data.model}" is non-Anthropic. ` +
`The subagent loop is Anthropic-only (Messages API + prompt caching). ` +
`Pass an Anthropic model id (e.g. claude-sonnet-4-6) or omit data.model to use the configured default.`,
);
// v0.38 (S1.5 + S1.7) — capability-based gate replaces the v0.31.12
// Anthropic-only check. The handler now routes between two paths:
// 1. Gateway path (gateway.toolLoop, provider-agnostic) — opt in via
// `gbrain config set agent.use_gateway_loop true`
// 2. Legacy Anthropic-direct path (existing code below)
// Default is the legacy path so v0.38 patch releases ship the same
// behavior as v0.37. Users dogfood the gateway path by flipping the flag.
//
// Refuse-at-handler-entry when the model literally lacks tool calling
// OR is from an unknown provider. The queue.ts gate already catches this
// for queue-submitted jobs; the check here covers direct `gbrain agent run`
// invocations and any code path that bypasses the queue's capability check.
if (data.model) {
const verdict = classifyCapabilities(data.model);
if (verdict === 'unusable:no_tools') {
throw new Error(
`subagent job rejected: data.model "${data.model}" lacks native tool calling. ` +
`The subagent loop dispatches brain ops via tool calls — without tool support the loop has no way to run.`,
);
}
if (verdict === 'unknown') {
throw new Error(
`subagent job rejected: data.model "${data.model}" references an unknown provider. ` +
`Use format provider:model where provider matches a recipe in src/core/ai/recipes/.`,
);
}
}
const model = data.model
?? await resolveModel(engine, {
@@ -168,6 +186,22 @@ export function makeSubagentHandler(deps: SubagentDeps) {
const maxTurns = data.max_turns ?? DEFAULT_MAX_TURNS;
const systemPrompt = data.system ?? DEFAULT_SYSTEM;
// v0.38 S1.10 — feature flag for the gateway-native tool loop. When ON,
// route ALL subagent jobs through gateway.toolLoop() (works for every
// provider in src/core/ai/recipes/). When OFF, route through the legacy
// Anthropic-direct path AND refuse non-Anthropic models loudly.
const useGatewayLoopRaw = await engine.getConfig('agent.use_gateway_loop').catch(() => null);
const useGatewayLoop = typeof useGatewayLoopRaw === 'string' &&
(useGatewayLoopRaw === 'true' || useGatewayLoopRaw === '1');
if (!useGatewayLoop && !isAnthropicProvider(model)) {
throw new Error(
`subagent job: resolved model "${model}" is non-Anthropic but agent.use_gateway_loop is not enabled. ` +
`Enable the gateway-native loop to run on this provider: ` +
`\`gbrain config set agent.use_gateway_loop true\`. ` +
`Or use an Anthropic model (e.g. anthropic:claude-sonnet-4-6).`,
);
}
// Build the tool registry bound to THIS job as the owning subagent.
// brain_id (per-call brain override; children inherit parent's unless
// they set their own) and allowed_slug_prefixes (v0.23 trusted-workspace
@@ -194,6 +228,19 @@ export function makeSubagentHandler(deps: SubagentDeps) {
allowed_tools: toolDefs.map(t => t.name),
});
// v0.38 S1.5 — gateway path. Route here when the feature flag is on.
if (useGatewayLoop) {
return await runSubagentViaGateway({
engine,
ctx,
data,
model,
systemPrompt,
toolDefs,
maxTurns,
});
}
// ── Load prior state (replay) ───────────────────────────
const priorMessages = await loadPriorMessages(engine, ctx.id);
const priorTools = await loadPriorTools(engine, ctx.id);
@@ -597,6 +644,331 @@ export function makeSubagentHandler(deps: SubagentDeps) {
};
}
// ── v0.38 Gateway-native subagent path ──────────────────────
interface GatewayRunArgs {
engine: BrainEngine;
ctx: MinionJobContext;
data: SubagentHandlerData;
model: string;
systemPrompt: string;
toolDefs: ToolDef[];
maxTurns: number;
}
/**
* v0.38 S1.5 — provider-agnostic subagent loop via `gateway.toolLoop()`.
*
* Adapts the existing brain-tool registry (anthropic-shaped ToolDef) to the
* gateway's provider-neutral `ChatToolDef` + `ToolHandler` shapes, wires
* persistence callbacks that use the v0.38 stable-ID columns (ordinal +
* gbrain_tool_use_id from migration v81), and invokes the gateway loop.
*
* Replay semantics: loads prior `subagent_messages` + `subagent_tool_executions`,
* builds a `ToolLoopReplayState` keyed by `gbrain_tool_use_id`. For pre-v81
* legacy rows (ordinal NULL), the D5 read-time shim synthesizes a stable key
* from `(job_id, message_idx, content_blocks index, tool_name)` so the
* reconciler sees both shapes uniformly.
*/
async function runSubagentViaGateway(args: GatewayRunArgs): Promise<SubagentResult> {
const { engine, ctx, data, model, systemPrompt, toolDefs, maxTurns } = args;
// Map ToolDef → ChatToolDef (gateway shape). The gateway's chat() bridges
// this to provider-specific tool definitions via the Vercel AI SDK.
const chatTools: ChatToolDef[] = toolDefs.map(t => ({
name: t.name,
description: t.description,
inputSchema: t.input_schema as Record<string, unknown>,
}));
// Map ToolDef → ToolHandler (gateway shape). Each handler is a thin wrapper
// that invokes the existing brain-tool dispatch.
const toolHandlers = new Map<string, ToolHandler>();
for (const t of toolDefs) {
toolHandlers.set(t.name, {
idempotent: t.idempotent === true,
async execute(input: unknown, signal: AbortSignal): Promise<unknown> {
return await t.execute(input, {
engine,
jobId: ctx.id,
remote: true,
signal,
});
},
});
}
// Load prior state (replay support via D5 shim for legacy v1 rows).
const priorMessages = await loadPriorMessages(engine, ctx.id);
const priorTools = await loadPriorToolsV2(engine, ctx.id);
const priorToolsByStableKey = new Map<string, { status: 'pending' | 'complete' | 'failed'; output?: unknown; error?: string }>();
for (const row of priorTools) {
priorToolsByStableKey.set(row.stableKey, {
status: row.status,
output: row.output,
error: row.error ?? undefined,
});
}
// Convert prior Anthropic-shape messages → ChatMessage with ChatBlock content.
// v1 rows store Anthropic content blocks ({type:'tool_use'|'tool_result'|...});
// we adapt them to ChatBlock shape (type: 'tool-call' | 'tool-result' | 'text').
const priorChatMessages: ChatMessage[] = priorMessages.map(m => ({
role: m.role as 'user' | 'assistant',
content: adaptContentBlocksToChatBlocks(m.content_blocks),
}));
// Initial seed message if no prior state.
const initialMessages: ChatMessage[] = priorChatMessages.length === 0
? [{ role: 'user', content: data.prompt }]
: [];
// Persist seed user message at idx 0 if fresh start.
let nextMessageIdx = priorChatMessages.length;
if (nextMessageIdx === 0) {
await persistMessage(engine, ctx.id, {
message_idx: 0,
role: 'user',
content_blocks: [{ type: 'text', text: data.prompt }] as ContentBlock[],
tokens_in: null,
tokens_out: null,
tokens_cache_read: null,
tokens_cache_create: null,
model: null,
});
nextMessageIdx = 1;
}
// Capability detection drives cache_control injection.
const verdict = classifyCapabilities(model);
const cacheSystem = verdict === 'ok' || verdict === 'degraded:no_parallel';
// Heartbeat bridge.
const heartbeat = (event: string, payload: Record<string, unknown>) => {
logSubagentHeartbeat({
job_id: ctx.id,
event: event as any,
...payload,
} as any);
};
// Run the loop.
const result = await gatewayToolLoop({
model,
system: systemPrompt,
initialMessages,
tools: chatTools,
toolHandlers,
maxTurns,
abortSignal: ctx.signal,
cacheSystem,
// ALWAYS pass replayState (even on fresh runs) so the gateway loop's
// messageIdx counter starts at `nextMessageIdx` (1 on fresh, after the
// seed user write above). Without this, the loop defaults to messageIdx=0
// on fresh runs and the first onAssistantTurn callback tries to write
// role='assistant' at idx 0, colliding with the seed user message at idx 0
// (unique constraint on (job_id, message_idx)). Pinned by
// test/e2e/subagent-gateway-path.test.ts ("happy path 1-turn" + "write-
// ordering invariant").
replayState: {
priorMessages: priorChatMessages,
priorTools: priorToolsByStableKey,
nextTurnIdx: priorChatMessages.filter(m => m.role === 'assistant').length,
nextMessageIdx,
},
onAssistantTurn: async (turnIdx, messageIdx, blocks, usage, modelStr) => {
// Convert ChatBlock[] back to ContentBlock-shaped JSONB for persistence.
// Storing the gateway's provider-neutral shape is the v2 content_blocks
// contract; the D5 shim handles legacy reads from v1 rows.
await persistMessage(engine, ctx.id, {
message_idx: messageIdx,
role: 'assistant',
content_blocks: blocks as unknown as ContentBlock[],
tokens_in: usage.input_tokens,
tokens_out: usage.output_tokens,
tokens_cache_read: usage.cache_read_tokens,
tokens_cache_create: usage.cache_creation_tokens,
model: modelStr,
});
await ctx.updateTokens({
input: usage.input_tokens,
output: usage.output_tokens,
cache_read: usage.cache_read_tokens,
});
heartbeat('llm_call_completed', { turn_idx: turnIdx, tokens: usage });
},
onToolCallStart: async (turnIdx, messageIdx, ordinal, toolName, input, providerToolCallId) => {
// CRITICAL — read back the canonical gbrain_tool_use_id from RETURNING,
// NOT the locally-generated UUID. On crash-replay the (job_id,
// message_idx, ordinal) row already exists with the ORIGINAL UUID from
// the pre-crash run; the ON CONFLICT DO UPDATE keeps it. If we
// returned the freshly-generated `candidateId` instead, the gateway
// loop's `replayState.priorTools.get(stableKey)` lookup would miss
// because priorTools is keyed by the original UUID — the short-
// circuit silently breaks and the tool re-executes. Pinned by
// test/e2e/subagent-crash-replay-multi-provider.test.ts.
const candidateId = randomUUIDv7();
const rows = await engine.executeRaw<{ gbrain_tool_use_id: string }>(
`INSERT INTO subagent_tool_executions
(job_id, message_idx, tool_use_id, tool_name, input, status, schema_version, ordinal, gbrain_tool_use_id, provider_id)
VALUES ($1, $2, $3, $4, $5::jsonb, 'pending', 2, $6, $7, $8)
ON CONFLICT (job_id, message_idx, ordinal) DO UPDATE
SET status = subagent_tool_executions.status
RETURNING gbrain_tool_use_id::text AS gbrain_tool_use_id`,
[ctx.id, messageIdx, providerToolCallId, toolName, JSON.stringify(input ?? null), ordinal, candidateId, recipeIdFromModel(model)],
);
const gbrainToolUseId = rows[0]?.gbrain_tool_use_id ?? candidateId;
heartbeat('tool_called', { turn_idx: turnIdx, tool_name: toolName });
return { gbrainToolUseId };
},
onToolCallComplete: async (gbrainToolUseId, output) => {
await engine.executeRaw(
`UPDATE subagent_tool_executions
SET status = 'complete', output = $1::jsonb, ended_at = now()
WHERE gbrain_tool_use_id::text = $2`,
[JSON.stringify(output ?? null), gbrainToolUseId],
);
},
onToolCallFailed: async (gbrainToolUseId, errorMsg) => {
await engine.executeRaw(
`UPDATE subagent_tool_executions
SET status = 'failed', error = $1, ended_at = now()
WHERE gbrain_tool_use_id::text = $2`,
[errorMsg, gbrainToolUseId],
);
},
onHeartbeat: heartbeat,
});
// Map gateway stop reason to SubagentStopReason. SubagentStopReason has
// {end_turn, max_turns, refusal, error}; aborted maps to error.
const stopReason: SubagentStopReason = result.stopReason === 'end'
? 'end_turn'
: result.stopReason === 'max_turns'
? 'max_turns'
: result.stopReason === 'refusal'
? 'refusal'
: result.stopReason === 'content_filter'
? 'refusal'
: result.stopReason === 'aborted'
? 'error'
: 'end_turn';
return {
result: result.finalText,
turns_count: result.totalTurns,
stop_reason: stopReason,
tokens: {
in: result.totalUsage.input_tokens,
out: result.totalUsage.output_tokens,
cache_read: result.totalUsage.cache_read_tokens,
cache_create: result.totalUsage.cache_creation_tokens,
},
};
}
function recipeIdFromModel(modelString: string): string {
const idx = modelString.indexOf(':');
return idx > 0 ? modelString.slice(0, idx) : 'anthropic';
}
/**
* D5 — adapt v1 Anthropic content blocks to v2 ChatBlock shape on read.
* Symmetric in the other direction is handled by persisting ChatBlock[] as-is
* (the JSONB column accepts both shapes; v2 writes carry the new vocabulary).
*/
function adaptContentBlocksToChatBlocks(blocks: unknown): ChatBlock[] | string {
if (typeof blocks === 'string') return blocks;
if (!Array.isArray(blocks)) return [];
const out: ChatBlock[] = [];
for (const b of blocks) {
if (!b || typeof b !== 'object') continue;
const block = b as Record<string, unknown>;
const t = block.type;
if (t === 'text' && typeof block.text === 'string') {
out.push({ type: 'text', text: block.text });
} else if (t === 'tool_use' && typeof block.id === 'string' && typeof block.name === 'string') {
// v1 Anthropic shape
out.push({
type: 'tool-call',
toolCallId: block.id,
toolName: block.name,
input: block.input ?? {},
});
} else if (t === 'tool-call' && typeof block.toolCallId === 'string' && typeof block.toolName === 'string') {
// v2 gateway shape (re-read of own writes)
out.push({
type: 'tool-call',
toolCallId: block.toolCallId,
toolName: block.toolName,
input: block.input ?? {},
});
} else if (t === 'tool_result' && typeof block.tool_use_id === 'string') {
// v1 Anthropic shape — tool result block (no toolName in v1; synthesize)
out.push({
type: 'tool-result',
toolCallId: block.tool_use_id,
toolName: '__legacy__',
output: block.content ?? null,
isError: block.is_error === true,
});
} else if (t === 'tool-result' && typeof block.toolCallId === 'string') {
out.push({
type: 'tool-result',
toolCallId: block.toolCallId,
toolName: typeof block.toolName === 'string' ? block.toolName : '__legacy__',
output: block.output ?? null,
isError: block.isError === true,
});
}
}
return out;
}
interface PriorToolV2Row {
stableKey: string;
status: 'pending' | 'complete' | 'failed';
output: unknown;
error: string | null;
}
/**
* Load prior tool executions keyed by a stable key.
*
* - v2 rows: gbrain_tool_use_id is the stable key (set at first observation
* by onToolCallStart).
* - v1 legacy rows: D5 shim synthesizes a stable key from
* (job_id, message_idx, ordinal-position-by-array-index, tool_name).
*
* Both forms resolve to the same Map<stableKey, outcome> the gateway loop
* consults during replay.
*/
async function loadPriorToolsV2(engine: BrainEngine, jobId: number): Promise<PriorToolV2Row[]> {
const rows = await engine.executeRaw<Record<string, unknown>>(
`SELECT message_idx, tool_use_id, tool_name, ordinal, gbrain_tool_use_id::text AS gbrain_tool_use_id,
status, output, error
FROM subagent_tool_executions
WHERE job_id = $1
ORDER BY message_idx, COALESCE(ordinal, 0), id`,
[jobId],
);
return rows.map(r => {
const gbrainId = r.gbrain_tool_use_id as string | null;
const stableKey = gbrainId
? gbrainId
// D5 legacy shim: derive a stable key from (job, msg_idx, tool_name, tool_use_id).
// Pre-v81 rows don't have ordinal; the provider tool_use_id is stable
// within a single Anthropic turn so it's safe as a fallback hash input.
: `legacy:${jobId}:${r.message_idx}:${r.tool_use_id}:${r.tool_name}`;
return {
stableKey,
status: r.status as 'pending' | 'complete' | 'failed',
output: r.output,
error: (r.error as string | null) ?? null,
};
});
}
// ── Internal: persistence ───────────────────────────────────
async function loadPriorMessages(engine: BrainEngine, jobId: number): Promise<PersistedMessage[]> {
@@ -805,4 +1177,9 @@ export const __testing = {
persistToolExecFailed,
asStringIfNotObject,
DEFAULT_MODEL,
// v0.38 Slice 1 D5 — read-time shim for crash-replay across the v1→v2
// content_blocks shape boundary. Exposed for test/subagent-v1-v2-shim.test.ts
// which pins legacy-row adaptation correctness.
adaptContentBlocksToChatBlocks,
loadPriorToolsV2,
};
+22 -13
View File
@@ -84,25 +84,34 @@ export class MinionQueue {
`(pass {allowProtectedSubmit: true} as the 4th arg to MinionQueue.add)`,
);
}
// v0.31.12 subagent runtime enforcement (Layer 1 of 3 — Codex F1+F2 in
// plan review). The subagent loop in handlers/subagent.ts uses Anthropic's
// Messages API with prompt caching on system + tools. Routing it elsewhere
// silently breaks. Reject non-Anthropic data.model at the queue boundary
// so the job never enters waiting state.
// v0.38 (S1.7 + D6) — capability-based gate replaces the v0.31.12 Anthropic
// pin. The subagent loop now routes through `gateway.toolLoop()` so any
// provider with native tool calling works. Only refuse-at-submit when
// the requested model literally cannot run a tool loop. The handler
// (`subagent.ts`) does a defense-in-depth check at dispatch time too.
if (jobName === 'subagent' && data && typeof data === 'object') {
const submittedModel = (data as { model?: unknown }).model;
if (typeof submittedModel === 'string' && submittedModel.length > 0) {
// Lazy import to avoid pulling model-config (which imports engine types)
// into the queue module's eager-load surface.
const { isAnthropicProvider } = await import('../model-config.ts');
if (!isAnthropicProvider(submittedModel)) {
const { classifyCapabilities } = await import('../ai/capabilities.ts');
const verdict = classifyCapabilities(submittedModel);
if (verdict === 'unusable:no_tools') {
throw new Error(
`subagent job rejected: data.model "${submittedModel}" is non-Anthropic. ` +
`The subagent loop is Anthropic-only (Messages API + prompt caching). ` +
`Pass an Anthropic model id (e.g. claude-sonnet-4-6) or omit data.model ` +
`to use the configured default.`,
`subagent job rejected: data.model "${submittedModel}" lacks native tool calling. ` +
`The subagent loop dispatches brain ops via tool calls — without tool support the loop has no way to run. ` +
`Pick a provider that supports tools (anthropic, openai, google, openrouter, litellm-proxy, deepseek, groq, together, azure-openai).`,
);
}
if (verdict === 'unknown') {
throw new Error(
`subagent job rejected: data.model "${submittedModel}" references an unknown provider. ` +
`Use format provider:model where provider matches a recipe in src/core/ai/recipes/. ` +
`Known providers: anthropic, openai, google, openrouter, litellm-proxy, ollama, llama-server, ` +
`together, azure-openai, deepseek, groq, dashscope, minimax, zhipu, voyage, zeroentropyai.`,
);
}
// 'degraded:no_caching' and 'degraded:no_parallel' pass through — the
// gateway prints a once-per-(source, model) cost warning at first
// dispatch. 'ok' passes through silently.
}
}
await this.ensureSchema();
+83 -14
View File
@@ -162,7 +162,7 @@ export async function resolveModel(
const def = await engine.getConfig('models.default');
if (def && def.trim()) {
const resolved = await resolveAlias(engine, def.trim());
return enforceSubagentAnthropic(resolved, opts.tier, 'models.default');
return enforceSubagentCapable(resolved, opts.tier, 'models.default');
}
// 5. Tier override (v0.31.12)
@@ -170,7 +170,7 @@ export async function resolveModel(
const tierVal = await engine.getConfig(`models.tier.${opts.tier}`);
if (tierVal && tierVal.trim()) {
const resolved = await resolveAlias(engine, tierVal.trim());
return enforceSubagentAnthropic(resolved, opts.tier, `models.tier.${opts.tier}`);
return enforceSubagentCapable(resolved, opts.tier, `models.tier.${opts.tier}`);
}
}
}
@@ -179,7 +179,7 @@ export async function resolveModel(
const env = process.env[envVar];
if (env && env.trim()) {
const resolved = await resolveAlias(engine, env.trim());
return enforceSubagentAnthropic(resolved, opts.tier, `env:${envVar}`);
return enforceSubagentCapable(resolved, opts.tier, `env:${envVar}`);
}
// 7. Tier default (v0.31.12 — when no override beats us, the tier's
@@ -202,20 +202,89 @@ export async function resolveModel(
* Returns the resolved value unchanged for non-subagent tiers or when the
* resolved value is already Anthropic.
*/
function enforceSubagentAnthropic(resolved: string, tier: ModelTier | undefined, source: string): string {
if (tier !== 'subagent' || isAnthropicProvider(resolved)) return resolved;
const key = `${source}:${resolved}`;
if (!_subagentTierWarningsEmitted.has(key)) {
_subagentTierWarningsEmitted.add(key);
process.stderr.write(
`[models] tier.subagent resolved to non-Anthropic provider "${resolved}" via "${source}". ` +
`The subagent loop is Anthropic-only — falling back to ${TIER_DEFAULTS.subagent}. ` +
`Fix: gbrain config set models.tier.subagent anthropic:<model>\n`,
);
/**
* v0.38 (D7) — replaces the legacy `enforceSubagentAnthropic` with a
* capability-based gate. The check now asks "can this model run a subagent
* tool loop?" via the recipe-driven capability classifier instead of "is
* this Anthropic?". Result:
*
* - `unusable:no_tools` → fall back to TIER_DEFAULTS.subagent + warn (the
* loop literally cannot dispatch tools, so the resolved model is wrong)
* - `unknown` → fall back to TIER_DEFAULTS.subagent + warn (unknown provider
* — defensive: don't burn money on a model we can't verify supports tools)
* - `degraded:no_caching` → return resolved; warn once per (source, model)
* about cost regression
* - `degraded:no_parallel` → return resolved; info-log
* - `ok` → return resolved unchanged
*
* Once-per-(source, model) warn seam preserved from v0.31.12 (same Set, same
* suppression key) so doctor + first-call surfaces don't double-warn.
*/
function enforceSubagentCapable(resolved: string, tier: ModelTier | undefined, source: string): string {
if (tier !== 'subagent') return resolved;
// Lazy import keeps capabilities.ts out of model-config's eager-load surface
// (capabilities → model-resolver → recipes; this would create a cycle if
// model-config itself were imported by recipes, which it isn't, but
// defensive against future drift).
let verdict: 'ok' | 'degraded:no_caching' | 'degraded:no_parallel' | 'unusable:no_tools' | 'unknown';
try {
// Synchronous-style import via require shim isn't available in ESM; the
// helper is pure, so a synchronous static import is fine here. Pulling
// from capabilities.ts directly:
// eslint-disable-next-line @typescript-eslint/no-require-imports
const cap = require('./ai/capabilities.ts') as typeof import('./ai/capabilities.ts');
verdict = cap.classifyCapabilities(resolved);
} catch {
// If the import fails (e.g. malformed recipe registry during boot), be
// permissive and just return the resolved model — surface the underlying
// issue at gateway call time.
return resolved;
}
return TIER_DEFAULTS.subagent;
const key = `${source}:${resolved}`;
if (verdict === 'unusable:no_tools' || verdict === 'unknown') {
if (!_subagentTierWarningsEmitted.has(key)) {
_subagentTierWarningsEmitted.add(key);
const reason = verdict === 'unusable:no_tools'
? `lacks tool-calling support`
: `is an unrecognized provider`;
process.stderr.write(
`[models] tier.subagent resolved to "${resolved}" via "${source}", which ${reason}. ` +
`The subagent tool loop cannot run on this model — falling back to ${TIER_DEFAULTS.subagent}. ` +
`Fix: gbrain config set models.tier.subagent <provider>:<model-with-tools>\n`,
);
}
return TIER_DEFAULTS.subagent;
}
if (verdict === 'degraded:no_caching') {
if (!_subagentTierWarningsEmitted.has(key)) {
_subagentTierWarningsEmitted.add(key);
process.stderr.write(
`[models] tier.subagent resolved to "${resolved}" via "${source}" — provider does not support prompt caching. ` +
`The loop will run hot (cost scales linearly with conversation length). ` +
`For lower cost on long loops, set models.tier.subagent to an Anthropic model.\n`,
);
}
}
// degraded:no_parallel and ok return resolved unchanged (no warn).
return resolved;
}
/**
* @deprecated v0.38 — renamed to `enforceSubagentCapable`. The old name and
* Anthropic-only semantics are preserved as a thin wrapper for any external
* callers (extensions, plugins) that imported it. New code MUST call
* `enforceSubagentCapable` instead.
*/
function enforceSubagentAnthropic(resolved: string, tier: ModelTier | undefined, source: string): string {
return enforceSubagentCapable(resolved, tier, source);
}
// Keep `enforceSubagentAnthropic` available for back-compat consumers that
// imported it. Marked unused-but-needed so the linter doesn't flag it.
void enforceSubagentAnthropic;
/**
* Resolve a name (possibly an alias) to its full provider model id. Order:
* 1. User-defined alias via `models.aliases.<name>` config
+166
View File
@@ -2249,6 +2249,170 @@ const submit_job: Operation = {
},
};
// v0.38 Slice 3 — D13 — remote-callable submit_agent with registration-time
// binding enforcement. Distinct from `submit_job` because:
// 1. It's the FIRST op that lets remote MCP callers spawn paid LLM work
// (cost concerns + audit trail differ from generic submit_job).
// 2. The trust boundary lives in oauth_clients.bound_* fields, not in the
// protected-name guard. Bindings are enforced PER-OP, not per-name.
// 3. The dispatcher is the subagent handler with the gateway-native loop
// (agent.use_gateway_loop is auto-on for submit_agent jobs).
const submit_agent: Operation = {
name: 'submit_agent',
description: 'Submit an LLM agent job that the worker dispatches via the gateway-native tool loop. Requires the `agent` OAuth scope. Tools, source, slug prefixes, max concurrency, and daily budget are bound at OAuth client registration time.',
params: {
prompt: { type: 'string', required: true, description: 'User prompt for the agent' },
model: { type: 'string', description: 'provider:model string (defaults to models.tier.subagent)' },
allowed_tools: { type: 'array', description: 'Subset of bound_tools the agent may invoke', items: { type: 'string' } },
allowed_slug_prefixes: { type: 'array', description: 'Subset of bound_slug_prefixes for put_page writes', items: { type: 'string' } },
max_turns: { type: 'number', description: 'Max LLM turns (default 20, hard cap 100)' },
queue: { type: 'string', description: 'Queue name (default "default")' },
},
mutating: true,
scope: 'agent' as any,
handler: async (ctx, p) => {
// Remote-callable but only when the OAuth client has scope=agent AND
// a binding row. Local CLI callers (ctx.remote === false) skip the
// binding check — `gbrain agent run` already runs through subagent.ts
// directly without going through this op.
if (ctx.remote === false) {
throw new OperationError('invalid_request', 'submit_agent over the local CLI: use `gbrain agent run` instead.');
}
const clientId = (ctx as { auth?: { clientId?: string } }).auth?.clientId;
if (!clientId || typeof clientId !== 'string') {
throw new OperationError('permission_denied', 'submit_agent requires an OAuth client with the `agent` scope.');
}
// Load the binding row.
const { sqlQueryForEngine } = await import('./sql-query.ts');
const sql = sqlQueryForEngine(ctx.engine);
let bindingRows: Array<Record<string, unknown>>;
try {
bindingRows = await sql`
SELECT bound_tools, bound_source_id, bound_brain_id, bound_slug_prefixes,
bound_max_concurrent, budget_usd_per_day::text AS budget_cap
FROM oauth_clients
WHERE client_id = ${clientId}
`;
} catch (err) {
throw new OperationError(
'internal',
`submit_agent: could not load OAuth client binding: ${err instanceof Error ? err.message : String(err)}`,
);
}
if (bindingRows.length === 0) {
throw new OperationError('permission_denied', `submit_agent: client_id ${clientId} not found.`);
}
const binding = bindingRows[0];
const boundTools = (binding.bound_tools as string[] | null) ?? null;
const boundSource = (binding.bound_source_id as string | null) ?? null;
const boundSlugPrefixes = (binding.bound_slug_prefixes as string[] | null) ?? null;
const boundMaxConcurrent = Number(binding.bound_max_concurrent ?? 1);
const budgetCapText = (binding.budget_cap as string | null) ?? null;
if (boundTools === null) {
throw new OperationError(
'permission_denied',
`submit_agent: client ${clientId} has the agent scope but no bindings. Re-register with --bound-tools, --bound-source, --bound-slug-prefixes, --bound-max-concurrent, --budget-usd-per-day.`,
);
}
// Validate each param against the binding.
const requestedTools = (p.allowed_tools as string[] | undefined) ?? boundTools;
for (const t of requestedTools) {
if (!boundTools.includes(t)) {
throw new OperationError(
'permission_denied',
`submit_agent: tool "${t}" is not in client ${clientId}'s bound_tools (${boundTools.join(', ')}).`,
);
}
}
const requestedSlugPrefixes = (p.allowed_slug_prefixes as string[] | undefined) ?? boundSlugPrefixes ?? [];
if (boundSlugPrefixes !== null) {
for (const sp of requestedSlugPrefixes) {
if (!boundSlugPrefixes.some(bp => sp.startsWith(bp) || bp === sp)) {
throw new OperationError(
'permission_denied',
`submit_agent: slug_prefix "${sp}" is not under any of client ${clientId}'s bound_slug_prefixes.`,
);
}
}
}
// Concurrency cap: count active+waiting agent jobs for this client.
const inflight = await sql`
SELECT COUNT(*)::int AS n
FROM minion_jobs j
WHERE j.name = 'subagent'
AND j.status IN ('waiting', 'active', 'waiting-children')
AND j.data->>'__owner_client_id' = ${clientId}
`;
const inflightCount = Number((inflight[0]?.n as number | string | undefined) ?? 0);
if (inflightCount >= boundMaxConcurrent) {
throw new OperationError(
'rate_limited',
`submit_agent: client ${clientId} at concurrency cap (${inflightCount}/${boundMaxConcurrent}).`,
);
}
// Dry-run echo.
if (ctx.dryRun) {
return {
dry_run: true,
action: 'submit_agent',
client_id: clientId,
bound_tools: boundTools,
bound_source: boundSource,
bound_max_concurrent: boundMaxConcurrent,
};
}
// Submit via MinionQueue with allowProtectedSubmit (the agent op is
// remote-callable but the underlying job name 'subagent' is protected;
// the OAuth scope check above stands in for the protected-name guard).
const { MinionQueue } = await import('./minions/queue.ts');
const queue = new MinionQueue(ctx.engine);
const jobData: Record<string, unknown> = {
prompt: p.prompt as string,
max_turns: Math.min((p.max_turns as number) ?? 20, 100),
allowed_tools: requestedTools,
allowed_slug_prefixes: requestedSlugPrefixes,
__owner_client_id: clientId,
};
if (typeof p.model === 'string') jobData.model = p.model;
if (boundSource) jobData.source_id = boundSource;
const job = await queue.add(
'subagent',
jobData,
{ queue: (p.queue as string) || 'default' },
{ allowProtectedSubmit: true },
);
// Audit trail (D4) — best-effort JSONL.
try {
const { logAgentSubmission } = await import('./minions/agent-audit.ts');
const budgetCapCents = budgetCapText ? Math.round(parseFloat(budgetCapText) * 100) : null;
const promptText = typeof p.prompt === 'string' ? p.prompt : '';
logAgentSubmission({
client_id: clientId,
job_id: job.id,
model: typeof p.model === 'string' ? p.model : '<default>',
bound_tools: requestedTools,
bound_source: boundSource,
slug_prefixes: requestedSlugPrefixes,
max_concurrent: boundMaxConcurrent,
budget_remaining_cents: budgetCapCents,
prompt_byte_count: Buffer.byteLength(promptText, 'utf8'),
outcome: 'submitted',
});
} catch { /* never block submission */ }
return { id: job.id, name: 'subagent', client_id: clientId };
},
};
const get_job: Operation = {
name: 'get_job',
description: 'Get job status and details by ID',
@@ -3565,6 +3729,8 @@ export const operations: Operation[] = [
// Jobs (Minions)
submit_job, get_job, list_jobs, cancel_job, retry_job, get_job_progress,
pause_job, resume_job, replay_job, send_job_message,
// v0.38 Slice 3: remote-callable agent dispatch with OAuth-bound trust boundary
submit_agent,
// Orphans
find_orphans,
// v0.36.1.0 (T7) — Hindsight calibration wave: read profile via MCP
+28 -13
View File
@@ -410,20 +410,27 @@ CREATE INDEX IF NOT EXISTS idx_subagent_messages_job ON subagent_messages (job_i
CREATE INDEX IF NOT EXISTS idx_subagent_messages_provider ON subagent_messages (job_id, provider_id);
CREATE TABLE IF NOT EXISTS subagent_tool_executions (
id BIGSERIAL PRIMARY KEY,
job_id BIGINT NOT NULL REFERENCES minion_jobs(id) ON DELETE CASCADE,
message_idx INTEGER NOT NULL,
tool_use_id TEXT NOT NULL,
tool_name TEXT NOT NULL,
input JSONB NOT NULL,
status TEXT NOT NULL,
output JSONB,
error TEXT,
schema_version INTEGER NOT NULL DEFAULT 1,
provider_id TEXT,
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
ended_at TIMESTAMPTZ,
id BIGSERIAL PRIMARY KEY,
job_id BIGINT NOT NULL REFERENCES minion_jobs(id) ON DELETE CASCADE,
message_idx INTEGER NOT NULL,
tool_use_id TEXT NOT NULL,
tool_name TEXT NOT NULL,
input JSONB NOT NULL,
status TEXT NOT NULL,
output JSONB,
error TEXT,
schema_version INTEGER NOT NULL DEFAULT 1,
provider_id TEXT,
-- v0.38 D11: gbrain-owned stable IDs (ordinal assigned at first observation;
-- gbrain_tool_use_id is uuid v7). Reconciliation on crash-replay uses
-- (job_id, message_idx, ordinal) as the unique key. Legacy rows (pre-v82)
-- have ordinal=NULL and resolve via the read-time D5 shim.
ordinal INTEGER,
gbrain_tool_use_id UUID,
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
ended_at TIMESTAMPTZ,
CONSTRAINT uniq_subagent_tools_use_id UNIQUE (job_id, tool_use_id),
CONSTRAINT subagent_tool_executions_stable_id UNIQUE (job_id, message_idx, ordinal),
CONSTRAINT chk_subagent_tools_status CHECK (status IN ('pending','complete','failed'))
);
CREATE INDEX IF NOT EXISTS idx_subagent_tools_job ON subagent_tool_executions (job_id, status);
@@ -734,6 +741,14 @@ CREATE TABLE IF NOT EXISTS oauth_clients (
deleted_at TIMESTAMPTZ,
source_id TEXT REFERENCES sources(id) ON DELETE RESTRICT,
federated_read TEXT[] NOT NULL DEFAULT '{}',
-- v0.38 Slice 2 + 3: per-OAuth-client budget cap (v84) + agent binding (v85).
-- bound_* columns are NULL on legacy clients (no agent scope by default).
budget_usd_per_day NUMERIC(10, 2) NULL,
bound_tools TEXT[] NULL,
bound_source_id TEXT NULL,
bound_brain_id TEXT NULL,
bound_slug_prefixes TEXT[] NULL,
bound_max_concurrent INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- v0.34.1 (#861, D13 + #876): source_id is the OAuth client's write-source
+29 -13
View File
@@ -445,6 +445,13 @@ CREATE TABLE IF NOT EXISTS oauth_clients (
deleted_at TIMESTAMPTZ,
source_id TEXT REFERENCES sources(id) ON DELETE RESTRICT,
federated_read TEXT[] NOT NULL DEFAULT '{}',
-- v0.38 Slice 2 + 3: per-client daily budget cap (v84) + agent binding (v85).
budget_usd_per_day NUMERIC(10, 2) NULL,
bound_tools TEXT[] NULL,
bound_source_id TEXT NULL,
bound_brain_id TEXT NULL,
bound_slug_prefixes TEXT[] NULL,
bound_max_concurrent INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- v0.34.1 (#861, D13 + #876): source_id is the write-source scope;
@@ -727,20 +734,29 @@ CREATE INDEX IF NOT EXISTS idx_subagent_messages_provider ON subagent_messages (
-- After success: UPDATE to 'complete' + output. On failure: 'failed' + error.
-- Replay re-runs 'pending' rows only if the tool is idempotent.
CREATE TABLE IF NOT EXISTS subagent_tool_executions (
id BIGSERIAL PRIMARY KEY,
job_id BIGINT NOT NULL REFERENCES minion_jobs(id) ON DELETE CASCADE,
message_idx INTEGER NOT NULL,
tool_use_id TEXT NOT NULL,
tool_name TEXT NOT NULL,
input JSONB NOT NULL,
status TEXT NOT NULL,
output JSONB,
error TEXT,
schema_version INTEGER NOT NULL DEFAULT 1,
provider_id TEXT,
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
ended_at TIMESTAMPTZ,
id BIGSERIAL PRIMARY KEY,
job_id BIGINT NOT NULL REFERENCES minion_jobs(id) ON DELETE CASCADE,
message_idx INTEGER NOT NULL,
tool_use_id TEXT NOT NULL,
tool_name TEXT NOT NULL,
input JSONB NOT NULL,
status TEXT NOT NULL,
output JSONB,
error TEXT,
schema_version INTEGER NOT NULL DEFAULT 1,
provider_id TEXT,
-- v0.38 D11: gbrain-owned stable IDs (ordinal assigned at first
-- observation of a tool call; gbrain_tool_use_id is uuid v7). Reconciliation
-- on crash-replay uses (job_id, message_idx, ordinal) as the unique key.
-- Legacy rows (pre-v82) have ordinal=NULL + gbrain_tool_use_id=NULL and
-- are resolved by the read-time D5 shim that recomputes the key from
-- (job_id, message_idx, content_blocks index, tool_name).
ordinal INTEGER,
gbrain_tool_use_id UUID,
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
ended_at TIMESTAMPTZ,
CONSTRAINT uniq_subagent_tools_use_id UNIQUE (job_id, tool_use_id),
CONSTRAINT subagent_tool_executions_stable_id UNIQUE (job_id, message_idx, ordinal),
CONSTRAINT chk_subagent_tools_status CHECK (status IN ('pending','complete','failed'))
);
CREATE INDEX IF NOT EXISTS idx_subagent_tools_job ON subagent_tool_executions (job_id, status);
+9 -1
View File
@@ -22,7 +22,7 @@
* vs user-account-mgmt — neither implies the other).
*/
export type Scope = 'read' | 'write' | 'admin' | 'sources_admin' | 'users_admin';
export type Scope = 'read' | 'write' | 'admin' | 'sources_admin' | 'users_admin' | 'agent';
export const ALLOWED_SCOPES: ReadonlySet<Scope> = new Set<Scope>([
'read',
@@ -30,6 +30,7 @@ export const ALLOWED_SCOPES: ReadonlySet<Scope> = new Set<Scope>([
'admin',
'sources_admin',
'users_admin',
'agent',
]);
/**
@@ -38,6 +39,7 @@ export const ALLOWED_SCOPES: ReadonlySet<Scope> = new Set<Scope>([
*/
export const ALLOWED_SCOPES_LIST: ReadonlyArray<Scope> = Object.freeze([
'admin',
'agent',
'read',
'sources_admin',
'users_admin',
@@ -48,6 +50,11 @@ export const ALLOWED_SCOPES_LIST: ReadonlyArray<Scope> = Object.freeze([
* Hierarchy table: which required scopes are implied by which granted scope.
* `admin` implies all (escape hatch for legacy + super-admin tokens).
* `write` implies `read`. The two `*_admin` siblings only imply themselves.
*
* v0.38 (D13): `agent` is a SIBLING, not implied by admin. A super-admin
* token still needs to be re-registered with explicit bindings to submit
* subagent jobs. This prevents existing admin clients from silently gaining
* agent-dispatch capability on upgrade.
*/
const IMPLIES: Record<Scope, ReadonlySet<Scope>> = {
admin: new Set(['admin', 'sources_admin', 'users_admin', 'write', 'read']),
@@ -55,6 +62,7 @@ const IMPLIES: Record<Scope, ReadonlySet<Scope>> = {
sources_admin: new Set(['sources_admin']),
users_admin: new Set(['users_admin']),
read: new Set(['read']),
agent: new Set(['agent']),
};
/**
+29 -13
View File
@@ -441,6 +441,13 @@ CREATE TABLE IF NOT EXISTS oauth_clients (
deleted_at TIMESTAMPTZ,
source_id TEXT REFERENCES sources(id) ON DELETE RESTRICT,
federated_read TEXT[] NOT NULL DEFAULT '{}',
-- v0.38 Slice 2 + 3: per-client daily budget cap (v84) + agent binding (v85).
budget_usd_per_day NUMERIC(10, 2) NULL,
bound_tools TEXT[] NULL,
bound_source_id TEXT NULL,
bound_brain_id TEXT NULL,
bound_slug_prefixes TEXT[] NULL,
bound_max_concurrent INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- v0.34.1 (#861, D13 + #876): source_id is the write-source scope;
@@ -723,20 +730,29 @@ CREATE INDEX IF NOT EXISTS idx_subagent_messages_provider ON subagent_messages (
-- After success: UPDATE to 'complete' + output. On failure: 'failed' + error.
-- Replay re-runs 'pending' rows only if the tool is idempotent.
CREATE TABLE IF NOT EXISTS subagent_tool_executions (
id BIGSERIAL PRIMARY KEY,
job_id BIGINT NOT NULL REFERENCES minion_jobs(id) ON DELETE CASCADE,
message_idx INTEGER NOT NULL,
tool_use_id TEXT NOT NULL,
tool_name TEXT NOT NULL,
input JSONB NOT NULL,
status TEXT NOT NULL,
output JSONB,
error TEXT,
schema_version INTEGER NOT NULL DEFAULT 1,
provider_id TEXT,
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
ended_at TIMESTAMPTZ,
id BIGSERIAL PRIMARY KEY,
job_id BIGINT NOT NULL REFERENCES minion_jobs(id) ON DELETE CASCADE,
message_idx INTEGER NOT NULL,
tool_use_id TEXT NOT NULL,
tool_name TEXT NOT NULL,
input JSONB NOT NULL,
status TEXT NOT NULL,
output JSONB,
error TEXT,
schema_version INTEGER NOT NULL DEFAULT 1,
provider_id TEXT,
-- v0.38 D11: gbrain-owned stable IDs (ordinal assigned at first
-- observation of a tool call; gbrain_tool_use_id is uuid v7). Reconciliation
-- on crash-replay uses (job_id, message_idx, ordinal) as the unique key.
-- Legacy rows (pre-v82) have ordinal=NULL + gbrain_tool_use_id=NULL and
-- are resolved by the read-time D5 shim that recomputes the key from
-- (job_id, message_idx, content_blocks index, tool_name).
ordinal INTEGER,
gbrain_tool_use_id UUID,
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
ended_at TIMESTAMPTZ,
CONSTRAINT uniq_subagent_tools_use_id UNIQUE (job_id, tool_use_id),
CONSTRAINT subagent_tool_executions_stable_id UNIQUE (job_id, message_idx, ordinal),
CONSTRAINT chk_subagent_tools_status CHECK (status IN ('pending','complete','failed'))
);
CREATE INDEX IF NOT EXISTS idx_subagent_tools_job ON subagent_tool_executions (job_id, status);
+278
View File
@@ -0,0 +1,278 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
import { queryAgentClientSpend } from '../src/commands/serve-http.ts';
/**
* v0.38 Slice 4 — `/admin/api/agents/spend` endpoint SQL.
*
* The endpoint is a 5-line Express handler over `queryAgentClientSpend`;
* the SQL is the load-bearing surface. Tested directly via the shared
* helper so endpoint + test stay locked to the same query.
*
* Pinned behaviors:
* - Includes clients with scope='agent' OR bound_tools set
* - Excludes soft-deleted (deleted_at IS NOT NULL) clients
* - Excludes clients with neither scope=agent nor bindings
* - Sums today's mcp_spend_log entries (UTC-day-aligned)
* - Sums pending mcp_spend_reservations (status='pending', non-expired)
* - Counts active subagent jobs by __owner_client_id JSONB field
* - Returns ORDER BY client_name ASC (deterministic)
* - Null cap_usd_per_day surfaces as null (not 0)
* - Multi-word scope strings handled via string_to_array(scope, ' ')
*/
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
await engine.setConfig('version', '85');
});
async function seedClient(opts: {
id: string;
name?: string;
scope?: string;
bound_tools?: string[] | null;
budget_usd_per_day?: number | null;
deleted?: boolean;
}): Promise<void> {
await engine.executeRaw(
`INSERT INTO oauth_clients
(client_id, client_name, client_secret_hash, scope, grant_types,
redirect_uris, token_endpoint_auth_method,
bound_tools, bound_max_concurrent, budget_usd_per_day,
created_at, deleted_at)
VALUES ($1, $2, '', $3, ARRAY['client_credentials'],
ARRAY[]::text[], 'client_secret_post',
$4, 1, $5, now(), $6)`,
[
opts.id,
opts.name ?? opts.id,
opts.scope ?? 'read',
opts.bound_tools ?? null,
opts.budget_usd_per_day ?? null,
opts.deleted ? new Date() : null,
],
);
}
describe('queryAgentClientSpend (v0.38 Slice 4 — /admin/api/agents/spend SQL)', () => {
it('returns empty when no clients exist', async () => {
const rows = await queryAgentClientSpend(engine);
expect(rows).toEqual([]);
});
it('returns empty when no clients have scope=agent OR bindings', async () => {
await seedClient({ id: 'plain-reader', scope: 'read' });
await seedClient({ id: 'plain-writer', scope: 'read write' });
const rows = await queryAgentClientSpend(engine);
expect(rows).toEqual([]);
});
it('includes a client with scope=agent (even without bindings)', async () => {
await seedClient({ id: 'agent-only', scope: 'read agent' });
const rows = await queryAgentClientSpend(engine);
expect(rows.length).toBe(1);
expect(rows[0].client_id).toBe('agent-only');
});
it('includes a client with bound_tools set (even without scope=agent)', async () => {
// Partial migration state: bindings copied over but scope not yet updated.
// Spend viewer should still surface this client.
await seedClient({ id: 'bound-no-scope', scope: 'admin', bound_tools: ['search'] });
const rows = await queryAgentClientSpend(engine);
expect(rows.length).toBe(1);
expect(rows[0].client_id).toBe('bound-no-scope');
});
it('excludes soft-deleted clients (deleted_at NOT NULL)', async () => {
await seedClient({ id: 'live', scope: 'read agent' });
await seedClient({ id: 'revoked', scope: 'read agent', deleted: true });
const rows = await queryAgentClientSpend(engine);
expect(rows.length).toBe(1);
expect(rows[0].client_id).toBe('live');
});
it('returns null cap_usd_per_day when budget unset (not 0)', async () => {
await seedClient({ id: 'no-cap', scope: 'read agent' });
const rows = await queryAgentClientSpend(engine);
expect(rows[0].cap_usd_per_day).toBe(null);
});
it('returns numeric cap_usd_per_day when budget set', async () => {
await seedClient({ id: 'cap-5', scope: 'read agent', budget_usd_per_day: 5.00 });
const rows = await queryAgentClientSpend(engine);
expect(rows[0].cap_usd_per_day).toBe(5);
});
it('returns 0 for spent_cents_today when no spend rows exist', async () => {
await seedClient({ id: 'fresh', scope: 'read agent' });
const rows = await queryAgentClientSpend(engine);
expect(rows[0].spent_cents_today).toBe(0);
});
it('sums today\'s mcp_spend_log for the client (UTC-day-aligned)', async () => {
await seedClient({ id: 'spent-some', scope: 'read agent' });
// Today's spend: 25 + 75 = 100 cents
await engine.executeRaw(
`INSERT INTO mcp_spend_log (client_id, operation, spend_cents, created_at)
VALUES ('spent-some', 'subagent_loop', 25, now()),
('spent-some', 'subagent_loop', 75, now())`,
);
const rows = await queryAgentClientSpend(engine);
expect(rows[0].spent_cents_today).toBe(100);
});
it('does NOT include yesterday\'s mcp_spend_log in today\'s total', async () => {
await seedClient({ id: 'mixed-days', scope: 'read agent' });
// Yesterday's spend (2 days ago to be safe across UTC midnight)
await engine.executeRaw(
`INSERT INTO mcp_spend_log (client_id, operation, spend_cents, created_at)
VALUES ('mixed-days', 'subagent_loop', 999, now() - interval '2 days')`,
);
// Today's spend
await engine.executeRaw(
`INSERT INTO mcp_spend_log (client_id, operation, spend_cents, created_at)
VALUES ('mixed-days', 'subagent_loop', 50, now())`,
);
const rows = await queryAgentClientSpend(engine);
expect(rows[0].spent_cents_today).toBe(50);
});
it('isolates spend by client_id (no cross-client leakage)', async () => {
await seedClient({ id: 'alice', scope: 'read agent' });
await seedClient({ id: 'bob', scope: 'read agent' });
await engine.executeRaw(
`INSERT INTO mcp_spend_log (client_id, operation, spend_cents, created_at)
VALUES ('alice', 'subagent_loop', 200, now()),
('bob', 'subagent_loop', 50, now())`,
);
const rows = await queryAgentClientSpend(engine);
const alice = rows.find(r => r.client_id === 'alice')!;
const bob = rows.find(r => r.client_id === 'bob')!;
expect(alice.spent_cents_today).toBe(200);
expect(bob.spent_cents_today).toBe(50);
});
it('sums pending mcp_spend_reservations (status=pending, non-expired)', async () => {
await seedClient({ id: 'in-flight', scope: 'read agent' });
const future = new Date(Date.now() + 10 * 60 * 1000); // 10min out
await engine.executeRaw(
`INSERT INTO mcp_spend_reservations
(reservation_id, client_id, estimated_cents, model, provider, status, expires_at)
VALUES ('00000000-0000-0000-0000-000000000001', 'in-flight', 30, 'm', 'p', 'pending', $1),
('00000000-0000-0000-0000-000000000002', 'in-flight', 40, 'm', 'p', 'pending', $1)`,
[future],
);
const rows = await queryAgentClientSpend(engine);
expect(rows[0].pending_cents).toBe(70);
});
it('excludes expired pending reservations from pending_cents', async () => {
await seedClient({ id: 'expired-pending', scope: 'read agent' });
const past = new Date(Date.now() - 60 * 1000);
const future = new Date(Date.now() + 60 * 1000);
await engine.executeRaw(
`INSERT INTO mcp_spend_reservations
(reservation_id, client_id, estimated_cents, model, provider, status, expires_at)
VALUES ('00000000-0000-0000-0000-000000000003', 'expired-pending', 99, 'm', 'p', 'pending', $1),
('00000000-0000-0000-0000-000000000004', 'expired-pending', 11, 'm', 'p', 'pending', $2)`,
[past, future],
);
const rows = await queryAgentClientSpend(engine);
expect(rows[0].pending_cents).toBe(11); // only the non-expired one
});
it('excludes settled reservations from pending_cents', async () => {
await seedClient({ id: 'settled-only', scope: 'read agent' });
const future = new Date(Date.now() + 60 * 1000);
await engine.executeRaw(
`INSERT INTO mcp_spend_reservations
(reservation_id, client_id, estimated_cents, actual_cents, model, provider, status, settled_at, expires_at)
VALUES ('00000000-0000-0000-0000-000000000005', 'settled-only', 100, 95, 'm', 'p', 'settled', now(), $1)`,
[future],
);
const rows = await queryAgentClientSpend(engine);
expect(rows[0].pending_cents).toBe(0);
});
it('counts active+waiting subagent jobs as inflight_count', async () => {
await seedClient({ id: 'busy-client', scope: 'read agent' });
// 1 active + 1 waiting + 1 waiting-children + 1 completed (excluded)
for (const status of ['active', 'waiting', 'waiting-children', 'completed']) {
await engine.executeRaw(
`INSERT INTO minion_jobs (name, status, data, queue, priority, created_at)
VALUES ('subagent', $1, $2::jsonb, 'default', 0, now())`,
[status, JSON.stringify({ prompt: 'x', __owner_client_id: 'busy-client' })],
);
}
const rows = await queryAgentClientSpend(engine);
expect(rows[0].inflight_count).toBe(3); // active + waiting + waiting-children
});
it('only counts subagent jobs (not shell/other minion jobs)', async () => {
await seedClient({ id: 'shell-too', scope: 'read agent' });
await engine.executeRaw(
`INSERT INTO minion_jobs (name, status, data, queue, priority, created_at)
VALUES ('shell', 'active', $1::jsonb, 'default', 0, now())`,
[JSON.stringify({ cmd: 'echo', __owner_client_id: 'shell-too' })],
);
const rows = await queryAgentClientSpend(engine);
expect(rows[0].inflight_count).toBe(0); // shell jobs don't count
});
it('orders results by client_name ASC (deterministic for UI rendering)', async () => {
await seedClient({ id: 'z-client', name: 'Zulu', scope: 'read agent' });
await seedClient({ id: 'a-client', name: 'Alpha', scope: 'read agent' });
await seedClient({ id: 'm-client', name: 'Mike', scope: 'read agent' });
const rows = await queryAgentClientSpend(engine);
expect(rows.map(r => r.client_name)).toEqual(['Alpha', 'Mike', 'Zulu']);
});
it('multi-word scope handled by string_to_array (agent + others)', async () => {
// Real-world clients have scopes like 'read write agent' (space-separated).
await seedClient({ id: 'multi-scope', scope: 'read write agent' });
const rows = await queryAgentClientSpend(engine);
expect(rows.length).toBe(1);
});
it('end-to-end happy path: all fields populated together', async () => {
await seedClient({ id: 'full-data', scope: 'read agent', budget_usd_per_day: 10.50 });
const future = new Date(Date.now() + 60_000);
await engine.executeRaw(
`INSERT INTO mcp_spend_log (client_id, operation, spend_cents, created_at)
VALUES ('full-data', 'subagent_loop', 250, now())`,
);
await engine.executeRaw(
`INSERT INTO mcp_spend_reservations
(reservation_id, client_id, estimated_cents, model, provider, status, expires_at)
VALUES ('00000000-0000-0000-0000-000000000006', 'full-data', 50, 'm', 'p', 'pending', $1)`,
[future],
);
await engine.executeRaw(
`INSERT INTO minion_jobs (name, status, data, queue, priority, created_at)
VALUES ('subagent', 'active', $1::jsonb, 'default', 0, now())`,
[JSON.stringify({ prompt: 'x', __owner_client_id: 'full-data' })],
);
const rows = await queryAgentClientSpend(engine);
expect(rows[0]).toEqual({
client_id: 'full-data',
client_name: 'full-data',
cap_usd_per_day: 10.5,
spent_cents_today: 250,
pending_cents: 50,
inflight_count: 1,
});
});
});
+35 -18
View File
@@ -165,17 +165,28 @@ describe('queue.add trusted-submit gate for subagent', () => {
expect(ok.name).toBe('subagent_aggregator');
});
test('v0.31.12: subagent with non-Anthropic data.model is rejected at submit time (Layer 1)', async () => {
// Codex F1 in v0.31.12 plan review: the subagent loop is Anthropic Messages
// API + prompt caching. A job submitted with `data.model = openai:gpt-5.5`
// would silently fail at runtime with a confusing provider error. The
// submit-time guard rejects BEFORE the job enters the queue.
await expect(
queue.add('subagent', { prompt: 'hi', model: 'openai:gpt-5.5' }, {}, { allowProtectedSubmit: true }),
).rejects.toThrow(/non-Anthropic/i);
test('v0.38 S1.7: subagent with any tool-supporting provider passes the queue gate', async () => {
// v0.38 D6/D7 — the Anthropic pin is removed. The gateway tool loop
// routes any provider with native tool calling. Submit-time guard now
// refuses ONLY on unusable:no_tools or unknown verdicts.
const openaiJob = await queue.add(
'subagent',
{ prompt: 'hi', model: 'openai:gpt-5.2' },
{},
{ allowProtectedSubmit: true },
);
expect(openaiJob.name).toBe('subagent');
const googleJob = await queue.add(
'subagent',
{ prompt: 'hi', model: 'google:gemini-1.5-pro' },
{},
{ allowProtectedSubmit: true },
);
expect(googleJob.name).toBe('subagent');
});
test('v0.31.12: subagent with Anthropic data.model still succeeds', async () => {
test('v0.38 S1.7: subagent with Anthropic data.model still succeeds', async () => {
const job = await queue.add(
'subagent',
{ prompt: 'hi', model: 'anthropic:claude-opus-4-7' },
@@ -185,15 +196,21 @@ describe('queue.add trusted-submit gate for subagent', () => {
expect(job.name).toBe('subagent');
});
test('v0.31.12: subagent with bare claude- model id passes (provider-prefix optional)', async () => {
// isAnthropicProvider accepts both `anthropic:claude-foo` and bare `claude-foo`.
const job = await queue.add(
'subagent',
{ prompt: 'hi', model: 'claude-sonnet-4-6' },
{},
{ allowProtectedSubmit: true },
);
expect(job.name).toBe('subagent');
test('v0.38 S1.7: subagent with unknown provider is rejected at submit time', async () => {
// The remaining hard reject — unknown providers can't be classified, so
// we refuse the job rather than risk burning money on something we
// can't verify supports tools.
await expect(
queue.add('subagent', { prompt: 'hi', model: 'madeup-provider:foo' }, {}, { allowProtectedSubmit: true }),
).rejects.toThrow(/unknown provider/i);
});
test('v0.38 S1.7: subagent with embedding-only provider (no chat) is rejected', async () => {
// Voyage has no chat touchpoint → classifyCapabilities returns 'unknown' →
// refused at submit. Same rejection path as unknown provider.
await expect(
queue.add('subagent', { prompt: 'hi', model: 'voyage:voyage-3-large' }, {}, { allowProtectedSubmit: true }),
).rejects.toThrow(/unknown provider/i);
});
});
+70
View File
@@ -0,0 +1,70 @@
import { describe, it, expect } from 'bun:test';
import { getProviderCapabilities, classifyCapabilities } from '../../src/core/ai/capabilities.ts';
describe('getProviderCapabilities (v0.38 Slice 1 — D6/D7 recipe-driven capabilities)', () => {
it('returns full capabilities for Anthropic (canonical reference)', () => {
const caps = getProviderCapabilities('anthropic:claude-sonnet-4-6');
expect(caps.supportsToolCalling).toBe(true);
expect(caps.supportsPromptCaching).toBe(true);
expect(caps.supportsParallelTools).toBe(true);
expect(caps.maxContext).toBe(200000);
});
it('returns capabilities for OpenAI (no prompt caching field set as true)', () => {
const caps = getProviderCapabilities('openai:gpt-5.2');
expect(caps.supportsToolCalling).toBe(true);
expect(caps.supportsPromptCaching).toBe(false); // OpenAI implicit caching doesn't get marked
expect(caps.maxContext).toBe(200000);
});
it('returns capabilities for Google Gemini', () => {
const caps = getProviderCapabilities('google:gemini-1.5-pro');
expect(caps.supportsToolCalling).toBe(true);
expect(caps.supportsPromptCaching).toBe(false);
expect(caps.maxContext).toBe(1000000); // Gemini 1.5 Pro
});
it('honors Anthropic alias (undated → dated)', () => {
const caps = getProviderCapabilities('anthropic:claude-haiku-4-5');
expect(caps.supportsToolCalling).toBe(true);
});
it('throws for unknown provider', () => {
expect(() => getProviderCapabilities('madeup-provider:foo')).toThrow();
});
it('throws for embedding-only provider (no chat touchpoint)', () => {
expect(() => getProviderCapabilities('voyage:voyage-3-large')).toThrow(
/does not offer a chat touchpoint/,
);
});
it('throws for missing colon', () => {
expect(() => getProviderCapabilities('claude-sonnet-4-6')).toThrow(/missing a provider prefix/);
});
});
describe('classifyCapabilities (D6 — three-tier capability verdict)', () => {
it('returns ok for fully-capable Anthropic models', () => {
expect(classifyCapabilities('anthropic:claude-sonnet-4-6')).toBe('ok');
expect(classifyCapabilities('anthropic:claude-opus-4-7')).toBe('ok');
});
it('returns degraded:no_caching for OpenAI (tools yes, caching no)', () => {
expect(classifyCapabilities('openai:gpt-5.2')).toBe('degraded:no_caching');
});
it('returns degraded:no_caching for Google Gemini', () => {
expect(classifyCapabilities('google:gemini-1.5-pro')).toBe('degraded:no_caching');
});
it('returns unknown for unrecognized providers', () => {
expect(classifyCapabilities('madeup:something')).toBe('unknown');
});
it('returns unknown for embedding-only providers (chat touchpoint missing)', () => {
// Voyage has no chat touchpoint → throws inside getProviderCapabilities
// → classifyCapabilities catches → returns 'unknown'.
expect(classifyCapabilities('voyage:voyage-3-large')).toBe('unknown');
});
});
+277
View File
@@ -0,0 +1,277 @@
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import {
toolLoop,
__setChatTransportForTests,
configureGateway,
resetGateway,
type ChatBlock,
type ToolHandler,
} from '../../src/core/ai/gateway.ts';
describe('gateway.toolLoop (v0.38 D11 — provider-agnostic loop control)', () => {
beforeEach(() => {
configureGateway({
chat_model: 'anthropic:claude-sonnet-4-6',
embedding_model: 'openai:text-embedding-3-large',
embedding_dimensions: 1536,
expansion_model: 'anthropic:claude-haiku-4-5',
env: { ANTHROPIC_API_KEY: 'stub', OPENAI_API_KEY: 'stub' },
});
});
afterEach(() => {
__setChatTransportForTests(null);
resetGateway();
});
it('exits cleanly on end stop_reason with no tools', async () => {
__setChatTransportForTests(async () => ({
text: 'hello world',
blocks: [{ type: 'text', text: 'hello world' }] as ChatBlock[],
stopReason: 'end',
usage: { input_tokens: 5, output_tokens: 2, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'anthropic:claude-sonnet-4-6',
providerId: 'anthropic',
}));
const result = await toolLoop({
initialMessages: [{ role: 'user', content: 'hi' }],
tools: [],
toolHandlers: new Map(),
});
expect(result.stopReason).toBe('end');
expect(result.finalText).toBe('hello world');
expect(result.totalTurns).toBe(0); // First turn ended cleanly without tool dispatch
expect(result.totalUsage.input_tokens).toBe(5);
});
it('dispatches a single tool call and feeds the result back to the next turn', async () => {
let turn = 0;
__setChatTransportForTests(async () => {
turn++;
if (turn === 1) {
return {
text: '',
blocks: [
{ type: 'tool-call', toolCallId: 'tc1', toolName: 'search', input: { q: 'foo' } },
] as ChatBlock[],
stopReason: 'tool_calls',
usage: { input_tokens: 10, output_tokens: 4, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'anthropic:claude-sonnet-4-6',
providerId: 'anthropic',
};
}
return {
text: 'final answer',
blocks: [{ type: 'text', text: 'final answer' }] as ChatBlock[],
stopReason: 'end',
usage: { input_tokens: 15, output_tokens: 5, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'anthropic:claude-sonnet-4-6',
providerId: 'anthropic',
};
});
let toolWasCalled = false;
const handler: ToolHandler = {
idempotent: true,
async execute(input) {
toolWasCalled = true;
expect(input).toEqual({ q: 'foo' });
return { ok: true, results: [{ slug: 'foo/bar' }] };
},
};
const result = await toolLoop({
initialMessages: [{ role: 'user', content: 'find foo' }],
tools: [{ name: 'search', description: 'search the brain', inputSchema: { type: 'object' } }],
toolHandlers: new Map([['search', handler]]),
});
expect(toolWasCalled).toBe(true);
expect(result.stopReason).toBe('end');
expect(result.finalText).toBe('final answer');
expect(result.totalUsage.input_tokens).toBe(25); // 10 + 15
expect(result.totalUsage.output_tokens).toBe(9); // 4 + 5
});
it('captures persistence callbacks in order: assistant → tool start → tool complete', async () => {
let turn = 0;
__setChatTransportForTests(async () => {
turn++;
if (turn === 1) {
return {
text: '',
blocks: [
{ type: 'tool-call', toolCallId: 'tc1', toolName: 'echo', input: { msg: 'hi' } },
] as ChatBlock[],
stopReason: 'tool_calls',
usage: { input_tokens: 5, output_tokens: 3, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'anthropic:claude-sonnet-4-6',
providerId: 'anthropic',
};
}
return {
text: 'done',
blocks: [{ type: 'text', text: 'done' }] as ChatBlock[],
stopReason: 'end',
usage: { input_tokens: 5, output_tokens: 1, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'anthropic:claude-sonnet-4-6',
providerId: 'anthropic',
};
});
const events: string[] = [];
await toolLoop({
initialMessages: [{ role: 'user', content: 'echo hi' }],
tools: [{ name: 'echo', description: 'echo', inputSchema: { type: 'object' } }],
toolHandlers: new Map([['echo', {
idempotent: true,
async execute(input) { events.push(`execute(${JSON.stringify(input)})`); return input; },
}]]),
onAssistantTurn: async (turnIdx, _msgIdx, _blocks, _usage, _model) => {
events.push(`onAssistantTurn(${turnIdx})`);
},
onToolCallStart: async (turnIdx, _msgIdx, ordinal, toolName, _input, providerToolCallId) => {
events.push(`onToolCallStart(turn=${turnIdx}, ordinal=${ordinal}, name=${toolName}, providerCallId=${providerToolCallId})`);
return { gbrainToolUseId: `gb-${turnIdx}-${ordinal}` };
},
onToolCallComplete: async (gbrainToolUseId, _output) => {
events.push(`onToolCallComplete(${gbrainToolUseId})`);
},
});
// Write-ordering invariant: assistant persisted BEFORE pending tool row;
// pending row persisted BEFORE execute; execute BEFORE complete.
expect(events[0]).toBe('onAssistantTurn(0)');
expect(events[1]).toMatch(/onToolCallStart\(turn=0, ordinal=0, name=echo/);
expect(events[2]).toMatch(/execute/);
expect(events[3]).toMatch(/onToolCallComplete\(gb-0-0\)/);
expect(events[4]).toBe('onAssistantTurn(1)'); // final assistant turn
});
it('replay short-circuits a complete prior tool execution', async () => {
let chatCalls = 0;
__setChatTransportForTests(async () => {
chatCalls++;
// Turn 1 emits a tool call. Turn 2 finishes.
if (chatCalls === 1) {
return {
text: '',
blocks: [
{ type: 'tool-call', toolCallId: 'provider-id-1', toolName: 'work', input: { x: 1 } },
] as ChatBlock[],
stopReason: 'tool_calls',
usage: { input_tokens: 1, output_tokens: 1, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'anthropic:claude-sonnet-4-6',
providerId: 'anthropic',
};
}
return {
text: 'fin',
blocks: [{ type: 'text', text: 'fin' }] as ChatBlock[],
stopReason: 'end',
usage: { input_tokens: 1, output_tokens: 1, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'anthropic:claude-sonnet-4-6',
providerId: 'anthropic',
};
});
let executed = false;
const result = await toolLoop({
initialMessages: [{ role: 'user', content: 'go' }],
tools: [{ name: 'work', description: 'w', inputSchema: { type: 'object' } }],
toolHandlers: new Map([['work', {
idempotent: false,
async execute() { executed = true; return 'fresh'; },
}]]),
onToolCallStart: async () => ({ gbrainToolUseId: 'gb-replay-key' }),
replayState: {
priorMessages: [],
priorTools: new Map([['gb-replay-key', {
status: 'complete' as const,
output: 'from-prior-run',
}]]),
nextTurnIdx: 0,
nextMessageIdx: 0,
},
});
expect(executed).toBe(false); // replay short-circuit
expect(result.stopReason).toBe('end');
expect(result.finalText).toBe('fin');
});
it('refuses replay of non-idempotent pending tool with unrecoverable error', async () => {
__setChatTransportForTests(async () => ({
text: '',
blocks: [
{ type: 'tool-call', toolCallId: 'tc-non-idem', toolName: 'mutate', input: {} },
] as ChatBlock[],
stopReason: 'tool_calls',
usage: { input_tokens: 1, output_tokens: 1, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'anthropic:claude-sonnet-4-6',
providerId: 'anthropic',
}));
await expect(
toolLoop({
initialMessages: [{ role: 'user', content: 'go' }],
tools: [{ name: 'mutate', description: 'm', inputSchema: { type: 'object' } }],
toolHandlers: new Map([['mutate', { idempotent: false, async execute() { return null; } }]]),
onToolCallStart: async () => ({ gbrainToolUseId: 'gb-pending-key' }),
replayState: {
priorMessages: [],
priorTools: new Map([['gb-pending-key', { status: 'pending' as const }]]),
nextTurnIdx: 0,
nextMessageIdx: 0,
},
}),
).rejects.toThrow(/non-idempotent.*pending/i);
});
it('hits max_turns when the model keeps calling tools', async () => {
__setChatTransportForTests(async () => ({
text: '',
blocks: [
{ type: 'tool-call', toolCallId: `tc-${Math.random()}`, toolName: 'loop', input: {} },
] as ChatBlock[],
stopReason: 'tool_calls',
usage: { input_tokens: 1, output_tokens: 1, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'anthropic:claude-sonnet-4-6',
providerId: 'anthropic',
}));
const result = await toolLoop({
initialMessages: [{ role: 'user', content: 'loop' }],
tools: [{ name: 'loop', description: 'l', inputSchema: { type: 'object' } }],
toolHandlers: new Map([['loop', { idempotent: true, async execute() { return null; } }]]),
maxTurns: 3,
});
expect(result.stopReason).toBe('max_turns');
expect(result.totalTurns).toBeGreaterThanOrEqual(3);
});
it('returns refusal reason without dispatching tools when stopReason=refusal', async () => {
__setChatTransportForTests(async () => ({
text: 'I cannot help with that',
blocks: [{ type: 'text', text: 'I cannot help with that' }] as ChatBlock[],
stopReason: 'refusal',
usage: { input_tokens: 1, output_tokens: 5, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'anthropic:claude-sonnet-4-6',
providerId: 'anthropic',
}));
let toolWasCalled = false;
const result = await toolLoop({
initialMessages: [{ role: 'user', content: 'bad request' }],
tools: [{ name: 'work', description: 'w', inputSchema: { type: 'object' } }],
toolHandlers: new Map([['work', { idempotent: true, async execute() { toolWasCalled = true; return null; } }]]),
});
expect(toolWasCalled).toBe(false);
expect(result.stopReason).toBe('refusal');
expect(result.finalText).toBe('I cannot help with that');
});
});
@@ -0,0 +1,512 @@
/**
* E2E: SIGKILL crash-replay reconciliation across the provider matrix.
*
* This is the LOAD-BEARING test the v0.38 CEO + Codex reviews called out
* as CRITICAL before ship. The contract:
*
* A subagent job whose worker crashes mid-tool-dispatch MUST reconcile
* correctly on the next worker claim. Specifically:
* 1. Tool executions marked status='complete' (or 'failed') in the DB
* before the crash MUST NOT be re-executed.
* 2. The reconciliation key MUST work across provider response shapes
* — the gbrain-owned stable key (ordinal + gbrain_tool_use_id from
* migration v81) is the canonical key, not the provider tool_use_id.
* 3. Legacy v1 rows (pre-v0.38, ordinal=NULL, gbrain_tool_use_id=NULL)
* get a synthesized stable key via the D5 read-time shim and replay
* the same way.
*
* We don't actually SIGKILL a subprocess (heavy, slow, flaky in CI). Instead
* we SIMULATE the crashed state by pre-seeding subagent_messages +
* subagent_tool_executions in the shape the DB would have post-crash, then
* invoke the handler and assert it reconciles correctly without
* re-executing the prior tools.
*
* Per-provider matrix: gateway.chat() abstracts providers through the
* Vercel AI SDK, but each provider returns slightly different response
* shapes (provider id, finishReason mapping, usage field names, content
* block ordering). We stub the second-turn response with provider-specific
* shapes to prove the reconciler handles all five without leaking
* provider-specific assumptions.
*
* Plan reference: ~/.claude/plans/system-instruction-you-are-working-shimmying-breeze.md
* (Risk register row "Stable-ID INSERT race across replays" + Slice 1
* verification step 4 "SIGKILL worker mid-call").
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'bun:test';
import { PGLiteEngine } from '../../src/core/pglite-engine.ts';
import { resetPgliteState } from '../helpers/reset-pglite.ts';
import { makeSubagentHandler } from '../../src/core/minions/handlers/subagent.ts';
import type { MinionJobContext, ToolDef, ToolCtx, ContentBlock } from '../../src/core/minions/types.ts';
import {
__setChatTransportForTests,
configureGateway,
resetGateway,
type ChatBlock,
type ChatResult,
} from '../../src/core/ai/gateway.ts';
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
await engine.setConfig('version', '85');
await engine.setConfig('agent.use_gateway_loop', 'true');
configureGateway({
chat_model: 'anthropic:claude-sonnet-4-6',
embedding_model: 'openai:text-embedding-3-large',
embedding_dimensions: 1536,
expansion_model: 'anthropic:claude-haiku-4-5',
env: { ANTHROPIC_API_KEY: 'stub', OPENAI_API_KEY: 'stub' },
});
});
afterEach(() => {
__setChatTransportForTests(null);
});
afterAll(() => {
resetGateway();
});
/**
* Provider matrix. Each entry is one provider whose response shape the
* gateway path must reconcile across. The gateway.chat() normalizer
* collapses these to ChatBlock[] before they hit our toolLoop, but the
* test stubs the normalizer's output to verify the loop downstream
* doesn't leak any provider-specific assumption.
*/
type ProviderShape = {
providerId: string;
modelId: string;
// The second-turn (post-replay) response.
finalResponse: ChatResult;
};
const PROVIDER_MATRIX: ProviderShape[] = [
{
providerId: 'anthropic',
modelId: 'anthropic:claude-sonnet-4-6',
finalResponse: {
text: 'anthropic resumed: search result was helpful',
blocks: [{ type: 'text', text: 'anthropic resumed: search result was helpful' }] as ChatBlock[],
stopReason: 'end',
usage: { input_tokens: 50, output_tokens: 8, cache_read_tokens: 30, cache_creation_tokens: 0 },
model: 'anthropic:claude-sonnet-4-6',
providerId: 'anthropic',
},
},
{
providerId: 'openai',
modelId: 'openai:gpt-5.2',
finalResponse: {
text: 'openai resumed: synthesized answer from prior tool result',
blocks: [{ type: 'text', text: 'openai resumed: synthesized answer from prior tool result' }] as ChatBlock[],
stopReason: 'end',
usage: { input_tokens: 45, output_tokens: 10, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'openai:gpt-5.2',
providerId: 'openai',
},
},
{
providerId: 'google',
modelId: 'google:gemini-1.5-pro',
finalResponse: {
text: 'gemini resumed: 1M-context replay went fine',
blocks: [{ type: 'text', text: 'gemini resumed: 1M-context replay went fine' }] as ChatBlock[],
stopReason: 'end',
usage: { input_tokens: 80, output_tokens: 6, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'google:gemini-1.5-pro',
providerId: 'google',
},
},
{
providerId: 'openrouter',
modelId: 'openrouter:anthropic/claude-sonnet-4-6',
finalResponse: {
text: 'openrouter resumed: proxied claude response',
blocks: [{ type: 'text', text: 'openrouter resumed: proxied claude response' }] as ChatBlock[],
stopReason: 'end',
usage: { input_tokens: 50, output_tokens: 7, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'openrouter:anthropic/claude-sonnet-4-6',
providerId: 'openrouter',
},
},
{
providerId: 'deepseek',
modelId: 'deepseek:deepseek-chat',
// Representative openai-compatible recipe with native chat + tools.
// (LiteLLM proxy was the original 5th slot in the plan but its recipe
// declares no chat touchpoint — it's embedding-only. Deepseek is the
// matching openai-compatible chat provider with tool calling.)
finalResponse: {
text: 'deepseek resumed: openai-compatible chat works',
blocks: [{ type: 'text', text: 'deepseek resumed: openai-compatible chat works' }] as ChatBlock[],
stopReason: 'end',
usage: { input_tokens: 40, output_tokens: 9, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'deepseek:deepseek-chat',
providerId: 'deepseek',
},
},
];
/**
* Stub tool registry that records every execution. Tests assert
* `executions.length === 0` to prove replay short-circuit.
*/
function makeStubTools(executions: Array<{ name: string; input: unknown }>): ToolDef[] {
return [
{
name: 'search',
description: 'stub search',
input_schema: { type: 'object' },
idempotent: true,
async execute(input: unknown, _ctx: ToolCtx) {
executions.push({ name: 'search', input });
return { results: [{ slug: 'wiki/foo' }] };
},
},
];
}
function buildHandler(toolRegistry: ToolDef[]) {
return makeSubagentHandler({
engine,
config: {} as any,
toolRegistry,
makeAnthropic: () => ({ messages: { create: async () => { throw new Error('legacy path should not be invoked'); } } }) as any,
});
}
/**
* Seed a "crashed-mid-loop" state for jobId:
* - 1 user message at idx 0 (the seed prompt)
* - 1 assistant message at idx 1 containing a tool-call block
* - 1 subagent_tool_executions row with status='complete' + the result
* the crashed worker had ALREADY written before SIGKILL
*
* `shape` controls whether the rows are v1 (pre-v0.38: Anthropic content
* blocks, ordinal=NULL, gbrain_tool_use_id=NULL) or v2 (post-v0.38:
* ChatBlock content, ordinal+gbrain_tool_use_id populated).
*/
async function seedCrashedState(
prompt: string,
shape: 'v1' | 'v2',
): Promise<{ jobId: number; toolUseId: string; gbrainId: string | null }> {
const jobRows = await engine.executeRaw<{ id: number }>(
`INSERT INTO minion_jobs (name, status, data, queue, priority, created_at)
VALUES ('subagent', 'active', $1::jsonb, 'default', 0, now())
RETURNING id`,
[JSON.stringify({ prompt })],
);
const jobId = jobRows[0].id;
// Seed user message at idx 0.
await engine.executeRaw(
`INSERT INTO subagent_messages
(job_id, message_idx, role, content_blocks, tokens_in, tokens_out,
tokens_cache_read, tokens_cache_create, model)
VALUES ($1, 0, 'user', $2::jsonb, NULL, NULL, NULL, NULL, NULL)`,
[
jobId,
JSON.stringify(shape === 'v1'
? [{ type: 'text', text: prompt }]
: [{ type: 'text', text: prompt }]),
],
);
// Seed assistant message at idx 1 with a tool-call block.
const toolUseId = shape === 'v1' ? 'toolu_v1_crashed' : 'provider-tc-v2-crashed';
const assistantBlocks: ContentBlock[] = shape === 'v1'
? [{ type: 'tool_use', id: toolUseId, name: 'search', input: { q: 'foo' } }]
: [{ type: 'tool-call' as any, toolCallId: toolUseId, toolName: 'search', input: { q: 'foo' } } as any];
await engine.executeRaw(
`INSERT INTO subagent_messages
(job_id, message_idx, role, content_blocks, tokens_in, tokens_out,
tokens_cache_read, tokens_cache_create, model)
VALUES ($1, 1, 'assistant', $2::jsonb, 10, 5, 0, 0, 'anthropic:claude-sonnet-4-6')`,
[jobId, JSON.stringify(assistantBlocks)],
);
// Seed the tool execution row. The crashed worker completed the tool
// and persisted the result, but crashed before persisting the next
// user message (the tool_result wrapper). Replay must see this as done.
const priorOutput = JSON.stringify({ results: ['prior'] });
let gbrainId: string | null = null;
if (shape === 'v2') {
gbrainId = '01987654-3210-7000-8000-aaaaaaaaaaaa';
await engine.executeRaw(
`INSERT INTO subagent_tool_executions
(job_id, message_idx, tool_use_id, tool_name, input, status,
schema_version, ordinal, gbrain_tool_use_id, output)
VALUES ($1, 1, $2, 'search', '{}'::jsonb, 'complete',
2, 0, $3::uuid, $4::jsonb)`,
[jobId, toolUseId, gbrainId, priorOutput],
);
} else {
// v1 row: no ordinal, no gbrain_tool_use_id.
await engine.executeRaw(
`INSERT INTO subagent_tool_executions
(job_id, message_idx, tool_use_id, tool_name, input, status,
schema_version, output)
VALUES ($1, 1, $2, 'search', '{}'::jsonb, 'complete',
1, $3::jsonb)`,
[jobId, toolUseId, priorOutput],
);
}
return { jobId, toolUseId, gbrainId };
}
async function makeCrashedCtx(jobId: number, prompt: string, modelId: string): Promise<MinionJobContext> {
const abortCtrl = new AbortController();
const shutdownCtrl = new AbortController();
return {
id: jobId,
name: 'subagent',
data: { prompt, model: modelId },
attempts_made: 1, // crashed once
signal: abortCtrl.signal,
shutdownSignal: shutdownCtrl.signal,
updateProgress: async () => {},
updateTokens: async () => {},
log: async () => {},
isActive: async () => true,
readInbox: async () => [],
};
}
// ── Tests ───────────────────────────────────────────────────
describe('SIGKILL crash-replay reconciliation across provider matrix (v0.38 LOAD-BEARING)', () => {
describe.each(PROVIDER_MATRIX)('provider $providerId', (provider) => {
it('replay short-circuits the prior complete tool (v2 shape, gbrain_tool_use_id key)', async () => {
// Stub the SECOND turn — replay should NOT call gateway.chat() for
// turn 1 (the tool dispatch already happened pre-crash). It should
// immediately re-feed the tool_result and ask the LLM for the final
// text answer.
__setChatTransportForTests(async () => provider.finalResponse);
const executions: Array<{ name: string; input: unknown }> = [];
const tools = makeStubTools(executions);
const handler = buildHandler(tools);
const { jobId } = await seedCrashedState('find foo', 'v2');
const ctx = await makeCrashedCtx(jobId, 'find foo', provider.modelId);
const result = await handler(ctx);
// LOAD-BEARING: prior tool MUST NOT re-execute.
expect(executions.length).toBe(0);
// Final result comes from the stubbed second turn.
expect(result.result).toBe(provider.finalResponse.text);
expect(result.stop_reason).toBe('end_turn');
// The prior complete tool row is still status='complete' (not overwritten).
const toolRows = await engine.executeRaw<Record<string, unknown>>(
`SELECT status FROM subagent_tool_executions WHERE job_id = $1`,
[jobId],
);
expect(toolRows.length).toBe(1);
expect(toolRows[0].status).toBe('complete');
});
it('replay short-circuits the prior complete tool (v1 legacy shape, D5 synthesized key)', async () => {
__setChatTransportForTests(async () => provider.finalResponse);
const executions: Array<{ name: string; input: unknown }> = [];
const tools = makeStubTools(executions);
const handler = buildHandler(tools);
const { jobId } = await seedCrashedState('find foo (v1)', 'v1');
const ctx = await makeCrashedCtx(jobId, 'find foo (v1)', provider.modelId);
const result = await handler(ctx);
// LOAD-BEARING: v1 legacy rows reconcile via D5 synthesized stable
// key — the prior tool MUST NOT re-execute even though it predates
// the gbrain_tool_use_id column.
expect(executions.length).toBe(0);
expect(result.result).toBe(provider.finalResponse.text);
expect(result.stop_reason).toBe('end_turn');
});
});
describe('non-idempotent tool with pending status (unrecoverable error)', () => {
it('throws unrecoverable when a non-idempotent tool is pending on resume', async () => {
// Resume stub re-emits the SAME tool call the worker crashed on.
// The gateway loop assigns the same (job_id, message_idx, ordinal)
// key, the existing row (status=pending) is read back via RETURNING,
// and the priorTools map lookup hits with status='pending'. Since the
// tool is non-idempotent, the loop throws unrecoverable rather than
// re-execute and risk a double side-effect.
__setChatTransportForTests(async () => ({
text: '',
blocks: [
{ type: 'tool-call', toolCallId: 'tc-pending', toolName: 'put_page', input: { slug: 'foo' } },
] as ChatBlock[],
stopReason: 'tool_calls',
usage: { input_tokens: 5, output_tokens: 2, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'anthropic:claude-sonnet-4-6',
providerId: 'anthropic',
} satisfies ChatResult));
const tools: ToolDef[] = [{
name: 'put_page',
description: 'non-idempotent stub',
input_schema: { type: 'object' },
idempotent: false,
async execute() { throw new Error('should not be called on replay'); },
}];
const handler = buildHandler(tools);
// Seed: crashed mid-loop with a pending non-idempotent tool exec.
// The user prompt is at idx 0 (the seed write subagent.ts does).
// The crashed worker started executing put_page at message_idx=2
// (which is the NEW assistant turn the resume will generate),
// ordinal=0. priorTools must surface this as status='pending'.
const jobRows = await engine.executeRaw<{ id: number }>(
`INSERT INTO minion_jobs (name, status, data, queue, priority, created_at)
VALUES ('subagent', 'active', '{}'::jsonb, 'default', 0, now())
RETURNING id`,
);
const jobId = jobRows[0].id;
const gbrainId = '01987654-3210-7000-8000-bbbbbbbbbbbb';
// Just the user prompt — no prior assistant turn, so the resume
// generates the first assistant turn fresh at message_idx=1, and
// the tool dispatch at ordinal=0 will hit the pre-seeded pending row.
await engine.executeRaw(
`INSERT INTO subagent_messages
(job_id, message_idx, role, content_blocks, model)
VALUES ($1, 0, 'user', '[{"type":"text","text":"do it"}]'::jsonb, NULL)`,
[jobId],
);
await engine.executeRaw(
`INSERT INTO subagent_tool_executions
(job_id, message_idx, tool_use_id, tool_name, input, status,
schema_version, ordinal, gbrain_tool_use_id)
VALUES ($1, 1, 'tc-pending', 'put_page', '{}'::jsonb, 'pending',
2, 0, $2::uuid)`,
[jobId, gbrainId],
);
const ctx = await makeCrashedCtx(jobId, 'do it', 'anthropic:claude-sonnet-4-6');
// The gateway-loop throws "non-idempotent ... pending on resume; cannot safely re-run".
// The subagent.ts handler doesn't catch this — it bubbles. Asserting it bubbles
// out as an Error is the contract; an UnrecoverableError variant would be a future
// upgrade.
await expect(handler(ctx)).rejects.toThrow(/non-idempotent.*pending/i);
});
});
describe('failed tool on prior turn — replay surfaces the error to the LLM', () => {
it('prior failed tool replays as is_error result, loop completes', async () => {
const tools = makeStubTools([]);
const handler = buildHandler(tools);
const jobRows = await engine.executeRaw<{ id: number }>(
`INSERT INTO minion_jobs (name, status, data, queue, priority, created_at)
VALUES ('subagent', 'active', '{}'::jsonb, 'default', 0, now())
RETURNING id`,
);
const jobId = jobRows[0].id;
const gbrainId = '01987654-3210-7000-8000-cccccccccccc';
// User msg + assistant turn with tool_use + failed tool row.
await engine.executeRaw(
`INSERT INTO subagent_messages
(job_id, message_idx, role, content_blocks, model)
VALUES ($1, 0, 'user', '[{"type":"text","text":"try"}]'::jsonb, NULL),
($1, 1, 'assistant', $2::jsonb, 'anthropic:claude-sonnet-4-6')`,
[
jobId,
JSON.stringify([{ type: 'tool-call', toolCallId: 'tc-failed', toolName: 'search', input: {} }]),
],
);
await engine.executeRaw(
`INSERT INTO subagent_tool_executions
(job_id, message_idx, tool_use_id, tool_name, input, status, error,
schema_version, ordinal, gbrain_tool_use_id)
VALUES ($1, 1, 'tc-failed', 'search', '{}'::jsonb, 'failed', 'rate limited',
2, 0, $2::uuid)`,
[jobId, gbrainId],
);
// Second turn: LLM acknowledges the failure and ends.
__setChatTransportForTests(async () => ({
text: 'I see search failed (rate limited). Aborting.',
blocks: [{ type: 'text', text: 'I see search failed (rate limited). Aborting.' }] as ChatBlock[],
stopReason: 'end',
usage: { input_tokens: 30, output_tokens: 11, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'anthropic:claude-sonnet-4-6',
providerId: 'anthropic',
} satisfies ChatResult));
const ctx = await makeCrashedCtx(jobId, 'try', 'anthropic:claude-sonnet-4-6');
const result = await handler(ctx);
expect(result.result).toContain('search failed');
expect(result.stop_reason).toBe('end_turn');
// The prior failed row stays failed (not overwritten).
const finalRows = await engine.executeRaw<Record<string, unknown>>(
`SELECT status, error FROM subagent_tool_executions WHERE job_id = $1`,
[jobId],
);
expect(finalRows[0].status).toBe('failed');
expect(finalRows[0].error).toBe('rate limited');
});
});
describe('reconciliation key uniqueness — concurrent replays don\'t double-insert', () => {
it('two simultaneous replay attempts both see the same prior tool outcome (idempotent reconciliation)', async () => {
// Simulates: worker A crashed → worker B picks up the job → worker C
// also tries to pick it up (lock-contention edge). Both must reconcile
// to the SAME stable key and skip the prior tool execution.
const { jobId } = await seedCrashedState('concurrent replay', 'v2');
const transport = async () => PROVIDER_MATRIX[0].finalResponse;
__setChatTransportForTests(transport);
const executions: Array<{ name: string; input: unknown }> = [];
const tools = makeStubTools(executions);
// Run handler twice in parallel against the same job. Real workers
// would use the queue's lock to serialize, but the reconciler MUST
// be safe even under spurious double-invocation.
const handler1 = buildHandler(tools);
const handler2 = buildHandler(tools);
const ctx1 = await makeCrashedCtx(jobId, 'concurrent replay', 'anthropic:claude-sonnet-4-6');
const ctx2 = await makeCrashedCtx(jobId, 'concurrent replay', 'anthropic:claude-sonnet-4-6');
const [result1, result2] = await Promise.all([handler1(ctx1), handler2(ctx2)]);
// Neither path re-executed the prior tool.
expect(executions.length).toBe(0);
expect(result1.result).toBe(PROVIDER_MATRIX[0].finalResponse.text);
expect(result2.result).toBe(PROVIDER_MATRIX[0].finalResponse.text);
// Only one prior tool exec row exists (no duplicate inserts).
const toolRows = await engine.executeRaw<Record<string, unknown>>(
`SELECT COUNT(*)::int AS n FROM subagent_tool_executions WHERE job_id = $1`,
[jobId],
);
expect(Number(toolRows[0].n)).toBe(1);
});
});
});
+426
View File
@@ -0,0 +1,426 @@
/**
* E2E: runSubagentViaGateway integration path (v0.38 Slice 1 + S1.5).
*
* Exercises the FULL gateway-native subagent handler path end-to-end:
* - Handler entry → reads `agent.use_gateway_loop` config → routes to gateway path
* - runSubagentViaGateway builds ChatToolDef[] + ToolHandler Map from ToolDef
* - Calls gateway.toolLoop() with persistence callbacks
* - Callbacks write subagent_messages (v2 ChatBlock shape) +
* subagent_tool_executions (with ordinal + gbrain_tool_use_id) under
* the write-ordering invariant
* - Returns SubagentResult mapped from gateway loop result
*
* Hermetic: PGLite in-memory engine, gateway transport stubbed via
* `__setChatTransportForTests`. No ANTHROPIC_API_KEY, no real Anthropic
* SDK instantiation (we stub `makeAnthropic` so the legacy-path fallback
* doesn't trip on missing env).
*
* Plan reference: ~/.claude/plans/system-instruction-you-are-working-shimmying-breeze.md
* (Slice 1 verification step 6 + cross-provider crash-replay regression — the
* load-bearing test the CEO/codex review called out before v0.38 ships).
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { PGLiteEngine } from '../../src/core/pglite-engine.ts';
import { resetPgliteState } from '../helpers/reset-pglite.ts';
import { makeSubagentHandler } from '../../src/core/minions/handlers/subagent.ts';
import type { MinionJobContext, ToolDef, ToolCtx } from '../../src/core/minions/types.ts';
import {
__setChatTransportForTests,
configureGateway,
resetGateway,
type ChatBlock,
type ChatResult,
} from '../../src/core/ai/gateway.ts';
// ── Helpers ─────────────────────────────────────────────────
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
await engine.setConfig('version', '85');
await engine.setConfig('agent.use_gateway_loop', 'true');
configureGateway({
chat_model: 'anthropic:claude-sonnet-4-6',
embedding_model: 'openai:text-embedding-3-large',
embedding_dimensions: 1536,
expansion_model: 'anthropic:claude-haiku-4-5',
env: { ANTHROPIC_API_KEY: 'stub', OPENAI_API_KEY: 'stub' },
});
});
function clearGateway(): void {
__setChatTransportForTests(null);
resetGateway();
}
interface FakeJobOpts {
prompt: string;
model?: string;
allowed_tools?: string[];
}
async function makeFakeJob(opts: FakeJobOpts): Promise<{ jobId: number; ctx: MinionJobContext; tokenSink: any[] }> {
// Insert a minion_jobs row so foreign keys validate (subagent_tool_executions.job_id FK).
const rows = await engine.executeRaw<{ id: number }>(
`INSERT INTO minion_jobs (name, status, data, queue, priority, created_at)
VALUES ('subagent', 'active', $1::jsonb, 'default', 0, now())
RETURNING id`,
[JSON.stringify({ prompt: opts.prompt, model: opts.model, allowed_tools: opts.allowed_tools })],
);
const jobId = rows[0].id;
const tokenSink: Array<{ input?: number; output?: number; cache_read?: number }> = [];
const abortCtrl = new AbortController();
const shutdownCtrl = new AbortController();
const ctx: MinionJobContext = {
id: jobId,
name: 'subagent',
data: { prompt: opts.prompt, model: opts.model, allowed_tools: opts.allowed_tools },
attempts_made: 0,
signal: abortCtrl.signal,
shutdownSignal: shutdownCtrl.signal,
updateProgress: async () => {},
updateTokens: async (t) => { tokenSink.push(t); },
log: async () => {},
isActive: async () => true,
readInbox: async () => [],
};
return { jobId, ctx, tokenSink };
}
/**
* Stub ToolDef registry — avoids pulling in buildBrainTools (which needs
* config + engine + brain setup). Tools are simple in-test functions.
*/
function makeStubTools(executions: Array<{ name: string; input: unknown; ts: number }>): ToolDef[] {
return [
{
name: 'search',
description: 'stub search',
input_schema: { type: 'object' },
idempotent: true,
async execute(input: unknown, _ctx: ToolCtx) {
executions.push({ name: 'search', input, ts: Date.now() });
return { results: [{ slug: 'wiki/foo' }] };
},
},
{
name: 'put_page',
description: 'stub put_page (non-idempotent)',
input_schema: { type: 'object' },
idempotent: false,
async execute(input: unknown, _ctx: ToolCtx) {
executions.push({ name: 'put_page', input, ts: Date.now() });
return { saved: true };
},
},
{
name: 'always_fail',
description: 'stub that always throws',
input_schema: { type: 'object' },
idempotent: true,
async execute(_input: unknown, _ctx: ToolCtx) {
throw new Error('intentional tool failure');
},
},
];
}
/**
* Build the handler with a stubbed Anthropic constructor so the legacy
* code path's `new Anthropic()` at construction never fires (we route
* through the gateway path; the legacy client is unused).
*/
function buildHandler(toolRegistry: ToolDef[]) {
return makeSubagentHandler({
engine,
config: {} as any,
toolRegistry,
makeAnthropic: () => ({ messages: { create: async () => { throw new Error('legacy path should not be invoked'); } } }) as any,
});
}
// ── Tests ───────────────────────────────────────────────────
describe('runSubagentViaGateway (v0.38 Slice 1 — full handler path through gateway.toolLoop)', () => {
afterAll(() => clearGateway());
it('happy path 1-turn: gateway returns text, handler returns SubagentResult', async () => {
__setChatTransportForTests(async () => ({
text: 'all done',
blocks: [{ type: 'text', text: 'all done' }] as ChatBlock[],
stopReason: 'end',
usage: { input_tokens: 12, output_tokens: 3, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'anthropic:claude-sonnet-4-6',
providerId: 'anthropic',
} satisfies ChatResult));
const executions: Array<{ name: string; input: unknown; ts: number }> = [];
const tools = makeStubTools(executions);
const handler = buildHandler(tools);
const { jobId, ctx } = await makeFakeJob({ prompt: 'hello', model: 'anthropic:claude-sonnet-4-6' });
const result = await handler(ctx);
expect(result.result).toBe('all done');
expect(result.stop_reason).toBe('end_turn');
expect(result.tokens.in).toBeGreaterThanOrEqual(12);
expect(result.tokens.out).toBeGreaterThanOrEqual(3);
expect(executions.length).toBe(0); // no tools called
// Verify persistence: 1 seed user message + 1 assistant message.
const messages = await engine.executeRaw<Record<string, unknown>>(
`SELECT message_idx, role FROM subagent_messages WHERE job_id = $1 ORDER BY message_idx`,
[jobId],
);
expect(messages.length).toBe(2);
expect(messages[0].role).toBe('user');
expect(messages[0].message_idx).toBe(0);
expect(messages[1].role).toBe('assistant');
expect(messages[1].message_idx).toBe(1);
});
it('happy path 2-turn with tool: dispatches, persists v2 stable ID, returns final text', async () => {
let turn = 0;
__setChatTransportForTests(async () => {
turn++;
if (turn === 1) {
return {
text: '',
blocks: [
{ type: 'tool-call', toolCallId: 'provider-tc-1', toolName: 'search', input: { q: 'acme' } },
] as ChatBlock[],
stopReason: 'tool_calls',
usage: { input_tokens: 20, output_tokens: 8, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'anthropic:claude-sonnet-4-6',
providerId: 'anthropic',
} satisfies ChatResult;
}
return {
text: 'found acme corp',
blocks: [{ type: 'text', text: 'found acme corp' }] as ChatBlock[],
stopReason: 'end',
usage: { input_tokens: 25, output_tokens: 4, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'anthropic:claude-sonnet-4-6',
providerId: 'anthropic',
} satisfies ChatResult;
});
const executions: Array<{ name: string; input: unknown; ts: number }> = [];
const tools = makeStubTools(executions);
const handler = buildHandler(tools);
const { jobId, ctx } = await makeFakeJob({
prompt: 'find acme',
model: 'anthropic:claude-sonnet-4-6',
allowed_tools: ['search'],
});
const result = await handler(ctx);
expect(result.result).toBe('found acme corp');
expect(result.stop_reason).toBe('end_turn');
expect(executions.length).toBe(1);
expect(executions[0].name).toBe('search');
expect(executions[0].input).toEqual({ q: 'acme' });
// Verify v2 stable-ID persistence: ordinal + gbrain_tool_use_id populated.
const toolRows = await engine.executeRaw<Record<string, unknown>>(
`SELECT message_idx, tool_use_id, tool_name, status, ordinal,
gbrain_tool_use_id::text AS gbrain_tool_use_id, schema_version
FROM subagent_tool_executions
WHERE job_id = $1`,
[jobId],
);
expect(toolRows.length).toBe(1);
expect(toolRows[0].tool_name).toBe('search');
expect(toolRows[0].status).toBe('complete');
expect(toolRows[0].ordinal).toBe(0);
expect(toolRows[0].schema_version).toBe(2); // v0.38 write
expect(String(toolRows[0].gbrain_tool_use_id)).toMatch(/^[0-9a-f-]{36}$/); // UUID v7
expect(toolRows[0].tool_use_id).toBe('provider-tc-1'); // provider id preserved
// Token accumulation across both turns.
expect(result.tokens.in).toBe(45); // 20 + 25
expect(result.tokens.out).toBe(12); // 8 + 4
});
it('tool error path: handler persists status=failed, loop continues with error feedback', async () => {
let turn = 0;
__setChatTransportForTests(async () => {
turn++;
if (turn === 1) {
return {
text: '',
blocks: [
{ type: 'tool-call', toolCallId: 'tc-fail', toolName: 'always_fail', input: {} },
] as ChatBlock[],
stopReason: 'tool_calls',
usage: { input_tokens: 5, output_tokens: 2, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'anthropic:claude-sonnet-4-6',
providerId: 'anthropic',
} satisfies ChatResult;
}
return {
text: 'sorry that failed',
blocks: [{ type: 'text', text: 'sorry that failed' }] as ChatBlock[],
stopReason: 'end',
usage: { input_tokens: 8, output_tokens: 3, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'anthropic:claude-sonnet-4-6',
providerId: 'anthropic',
} satisfies ChatResult;
});
const executions: Array<{ name: string; input: unknown; ts: number }> = [];
const tools = makeStubTools(executions);
const handler = buildHandler(tools);
const { jobId, ctx } = await makeFakeJob({ prompt: 'try', model: 'anthropic:claude-sonnet-4-6' });
const result = await handler(ctx);
expect(result.result).toBe('sorry that failed');
const toolRows = await engine.executeRaw<Record<string, unknown>>(
`SELECT status, error FROM subagent_tool_executions WHERE job_id = $1`,
[jobId],
);
expect(toolRows[0].status).toBe('failed');
expect(String(toolRows[0].error)).toContain('intentional tool failure');
});
it('max_turns: loop terminates when budget exhausted', async () => {
// Always return tool_calls — never end. Should hit max_turns cap (default 20 in subagent.ts).
__setChatTransportForTests(async () => ({
text: '',
blocks: [
{ type: 'tool-call', toolCallId: `tc-${Math.random()}`, toolName: 'search', input: {} },
] as ChatBlock[],
stopReason: 'tool_calls',
usage: { input_tokens: 1, output_tokens: 1, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'anthropic:claude-sonnet-4-6',
providerId: 'anthropic',
} satisfies ChatResult));
const executions: Array<{ name: string; input: unknown; ts: number }> = [];
const tools = makeStubTools(executions);
const handler = buildHandler(tools);
const { ctx } = await makeFakeJob({
prompt: 'loop forever',
model: 'anthropic:claude-sonnet-4-6',
});
// Override max_turns via data so the test runs in <1s.
ctx.data.max_turns = 3;
const result = await handler(ctx);
expect(result.stop_reason).toBe('max_turns');
// 3 tool dispatches over 3 turns (max_turns cap).
expect(executions.length).toBe(3);
});
it('refusal stop reason: handler maps refusal → SubagentStopReason refusal', async () => {
__setChatTransportForTests(async () => ({
text: 'I cannot help with that',
blocks: [{ type: 'text', text: 'I cannot help with that' }] as ChatBlock[],
stopReason: 'refusal',
usage: { input_tokens: 5, output_tokens: 7, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'anthropic:claude-sonnet-4-6',
providerId: 'anthropic',
} satisfies ChatResult));
const tools = makeStubTools([]);
const handler = buildHandler(tools);
const { ctx } = await makeFakeJob({ prompt: 'bad request', model: 'anthropic:claude-sonnet-4-6' });
const result = await handler(ctx);
expect(result.stop_reason).toBe('refusal');
expect(result.result).toBe('I cannot help with that');
});
it('non-Anthropic model routes through gateway path (the load-bearing v0.38 unlock)', async () => {
// This is the headline scenario: openai:gpt-5.2 (no caching) works.
// Pre-v0.38, this would have refused at queue.ts. With the gateway path
// flag on, the loop runs end-to-end.
__setChatTransportForTests(async () => ({
text: 'gpt-5 says hi',
blocks: [{ type: 'text', text: 'gpt-5 says hi' }] as ChatBlock[],
stopReason: 'end',
usage: { input_tokens: 4, output_tokens: 4, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'openai:gpt-5.2',
providerId: 'openai',
} satisfies ChatResult));
const tools = makeStubTools([]);
const handler = buildHandler(tools);
const { ctx } = await makeFakeJob({ prompt: 'hi', model: 'openai:gpt-5.2' });
const result = await handler(ctx);
expect(result.result).toBe('gpt-5 says hi');
expect(result.stop_reason).toBe('end_turn');
});
it('write-ordering invariant: assistant message persisted BEFORE tool pending row', async () => {
// The D11 + codex P1 write-ordering invariant: persistence callbacks
// fire in order so a SIGKILL between any two steps leaves the DB in a
// reconcilable state. This test asserts the message_idx of the assistant
// is strictly less than any subagent_tool_executions row that references it.
let turn = 0;
__setChatTransportForTests(async () => {
turn++;
if (turn === 1) {
return {
text: '',
blocks: [
{ type: 'tool-call', toolCallId: 'order-tc', toolName: 'search', input: {} },
] as ChatBlock[],
stopReason: 'tool_calls',
usage: { input_tokens: 1, output_tokens: 1, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'anthropic:claude-sonnet-4-6',
providerId: 'anthropic',
} satisfies ChatResult;
}
return {
text: 'done',
blocks: [{ type: 'text', text: 'done' }] as ChatBlock[],
stopReason: 'end',
usage: { input_tokens: 1, output_tokens: 1, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'anthropic:claude-sonnet-4-6',
providerId: 'anthropic',
} satisfies ChatResult;
});
const tools = makeStubTools([]);
const handler = buildHandler(tools);
const { jobId, ctx } = await makeFakeJob({ prompt: 'go', model: 'anthropic:claude-sonnet-4-6' });
await handler(ctx);
// Find the assistant turn that contains the tool call.
const msgs = await engine.executeRaw<Record<string, unknown>>(
`SELECT message_idx, role FROM subagent_messages WHERE job_id = $1 ORDER BY message_idx`,
[jobId],
);
const assistantIdx = (msgs.find(m => m.role === 'assistant') as any).message_idx;
const toolRow = await engine.executeRaw<Record<string, unknown>>(
`SELECT message_idx FROM subagent_tool_executions WHERE job_id = $1`,
[jobId],
);
expect(toolRow[0].message_idx).toBe(assistantIdx);
// Both rows present means the order completed correctly (assistant first,
// tool exec keyed to it, follow-up user with results, second assistant).
expect(msgs.length).toBeGreaterThanOrEqual(3);
});
});
+171
View File
@@ -0,0 +1,171 @@
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { withEnv } from '../helpers/with-env.ts';
import {
logAgentSubmission,
readRecentAgentEvents,
computeAuditFilename,
type AgentAuditEvent,
} from '../../src/core/minions/agent-audit.ts';
// Wrap every test body's env mutation through `withEnv` (see R1 in
// scripts/check-test-isolation.sh). The audit helpers read GBRAIN_AUDIT_DIR
// at call time so the env shadow only needs to survive the duration of the
// log/read calls inside each test.
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-audit-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
async function withAuditDir<T>(fn: () => T | Promise<T>): Promise<T> {
return await withEnv({ GBRAIN_AUDIT_DIR: tmpDir }, async () => await fn());
}
describe('agent-audit (v0.38 Slice 3 — D4 JSONL trail for submit_agent)', () => {
describe('computeAuditFilename (ISO-week rotation)', () => {
it('produces agent-jobs-YYYY-Www.jsonl shape', () => {
const name = computeAuditFilename(new Date('2026-05-21T12:00:00Z'));
expect(name).toMatch(/^agent-jobs-\d{4}-W\d{2}\.jsonl$/);
});
it('handles year-boundary edge correctly (ISO week of Dec 30)', () => {
// Dec 30 2025 is a Tuesday; ISO week 1 of 2026 starts Mon Dec 29 2025.
const name = computeAuditFilename(new Date('2025-12-30T12:00:00Z'));
expect(name).toBe('agent-jobs-2026-W01.jsonl');
});
});
describe('logAgentSubmission()', () => {
it('writes a JSONL line with all expected fields', async () => {
await withAuditDir(() => {
logAgentSubmission({
client_id: 'cursor-test',
job_id: 42,
model: 'openai:gpt-5.2',
bound_tools: ['search', 'get_page'],
bound_source: 'default',
slug_prefixes: ['wiki/'],
max_concurrent: 3,
budget_remaining_cents: 425,
prompt_byte_count: 128,
outcome: 'submitted',
});
const file = path.join(tmpDir, computeAuditFilename());
expect(fs.existsSync(file)).toBe(true);
const lines = fs.readFileSync(file, 'utf8').trim().split('\n');
expect(lines.length).toBe(1);
const ev = JSON.parse(lines[0]) as AgentAuditEvent;
expect(ev.ts).toMatch(/^\d{4}-\d{2}-\d{2}T/);
expect(ev.client_id).toBe('cursor-test');
expect(ev.job_id).toBe(42);
expect(ev.model).toBe('openai:gpt-5.2');
expect(ev.bound_tools).toEqual(['search', 'get_page']);
expect(ev.bound_source).toBe('default');
expect(ev.slug_prefixes).toEqual(['wiki/']);
expect(ev.max_concurrent).toBe(3);
expect(ev.budget_remaining_cents).toBe(425);
expect(ev.prompt_byte_count).toBe(128);
expect(ev.outcome).toBe('submitted');
});
});
it('appends multiple events to the same weekly file', async () => {
await withAuditDir(() => {
for (let i = 0; i < 3; i++) {
logAgentSubmission({
client_id: 'c',
job_id: i,
model: 'm',
bound_tools: [],
bound_source: null,
slug_prefixes: [],
max_concurrent: 1,
budget_remaining_cents: null,
prompt_byte_count: 0,
outcome: 'submitted',
});
}
const file = path.join(tmpDir, computeAuditFilename());
const lines = fs.readFileSync(file, 'utf8').trim().split('\n');
expect(lines.length).toBe(3);
});
});
it('NEVER logs prompt content (only byte count)', async () => {
const secret = 'this is a private prompt that must never appear in audit';
await withAuditDir(() => {
logAgentSubmission({
client_id: 'c',
job_id: 1,
model: 'm',
bound_tools: [],
bound_source: null,
slug_prefixes: [],
max_concurrent: 1,
budget_remaining_cents: null,
// Caller is expected to pre-compute byte count; audit module never sees prompt text.
prompt_byte_count: Buffer.byteLength(secret, 'utf8'),
outcome: 'submitted',
});
const file = path.join(tmpDir, computeAuditFilename());
const content = fs.readFileSync(file, 'utf8');
expect(content).not.toContain(secret);
expect(content).toContain(`"prompt_byte_count":${Buffer.byteLength(secret, 'utf8')}`);
});
});
});
describe('readRecentAgentEvents()', () => {
it('returns events written within the window, newest first', async () => {
await withAuditDir(() => {
logAgentSubmission({
client_id: 'a',
job_id: 1,
model: 'm',
bound_tools: [],
bound_source: null,
slug_prefixes: [],
max_concurrent: 1,
budget_remaining_cents: null,
prompt_byte_count: 0,
outcome: 'submitted',
});
// Sleep ~5ms so the second event has a strictly later ts.
const t0 = Date.now();
while (Date.now() - t0 < 5) { /* spin */ }
logAgentSubmission({
client_id: 'b',
job_id: 2,
model: 'm',
bound_tools: [],
bound_source: null,
slug_prefixes: [],
max_concurrent: 1,
budget_remaining_cents: null,
prompt_byte_count: 0,
outcome: 'submitted',
});
const events = readRecentAgentEvents(7);
expect(events.length).toBe(2);
// Newest first.
expect(events[0].client_id).toBe('b');
expect(events[1].client_id).toBe('a');
});
});
it('returns empty when audit dir doesnt exist yet', async () => {
// tmpDir exists but no files written.
await withAuditDir(() => {
const events = readRecentAgentEvents(7);
expect(events).toEqual([]);
});
});
});
});
+215
View File
@@ -0,0 +1,215 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { PGLiteEngine } from '../../src/core/pglite-engine.ts';
import { resetPgliteState } from '../helpers/reset-pglite.ts';
import {
reserve,
settle,
sweepExpiredReservations,
getClientDailyCapCents,
clientLockKey,
BudgetExceededError,
RESERVATION_TTL_MS,
} from '../../src/core/minions/budget-meter.ts';
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
});
async function seedClient(clientId: string, capUsd: number | null): Promise<void> {
await engine.executeRaw(
`INSERT INTO oauth_clients
(client_id, client_name, client_secret_hash, scope, grant_types, redirect_uris, token_endpoint_auth_method, budget_usd_per_day, created_at, deleted_at)
VALUES ($1, $1, '', 'agent', ARRAY['client_credentials'], ARRAY[]::text[], 'client_secret_post', $2, now(), NULL)
ON CONFLICT (client_id) DO UPDATE SET budget_usd_per_day = EXCLUDED.budget_usd_per_day`,
[clientId, capUsd],
);
}
describe('minions/budget-meter (v0.38 Slice 2 — D3 reserve-then-settle)', () => {
describe('clientLockKey (FNV-1a determinism)', () => {
it('returns the same int for the same client_id', () => {
expect(clientLockKey('client-a')).toBe(clientLockKey('client-a'));
});
it('returns different ints for different client_ids', () => {
expect(clientLockKey('client-a')).not.toBe(clientLockKey('client-b'));
});
it('output fits in positive INT32', () => {
const k = clientLockKey('any-client-id-with-some-length');
expect(k).toBeGreaterThanOrEqual(0);
expect(k).toBeLessThan(2 ** 32);
});
});
describe('reserve()', () => {
it('passes when projected total ≤ cap', async () => {
await seedClient('alice', 5.00);
const r = await reserve(engine, {
clientId: 'alice',
estimatedCents: 100,
capCents: 500,
model: 'anthropic:claude-sonnet-4-6',
provider: 'anthropic',
});
expect(r.reservationId).toMatch(/^[0-9a-f-]+$/i);
expect(r.estimatedCents).toBe(100);
expect(r.ttlMs).toBe(RESERVATION_TTL_MS);
});
it('refuses with BudgetExceededError when projected > cap', async () => {
await seedClient('alice', 1.00);
await expect(
reserve(engine, {
clientId: 'alice', estimatedCents: 200, capCents: 100,
model: 'm', provider: 'p',
}),
).rejects.toThrow(BudgetExceededError);
});
it('two sequential reserves both succeed when under cap', async () => {
await seedClient('alice', 5.00);
const r1 = await reserve(engine, {
clientId: 'alice', estimatedCents: 100, capCents: 500,
model: 'm', provider: 'p',
});
const r2 = await reserve(engine, {
clientId: 'alice', estimatedCents: 100, capCents: 500,
model: 'm', provider: 'p',
});
expect(r1.reservationId).not.toBe(r2.reservationId);
});
it('refuses second reserve when pending sum pushes over cap', async () => {
await seedClient('alice', 1.00);
await reserve(engine, {
clientId: 'alice', estimatedCents: 80, capCents: 100,
model: 'm', provider: 'p',
});
await expect(
reserve(engine, {
clientId: 'alice', estimatedCents: 80, capCents: 100,
model: 'm', provider: 'p',
}),
).rejects.toThrow(BudgetExceededError);
});
});
describe('settle()', () => {
it('marks settled and writes to mcp_spend_log', async () => {
await seedClient('alice', 5.00);
const r = await reserve(engine, {
clientId: 'alice', estimatedCents: 100, capCents: 500,
model: 'anthropic:sonnet', provider: 'anthropic',
});
await settle(engine, r.reservationId, 75);
const reservationRows = await engine.executeRaw<Record<string, unknown>>(
`SELECT status, actual_cents::text AS a FROM mcp_spend_reservations WHERE reservation_id = $1`,
[r.reservationId],
);
expect(reservationRows[0]?.status).toBe('settled');
expect(parseFloat(String(reservationRows[0]?.a))).toBe(75);
const logRows = await engine.executeRaw<Record<string, unknown>>(
`SELECT spend_cents::text AS s FROM mcp_spend_log WHERE client_id = $1`,
['alice'],
);
expect(logRows.length).toBe(1);
expect(parseFloat(String(logRows[0]?.s))).toBe(75);
});
it('second settle on same reservation is no-op', async () => {
await seedClient('alice', 5.00);
const r = await reserve(engine, {
clientId: 'alice', estimatedCents: 100, capCents: 500,
model: 'm', provider: 'p',
});
await settle(engine, r.reservationId, 50);
await settle(engine, r.reservationId, 99);
const rows = await engine.executeRaw<Record<string, unknown>>(
`SELECT actual_cents::text AS a FROM mcp_spend_reservations WHERE reservation_id = $1`,
[r.reservationId],
);
expect(parseFloat(String(rows[0]?.a))).toBe(50);
const logCount = await engine.executeRaw<Record<string, unknown>>(
`SELECT count(*)::int AS n FROM mcp_spend_log WHERE client_id = $1`,
['alice'],
);
expect(Number(logCount[0]?.n)).toBe(1);
});
});
describe('sweepExpiredReservations()', () => {
it('marks past-TTL pending rows as expired', async () => {
await seedClient('alice', 5.00);
const expired = new Date(Date.now() - 60_000).toISOString();
await engine.executeRaw(
`INSERT INTO mcp_spend_reservations
(reservation_id, client_id, estimated_cents, model, provider, status, expires_at)
VALUES ('00000000-0000-0000-0000-000000000001', 'alice', 50, 'm', 'p', 'pending', $1)`,
[expired],
);
const n = await sweepExpiredReservations(engine);
expect(n).toBe(1);
const rows = await engine.executeRaw<Record<string, unknown>>(
`SELECT status FROM mcp_spend_reservations WHERE reservation_id = '00000000-0000-0000-0000-000000000001'`,
);
expect(rows[0]?.status).toBe('expired');
});
it('leaves fresh pending rows alone', async () => {
await seedClient('alice', 5.00);
const r = await reserve(engine, {
clientId: 'alice', estimatedCents: 50, capCents: 500,
model: 'm', provider: 'p',
});
const n = await sweepExpiredReservations(engine);
expect(n).toBe(0);
const rows = await engine.executeRaw<Record<string, unknown>>(
`SELECT status FROM mcp_spend_reservations WHERE reservation_id = $1`,
[r.reservationId],
);
expect(rows[0]?.status).toBe('pending');
});
});
describe('getClientDailyCapCents()', () => {
it('returns cap in cents when set', async () => {
await seedClient('alice', 5.00);
expect(await getClientDailyCapCents(engine, 'alice')).toBe(500);
});
it('returns null when unset', async () => {
await seedClient('bob', null);
expect(await getClientDailyCapCents(engine, 'bob')).toBe(null);
});
it('returns null for unknown client', async () => {
expect(await getClientDailyCapCents(engine, 'nobody')).toBe(null);
});
});
describe('committed spend feeds next reserve', () => {
it('settled spend pushes the next reserve over cap', async () => {
await seedClient('alice', 1.00);
const r1 = await reserve(engine, {
clientId: 'alice', estimatedCents: 60, capCents: 100,
model: 'm', provider: 'p',
});
await settle(engine, r1.reservationId, 55);
await expect(
reserve(engine, {
clientId: 'alice', estimatedCents: 50, capCents: 100,
model: 'm', provider: 'p',
}),
).rejects.toThrow(BudgetExceededError);
});
});
});
+19 -14
View File
@@ -175,26 +175,31 @@ describe('resolveModel — v0.31.12 tier system', () => {
expect(m).toBe(TIER_DEFAULTS.reasoning);
});
test('tier.subagent falls back to TIER_DEFAULTS.subagent when models.default is non-Anthropic', async () => {
stub.set('models.default', 'openai:gpt-5.5');
test('v0.38 D7: tier.subagent accepts non-Anthropic models that support tools (with cost warn)', async () => {
// Pre-v0.38 the resolver hard-fell-back to TIER_DEFAULTS.subagent for any
// non-Anthropic model. v0.38 (D6/D7) replaces that with a capability check:
// OpenAI/Gemini/etc. support tools → resolved unchanged + warn about
// missing prompt caching (cost regression on long loops, not a refusal).
stub.set('models.default', 'openai:gpt-5.2');
const m = await resolveModel(stub as never, {
tier: 'subagent',
fallback: 'sonnet',
});
expect(m).toBe('openai:gpt-5.2');
expect(stderrCapture).toContain('caching');
});
test('v0.38 D7: tier.subagent rejects unknown providers (falls back to default)', async () => {
// Unknown providers fail the capability check (verdict='unknown'); the
// resolver falls back to TIER_DEFAULTS.subagent rather than burn money on
// an unverified model.
stub.set('models.tier.subagent', 'madeup-provider:weird-model');
const m = await resolveModel(stub as never, {
tier: 'subagent',
fallback: 'sonnet',
});
expect(m).toBe(TIER_DEFAULTS.subagent);
expect(stderrCapture).toContain('tier.subagent');
expect(stderrCapture).toContain('non-Anthropic');
expect(stderrCapture).toContain('models.default');
});
test('tier.subagent falls back when explicitly set to non-Anthropic', async () => {
stub.set('models.tier.subagent', 'openai:gpt-5.5');
const m = await resolveModel(stub as never, {
tier: 'subagent',
fallback: 'sonnet',
});
expect(m).toBe(TIER_DEFAULTS.subagent);
expect(stderrCapture).toContain('models.tier.subagent');
});
test('tier.subagent accepts explicit Anthropic override', async () => {
+6 -4
View File
@@ -603,21 +603,23 @@ describe('operation scope annotations', () => {
for (const op of operations) {
expect(op.scope, `${op.name} missing scope`).toBeDefined();
// v0.28 added sources_admin and users_admin to the union.
// v0.38 added 'agent' for submit_agent (D13).
expect([
'read', 'write', 'admin', 'sources_admin', 'users_admin',
'read', 'write', 'admin', 'sources_admin', 'users_admin', 'agent',
]).toContain(op.scope);
}
});
test('mutating operations are write/admin/sources_admin/users_admin scoped', () => {
test('mutating operations are write/admin/sources_admin/users_admin/agent scoped', () => {
const { operations } = require('../src/core/operations.ts');
for (const op of operations) {
if (op.mutating) {
// v0.28: sources_admin permits sources_add / sources_remove (mutating
// sources, not pages); read scope is the only thing too narrow for
// any mutating op.
// any mutating op. v0.38: 'agent' is a mutating-axis scope for
// submit_agent (creates jobs, spends money, but contained by bindings).
expect(
['write', 'admin', 'sources_admin', 'users_admin'],
['write', 'admin', 'sources_admin', 'users_admin', 'agent'],
`${op.name} is mutating but not a write-axis scope`,
).toContain(op.scope);
}
+95
View File
@@ -0,0 +1,95 @@
import { describe, it, expect } from 'bun:test';
import { hasScope, ALLOWED_SCOPES, ALLOWED_SCOPES_LIST } from '../src/core/scope.ts';
/**
* v0.38 D13 regression guard — `agent` is a SIBLING of admin, NOT implied.
*
* The bug class this prevents:
*
* Existing admin clients (legacy super-admin tokens, ops automation,
* personal `--scopes admin`) MUST NOT silently gain the ability to
* dispatch `submit_agent` jobs on upgrade. The submit_agent op spends
* real money against the OAuth client's bound_* fields, and admin
* clients pre-v0.38 have NULL bindings — so silent inheritance would
* either (a) refuse with a confusing "no bindings" error or worse
* (b) become exploitable if the bindings-missing check ever regresses.
*
* The contract: opt-in only. To submit agent jobs, the client must be
* re-registered with `--scopes admin,agent` and explicit `--bound-*`
* flags. This file is the regression guard that pins that contract.
*
* If this test fails, the scope hierarchy in src/core/scope.ts changed
* such that admin now implies agent. That is a SECURITY REGRESSION —
* don't relax this test, fix the scope table.
*/
describe('v0.38 D13 — agent scope isolation (regression guard)', () => {
it('agent IS in the allow-list', () => {
expect(ALLOWED_SCOPES.has('agent' as any)).toBe(true);
expect(ALLOWED_SCOPES_LIST.includes('agent' as any)).toBe(true);
});
it('admin does NOT imply agent (sibling, not parent)', () => {
// The load-bearing check: a token with only `admin` granted CANNOT
// satisfy a required `agent` scope check. Without this, every existing
// admin OAuth client would silently acquire submit_agent dispatch on
// upgrade — security regression.
expect(hasScope(['admin'], 'agent')).toBe(false);
});
it('admin implies its siblings (the v0.31 contract still holds)', () => {
// Don't regress the existing admin hierarchy in the process of
// isolating agent. admin still implies sources_admin, users_admin,
// write, read.
expect(hasScope(['admin'], 'sources_admin')).toBe(true);
expect(hasScope(['admin'], 'users_admin')).toBe(true);
expect(hasScope(['admin'], 'write')).toBe(true);
expect(hasScope(['admin'], 'read')).toBe(true);
});
it('agent does NOT imply anything else (no inheritance the other way)', () => {
// An agent-scoped token must NOT also satisfy read/write/admin. The
// submit_agent op's bindings are the ONLY axis of capability — agent
// doesn't piggyback on the broader permission tree.
expect(hasScope(['agent'], 'read')).toBe(false);
expect(hasScope(['agent'], 'write')).toBe(false);
expect(hasScope(['agent'], 'admin')).toBe(false);
expect(hasScope(['agent'], 'sources_admin')).toBe(false);
expect(hasScope(['agent'], 'users_admin')).toBe(false);
});
it('agent satisfies a required agent scope (self-implies)', () => {
expect(hasScope(['agent'], 'agent')).toBe(true);
});
it('explicit admin+agent compound grant satisfies both', () => {
// Recommended re-registration for legacy admin clients that want
// submit_agent capability: `--scopes admin,agent`.
expect(hasScope(['admin', 'agent'], 'admin')).toBe(true);
expect(hasScope(['admin', 'agent'], 'agent')).toBe(true);
expect(hasScope(['admin', 'agent'], 'write')).toBe(true); // via admin
});
it('read+write does NOT imply agent (the most common legacy shape)', () => {
// Pre-v0.38 OAuth clients registered with `--scopes read,write` (the
// default for most thin-client MCP setups) MUST NOT silently gain
// agent dispatch.
expect(hasScope(['read', 'write'], 'agent')).toBe(false);
});
it('sources_admin and users_admin do NOT imply agent', () => {
expect(hasScope(['sources_admin'], 'agent')).toBe(false);
expect(hasScope(['users_admin'], 'agent')).toBe(false);
});
it('ALLOWED_SCOPES_LIST is sorted alphabetically (wire-format determinism)', () => {
const sorted = [...ALLOWED_SCOPES_LIST].sort();
expect([...ALLOWED_SCOPES_LIST]).toEqual(sorted);
// Specifically: 'agent' must sort between 'admin' and 'read'.
const idx = ALLOWED_SCOPES_LIST.indexOf('agent' as any);
const adminIdx = ALLOWED_SCOPES_LIST.indexOf('admin' as any);
const readIdx = ALLOWED_SCOPES_LIST.indexOf('read' as any);
expect(idx).toBeGreaterThan(adminIdx);
expect(idx).toBeLessThan(readIdx);
});
});
+4 -2
View File
@@ -129,17 +129,19 @@ describe('F3 refresh-token subset semantics under hasScope', () => {
// ---------------------------------------------------------------------------
describe('ALLOWED_SCOPES — exact list pinned', () => {
test('contains the 5 canonical scopes', () => {
expect(ALLOWED_SCOPES.size).toBe(5);
test('contains the 6 canonical scopes (v0.38: agent added)', () => {
expect(ALLOWED_SCOPES.size).toBe(6);
expect(ALLOWED_SCOPES.has('read')).toBe(true);
expect(ALLOWED_SCOPES.has('write')).toBe(true);
expect(ALLOWED_SCOPES.has('admin')).toBe(true);
expect(ALLOWED_SCOPES.has('sources_admin')).toBe(true);
expect(ALLOWED_SCOPES.has('users_admin')).toBe(true);
expect(ALLOWED_SCOPES.has('agent')).toBe(true);
});
test('list is sorted alphabetically (deterministic for wire/drift check)', () => {
expect([...ALLOWED_SCOPES_LIST]).toEqual([
'admin',
'agent',
'read',
'sources_admin',
'users_admin',
+281
View File
@@ -0,0 +1,281 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
import { __testing } from '../src/core/minions/handlers/subagent.ts';
const { adaptContentBlocksToChatBlocks, loadPriorToolsV2 } = __testing as any;
/**
* v0.38 Slice 1 — D5 read-time content_blocks shim.
*
* The crash-replay reconciliation key in v0.38+ is a gbrain-owned UUID v7
* (ordinal + gbrain_tool_use_id columns added in migration v81). Pre-v0.38
* rows used Anthropic's provider-supplied tool_use_id as the key and stored
* Anthropic-shaped content blocks ({type:'tool_use', id, ...}). The shim is
* what lets crash-replay reconcile across the binary upgrade boundary —
* jobs that committed v1-shaped rows pre-upgrade must replay through the
* post-upgrade gateway loop without double-executing tools.
*
* Tests pin both shim functions:
* - adaptContentBlocksToChatBlocks: v1 Anthropic blocks → v2 ChatBlocks
* - loadPriorToolsV2: v1 rows (ordinal=NULL, gbrain_tool_use_id=NULL)
* synthesize a stable key from (jobId, msgIdx, tool_use_id, tool_name)
* so the gateway-loop reconciler sees them keyed alongside v2 rows.
*/
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
await engine.setConfig('version', '85');
});
describe('adaptContentBlocksToChatBlocks (D5 — v1 Anthropic → v2 ChatBlock shape)', () => {
it('passes a string through unchanged (plain-text user message)', () => {
expect(adaptContentBlocksToChatBlocks('hello world')).toBe('hello world');
});
it('returns [] for a non-array, non-string input (defensive)', () => {
expect(adaptContentBlocksToChatBlocks(null)).toEqual([]);
expect(adaptContentBlocksToChatBlocks(undefined)).toEqual([]);
expect(adaptContentBlocksToChatBlocks(42)).toEqual([]);
});
it('adapts v1 text block (type:text) verbatim', () => {
const blocks = [{ type: 'text', text: 'hello' }];
expect(adaptContentBlocksToChatBlocks(blocks)).toEqual([
{ type: 'text', text: 'hello' },
]);
});
it('adapts v1 Anthropic tool_use block → v2 tool-call', () => {
// Anthropic shape: {type:'tool_use', id, name, input}
// Gateway ChatBlock shape: {type:'tool-call', toolCallId, toolName, input}
const blocks = [{
type: 'tool_use',
id: 'toolu_01ABC',
name: 'search',
input: { q: 'foo' },
}];
expect(adaptContentBlocksToChatBlocks(blocks)).toEqual([{
type: 'tool-call',
toolCallId: 'toolu_01ABC',
toolName: 'search',
input: { q: 'foo' },
}]);
});
it('passes v2 tool-call block through (re-read of own writes)', () => {
const blocks = [{
type: 'tool-call',
toolCallId: 'gbrain-uuid-7',
toolName: 'get_page',
input: { slug: 'wiki/foo' },
}];
expect(adaptContentBlocksToChatBlocks(blocks)).toEqual(blocks);
});
it('adapts v1 Anthropic tool_result block → v2 tool-result (synthesizes __legacy__ toolName)', () => {
// v1 tool_result blocks don't carry tool_name — they reference the
// assistant turn's tool_use_id via tool_use_id. The shim synthesizes
// a sentinel toolName since the gateway shape requires one.
const blocks = [{
type: 'tool_result',
tool_use_id: 'toolu_01ABC',
content: 'search results json',
}];
expect(adaptContentBlocksToChatBlocks(blocks)).toEqual([{
type: 'tool-result',
toolCallId: 'toolu_01ABC',
toolName: '__legacy__',
output: 'search results json',
isError: false,
}]);
});
it('adapts v1 tool_result with is_error: true', () => {
const blocks = [{
type: 'tool_result',
tool_use_id: 'toolu_01XYZ',
content: 'tool failed: timeout',
is_error: true,
}];
const out = adaptContentBlocksToChatBlocks(blocks) as any[];
expect(out[0].isError).toBe(true);
});
it('passes v2 tool-result block through', () => {
const blocks = [{
type: 'tool-result',
toolCallId: 'tc1',
toolName: 'search',
output: { results: [] },
isError: false,
}];
expect(adaptContentBlocksToChatBlocks(blocks)).toEqual(blocks);
});
it('handles a mixed-shape array (v1 + v2 blocks in same message — mid-upgrade scenario)', () => {
const blocks = [
{ type: 'text', text: 'thinking...' },
{ type: 'tool_use', id: 'toolu_a', name: 'search', input: { q: 'foo' } },
{ type: 'tool-call', toolCallId: 'gb-b', toolName: 'get_page', input: { slug: 'x' } },
];
const out = adaptContentBlocksToChatBlocks(blocks) as any[];
expect(out.length).toBe(3);
expect(out[0]).toEqual({ type: 'text', text: 'thinking...' });
expect(out[1].type).toBe('tool-call');
expect(out[1].toolCallId).toBe('toolu_a');
expect(out[2].type).toBe('tool-call');
expect(out[2].toolCallId).toBe('gb-b');
});
it('skips malformed blocks (defensive: null entries, missing fields)', () => {
const blocks = [
null,
{ type: 'text' /* missing text */ },
{ type: 'tool_use', id: 'ok', name: 'real', input: {} },
{ type: 'unknown_type', whatever: true },
];
const out = adaptContentBlocksToChatBlocks(blocks) as any[];
expect(out.length).toBe(1);
expect(out[0].toolCallId).toBe('ok');
});
});
describe('loadPriorToolsV2 (D5 — synthesizes stable keys for v1 rows)', () => {
it('returns empty array when no rows exist for the job', async () => {
const rows = await loadPriorToolsV2(engine, 999);
expect(rows).toEqual([]);
});
it('uses gbrain_tool_use_id as stable key for v2 rows', async () => {
// Seed a minion_jobs row + v2 tool execution.
const job = await engine.executeRaw<{ id: number }>(
`INSERT INTO minion_jobs (name, status, data, queue, priority, created_at)
VALUES ('subagent', 'active', '{}'::jsonb, 'default', 0, now())
RETURNING id`,
);
const jobId = job[0].id;
const gbrainId = '01987654-3210-7000-8000-000000000001';
await engine.executeRaw(
`INSERT INTO subagent_tool_executions
(job_id, message_idx, tool_use_id, tool_name, input, status,
schema_version, ordinal, gbrain_tool_use_id, output)
VALUES ($1, 0, 'toolu_provider', 'search', '{}'::jsonb, 'complete',
2, 0, $2::uuid, '"results"'::jsonb)`,
[jobId, gbrainId],
);
const rows = await loadPriorToolsV2(engine, jobId);
expect(rows.length).toBe(1);
expect(rows[0].stableKey).toBe(gbrainId);
expect(rows[0].status).toBe('complete');
expect(rows[0].output).toBe('results');
});
it('synthesizes a legacy-prefixed stable key for v1 rows (ordinal NULL, gbrain_tool_use_id NULL)', async () => {
const job = await engine.executeRaw<{ id: number }>(
`INSERT INTO minion_jobs (name, status, data, queue, priority, created_at)
VALUES ('subagent', 'active', '{}'::jsonb, 'default', 0, now())
RETURNING id`,
);
const jobId = job[0].id;
await engine.executeRaw(
`INSERT INTO subagent_tool_executions
(job_id, message_idx, tool_use_id, tool_name, input, status, schema_version, output)
VALUES ($1, 0, 'toolu_legacy_xyz', 'get_page', '{}'::jsonb, 'complete', 1, '"page content"'::jsonb)`,
[jobId],
);
const rows = await loadPriorToolsV2(engine, jobId);
expect(rows.length).toBe(1);
expect(rows[0].stableKey).toBe(`legacy:${jobId}:0:toolu_legacy_xyz:get_page`);
expect(rows[0].status).toBe('complete');
});
it('preserves status + error text for failed legacy rows', async () => {
const job = await engine.executeRaw<{ id: number }>(
`INSERT INTO minion_jobs (name, status, data, queue, priority, created_at)
VALUES ('subagent', 'active', '{}'::jsonb, 'default', 0, now())
RETURNING id`,
);
const jobId = job[0].id;
await engine.executeRaw(
`INSERT INTO subagent_tool_executions
(job_id, message_idx, tool_use_id, tool_name, input, status, error, schema_version)
VALUES ($1, 0, 'toolu_fail', 'search', '{}'::jsonb, 'failed', 'timeout after 30s', 1)`,
[jobId],
);
const rows = await loadPriorToolsV2(engine, jobId);
expect(rows[0].status).toBe('failed');
expect(rows[0].error).toBe('timeout after 30s');
});
it('returns v1 + v2 rows side-by-side with both stable-key shapes resolving', async () => {
// The mid-upgrade scenario: a long-running subagent job has v1 rows
// (committed pre-upgrade) AND v2 rows (committed post-upgrade by the
// gateway path). loadPriorToolsV2 must surface both keyed correctly
// so the reconciler's Map<stableKey, outcome> sees both.
const job = await engine.executeRaw<{ id: number }>(
`INSERT INTO minion_jobs (name, status, data, queue, priority, created_at)
VALUES ('subagent', 'active', '{}'::jsonb, 'default', 0, now())
RETURNING id`,
);
const jobId = job[0].id;
// v1 row: ordinal NULL, gbrain_tool_use_id NULL
await engine.executeRaw(
`INSERT INTO subagent_tool_executions
(job_id, message_idx, tool_use_id, tool_name, input, status, schema_version, output)
VALUES ($1, 0, 'toolu_v1', 'search', '{}'::jsonb, 'complete', 1, '"v1 result"'::jsonb)`,
[jobId],
);
// v2 row: ordinal + gbrain_tool_use_id populated
const gbrainId = '01987654-3210-7000-8000-000000000002';
await engine.executeRaw(
`INSERT INTO subagent_tool_executions
(job_id, message_idx, tool_use_id, tool_name, input, status,
schema_version, ordinal, gbrain_tool_use_id, output)
VALUES ($1, 2, 'toolu_v2', 'get_page', '{}'::jsonb, 'complete',
2, 0, $2::uuid, '"v2 result"'::jsonb)`,
[jobId, gbrainId],
);
const rows = await loadPriorToolsV2(engine, jobId);
expect(rows.length).toBe(2);
const keys = (rows as Array<{ stableKey: string }>).map(r => r.stableKey);
expect(keys).toContain(`legacy:${jobId}:0:toolu_v1:search`);
expect(keys).toContain(gbrainId);
});
it('ordering: rows return ORDER BY message_idx, ordinal, id (stable for replay)', async () => {
const job = await engine.executeRaw<{ id: number }>(
`INSERT INTO minion_jobs (name, status, data, queue, priority, created_at)
VALUES ('subagent', 'active', '{}'::jsonb, 'default', 0, now())
RETURNING id`,
);
const jobId = job[0].id;
// Insert in scrambled order — message_idx 2 first, then 0, then 1.
for (const [msg_idx, name] of [[2, 'third'], [0, 'first'], [1, 'second']] as const) {
await engine.executeRaw(
`INSERT INTO subagent_tool_executions
(job_id, message_idx, tool_use_id, tool_name, input, status, schema_version)
VALUES ($1, $2, $3, $3, '{}'::jsonb, 'complete', 1)`,
[jobId, msg_idx, name],
);
}
const rows = await loadPriorToolsV2(engine, jobId);
expect(rows.length).toBe(3);
// The synthesized stable key embeds tool_name; pull names by parse.
expect(rows[0].stableKey).toContain(':first');
expect(rows[1].stableKey).toContain(':second');
expect(rows[2].stableKey).toContain(':third');
});
});
+389
View File
@@ -0,0 +1,389 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
import { withEnv } from './helpers/with-env.ts';
import { operationsByName } from '../src/core/operations.ts';
/**
* v0.38 Slice 3 — `submit_agent` MCP op tests.
*
* Covers the load-bearing trust-boundary surface:
* - Per-dispatch binding enforcement against oauth_clients.bound_*
* - allowed_tools ⊆ bound_tools subset check
* - allowed_slug_prefixes prefix-match against bound_slug_prefixes
* - bound_max_concurrent concurrency cap
* - Local CLI bypass (ctx.remote === false → invalid_request)
* - Refusal when client has scope but missing bindings
* - Refusal for unknown client_id
* - dry_run path
* - Happy-path submission writes audit row + queue row
*
* Audit-trail writes go to a tmpdir via GBRAIN_AUDIT_DIR (withEnv-wrapped).
*/
const submit_agent = operationsByName['submit_agent'];
if (!submit_agent) {
throw new Error('submit_agent op missing from operations registry — test fixture invalid');
}
let engine: PGLiteEngine;
let tmpAuditDir: string;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
// resetPgliteState truncates `config` table; restore the version row so
// MinionQueue.ensureSchema() sees the migrated state. The schema itself
// is preserved (initSchema applied in beforeAll); only the config-table
// marker row needs re-seeding.
await engine.setConfig('version', '85');
tmpAuditDir = fs.mkdtempSync(path.join(os.tmpdir(), 'submit-agent-audit-'));
});
interface SeedOpts {
bound_tools?: string[] | null;
bound_source_id?: string | null;
bound_brain_id?: string | null;
bound_slug_prefixes?: string[] | null;
bound_max_concurrent?: number;
budget_usd_per_day?: number | null;
scope?: string;
}
async function seedClient(clientId: string, opts: SeedOpts = {}): Promise<void> {
await engine.executeRaw(
`INSERT INTO oauth_clients
(client_id, client_name, client_secret_hash, scope, grant_types,
redirect_uris, token_endpoint_auth_method,
bound_tools, bound_source_id, bound_brain_id, bound_slug_prefixes,
bound_max_concurrent, budget_usd_per_day, created_at, deleted_at)
VALUES ($1, $1, '', $2, ARRAY['client_credentials'],
ARRAY[]::text[], 'client_secret_post',
$3, $4, $5, $6, $7, $8, now(), NULL)
ON CONFLICT (client_id) DO UPDATE SET
bound_tools = EXCLUDED.bound_tools,
bound_source_id = EXCLUDED.bound_source_id,
bound_slug_prefixes = EXCLUDED.bound_slug_prefixes,
bound_max_concurrent = EXCLUDED.bound_max_concurrent,
budget_usd_per_day = EXCLUDED.budget_usd_per_day,
scope = EXCLUDED.scope`,
[
clientId,
opts.scope ?? 'read agent',
opts.bound_tools ?? null,
opts.bound_source_id ?? null,
opts.bound_brain_id ?? null,
opts.bound_slug_prefixes ?? null,
opts.bound_max_concurrent ?? 1,
opts.budget_usd_per_day ?? null,
],
);
}
function makeCtx(opts: { clientId?: string; remote?: boolean; dryRun?: boolean } = {}): any {
return {
engine,
config: {},
logger: console,
dryRun: opts.dryRun ?? false,
remote: opts.remote ?? true,
auth: opts.clientId ? { clientId: opts.clientId } : undefined,
};
}
async function callSubmitAgent(ctx: any, params: Record<string, unknown>): Promise<any> {
return await withEnv({ GBRAIN_AUDIT_DIR: tmpAuditDir }, async () => {
return await submit_agent.handler(ctx, params);
});
}
describe('submit_agent op (v0.38 Slice 3 — remote-callable agent dispatch with binding enforcement)', () => {
describe('op surface', () => {
it('declares scope=agent + mutating=true', () => {
expect(submit_agent.scope).toBe('agent' as any);
expect(submit_agent.mutating).toBe(true);
});
it('declares required prompt param', () => {
expect(submit_agent.params.prompt).toBeDefined();
expect((submit_agent.params.prompt as any).required).toBe(true);
});
});
describe('local CLI bypass (ctx.remote === false)', () => {
it('throws invalid_request — local CLI must use gbrain agent run', async () => {
const ctx = makeCtx({ remote: false });
await expect(callSubmitAgent(ctx, { prompt: 'hi' })).rejects.toThrow(
/local CLI.*gbrain agent run/i,
);
});
});
describe('OAuth client requirement', () => {
it('refuses when no clientId in ctx.auth', async () => {
const ctx = makeCtx(); // no clientId
await expect(callSubmitAgent(ctx, { prompt: 'hi' })).rejects.toThrow(
/requires an OAuth client with the `agent` scope/i,
);
});
it('refuses when client_id is unknown', async () => {
const ctx = makeCtx({ clientId: 'nobody-here' });
await expect(callSubmitAgent(ctx, { prompt: 'hi' })).rejects.toThrow(
/client_id nobody-here not found/,
);
});
});
describe('binding requirement (D13 — opt-in only)', () => {
it('refuses when client has agent scope but bound_tools is NULL', async () => {
// Legacy admin client gets agent scope appended via re-registration but
// forgot to set --bound-tools. Refuse with the paste-ready hint.
await seedClient('legacy-admin', { bound_tools: null });
const ctx = makeCtx({ clientId: 'legacy-admin' });
await expect(callSubmitAgent(ctx, { prompt: 'hi' })).rejects.toThrow(
/has the agent scope but no bindings.*re-register/i,
);
});
});
describe('allowed_tools subset enforcement', () => {
it('passes when allowed_tools ⊆ bound_tools', async () => {
await seedClient('cursor', {
bound_tools: ['search', 'get_page', 'put_page'],
bound_source_id: 'default',
bound_slug_prefixes: ['wiki/'],
bound_max_concurrent: 3,
});
const ctx = makeCtx({ clientId: 'cursor', dryRun: true });
const result = await callSubmitAgent(ctx, {
prompt: 'go',
allowed_tools: ['search', 'get_page'],
});
expect(result.dry_run).toBe(true);
expect(result.action).toBe('submit_agent');
});
it('refuses when allowed_tools requests a tool outside bound_tools', async () => {
await seedClient('cursor', {
bound_tools: ['search', 'get_page'],
bound_source_id: 'default',
bound_slug_prefixes: ['wiki/'],
});
const ctx = makeCtx({ clientId: 'cursor' });
await expect(
callSubmitAgent(ctx, { prompt: 'go', allowed_tools: ['put_page'] }),
).rejects.toThrow(/tool "put_page" is not in client cursor's bound_tools/);
});
it('defaults to bound_tools when allowed_tools omitted', async () => {
await seedClient('cursor', {
bound_tools: ['search'],
bound_source_id: 'default',
bound_slug_prefixes: ['wiki/'],
});
const ctx = makeCtx({ clientId: 'cursor', dryRun: true });
const result = await callSubmitAgent(ctx, { prompt: 'go' });
expect(result.dry_run).toBe(true);
});
});
describe('allowed_slug_prefixes enforcement', () => {
it('passes when each requested prefix is under a bound prefix', async () => {
await seedClient('cursor', {
bound_tools: ['put_page'],
bound_source_id: 'default',
bound_slug_prefixes: ['wiki/', 'people/'],
});
const ctx = makeCtx({ clientId: 'cursor', dryRun: true });
// 'wiki/' starts with 'wiki/' (exact prefix match)
const r1 = await callSubmitAgent(ctx, {
prompt: 'go',
allowed_slug_prefixes: ['wiki/'],
});
expect(r1.dry_run).toBe(true);
});
it('refuses when a requested prefix has no bound parent', async () => {
await seedClient('cursor', {
bound_tools: ['put_page'],
bound_source_id: 'default',
bound_slug_prefixes: ['wiki/'],
});
const ctx = makeCtx({ clientId: 'cursor' });
await expect(
callSubmitAgent(ctx, {
prompt: 'go',
allowed_slug_prefixes: ['private/'],
}),
).rejects.toThrow(/slug_prefix "private\/" is not under any.*bound_slug_prefixes/);
});
});
describe('concurrency cap enforcement', () => {
it('refuses when inflight count >= bound_max_concurrent', async () => {
await seedClient('cursor', {
bound_tools: ['search'],
bound_source_id: 'default',
bound_slug_prefixes: ['wiki/'],
bound_max_concurrent: 2,
});
// Seed 2 already-running subagent jobs for this client.
for (let i = 0; i < 2; i++) {
await engine.executeRaw(
`INSERT INTO minion_jobs (name, status, data, queue, priority, created_at)
VALUES ('subagent', 'active', $1::jsonb, 'default', 0, now())`,
[JSON.stringify({ prompt: `existing-${i}`, __owner_client_id: 'cursor' })],
);
}
const ctx = makeCtx({ clientId: 'cursor' });
await expect(callSubmitAgent(ctx, { prompt: 'one too many' })).rejects.toThrow(
/at concurrency cap \(2\/2\)/,
);
});
it('allows submit when inflight count < cap', async () => {
await seedClient('cursor', {
bound_tools: ['search'],
bound_source_id: 'default',
bound_slug_prefixes: ['wiki/'],
bound_max_concurrent: 3,
});
await engine.executeRaw(
`INSERT INTO minion_jobs (name, status, data, queue, priority, created_at)
VALUES ('subagent', 'active', $1::jsonb, 'default', 0, now())`,
[JSON.stringify({ prompt: 'one', __owner_client_id: 'cursor' })],
);
const ctx = makeCtx({ clientId: 'cursor', dryRun: true });
const result = await callSubmitAgent(ctx, { prompt: 'two' });
expect(result.dry_run).toBe(true);
expect(result.bound_max_concurrent).toBe(3);
});
it('does NOT count terminal-state jobs toward the cap', async () => {
await seedClient('cursor', {
bound_tools: ['search'],
bound_source_id: 'default',
bound_slug_prefixes: ['wiki/'],
bound_max_concurrent: 1,
});
// 5 completed jobs — none counted (status filter is waiting/active/waiting-children).
for (let i = 0; i < 5; i++) {
await engine.executeRaw(
`INSERT INTO minion_jobs (name, status, data, queue, priority, created_at)
VALUES ('subagent', 'completed', $1::jsonb, 'default', 0, now())`,
[JSON.stringify({ prompt: `done-${i}`, __owner_client_id: 'cursor' })],
);
}
const ctx = makeCtx({ clientId: 'cursor', dryRun: true });
const result = await callSubmitAgent(ctx, { prompt: 'fresh' });
expect(result.dry_run).toBe(true);
});
it('isolates inflight count by client_id (no cross-client leakage)', async () => {
await seedClient('alice', {
bound_tools: ['search'],
bound_source_id: 'default',
bound_slug_prefixes: ['wiki/'],
bound_max_concurrent: 1,
});
await seedClient('bob', {
bound_tools: ['search'],
bound_source_id: 'default',
bound_slug_prefixes: ['wiki/'],
bound_max_concurrent: 1,
});
// Alice has 1 active — at her cap.
await engine.executeRaw(
`INSERT INTO minion_jobs (name, status, data, queue, priority, created_at)
VALUES ('subagent', 'active', $1::jsonb, 'default', 0, now())`,
[JSON.stringify({ prompt: 'alice-busy', __owner_client_id: 'alice' })],
);
// Bob's submit should succeed — his cap (1) is independent.
const ctxBob = makeCtx({ clientId: 'bob', dryRun: true });
const result = await callSubmitAgent(ctxBob, { prompt: 'bob-fresh' });
expect(result.dry_run).toBe(true);
});
});
describe('happy-path submission', () => {
it('inserts a subagent job + writes audit row', async () => {
await seedClient('cursor', {
bound_tools: ['search', 'get_page'],
bound_source_id: 'default',
bound_slug_prefixes: ['wiki/'],
bound_max_concurrent: 3,
budget_usd_per_day: 5.00,
});
const ctx = makeCtx({ clientId: 'cursor' });
const result = await callSubmitAgent(ctx, {
prompt: 'research the YC W26 batch',
allowed_tools: ['search'],
});
expect(result.id).toBeGreaterThan(0);
expect(result.name).toBe('subagent');
expect(result.client_id).toBe('cursor');
// Job persisted with correct shape.
const rows = await engine.executeRaw<Record<string, unknown>>(
`SELECT name, status, data FROM minion_jobs WHERE id = $1`,
[result.id],
);
expect(rows.length).toBe(1);
expect(rows[0].name).toBe('subagent');
const data = typeof rows[0].data === 'string'
? JSON.parse(rows[0].data as string)
: (rows[0].data as Record<string, unknown>);
expect(data.prompt).toBe('research the YC W26 batch');
expect(data.allowed_tools).toEqual(['search']);
expect(data.__owner_client_id).toBe('cursor');
expect(data.source_id).toBe('default'); // auto-set from bound_source_id
// Audit file written.
const auditFiles = fs.readdirSync(tmpAuditDir).filter(f => f.startsWith('agent-jobs-'));
expect(auditFiles.length).toBe(1);
const auditContent = fs.readFileSync(path.join(tmpAuditDir, auditFiles[0]), 'utf8');
const auditLine = JSON.parse(auditContent.trim().split('\n')[0]);
expect(auditLine.client_id).toBe('cursor');
expect(auditLine.job_id).toBe(result.id);
expect(auditLine.bound_tools).toEqual(['search']);
expect(auditLine.bound_source).toBe('default');
expect(auditLine.budget_remaining_cents).toBe(500); // 5.00 USD → 500 cents
expect(auditLine.outcome).toBe('submitted');
// CRITICAL: prompt text MUST NOT be in audit (only byte count).
expect(auditContent).not.toContain('YC W26 batch');
});
it('caps max_turns at 100', async () => {
await seedClient('cursor', {
bound_tools: ['search'],
bound_source_id: 'default',
bound_slug_prefixes: ['wiki/'],
});
const ctx = makeCtx({ clientId: 'cursor' });
const result = await callSubmitAgent(ctx, {
prompt: 'long',
max_turns: 9999, // way over cap
});
const rows = await engine.executeRaw<Record<string, unknown>>(
`SELECT data FROM minion_jobs WHERE id = $1`,
[result.id],
);
const data = typeof rows[0].data === 'string'
? JSON.parse(rows[0].data as string)
: (rows[0].data as Record<string, unknown>);
expect(data.max_turns).toBe(100);
});
});
});