Files
gbrain/src/core/minions/handlers/subagent.ts
T
83c4ca0564 v0.39.0.0 feat: brainstorm cost cathedral (P1-P7) + page_links schema fix (#1283)
* feat(brainstorm): T1 cost guardrails + judge chunking + far-set cap

Ports PR #1234 with a typed-error swap (Q2). Brings:

- `--max-cost`, `--max-far-set`, `--strict-budget`, `--judge-model`,
  `--max-ideas-per-judge-call` CLI flags on `gbrain brainstorm` / `lsd`
- Domain-bank prefix-cap + shuffle + final-trim to `m` by distance score
- Judge auto-chunks idea sets > 100 across multiple LLM calls
- UTF-16 surrogate sanitization on cross prompts
- Phase-0.5 hard cost ceiling + mid-run cost guard

Phase-1 diff from PR #1234: per-cross error-rethrow uses inline typed
`BudgetExhausted` instead of string-match on the error message. Phase 2
of the wave will move the class to `src/core/budget/budget-tracker.ts`
and the orchestrator will import it.

Postmortem doc + 12-case regression test included verbatim from #1234.

T1 of the brainstorm cost cathedral plan
(~/.claude/plans/system-instruction-you-are-working-rippling-moth.md).

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

* feat(budget): T2 BudgetTracker + BudgetExhausted + audit-week helper

The keystone primitive for the v0.37.x budget cathedral. One class,
one typed error, one schema-stable audit JSONL. Replaces three parallel
copies (brainstorm orchestrator inline class, cycle/budget-meter,
eval-contradictions cost-prompt/tracker) — those adapt to this one in
T5/T6.

Contracts pinned by 26 unit tests:
  - TX1: record() throws BudgetExhausted(reason:'cost') when cumulative
    spend > cap. A single underestimated call cannot leak past the cap.
  - TX2: reserve() hard-fails with BudgetExhausted(reason:'no_pricing')
    when cap is set + model is missing from pricing maps. When cap is
    unset, legacy warn-once behavior is preserved.
  - A3 amended: extractUsageFromError(err, fallback) returns err.usage
    when SDK provides it, else the pessimistic fallback (caller passes
    maxOutputTokens, not the optimistic pre-call estimate).
  - onExhausted callback fires once, synchronously, before the throw
    propagates. Callbacks do sync I/O (writeFileSync) for checkpoint
    persistence.
  - Audit JSONL is schema-stable: every line carries schema_version=1.
    Reorderings tolerated, field renames are breaking.

Also ships src/core/audit-week-file.ts — the shared ISO-week filename
helper consumed by every audit writer in T4. Year-boundary correctness
pinned by 5 cases including 2020-W53 (the 53-week year), 2025-W01
rolling in from 2024-12-30 (Monday), and the GBRAIN_AUDIT_DIR override.

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

* feat(gateway): T3 withBudgetTracker + AsyncLocalStorage composition

TX5: every gateway.chat / embed / rerank call now auto-composes the
active BudgetTracker via a module-internal AsyncLocalStorage. No
per-call injection seam, no flag plumbing — callers wrap their
entrypoint in `withBudgetTracker(tracker, async () => { ... })` and
every downstream LLM call honors the cap.

Outside any scope, the gateway is a budget no-op (back-compat with the
pre-v0.37 contract).

Wiring:
  - chat(): reserves on entry using prompt-char heuristic + opts.maxTokens.
    Records actual usage from result.usage on success; on failure, charges
    the pessimistic A3-amended fallback so the cap is real.
  - embed(): reserves total estimated input tokens (chars / chars-per-token).
    Records the same total in try/finally; SDK doesn't surface per-batch
    embed token counts.
  - rerank(): reserves and records query + docs char count.
    Reranker pricing isn't in the canonical map yet, so reserve() takes
    the warn-once path under no-cap and the TX2 hard-fail under cap.

6 unit cases pin the contract: chat auto-composes, outside-scope is
no-op, nested scope restores outer, over-cap reserve throws BEFORE
provider call (proves circuit breaker), TX1 mid-run cumulative cap
fires via record(), parallel Promise.all scopes do not bleed trackers.

All 255 existing gateway tests and 50 brainstorm tests still pass.

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

* chore(audit): T4 migrate 4 audit writers to shared isoWeekFilename helper

Q1: extract the ISO-week filename math into one canonical helper
(src/core/audit-week-file.ts, landed in T2) and migrate every audit
JSONL writer in the codebase to consume it.

Sites migrated:
  - src/core/minions/handlers/shell-audit.ts  (shell-jobs-YYYY-Www.jsonl)
  - src/core/facts/phantom-audit.ts            (phantoms-YYYY-Www.jsonl)
  - src/core/audit-slug-fallback.ts            (slug-fallback-YYYY-Www.jsonl)
  - src/core/cycle/budget-meter.ts             (dream-budget-YYYY-Www.jsonl)

Each call site had its own copy of the ISO-week-from-Date algorithm.
They mostly agreed but subtle drift was already accumulating (one used
local time, one approximated the Thursday-anchor formula, etc.). One
helper, one set of regression tests, no drift.

Compute helpers (computeAuditFilename, computePhantomAuditFilename,
computeSlugFallbackAuditFilename) are preserved as thin wrappers so
existing import sites and tests don't break.

All audit + slug-fallback + phantom + budget-meter tests still pass.

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

* feat(cycle): T5 BudgetMeter schema_version=1 + golden fixture (A2 amended)

Adapter pass: the existing BudgetMeter keeps its public shape
(`BudgetMeter`, `SubmitEstimate`, `BudgetCheckResult`) verbatim so every
dream-cycle call site keeps working without rewires. The audit JSONL
grew one new field on every line: `schema_version: 1`.

A2 amended: the codex outside-voice review relaxed the byte-stable
contract to schema-stable. Field reorderings are tolerated; the
documented set (schema_version, ts, phase, event, model, label,
plus per-event cost or token fields) is what every consumer can rely
on. Renames or removals are breaking.

test/fixtures/dream-budget-schema-v1.jsonl carries one canonical row
per event variant (submit / submit_denied / submit_unpriced) as
documentation of the schema. The new in-suite case in
test/budget-meter.test.ts walks every emitted line and asserts the
fields are present + the right type.

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

* feat(eval): T6 wrap eval-contradictions runner in withBudgetTracker

The runner now installs a BudgetTracker scope around its body so every
gateway-layer chat / embed / rerank call (the judge model + per-query
embedding) auto-records via the AsyncLocalStorage from T3. Currently
telemetry-only — the existing CostTracker remains the primary soft-
ceiling enforcement, so the public --budget-usd surface and
PreFlightBudgetError shape are byte-identical.

The wiring is the seam: future waves can promote the cap to BudgetTracker
semantics (TX1 + TX2 semantics on cumulative + no_pricing) by passing
maxCostUsd through to BudgetTracker without touching the CLI.

All 79 eval-contradictions tests pass.

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

* feat(doctor): T7 --remediate budget tracker + checkpoint + --resume (A4)

A4 amended: doctor --remediate gains a resumable cost ceiling. The
runRemediate loop now runs inside `withBudgetTracker(tracker, ...)` so
every gateway-routed LLM call inside a Minion handler (synthesize,
patterns, consolidate, embed) honors the cap. When BudgetExhausted
fires mid-run, the onExhausted callback persists a checkpoint of
completed step ids + idempotency_keys to
~/.gbrain/remediation/<plan_hash>.json BEFORE the throw propagates,
and the catch surfaces a paste-ready --resume hint.

Wire-up:
  - New --resume <plan_hash> flag (with implicit "most recent matching"
    when no hash given) loads the checkpoint and skips already-
    completed steps. Mismatched plan_hash refuses with an explicit
    message.
  - --max-cost is now an alias for --max-usd. Both spellings honored
    and threaded through to BudgetTracker.maxCostUsd so the cap is
    a real ceiling, not just pre-flight advice.
  - On BudgetExhausted, exit 1 with the resume hint; on clean
    completion, clear the checkpoint.

New file: src/core/remediation-checkpoint.ts with
computePlanHash / save / load / list / clear helpers. Atomic write
via .tmp + rename. Pinned by 13 unit cases including determinism +
sort-order invariance + schema-mismatch return-null + atomic-rename.

All 48 doctor.test.ts cases still pass.

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

* docs(subagent): T8 A1 ordering ASCII diagram before acquireLease

Documents the load-bearing ordering invariant: the gateway's
BudgetTracker reserve() runs (implicitly, via AsyncLocalStorage)
BEFORE acquireLease() inside the subagent loop. A BudgetExhausted
throw must NOT consume a rate-lease slot, because the lease is the
rate-limit pacer for the entire fleet.

The handler body intentionally does NOT explicitly thread BudgetTracker;
TX5 (gateway-layer composition) handles that. The comment is the
reader's signpost.

No behavioral change. All 58 subagent tests still pass.

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

* feat(diarize): T9 payload-fitter (P6) with batch + summarize + gate

Generic utility for fitting arbitrarily-large item lists into a
downstream caller's per-call token budget. Two strategies:

  - 'batch': deterministic token-budgeted chunking. No LLM calls. The
    fitted list shape matches the input; the caller decides how to
    consume it (e.g. brainstorm judge concatenates per-chunk results).
    Surfaces a `dropped` count for items that exceed the per-call cap.

  - 'summarize': embed-cluster into ceil(items/4) groups via cheap
    deterministic nearest-neighbor on cosine; Haiku-summarize each
    cluster via Promise.allSettled at parallelism=4 (Perf1). Each
    Haiku call composes the active BudgetTracker via the gateway's
    AsyncLocalStorage scope (T3) — no per-call injection.

Quality gate (codex outside-voice finding #4): when summarize's
success_ratio < min_success_ratio (default 0.75), the result is
flagged `degraded: true` so the caller (brainstorm) can decide to
surface a partial result or abort. The fitter itself preserves the
successful subset either way.

Tested via 4 cases across two files (T3 contract):
  - happy path (all clusters succeed → degraded=false)
  - partial failure tolerated (1/5 fails, success_ratio=0.8 > 0.75 → degraded=false)
  - high-failure rate flips the gate (3/5 fails → degraded=true)
  - budget-respecting (BudgetExhausted thrown mid-cluster propagates
    via Promise.allSettled)

11 unit cases across batch + summarize. Brainstorm + cost-guardrails
tests still green; judges.ts internal chunking deferred to a follow-up
wave (TODOS) so the existing chunked-batch contract stays byte-stable
during this drop.

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

* feat(brainstorm): T10 checkpoint + --resume with full idea bodies (P7)

The brainstorm cathedral capstone. Crashed runs can resume cleanly via
`gbrain brainstorm --resume <run_id>` (and `gbrain lsd --resume` etc).

TX3 load-bearing contract: completed_crosses on disk carries FULL idea
bodies (~50KB per run), not just counts. The resumed BrainstormResult
contains the pre-crash ideas (loaded from disk) merged with the post-
resume ideas — codex's outside-voice finding was that a resume that
produces only "what we generated this run" is silent partial output.

TX4 single rule: --resume continues any cross not in completed_crosses.
The proposed --retry-failed was dropped per codex review; failed AND
never-attempted crosses both go through --resume.

A5 amended: run_id = sha256(question + profile + sort(close_slugs) +
sort(far_slugs)).slice(0,16). NO embedding bits — stable across
embedding-model swaps. 7-day mtime-based GC.

Q2 fold: orchestrator.ts drops its inline BudgetExhausted class and
re-exports the canonical one from src/core/budget/budget-tracker.ts
(Phase 2). runBrainstorm now wraps the body in withBudgetTracker so
every gateway-layer chat call auto-records cost. The cap remains
opts.maxCostUsd (default $5).

New CLI flags:
  --resume <run_id>   Continue any cross not in completed_crosses.
                      Refuses to start when run_id doesn't match the
                      active inputs (paste-ready hint).
  --force-resume      Bypass the 7-day staleness gate.
  --list-runs         Print saved run_ids and exit.

Cycle purge phase (the 9th cycle phase) now also GCs stale brainstorm
checkpoints alongside op_checkpoints (~7d window).

Tests:
  - 20 unit cases in test/brainstorm/checkpoint.test.ts:
    computeRunId is deterministic + slug-array-order invariant + stable
    across embedding-model swaps; round-trip preserves ideas verbatim;
    saveCheckpoint atomic via .tmp+rename; loadCheckpoint returns null
    on missing/schema-mismatch/corrupt-JSON; gcStaleCheckpoints unlinks
    >N days; listRuns mtime-ordered.
  - 3 E2E cases in test/e2e/brainstorm-resume.test.ts:
    crash on cross 4 → first run aborts with checkpoint of crosses 1..N
    with full idea bodies; second run with resumeRunId merges pre-crash
    + post-resume ideas (TX3 contract); mismatched run_id refuses with
    paste-ready hint.

The PGLite schema-gap workaround in the E2E (CREATE VIEW page_links AS
SELECT * FROM links) is filed as a follow-up in TODOS T12 — the
real-engine brainstorm path needs that view to materialize as a
canonical schema fix.

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

* docs: T11 + T12 wave release docs + deferred follow-ups

CHANGELOG entry for the brainstorm cost cathedral (Unreleased slot;
/ship will assign the next version):
  - ELI10 lead per CLAUDE.md voice rules
  - "How to turn it on" with paste-ready commands
  - "Things to watch" calls out the A4 semantic shift for
    `doctor --remediate --max-usd` (pre-flight → mid-run abort
    with resumable checkpoint)
  - Itemized changes by file/area
  - "For contributors" section noting the 73 new tests + the PGLite
    schema-gap workaround for the E2E

CLAUDE.md Key Files: 6 new entries for budget-tracker, audit-week-file,
gateway withBudgetTracker, payload-fitter, brainstorm/checkpoint,
remediation-checkpoint. Regenerated llms-full.txt + llms.txt (passes
test/build-llms.test.ts).

docs/incidents/2026-05-20-lsd-cost-explosion.md gains a closing
"Shipped in v0.37.x (the budget cathedral wave)" section listing P1-P7
completion status + the deferred follow-ups so the incident's audit
trail closes the loop.

TODOS.md gets a new top section for the wave's deferred items:
  - PGLite `page_links` schema gap fix
  - Explicit --max-cost on extract / enrich / integrity auto
  - P5 config-schema budgets: block in ~/.gbrain/config.json
  - Multi-day brainstorm resume (>7d)
  - Async-batched audit writes (profiling trigger criterion)
  - BudgetLedger unification with BudgetTracker
  - judges.ts internal chunking → payload-fitter delegation

Also: fixed a payload-fitter typecheck error (ChatFn import). Final
typecheck is clean on every file the wave touched.

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

* fix(schema): F1 page_links view alias for both engines

Brainstorm's domain-bank queries reference `page_links` (pglite-engine.ts:896,
postgres-engine.ts:959) but the canonical table is `links`. Without the alias
view, `gbrain brainstorm` against PGLite fails with `relation "page_links"
does not exist`; the same was a latent bug on Postgres.

This commit lands the fix at three sites:

1. `src/core/pglite-schema.ts` — embedded schema bundle gets the view at
   table-bundle time, so fresh PGLite installs are correct from boot.
2. `src/core/migrate.ts` v81 (`page_links_view_alias`) — existing brains on
   either engine pick up the view via `gbrain apply-migrations`. CREATE OR
   REPLACE VIEW is idempotent; re-running is safe.
3. `test/e2e/brainstorm-resume.test.ts` — removed the ad-hoc workaround view
   from the test setup. The E2E now exercises the same schema path real
   users will see.

`TODOS.md` entry for the gap closed out.

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

* test(brainstorm): F2 pre-flight --max-cost refusal smoke E2E

Pins the user-facing path that closed the original \$50 incident: when
the pre-run estimate exceeds the configured cap, runBrainstorm throws
BudgetExhausted with reason='cost' and a paste-ready hint pointing at
--limit / --max-cost / --max-far-set before any chat call happens.

The four assertions are the four things a real user can verify after
the throw lands:
  1. Typed BudgetExhausted (not a generic Error)
  2. reason === 'cost' (not runtime or no_pricing)
  3. Message names the remediation flags
  4. No provider HTTP would have happened (chat.crossCalls === 0)

Uses the same PGLite engine + tinyProfile + stub chatFn as the existing
--resume tests. Hermetic; ~5s wallclock.

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

* feat(reindex-code): F3 --max-cost flag via withBudgetTracker

Wires gbrain reindex --code into the v0.38 budget cathedral. When the
caller passes --max-cost N (or --max-cost-usd N), runReindexCode wraps
its per-page import loop in withBudgetTracker so every gateway.embed()
call inside importCodeFile auto-composes the cap. On BudgetExhausted,
the partial-progress result reports what got reindexed before the cap
fired plus a synthetic failure row naming the cap throw.

reindex-code is idempotent (content_hash short-circuit in importCodeFile),
so a re-run after a budget abort picks up where the cap fired — no
manual checkpoint state needed.

Both --max-cost and --max-cost-usd are accepted (symmetry with brainstorm
which uses --max-cost, and a precedent for the spelling we want long-term).

When --max-cost is unset, the body runs outside any tracker scope — byte-
stable pre-F3 behavior for legacy callers.

Files:
  src/commands/reindex-code.ts:
    - ReindexCodeOpts.maxCostUsd?: number
    - runReindexCode wraps body in withBudgetTracker when set
    - runReindexCodeCli parses --max-cost / --max-cost-usd
    - BudgetExhausted caught + returned as partial-progress result
  test/reindex-code-max-cost.serial.test.ts (NEW):
    - dry-run + maxCostUsd happy path
    - empty-brain + maxCostUsd hits early-return cleanly
    - no tracker installed when cap is unset (regression guard for
      the conditional wrap)

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

* fix(schema): narrow page_links view projection to bootstrap-safe columns

The v0.38 page_links view alias initially used SELECT * FROM links, which
broke the pre-v0.13 bootstrap test: applyForwardReferenceBootstrap drops
link_source + origin_page_id to simulate the pre-v0.13 schema shape, but
the SELECT * view created a dependency that blocked the column DROP.

Engine queries only reference pl.id (via COUNT(*)) and pl.to_page_id, so
the view's projection is now SELECT id, from_page_id, to_page_id FROM links
— what callers actually use, no more. This unblocks legacy-brain upgrade
paths AND keeps the bootstrap forward-reference probes safe.

Bootstrap suite: 15/15 pass after the change.

Also files a P0 TODO for a pre-existing test failure
(test/doctor-report-remote.test.ts "full report on healthy brain") that
fails on master too — out of scope for this wave but noticed during
/ship triage.

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

* chore: bump version to v0.39.0.0

Brainstorm cost cathedral wave (P1-P7). MINOR bump per user direction:
new architectural seam (gateway-layer BudgetTracker via AsyncLocalStorage),
5 new modules, new CLI flags (--max-cost / --resume / --list-runs /
--force-resume), new migration v81 (page_links view alias).

No breaking changes — BudgetExhausted re-exported from orchestrator for
back-compat; --max-usd preserved as alias for --max-cost; eval-contradictions
--budget-usd surface byte-identical.

CHANGELOG entry renamed from [Unreleased] to [0.39.0.0] and adds the
mandatory "To take advantage of v0.39.0.0" block per CLAUDE.md.

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

* test(isolation): rename 3 env-mutating tests to .serial.test.ts (CI fix)

CI's `check:test-isolation` flagged three tests added in the v0.39.0.0
cathedral that directly mutate `process.env` across test boundaries:

- test/brainstorm/checkpoint.test.ts (mutates GBRAIN_HOME)
- test/core/audit-week-file.test.ts (mutates GBRAIN_AUDIT_DIR)
- test/core/remediation-checkpoint.test.ts (mutates GBRAIN_HOME)

Per CLAUDE.md rule R1: env-mutating tests either use withEnv() OR rename
to *.serial.test.ts (the quarantine escape hatch). The mutation lives in
beforeEach/afterEach which spans the whole describe block, so .serial
rename is the cleaner fix — withEnv() would require restructuring every
test. The serial-test runner gives them their own bun process; no cross-
file env races.

Verified: check:test-isolation passes (527 non-serial unit files clean),
`bun run verify` passes, all 41 tests in the three renamed files pass.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 11:53:11 -07:00

1211 lines
48 KiB
TypeScript

/**
* Subagent LLM-loop handler (v0.15).
*
* Runs one Anthropic Messages API conversation with tool use. The loop is
* crash-resumable: subagent_messages + subagent_tool_executions together
* are the single source of truth about where the conversation is. On
* resume after a worker kill, we load all committed rows, trust any tool
* execution marked 'complete' or 'failed', and re-run 'pending' ones only
* for idempotent tools.
*
* Safety rails:
* - rate leases around every LLM call (acquire → call → release). Mid-
* call renewal with backoff. Persistent renewal failure aborts as a
* renewable error so the worker re-claims.
* - dual-signal abort wiring (ctx.signal + ctx.shutdownSignal) drains
* the in-flight call and commits whatever turns are already persisted.
* - Anthropic prompt cache markers on system + tools blocks.
* - token rollup via ctx.updateTokens per turn.
*
* NOT in v0.15: refusal detection, stop_reason=max_tokens partial
* recovery, parallel tool-use dispatch (runs tools sequentially; the
* Messages API allows parallel tool_use blocks and the replay tolerates
* them, but v1 dispatches serially for simplicity). All three are tracked
* as P2 items in the plan file.
*/
import Anthropic from '@anthropic-ai/sdk';
import type { MinionJobContext, MinionJob } from '../types.ts';
import { UnrecoverableError } from '../types.ts';
import type {
ContentBlock,
SubagentHandlerData,
SubagentResult,
SubagentStopReason,
ToolDef,
} from '../types.ts';
import type { BrainEngine } from '../../engine.ts';
import type { GBrainConfig } from '../../config.ts';
import { loadConfig } from '../../config.ts';
import { buildBrainTools, filterAllowedTools } from '../tools/brain-allowlist.ts';
import {
acquireLease,
releaseLease,
renewLeaseWithBackoff,
} from '../rate-leases.ts';
import {
logSubagentSubmission,
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 ────────────────────────────────────────────────
const DEFAULT_MODEL = 'claude-sonnet-4-6';
const DEFAULT_MAX_TURNS = 20;
const DEFAULT_RATE_KEY = 'anthropic:messages';
const DEFAULT_MAX_CONCURRENT = Number(process.env.GBRAIN_ANTHROPIC_MAX_INFLIGHT ?? '8');
const DEFAULT_LEASE_TTL_MS = 120_000;
const DEFAULT_SYSTEM = 'You are a helpful assistant running as a gbrain subagent.';
// ── Injectable surfaces (for tests) ─────────────────────────
/**
* Anthropic Messages client. The real Anthropic SDK implements this
* structurally; tests can substitute a mock without the SDK import.
*/
export interface MessagesClient {
create(params: Anthropic.MessageCreateParamsNonStreaming, opts?: { signal?: AbortSignal }): Promise<Anthropic.Message>;
}
export interface SubagentDeps {
/** Engine for DB-backed ops (tools + message persistence + rate leases). */
engine: BrainEngine;
/** Anthropic client. Defaults to the SDK-constructed client. */
client?: MessagesClient;
/**
* Anthropic SDK constructor. Defaults to `() => new Anthropic()`.
* Overridable in tests so the factory default-client branch is
* exercisable without an ANTHROPIC_API_KEY or a real API call.
* When `deps.client` is provided, this is unused.
*/
makeAnthropic?: () => Anthropic;
/** Config (MCP, brain, etc.). Defaults to loadConfig(). */
config?: GBrainConfig;
/** Rate-lease key. Defaults to `anthropic:messages`. */
rateLeaseKey?: string;
/** Max concurrent inflight calls on that key. Defaults to GBRAIN_ANTHROPIC_MAX_INFLIGHT or 8. */
maxConcurrent?: number;
/** Lease TTL. Defaults to 120s. */
leaseTtlMs?: number;
/**
* Override tool registry. When omitted, buildBrainTools is called with
* the caller's subagentId at dispatch time.
*/
toolRegistry?: ToolDef[];
}
// ── Types for internal state ────────────────────────────────
interface PersistedMessage {
message_idx: number;
role: 'user' | 'assistant';
content_blocks: ContentBlock[];
tokens_in: number | null;
tokens_out: number | null;
tokens_cache_read: number | null;
tokens_cache_create: number | null;
model: string | null;
}
interface PersistedToolExec {
message_idx: number;
tool_use_id: string;
tool_name: string;
input: unknown;
status: 'pending' | 'complete' | 'failed';
output: unknown;
error: string | null;
}
// ── Public handler factory ──────────────────────────────────
/**
* Build a subagent handler bound to a specific engine. `registerBuiltin
* Handlers` wires this up as `worker.register('subagent', handler)` at
* worker startup. Always registered — `ANTHROPIC_API_KEY` is the natural
* cost gate and `PROTECTED_JOB_NAMES` gates submission.
*/
export function makeSubagentHandler(deps: SubagentDeps) {
const engine = deps.engine;
// sdk.messages IS the MessagesClient-shaped object. The v0.16.0 bug was
// casting new Anthropic() (top level) to MessagesClient, but .create()
// lives at sdk.messages.create. Assigning sdk.messages directly gets the
// right object; JS method-call semantics preserve `this` at the call
// site (subagent.ts invokes client.create(...) with client === sdk.messages).
const makeAnthropic = deps.makeAnthropic ?? (() => new Anthropic());
const client: MessagesClient = deps.client ?? makeAnthropic().messages;
const config = deps.config ?? loadConfig() ?? ({ engine: 'postgres' } as GBrainConfig);
const rateLeaseKey = deps.rateLeaseKey ?? DEFAULT_RATE_KEY;
const maxConcurrent = deps.maxConcurrent ?? DEFAULT_MAX_CONCURRENT;
const leaseTtlMs = deps.leaseTtlMs ?? DEFAULT_LEASE_TTL_MS;
return async function subagentHandler(ctx: MinionJobContext): Promise<SubagentResult> {
const data = (ctx.data ?? {}) as unknown as SubagentHandlerData;
if (!data.prompt || typeof data.prompt !== 'string') {
throw new Error('subagent job data.prompt is required (string)');
}
// 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, {
tier: 'subagent',
configKey: 'models.subagent',
fallback: TIER_DEFAULTS.subagent,
});
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
// allow-list — flows through buildBrainTools → the put_page schema
// description AND the OperationContext, so the model's tool schema and
// the server-side check stay in sync).
const registry = deps.toolRegistry ?? buildBrainTools({
subagentId: ctx.id,
engine,
config,
brainId: data.brain_id,
allowedSlugPrefixes: data.allowed_slug_prefixes,
});
const toolDefs = data.allowed_tools && data.allowed_tools.length > 0
? filterAllowedTools(registry, data.allowed_tools)
: registry;
logSubagentSubmission({
caller: 'worker',
remote: true,
job_id: ctx.id,
model,
tools_count: toolDefs.length,
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);
const priorToolByUseId = new Map(priorTools.map(t => [t.tool_use_id, t]));
// Rebuild the Anthropic messages array from persisted rows.
const anthroMessages: Anthropic.MessageParam[] = priorMessages.length > 0
? priorMessages.map(m => ({ role: m.role, content: m.content_blocks as any }))
: [{ role: 'user', content: data.prompt }];
// If we had no prior messages, persist the seed user message.
let nextMessageIdx = priorMessages.length;
if (priorMessages.length === 0) {
await persistMessage(engine, ctx.id, {
message_idx: 0,
role: 'user',
content_blocks: [{ type: 'text', text: data.prompt }],
tokens_in: null,
tokens_out: null,
tokens_cache_read: null,
tokens_cache_create: null,
model: null,
});
nextMessageIdx = 1;
}
// Token rollup.
const tokenTotals = { in: 0, out: 0, cache_read: 0, cache_create: 0 };
for (const m of priorMessages) {
if (m.tokens_in) tokenTotals.in += m.tokens_in;
if (m.tokens_out) tokenTotals.out += m.tokens_out;
if (m.tokens_cache_read) tokenTotals.cache_read += m.tokens_cache_read;
if (m.tokens_cache_create) tokenTotals.cache_create += m.tokens_cache_create;
}
// Count assistant messages already persisted toward max_turns.
let assistantTurns = priorMessages.filter(m => m.role === 'assistant').length;
// ── Replay reconciliation ───────────────────────────────
//
// If the last persisted message is an assistant with tool_use blocks
// AND no subsequent user message has been synthesized yet, we crashed
// mid-tool-dispatch. Finish those tools now so the next LLM call sees
// a consistent conversation.
//
// v0.37.7.0 #1151: if the last persisted message is an assistant
// with NO tool_use blocks, the prior run already reached terminal
// end_turn. Sonnet 4.6+ rejects assistant-prefill, so calling
// messages.create here would dead-letter the job despite the work
// being already committed. Return immediately with the persisted
// text as finalText. Mirrors the live-loop terminal logic below.
const last = priorMessages[priorMessages.length - 1];
if (last && last.role === 'assistant') {
const pendingToolUses = last.content_blocks.filter(
(b): b is { type: 'tool_use'; id: string; name: string; input: unknown } & Record<string, unknown> =>
b.type === 'tool_use',
);
if (pendingToolUses.length === 0) {
const finalText = last.content_blocks
.filter((b): b is { type: 'text'; text: string } & Record<string, unknown> =>
b.type === 'text' && typeof (b as { text?: unknown }).text === 'string',
)
.map(b => b.text)
.join('\n');
return {
result: finalText,
turns_count: assistantTurns,
stop_reason: 'end_turn',
tokens: tokenTotals,
};
}
if (pendingToolUses.length > 0) {
const synthesizedResults: ContentBlock[] = [];
for (const use of pendingToolUses) {
const prior = priorToolByUseId.get(use.id);
if (prior?.status === 'complete') {
synthesizedResults.push({
type: 'tool_result',
tool_use_id: use.id,
content: asStringIfNotObject(prior.output),
} as ContentBlock);
continue;
}
if (prior?.status === 'failed') {
synthesizedResults.push({
type: 'tool_result',
tool_use_id: use.id,
content: prior.error ?? 'tool failed',
is_error: true,
} as ContentBlock);
continue;
}
// pending or no row yet — try to dispatch.
const toolDef = toolDefs.find(t => t.name === use.name);
if (!toolDef) {
await persistToolExecFailed(
engine, ctx.id, last.message_idx, use.id, use.name, use.input,
`tool "${use.name}" is not in the registry for this subagent`,
);
synthesizedResults.push({
type: 'tool_result', tool_use_id: use.id,
content: `tool "${use.name}" is not available`, is_error: true,
} as ContentBlock);
continue;
}
if (prior?.status === 'pending' && !toolDef.idempotent) {
throw new Error(`non-idempotent tool "${use.name}" pending on resume; cannot safely re-run`);
}
await persistToolExecPending(engine, ctx.id, last.message_idx, use.id, use.name, use.input);
try {
const output = await toolDef.execute(use.input, {
engine, jobId: ctx.id, remote: true, signal: ctx.signal,
});
await persistToolExecComplete(engine, ctx.id, use.id, output);
synthesizedResults.push({
type: 'tool_result', tool_use_id: use.id,
content: asStringIfNotObject(output),
} as ContentBlock);
} catch (e) {
const errText = e instanceof Error ? (e.stack ?? e.message) : String(e);
await persistToolExecFailed(engine, ctx.id, last.message_idx, use.id, use.name, use.input, errText);
synthesizedResults.push({
type: 'tool_result', tool_use_id: use.id,
content: errText, is_error: true,
} as ContentBlock);
}
}
// Persist the synthesized user turn so next-resume picks up here.
const userIdx = nextMessageIdx++;
await persistMessage(engine, ctx.id, {
message_idx: userIdx,
role: 'user',
content_blocks: synthesizedResults,
tokens_in: null, tokens_out: null, tokens_cache_read: null, tokens_cache_create: null, model: null,
});
anthroMessages.push({ role: 'user', content: synthesizedResults as any });
}
}
// ── Main loop ───────────────────────────────────────────
let stopReason: SubagentStopReason = 'error';
let finalText = '';
while (true) {
if (assistantTurns >= maxTurns) {
stopReason = 'max_turns';
break;
}
if (ctx.signal.aborted || ctx.shutdownSignal.aborted) {
stopReason = 'error';
throw new Error('subagent aborted before turn');
}
// 1. Acquire rate lease for the outbound call.
//
// A1 ORDERING (v0.37.x budget cathedral):
//
// +----------------------------------+
// | gateway.chat() inside subagent |
// +-----+----------------------------+
// |
// 1. getCurrentBudgetTracker()?.reserve(...)
// | (runs via the gateway's AsyncLocalStorage scope,
// | set by the upstream caller of the subagent.
// | On BudgetExhausted: throw BEFORE we touch the lease.)
// v
// 2. acquireLease(...) <-- the line below
// | (only attempted if the budget gate passed)
// v
// 3. provider HTTP call
// |
// v
// 4. tracker.record(actual usage)
//
// The handler body intentionally does NOT thread `BudgetTracker`
// explicitly. Gateway-layer composition (TX5) handles it. The
// ordering is load-bearing: a budget throw must NOT consume a
// lease slot, because the lease is the rate-limit pacer for the
// entire fleet.
const lease = await acquireLease(engine, rateLeaseKey, ctx.id, maxConcurrent, { ttlMs: leaseTtlMs });
if (!lease.acquired) {
// No slots — treat as a renewable error so the worker re-claims
// the job later. Don't fail terminally.
throw new RateLeaseUnavailableError(rateLeaseKey, lease.activeCount, lease.maxConcurrent);
}
let assistantMsg: Anthropic.Message;
const turnIdx = assistantTurns;
const t0 = Date.now();
logSubagentHeartbeat({ job_id: ctx.id, event: 'llm_call_started', turn_idx: turnIdx });
// Renewal is short-lived; for single-call turns the initial TTL
// covers the whole request. A mid-call renewal loop would add
// complexity; for v0.15 we lean on the 120s TTL + abort-on-signal.
try {
const params: Anthropic.MessageCreateParamsNonStreaming = {
model,
max_tokens: 4096,
system: [
{ type: 'text', text: systemPrompt, cache_control: { type: 'ephemeral' } },
] as any,
messages: anthroMessages,
...(toolDefs.length > 0
? {
tools: toolDefs.map((t, i) => {
const def: any = {
name: t.name,
description: t.description,
input_schema: t.input_schema,
};
// Cache only the last tool def — Anthropic treats cache_control
// as "cache everything up to and including this block".
if (i === toolDefs.length - 1) def.cache_control = { type: 'ephemeral' };
return def;
}),
}
: {}),
};
const combinedSignal = mergeSignals(ctx.signal, ctx.shutdownSignal);
assistantMsg = await client.create(params, { signal: combinedSignal });
} catch (err) {
// Release lease eagerly on error so we don't starve capacity.
await releaseLease(engine, lease.leaseId!).catch(() => {});
// Terminal classification: a 400 "prompt is too long" from Anthropic
// is unrecoverable — retrying with the same prompt will always fail.
// Convert to UnrecoverableError so the worker routes the job
// straight to `dead`, bypassing max_stalled retries (the v0.30.x
// dream-cycle queue-clog the chunking work was built to prevent).
if (isPromptTooLongError(err)) {
const origMsg = err instanceof Error ? err.message : String(err);
throw new UnrecoverableError(`prompt_too_long: ${origMsg}`);
}
throw err;
}
// 2. Release lease as soon as the call returns. Tool execution runs
// outside the lease — tool calls use their own capacity.
await releaseLease(engine, lease.leaseId!).catch(() => {});
const ms = Date.now() - t0;
const inTokens = assistantMsg.usage?.input_tokens ?? 0;
const outTokens = assistantMsg.usage?.output_tokens ?? 0;
const cacheRead = (assistantMsg.usage as any)?.cache_read_input_tokens ?? 0;
const cacheCreate = (assistantMsg.usage as any)?.cache_creation_input_tokens ?? 0;
tokenTotals.in += inTokens;
tokenTotals.out += outTokens;
tokenTotals.cache_read += cacheRead;
tokenTotals.cache_create += cacheCreate;
logSubagentHeartbeat({
job_id: ctx.id,
event: 'llm_call_completed',
turn_idx: turnIdx,
ms_elapsed: ms,
tokens: { in: inTokens, out: outTokens, cache_read: cacheRead, cache_create: cacheCreate },
});
// Update job-level token rollup (best-effort; may throw if lock lost).
await ctx.updateTokens({
input: inTokens,
output: outTokens,
cache_read: cacheRead,
});
const blocks = assistantMsg.content as ContentBlock[];
// 3. Persist the assistant message BEFORE tool dispatch so replay
// sees a consistent state.
const assistantIdx = nextMessageIdx++;
await persistMessage(engine, ctx.id, {
message_idx: assistantIdx,
role: 'assistant',
content_blocks: blocks,
tokens_in: inTokens,
tokens_out: outTokens,
tokens_cache_read: cacheRead,
tokens_cache_create: cacheCreate,
model,
});
anthroMessages.push({ role: 'assistant', content: blocks as any });
assistantTurns++;
// 4. Collect tool_use blocks. If none, we're done.
const toolUses = blocks.filter(
(b): b is { type: 'tool_use'; id: string; name: string; input: unknown } & Record<string, unknown> =>
b.type === 'tool_use',
);
if (toolUses.length === 0) {
stopReason = 'end_turn';
// Concatenate text blocks as the final answer.
finalText = blocks
.filter(b => b.type === 'text' && typeof b.text === 'string')
.map(b => b.text as string)
.join('\n');
break;
}
// 5. Dispatch each tool_use. Two-phase persist (pending → complete/failed).
const toolResults: ContentBlock[] = [];
for (const use of toolUses) {
if (ctx.signal.aborted || ctx.shutdownSignal.aborted) {
throw new Error('subagent aborted during tool dispatch');
}
const toolName = use.name;
const toolDef = toolDefs.find(t => t.name === toolName);
if (!toolDef) {
// Model called a tool we didn't expose. Mark execution failed
// with a clear error and feed the error back in the next turn.
await persistToolExecFailed(
engine, ctx.id, assistantIdx, use.id, toolName, use.input,
`tool "${toolName}" is not in the registry for this subagent`,
);
toolResults.push({
type: 'tool_result',
tool_use_id: use.id,
content: `tool "${toolName}" is not available`,
is_error: true,
} as ContentBlock);
logSubagentHeartbeat({
job_id: ctx.id,
event: 'tool_failed',
turn_idx: turnIdx,
tool_name: toolName,
error: 'not in registry',
});
continue;
}
// Replay: if we already have a row for this tool_use_id, trust it
// unless status='pending' and the tool is idempotent (re-run).
const prior = priorToolByUseId.get(use.id);
if (prior && prior.status === 'complete') {
toolResults.push({
type: 'tool_result',
tool_use_id: use.id,
content: asStringIfNotObject(prior.output),
} as ContentBlock);
continue;
}
if (prior && prior.status === 'failed') {
toolResults.push({
type: 'tool_result',
tool_use_id: use.id,
content: prior.error ?? 'tool failed',
is_error: true,
} as ContentBlock);
continue;
}
if (prior && prior.status === 'pending' && !toolDef.idempotent) {
// Non-idempotent and we don't know the outcome — fail the job.
throw new Error(`non-idempotent tool "${toolName}" pending on resume; cannot safely re-run`);
}
// Fresh or idempotent-replay dispatch.
await persistToolExecPending(engine, ctx.id, assistantIdx, use.id, toolName, use.input);
logSubagentHeartbeat({ job_id: ctx.id, event: 'tool_called', turn_idx: turnIdx, tool_name: toolName });
const toolStart = Date.now();
try {
const output = await toolDef.execute(use.input, {
engine,
jobId: ctx.id,
remote: true,
signal: ctx.signal,
});
await persistToolExecComplete(engine, ctx.id, use.id, output);
logSubagentHeartbeat({
job_id: ctx.id,
event: 'tool_result',
turn_idx: turnIdx,
tool_name: toolName,
ms_elapsed: Date.now() - toolStart,
});
toolResults.push({
type: 'tool_result',
tool_use_id: use.id,
content: asStringIfNotObject(output),
} as ContentBlock);
} catch (e) {
const errText = e instanceof Error
? (e.stack ?? e.message)
: String(e);
await persistToolExecFailed(engine, ctx.id, assistantIdx, use.id, toolName, use.input, errText);
logSubagentHeartbeat({
job_id: ctx.id,
event: 'tool_failed',
turn_idx: turnIdx,
tool_name: toolName,
ms_elapsed: Date.now() - toolStart,
error: errText,
});
toolResults.push({
type: 'tool_result',
tool_use_id: use.id,
content: errText,
is_error: true,
} as ContentBlock);
}
}
// 6. Append the synthesized user turn (tool_result wrappers) to the
// conversation and persist it so replay picks it up.
const userIdx = nextMessageIdx++;
await persistMessage(engine, ctx.id, {
message_idx: userIdx,
role: 'user',
content_blocks: toolResults,
tokens_in: null,
tokens_out: null,
tokens_cache_read: null,
tokens_cache_create: null,
model: null,
});
anthroMessages.push({ role: 'user', content: toolResults as any });
}
return {
result: finalText,
turns_count: assistantTurns,
stop_reason: stopReason,
tokens: tokenTotals,
};
};
}
// ── 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[]> {
const rows = await engine.executeRaw<Record<string, unknown>>(
`SELECT message_idx, role, content_blocks, tokens_in, tokens_out,
tokens_cache_read, tokens_cache_create, model
FROM subagent_messages
WHERE job_id = $1
ORDER BY message_idx ASC`,
[jobId],
);
return rows.map(r => ({
message_idx: r.message_idx as number,
role: r.role as 'user' | 'assistant',
content_blocks: (typeof r.content_blocks === 'string'
? JSON.parse(r.content_blocks as string)
: r.content_blocks) as ContentBlock[],
tokens_in: (r.tokens_in as number) ?? null,
tokens_out: (r.tokens_out as number) ?? null,
tokens_cache_read: (r.tokens_cache_read as number) ?? null,
tokens_cache_create: (r.tokens_cache_create as number) ?? null,
model: (r.model as string) ?? null,
}));
}
async function loadPriorTools(engine: BrainEngine, jobId: number): Promise<PersistedToolExec[]> {
const rows = await engine.executeRaw<Record<string, unknown>>(
`SELECT message_idx, tool_use_id, tool_name, input, status, output, error
FROM subagent_tool_executions
WHERE job_id = $1`,
[jobId],
);
return rows.map(r => ({
message_idx: r.message_idx as number,
tool_use_id: r.tool_use_id as string,
tool_name: r.tool_name as string,
input: typeof r.input === 'string' ? JSON.parse(r.input) : r.input,
status: r.status as 'pending' | 'complete' | 'failed',
output: r.output == null
? null
: (typeof r.output === 'string' ? JSON.parse(r.output) : r.output),
error: (r.error as string) ?? null,
}));
}
async function persistMessage(engine: BrainEngine, jobId: number, msg: PersistedMessage): Promise<void> {
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, $2, $3, $4::jsonb, $5, $6, $7, $8, $9)
ON CONFLICT (job_id, message_idx) DO NOTHING`,
[
jobId,
msg.message_idx,
msg.role,
JSON.stringify(msg.content_blocks),
msg.tokens_in,
msg.tokens_out,
msg.tokens_cache_read,
msg.tokens_cache_create,
msg.model,
],
);
}
async function persistToolExecPending(
engine: BrainEngine,
jobId: number,
messageIdx: number,
toolUseId: string,
toolName: string,
input: unknown,
): Promise<void> {
// Serialize to JSON string for the ::jsonb cast. When `input` is already a
// string (e.g. pre-serialized), avoid double-encoding which produces a jsonb
// scalar string instead of a jsonb object — breaking `input->>'key'` lookups.
const jsonStr = typeof input === 'string' ? input : JSON.stringify(input);
await engine.executeRaw(
`INSERT INTO subagent_tool_executions (job_id, message_idx, tool_use_id, tool_name, input, status)
VALUES ($1, $2, $3, $4, $5::jsonb, 'pending')
ON CONFLICT (job_id, tool_use_id) DO NOTHING`,
[jobId, messageIdx, toolUseId, toolName, jsonStr],
);
}
async function persistToolExecComplete(
engine: BrainEngine,
jobId: number,
toolUseId: string,
output: unknown,
): Promise<void> {
await engine.executeRaw(
`UPDATE subagent_tool_executions
SET status = 'complete', output = $3::jsonb, ended_at = now()
WHERE job_id = $1 AND tool_use_id = $2`,
[jobId, toolUseId, typeof output === 'string' ? output : JSON.stringify(output)],
);
}
async function persistToolExecFailed(
engine: BrainEngine,
jobId: number,
messageIdx: number,
toolUseId: string,
toolName: string,
input: unknown,
error: string,
): Promise<void> {
// INSERT-or-UPDATE to failed — covers both "no pending row yet" (tool
// rejected upfront) and "pending row exists" (tool threw mid-execute).
await engine.executeRaw(
`INSERT INTO subagent_tool_executions (job_id, message_idx, tool_use_id, tool_name, input, status, error, ended_at)
VALUES ($1, $2, $3, $4, $5::jsonb, 'failed', $6, now())
ON CONFLICT (job_id, tool_use_id) DO UPDATE
SET status = 'failed', error = EXCLUDED.error, ended_at = now()`,
[jobId, messageIdx, toolUseId, toolName, typeof input === 'string' ? input : JSON.stringify(input), error],
);
}
// ── Internal: helpers ───────────────────────────────────────
function asStringIfNotObject(value: unknown): string {
if (typeof value === 'string') return value;
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
/**
* Merge two AbortSignals into one. Fires when either source aborts. No-op
* polyfill when AbortSignal.any isn't available yet (Node ≥ 20 has it).
*/
function mergeSignals(a: AbortSignal, b: AbortSignal): AbortSignal {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const anyFn = (AbortSignal as any).any;
if (typeof anyFn === 'function') return anyFn([a, b]) as AbortSignal;
// Manual merge.
const ac = new AbortController();
if (a.aborted || b.aborted) ac.abort();
else {
a.addEventListener('abort', () => ac.abort(), { once: true });
b.addEventListener('abort', () => ac.abort(), { once: true });
}
return ac.signal;
}
/**
* Error thrown when acquireLease returns acquired=false. The worker
* treats this as a renewable error — job goes back to waiting with
* backoff, no terminal fail.
*/
export class RateLeaseUnavailableError extends Error {
constructor(public key: string, public active: number, public max: number) {
super(`rate lease "${key}" full (${active}/${max})`);
this.name = 'RateLeaseUnavailableError';
}
}
/**
* Detect Anthropic SDK errors that indicate the input prompt exceeded the
* model's context window. Two recognized shapes:
* - `Anthropic.APIError` with `.status === 400` and message containing
* "prompt is too long" (current SDK wording, observed in production
* as `prompt is too long: 1707509 tokens > 1000000 maximum`).
* - Any error whose message includes "prompt is too long" (defensive
* against SDK-wrap shape changes).
*
* Case-insensitive on the phrase. Also matches `request_too_large` and
* `invalid_request_error` types when accompanied by the same message.
*
* Exported for unit testing.
*/
export function isPromptTooLongError(err: unknown): boolean {
if (!err) return false;
// Walk both `.message` and `.error?.message` shapes.
const msg = (err as { message?: unknown })?.message;
const inner = (err as { error?: { message?: unknown } })?.error?.message;
const candidates = [msg, inner].filter((s): s is string => typeof s === 'string');
for (const c of candidates) {
if (/prompt is too long/i.test(c)) return true;
}
// Anthropic SDK wraps with .status; 400 + 'invalid_request_error' /
// 'request_too_large' types both indicate the same class. Only treat
// as terminal when the message actually says prompt-too-long; broader
// 400s could be transient (e.g., malformed JSON from a test stub).
const status = (err as { status?: unknown })?.status;
const errType = (err as { error?: { type?: unknown } })?.error?.type;
if (status === 400 && (errType === 'invalid_request_error' || errType === 'request_too_large')) {
for (const c of candidates) {
if (/too long|exceed|maximum/i.test(c)) return true;
}
}
return false;
}
// ── Testing surface ─────────────────────────────────────────
export const __testing = {
loadPriorMessages,
loadPriorTools,
persistMessage,
persistToolExecPending,
persistToolExecComplete,
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,
};