Compare commits

...
32 Commits
Author SHA1 Message Date
Sinabina 7b839dc996 merge: fix-wave A gateway tool-loop consolidation (supersedes #1934 #2062 #2065 #2112 #2274 #2487 #2336 #2257 #2499 #2491 #2063 #2488 #2614 #2806 #2617 #2572)
# Conflicts:
#	src/core/ai/gateway.ts
#	src/core/ai/recipes/deepseek.ts
#	src/core/ai/types.ts
2026-07-13 17:12:43 -07:00
+3 1c9acde3c2 fix(gateway): consolidate tool-loop resume + provider fixes (fix-wave A)
Collector branch superseding the gateway tool-loop duplicate cluster and
adjacent provider fixes. Re-implemented from the best of each PR (deduped by
content, not file-overlap); every fix carries test coverage.

Gateway tool-loop resume (supersedes #1934 #2062 #2065 #2112 #2274 #2487
#2336 #2257 #2499 #2491, test #2063):
- toolLoop now persists the tool-result user turn per round (onToolResultTurn),
  so a resumed subagent job reloads a balanced transcript instead of dangling
  assistant tool-calls that non-Anthropic providers reject with
  AI_MissingToolResultsError.
- runSubagentViaGateway reconciles an already-corrupted transcript on resume:
  it heals every dangling assistant tool-call turn (not just the tail) from the
  settled subagent_tool_executions rows, re-dispatching idempotent-pending
  tools and throwing on non-idempotent, mirroring the legacy Anthropic path.
  Terminal early-return for a transcript that already reached end_turn.
- repairToolPairing() is a last-resort normalization at the chat() boundary
  (from #2336): back-fills error stubs for any assistant tool-call still
  unanswered (partial turns, provider-duplicated/dropped IDs on local models,
  length-truncated batches). No-op on balanced input.
- toModelMessages is Date-safe (Postgres timestamptz -> ISO via a JSON round
  trip at the SDK boundary, never a ::jsonb cast; degrades bigint/circular to a
  string instead of throwing) and drops non-string text blocks reasoning models
  emit that AI SDK v6 rejects (#2488).

Adjacent provider fixes:
- Model-aware default max output tokens: thinking-by-default Claude 5 models get
  headroom (gateway 32000, think 16000) while everything else stays 4096/4000,
  so DeepSeek/OpenAI subagents don't exceed provider caps (#2614 #2806).
- DeepSeek: promote reasoning_content into content when content is empty, via a
  fail-open recipe fetch shim (#2617).
- OpenRouter: map openrouter_api_key (config + env) into OPENROUTER_API_KEY
  through buildGatewayConfig; register agent.use_gateway_loop, zeroentropy and
  openrouter keys in KNOWN_CONFIG_KEYS so `config set` accepts them (#2572,
  config key from #2112).

Preserves JSONB (no JSON.stringify into ::jsonb), engine parity, source
isolation, and trust-boundary invariants. Verified with the gbrain-pr-test-env
consumer matrix (clawlancer/Postgres, gstack + hivemindos/PGLite) baseline-FAIL
-> candidate-PASS on the same repro, plus bun run verify (31/31) and 439
targeted unit + e2e tests.

Co-Authored-By: thomaskong119 <thomaskong119@hotmail.com>
Co-Authored-By: maxpetrusenkoagent <max.petrusenko.agent@gmail.com>
Co-Authored-By: brettdavies <brettdavies@users.noreply.github.com>
Co-Authored-By: ivandebot <187176982+ivandebot@users.noreply.github.com>
Co-Authored-By: Rafael Reis <rafael.reis@contabilizei.com.br>
Co-Authored-By: fbal23 <fbal.public@gmail.com>
Co-Authored-By: javieraldape <javieraldape@users.noreply.github.com>
Co-Authored-By: David Carolan <david@joyrestart.com>
Co-Authored-By: Masashi-Ono0611 <masashi.ono.0611@gmail.com>
Co-Authored-By: spiky02plateau <155588579+spiky02plateau@users.noreply.github.com>
Co-Authored-By: psam-717 <mphilannorbah@gmail.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 17:02:47 -07:00
SinabinaandClaude Fable 5 cd33efdd68 Merge PR #2630: feat(autopilot): wire the nightly conversation-parser probe into the loop
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 11:28:00 -07:00
Sinabina 062915fe7d Merge PR #2629: fix(autopilot,eval): make the nightly quality probe enable path work end-to-end
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

# Conflicts:
#	test/engine-find-trajectory.test.ts
2026-07-13 11:28:00 -07:00
Sinabina 62592c939e Merge PR #2600: fix(ai): add chat touchpoint to litellm proxy recipe
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

# Conflicts:
#	src/core/ai/recipes/litellm-proxy.ts
2026-07-13 11:27:14 -07:00
SinabinaandClaude Fable 5 7c04f46f12 Merge PR #2632: fix(config): register Life Chronicle keys so the documented enable command works
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 11:26:35 -07:00
SinabinaandClaude Fable 5 cde2f0ec79 Merge PR #2628: perf(contextual-retrieval): bound per-chunk synopsis concurrency
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 11:26:35 -07:00
SinabinaandClaude Fable 5 92b6f00833 Merge PR #2625: fix(security): prevent leaking admin bootstrap token to non-TTY logs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 11:26:35 -07:00
SinabinaandClaude Fable 5 0d76183528 Merge PR #2621: fix(trajectory): stop negative metrics from inverting regression signals
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 11:26:35 -07:00
SinabinaandClaude Fable 5 f4593769aa Merge PR #2617: fix(ai): promote DeepSeek reasoning_content when content is empty
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 11:26:35 -07:00
SinabinaandClaude Fable 5 9c04c42f89 Merge PR #2616: fix(migrations): let force-retry escape completed ledger entries
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 11:26:34 -07:00
SinabinaandClaude Fable 5 819e807ed3 Merge PR #2615: feat(extract_atoms): honor pack manifest extractable flag in page discovery
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 11:26:34 -07:00
SinabinaandClaude Fable 5 cd4e3d2c47 Merge PR #2611: fix(minions): default timeout for contextual reindex
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 11:26:34 -07:00
SinabinaandClaude Fable 5 9695c7383a Merge PR #2596: fix(search): port the CJK keyword fallback to the Postgres engine (PGLite parity)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 11:26:34 -07:00
Paolo BelcastroandClaude Fable 5 f29f24dfb1 fix(config): register takes.bootstrap_enabled too — same unregistered-key class
Hit while enabling the takes bootstrap on a live brain: the onboard
remediation's documented enable key fails 'Unknown config key' exactly like
auto_chronicle did. Registered + pinned by the same regression test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FQgByq4aqQq2PP8UHCdnfk
2026-07-06 18:06:02 +02:00
Paolo BelcastroandClaude Fable 5 f2e7a87910 test(find-trajectory): pin embedding dims — kill the shard-order 1280/1536 flake
engine-find-trajectory.test.ts hardcodes Float32Array(1536) vectors but let
initSchema size its vector columns from process-global gateway state
(getEmbeddingDimensions(), default 1280). Whether the file passed depended
on which test files ran before it in the shard: any predecessor leaving the
gateway configured without dims (or a bare CI env) yields vector(1280) and
every insert dies with 'expected 1280 dimensions, not 1536'. Adding test
files to the repo reshuffles the weight-packed shards, so unrelated PRs
trip it (seen on #2629/#2630 CI, test (5)).

Same fix + rationale as cosine-rescore-column.test.ts, which documents this
exact class: configureGateway(1536) in beforeAll, resetGateway in afterAll.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FQgByq4aqQq2PP8UHCdnfk
2026-07-06 14:11:16 +02:00
Paolo BelcastroandClaude Fable 5 7639493e8c feat(autopilot): wire the nightly conversation-parser probe into the loop
The v0.41.16.0 probe module (runConversationParserNightlyProbe) shipped as
a callable phase with its scheduler wire-up deferred; the follow-up never
landed. Result: zero call sites — the enable flag did nothing, the D10
tokenmax default-on never fired, and doctor's conversation_parser_probe_health
was a hardcoded 'Skipped' stub that ignored config and mode.

This adds the deferred wiring as autopilot step 4.6, mirroring the nightly
quality probe (4.5):

- Flag reads dual-plane (DB row from gbrain config set wins; file plane
  fallback); search.mode=tokenmax keeps the probe default-on per D10.
- Fixtures resolve from the gbrain package root (the committed
  test/fixtures/conversation-formats/), NOT the brain repoPath. Compiled
  binaries carry no source tree: skip quietly with a once-per-process
  stderr note instead of writing failure rows that would flip doctor to
  WARN on every binary install.
- New parser-probe audit trail (audit-parser-probe.ts, shared audit-writer
  primitive, parser-probe-YYYY-Www.jsonl). rate_limited outcomes are NOT
  logged: the loop ticks every few minutes, so auditing each skip would
  flood the file. The 24h gate (parserProbeRanWithin) reads real outcomes.
- Doctor stub replaced with computeConversationParserProbeHealthCheck:
  enable hint when off+silent, WARN on any non-pass run in 7 days.
- try/catch posture identical to 4.5 — a probe failure never crashes the
  loop and never bumps consecutiveErrors.

Tests: audit round-trip + rate-limit gate, doctor check branches, and
source-shape pins for the wiring protections.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FQgByq4aqQq2PP8UHCdnfk
2026-07-06 14:11:16 +02:00
Paolo BelcastroandClaude Fable 5 22a9c985d2 test(find-trajectory): pin embedding dims — kill the shard-order 1280/1536 flake
engine-find-trajectory.test.ts hardcodes Float32Array(1536) vectors but let
initSchema size its vector columns from process-global gateway state
(getEmbeddingDimensions(), default 1280). Whether the file passed depended
on which test files ran before it in the shard: any predecessor leaving the
gateway configured without dims (or a bare CI env) yields vector(1280) and
every insert dies with 'expected 1280 dimensions, not 1536'. Adding test
files to the repo reshuffles the weight-packed shards, so unrelated PRs
trip it (seen on #2629/#2630 CI, test (5)).

Same fix + rationale as cosine-rescore-column.test.ts, which documents this
exact class: configureGateway(1536) in beforeAll, resetGateway in afterAll.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FQgByq4aqQq2PP8UHCdnfk
2026-07-06 14:11:14 +02:00
Paolo BelcastroandClaude Fable 5 e6d392ad08 fix(eval,probe): nightly probe reaches real verdicts — SDK model strip, live default slots, gold-answer QA judging
Three more layers of the same never-exercised enable path, each found by
running the probe end-to-end after the previous fix:

1. eval-longmemeval passed resolveModel's RECIPE ids (anthropic:claude-…)
   straight to the raw Anthropic SDK — the API 404s on the prefixed string,
   so every answer/extractor call failed and batches were all-upstream_error.
   (think avoids this by routing through the gateway; this eval's raw-SDK
   client is deliberate — hermetic — so strip via splitProviderModelId.)

2. DEFAULT_SLOTS pinned openai:gpt-4o after the OpenAI recipe dropped it, so
   judge slot A errored 'not listed for OpenAI chat' on every install and
   the panel could not reach its 2-model quorum without a Google key —
   verdicts were permanently inconclusive. Bumped to a listed model and
   added a slots↔recipes consistency test so defaults can't drift again.

3. The judge panel scored terse LongMemEval facts ('in widget-co') on a
   rich-response rubric (DEPTH/SOURCING ≥7) with no gold answer and no
   haystack — guaranteed FAIL even when healthy. eval-longmemeval now emits
   the dataset's gold answer in each row; batch mode folds it into the
   judge task when present; the probe adapter passes QA-shaped dimensions
   (CORRECTNESS + DIRECTNESS; deliberately no grounding dimension — judges
   who never see the haystack read correct extra detail as invented).

Verified end-to-end on a real brain: 9/10 fixture questions pass; the one
failure is a genuine multi-session aggregation miss (2 of 3 evidence
sessions retrieved), i.e. the probe now surfaces real signal instead of
plumbing artifacts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FQgByq4aqQq2PP8UHCdnfk
2026-07-06 14:11:14 +02:00
Paolo BelcastroandClaude Fable 5 bce5f20c25 fix(autopilot): quality probe finds its fixture + rate-limit skips stop flooding the audit
Two more blockers on the same enable path, found while verifying the
config-plane fix end-to-end:

1. Fixture root: the wiring passed resolveRepoRoot = repoPath (sync.repo_path,
   the user's BRAIN repo) but the committed fixture lives in the gbrain
   package. On every real install the probe error'd with fixture-not-found.
   The DI test harness passes process.cwd() — the gbrain repo in CI — which
   papered over it. Now resolves the package root from the module location,
   with repoPath kept as fallback.

2. rate_limited flood: the loop invokes the probe every cycle (~5-10 min);
   each within-24h skip wrote a rate_limited audit row (~hundreds/day), and
   doctor counts any non-pass outcome as bad — so enabling the probe flipped
   nightly_quality_probe_health to a permanent WARN by design. Skips are now
   non-events: no audit row; the real runs gate the 24h window.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FQgByq4aqQq2PP8UHCdnfk
2026-07-06 14:11:14 +02:00
Paolo BelcastroandClaude Fable 5 3265dc2c93 fix(autopilot,doctor): nightly quality probe honors gbrain config set (dual-plane flag read)
The doctor check prints a paste-ready enable hint — gbrain config set
autopilot.nightly_quality_probe.enabled true — which writes the DB config
plane. But the autopilot gate and the doctor check both read only the file
plane (~/.gbrain/config.json), so following the printed hint was a silent
no-op: the probe never ran and doctor kept reporting disabled.

Fix: resolveProbeEnabled / resolveProbeMaxUsd in nightly-quality-probe.ts
apply the dual-plane rule already used by mcp.publish_skills (serve-http.ts):
the DB row wins when present, the file plane is the fallback. Both call
sites (autopilot loop gate + doctor health check) now share the helpers, so
they can no longer diverge (doctor's old Boolean() read also disagreed with
autopilot's === true on string values).

Covers the max_usd cap with the same rule. Regression tests pin the helper
semantics and the new wiring shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FQgByq4aqQq2PP8UHCdnfk
2026-07-06 14:11:14 +02:00
Paolo BelcastroandClaude Fable 5 f589739469 fix(config): register Life Chronicle keys so the documented enable command works
The v0.42.56.0 release notes say `gbrain config set auto_chronicle true`,
but the key was never added to KNOWN_CONFIG_KEYS — the documented command
fails with 'Unknown config key' and the operator has to discover --force by
reading source. Registers 'auto_chronicle' plus the 'chronicle.' prefix
(chronicle.tz and future knobs). Same registration class as the v0.42.42.0
spend-controls fix. Regression test pins both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FQgByq4aqQq2PP8UHCdnfk
2026-07-06 13:37:55 +02:00
spiky02plateau cc90471a96 perf(contextual-retrieval): bound per-chunk synopsis concurrency
Replace the strictly sequential per-chunk synopsis loop with a bounded
sliding worker pool (existing runSlidingPool helper). Results land in
chunk order via index-addressed writes; code chunks still bypass the
wrapper; embedding remains one page-level batch after all synopses.

New knob GBRAIN_CONTEXTUAL_CHUNK_CONCURRENCY, default 4, clamped to
[1,16]; 1 reproduces the prior sequential behavior exactly. Each chunk
task still acquires/releases the global synopsis rate-lease, which
remains the cross-worker governor; the lease id now travels from
acquire to release instead of shared mutable state, and lease waits
are abort-responsive.

At 20-45s per synopsis call, a 120-chunk transcript page previously
needed 60-90+ min wall time and routinely outlived job timeouts.
2026-07-06 11:58:47 +02:00
irresiandClaude Opus 4.8 41d72585d0 fix(security): #2624 banner shows 'from env' before non-TTY hidden guard
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 22:12:15 -07:00
irresiandClaude Opus 4.8 a21abd9d49 fix(security): #2624 don't print admin bootstrap token on non-TTY (log-leak)
serve --http printed the generated admin token in the startup banner
unconditionally. In containerized deploys stderr ships to centralized log
storage, turning the token into a standing secret in logs.

Fail-safe default: the generated token now prints only when stderr is an
interactive TTY. Non-TTY starts hide it (--print-admin-token forces it;
$GBRAIN_ADMIN_BOOTSTRAP_TOKEN + --suppress-bootstrap-token already existed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 22:03:37 -07:00
morluto 9102ce75e1 fix(trajectory): stop negative metrics from inverting regression signals 2026-07-05 20:44:25 +00:00
spiky02plateau 89af5e3c09 fix(ai): promote DeepSeek reasoning_content when content is empty
DeepSeek-style reasoning models (deepseek-reasoner, GLM et al behind the
openai-compatible recipe) can return the substantive answer in
message.reasoning_content with content empty. The AI SDK adapter reads
only content, so gateway.chat() yielded text:'' and callers classified
the response as empty (observed: ~54% of per-chunk synopsis calls on one
such model collapsed to title-only fallback, 1,030 pages).

Add a recipe-scoped compat fetch (mirroring the voyage/zeroEntropy
inbound-rewrite precedent) that promotes reasoning_content into content
only when content is empty/whitespace; non-streaming JSON responses only.
New recipe-level compat.fetch hook preserves the base_urls override
chain.
2026-07-05 17:30:08 +02:00
spiky02plateau 63f6665b73 fix(migrations): let force-retry escape completed ledger entries
statusForVersion short-circuited on any 'complete' entry before checking
the trailing 'retry' marker, so --force-retry appended an inert row and a
version marked complete with zero work done could never be re-run without
hand-editing completed.jsonl. Check retry-latest first: an explicit
--force-retry now yields 'pending' even past an earlier 'complete', while
a stray 'partial' after 'complete' still cannot regress the version.
2026-07-05 17:27:28 +02:00
Paolo BelcastroandClaude Opus 4.8 78f3ebea33 feat(extract_atoms): honor pack manifest extractable flag in page discovery
Atom-extraction page discovery hardcoded EXTRACTABLE_PAGE_TYPES and ignored the
active pack's `extractable: true` flags, so a type declared extractable in the
manifest (e.g. `note`) never actually extracted. Closes the D2 TODO the code
already flagged (extract-atoms.ts: 'future pack-aware refactor ... pull from the
active pack manifest').

Resolve the allowlist as: legacy hardcoded floor UNION the pack's extractable
types, MINUS synthesis outputs (atom, concept — extracting from these would loop,
since concepts are synthesized from atoms). Mirrors facts/eligibility.ts, which
excludes concept the same way. Back-compat: gbrain-base brains keep every legacy
target via the union; fail-soft falls back to the legacy floor if the pack can't
load.

- pure unionExtractableTypes() policy, unit-tested
- discoverExtractablePages + countExtractAtomsBacklog resolve from the pack
- page-discovery fixture updated (note now extracts; concept stays excluded)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 17:20:15 +02:00
spiky02plateau 62bee97d20 fix(minions): default timeout for contextual reindex 2026-07-05 14:58:31 +02:00
colinagent 08bdae892e fix(ai): add chat touchpoint to litellm proxy recipe 2026-07-05 14:51:52 +08:00
SilverNineandClaude Fable 5 0621687276 fix(search): port the CJK keyword fallback to the Postgres engine (PGLite parity)
websearch_to_tsquery('english') can't tokenize CJK, so the keyword arm
of hybrid search degrades badly for Korean/Chinese/Japanese queries on
the Postgres engine — particle-suffixed Korean often matches nothing.
PGLite has had the hasCJK() -> ILIKE + occurrence-count-ranking fallback
since v0.32.7; the Postgres engine never got it.

Port _searchKeywordCJK to postgres-engine.ts at both FTS sites
(searchKeyword dedup path + searchKeywordChunks), keeping the host
engine's conventions (sql.begin + SET LOCAL statement_timeout, named
limit params, its extra-filter set including types/exclude_slugs).
scoreExpr, ILIKE ESCAPE discipline, empty-query guard, and the
dedup/chunk-grain split are copied from PGLite exactly.

Measured on a private production Korean corpus (74k chunks):
particle-suffixed queries returned 7 rows via english FTS vs 152 via
the fallback; a common two-word query 21 vs 847 — a 20-40x recall gap
this closes.

Tests: 6 source-guardrail tests in test/postgres-engine.test.ts +
3 Postgres-vs-PGLite parity e2e tests with synthetic Korean fixtures.
bun run verify 31/31.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 12:51:40 +09:00
55 changed files with 3232 additions and 216 deletions
+7 -6
View File
@@ -133,14 +133,15 @@ function indexCompleted(entries: CompletedMigrationEntry[]): CompletedIndex {
* Returns the resolved status for a migration based on its entries.
*
* Semantics (Bug 3 — keep "complete wins" safety):
* - If any entry is `complete`, the version is complete. Terminal state.
* - Otherwise, if the latest entry is `retry`, the version is pending
* (user requested a fresh attempt).
* - If the latest entry is `retry`, the version is pending. This is the
* explicit escape hatch written by `--force-retry`, and it overrides an
* earlier `complete` entry without hand-editing the ledger.
* - Otherwise, if any entry is `complete`, the version is complete.
* - Otherwise, if any entry is `partial`, the version is partial.
* - Otherwise, pending.
*
* `complete` never regresses. A later accidental `partial` append cannot
* undo a completed migration.
* `complete` never regresses accidentally. A later `partial` append cannot
* undo a completed migration; only a trailing, explicit `retry` marker can.
*/
function statusForVersion(
version: string,
@@ -148,9 +149,9 @@ function statusForVersion(
): 'complete' | 'partial' | 'pending' | 'wedged' {
const entries = idx.byVersion.get(version) ?? [];
if (entries.length === 0) return 'pending';
if (entries.some(e => e.status === 'complete')) return 'complete';
const latest = entries[entries.length - 1];
if (latest.status === 'retry') return 'pending';
if (entries.some(e => e.status === 'complete')) return 'complete';
// Bug 3 attempt cap — count consecutive partials from the end (stopping
// at any 'retry' or 'complete'). If we hit MAX_CONSECUTIVE_PARTIALS,
// the migration is wedged and needs explicit --force-retry to try again.
+82 -4
View File
@@ -476,6 +476,9 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
process.on('SIGINT', () => { void shutdown('SIGINT'); });
let consecutiveErrors = 0;
// Parser-probe fixture warning is once-per-process, not once-per-cycle
// (compiled-binary installs have no source tree; don't spam the log).
let parserProbeFixtureWarned = false;
// v0.37.7.0 #1162 — counter for consecutive reconnect failures.
// Reset on every successful health probe or reconnect. Threshold
// controlled by GBRAIN_AUTOPILOT_MAX_RECONNECT_FAILS env (default 30).
@@ -1022,17 +1025,36 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
// loop. Probe runs even when cycleOk=false (probe may surface signal
// explaining why the cycle is failing).
try {
const probeEnabled = cfg?.autopilot?.nightly_quality_probe?.enabled === true;
const { resolveProbeEnabled, resolveProbeMaxUsd, runNightlyQualityProbe } = await import('../core/cycle/nightly-quality-probe.ts');
// Dual-plane read: `gbrain config set` (what the doctor enable hint
// prints) writes the DB plane; ~/.gbrain/config.json is the fallback.
let dbEnabled: string | null = null;
let dbMaxUsd: string | null = null;
try {
dbEnabled = await engine.getConfig('autopilot.nightly_quality_probe.enabled');
dbMaxUsd = await engine.getConfig('autopilot.nightly_quality_probe.max_usd');
} catch { /* DB unavailable → file plane only */ }
const probeEnabled = resolveProbeEnabled(dbEnabled, cfg?.autopilot?.nightly_quality_probe?.enabled);
if (probeEnabled) {
const { runNightlyQualityProbe } = await import('../core/cycle/nightly-quality-probe.ts');
const { runLongMemEvalForProbe, runCrossModalBatchForProbe } = await import('../core/cycle/nightly-probe-adapters.ts');
const { isAvailable } = await import('../core/ai/gateway.ts');
const maxUsd = Number(cfg?.autopilot?.nightly_quality_probe?.max_usd ?? 5);
const { existsSync } = await import('node:fs');
const { fileURLToPath } = await import('node:url');
const { join } = await import('node:path');
const maxUsd = resolveProbeMaxUsd(dbMaxUsd, cfg?.autopilot?.nightly_quality_probe?.max_usd);
// The committed fixture (test/fixtures/longmemeval-nightly.jsonl)
// lives in the gbrain PACKAGE, not the brain repo — repoPath is
// sync.repo_path (the user's brain), where the fixture never
// exists, so the probe error'd on every real install. Resolve the
// package root from the module location; keep repoPath as the
// fallback for setups that vendor the fixture into the brain repo.
const pkgRoot = fileURLToPath(new URL('../..', import.meta.url));
const fixtureAtPkgRoot = existsSync(join(pkgRoot, 'test', 'fixtures', 'longmemeval-nightly.jsonl'));
await runNightlyQualityProbe({
isEnabled: () => true, // already gated above; phase re-checks for defense-in-depth
hasEmbeddingProvider: () => isAvailable('embedding'),
resolveMaxUsd: () => maxUsd,
resolveRepoRoot: () => repoPath ?? gbrainHomePath('.'),
resolveRepoRoot: () => (fixtureAtPkgRoot ? pkgRoot : repoPath ?? gbrainHomePath('.')),
runLongMemEval: runLongMemEvalForProbe,
runCrossModalBatch: runCrossModalBatchForProbe,
now: () => new Date(),
@@ -1044,6 +1066,62 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
// informational; autopilot loop continues.
}
// 4.6 — Nightly conversation-parser probe (v0.41.16.0 phase module;
// the scheduler wire-up was deferred at ship and is added here). Same
// posture as 4.5: the phase owns its gates (enabled/mode-gate, LLM
// key), the wiring owns invocation + the audit row, and a probe
// failure NEVER crashes the autopilot loop. Per D10 the probe is
// default-ON for search.mode=tokenmax, opt-in otherwise.
try {
const { runConversationParserNightlyProbe } = await import('../core/conversation-parser/nightly-probe.ts');
const { logParserProbeEvent, parserProbeRanWithin } = await import('../core/audit-parser-probe.ts');
const { isAvailable } = await import('../core/ai/gateway.ts');
const { existsSync } = await import('node:fs');
const { fileURLToPath } = await import('node:url');
const { join } = await import('node:path');
// Flag reads dual-plane: the DB row (`gbrain config set …`) wins,
// ~/.gbrain/config.json is the fallback. search.mode lives on the
// DB plane only (mode.ts owns it).
let parserDbEnabled: string | null = null;
let dbSearchMode: string | null = null;
try {
parserDbEnabled = await engine.getConfig('autopilot.conversation_parser_probe.enabled');
dbSearchMode = await engine.getConfig('search.mode');
} catch { /* DB unavailable → file plane only */ }
const parserEnabled = parserDbEnabled != null
? parserDbEnabled === 'true'
: cfg?.autopilot?.conversation_parser_probe?.enabled === true;
const searchMode = dbSearchMode ?? '';
// Fixtures are committed in the gbrain package (test/fixtures/…),
// NOT the brain repo — resolve from the module location. Compiled
// binaries carry no source tree: skip quietly instead of writing
// failure rows that would flip doctor to WARN on every binary install.
const pkgRoot = fileURLToPath(new URL('../..', import.meta.url));
const fixturePath = join(pkgRoot, 'test', 'fixtures', 'conversation-formats', 'all.jsonl');
const adversarialPath = join(pkgRoot, 'test', 'fixtures', 'conversation-formats', 'adversarial.jsonl');
const shouldInvoke = parserEnabled || searchMode === 'tokenmax';
if (shouldInvoke && existsSync(fixturePath) && existsSync(adversarialPath)) {
const result = await runConversationParserNightlyProbe({
isEnabled: () => parserEnabled,
searchMode: () => searchMode,
hasLlmKey: () => isAvailable('chat'),
resolveFixturePath: () => fixturePath,
resolveAdversarialPath: () => adversarialPath,
now: () => new Date(),
shouldSkipForRateLimit: () => parserProbeRanWithin(24 * 60 * 60 * 1000),
});
// rate_limited is a non-run: the loop ticks every few minutes, so
// logging every skip would flood the audit file with no-signal rows.
if (result.outcome !== 'rate_limited') logParserProbeEvent(result);
} else if (shouldInvoke && !parserProbeFixtureWarned) {
parserProbeFixtureWarned = true;
console.error(`[parser-probe] fixtures not found under ${pkgRoot}; skipping (probe needs a source-checkout install)`);
}
} catch (e) {
logError('autopilot.parser_probe', e);
// Informational, like 4.5: do NOT bump consecutiveErrors.
}
// Wait for next cycle
await new Promise(r => setTimeout(r, interval * 1000));
}
+79 -14
View File
@@ -2896,6 +2896,54 @@ function _resolveSyncFreshnessHours(varName: string, fallback: number): number {
* branch (disabled / enabled-no-events / enabled-all-pass / enabled-with-failures)
* without spinning up the audit JSONL or a real config file.
*/
/**
* Pure function form of the conversation_parser_probe_health check.
* Mirrors computeNightlyQualityProbeHealthCheck: skip-with-hint when the
* probe is off and silent, surface the last 7 days of audit events when
* it has run, WARN on any non-pass outcome.
*
* `effectiveEnabled` folds the D10 mode-gate in: explicitly enabled OR
* search.mode=tokenmax (where the probe is default-on).
*/
export function computeConversationParserProbeHealthCheck(
effectiveEnabled: boolean,
events: ReadonlyArray<{ outcome: string; ts: string; reason?: string }>,
): Check {
const name = 'conversation_parser_probe_health';
if (!effectiveEnabled && events.length === 0) {
return {
name,
status: 'ok',
message:
'disabled (opt-in; default-on only for search.mode=tokenmax). Enable with: ' +
'`gbrain config set autopilot.conversation_parser_probe.enabled true`',
};
}
if (events.length === 0) {
return {
name,
status: 'ok',
message: 'enabled but no probe events in the last 7 days (next run by autopilot; fixtures require a source-checkout install).',
};
}
const bad = events.filter(e => e.outcome !== 'pass');
const latest = events[events.length - 1]!;
if (bad.length > 0) {
return {
name,
status: 'warn',
message:
`${bad.length}/${events.length} probe run(s) in the last 7 days did not pass; ` +
`latest: ${latest.outcome}${latest.reason ? ` (${latest.reason})` : ''}`,
};
}
return {
name,
status: 'ok',
message: `${events.length} probe run(s) in the last 7 days, all pass (latest ${latest.ts}).`,
};
}
export function computeNightlyQualityProbeHealthCheck(
probeEnabled: boolean,
events: ReadonlyArray<{ outcome: string; ts: string; detail?: string }>,
@@ -4779,10 +4827,17 @@ export async function buildChecks(
try {
const { readRecentQualityProbeEvents } = await import('../core/audit-quality-probe.ts');
const { loadConfig } = await import('../core/config.ts');
const { resolveProbeEnabled } = await import('../core/cycle/nightly-quality-probe.ts');
let probeEnabled = false;
try {
// Dual-plane read, matching the autopilot gate: the DB row (what the
// enable hint's `gbrain config set` writes) wins; file plane fallback.
let dbVal: string | null = null;
try {
dbVal = engine ? await engine.getConfig('autopilot.nightly_quality_probe.enabled') : null;
} catch { /* DB unavailable → file plane only */ }
const cfg = loadConfig();
probeEnabled = Boolean((cfg as any)?.autopilot?.nightly_quality_probe?.enabled);
probeEnabled = resolveProbeEnabled(dbVal, (cfg as any)?.autopilot?.nightly_quality_probe?.enabled);
} catch { /* config unavailable → treat as disabled */ }
const events = readRecentQualityProbeEvents(7);
const check = computeNightlyQualityProbeHealthCheck(probeEnabled, events);
@@ -4965,19 +5020,29 @@ export async function buildChecks(
// 3d.5 v0.41.13.0 — conversation_parser_probe_health. Mode-gated
// per D10: ON when search.mode=tokenmax, opt-in for other modes.
// Surface the last 7 days of nightly-probe events; warn on FAIL /
// BUDGET_EXCEEDED / adversarial_false_positive.
//
// v0.41.13.0 ships the probe as opt-in (autopilot wiring deferred
// to T7 in the cathedral plan); this check skips with an enable
// hint until the probe has at least one audit event written.
checks.push({
name: 'conversation_parser_probe_health',
status: 'ok',
message:
'Skipped (nightly probe is opt-in; enable with ' +
'`gbrain config set autopilot.conversation_parser_probe.enabled true`)',
});
// Surfaces the last 7 days of nightly-probe audit events; warn on any
// non-pass outcome (fail / budget_exceeded / adversarial_false_positive).
// (Until the autopilot wire-up this was a hardcoded "Skipped" stub.)
try {
const { readRecentParserProbeEvents } = await import('../core/audit-parser-probe.ts');
let parserProbeEnabled = false;
try {
let dbVal: string | null = null;
let dbMode: string | null = null;
try {
dbVal = engine ? await engine.getConfig('autopilot.conversation_parser_probe.enabled') : null;
dbMode = engine ? await engine.getConfig('search.mode') : null;
} catch { /* DB unavailable → file plane only */ }
const { loadConfig } = await import('../core/config.ts');
const fileVal = (loadConfig() as any)?.autopilot?.conversation_parser_probe?.enabled;
const flagOn = dbVal != null ? dbVal === 'true' : fileVal === true;
parserProbeEnabled = flagOn || dbMode === 'tokenmax';
} catch { /* config unavailable → treat as disabled */ }
const parserEvents = readRecentParserProbeEvents(7);
checks.push(computeConversationParserProbeHealthCheck(parserProbeEnabled, parserEvents));
} catch {
// Best-effort; audit-log read failure shouldn't stop doctor.
}
// 3e. home_dir_in_worktree (v0.35.8.0). Walks up from `gbrainPath()`
// looking for a `.git` directory OR file. If found, warns: `~/.gbrain/`
+14 -1
View File
@@ -466,6 +466,14 @@ interface BatchRow {
question_id: string;
question: string;
hypothesis: string;
/**
* Gold answer from the benchmark dataset, when the upstream eval emits
* it (eval-longmemeval does). Folded into the judge task so CORRECTNESS
* is verifiable — without it a judge panel that sees only
* {question, hypothesis} cannot validate a terse factual answer against
* a haystack it never saw.
*/
answer?: string;
}
/**
@@ -579,6 +587,7 @@ function readBatchRows(path: string): BatchReadResult {
question_id: typeof obj.question_id === 'string' ? obj.question_id : `line-${lineNo}`,
question: obj.question,
hypothesis: obj.hypothesis,
...(typeof obj.answer === 'string' && obj.answer.length > 0 ? { answer: obj.answer } : {}),
});
}
if (summarySkipped > 0) {
@@ -695,7 +704,11 @@ async function runBatchMode(parsed: ParsedArgs, opts: RunCrossModalOpts): Promis
fn: async (row, idx) => {
process.stderr.write(`[eval cross-modal batch] ${idx + 1}/${rows.length} ${row.question_id} starting...\n`);
return await runEvalFn({
task: row.question,
// With a gold answer the judges can actually verify correctness;
// without one they see only {question, hypothesis} and cannot.
task: row.answer
? `${row.question}\n\nExpected answer (gold label from the benchmark dataset): ${row.answer}`
: row.question,
output: row.hypothesis,
slug: row.question_id,
dimensions,
+17 -3
View File
@@ -33,6 +33,7 @@ import {
type AliasMap,
} from '../eval/longmemeval/extract.ts';
import { extractCandidateEntities } from '../core/think/entity-extract.ts';
import { splitProviderModelId } from '../core/model-id.ts';
import { resolveEntitySlugWithSource, type ResolutionSource } from '../core/entities/resolve.ts';
import { formatTrajectoryBlock } from '../core/trajectory-format.ts';
@@ -469,14 +470,22 @@ export async function runEvalLongMemEval(args: string[], runOpts: RunOpts = {}):
});
// Wrap Anthropic SDK so its `.messages.create` shape matches ThinkLLMClient.
// Same pattern as src/core/think/index.ts:247-249.
// Same pattern as src/core/think/index.ts:247-249 — EXCEPT think's default
// client routes through the gateway, which parses `provider:model` recipe
// ids. This eval's client is a raw SDK by design (hermetic, no gateway
// dependency), and resolveModel returns RECIPE ids (`anthropic:claude-…`);
// passing one through unstripped 404s every answer/extractor call, which
// surfaces downstream as all-upstream_error batches in the nightly probe.
const toSdkModel = (m: string): string => splitProviderModelId(m).model || m;
const realClient = new Anthropic();
const client: ThinkLLMClient = runOpts.client ?? {
create: (params, callOpts) => realClient.messages.create(params, callOpts),
create: (params, callOpts) =>
realClient.messages.create({ ...params, model: toSdkModel(params.model) }, callOpts),
};
// v0.40.2.0 — separate extractor client (defaults to same SDK).
const extractorClient: ThinkLLMClient = runOpts.extractorClient ?? {
create: (params, callOpts) => realClient.messages.create(params, callOpts),
create: (params, callOpts) =>
realClient.messages.create({ ...params, model: toSdkModel(params.model) }, callOpts),
};
const trajectoryEnabled = !opts.noTrajectory;
const extractorModel = trajectoryEnabled
@@ -751,6 +760,11 @@ async function runOneQuestion(
// v0.40.1.0 (Track D / T2) — copy question_type into the row so the
// by_type_summary can be rebuilt from the file on resume runs.
question_type: q.question_type,
// Gold answer for downstream consumers that verify correctness (the
// cross-modal --batch judge folds it into the task; evaluate_qa.py
// ignores unknown fields). Without it a judge can't validate a terse
// factual hypothesis against a haystack it never saw.
...(q.answer !== undefined ? { answer: q.answer } : {}),
hypothesis,
retrieved_session_ids: retrievedSessionIds,
...(recallHit !== undefined ? { recall_hit: recallHit } : {}),
+38 -5
View File
@@ -90,6 +90,26 @@ export function resolveBootstrapToken(
return { kind: 'ok', token: trimmed, fromEnv: true };
}
/**
* #2624: decide whether the generated admin bootstrap token is hidden from
* the startup banner. Fail-safe default: a generated token is NOT printed
* unless stderr is an interactive TTY, so containerized (non-TTY) deploys
* never ship the secret to centralized log storage. Env-sourced tokens are
* always hidden (operator already holds them). Explicit --suppress hides
* everything; --print-admin-token forces the raw value even on a non-TTY.
*/
export function shouldSuppressBootstrapPrint(opts: {
suppress: boolean;
fromEnv: boolean;
forcePrint: boolean;
isTty: boolean;
}): boolean {
if (opts.suppress) return true;
if (opts.fromEnv) return true;
if (opts.forcePrint) return false;
return !opts.isTty;
}
export type ProbeHealthResult =
| { ok: true; status: 200; body: { status: 'ok'; version: string; engine: string; [k: string]: unknown } }
| { ok: false; status: 503; body: { error: 'service_unavailable'; error_description: string } };
@@ -304,6 +324,14 @@ interface ServeHttpOptions {
* tracking the regenerated value through other means.
*/
suppressBootstrapToken?: boolean;
/**
* #2624: force-print the generated admin bootstrap token even on a
* non-TTY (containerized) start. By default the raw token is only printed
* when stderr is an interactive TTY, so it never lands in centralized log
* storage for headless deploys. Set this when you genuinely need the value
* captured to a non-interactive log and accept the leak.
*/
printAdminToken?: boolean;
}
/**
@@ -530,7 +558,12 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
let bootstrapToken: string = resolved.token;
let bootstrapFromEnv: boolean = resolved.fromEnv;
const bootstrapHash = createHash('sha256').update(bootstrapToken).digest('hex');
const suppressBootstrapPrint = options.suppressBootstrapToken === true;
const suppressBootstrapPrint = shouldSuppressBootstrapPrint({
suppress: options.suppressBootstrapToken === true,
fromEnv: bootstrapFromEnv,
forcePrint: options.printAdminToken === true,
isTty: process.stderr.isTTY === true,
});
const adminSessions = new Map<string, number>(); // sessionId → expiresAt
// SSE clients for live activity feed
@@ -2166,10 +2199,10 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
║ MCP: http://localhost:${port}/mcp${' '.repeat(Math.max(0, 21 - String(port).length))}
║ Health: http://localhost:${port}/health${' '.repeat(Math.max(0, 18 - String(port).length))}
╠══════════════════════════════════════════════════════╣
${suppressBootstrapPrint
? '║ Admin Token: suppressed (--suppress-bootstrap-token) ║\n╚══════════════════════════════════════════════════════╝'
: bootstrapFromEnv
? '║ Admin Token: from $GBRAIN_ADMIN_BOOTSTRAP_TOKEN ║\n╚══════════════════════════════════════════════════════╝'
${bootstrapFromEnv
? '║ Admin Token: from $GBRAIN_ADMIN_BOOTSTRAP_TOKEN ║\n╚══════════════════════════════════════════════════════╝'
: suppressBootstrapPrint
? '║ Admin Token: hidden (non-TTY log-leak guard) ║\n║ set $GBRAIN_ADMIN_BOOTSTRAP_TOKEN, or pass ║\n║ --print-admin-token on a trusted terminal. ║\n╚══════════════════════════════════════════════════════╝'
: `║ Admin Token (paste into /admin login): ║\n║ ${bootstrapToken.substring(0, 50)}\n║ ${bootstrapToken.substring(50).padEnd(50)}\n╚══════════════════════════════════════════════════════╝`}
`);
});
+6 -1
View File
@@ -118,8 +118,13 @@ export async function runServe(
// restart.
const suppressBootstrapToken = args.includes('--suppress-bootstrap-token');
// #2624: by default the generated token only prints on an interactive
// TTY (never into container log storage). --print-admin-token forces the
// raw value even on a non-TTY start.
const printAdminToken = args.includes('--print-admin-token');
const { runServeHttp } = await import('./serve-http.ts');
await runServeHttp(engine, { port, tokenTtl, enableDcr, enableDcrInsecure, publicUrl, logFullParams, bind, suppressBootstrapToken });
await runServeHttp(engine, { port, tokenTtl, enableDcr, enableDcrInsecure, publicUrl, logFullParams, bind, suppressBootstrapToken, printAdminToken });
return;
}
+4
View File
@@ -34,6 +34,10 @@ export function buildGatewayConfig(c: GBrainConfig): AIGatewayConfig {
// plane field now exists (GBrainConfig type) and gets mapped here, so
// setting it via `~/.gbrain/config.json` propagates into the gateway.
if (c.zeroentropy_api_key) envFromConfig.ZEROENTROPY_API_KEY = c.zeroentropy_api_key;
// Same seam for OpenRouter: `gbrain config set openrouter_api_key X` (or
// config.json) must reach the openrouter recipe's OPENROUTER_API_KEY.
// process.env still wins via the later spread.
if (c.openrouter_api_key) envFromConfig.OPENROUTER_API_KEY = c.openrouter_api_key;
// v0.32 codex finding #4+#5 fix: thread local-server _BASE_URL env vars
// into base_urls so the gateway hits the user's configured port. Without
+140 -15
View File
@@ -377,7 +377,8 @@ export function applyOpenAICompatConfig(
cfg: AIGatewayConfig,
): { baseURL: string; fetch?: typeof fetch } {
if (recipe.resolveOpenAICompatConfig) {
return recipe.resolveOpenAICompatConfig(cfg.env);
const resolved = recipe.resolveOpenAICompatConfig(cfg.env);
return { ...resolved, fetch: resolved.fetch ?? recipe.compat?.fetch };
}
const baseURL = cfg.base_urls?.[recipe.id] ?? recipe.base_url_default;
if (!baseURL) {
@@ -386,7 +387,7 @@ export function applyOpenAICompatConfig(
recipe.setup_hint,
);
}
return { baseURL };
return { baseURL, fetch: recipe.compat?.fetch };
}
/**
@@ -2383,6 +2384,58 @@ export interface ChatToolDef {
* production subagent jobs) throws "messages do not match the ModelMessage[]
* schema" the moment the model calls a tool. Surfaced by the SkillOpt eval.
*/
/**
* Default per-call max output tokens. Thinking-by-default Claude 5 models
* (`anthropic:claude-*-5`) burn a large chunk of the budget on internal
* reasoning before emitting any text, so a 4096 default leaves them with empty
* final text on the subagent tool loop. Give those models headroom; providers
* bill actual tokens, not the cap, so it is free for the models that don't use
* it. Everything else keeps 4096 on purpose: raising the default blanket-wide
* would exceed some openai-compat providers' hard max-output caps (DeepSeek
* 8192, gpt-4o 16384) and 400 on them a regression for exactly the
* non-Anthropic subagent users the gateway loop exists to serve.
*/
const DEFAULT_MAX_OUTPUT_TOKENS = 4096;
const THINKING_MODEL_MAX_OUTPUT_TOKENS = 32000;
const THINKING_BY_DEFAULT_MODEL_RE = /^anthropic[:/]claude-[a-z0-9]+-5(?:[.-]|$)/i;
function defaultMaxOutputTokens(modelStr: string | undefined): number {
return modelStr && THINKING_BY_DEFAULT_MODEL_RE.test(modelStr)
? THINKING_MODEL_MAX_OUTPUT_TOKENS
: DEFAULT_MAX_OUTPUT_TOKENS;
}
/**
* Deep-serialize a tool output into a plain JSON value for the AI SDK v6
* ModelMessage schema. node-postgres returns `timestamptz` columns as JS
* `Date` instances, and AI SDK v6's `JSONValue` schema rejects a raw Date,
* throwing "Invalid prompt ... ModelMessage[] schema" the moment a
* timestamp-bearing tool result (e.g. `brain_get_page`, `brain_list_pages`)
* is fed back dead-lettering the whole multi-tool loop. The JSON round-trip
* runs `Date.prototype.toJSON` (ISO string) recursively and drops `undefined`.
* This is a serialization fix at the SDK boundary, NOT a `::jsonb` DB cast
* it never touches Postgres. (BigInt / circular outputs still throw in
* JSON.stringify; those aren't LLM-serializable and are out of scope.)
*/
function toJsonSafe(value: unknown): unknown {
try {
return JSON.parse(JSON.stringify(value ?? null));
} catch {
// BigInt / circular output isn't LLM-serializable; degrade to a string
// rather than throwing and dead-lettering the whole tool loop.
return safeStringify(value);
}
}
/** Stringify that never throws (bigint/circular fall back to String()). */
function safeStringify(value: unknown): string {
if (typeof value === 'string') return value;
try {
return JSON.stringify(value ?? null);
} catch {
return String(value);
}
}
export function toModelMessages(messages: ChatMessage[]): unknown[] {
return messages.map((m) => {
if (typeof m.content === 'string') return { role: m.role, content: m.content };
@@ -2398,24 +2451,86 @@ export function toModelMessages(messages: ChatMessage[]): unknown[] {
toolCallId: b.toolCallId,
toolName: b.toolName,
output: b.isError
? { type: 'error-text' as const, value: typeof b.output === 'string' ? b.output : JSON.stringify(b.output) }
? { type: 'error-text' as const, value: safeStringify(b.output) }
: (typeof b.output === 'string'
? { type: 'text' as const, value: b.output }
: { type: 'json' as const, value: (b.output ?? null) as never }),
: { type: 'json' as const, value: toJsonSafe(b.output) as never }),
})),
};
}
return {
role: m.role,
content: blocks.map((b) => {
if (b.type === 'text') return { type: 'text' as const, text: b.text };
if (b.type === 'tool-call') return { type: 'tool-call' as const, toolCallId: b.toolCallId, toolName: b.toolName, input: b.input };
return b;
}),
// Drop text blocks whose `text` isn't a string: reasoning models
// (DeepSeek v4, etc.) surface `text: null/undefined` thinking parts that
// AI SDK v6's Zod schema rejects, poisoning the whole call. `''` is valid
// and kept.
content: blocks
.filter((b) => b.type !== 'text' || typeof b.text === 'string')
.map((b) => {
if (b.type === 'text') return { type: 'text' as const, text: b.text };
if (b.type === 'tool-call') return { type: 'tool-call' as const, toolCallId: b.toolCallId, toolName: b.toolName, input: b.input };
return b;
}),
};
});
}
/**
* Last-resort normalization at the `chat()` boundary: back-fill error stubs for
* any assistant tool-call that isn't answered by the immediately-following
* tool-result turn. The subagent handler already balances its own transcript
* (see reconcileGatewayReplay), so this is a no-op there it exists for the
* paths reconcile can't reach: a partially-answered turn, a provider that
* duplicates or drops tool-call IDs (local vLLM), or a `finishReason:'length'`
* truncation mid-batch. Without it those histories throw
* AI_MissingToolResultsError inside `generateText`. No-op on balanced input.
*
* @internal exported for tests.
*/
export function repairToolPairing(messages: ChatMessage[]): ChatMessage[] {
const out: ChatMessage[] = [];
for (let i = 0; i < messages.length; i++) {
const m = messages[i];
out.push(m);
if (typeof m.content === 'string' || m.role !== 'assistant') continue;
const calls = m.content.filter(
(b): b is Extract<ChatBlock, { type: 'tool-call' }> => b.type === 'tool-call',
);
if (calls.length === 0) continue;
// v6 only accepts results in the immediately-following message.
const next = messages[i + 1];
const nextBlocks = next && typeof next.content !== 'string' ? next.content : [];
const resolved = new Set(
nextBlocks
.filter((b): b is Extract<ChatBlock, { type: 'tool-result' }> => b.type === 'tool-result')
.map((b) => b.toolCallId),
);
const missing = calls.filter((c) => !resolved.has(c.toolCallId));
if (missing.length === 0) continue;
const stubs: ChatBlock[] = missing.map((c) => ({
type: 'tool-result',
toolCallId: c.toolCallId,
toolName: c.toolName,
output: 'tool result unavailable (recovered after interrupted run)',
isError: true,
}));
if (resolved.size > 0) {
// A tool-result message follows but is incomplete — merge the stubs in.
out.push({ role: next!.role, content: [...(nextBlocks as ChatBlock[]), ...stubs] });
i++; // the merged message replaces the original; don't emit it twice.
} else {
// No following tool-result message at all — synthesize one.
out.push({ role: 'user', content: stubs });
}
}
return out;
}
export interface ChatResult {
/** Final text content concatenated from text blocks. */
text: string;
@@ -2697,7 +2812,7 @@ export async function chat(opts: ChatOpts): Promise<ChatResult> {
}
}
const estimatedInputTokens = estimateChatInputTokens(opts);
const maxOutputTokens = opts.maxTokens ?? 4096;
const maxOutputTokens = opts.maxTokens ?? defaultMaxOutputTokens(modelStrEarly);
// TX5: reserve BEFORE the provider call. Throws BudgetExhausted on cost,
// runtime, or no_pricing (when cap is set). Pre-resolution model id is
@@ -2804,9 +2919,9 @@ export async function chat(opts: ChatOpts): Promise<ChatResult> {
const result = await generateText({
model,
system: opts.system,
messages: toModelMessages(opts.messages) as any,
messages: toModelMessages(repairToolPairing(opts.messages)) as any,
tools: opts.tools && opts.tools.length > 0 ? tools : undefined,
maxOutputTokens: opts.maxTokens ?? 4096,
maxOutputTokens: opts.maxTokens ?? defaultMaxOutputTokens(modelStr),
// v0.42.20.0 — default a chat timeout (composes with the caller's signal,
// shorter wins). Covers native-anthropic (the default provider + facts Haiku).
abortSignal: withDefaultTimeout(opts.abortSignal, AI_CHAT_TIMEOUT_MS),
@@ -2957,6 +3072,14 @@ export interface ToolLoopOpts {
) => Promise<{ gbrainToolUseId: string }>;
onToolCallComplete?: (gbrainToolUseId: string, output: unknown) => Promise<void>;
onToolCallFailed?: (gbrainToolUseId: string, error: string) => Promise<void>;
/**
* Persist the tool-result user turn that closes each tool round, BEFORE it is
* appended to the in-memory history. Without this the loop only kept the
* tool-result turn in memory, so a resumed job reloaded assistant tool-calls
* with no matching results and non-Anthropic providers rejected the
* unbalanced history (AI_MissingToolResultsError). Fires per completed round.
*/
onToolResultTurn?: (turnIdx: number, messageIdx: number, blocks: ChatBlock[]) => Promise<void>;
/** Optional per-call heartbeat for observability. */
onHeartbeat?: (event: string, data: Record<string, unknown>) => void;
@@ -2991,7 +3114,7 @@ export interface ToolLoopResult {
*/
export async function toolLoop(opts: ToolLoopOpts): Promise<ToolLoopResult> {
const maxTurns = opts.maxTurns ?? 20;
const maxTokens = opts.maxTokens ?? 4096;
const maxTokens = opts.maxTokens ?? defaultMaxOutputTokens(opts.model ?? getChatModel());
const handlers = opts.toolHandlers;
const totalUsage: ChatResult['usage'] = {
input_tokens: 0,
@@ -3180,9 +3303,11 @@ export async function toolLoop(opts: ToolLoopOpts): Promise<ToolLoopResult> {
if (stopReason === 'aborted') break;
// Feed all tool results back as a single user message.
// Persist + feed all tool results back as a single user message. The
// persist-before-push mirrors onAssistantTurn's write-ordering: a crash
// after this leaves a balanced transcript for the next resume.
const userMessageIdx = messageIdx++;
void userMessageIdx;
await opts.onToolResultTurn?.(turnIdx, userMessageIdx, toolResultBlocks);
messages.push({ role: 'user', content: toolResultBlocks });
turnIdx++;
+62
View File
@@ -1,5 +1,64 @@
import type { Recipe } from '../types.ts';
/**
* `deepseek-reasoner` returns its answer in a separate `reasoning_content`
* field and leaves `content` empty/whitespace when the whole response was
* reasoning. The AI SDK's openai-compatible adapter reads only `content`, so
* the model appears to answer with nothing. This transport shim promotes
* `reasoning_content` into `content` when `content` is empty, before the
* adapter parses the body. Fail-open: any error returns the original response.
* Non-streaming JSON chat completions only.
*
* @internal exported for tests.
*/
// Cast through `unknown` because TS's `typeof fetch` includes a `preconnect`
// member the arrow function does not implement (matches azure-openai.ts).
export const deepseekReasoningContentCompatFetch = (async (
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> => {
const res = await fetch(input as any, init as any);
try {
if (!res.ok) return res;
const ctype = res.headers.get('content-type') ?? '';
if (!ctype.includes('application/json')) return res;
const json = await res.clone().json();
const choices = Array.isArray(json?.choices) ? json.choices : [];
let modified = false;
for (const choice of choices) {
const msg = choice?.message;
if (!msg) continue;
// A tool-call turn legitimately carries content:null — the answer is the
// tool call, not text. NEVER promote reasoning_content there: DeepSeek's
// chain-of-thought must not be fed back to the model (it would be
// persisted as assistant text and replayed every subsequent turn,
// contaminating context and inflating tokens). Only promote on a terminal
// text turn whose content is empty.
const hasToolCalls = Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0;
const content = msg.content;
const reasoning = msg.reasoning_content;
const contentEmpty = content == null || (typeof content === 'string' && content.trim() === '');
if (!hasToolCalls && contentEmpty && typeof reasoning === 'string' && reasoning.trim() !== '') {
msg.content = reasoning;
modified = true;
}
}
if (!modified) return res;
// Rebuild with a fresh header set: the body length changed, so the
// upstream content-length / content-encoding would now be wrong.
const headers = new Headers(res.headers);
headers.delete('content-length');
headers.delete('content-encoding');
return new Response(JSON.stringify(json), {
status: res.status,
statusText: res.statusText,
headers,
});
} catch {
return res;
}
}) as unknown as typeof fetch;
/**
* DeepSeek exposes an OpenAI-compatible /v1/chat/completions endpoint.
* Useful as the second hop in a refusal-fallback chain and for cheap-
@@ -16,6 +75,9 @@ export const deepseek: Recipe = {
required: ['DEEPSEEK_API_KEY'],
setup_url: 'https://platform.deepseek.com/api_keys',
},
compat: {
fetch: deepseekReasoningContentCompatFetch,
},
touchpoints: {
chat: {
models: ['deepseek-chat', 'deepseek-reasoner'],
+10 -1
View File
@@ -20,6 +20,15 @@ export const litellmProxy: Recipe = {
setup_url: 'https://docs.litellm.ai/docs/proxy/quick_start',
},
touchpoints: {
chat: {
// Models depend on the proxy's config; the openai-compat tier accepts
// user-provided IDs and lets the proxied backend validate them.
models: [],
supports_tools: true,
supports_subagent_loop: false,
supports_prompt_cache: false,
price_last_verified: '2026-04-20',
},
embedding: {
// Models depend on the proxy's config; declare empties so wizard prompts user.
models: [],
@@ -42,5 +51,5 @@ export const litellmProxy: Recipe = {
supports_multimodal: true,
},
},
setup_hint: 'Run LiteLLM (https://docs.litellm.ai) in front of any provider; set LITELLM_BASE_URL (include the /v1 suffix if your proxy serves the OpenAI route there, e.g. http://localhost:4000/v1) + pass --embedding-model litellm:<model> and --embedding-dimensions <N>.',
setup_hint: 'Run LiteLLM (https://docs.litellm.ai) in front of any provider; set LITELLM_BASE_URL (include the /v1 suffix if your proxy serves the OpenAI route there, e.g. http://localhost:4000/v1), optionally set LITELLM_API_KEY, and use litellm:<model> for chat. For embeddings, also pass --embedding-model litellm:<model> and --embedding-dimensions <N>.',
};
+12
View File
@@ -326,6 +326,18 @@ export interface Recipe {
baseURL: string;
fetch?: typeof fetch;
};
/**
* Optional inbound-response rewriter for openai-compatible recipes whose wire
* shape needs normalizing before the AI SDK adapter parses it. `fetch` wraps
* the transport and MUST be fail-open (return the original response on any
* error). Used by DeepSeek to promote `reasoning_content` into `content` when
* the reasoner returns an empty `content` (the adapter reads only `content`).
* Applied by `applyOpenAICompatConfig`; a `resolveOpenAICompatConfig`-provided
* fetch takes precedence when both are present.
*/
compat?: {
fetch?: typeof fetch;
};
/**
* v0.32 (D13=A): optional runtime readiness check for local-server
* recipes (ollama, llama-server, future lmstudio-recipe). Returns
+63
View File
@@ -0,0 +1,63 @@
/**
* Nightly conversation-parser probe audit trail.
*
* One event per REAL probe run lands in
* `~/.gbrain/audit/parser-probe-YYYY-Www.jsonl` (ISO-week rotation via the
* shared audit-writer primitive; honors `GBRAIN_AUDIT_DIR`).
* Scheduler-cadence skips (`rate_limited`) are NOT logged the autopilot
* loop ticks every few minutes, so logging every skip would flood the
* audit file with rows that carry no signal.
*
* Read by `gbrain doctor`'s `conversation_parser_probe_health` check and
* by the autopilot wiring's 24h rate-limit gate (`parserProbeRanWithin`).
*/
import { createAuditWriter } from './audit/audit-writer.ts';
import type { NightlyProbeResult } from './conversation-parser/nightly-probe.ts';
export type ParserProbeAuditEvent = NightlyProbeResult;
const writer = createAuditWriter<ParserProbeAuditEvent>({
featureName: 'parser-probe',
errorLabel: 'gbrain',
errorMessagePrefix: 'parser-probe audit ',
errorTrailer: '; probe continues',
});
/** Append one parser-probe event. Best-effort; never throws. */
export function logParserProbeEvent(event: ParserProbeAuditEvent): void {
writer.log(event);
}
/**
* Read recent parser-probe events (current + previous ISO week, filtered
* to the window). Missing files and corrupt rows are skipped silently.
*/
export function readRecentParserProbeEvents(
days = 7,
now: Date = new Date(),
): ParserProbeAuditEvent[] {
return writer.readRecent(days, now);
}
/** Exposed for tests pinning the rotation edge cases. */
export function computeParserProbeAuditFilename(now: Date = new Date()): string {
return writer.computeFilename(now);
}
/**
* 24h rate-limit gate for the autopilot wiring: true when any audited run
* happened within `windowMs` of `now`. Only REAL outcomes are audited (see
* module header), so a pass/fail today blocks re-runs until tomorrow while
* scheduler-cadence skips never extend the window.
*/
export function parserProbeRanWithin(
windowMs: number,
now: Date = new Date(),
): boolean {
const cutoff = now.getTime() - windowMs;
return readRecentParserProbeEvents(2, now).some((ev) => {
const ts = Date.parse(ev.ts);
return Number.isFinite(ts) && ts >= cutoff;
});
}
+36
View File
@@ -41,6 +41,13 @@ export interface GBrainConfig {
* merge buildGatewayConfig env dict recipe reads ZEROENTROPY_API_KEY.
*/
zeroentropy_api_key?: string;
/**
* OpenRouter API key. File-plane slot so `gbrain config set
* openrouter_api_key X` (or config.json) reaches the openrouter recipe:
* file plane loadConfig env merge buildGatewayConfig env dict recipe
* reads OPENROUTER_API_KEY.
*/
openrouter_api_key?: string;
/** AI gateway config (v0.14+). v0.36+ default: "zeroentropyai:zembed-1" / 1280 / "anthropic:claude-haiku-4-5-20251001". */
embedding_model?: string;
embedding_dimensions?: number;
@@ -96,6 +103,16 @@ export interface GBrainConfig {
*/
max_usd?: number;
};
/**
* v0.41.16.0 nightly conversation-parser probe. Per D10: default ON
* for `search.mode=tokenmax` brains, opt-in for conservative/balanced.
* ~$0.05/night with the committed fixtures × Haiku polish. Gated
* INSIDE the autopilot tick body, like nightly_quality_probe.
*/
conversation_parser_probe?: {
/** Enable for non-tokenmax modes. Defaults to false. */
enabled?: boolean;
};
/**
* v0.42.x (#1685 GAP D) extract_atoms backlog auto-drain. Default ON so a
* pack-gated silent backlog never piles up unseen; daily-spend-capped so the
@@ -526,6 +543,7 @@ export function loadConfig(): GBrainConfig | null {
...(process.env.OPENAI_API_KEY ? { openai_api_key: process.env.OPENAI_API_KEY } : {}),
...(process.env.ANTHROPIC_API_KEY ? { anthropic_api_key: process.env.ANTHROPIC_API_KEY } : {}),
...(process.env.ZEROENTROPY_API_KEY ? { zeroentropy_api_key: process.env.ZEROENTROPY_API_KEY } : {}),
...(process.env.OPENROUTER_API_KEY ? { openrouter_api_key: process.env.OPENROUTER_API_KEY } : {}),
...(process.env.GBRAIN_EMBEDDING_MODEL ? { embedding_model: process.env.GBRAIN_EMBEDDING_MODEL } : {}),
...(process.env.GBRAIN_EMBEDDING_DIMENSIONS ? { embedding_dimensions: parseInt(process.env.GBRAIN_EMBEDDING_DIMENSIONS, 10) } : {}),
...(process.env.GBRAIN_EXPANSION_MODEL ? { expansion_model: process.env.GBRAIN_EXPANSION_MODEL } : {}),
@@ -815,6 +833,8 @@ export const KNOWN_CONFIG_KEYS: readonly string[] = [
'database_path',
'openai_api_key',
'anthropic_api_key',
'zeroentropy_api_key',
'openrouter_api_key',
'embedding_model',
'embedding_dimensions',
'embedding_disabled',
@@ -836,6 +856,11 @@ export const KNOWN_CONFIG_KEYS: readonly string[] = [
'sync',
'sync.repo_path',
'sync.last_commit',
// Gateway-native subagent loop toggle (routes subagent jobs through the
// provider-agnostic gateway.toolLoop for non-Anthropic providers). The
// subagent handler's error message tells users to `config set` this, so it
// must be a known key or `config set` rejects it without --force.
'agent.use_gateway_loop',
// DB-plane (v0.32.3 search modes + related)
'search.mode',
'search.cache.enabled',
@@ -921,6 +946,16 @@ export const KNOWN_CONFIG_KEYS: readonly string[] = [
// operator had to discover these by reading source. Registered so `config
// set` accepts them directly. See docs/operations/spend-controls.md.
'spend.posture',
// Life Chronicle (v0.42.56.0, #2390). The release notes' enable command is
// `gbrain config set auto_chronicle true`, but the key was never registered
// — so the documented command failed with "Unknown config key" and the
// operator had to discover --force by reading source. Same class as the
// spend-controls registration above.
'auto_chronicle',
// Takes bootstrap (v0.41.18.0, A12). The onboard remediation's two-gate
// consent reads this key, and enabling it is the documented path to
// `gbrain takes extract --from-pages` — same unregistered-key class.
'takes.bootstrap_enabled',
'sync.cost_gate_min_usd',
'sync.federated_v2',
'embed.backfill_cooldown_min',
@@ -943,6 +978,7 @@ export const KNOWN_CONFIG_KEY_PREFIXES: readonly string[] = [
'content_sanity.', // v0.41 content-sanity tunables
'mcp.', // mcp.publish_skills, mcp.skills_dir (PR1 skill catalog)
'autopilot.', // autopilot.nightly_quality_probe.*, autopilot.auto_drain.* (#1685)
'chronicle.', // chronicle.tz + future Life Chronicle knobs (#2390)
'self_upgrade.', // v0.42 self-upgrade (mode, quiet_hours, state)
];
+164 -79
View File
@@ -45,7 +45,6 @@ import { embedBatch } from './embedding.ts';
import { resolveContextualRetrievalMode } from './contextual-retrieval-resolver.ts';
import {
buildContextualPrefix,
extractFirstTwoSentences,
modeRequiresHaiku,
modeRequiresWrapper,
sanitizeTitle,
@@ -56,10 +55,8 @@ import {
SYNOPSIS_PROMPT_VERSION,
type GeneratePerChunkSynopsisResult,
} from './page-summary.ts';
import {
logSynopsisFailure,
type SynopsisFailureKind,
} from './audit-synopsis.ts';
import type { SynopsisFailureKind } from './audit-synopsis.ts';
import { runSlidingPool } from './worker-pool.ts';
import type { BrainEngine } from './engine.ts';
import type { ChunkInput, CRMode, Page } from './types.ts';
import type { SourceRow } from './sources-ops.ts';
@@ -72,6 +69,24 @@ import type { SourceRow } from './sources-ops.ts';
* corpus_generation hash.
*/
export const TITLE_WRAPPER_VERSION = 1;
const DEFAULT_HAIKU_MODEL = 'anthropic:claude-haiku-4-5-20251001';
export const DEFAULT_CONTEXTUAL_CHUNK_CONCURRENCY = 4;
export const MAX_CONTEXTUAL_CHUNK_CONCURRENCY = 16;
export function resolveContextualChunkConcurrency(
env: Record<string, string | undefined> = process.env,
): number {
const raw = env.GBRAIN_CONTEXTUAL_CHUNK_CONCURRENCY;
if (raw === undefined || raw.trim() === '') return DEFAULT_CONTEXTUAL_CHUNK_CONCURRENCY;
const n = Number(raw);
if (!Number.isFinite(n)) return DEFAULT_CONTEXTUAL_CHUNK_CONCURRENCY;
return clampContextualChunkConcurrency(n);
}
function clampContextualChunkConcurrency(n: number): number {
if (!Number.isFinite(n)) return DEFAULT_CONTEXTUAL_CHUNK_CONCURRENCY;
return Math.max(1, Math.min(MAX_CONTEXTUAL_CHUNK_CONCURRENCY, Math.trunc(n)));
}
/**
* Embedding model placeholder. The actual model name lands here from
@@ -196,12 +211,16 @@ export interface ReembedPageArgs {
* src/core/minions/rate-leases.ts here; inline callers (import-file,
* reindex command) pass undefined and rely on gateway-level retry.
*/
acquireSynopsisLease?: () => Promise<void>;
releaseSynopsisLease?: () => Promise<void>;
acquireSynopsisLease?: () => Promise<unknown>;
releaseSynopsisLease?: (lease?: unknown) => Promise<void>;
/**
* Intra-page per-chunk synopsis concurrency. 1 preserves the legacy
* sequential loop exactly; higher values only parallelize Haiku synopsis
* calls. Embedding remains one batch after all synopses succeed.
*/
chunkConcurrency?: number;
}
const DEFAULT_HAIKU_MODEL = 'anthropic:claude-haiku-4-5-20251001';
/**
* Re-embed one page through the active CR mode. Implements the D26 P0-2
* two-phase build pattern.
@@ -415,82 +434,41 @@ async function tryBuildPhase1(opts: {
}
// per_chunk_synopsis path. Read source text via fallback chain,
// generate synopsis per chunk sequentially within this page (D10),
// generate synopsis per chunk through a bounded sliding pool, then
// batch embed at the end (D27 P2-2).
const sourceText = readSourceTextWithFallback(page, chunks);
const wrappedTexts: string[] = [];
const wrappedTexts: string[] = new Array(chunks.length);
const chunkConcurrency = clampContextualChunkConcurrency(
args.chunkConcurrency ?? resolveContextualChunkConcurrency(),
);
for (let i = 0; i < chunks.length; i++) {
const c = chunks[i];
// Code chunks always bypass the wrapper (D20-T4) — pass through.
if (c.chunk_source === 'fenced_code') {
wrappedTexts.push(c.chunk_text);
continue;
}
// Acquire rate-lease per chunk (D26 P0-3). Inline callers pass no
// hooks; only the Minion handler wires through rate-leases.ts.
if (args.acquireSynopsisLease) {
await args.acquireSynopsisLease();
}
let synopsisResult: GeneratePerChunkSynopsisResult;
try {
synopsisResult = await generatePerChunkSynopsis({
documentText: sourceText,
chunkText: c.chunk_text,
pageTitle: page.title,
pageSlug: args.pageSlug,
sourceId: args.sourceId,
chunkIndex: c.chunk_index,
model: haikuModel,
abortSignal: args.abortSignal,
const poolResult = await runSlidingPool({
items: chunks,
workers: chunkConcurrency,
signal: args.abortSignal,
onError: 'abort',
failureLabel: (c) => String(c.chunk_index),
onItem: async (c, i) => {
wrappedTexts[i] = await buildWrappedChunkText({
chunk: c,
sourceText,
safeTitle,
page,
args,
haikuModel,
});
} finally {
if (args.releaseSynopsisLease) {
try {
await args.releaseSynopsisLease();
} catch {
// Lease release failure shouldn't abort the page; surfacing it
// would race with the synopsis result. Audit-only.
}
}
}
},
});
if (synopsisResult.kind === 'success') {
const prefix = buildContextualPrefix(safeTitle, synopsisResult.synopsis);
wrappedTexts.push(
wrapChunkForEmbedding(c.chunk_text, prefix, c.chunk_source),
);
continue;
if (poolResult.failures.length > 0) {
const failure = [...poolResult.failures].sort((a, b) => a.idx - b.idx)[0].error;
if (failure instanceof ChunkSynopsisPhase1Error) {
return failure.result;
}
// Failure classification per D27 P1-2:
// refusal | empty | malformed → page-level fall-back to title-only
// auth_failure → permanent (won't fix with retry)
// rate_limit | timeout | network | provider_5xx → transient
// source_missing → walked into fallback already; would be 'malformed'
// from generatePerChunkSynopsis if we ever propagated it here
if (
synopsisResult.kind === 'refusal' ||
synopsisResult.kind === 'empty' ||
synopsisResult.kind === 'malformed'
) {
return { kind: 'page_level_fallback_requested', cause: synopsisResult.kind };
}
if (synopsisResult.kind === 'auth_failure') {
return {
kind: 'permanent',
cause: synopsisResult.kind,
detail: synopsisResult.detail ?? 'auth failure',
};
}
return {
kind: 'transient',
cause: synopsisResult.kind,
detail: synopsisResult.detail ?? 'transient',
};
throw failure;
}
if (poolResult.aborted || args.abortSignal?.aborted) {
return { kind: 'transient', cause: 'timeout', detail: 'aborted' };
}
// All chunks synthesized successfully. Single batch embed (D27 P2-2).
@@ -511,6 +489,113 @@ async function tryBuildPhase1(opts: {
}
}
class ChunkSynopsisPhase1Error extends Error {
constructor(readonly result: Exclude<Phase1Result, Phase1Success>) {
super(`chunk synopsis failed: ${result.kind}`);
this.name = 'ChunkSynopsisPhase1Error';
}
}
async function buildWrappedChunkText(opts: {
chunk: ChunkInput;
sourceText: string;
safeTitle: string;
page: Page;
args: ReembedPageArgs;
haikuModel: string;
}): Promise<string> {
const { chunk: c, sourceText, safeTitle, page, args, haikuModel } = opts;
// Code chunks always bypass the wrapper (D20-T4) — pass through.
if (c.chunk_source === 'fenced_code') {
return c.chunk_text;
}
// Acquire rate-lease per chunk (D26 P0-3). Inline callers pass no
// hooks; only the Minion handler wires through rate-leases.ts.
let lease: unknown;
let leaseAcquired = false;
let synopsisResult: GeneratePerChunkSynopsisResult;
try {
if (args.acquireSynopsisLease) {
try {
lease = await args.acquireSynopsisLease();
} catch (err) {
if (args.abortSignal?.aborted || isAbortError(err)) {
throw new ChunkSynopsisPhase1Error({
kind: 'transient',
cause: 'timeout',
detail: 'aborted',
});
}
throw err;
}
leaseAcquired = true;
}
synopsisResult = await generatePerChunkSynopsis({
documentText: sourceText,
chunkText: c.chunk_text,
pageTitle: page.title,
pageSlug: args.pageSlug,
sourceId: args.sourceId,
chunkIndex: c.chunk_index,
model: haikuModel,
abortSignal: args.abortSignal,
});
} finally {
if (leaseAcquired && args.releaseSynopsisLease) {
try {
await args.releaseSynopsisLease(lease);
} catch {
// Lease release failure shouldn't abort the page; surfacing it
// would race with the synopsis result. Audit-only.
}
}
}
if (synopsisResult.kind === 'success') {
const prefix = buildContextualPrefix(safeTitle, synopsisResult.synopsis);
return wrapChunkForEmbedding(c.chunk_text, prefix, c.chunk_source);
}
// Failure classification per D27 P1-2:
// refusal | empty | malformed → page-level fall-back to title-only
// auth_failure → permanent (won't fix with retry)
// rate_limit | timeout | network | provider_5xx → transient
// source_missing → walked into fallback already; would be 'malformed'
// from generatePerChunkSynopsis if we ever propagated it here
if (
synopsisResult.kind === 'refusal' ||
synopsisResult.kind === 'empty' ||
synopsisResult.kind === 'malformed'
) {
throw new ChunkSynopsisPhase1Error({
kind: 'page_level_fallback_requested',
cause: synopsisResult.kind,
});
}
if (synopsisResult.kind === 'auth_failure') {
throw new ChunkSynopsisPhase1Error({
kind: 'permanent',
cause: synopsisResult.kind,
detail: synopsisResult.detail ?? 'auth failure',
});
}
throw new ChunkSynopsisPhase1Error({
kind: 'transient',
cause: synopsisResult.kind,
detail: synopsisResult.detail ?? 'transient',
});
}
function isAbortError(err: unknown): boolean {
return (
typeof err === 'object' &&
err !== null &&
(err as { name?: unknown }).name === 'AbortError'
);
}
/**
* Source-text fallback chain per D11:
* 1. read page.source_path from disk (truest "document")
@@ -17,11 +17,11 @@
* Cost: ~$0.05/night with default fixtures × Haiku polish. Bounded
* by the active BudgetTracker the autopilot loop creates per-tick.
*
* **Wiring into the autopilot loop is deferred to a follow-up**
* (filed in TODOS.md). v0.41.16.0 ships the phase as a callable
* module so doctor + future cron drivers can invoke it; the
* scheduler wire-up follows the same shape as
* `src/core/cycle/nightly-quality-probe.ts` (v0.40.1.0 Track D / T6).
* Wired into the autopilot loop (step 4.6 in autopilot.ts), following
* the same shape as `src/core/cycle/nightly-quality-probe.ts`
* (v0.40.1.0 Track D / T6): the wiring resolves fixtures from the
* gbrain package root, writes real outcomes to the parser-probe audit
* trail (`audit-parser-probe.ts`), and never crashes the loop.
*
* Test seam: all dependencies are injected via NightlyProbeDeps so
* unit tests don't touch real LLMs or real fixtures.
+6 -1
View File
@@ -44,7 +44,12 @@ export const DEFAULT_DIMENSIONS: string[] = [
* `--slot-a-model`, `--slot-b-model`, `--slot-c-model` on the CLI.
*/
export const DEFAULT_SLOTS: SlotConfig[] = [
{ id: 'A', model: 'openai:gpt-4o' },
// Every default MUST be listed in its recipe's chat touchpoint (pinned by
// test/cross-modal-default-slots.test.ts) — `openai:gpt-4o` sat here after
// the OpenAI recipe dropped it, so slot A errored "not listed for OpenAI
// chat" on every install and the 3-slot panel could never reach its
// 2-model quorum without a Google key (verdict: permanently inconclusive).
{ id: 'A', model: 'openai:gpt-5.2' },
{ id: 'B', model: 'anthropic:claude-opus-4-7' },
{ id: 'C', model: 'google:gemini-1.5-pro' },
];
+49 -8
View File
@@ -61,16 +61,56 @@ const ATOM_TYPES = [
'critique', 'collection',
] as const;
// v0.41.2.1 (D2): brain-page discovery constants. Hardcoded for now;
// future pack-aware refactor is a one-line change to pull from the
// active pack manifest (symmetric with the existing
// src/core/facts/eligibility.ts:49 TODO).
const EXTRACTABLE_PAGE_TYPES = [
// v0.41.2.1 (D2): brain-page discovery constants.
//
// Legacy floor: the pre-pack hardcoded atom-extraction types. Retained as a
// back-compat union member so a gbrain-base brain never loses an extraction
// target when we begin honoring the pack manifest's `extractable` flags.
const LEGACY_EXTRACTABLE_TYPES = [
'meeting', 'source', 'article', 'video', 'book', 'original',
] as const;
// Synthesis outputs are never extraction inputs: extracting atoms from atoms or
// concepts would loop (concepts are synthesized FROM atoms). Mirrors
// facts/eligibility.ts, which likewise excludes `concept` despite its
// extractable:true flag being a documented forward-compat marker.
const SYNTHESIS_OUTPUT_TYPES = new Set<string>(['atom', 'concept']);
const PAGE_DISCOVERY_BUDGET = 50;
const MIN_PAGE_CHARS_FOR_EXTRACTION = 500;
/**
* Pure allowlist policy: the legacy floor UNION the pack's `extractable: true`
* types, MINUS synthesis outputs. Exported for unit tests; keep I/O-free.
*/
export function unionExtractableTypes(packExtractable: Iterable<string>): string[] {
const types = new Set<string>(LEGACY_EXTRACTABLE_TYPES);
for (const t of packExtractable) types.add(t);
for (const t of SYNTHESIS_OUTPUT_TYPES) types.delete(t);
return [...types];
}
/**
* Resolve the atom-extraction type allowlist from the active schema pack.
* Closes the D2 TODO of honoring the pack manifest (so a type declared
* extractable e.g. `note` actually extracts) while preserving behavior for
* gbrain-base via the legacy-floor union. Fail-soft: any pack-load error falls
* back to the legacy floor.
*/
async function resolveExtractableTypes(): Promise<string[]> {
let packExtractable: Iterable<string> = [];
try {
const { loadConfig } = await import('../config.ts');
const { loadActivePack } = await import('../schema-pack/load-active.ts');
const { extractableTypesFromPack } = await import('../schema-pack/extractable.ts');
const resolved = await loadActivePack({ cfg: loadConfig(), remote: false });
packExtractable = extractableTypesFromPack(resolved.manifest);
} catch {
// Pack unavailable (test seams, bootstrap) — legacy floor only.
}
return unionExtractableTypes(packExtractable);
}
export interface ExtractAtomsOpts {
brainDir?: string;
sourceId?: string;
@@ -195,7 +235,7 @@ export async function discoverExtractablePages(
`;
const params: unknown[] = [
sourceId,
EXTRACTABLE_PAGE_TYPES as unknown as string[],
await resolveExtractableTypes(),
MIN_PAGE_CHARS_FOR_EXTRACTION,
PAGE_DISCOVERY_BUDGET,
];
@@ -272,9 +312,10 @@ export async function countExtractAtomsBacklog(
AND atom.frontmatter->>'source_hash' = substring(p.content_hash from 1 for 16)
AND atom.deleted_at IS NULL
)`;
const extractableTypes = await resolveExtractableTypes();
const params = scoped
? [sourceId, EXTRACTABLE_PAGE_TYPES as unknown as string[], MIN_PAGE_CHARS_FOR_EXTRACTION]
: [EXTRACTABLE_PAGE_TYPES as unknown as string[], MIN_PAGE_CHARS_FOR_EXTRACTION];
? [sourceId, extractableTypes, MIN_PAGE_CHARS_FOR_EXTRACTION]
: [extractableTypes, MIN_PAGE_CHARS_FOR_EXTRACTION];
const rows = await engine.executeRaw<{ cnt: string | number }>(sql, params);
return Number(rows[0]?.cnt ?? 0);
} catch (err) {
+24
View File
@@ -70,6 +70,28 @@ export async function runLongMemEvalForProbe(args: LongMemEvalProbeArgs): Promis
* the batch input) or unparseable (cross-modal wrote garbage). Both
* cases are paste-ready in the error message.
*/
/**
* QA-shaped judge dimensions for the nightly probe. The batch judge's
* DEFAULT_DIMENSIONS rubric (DEPTH / SOURCING / SPECIFICITY / ) is built
* for rich agent responses; LongMemEval hypotheses are deliberately terse
* factual answers ("in widget-co") that can never score 7 on DEPTH or
* SOURCING so with the default rubric the probe FAILs every night even
* when retrieval + answering are perfectly healthy. The probe owns its
* invocation of the eval tool and passes dimensions matching the
* fixture's QA shape instead.
*
* NOTE: the `--dimensions` CLI flag splits on commas, so these dimension
* descriptions must stay comma-free.
*/
export const PROBE_QA_DIMENSIONS: string[] = [
// No faithfulness/grounding dimension on purpose: the judge never sees
// the haystack, so any accurate detail beyond the terse gold label reads
// as "invented" and correct answers fail (verified empirically — a
// correct "before + dates" answer scored 4/10 on such a dimension).
'CORRECTNESS — Does the hypothesis state the same fact as the expected answer? A terse direct answer is ideal.',
'DIRECTNESS — Does it answer THIS question without hedging or padding or answering something else?',
];
export async function runCrossModalBatchForProbe(
args: CrossModalProbeArgs,
): Promise<{ exitCode: number; summary: CrossModalBatchSummary }> {
@@ -81,6 +103,8 @@ export async function runCrossModalBatchForProbe(
args.summaryPath,
'--max-usd',
String(args.maxUsd),
'--dimensions',
PROBE_QA_DIMENSIONS.join(','),
'--yes',
'--json',
]);
+43 -11
View File
@@ -62,6 +62,42 @@ export interface NightlyProbeDeps {
now: () => Date;
}
/**
* Dual-plane flag resolution (same precedent as `mcp.publish_skills` in
* serve-http.ts): the DB config row what `gbrain config set` writes
* wins when present; the file plane (~/.gbrain/config.json) is the
* fallback. Doctor's paste-ready enable hint says `gbrain config set
* autopilot.nightly_quality_probe.enabled true`, so the gate MUST read
* the DB plane a file-only read turns that hint into a silent no-op.
*/
export function resolveProbeEnabled(
dbVal: string | null | undefined,
fileVal: unknown,
): boolean {
if (dbVal != null) return dbVal === 'true';
return fileVal === true;
}
/**
* Same dual-plane rule for the per-run cost cap. Malformed or negative
* values on either plane fall through to the next plane / the default.
*/
export function resolveProbeMaxUsd(
dbVal: string | null | undefined,
fileVal: unknown,
fallback: number = DEFAULT_MAX_USD,
): number {
if (dbVal != null) {
const n = Number(dbVal);
if (Number.isFinite(n) && n >= 0) return n;
}
if (fileVal != null) {
const n = Number(fileVal);
if (Number.isFinite(n) && n >= 0) return n;
}
return fallback;
}
/**
* Pure function: decide whether the probe should run given the audit
* history. Returns reason when skipping.
@@ -101,21 +137,17 @@ export async function runNightlyQualityProbe(deps: NightlyProbeDeps): Promise<Ni
return { outcome: 'disabled', exit_code: 0, detail: 'feature flag off' };
}
// 24h rate limit — skip + audit "rate_limited".
// 24h rate limit — skip WITHOUT an audit row. The autopilot loop invokes
// the probe every cycle (~5-10 min), so all but one invocation per day
// lands here; logging each skip floods the audit file (~hundreds of
// rows/day) and — because doctor treats any non-pass outcome as bad
// signal — flips nightly_quality_probe_health to a permanent WARN the
// moment the probe is enabled. A skip is a non-event: the real runs are
// the signal, and their rows are what gates the next 24h window.
const now = deps.now();
const recent = readRecentQualityProbeEvents(2, now); // 2-day window is enough for 24h check
const decision = shouldRunNightly(now, recent);
if (!decision.run) {
logQualityProbeEvent({
outcome: 'rate_limited',
exit_code: 0,
pass_count: 0,
fail_count: 0,
inconclusive_count: 0,
error_count: 0,
est_cost_usd: 0,
detail: 'already ran within 24h window',
});
return { outcome: 'rate_limited', exit_code: 0, detail: 'already ran within 24h' };
}
+5
View File
@@ -24,6 +24,7 @@
*/
const THIRTY_MIN_MS = 30 * 60 * 1000;
const SIXTY_MIN_MS = 60 * 60 * 1000;
const TEN_MIN_MS = 10 * 60 * 1000;
/**
@@ -42,6 +43,10 @@ export const HANDLER_DEFAULT_TIMEOUT_MS: Readonly<Record<string, number>> = {
// few writes. Generous 10-min budget (vs the tight null-default) covers a
// slow gateway without the 30-min loop budget.
chronicle_extract: TEN_MIN_MS,
// Per-page contextual reindex jobs process chunks sequentially with one
// rate-leased LLM synopsis call per chunk; large transcript pages need more
// than the standard 30-min long-job budget.
contextual_reindex_per_chunk: SIXTY_MIN_MS,
};
/**
@@ -40,6 +40,7 @@ import { UnrecoverableError } from '../types.ts';
import type { BrainEngine } from '../../engine.ts';
import {
reembedPageWithContextualRetrieval,
resolveContextualChunkConcurrency,
type ReembedPageResult,
} from '../../contextual-retrieval-service.ts';
import {
@@ -132,7 +133,7 @@ export function makeContextualReindexHandler(opts: MakeContextualReindexHandlerO
// call inside the service acquires/releases a lease against the
// shared key across all worker processes.
const maxConcurrent = resolveMaxConcurrent();
let currentLeaseId: number | null = null;
const chunkConcurrency = resolveContextualChunkConcurrency();
const result: ReembedPageResult = await reembedPageWithContextualRetrieval({
engine,
@@ -141,32 +142,32 @@ export function makeContextualReindexHandler(opts: MakeContextualReindexHandlerO
globalMode,
killSwitchDisabled,
abortSignal: ctx.signal,
chunkConcurrency,
acquireSynopsisLease: async () => {
// Poll-acquire with brief backoff. The service's per-chunk loop
// is sequential within a page; this guards against the cross-
// worker pile-up.
// is bounded within a page; this guards against the cross-worker
// pile-up and remains the global rate governor.
let attempts = 0;
const maxAttempts = 60; // ~1 min max wait per chunk before giving up
while (attempts < maxAttempts) {
if (ctx.signal.aborted) throw abortError();
const res = await acquireLease(engine, RATE_LEASE_KEY, ctx.id, maxConcurrent, {
ttlMs: 60_000,
});
if (res.acquired && res.leaseId != null) {
currentLeaseId = res.leaseId;
return;
return res.leaseId;
}
attempts++;
await new Promise((r) => setTimeout(r, 1000));
await sleepWithAbort(1000, ctx.signal);
}
throw new Error(
`Failed to acquire ${RATE_LEASE_KEY} lease after ${maxAttempts} attempts; ` +
`Haiku rate limit pile-up too deep.`,
);
},
releaseSynopsisLease: async () => {
if (currentLeaseId != null) {
await releaseLease(engine, currentLeaseId);
currentLeaseId = null;
releaseSynopsisLease: async (lease) => {
if (typeof lease === 'number') {
await releaseLease(engine, lease);
}
},
});
@@ -218,6 +219,26 @@ async function tryLoadPageAcrossSources(
return null;
}
function sleepWithAbort(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal.aborted) {
reject(abortError());
return;
}
const timer = setTimeout(resolve, ms);
signal.addEventListener('abort', () => {
clearTimeout(timer);
reject(abortError());
}, { once: true });
});
}
function abortError(): Error {
const err = new Error('aborted');
err.name = 'AbortError';
return err;
}
function classifyResult(
pageSlug: string,
result: ReembedPageResult,
+215 -11
View File
@@ -777,22 +777,57 @@ async function runSubagentViaGateway(args: GatewayRunArgs): Promise<SubagentResu
});
}
// 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),
}));
// Token rollup across the prior transcript (returned as-is on the terminal
// early-return path; the loop adds only NEW-turn usage otherwise).
const priorTokens = { in: 0, out: 0, cache_read: 0, cache_create: 0 };
for (const m of priorMessages) {
if (m.tokens_in) priorTokens.in += m.tokens_in;
if (m.tokens_out) priorTokens.out += m.tokens_out;
if (m.tokens_cache_read) priorTokens.cache_read += m.tokens_cache_read;
if (m.tokens_cache_create) priorTokens.cache_create += m.tokens_cache_create;
}
// Reconcile an unbalanced transcript from a prior crashed/resumed run. The
// gateway loop persists each assistant turn but (pre-fix) never persisted the
// following tool-result user turn, so a resumed job reloads assistant
// tool-calls with no matching results — which non-Anthropic (openai-compat)
// providers reject with AI_MissingToolResultsError, dead-lettering the job.
// reconcileGatewayReplay heals every such dangling turn from settled tool
// executions (mirroring the legacy Anthropic path) and reports the terminal
// case where the prior run already reached end_turn.
const priorToolsV1 = await loadPriorTools(engine, ctx.id);
const { chatMessages: priorChatMessages, nextMessageIdx: reconciledNextIdx, terminalText } =
await reconcileGatewayReplay({
engine,
jobId: ctx.id,
priorMessages,
priorTools: priorToolsV1,
toolDefs,
signal: ctx.signal,
});
// Terminal early-return (#1151 parity): the prior run already reached
// end_turn. Non-Anthropic providers reject a trailing assistant "prefill",
// so surface the persisted text and skip the loop entirely.
if (terminalText !== null) {
return {
result: terminalText,
turns_count: priorChatMessages.filter(m => m.role === 'assistant').length,
stop_reason: 'end_turn',
tokens: priorTokens,
};
}
// 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) {
// Persist seed user message at idx 0 if fresh start. reconciledNextIdx is
// max(known message_idx) + 1 (0 when no prior rows), which keeps the loop's
// subsequent writes clear of any healed tool-result turn we just inserted.
let nextMessageIdx = reconciledNextIdx;
if (priorChatMessages.length === 0) {
await persistMessage(engine, ctx.id, {
message_idx: 0,
role: 'user',
@@ -904,6 +939,22 @@ async function runSubagentViaGateway(args: GatewayRunArgs): Promise<SubagentResu
[errorMsg, gbrainToolUseId],
);
},
// Persist the tool-result user turn so a resume reloads a balanced
// transcript. JSON.stringify inside persistMessage ISO-izes any Date in
// tool output, and the value binds through $N::text::jsonb (never
// JSON.stringify into a bare ::jsonb).
onToolResultTurn: async (_turnIdx, messageIdx, blocks) => {
await persistMessage(engine, ctx.id, {
message_idx: messageIdx,
role: 'user',
content_blocks: blocks as unknown as ContentBlock[],
tokens_in: null,
tokens_out: null,
tokens_cache_read: null,
tokens_cache_create: null,
model: null,
});
},
onHeartbeat: heartbeat,
});
@@ -934,6 +985,158 @@ async function runSubagentViaGateway(args: GatewayRunArgs): Promise<SubagentResu
};
}
interface ReconcileArgs {
engine: BrainEngine;
jobId: number;
priorMessages: PersistedMessage[];
priorTools: PersistedToolExec[];
toolDefs: ToolDef[];
signal: AbortSignal;
}
interface ReconcileResult {
chatMessages: ChatMessage[];
/** max(known/healed message_idx) + 1 — 0 when there is no prior transcript. */
nextMessageIdx: number;
/** Non-null when the transcript already ended on an assistant text turn. */
terminalText: string | null;
}
/**
* Heal an unbalanced gateway replay transcript before it reaches the provider.
*
* The gateway loop persists each assistant turn but historically never
* persisted the following tool-result user turn, so a resumed job reloaded
* assistant tool-calls with no matching results. Non-Anthropic (openai-compat)
* providers reject that with AI_MissingToolResultsError and the job
* dead-letters. This walks EVERY prior assistant turn that carries tool-call
* blocks (not just the tail a pre-fix multi-turn job persisted several
* consecutive dangling assistant turns) and, if the turn isn't already answered
* by the following tool-result user turn, synthesizes that turn from the
* settled `subagent_tool_executions` rows with the same semantics as the legacy
* Anthropic replay path:
* - complete stored output
* - failed stored error (isError)
* - idempotent-pending / missing row re-dispatch, persist, use output
* - non-idempotent-pending throw (cannot safely re-run)
* - tool no longer registered persist failed + error stub
* Each synthesized turn is persisted at `assistant.message_idx + 1` (the free
* slot the pre-fix loop skipped) via `ON CONFLICT DO NOTHING`, so the next
* resume stays balanced.
*/
async function reconcileGatewayReplay(args: ReconcileArgs): Promise<ReconcileResult> {
const { engine, jobId, priorMessages, priorTools, toolDefs, signal } = args;
const work = priorMessages.map(m => {
const adapted = adaptContentBlocksToChatBlocks(m.content_blocks);
return {
message_idx: m.message_idx,
role: m.role,
blocks: typeof adapted === 'string' ? [{ type: 'text', text: adapted } as ChatBlock] : adapted,
};
});
// Settled executions, looked up by (assistant message_idx, provider
// tool_use_id) with an ordinal-position fallback for legacy rows.
const execByKey = new Map<string, PersistedToolExec>();
const execByMsg = new Map<number, PersistedToolExec[]>();
for (const t of priorTools) {
execByKey.set(`${t.message_idx}:${t.tool_use_id}`, t);
const arr = execByMsg.get(t.message_idx) ?? [];
arr.push(t);
execByMsg.set(t.message_idx, arr);
}
let maxIdx = work.reduce((mx, w) => Math.max(mx, w.message_idx), -1);
for (let i = 0; i < work.length; i++) {
const msg = work[i];
if (msg.role !== 'assistant') continue;
const toolCalls = msg.blocks.filter(
(b): b is Extract<ChatBlock, { type: 'tool-call' }> => b.type === 'tool-call',
);
if (toolCalls.length === 0) continue;
// Skip if a following tool-result user turn exists AT ALL. A fully-answered
// turn is already balanced; a PARTIALLY-answered turn (only reachable from
// externally-corrupted data — gbrain persists all of a turn's results in one
// message) is left for repairToolPairing() at the chat() boundary, which
// back-fills only the missing ids. Synthesizing a full turn here would
// duplicate the answered results and collide with the persisted row.
const next = work[i + 1];
if (next && next.role === 'user' && next.blocks.some(b => b.type === 'tool-result')) continue;
const results: ChatBlock[] = [];
for (let callIdx = 0; callIdx < toolCalls.length; callIdx++) {
const call = toolCalls[callIdx];
// Prefer an exact (message_idx, provider tool_use_id) match. The
// positional fallback is used only when the row at that ordinal is for
// the SAME tool, so a missing row can't mis-attribute a sibling's output.
const fallback = execByMsg.get(msg.message_idx)?.[callIdx];
const exec = execByKey.get(`${msg.message_idx}:${call.toolCallId}`)
?? (fallback && fallback.tool_name === call.toolName ? fallback : undefined);
if (exec?.status === 'complete') {
results.push({ type: 'tool-result', toolCallId: call.toolCallId, toolName: call.toolName, output: exec.output ?? null });
continue;
}
if (exec?.status === 'failed') {
results.push({ type: 'tool-result', toolCallId: call.toolCallId, toolName: call.toolName, output: exec.error ?? 'tool failed', isError: true });
continue;
}
const toolDef = toolDefs.find(t => t.name === call.toolName);
if (!toolDef) {
await persistToolExecFailed(engine, jobId, msg.message_idx, call.toolCallId, call.toolName, call.input, `tool "${call.toolName}" is not in the registry for this subagent`);
results.push({ type: 'tool-result', toolCallId: call.toolCallId, toolName: call.toolName, output: `tool "${call.toolName}" is not available`, isError: true });
continue;
}
if (exec?.status === 'pending' && !toolDef.idempotent) {
throw new Error(`non-idempotent tool "${call.toolName}" pending on resume; cannot safely re-run`);
}
await persistToolExecPending(engine, jobId, msg.message_idx, call.toolCallId, call.toolName, call.input);
try {
const output = await toolDef.execute(call.input, { engine, jobId, remote: true, signal });
await persistToolExecComplete(engine, jobId, call.toolCallId, output);
results.push({ type: 'tool-result', toolCallId: call.toolCallId, toolName: call.toolName, output });
} catch (e) {
const errText = e instanceof Error ? (e.stack ?? e.message) : String(e);
await persistToolExecFailed(engine, jobId, msg.message_idx, call.toolCallId, call.toolName, call.input, errText);
results.push({ type: 'tool-result', toolCallId: call.toolCallId, toolName: call.toolName, output: errText, isError: true });
}
}
const resultIdx = msg.message_idx + 1;
await persistMessage(engine, jobId, {
message_idx: resultIdx,
role: 'user',
content_blocks: results as unknown as ContentBlock[],
tokens_in: null, tokens_out: null, tokens_cache_read: null, tokens_cache_create: null, model: null,
});
maxIdx = Math.max(maxIdx, resultIdx);
work.splice(i + 1, 0, { message_idx: resultIdx, role: 'user' as const, blocks: results });
i++; // skip the turn we just inserted
}
const chatMessages: ChatMessage[] = work.map(w => ({ role: w.role, content: w.blocks }));
// Terminal case: the transcript already ends on an assistant turn that
// carried real text and made no tool calls (prior run reached end_turn).
// Surface its text; skip the loop. An assistant turn whose blocks are all
// empty (e.g. a reasoning-only/null-text turn that adaptation dropped) is NOT
// terminal — falling through lets the loop re-issue the call rather than
// returning an empty result.
const lastMsg = work[work.length - 1];
let terminalText: string | null = null;
if (lastMsg && lastMsg.role === 'assistant' && !lastMsg.blocks.some(b => b.type === 'tool-call')) {
const text = lastMsg.blocks
.filter((b): b is Extract<ChatBlock, { type: 'text' }> => b.type === 'text')
.map(b => b.text)
.join('\n');
if (text.trim() !== '') terminalText = text;
}
return { chatMessages, nextMessageIdx: maxIdx + 1, terminalText };
}
function recipeIdFromModel(modelString: string): string {
const idx = modelString.indexOf(':');
return idx > 0 ? modelString.slice(0, idx) : 'anthropic';
@@ -1087,7 +1290,8 @@ async function loadPriorTools(engine: BrainEngine, jobId: number): Promise<Persi
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`,
WHERE job_id = $1
ORDER BY message_idx, COALESCE(ordinal, 0), id`,
[jobId],
);
return rows.map(r => ({
+2 -2
View File
@@ -1656,8 +1656,8 @@ export class PGLiteEngine implements BrainEngine {
* chunk_text are compared as-stored).
* - Empty-query guard returns no results without binding SQL.
*
* Postgres engine is intentionally untouched (multi-tenant deployments
* can install pgroonga / zhparser when needed; out of scope here).
* Ported to postgres-engine.ts `_searchKeywordCJK` (Postgres parity) so
* both engines share the same CJK fallback semantics.
*/
private async _searchKeywordCJK(
query: string,
+247 -7
View File
@@ -62,6 +62,7 @@ import { ConnectionManager } from './connection-manager.ts';
import { logConnectionEvent } from './connection-audit.ts';
import { validateSlug, contentHash, rowToPage, rowToStalePage, rowToChunk, rowToSearchResult, parseEmbedding, tryParseEmbedding, takeRowToTake, isUndefinedTableError, warnOncePerProcess } from './utils.ts';
import { resolveBoostMap, resolveHardExcludes } from './search/source-boost.ts';
import { hasCJK, escapeLikePattern } from './cjk.ts';
import { buildSourceFactorCase, buildHardExcludeClause, buildVisibilityClause, buildRecencyComponentSql, buildBestPerPagePoolCte } from './search/sql-ranking.ts';
import { DEFAULT_EMBEDDING_MODEL, DEFAULT_EMBEDDING_DIMENSIONS } from './ai/defaults.ts';
import { DELETE_BATCH_SIZE } from './engine-constants.ts';
@@ -1586,6 +1587,30 @@ export class PostgresEngine implements BrainEngine {
const hardExcludePrefixes = resolveHardExcludes(opts?.exclude_slug_prefixes, opts?.include_slug_prefixes);
const hardExcludeClause = buildHardExcludeClause('p.slug', hardExcludePrefixes);
// v0.26.5: visibility filter hides soft-deleted pages and pages from
// archived sources. Joined `sources s` lets the predicate compile to a
// column lookup. NOT bypassed by detail=high — soft-delete is a contract,
// not a temporal preference. (Hoisted above the CJK branch so the
// fallback can reuse it, mirroring the PGLite hoist.)
const visibilityClause = buildVisibilityClause('p', 's');
// v0.32.7 CJK branch (Postgres parity): `websearch_to_tsquery('english')`
// can't tokenize CJK — a Chinese/Japanese/Korean query compiles to an
// empty tsquery and the FTS path silently returns nothing. Switch to
// ILIKE on chunk_text with occurrence-count ranking as the ts_rank
// substitute when the query contains CJK characters, mirroring the
// PGLite engine's `_searchKeywordCJK`. ASCII path stays exactly the
// same below.
if (hasCJK(query)) {
return this._searchKeywordCJK(query, {
limit, offset, innerLimit, sourceFactorCase,
hardExcludeClause, visibilityClause,
detailFilter: detailLow ? `AND cc.chunk_source = 'compiled_truth'` : '',
opts,
dedup: true,
});
}
const params: unknown[] = [query];
let typeClause = '';
if (type) {
@@ -1648,11 +1673,8 @@ export class PostgresEngine implements BrainEngine {
params.push(offset);
const offsetParam = `$${params.length}`;
// v0.26.5: visibility filter hides soft-deleted pages and pages from
// archived sources. Joined `sources s` lets the predicate compile to a
// column lookup. NOT bypassed by detail=high — soft-delete is a contract,
// not a temporal preference.
const visibilityClause = buildVisibilityClause('p', 's');
// visibilityClause already declared above (v0.32.7 parity: hoisted so
// the CJK branch can reuse it).
const rawQuery = `
WITH ranked_chunks AS (
@@ -1703,6 +1725,204 @@ export class PostgresEngine implements BrainEngine {
return rows.map(rowToSearchResult);
}
/**
* v0.32.7 CJK keyword fallback (Postgres parity port of the PGLite
* engine's `_searchKeywordCJK`). `websearch_to_tsquery('english')` can't
* tokenize CJK so the FTS path returns empty for Chinese / Japanese /
* Korean queries. This routes to an ILIKE substring scan with
* occurrence-count ranking as a ts_rank substitute.
*
* Same discipline as the PGLite implementation (codex outside-voice C8):
* - Two distinct parameter bindings: $1 qLike (LIKE-escaped, for ILIKE)
* and $2 qRaw (un-escaped, for ranking arithmetic via
* position/replace). Escaped chars cannot be reused as ranking
* substrings.
* - Explicit `ESCAPE '\'` on the ILIKE clause.
* - Symmetric: no asymmetric whitespace strip (caller's query and
* chunk_text are compared as-stored).
* - Empty-query guard returns no results without binding SQL.
*
* Adapted to this engine's conventions rather than copied verbatim:
* postgres.js `sql.begin()` + `SET LOCAL statement_timeout` (pool-safe,
* R6-F006), filter params first with limit/offset appended last,
* `false AS stale`, and this engine's full filter set (type / types /
* exclude_slugs / language / symbolKind / afterDate / beforeDate /
* source scope the superset the ASCII FTS paths support here).
*/
private async _searchKeywordCJK(
query: string,
ctx: {
limit: number;
offset: number;
innerLimit: number;
sourceFactorCase: string;
hardExcludeClause: string;
visibilityClause: string;
detailFilter: string;
opts: SearchOpts | undefined;
dedup: boolean;
},
): Promise<SearchResult[]> {
const sql = this.sql;
const { limit, offset, innerLimit, sourceFactorCase, hardExcludeClause, visibilityClause, detailFilter, opts, dedup } = ctx;
const qRaw = query;
if (qRaw.length === 0) return [];
const qLike = escapeLikePattern(qRaw);
// $1 = qLike (escaped for ILIKE)
// $2 = qRaw (raw for position()/replace() ranking arithmetic)
// Filter params next; inner-limit/limit/offset appended last (this
// engine's named-param convention).
const params: unknown[] = [qLike, qRaw];
let typeClause = '';
if (opts?.type) {
params.push(opts.type);
typeClause = `AND p.type = $${params.length}`;
}
// v0.33: multi-type filter for whoknows. AND-applied alongside the
// single-value `type` filter (callers can use either or both).
let typesClause = '';
if (opts?.types && opts.types.length > 0) {
params.push(opts.types);
typesClause = `AND p.type = ANY($${params.length}::text[])`;
}
let excludeSlugsClause = '';
if (opts?.exclude_slugs?.length) {
params.push(opts.exclude_slugs);
excludeSlugsClause = `AND p.slug != ALL($${params.length}::text[])`;
}
let languageClause = '';
if (opts?.language) {
params.push(opts.language);
languageClause = `AND cc.language = $${params.length}`;
}
let symbolKindClause = '';
if (opts?.symbolKind) {
params.push(opts.symbolKind);
symbolKindClause = `AND cc.symbol_type = $${params.length}`;
}
// v0.27.0: date filtering support
let afterDateClause = '';
if (opts?.afterDate) {
params.push(opts.afterDate);
afterDateClause = `AND COALESCE(p.updated_at, p.created_at) > $${params.length}::timestamptz`;
}
let beforeDateClause = '';
if (opts?.beforeDate) {
params.push(opts.beforeDate);
beforeDateClause = `AND COALESCE(p.updated_at, p.created_at) < $${params.length}::timestamptz`;
}
// v0.34.1 (#861 — P0 leak seal): source-isolation on the CJK fallback
// path too. Array form wins over scalar.
let sourceClause = '';
if (opts?.sourceIds && opts.sourceIds.length > 0) {
params.push(opts.sourceIds);
sourceClause = `AND p.source_id = ANY($${params.length}::text[])`;
} else if (opts?.sourceId) {
params.push(opts.sourceId);
sourceClause = `AND p.source_id = $${params.length}`;
}
// Occurrence-count ranking: count occurrences of $qRaw in chunk_text via
// (length(chunk) - length(replace(chunk, q, ''))) / length(q). Acts as
// a ts_rank substitute. position()-tiebreaker so earlier-in-chunk hits
// outrank later ones at the same occurrence count. Same expression as
// the PGLite implementation.
const scoreExpr = `
((LENGTH(cc.chunk_text) - LENGTH(REPLACE(cc.chunk_text, $2, ''))) / NULLIF(LENGTH($2), 0)::real
+ 1.0 / NULLIF(POSITION($2 IN cc.chunk_text), 0)::real)
* ${sourceFactorCase}
`;
let rawQuery: string;
if (dedup) {
params.push(innerLimit);
const innerLimitParam = `$${params.length}`;
params.push(limit);
const limitParam = `$${params.length}`;
params.push(offset);
const offsetParam = `$${params.length}`;
rawQuery = `
WITH ranked_chunks AS (
SELECT
p.slug, p.id as page_id, p.title, p.type, p.source_id,
p.effective_date, p.effective_date_source,
cc.id as chunk_id, cc.chunk_index, cc.chunk_text, cc.chunk_source,
${scoreExpr} AS score
FROM content_chunks cc
JOIN pages p ON p.id = cc.page_id
JOIN sources s ON s.id = p.source_id
WHERE cc.chunk_text ILIKE '%' || $1 || '%' ESCAPE '\\'
${typeClause}
${typesClause}
${excludeSlugsClause}
${detailFilter}
${languageClause}
${symbolKindClause}
${afterDateClause}
${beforeDateClause}
${sourceClause}
${hardExcludeClause}
${visibilityClause}
-- v0.27.1: hide image rows from text-keyword search so OCR text
-- doesn't drown text-page hits (same as the FTS path).
AND cc.modality = 'text'
ORDER BY score DESC
LIMIT ${innerLimitParam}
),
${buildBestPerPagePoolCte('ranked_chunks')}
SELECT slug, page_id, title, type, source_id,
effective_date, effective_date_source,
chunk_id, chunk_index, chunk_text, chunk_source, score,
false AS stale
FROM best_per_page
ORDER BY score DESC
LIMIT ${limitParam}
OFFSET ${offsetParam}
`;
} else {
params.push(limit);
const limitParam = `$${params.length}`;
params.push(offset);
const offsetParam = `$${params.length}`;
rawQuery = `
SELECT
p.slug, p.id as page_id, p.title, p.type, p.source_id,
p.effective_date, p.effective_date_source,
cc.id as chunk_id, cc.chunk_index, cc.chunk_text, cc.chunk_source,
${scoreExpr} AS score,
false AS stale
FROM content_chunks cc
JOIN pages p ON p.id = cc.page_id
JOIN sources s ON s.id = p.source_id
WHERE cc.chunk_text ILIKE '%' || $1 || '%' ESCAPE '\\'
${typeClause}
${typesClause}
${excludeSlugsClause}
${detailFilter}
${languageClause}
${symbolKindClause}
${afterDateClause}
${beforeDateClause}
${sourceClause}
${hardExcludeClause}
${visibilityClause}
ORDER BY score DESC
LIMIT ${limitParam}
OFFSET ${offsetParam}
`;
}
// Search-only timeout, same pool-safety contract as the FTS paths:
// SET LOCAL inside sql.begin() scopes the GUC to the transaction so
// it can never leak onto a pooled connection.
const rows = await sql.begin(async sql => {
await sql`SET LOCAL statement_timeout = '8s'`;
return await sql.unsafe(rawQuery, params as Parameters<typeof sql.unsafe>[1]);
});
return rows.map(rowToSearchResult);
}
/**
* v0.20.0 Cathedral II Layer 3 (1b) chunk-grain keyword search.
* Ranks chunks via content_chunks.search_vector WITHOUT the
@@ -1735,6 +1955,26 @@ export class PostgresEngine implements BrainEngine {
const hardExcludePrefixes = resolveHardExcludes(opts?.exclude_slug_prefixes, opts?.include_slug_prefixes);
const hardExcludeClause = buildHardExcludeClause('p.slug', hardExcludePrefixes);
// v0.26.5: visibility filter for searchKeywordChunks (anchor primitive).
// (Hoisted above the CJK branch so the fallback can reuse it.)
const visibilityClause = buildVisibilityClause('p', 's');
// v0.32.7 CJK branch (Postgres parity): same as searchKeyword but
// without page-dedup — english tsquery can't tokenize CJK, so route to
// the ILIKE + occurrence-count fallback (mirrors the PGLite engine's
// chunk-grain CJK call site).
if (hasCJK(query)) {
return this._searchKeywordCJK(query, {
limit, offset,
innerLimit: 0, // unused on chunk-grain (no inner CTE)
sourceFactorCase,
hardExcludeClause, visibilityClause,
detailFilter: detailLow ? `AND cc.chunk_source = 'compiled_truth'` : '',
opts,
dedup: false,
});
}
const params: unknown[] = [query];
let typeClause = '';
if (type) {
@@ -1790,8 +2030,8 @@ export class PostgresEngine implements BrainEngine {
params.push(offset);
const offsetParam = `$${params.length}`;
// v0.26.5: visibility filter for searchKeywordChunks (anchor primitive).
const visibilityClause = buildVisibilityClause('p', 's');
// visibilityClause already declared above (v0.32.7 parity: hoisted so
// the CJK branch can reuse it).
const rawQuery = `
SELECT
+14 -1
View File
@@ -152,6 +152,19 @@ export interface ThinkResult {
const DEFAULT_MAX_OUTPUT_TOKENS = 4000;
// Thinking-by-default Claude 5 models (`anthropic:claude-*-5`) spend a large
// share of the output budget on internal reasoning before emitting any answer,
// so the 4000 default leaves `think` with empty or truncated text. Give those
// models headroom; providers bill actual tokens, not the cap. Everything else
// keeps 4000.
const THINKING_DEFAULT_MAX_OUTPUT_TOKENS = 16000;
const THINKING_BY_DEFAULT_MODEL_RE = /^anthropic[:/]claude-[a-z0-9]+-5(?:[.-]|$)/i;
export function maxOutputTokensFor(modelStr: string): number {
return THINKING_BY_DEFAULT_MODEL_RE.test(modelStr)
? THINKING_DEFAULT_MAX_OUTPUT_TOKENS
: DEFAULT_MAX_OUTPUT_TOKENS;
}
function inferIntent(question: string, anchor?: string): string {
if (anchor) return 'entity';
const q = question.toLowerCase();
@@ -465,7 +478,7 @@ export async function runThink(
}
const result = await client.create({
model: modelUsed,
max_tokens: DEFAULT_MAX_OUTPUT_TOKENS,
max_tokens: maxOutputTokensFor(normalizeModelId(modelUsed)),
system: systemPrompt,
messages: [{ role: 'user', content: userMessage }],
});
+6 -4
View File
@@ -34,7 +34,7 @@ export interface TrajectoryRegression {
from_date: string; // YYYY-MM-DD
to_value: number;
to_date: string;
delta_pct: number; // negative for a drop; range typically [-1, 0)
delta_pct: number; // negative for a numeric drop; may be < -1 across zero
}
export interface TrajectoryStats {
@@ -82,8 +82,10 @@ function cosineSim(a: Float32Array, b: Float32Array): number {
*
* Iterates per-metric (so trajectories that interleave mrr + arr + team_size
* don't trip false regressions across metric boundaries). Within each metric,
* walks consecutive value pairs; a pair fires when
* `(newer - older) / older <= -threshold`.
* walks consecutive value pairs; a pair fires when the newer value is lower
* than the older value by at least the threshold. The relative delta uses
* `abs(older)` as the denominator so negative-valued metrics (net income,
* cash flow, etc.) do not invert improvement and regression.
*
* Pre-condition: caller passed points sorted by (valid_from ASC, fact_id ASC).
* The engine's `findTrajectory` enforces this. No re-sort here.
@@ -111,7 +113,7 @@ export function detectRegressions(
// Guard against division-by-zero: a metric starting at exactly 0
// can't compute a relative delta. Skip.
if (oldVal === 0) continue;
const delta = (newVal - oldVal) / oldVal;
const delta = (newVal - oldVal) / Math.abs(oldVal);
if (delta <= -threshold) {
out.push({
metric,
+20
View File
@@ -86,6 +86,26 @@ describe('buildGatewayConfig env-baseURL passthrough', () => {
});
});
describe('buildGatewayConfig config-plane API-key folding', () => {
test('openrouter_api_key folds into gateway env as OPENROUTER_API_KEY', async () => {
await withEnv({ OPENROUTER_API_KEY: undefined }, async () => {
const cfg = buildGatewayConfig({
openrouter_api_key: 'sk-or-config-plane',
} as unknown as GBrainConfig);
expect(cfg.env.OPENROUTER_API_KEY).toBe('sk-or-config-plane');
});
});
test('a real OPENROUTER_API_KEY process.env value wins over the config-plane fallback', async () => {
await withEnv({ OPENROUTER_API_KEY: 'sk-or-env-plane' }, async () => {
const cfg = buildGatewayConfig({
openrouter_api_key: 'sk-or-config-plane',
} as unknown as GBrainConfig);
expect(cfg.env.OPENROUTER_API_KEY).toBe('sk-or-env-plane');
});
});
});
describe('buildGatewayConfig env empty-string clobber guard (#1249)', () => {
test('an empty-string process.env value does NOT clobber a valid config-plane key', async () => {
// Claude Code injects ANTHROPIC_API_KEY='' to neuter subprocess LLM calls.
+124
View File
@@ -0,0 +1,124 @@
/**
* Pins the DeepSeek reasoning_content transport shim. `deepseek-reasoner`
* returns its answer in a separate `reasoning_content` field and leaves
* `content` empty when the whole turn was reasoning; the AI SDK's
* openai-compatible adapter reads only `content`, so the model appears to
* answer with nothing. The shim promotes `reasoning_content` into `content`
* when `content` is empty, fail-open on anything unexpected.
*/
import { describe, test, expect, afterEach } from 'bun:test';
import { deepseekReasoningContentCompatFetch, deepseek } from '../../src/core/ai/recipes/deepseek.ts';
import { applyOpenAICompatConfig } from '../../src/core/ai/gateway.ts';
import type { Recipe, AIGatewayConfig } from '../../src/core/ai/types.ts';
const realFetch = globalThis.fetch;
afterEach(() => { globalThis.fetch = realFetch; });
function stubFetch(body: unknown, init?: { status?: number; contentType?: string }) {
globalThis.fetch = (async () =>
new Response(JSON.stringify(body), {
status: init?.status ?? 200,
headers: { 'content-type': init?.contentType ?? 'application/json' },
})) as unknown as typeof fetch;
}
describe('deepseekReasoningContentCompatFetch', () => {
test('promotes reasoning_content when content is empty', async () => {
stubFetch({ choices: [{ message: { role: 'assistant', content: '', reasoning_content: 'the answer' } }] });
const res = await deepseekReasoningContentCompatFetch('https://api.deepseek.com/v1/chat/completions');
const json = await res.json();
expect(json.choices[0].message.content).toBe('the answer');
});
test('promotes when content is null or whitespace-only', async () => {
stubFetch({ choices: [{ message: { content: null, reasoning_content: 'from null' } }, { message: { content: ' ', reasoning_content: 'from ws' } }] });
const res = await deepseekReasoningContentCompatFetch('u');
const json = await res.json();
expect(json.choices[0].message.content).toBe('from null');
expect(json.choices[1].message.content).toBe('from ws');
});
test('leaves non-empty content untouched (no duplication)', async () => {
stubFetch({ choices: [{ message: { content: 'real content', reasoning_content: 'ignored' } }] });
const res = await deepseekReasoningContentCompatFetch('u');
const json = await res.json();
expect(json.choices[0].message.content).toBe('real content');
});
test('both empty: stays empty, no crash', async () => {
stubFetch({ choices: [{ message: { content: '', reasoning_content: '' } }] });
const res = await deepseekReasoningContentCompatFetch('u');
const json = await res.json();
expect(json.choices[0].message.content).toBe('');
});
test('tool-call turn (content:null + tool_calls) is NOT promoted — never feed CoT back', async () => {
// content:null is the standard OpenAI shape on a tool-call turn. Promoting
// reasoning_content here would inject the whole chain-of-thought as assistant
// text, which the loop persists + replays every turn. Must be left alone.
stubFetch({ choices: [{ finish_reason: 'tool_calls', message: {
content: null,
reasoning_content: 'INTERNAL CHAIN OF THOUGHT — must not leak',
tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'brain_search', arguments: '{}' } }],
} }] });
const res = await deepseekReasoningContentCompatFetch('u');
const json = await res.json();
expect(json.choices[0].message.content).toBeNull();
expect(json.choices[0].message.tool_calls).toHaveLength(1);
});
test('rebuilt response drops stale content-length header', async () => {
globalThis.fetch = (async () => new Response(JSON.stringify({ choices: [{ message: { content: '', reasoning_content: 'x' } }] }), {
status: 200, headers: { 'content-type': 'application/json', 'content-length': '999999' },
})) as unknown as typeof fetch;
const res = await deepseekReasoningContentCompatFetch('u');
expect(res.headers.get('content-length')).toBeNull();
expect((await res.json()).choices[0].message.content).toBe('x');
});
});
describe('applyOpenAICompatConfig — compat.fetch wiring (gateway seam)', () => {
const cfg = { env: {}, base_urls: {} } as unknown as AIGatewayConfig;
test('threads recipe.compat.fetch onto the resolved config (deepseek)', () => {
// Guards the src/core/ai/gateway.ts wiring: without `?? recipe.compat?.fetch`
// the DeepSeek shim would never install in production.
const resolved = applyOpenAICompatConfig(deepseek, cfg);
expect(resolved.fetch).toBe(deepseekReasoningContentCompatFetch);
});
test('a resolveOpenAICompatConfig-provided fetch takes precedence over compat.fetch', () => {
const ownFetch = (async () => new Response('{}')) as unknown as typeof fetch;
const recipe = {
id: 'x', name: 'X', tier: 'openai-compat', implementation: 'openai-compatible',
touchpoints: {},
compat: { fetch: deepseekReasoningContentCompatFetch },
resolveOpenAICompatConfig: () => ({ baseURL: 'http://x', fetch: ownFetch }),
} as unknown as Recipe;
expect(applyOpenAICompatConfig(recipe, cfg).fetch).toBe(ownFetch);
});
test('falls back to compat.fetch when resolveOpenAICompatConfig omits a fetch', () => {
const recipe = {
id: 'y', name: 'Y', tier: 'openai-compat', implementation: 'openai-compatible',
touchpoints: {},
compat: { fetch: deepseekReasoningContentCompatFetch },
resolveOpenAICompatConfig: () => ({ baseURL: 'http://y' }),
} as unknown as Recipe;
expect(applyOpenAICompatConfig(recipe, cfg).fetch).toBe(deepseekReasoningContentCompatFetch);
});
test('fail-open on non-ok / non-json responses', async () => {
stubFetch({ error: 'nope' }, { status: 500 });
const res = await deepseekReasoningContentCompatFetch('u');
expect(res.status).toBe(500);
globalThis.fetch = (async () =>
new Response('plain text', { status: 200, headers: { 'content-type': 'text/plain' } })) as unknown as typeof fetch;
const res2 = await deepseekReasoningContentCompatFetch('u');
expect(await res2.text()).toBe('plain text');
});
test('recipe wires the shim via compat.fetch', () => {
expect(deepseek.compat?.fetch).toBe(deepseekReasoningContentCompatFetch);
});
});
+65
View File
@@ -150,6 +150,47 @@ describe('gateway.toolLoop (v0.38 D11 — provider-agnostic loop control)', () =
expect(events[4]).toBe('onAssistantTurn(1)'); // final assistant turn
});
it('persists the tool-result user turn via onToolResultTurn before the next chat', async () => {
let turn = 0;
__setChatTransportForTests(async () => {
turn++;
if (turn === 1) {
return {
text: '',
blocks: [{ type: 'tool-call', toolCallId: 'tc1', toolName: 'search', input: { q: 'x' } }] 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: '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',
};
});
const resultTurns: Array<{ turnIdx: number; messageIdx: number; blocks: ChatBlock[] }> = [];
await toolLoop({
initialMessages: [{ role: 'user', content: 'go' }],
tools: [{ name: 'search', description: 's', inputSchema: { type: 'object' } }],
toolHandlers: new Map([['search', { idempotent: true, async execute() { return { hits: 1 }; } }]]),
onToolResultTurn: async (turnIdx, messageIdx, blocks) => {
resultTurns.push({ turnIdx, messageIdx, blocks });
},
});
// Fired exactly once, for the single tool round, carrying the tool-result.
expect(resultTurns).toHaveLength(1);
expect(resultTurns[0].turnIdx).toBe(0);
expect(resultTurns[0].blocks[0].type).toBe('tool-result');
expect((resultTurns[0].blocks[0] as Extract<ChatBlock, { type: 'tool-result' }>).toolCallId).toBe('tc1');
});
it('replay short-circuits a complete prior tool execution', async () => {
let chatCalls = 0;
__setChatTransportForTests(async () => {
@@ -230,6 +271,30 @@ describe('gateway.toolLoop (v0.38 D11 — provider-agnostic loop control)', () =
).rejects.toThrow(/non-idempotent.*pending/i);
});
it('defaults max output tokens per model: 4096 for non-thinking, 32000 for Claude 5', async () => {
const seen: Array<number | undefined> = [];
__setChatTransportForTests(async (opts) => {
seen.push(opts.maxTokens);
return {
text: 'ok',
blocks: [{ type: 'text', text: 'ok' }] as ChatBlock[],
stopReason: 'end',
usage: { input_tokens: 1, output_tokens: 1, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: opts.model ?? 'anthropic:claude-sonnet-4-6',
providerId: 'anthropic',
};
});
await toolLoop({ model: 'openai:gpt-4o', initialMessages: [{ role: 'user', content: 'hi' }], tools: [], toolHandlers: new Map() });
await toolLoop({ model: 'anthropic:claude-sonnet-4-6', initialMessages: [{ role: 'user', content: 'hi' }], tools: [], toolHandlers: new Map() });
await toolLoop({ model: 'anthropic:claude-sonnet-5', initialMessages: [{ role: 'user', content: 'hi' }], tools: [], toolHandlers: new Map() });
await toolLoop({ model: 'anthropic:claude-fable-5', initialMessages: [{ role: 'user', content: 'hi' }], tools: [], toolHandlers: new Map() });
// Non-thinking / non-Claude-5 stay 4096 (safe under openai-compat caps);
// thinking-by-default Claude 5 models get 32000 headroom.
expect(seen).toEqual([4096, 4096, 32000, 32000]);
});
it('hits max_turns when the model keeps calling tools', async () => {
__setChatTransportForTests(async () => ({
text: '',
+100
View File
@@ -0,0 +1,100 @@
/**
* Pins `repairToolPairing` (the chat()-boundary safety net) and proves, against
* the REAL AI SDK v6 `generateText`, that the two failure modes this wave fixes
* are gone:
* - an unbalanced tool history (assistant tool-call with no tool-result) is
* back-filled so v6 no longer throws AI_MissingToolResultsError, and
* - a Date-bearing tool-result (Postgres timestamptz) passes v6's ModelMessage
* JSONValue schema after `toModelMessages` ISO-izes it, whereas a raw Date
* is still rejected (control).
*
* MockLanguageModelV3 = no network / no keys.
*/
import { describe, expect, it } from 'bun:test';
import { generateText } from 'ai';
import { MockLanguageModelV3 } from 'ai/test';
import { repairToolPairing, toModelMessages, type ChatMessage } from '../../src/core/ai/gateway.ts';
function mockModel(): MockLanguageModelV3 {
return new MockLanguageModelV3({
doGenerate: async () => ({
content: [{ type: 'text', text: 'ok' }],
finishReason: 'stop',
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
warnings: [],
}),
} as any);
}
describe('repairToolPairing', () => {
it('is a no-op on a balanced history', () => {
const msgs: ChatMessage[] = [
{ role: 'user', content: 'hi' },
{ role: 'assistant', content: [{ type: 'tool-call', toolCallId: 'c1', toolName: 'search', input: {} }] },
{ role: 'user', content: [{ type: 'tool-result', toolCallId: 'c1', toolName: 'search', output: { ok: 1 } }] },
];
expect(repairToolPairing(msgs)).toEqual(msgs);
});
it('synthesizes a tool-result turn when the assistant tool-call is fully unanswered', () => {
const msgs: ChatMessage[] = [
{ role: 'assistant', content: [{ type: 'tool-call', toolCallId: 'c1', toolName: 'search', input: {} }] },
];
const out = repairToolPairing(msgs);
expect(out).toHaveLength(2);
expect(out[1].role).toBe('user');
const block = (out[1].content as any[])[0];
expect(block).toMatchObject({ type: 'tool-result', toolCallId: 'c1', isError: true });
});
it('merges stubs into a PARTIALLY-answered turn without duplicating the answered id', () => {
const msgs: ChatMessage[] = [
{ role: 'assistant', content: [
{ type: 'tool-call', toolCallId: 'a', toolName: 'search', input: {} },
{ type: 'tool-call', toolCallId: 'b', toolName: 'search', input: {} },
] },
{ role: 'user', content: [{ type: 'tool-result', toolCallId: 'a', toolName: 'search', output: { ok: 1 } }] },
];
const out = repairToolPairing(msgs);
expect(out).toHaveLength(2); // merged in place, not appended
const ids = (out[1].content as any[]).map((x) => x.toolCallId);
expect(ids).toEqual(['a', 'b']); // 'a' kept once, 'b' back-filled
expect((out[1].content as any[]).filter((x) => x.toolCallId === 'a')).toHaveLength(1);
});
});
describe('real AI SDK v6 validation', () => {
it('an unbalanced history passes generateText after repairToolPairing', async () => {
const model = mockModel();
const unbalanced: ChatMessage[] = [
{ role: 'user', content: 'go' },
{ role: 'assistant', content: [{ type: 'tool-call', toolCallId: 'c1', toolName: 'search', input: {} }] },
// no tool-result turn
];
const result = await generateText({
model: model as any,
messages: toModelMessages(repairToolPairing(unbalanced)) as any,
});
expect(result.text).toBe('ok');
const prompt = model.doGenerateCalls[0]!.prompt as any[];
expect(prompt.some((m) => m.role === 'tool')).toBe(true); // stub promoted to tool role
});
it('a Date-bearing tool-result passes after toModelMessages ISO-izes it; a raw Date is rejected', async () => {
const withDate: ChatMessage[] = [
{ role: 'user', content: 'go' },
{ role: 'assistant', content: [{ type: 'tool-call', toolCallId: 'c1', toolName: 'brain_get_page', input: {} }] },
{ role: 'user', content: [{ type: 'tool-result', toolCallId: 'c1', toolName: 'brain_get_page', output: { updated_at: new Date('2026-06-26T06:56:59.000Z') } }] },
];
// Fixed path: converted history validates.
await expect(generateText({ model: mockModel() as any, messages: toModelMessages(withDate) as any })).resolves.toBeDefined();
// Control: a raw Date placed straight into a v6 ModelMessage json value is rejected.
const rawDateMessages = [
{ role: 'user', content: 'go' },
{ role: 'assistant', content: [{ type: 'tool-call', toolCallId: 'c1', toolName: 'brain_get_page', input: {} }] },
{ role: 'tool', content: [{ type: 'tool-result', toolCallId: 'c1', toolName: 'brain_get_page', output: { type: 'json', value: { updated_at: new Date('2026-06-26T06:56:59.000Z') } } }] },
];
await expect(generateText({ model: mockModel() as any, messages: rawDateMessages as any })).rejects.toThrow();
});
});
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, test } from 'bun:test';
import { defaultResolveAuth } from '../../src/core/ai/gateway.ts';
import { assertTouchpoint } from '../../src/core/ai/model-resolver.ts';
import { getRecipe } from '../../src/core/ai/recipes/index.ts';
describe('recipe: litellm proxy', () => {
test('registered with expected openai-compatible shape', () => {
const r = getRecipe('litellm');
expect(r).toBeDefined();
expect(r!.id).toBe('litellm');
expect(r!.tier).toBe('openai-compat');
expect(r!.implementation).toBe('openai-compatible');
expect(r!.base_url_default).toBe('http://localhost:4000');
expect(r!.auth_env?.required ?? []).toEqual([]);
expect(r!.auth_env?.optional ?? []).toContain('LITELLM_BASE_URL');
expect(r!.auth_env?.optional ?? []).toContain('LITELLM_API_KEY');
});
test('chat touchpoint accepts arbitrary proxied model IDs', () => {
const r = getRecipe('litellm')!;
expect(r.touchpoints.chat).toBeDefined();
expect(r.touchpoints.chat!.models).toEqual([]);
expect(r.touchpoints.chat!.supports_tools).toBe(true);
expect(r.touchpoints.chat!.supports_subagent_loop).toBe(false);
expect(() => assertTouchpoint(r, 'chat', 'gpt-4o')).not.toThrow();
expect(() => assertTouchpoint(r, 'chat', 'deepseek-v4-pro')).not.toThrow();
});
test('embedding touchpoint still uses user-provided models and dimensions', () => {
const r = getRecipe('litellm')!;
expect(r.touchpoints.embedding).toBeDefined();
expect(r.touchpoints.embedding!.models).toEqual([]);
expect(r.touchpoints.embedding!.user_provided_models).toBe(true);
expect(r.touchpoints.embedding!.default_dims).toBe(0);
expect(r.touchpoints.embedding!.no_batch_cap).toBe(true);
});
test('default auth honors LITELLM_API_KEY and ignores URL-only config', () => {
const r = getRecipe('litellm')!;
const noAuth = defaultResolveAuth(r, {}, 'chat');
expect(noAuth.headerName).toBe('Authorization');
expect(noAuth.token).toBe('Bearer unauthenticated');
const urlOnly = defaultResolveAuth(r, { LITELLM_BASE_URL: 'http://proxy.example' }, 'chat');
expect(urlOnly.token).toBe('Bearer unauthenticated');
const withKey = defaultResolveAuth(r, {
LITELLM_BASE_URL: 'http://proxy.example',
LITELLM_API_KEY: 'sk-litellm-fake',
}, 'chat');
expect(withKey.token).toBe('Bearer sk-litellm-fake');
});
});
+35
View File
@@ -167,6 +167,41 @@ describe('buildPlan — diff against completed + installed VERSION', () => {
});
});
describe('force-retry escape hatch', () => {
test("complete then retry-latest → pending and buildPlan lists the version as pending", () => {
const idx = indexCompleted([
{ version: '0.11.0', status: 'complete' },
{ version: '0.11.0', status: 'retry' },
]);
expect(statusForVersion('0.11.0', idx)).toBe('pending');
const plan = buildPlan(idx, '0.11.1', '0.11.0');
expect(plan.pending.map(m => m.version)).toEqual(['0.11.0']);
expect(plan.applied).toEqual([]);
expect(plan.partial).toEqual([]);
expect(plan.wedged).toEqual([]);
});
test('complete then stray partial without retry → still complete', () => {
const idx = indexCompleted([
{ version: '0.11.0', status: 'complete' },
{ version: '0.11.0', status: 'partial' },
]);
expect(statusForVersion('0.11.0', idx)).toBe('complete');
});
test('retry followed by a newer complete → complete', () => {
const idx = indexCompleted([
{ version: '0.11.0', status: 'complete' },
{ version: '0.11.0', status: 'retry' },
{ version: '0.11.0', status: 'complete' },
]);
expect(statusForVersion('0.11.0', idx)).toBe('complete');
});
});
// v0.36.1.x (cherry-pick #1062): list, dry-run, and "all migrations up to
// date" paths must exit 0 so shell scripts gating on the exit code work.
// Pre-fix, these `return` statements left the CLI dispatcher's implicit
+102
View File
@@ -0,0 +1,102 @@
/**
* Tests for the parser-probe audit trail + the 24h rate-limit gate.
*
* Uses GBRAIN_AUDIT_DIR override pointed at a tmpdir for hermeticity
* (same pattern as audit-slug-fallback.serial.test.ts). Serial because
* the env override is process-global.
*/
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { mkdtempSync, rmSync, readdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
computeParserProbeAuditFilename,
logParserProbeEvent,
parserProbeRanWithin,
readRecentParserProbeEvents,
type ParserProbeAuditEvent,
} from '../src/core/audit-parser-probe.ts';
let auditDir: string;
let savedEnv: string | undefined;
beforeEach(() => {
auditDir = mkdtempSync(join(tmpdir(), 'parser-probe-audit-'));
savedEnv = process.env.GBRAIN_AUDIT_DIR;
process.env.GBRAIN_AUDIT_DIR = auditDir;
});
afterEach(() => {
if (savedEnv === undefined) delete process.env.GBRAIN_AUDIT_DIR;
else process.env.GBRAIN_AUDIT_DIR = savedEnv;
rmSync(auditDir, { recursive: true, force: true });
});
function makeEvent(overrides: Partial<ParserProbeAuditEvent> = {}): ParserProbeAuditEvent {
return {
schema_version: 1,
ts: new Date().toISOString(),
outcome: 'pass',
fixtures_total: 12,
fixtures_passed: 12,
recall_mean: 0.98,
participants_recall_mean: 0.97,
adversarial_false_positives: 0,
failed_fixture_ids: [],
...overrides,
};
}
describe('parser-probe audit trail', () => {
test('log + readRecent round-trip', () => {
logParserProbeEvent(makeEvent({ outcome: 'fail', reason: '2 fixture(s) failed' }));
const events = readRecentParserProbeEvents(7);
expect(events.length).toBe(1);
expect(events[0]!.outcome).toBe('fail');
expect(events[0]!.reason).toBe('2 fixture(s) failed');
const files = readdirSync(auditDir);
expect(files.length).toBe(1);
expect(files[0]).toMatch(/^parser-probe-\d{4}-W\d{2}\.jsonl$/);
});
test('filename uses ISO-week rotation with the parser-probe prefix', () => {
// Year-boundary edge pinned by the shared writer's own tests; here we
// pin the prefix wiring.
expect(computeParserProbeAuditFilename(new Date('2026-07-06T12:00:00Z'))).toBe(
'parser-probe-2026-W28.jsonl',
);
});
test('readRecent filters by window', () => {
const old = new Date(Date.now() - 10 * 86400000).toISOString();
logParserProbeEvent(makeEvent({ ts: old }));
expect(readRecentParserProbeEvents(7).length).toBe(0);
});
});
describe('parserProbeRanWithin — 24h rate-limit gate', () => {
const DAY_MS = 24 * 60 * 60 * 1000;
test('false when no runs are audited', () => {
expect(parserProbeRanWithin(DAY_MS)).toBe(false);
});
test('true when a run landed within the window', () => {
logParserProbeEvent(makeEvent({ ts: new Date(Date.now() - 60_000).toISOString() }));
expect(parserProbeRanWithin(DAY_MS)).toBe(true);
});
test('false when the last run is older than the window', () => {
logParserProbeEvent(makeEvent({ ts: new Date(Date.now() - 25 * 3600_000).toISOString() }));
expect(parserProbeRanWithin(DAY_MS)).toBe(false);
});
test('non-pass outcomes also hold the window (mirrors quality-probe semantics)', () => {
logParserProbeEvent(makeEvent({
outcome: 'no_embedding_key',
ts: new Date(Date.now() - 3600_000).toISOString(),
}));
expect(parserProbeRanWithin(DAY_MS)).toBe(true);
});
});
+20 -4
View File
@@ -31,10 +31,15 @@ describe('autopilot wiring: nightly quality probe', () => {
expect(SOURCE).toContain(`runCrossModalBatchForProbe`);
});
test('feature flag gate present: cfg.autopilot.nightly_quality_probe.enabled', () => {
test('feature flag gate present: dual-plane read (DB row wins, file plane fallback)', () => {
// Per D10: the scheduler ONLY checks the feature flag. The 24h rate-limit
// lives inside runNightlyQualityProbe itself (no scheduler-side precheck).
expect(SOURCE).toContain(`nightly_quality_probe?.enabled === true`);
// The flag resolves through resolveProbeEnabled so `gbrain config set
// autopilot.nightly_quality_probe.enabled true` (the doctor hint, DB
// plane) and ~/.gbrain/config.json (file plane) BOTH work — a file-only
// read made the printed hint a silent no-op.
expect(SOURCE).toContain(`getConfig('autopilot.nightly_quality_probe.enabled')`);
expect(SOURCE).toMatch(/resolveProbeEnabled\(dbEnabled,\s*cfg\?\.autopilot\?\.nightly_quality_probe\?\.enabled\)/);
});
test('NO scheduler-side rate-limit check (D10 simplification)', () => {
@@ -64,12 +69,23 @@ describe('autopilot wiring: nightly quality probe', () => {
expect(SOURCE).toContain(`now:`);
});
test('resolveRepoRoot prefers the gbrain package root (committed fixture home), not the brain repoPath', () => {
// The DI harness in nightly-quality-probe.test.ts passes process.cwd()
// (= the gbrain repo in CI), which papered over the wiring passing
// repoPath (= sync.repo_path, the user's BRAIN repo, where the fixture
// never exists). Pin the package-root resolution + existence check.
expect(SOURCE).toMatch(/fileURLToPath\(new URL\('\.\.\/\.\.', import\.meta\.url\)\)/);
expect(SOURCE).toContain(`'longmemeval-nightly.jsonl'`);
expect(SOURCE).toMatch(/fixtureAtPkgRoot \? pkgRoot : repoPath/);
});
test('hasEmbeddingProvider reads from gateway.isAvailable("embedding") (codex round-2 #12 — in-process, not subprocess)', () => {
expect(SOURCE).toContain(`isAvailable('embedding')`);
expect(SOURCE).toContain(`gateway`);
});
test('max_usd default = 5 when config unset (matches plan default per D10)', () => {
expect(SOURCE).toMatch(/max_usd\s*\?\?\s*5/);
test('max_usd resolves dual-plane (default = 5 pinned by resolveProbeMaxUsd unit tests)', () => {
expect(SOURCE).toContain(`getConfig('autopilot.nightly_quality_probe.max_usd')`);
expect(SOURCE).toMatch(/resolveProbeMaxUsd\(dbMaxUsd,\s*cfg\?\.autopilot\?\.nightly_quality_probe\?\.max_usd\)/);
});
});
@@ -0,0 +1,77 @@
/**
* Source-shape regression tests for the autopilot wiring of
* `runConversationParserNightlyProbe` (step 4.6).
*
* Same rationale as autopilot-nightly-probe-wiring.test.ts: the loop is
* hard to drive end-to-end, so these pin the structural protections
* the dual-plane flag read, the D10 tokenmax mode-gate, the package-root
* fixture resolution, the audit-flood guard, and the try/catch posture.
*
* The probe's own gate/scoring logic is pinned by the module's unit
* tests; the audit trail by audit-parser-probe.serial.test.ts.
*/
import { describe, test, expect } from 'bun:test';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
const AUTOPILOT_SRC = resolve('src/commands/autopilot.ts');
const SOURCE = readFileSync(AUTOPILOT_SRC, 'utf-8');
describe('autopilot wiring: conversation-parser probe', () => {
test('invokes the phase module and the audit trail', () => {
expect(SOURCE).toContain(`runConversationParserNightlyProbe`);
expect(SOURCE).toContain(`conversation-parser/nightly-probe`);
expect(SOURCE).toContain(`logParserProbeEvent`);
expect(SOURCE).toContain(`audit-parser-probe`);
});
test('flag reads dual-plane: DB row (gbrain config set) wins, file plane fallback', () => {
expect(SOURCE).toContain(`getConfig('autopilot.conversation_parser_probe.enabled')`);
expect(SOURCE).toContain(`cfg?.autopilot?.conversation_parser_probe?.enabled === true`);
});
test('D10 mode-gate present: tokenmax brains run the probe by default', () => {
expect(SOURCE).toMatch(/parserEnabled \|\| searchMode === 'tokenmax'/);
});
test('fixtures resolve from the gbrain package root, NOT the brain repoPath', () => {
// The committed fixtures live in the gbrain source tree; resolving
// them against sync.repo_path would point into the user's brain repo.
expect(SOURCE).toMatch(/fileURLToPath\(new URL\('\.\.\/\.\.', import\.meta\.url\)\)/);
expect(SOURCE).toContain(`'conversation-formats', 'all.jsonl'`);
expect(SOURCE).toContain(`'conversation-formats', 'adversarial.jsonl'`);
});
test('missing fixtures skip quietly (no audit row, once-per-process stderr note)', () => {
// Compiled-binary installs carry no source tree; writing failure rows
// would flip doctor to WARN on every binary install.
expect(SOURCE).toContain(`parserProbeFixtureWarned`);
});
test('rate_limited outcomes are NOT audit-logged (flood guard)', () => {
expect(SOURCE).toMatch(/outcome !== 'rate_limited'\) logParserProbeEvent\(result\)/);
});
test('rate-limit gate delegates to the audit module, not inline event reads', () => {
expect(SOURCE).toContain(`parserProbeRanWithin(24 * 60 * 60 * 1000)`);
});
test('LLM-key gate reads gateway.isAvailable("chat") in-process', () => {
expect(SOURCE).toContain(`isAvailable('chat')`);
});
test('probe call wrapped in try/catch that does NOT bump consecutiveErrors', () => {
expect(SOURCE).toMatch(/catch[\s\S]*?autopilot\.parser_probe[\s\S]*?do NOT bump consecutiveErrors/);
});
test('DI shape: the exact 7 fields of the parser probe NightlyProbeDeps', () => {
expect(SOURCE).toContain(`isEnabled:`);
expect(SOURCE).toContain(`searchMode:`);
expect(SOURCE).toContain(`hasLlmKey:`);
expect(SOURCE).toContain(`resolveFixturePath:`);
expect(SOURCE).toContain(`resolveAdversarialPath:`);
expect(SOURCE).toContain(`shouldSkipForRateLimit:`);
expect(SOURCE).toContain(`now:`);
});
});
+9
View File
@@ -38,6 +38,15 @@ describe('KNOWN_CONFIG_KEYS', () => {
expect(KNOWN_CONFIG_KEYS).toContain('embed.backfill_max_usd');
});
test('includes the gateway-loop toggle and provider API keys the wave wires', () => {
// The subagent handler's error message tells users to run
// `gbrain config set agent.use_gateway_loop true`; it must be a known key
// or `config set` rejects the wave's own enable command without --force.
expect(KNOWN_CONFIG_KEYS).toContain('agent.use_gateway_loop');
expect(KNOWN_CONFIG_KEYS).toContain('openrouter_api_key');
expect(KNOWN_CONFIG_KEYS).toContain('zeroentropy_api_key');
});
test('no duplicate entries', () => {
const set = new Set(KNOWN_CONFIG_KEYS);
expect(set.size).toBe(KNOWN_CONFIG_KEYS.length);
+12
View File
@@ -253,3 +253,15 @@ describe('loadConfig — GBRAIN_MAX_MARKUP_RATIO env (v0.42 #1699)', () => {
});
});
});
describe('KNOWN_CONFIG_KEYS — documented enable commands must be registered', () => {
test('Life Chronicle keys are registered (v0.42.56.0 release notes say `config set auto_chronicle true`)', async () => {
const { KNOWN_CONFIG_KEYS, KNOWN_CONFIG_KEY_PREFIXES } = await import('../src/core/config.ts');
// The flag the chronicle backstop reads (isAutoChronicleEnabled).
expect(KNOWN_CONFIG_KEYS).toContain('auto_chronicle');
// The takes bootstrap two-gate consent flag (v0.41.18.0 A12).
expect(KNOWN_CONFIG_KEYS).toContain('takes.bootstrap_enabled');
// chronicle.tz (chronicleTz) + future chronicle.* knobs.
expect(KNOWN_CONFIG_KEY_PREFIXES.some(p => 'chronicle.tz'.startsWith(p))).toBe(true);
});
});
+338 -6
View File
@@ -1,20 +1,38 @@
/**
* Pure-function tests for src/core/contextual-retrieval-service.ts.
*
* The full service test (PHASE 1 + PHASE 2 happy path, refusal restart,
* transient error propagation) needs a real PGLite + gateway stub seam.
* That lands in test/e2e/contextual-retrieval.test.ts. This file pins
* the service's pure helpers: corpus_generation hash composition + the
* expectedMode helper used by the T9 reindex sweep predicate.
* This file pins the service's pure helpers plus hermetic service behavior
* driven through fake engine + gateway seams. Full PGLite coverage lives in
* test/e2e/contextual-retrieval-pglite.test.ts.
*/
import { describe, test, expect } from 'bun:test';
import { afterEach, describe, test, expect } from 'bun:test';
import {
computeCorpusGeneration,
computeSourceTextHash,
expectedModeForPageSourceOnly,
reembedPageWithContextualRetrieval,
resolveContextualChunkConcurrency,
TITLE_WRAPPER_VERSION,
} from '../src/core/contextual-retrieval-service.ts';
import {
__setChatTransportForTests,
__setEmbedTransportForTests,
configureGateway,
resetGateway,
type ChatOpts,
type ChatResult,
} from '../src/core/ai/gateway.ts';
import type { ChunkInput } from '../src/core/types.ts';
import { withEnv } from './helpers/with-env.ts';
const TEST_DIMS = 1536;
afterEach(() => {
__setChatTransportForTests(null);
__setEmbedTransportForTests(null);
resetGateway();
});
describe('computeCorpusGeneration', () => {
test('returns 16-char hex hash', () => {
@@ -138,3 +156,317 @@ describe('expectedModeForPageSourceOnly (T9 reindex sweep helper)', () => {
}
});
});
describe('resolveContextualChunkConcurrency', () => {
test('defaults to 4 and reads the process env', async () => {
await withEnv({ GBRAIN_CONTEXTUAL_CHUNK_CONCURRENCY: undefined }, async () => {
expect(resolveContextualChunkConcurrency()).toBe(4);
});
await withEnv({ GBRAIN_CONTEXTUAL_CHUNK_CONCURRENCY: '7' }, async () => {
expect(resolveContextualChunkConcurrency()).toBe(7);
});
});
test('clamps to [1, 16] and ignores invalid values', () => {
expect(resolveContextualChunkConcurrency({
GBRAIN_CONTEXTUAL_CHUNK_CONCURRENCY: '0',
})).toBe(1);
expect(resolveContextualChunkConcurrency({
GBRAIN_CONTEXTUAL_CHUNK_CONCURRENCY: '-3',
})).toBe(1);
expect(resolveContextualChunkConcurrency({
GBRAIN_CONTEXTUAL_CHUNK_CONCURRENCY: '99',
})).toBe(16);
expect(resolveContextualChunkConcurrency({
GBRAIN_CONTEXTUAL_CHUNK_CONCURRENCY: '1.9',
})).toBe(1);
expect(resolveContextualChunkConcurrency({
GBRAIN_CONTEXTUAL_CHUNK_CONCURRENCY: 'not-a-number',
})).toBe(4);
});
});
describe('per-chunk synopsis concurrency', () => {
test('concurrency > 1 preserves chunk-order embed input', async () => {
const chunks = makeChunks(['alpha', 'beta', 'gamma', 'delta']);
const delays: Record<string, number> = { alpha: 30, beta: 5, gamma: 20, delta: 1 };
const sequential = await runWithChatStub({
chunks,
concurrency: 1,
delayForChunk: (chunk) => delays[chunk] ?? 1,
});
const parallel = await runWithChatStub({
chunks,
concurrency: 4,
delayForChunk: (chunk) => delays[chunk] ?? 1,
});
expect(parallel.result.kind).toBe('success');
expect(parallel.embedInputs).toEqual(sequential.embedInputs);
expect(parallel.embeddedChunks.map((c) => c.chunk_text)).toEqual(
chunks.map((c) => c.chunk_text),
);
});
test('concurrency is bounded', async () => {
let active = 0;
let maxActive = 0;
let leaseActive = 0;
let maxLeaseActive = 0;
let acquired = 0;
let released = 0;
const chunks = makeChunks(Array.from({ length: 8 }, (_, i) => `chunk-${i}`));
const out = await runWithChatStub({
chunks,
concurrency: 3,
acquireSynopsisLease: async () => {
acquired++;
leaseActive++;
maxLeaseActive = Math.max(maxLeaseActive, leaseActive);
return acquired;
},
releaseSynopsisLease: async () => {
released++;
leaseActive--;
},
chat: async (opts) => {
active++;
maxActive = Math.max(maxActive, active);
try {
await delay(20, opts.abortSignal);
return chatSuccess(`Synopsis for ${extractChunk(opts)}`);
} finally {
active--;
}
},
});
expect(out.result.kind).toBe('success');
expect(maxActive).toBeGreaterThan(1);
expect(maxActive).toBeLessThanOrEqual(3);
expect(maxLeaseActive).toBeLessThanOrEqual(3);
expect(acquired).toBe(8);
expect(released).toBe(8);
expect(leaseActive).toBe(0);
});
test('one chunk failure aborts queued work and falls back at page level', async () => {
let started = 0;
const chunks = makeChunks(Array.from({ length: 9 }, (_, i) => `chunk-${i}`));
const out = await runWithChatStub({
chunks,
concurrency: 3,
chat: async (opts) => {
started++;
const chunk = extractChunk(opts);
if (chunk === 'chunk-0') return chatSuccess('');
await delay(30, opts.abortSignal);
return chatSuccess(`Synopsis for ${chunk}`);
},
});
expect(out.result.kind).toBe('page_fallback');
expect(started).toBeLessThanOrEqual(3);
});
test('fenced code chunks bypass synopsis calls and leases', async () => {
let chatCalls = 0;
let leaseCalls = 0;
const chunks: ChunkInput[] = [
{ chunk_index: 0, chunk_text: 'intro', chunk_source: 'compiled_truth' },
{ chunk_index: 1, chunk_text: 'const x = 1;', chunk_source: 'fenced_code' },
{ chunk_index: 2, chunk_text: 'outro', chunk_source: 'compiled_truth' },
];
const out = await runWithChatStub({
chunks,
concurrency: 3,
acquireSynopsisLease: async () => {
leaseCalls++;
},
releaseSynopsisLease: async () => {},
chat: async (opts) => {
chatCalls++;
return chatSuccess(`Synopsis for ${extractChunk(opts)}`);
},
});
expect(out.result.kind).toBe('success');
expect(chatCalls).toBe(2);
expect(leaseCalls).toBe(2);
expect(out.embedInputs[1]).toBe('const x = 1;');
});
test('abortSignal cancels in-flight and queued synopsis work promptly', async () => {
const controller = new AbortController();
let started = 0;
const chunks = makeChunks(Array.from({ length: 20 }, (_, i) => `chunk-${i}`));
const startedAt = Date.now();
const promise = runWithChatStub({
chunks,
concurrency: 4,
abortSignal: controller.signal,
chat: async (opts) => {
started++;
await delay(1000, opts.abortSignal);
return chatSuccess(`Synopsis for ${extractChunk(opts)}`);
},
});
setTimeout(() => controller.abort(), 20);
const out = await promise;
expect(out.result.kind).toBe('transient_error');
if (out.result.kind === 'transient_error') {
expect(out.result.cause).toBe('timeout');
}
expect(started).toBeLessThanOrEqual(4);
expect(Date.now() - startedAt).toBeLessThan(300);
});
});
function makeChunks(texts: string[]): ChunkInput[] {
return texts.map((text, i) => ({
chunk_index: i,
chunk_text: text,
chunk_source: 'compiled_truth',
}));
}
async function runWithChatStub(opts: {
chunks: ChunkInput[];
concurrency: number;
abortSignal?: AbortSignal;
delayForChunk?: (chunk: string) => number;
chat?: (opts: ChatOpts) => Promise<ChatResult>;
acquireSynopsisLease?: () => Promise<unknown>;
releaseSynopsisLease?: (lease?: unknown) => Promise<void>;
}) {
configureGateway({
embedding_model: 'openai:text-embedding-3-large',
embedding_dimensions: TEST_DIMS,
env: { OPENAI_API_KEY: 'sk-test' },
});
const embedInputs: string[][] = [];
__setEmbedTransportForTests(async ({ values }: any) => {
embedInputs.push([...values]);
return {
embeddings: values.map((_: string, i: number) =>
Array.from({ length: TEST_DIMS }, () => 0.001 + i * 0.001),
),
usage: { tokens: 0 },
} as any;
});
__setChatTransportForTests(opts.chat ?? (async (chatOpts) => {
const chunk = extractChunk(chatOpts);
await delay(opts.delayForChunk?.(chunk) ?? 1, chatOpts.abortSignal);
return chatSuccess(`Synopsis for ${chunk}`);
}));
const engine = makeServiceEngine(opts.chunks);
const result = await reembedPageWithContextualRetrieval({
engine,
pageSlug: 'wiki/concepts/concurrency-test',
sourceId: 'default',
globalMode: 'per_chunk_synopsis',
chunkConcurrency: opts.concurrency,
abortSignal: opts.abortSignal,
...(opts.acquireSynopsisLease && { acquireSynopsisLease: opts.acquireSynopsisLease }),
...(opts.releaseSynopsisLease && { releaseSynopsisLease: opts.releaseSynopsisLease }),
});
return {
result,
embedInputs: embedInputs.flat(),
embeddedChunks: engine.embeddedChunks as ChunkInput[],
};
}
function makeServiceEngine(chunks: ChunkInput[]) {
const engine: any = {
embeddedChunks: [] as ChunkInput[],
async getPage() {
return {
id: 1,
slug: 'wiki/concepts/concurrency-test',
source_id: 'default',
type: 'concept',
title: 'Concurrency Test',
compiled_truth: chunks.map((c) => c.chunk_text).join('\n\n'),
timeline: '',
frontmatter: {},
created_at: new Date('2026-01-01T00:00:00Z'),
updated_at: new Date('2026-01-01T00:00:00Z'),
deleted_at: null,
};
},
async executeRaw() {
return [{
id: 'default',
name: 'Default',
local_path: null,
last_commit: null,
last_sync_at: null,
config: {},
created_at: new Date('2026-01-01T00:00:00Z'),
contextual_retrieval_mode: null,
trust_frontmatter_overrides: false,
}];
},
async getChunks() {
return chunks;
},
async transaction(fn: (tx: any) => Promise<void>) {
await fn({
upsertChunks: async (_slug: string, embedded: ChunkInput[]) => {
engine.embeddedChunks = embedded;
},
updatePageContextualRetrievalState: async () => {},
});
},
async updatePageContextualRetrievalState() {},
};
return engine;
}
function extractChunk(opts: ChatOpts): string {
const content = String(opts.messages[0]?.content ?? '');
return content.match(/<chunk>\n([\s\S]*?)\n<\/chunk>/)?.[1] ?? '';
}
function chatSuccess(text: string): ChatResult {
return {
text,
blocks: [],
stopReason: 'end',
usage: {
input_tokens: 0,
output_tokens: 0,
cache_read_tokens: 0,
cache_creation_tokens: 0,
},
model: 'stub:chat',
providerId: 'stub',
};
}
function delay(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(abortError());
return;
}
const timer = setTimeout(resolve, ms);
signal?.addEventListener('abort', () => {
clearTimeout(timer);
reject(abortError());
}, { once: true });
});
}
function abortError(): Error {
const err = new Error('aborted');
err.name = 'AbortError';
return err;
}
+35
View File
@@ -0,0 +1,35 @@
/**
* Consistency guard: every cross-modal DEFAULT_SLOTS model must be listed
* in its recipe's chat touchpoint. `openai:gpt-4o` drifted out of the
* OpenAI recipe while remaining the slot-A default the gateway then
* rejected slot A ("not listed for OpenAI chat") on every install, and the
* 3-slot judge panel could never reach its 2-model quorum without a Google
* key, pinning every batch verdict at inconclusive (which the nightly
* quality probe surfaces as a doctor WARN).
*/
import { describe, expect, test } from 'bun:test';
import { DEFAULT_SLOTS } from '../src/core/cross-modal-eval/runner.ts';
import { getRecipe } from '../src/core/ai/recipes/index.ts';
import { splitProviderModelId } from '../src/core/model-id.ts';
describe('cross-modal DEFAULT_SLOTS ↔ recipe consistency', () => {
test('every default slot model is listed in its recipe chat touchpoint', () => {
for (const slot of DEFAULT_SLOTS) {
const { provider, model } = splitProviderModelId(slot.model);
expect(provider).not.toBeNull();
const recipe = getRecipe(provider!);
expect(recipe, `slot ${slot.id}: unknown recipe "${provider}"`).toBeDefined();
const chatModels = recipe!.touchpoints.chat?.models ?? [];
expect(
chatModels,
`slot ${slot.id}: "${model}" not listed for ${provider} chat — the judge slot can never run`,
).toContain(model);
}
});
test('slots span three distinct providers (uncorrelated blind spots)', () => {
const providers = new Set(DEFAULT_SLOTS.map(s => splitProviderModelId(s.model).provider));
expect(providers.size).toBe(3);
});
});
+80
View File
@@ -0,0 +1,80 @@
import { afterEach, describe, expect, test } from 'bun:test';
import {
chat,
configureGateway,
resetGateway,
} from '../src/core/ai/gateway.ts';
const originalFetch = globalThis.fetch;
function installDeepSeekResponse(message: Record<string, unknown>): void {
globalThis.fetch = (async () => {
const json = {
id: 'fake-deepseek-chatcmpl',
object: 'chat.completion',
created: 0,
model: 'deepseek-reasoner',
choices: [
{
index: 0,
message: {
role: 'assistant',
...message,
},
finish_reason: 'stop',
},
],
usage: { prompt_tokens: 3, completion_tokens: 5, total_tokens: 8 },
};
return new Response(JSON.stringify(json), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}) as unknown as typeof fetch;
}
async function runDeepSeekChat(): Promise<string> {
configureGateway({
chat_model: 'deepseek:deepseek-reasoner',
env: { DEEPSEEK_API_KEY: 'sk-deepseek-fake' },
});
const result = await chat({
model: 'deepseek:deepseek-reasoner',
messages: [{ role: 'user', content: 'summarize this chunk' }],
});
return result.text;
}
afterEach(() => {
globalThis.fetch = originalFetch;
resetGateway();
});
describe('DeepSeek reasoning_content compatibility fetch', () => {
test('empty content + reasoning_content promotes reasoning text into chat output', async () => {
installDeepSeekResponse({
content: '',
reasoning_content: 'The synopsis lives here.',
});
await expect(runDeepSeekChat()).resolves.toBe('The synopsis lives here.');
});
test('non-empty content + reasoning_content passes content through unchanged', async () => {
installDeepSeekResponse({
content: 'Use the final answer.',
reasoning_content: 'Do not duplicate this reasoning.',
});
await expect(runDeepSeekChat()).resolves.toBe('Use the final answer.');
});
test('empty content + empty reasoning_content stays empty without crashing', async () => {
installDeepSeekResponse({
content: '',
reasoning_content: '',
});
await expect(runDeepSeekChat()).resolves.toBe('');
});
});
+51
View File
@@ -0,0 +1,51 @@
/**
* Tests for computeConversationParserProbeHealthCheck the pure function
* behind doctor's conversation_parser_probe_health check, which replaced
* the v0.41.13.0 hardcoded "Skipped" stub when the autopilot wiring
* landed. Mirrors the branch coverage style of the quality-probe check.
*/
import { describe, expect, test } from 'bun:test';
import { computeConversationParserProbeHealthCheck } from '../src/commands/doctor.ts';
const ev = (outcome: string, reason?: string, ts = new Date().toISOString()) => ({
outcome,
ts,
...(reason !== undefined ? { reason } : {}),
});
describe('computeConversationParserProbeHealthCheck', () => {
test('disabled + no events → ok with paste-ready enable hint', () => {
const check = computeConversationParserProbeHealthCheck(false, []);
expect(check.status).toBe('ok');
expect(check.message).toContain('gbrain config set autopilot.conversation_parser_probe.enabled true');
});
test('enabled + no events yet → ok, next run by autopilot', () => {
const check = computeConversationParserProbeHealthCheck(true, []);
expect(check.status).toBe('ok');
expect(check.message).toContain('no probe events');
});
test('disabled flag but events exist (tokenmax mode-gate ran it) → events win over the hint', () => {
const check = computeConversationParserProbeHealthCheck(false, [ev('pass')]);
expect(check.status).toBe('ok');
expect(check.message).toContain('all pass');
});
test('any non-pass outcome in the window → warn, latest surfaced with reason', () => {
const check = computeConversationParserProbeHealthCheck(true, [
ev('pass'),
ev('adversarial_false_positive', '1 adversarial fixture(s) parsed to non-empty'),
]);
expect(check.status).toBe('warn');
expect(check.message).toContain('adversarial_false_positive');
expect(check.message).toContain('parsed to non-empty');
});
test('all pass → ok with run count', () => {
const check = computeConversationParserProbeHealthCheck(true, [ev('pass'), ev('pass')]);
expect(check.status).toBe('ok');
expect(check.message).toContain('2 probe run(s)');
});
});
+59
View File
@@ -76,6 +76,23 @@ const SEED_PAGES: SeedPage[] = [
body: 'example founder unrelated content for distraction',
embeddingDim: 50,
},
// v0.32.7 CJK branch (Postgres parity) fixtures — synthetic Korean text
// about a fictional "그리팅 서비스". Multi-hit page (3× 그리팅) must
// outrank the one-hit page under occurrence-count ranking on BOTH engines.
{
slug: 'concepts/greeting-service',
type: 'concept',
title: '그리팅 서비스 개요',
body: '그리팅 서비스는 가상의 인사 자동화 플랫폼입니다. 그리팅 봇이 아침마다 인사를 보냅니다. 오늘도 그리팅 팀은 새로운 인사말을 준비했습니다.',
embeddingDim: 33,
},
{
slug: 'notes/greeting-one-hit',
type: 'note',
title: '가상 메모',
body: '어제 회의에서 그리팅 이야기가 잠깐 나왔다. 나머지는 다른 주제였다.',
embeddingDim: 34,
},
];
async function seedEngine(eng: BrainEngine) {
@@ -141,6 +158,48 @@ describeBoth('Engine parity — Postgres vs PGLite', () => {
});
}
// v0.32.7 CJK branch (Postgres parity): websearch_to_tsquery('english')
// can't tokenize CJK, so both engines route CJK queries to the ILIKE +
// occurrence-count fallback. These pin the fallback's cross-engine
// contract with purely synthetic Korean fixtures.
test('CJK parity: searchKeyword("그리팅") finds the same pages, same top rank', async () => {
const pgResults = await pgEngine.searchKeyword('그리팅', { limit: 5 });
const pgliteResults = await pgliteEngine.searchKeyword('그리팅', { limit: 5 });
// Without the fallback the Postgres engine returned [] here (empty
// english tsquery) while PGLite matched — the exact drift this pins.
expect(pgResults.length).toBeGreaterThan(0);
expect(pgliteResults.length).toBeGreaterThan(0);
// Occurrence-count ranking: 3-hit page outranks 1-hit page on BOTH.
expect(pgResults[0].slug).toBe('concepts/greeting-service');
expect(pgliteResults[0].slug).toBe('concepts/greeting-service');
const pgSlugs = pgResults.map((r: SearchResult) => r.slug);
const pgliteSlugs = pgliteResults.map((r: SearchResult) => r.slug);
expect(pgSlugs).toContain('notes/greeting-one-hit');
expect(new Set(pgSlugs)).toEqual(new Set(pgliteSlugs));
});
test('CJK parity: chunk-grain searchKeywordChunks matches across engines', async () => {
const pgResults = await pgEngine.searchKeywordChunks('그리팅', { limit: 5 });
const pgliteResults = await pgliteEngine.searchKeywordChunks('그리팅', { limit: 5 });
expect(pgResults.length).toBeGreaterThan(0);
expect(pgliteResults.length).toBeGreaterThan(0);
expect(pgResults[0].slug).toBe(pgliteResults[0].slug);
});
test('CJK parity: LIKE-meta-char escape — literal % query does not wildcard-match', async () => {
// After escapeLikePattern, ILIKE looks for a literal `%` character,
// which no seeded fixture contains. Both engines must return empty
// rather than treating % as a wildcard.
const pgResults = await pgEngine.searchKeyword('100% 그리팅');
const pgliteResults = await pgliteEngine.searchKeyword('100% 그리팅');
expect(pgResults.length).toBe(0);
expect(pgliteResults.length).toBe(0);
});
test('searchVector: top result matches between engines', async () => {
const queryVec = basisEmbedding(7); // article direction
const pgResults = await pgEngine.searchVector(queryVec, { limit: 5 });
@@ -0,0 +1,280 @@
/**
* E2E: gateway-loop resume reconciliation (fix-wave A).
*
* The gateway-native subagent loop persists each assistant turn but historically
* never persisted the following tool-result user turn. A resumed job therefore
* reloaded assistant tool-calls with no matching tool-result, and non-Anthropic
* (openai-compat) providers reject that unbalanced history with
* AI_MissingToolResultsError dead-lettering the job. This wave:
* 1. forward-persists the tool-result user turn (onToolResultTurn), and
* 2. reconciles an already-corrupted transcript on resume by rebuilding the
* missing tool-result turns from settled subagent_tool_executions,
* re-dispatching idempotent-pending tools and throwing on non-idempotent.
*
* Hermetic: PGLite in-memory engine, gateway transport stubbed. Seeds use the
* sanctioned `$N::text::jsonb` positional bind (NEVER JSON.stringify into a
* bare `::jsonb`) so the seed is Postgres-safe too.
*
* Supersedes the resume/replay work in #1934 #2062 #2065 #2112 #2274 #2487
* #2802 #2336 #2257 #2499.
*/
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,
toModelMessages,
type ChatBlock,
type ChatMessage,
type ChatResult,
} from '../../src/core/ai/gateway.ts';
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
__setChatTransportForTests(null);
resetGateway();
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' },
});
});
async function makeJob(prompt: string, model: string): Promise<{ jobId: number; ctx: MinionJobContext }> {
const rows = await engine.executeRaw<{ id: number }>(
`INSERT INTO minion_jobs (name, status, data, queue, priority, created_at)
VALUES ('subagent', 'active', $1::text::jsonb, 'default', 0, now()) RETURNING id`,
[JSON.stringify({ prompt, model })],
);
const jobId = rows[0].id;
const ctx: MinionJobContext = {
id: jobId, name: 'subagent', data: { prompt, model }, attempts_made: 1,
signal: new AbortController().signal, shutdownSignal: new AbortController().signal,
updateProgress: async () => {}, updateTokens: async () => {}, log: async () => {},
isActive: async () => true, readInbox: async () => [],
};
return { jobId, ctx };
}
function makeTools(executions: string[]): ToolDef[] {
return [
{ name: 'search', description: 's', input_schema: { type: 'object' }, idempotent: true,
async execute(input: unknown, _c: ToolCtx) { executions.push('search'); return { results: ['fresh'] }; } },
{ name: 'put_page', description: 'p', input_schema: { type: 'object' }, idempotent: false,
async execute(_input: unknown, _c: ToolCtx) { executions.push('put_page'); return { saved: true }; } },
];
}
function buildHandler(toolRegistry: ToolDef[]) {
return makeSubagentHandler({
engine, config: {} as any, toolRegistry,
makeAnthropic: () => ({ messages: { create: async () => { throw new Error('legacy path unused'); } } }) as any,
});
}
async function seedMessage(jobId: number, idx: number, role: string, blocks: ChatBlock[]): Promise<void> {
await engine.executeRaw(
`INSERT INTO subagent_messages (job_id, message_idx, role, content_blocks, schema_version)
VALUES ($1, $2, $3, $4::text::jsonb, 2)`,
[jobId, idx, role, JSON.stringify(blocks)],
);
}
async function seedExec(jobId: number, msgIdx: number, toolUseId: string, name: string, status: string, output: unknown, ordinal: number, error?: string): Promise<void> {
await engine.executeRaw(
`INSERT INTO subagent_tool_executions
(job_id, message_idx, tool_use_id, tool_name, input, status, output, error, schema_version, ordinal)
VALUES ($1, $2, $3, $4, $5::text::jsonb, $6, $7::text::jsonb, $8, 2, $9)`,
[jobId, msgIdx, toolUseId, name, JSON.stringify({}), status, output == null ? null : JSON.stringify(output), error ?? null, ordinal],
);
}
/** Assert every assistant tool-call turn is answered by a following tool-result. */
function assertBalanced(messages: ChatMessage[]): void {
for (let i = 0; i < messages.length; i++) {
const m = messages[i];
if (m.role !== 'assistant' || typeof m.content === 'string') continue;
const calls = m.content.filter((b): b is Extract<ChatBlock, { type: 'tool-call' }> => b.type === 'tool-call');
if (calls.length === 0) continue;
const next = messages[i + 1];
expect(next, `assistant tool-call turn at ${i} must be followed by a tool-result turn`).toBeDefined();
const answered = new Set(
(typeof next!.content === 'string' ? [] : next!.content)
.filter((b): b is Extract<ChatBlock, { type: 'tool-result' }> => b.type === 'tool-result')
.map(b => b.toolCallId),
);
for (const c of calls) expect(answered.has(c.toolCallId), `tool-call ${c.toolCallId} unanswered`).toBe(true);
}
// The real provider path: this must not throw the ModelMessage schema error.
expect(() => toModelMessages(messages)).not.toThrow();
}
describe('gateway resume reconciliation', () => {
it('forward-persists the tool-result user turn (idx 2) in a 2-turn flow', async () => {
let turn = 0;
__setChatTransportForTests(async () => {
turn++;
if (turn === 1) return {
text: '', blocks: [{ type: 'tool-call', toolCallId: 'tc1', toolName: 'search', input: { q: 'x' } }] 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 { jobId, ctx } = await makeJob('go', 'anthropic:claude-sonnet-4-6');
await buildHandler(makeTools([]))(ctx);
const msgs = await engine.executeRaw<{ message_idx: number; role: string; content_blocks: unknown }>(
`SELECT message_idx, role, content_blocks FROM subagent_messages WHERE job_id = $1 ORDER BY message_idx`, [jobId]);
expect(msgs.map(m => [m.message_idx, m.role])).toEqual([[0, 'user'], [1, 'assistant'], [2, 'user'], [3, 'assistant']]);
const toolResultTurn = typeof msgs[2].content_blocks === 'string' ? JSON.parse(msgs[2].content_blocks as string) : msgs[2].content_blocks;
expect((toolResultTurn as any[])[0].type).toBe('tool-result');
expect((toolResultTurn as any[])[0].toolCallId).toBe('tc1');
});
it('self-heals a pre-fix corrupted job from the stored output (no re-execute), transcript balanced', async () => {
const { jobId, ctx } = await makeJob('resume me', 'openai:gpt-4o'); // non-Anthropic: strict pairing
// Corrupted pre-fix state: seed user + assistant(tool-call), a complete
// exec row, but NO tool-result user turn at idx 2.
await seedMessage(jobId, 0, 'user', [{ type: 'text', text: 'resume me' }]);
await seedMessage(jobId, 1, 'assistant', [{ type: 'tool-call', toolCallId: 'prov-tc-1', toolName: 'search', input: { q: 'x' } }]);
await seedExec(jobId, 1, 'prov-tc-1', 'search', 'complete', { results: ['from-prior-run'] }, 0);
let captured: ChatMessage[] = [];
__setChatTransportForTests(async (opts) => {
captured = opts.messages;
return {
text: 'recovered and done', blocks: [{ type: 'text', text: 'recovered and done' }] as ChatBlock[],
stopReason: 'end', usage: { input_tokens: 1, output_tokens: 1, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'openai:gpt-4o', providerId: 'openai',
} satisfies ChatResult;
});
const executions: string[] = [];
const result = await buildHandler(makeTools(executions))(ctx);
expect(result.result).toBe('recovered and done');
expect(executions.length).toBe(0); // stored output reused, tool NOT re-run
assertBalanced(captured);
// The healed tool-result carries the real stored output.
const healed = (captured[2].content as ChatBlock[])[0] as Extract<ChatBlock, { type: 'tool-result' }>;
expect(healed.output).toEqual({ results: ['from-prior-run'] });
// Durably persisted so the next resume stays balanced.
const msgs = await engine.executeRaw<{ message_idx: number; role: string }>(
`SELECT message_idx, role FROM subagent_messages WHERE job_id = $1 ORDER BY message_idx`, [jobId]);
expect(msgs.map(m => [m.message_idx, m.role])).toEqual([[0, 'user'], [1, 'assistant'], [2, 'user'], [3, 'assistant']]);
});
it('heals MULTIPLE consecutive dangling assistant turns (pre-fix multi-turn corruption)', async () => {
const { jobId, ctx } = await makeJob('multi', 'openai:gpt-4o');
// Pre-fix loop persisted assistants at 1 and 3 (gaps at 2 = skipped user idx).
await seedMessage(jobId, 0, 'user', [{ type: 'text', text: 'multi' }]);
await seedMessage(jobId, 1, 'assistant', [{ type: 'tool-call', toolCallId: 'tc-a', toolName: 'search', input: {} }]);
await seedExec(jobId, 1, 'tc-a', 'search', 'complete', { results: ['a'] }, 0);
await seedMessage(jobId, 3, 'assistant', [{ type: 'tool-call', toolCallId: 'tc-b', toolName: 'search', input: {} }]);
await seedExec(jobId, 3, 'tc-b', 'search', 'complete', { results: ['b'] }, 0);
let captured: ChatMessage[] = [];
__setChatTransportForTests(async (opts) => {
captured = opts.messages;
return { text: 'ok', blocks: [{ type: 'text', text: 'ok' }] as ChatBlock[], stopReason: 'end',
usage: { input_tokens: 1, output_tokens: 1, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'openai:gpt-4o', providerId: 'openai' } satisfies ChatResult;
});
const executions: string[] = [];
await buildHandler(makeTools(executions))(ctx);
expect(executions.length).toBe(0);
assertBalanced(captured);
// Both dangling turns healed and persisted (idx 2 and 4).
const msgs = await engine.executeRaw<{ message_idx: number; role: string }>(
`SELECT message_idx, role FROM subagent_messages WHERE job_id = $1 ORDER BY message_idx`, [jobId]);
expect(msgs.map(m => m.message_idx)).toContain(2);
expect(msgs.map(m => m.message_idx)).toContain(4);
});
it('re-dispatches an idempotent tool that was still pending on resume', async () => {
const { jobId, ctx } = await makeJob('redispatch', 'openai:gpt-4o');
await seedMessage(jobId, 0, 'user', [{ type: 'text', text: 'redispatch' }]);
await seedMessage(jobId, 1, 'assistant', [{ type: 'tool-call', toolCallId: 'tc-pending', toolName: 'search', input: {} }]);
await seedExec(jobId, 1, 'tc-pending', 'search', 'pending', null, 0);
__setChatTransportForTests(async () => ({ text: 'ok', blocks: [{ type: 'text', text: 'ok' }] as ChatBlock[], stopReason: 'end',
usage: { input_tokens: 1, output_tokens: 1, cache_read_tokens: 0, cache_creation_tokens: 0 }, model: 'openai:gpt-4o', providerId: 'openai' } satisfies ChatResult));
const executions: string[] = [];
await buildHandler(makeTools(executions))(ctx);
expect(executions).toEqual(['search']); // idempotent-pending re-executed once
});
it('throws on a non-idempotent tool still pending on resume', async () => {
const { jobId, ctx } = await makeJob('unsafe', 'openai:gpt-4o');
await seedMessage(jobId, 0, 'user', [{ type: 'text', text: 'unsafe' }]);
await seedMessage(jobId, 1, 'assistant', [{ type: 'tool-call', toolCallId: 'tc-mut', toolName: 'put_page', input: {} }]);
await seedExec(jobId, 1, 'tc-mut', 'put_page', 'pending', null, 0);
__setChatTransportForTests(async () => ({ text: '', blocks: [] as ChatBlock[], stopReason: 'end',
usage: { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_creation_tokens: 0 }, model: 'openai:gpt-4o', providerId: 'openai' } satisfies ChatResult));
await expect(buildHandler(makeTools([]))(ctx)).rejects.toThrow(/non-idempotent tool "put_page" pending on resume/i);
});
it('error-stubs a dangling tool-call whose tool is no longer registered', async () => {
const { jobId, ctx } = await makeJob('gone tool', 'openai:gpt-4o');
await seedMessage(jobId, 0, 'user', [{ type: 'text', text: 'gone tool' }]);
await seedMessage(jobId, 1, 'assistant', [{ type: 'tool-call', toolCallId: 'tc-gone', toolName: 'removed_tool', input: {} }]);
// No exec row and the tool isn't in the registry (only 'search'/'put_page').
let captured: ChatMessage[] = [];
__setChatTransportForTests(async (opts) => {
captured = opts.messages;
return { text: 'handled', blocks: [{ type: 'text', text: 'handled' }] as ChatBlock[], stopReason: 'end',
usage: { input_tokens: 1, output_tokens: 1, cache_read_tokens: 0, cache_creation_tokens: 0 }, model: 'openai:gpt-4o', providerId: 'openai' } satisfies ChatResult;
});
const result = await buildHandler(makeTools([]))(ctx);
expect(result.result).toBe('handled');
assertBalanced(captured);
const stub = (captured[2].content as ChatBlock[])[0] as Extract<ChatBlock, { type: 'tool-result' }>;
expect(stub.isError).toBe(true);
expect(String(stub.output)).toContain('removed_tool');
// Persisted as a failed exec so the next resume is stable.
const rows = await engine.executeRaw<{ status: string }>(
`SELECT status FROM subagent_tool_executions WHERE job_id = $1 AND tool_use_id = 'tc-gone'`, [jobId]);
expect(rows[0].status).toBe('failed');
});
it('terminal resume: a completed transcript returns its text without calling the model', async () => {
const { jobId, ctx } = await makeJob('already done', 'openai:gpt-4o');
await seedMessage(jobId, 0, 'user', [{ type: 'text', text: 'already done' }]);
await seedMessage(jobId, 1, 'assistant', [{ type: 'text', text: 'the final answer' }]);
let chatCalls = 0;
__setChatTransportForTests(async () => { chatCalls++; return { text: 'SHOULD NOT RUN', blocks: [] as ChatBlock[], stopReason: 'end',
usage: { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_creation_tokens: 0 }, model: 'openai:gpt-4o', providerId: 'openai' } satisfies ChatResult; });
const result = await buildHandler(makeTools([]))(ctx);
expect(chatCalls).toBe(0);
expect(result.result).toBe('the final answer');
expect(result.stop_reason).toBe('end_turn');
});
});
+12 -8
View File
@@ -15,7 +15,7 @@
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { configureGateway } from '../src/core/ai/gateway.ts';
import { configureGateway, resetGateway } from '../src/core/ai/gateway.ts';
import {
detectRegressions,
computeDriftScore,
@@ -27,16 +27,19 @@ import type { TrajectoryPoint } from '../src/core/engine.ts';
let engine: PGLiteEngine;
beforeAll(async () => {
// This file hardcodes 1536-d vectors (vecForMetric). initSchema sizes the
// facts halfvec column from the AMBIENT gateway state, and a beforeAll runs
// before the legacy-embedding-preload's per-test restore — so a preceding
// file in the shard that left the gateway reset or non-1536 would seed a
// 1280-d schema and every insert here would fail with a width mismatch.
// Pin the schema shape explicitly (the pattern bunfig's preload documents).
// Pin the embedding dim to 1536 BEFORE initSchema. This file hardcodes
// Float32Array(1536) vectors, but initSchema sizes vector columns from
// process-global gateway state (getEmbeddingDimensions(), default 1280 =
// zeroentropyai). Whether this file passes therefore depended on which
// test files happened to run before it in the shard: a predecessor that
// leaves the gateway configured without dims (or a bare CI env) yields
// vector(1280) and every insert here dies with "expected 1280 dimensions,
// not 1536". Same fix + rationale as cosine-rescore-column.test.ts, which
// documents this exact class.
configureGateway({
embedding_model: 'openai:text-embedding-3-large',
embedding_dimensions: 1536,
env: { ...process.env },
env: { OPENAI_API_KEY: 'sk-test-find-trajectory' },
});
engine = new PGLiteEngine();
await engine.connect({});
@@ -45,6 +48,7 @@ beforeAll(async () => {
afterAll(async () => {
await engine.disconnect();
resetGateway();
});
beforeEach(async () => {
@@ -0,0 +1,39 @@
/**
* Pack-driven extractable-type allowlist (unionExtractableTypes): honors the
* schema-pack manifest's `extractable: true` flags while preserving the legacy
* hardcoded floor and excluding synthesis outputs. Closes the D2 TODO in
* extract-atoms.ts (page discovery was ignoring the pack's extractable flag, so
* a type declared extractable e.g. `note` never actually extracted).
*/
import { describe, test, expect } from 'bun:test';
import { unionExtractableTypes } from '../src/core/cycle/extract-atoms.ts';
const LEGACY = ['meeting', 'source', 'article', 'video', 'book', 'original'];
describe('unionExtractableTypes', () => {
test('legacy floor is always present (back-compat)', () => {
const r = unionExtractableTypes([]);
for (const t of LEGACY) expect(r).toContain(t);
});
test('pack-declared extractable types are added (e.g. note)', () => {
const r = unionExtractableTypes(['note', 'writing']);
expect(r).toContain('note');
expect(r).toContain('writing');
for (const t of LEGACY) expect(r).toContain(t);
});
test('synthesis outputs are excluded even when the pack marks them extractable', () => {
// gbrain-base declares `concept` extractable:true, but extracting atoms FROM
// concepts would loop (concepts are synthesized from atoms).
const r = unionExtractableTypes(['note', 'concept', 'atom']);
expect(r).toContain('note');
expect(r).not.toContain('concept');
expect(r).not.toContain('atom');
});
test('no duplicates when the pack repeats a legacy type', () => {
const r = unionExtractableTypes(['meeting', 'source']);
expect(r.filter((t) => t === 'meeting')).toHaveLength(1);
});
});
+8 -4
View File
@@ -108,12 +108,15 @@ async function seedPage(opts: {
}
describe('v0.41.2.1: discoverExtractablePages SQL contract', () => {
test('filters by all 6 extractable types', async () => {
for (const type of ['meeting', 'source', 'article', 'video', 'book', 'original']) {
test('discovers legacy + pack-extractable types, excludes synthesis outputs', async () => {
// Legacy floor + `note` (declared extractable:true in gbrain-base, now
// honored via the pack manifest — the D2 fix).
for (const type of ['meeting', 'source', 'article', 'video', 'book', 'original', 'note']) {
await seedPage({ slug: `${type}/x`, type });
}
// Add a non-extractable page that should NOT appear
await seedPage({ slug: 'notes/skip-me', type: 'note' });
// `concept` is also extractable:true in gbrain-base, but extracting atoms
// FROM concepts would loop — synthesis outputs are always excluded.
await seedPage({ slug: 'wiki/concepts/skip-me', type: 'concept' });
const discovered = await discoverExtractablePages(engine, 'default');
const slugs = discovered.map((d) => d.slug).sort();
@@ -121,6 +124,7 @@ describe('v0.41.2.1: discoverExtractablePages SQL contract', () => {
'article/x',
'book/x',
'meeting/x',
'note/x',
'original/x',
'source/x',
'video/x',
+57
View File
@@ -107,6 +107,63 @@ describe('toModelMessages — v6 ModelMessage shape', () => {
]);
});
test('Date in tool-result json output serializes to ISO string (Postgres timestamptz)', () => {
// node-postgres returns timestamptz columns as JS Date; AI SDK v6's
// JSONValue schema rejects a raw Date, dead-lettering the tool loop.
const msgs: ChatMessage[] = [
{
role: 'user',
content: [{
type: 'tool-result',
toolCallId: 'c1',
toolName: 'brain_get_page',
output: { rows: [{ updated_at: new Date('2026-06-26T06:56:59.000Z'), nested: { created_at: new Date('2026-01-02T03:04:05.000Z') } }] },
}],
},
];
const out = toModelMessages(msgs) as any[];
const value = out[0].content[0].output.value;
expect(out[0].content[0].output.type).toBe('json');
expect(value.rows[0].updated_at).toBe('2026-06-26T06:56:59.000Z');
expect(value.rows[0].nested.created_at).toBe('2026-01-02T03:04:05.000Z');
// No Date instance survives (would throw in AI SDK v6).
expect(value.rows[0].updated_at instanceof Date).toBe(false);
});
test('non-string text block is dropped (reasoning-model null-text guard)', () => {
// DeepSeek v4 / reasoning models can emit text:null/undefined thinking
// parts; AI SDK v6 rejects them. Dropped here; tool-call sibling kept.
const msgs: ChatMessage[] = [
{
role: 'assistant',
content: [
{ type: 'text', text: null as unknown as string },
{ type: 'text', text: undefined as unknown as string },
{ type: 'text', text: 'kept' },
{ type: 'text', text: '' }, // empty string is valid — kept
{ type: 'tool-call', toolCallId: 'c1', toolName: 'search', input: {} },
],
},
];
const out = toModelMessages(msgs) as any[];
expect(out[0].content).toEqual([
{ type: 'text', text: 'kept' },
{ type: 'text', text: '' },
{ type: 'tool-call', toolCallId: 'c1', toolName: 'search', input: {} },
]);
});
test('errored tool-result never throws on circular/bigint output (safeStringify)', () => {
const circular: any = {};
circular.self = circular;
const msgs: ChatMessage[] = [
{ role: 'user', content: [{ type: 'tool-result', toolCallId: 'c1', toolName: 'x', output: circular, isError: true }] },
];
const out = toModelMessages(msgs) as any[];
expect(out[0].content[0].output.type).toBe('error-text');
expect(typeof out[0].content[0].output.value).toBe('string');
});
test('full multi-turn conversation: user → assistant(tool-call) → tool(result)', () => {
const msgs: ChatMessage[] = [
{ role: 'user', content: 'find widget' },
+7
View File
@@ -354,6 +354,13 @@ describe('MinionQueue: #1737 per-handler default timeout', () => {
expect(sub.timeout_ms).toBe(30 * 60 * 1000);
});
test('contextual per-chunk reindex gets the 60-min default', async () => {
const job = await queue.add('contextual_reindex_per_chunk', { page_slug: 'large-transcript' }, undefined, {
allowProtectedSubmit: true,
});
expect(job.timeout_ms).toBe(60 * 60 * 1000);
});
test('explicit timeout_ms always wins over the default', async () => {
const job = await queue.add('embed-backfill', { sourceId: 'x' }, { timeout_ms: 5000 });
expect(job.timeout_ms).toBe(5000);
+74
View File
@@ -0,0 +1,74 @@
// Regression test for the nightly-quality-probe config-plane split-brain.
//
// The doctor check prints a paste-ready enable hint — `gbrain config set
// autopilot.nightly_quality_probe.enabled true` — which writes the DB config
// plane. But both the autopilot gate and the doctor check used to read ONLY
// the file plane (~/.gbrain/config.json via loadConfig), so following the
// hint was a silent no-op: the probe never ran and doctor kept reporting
// "disabled (opt-in)".
//
// resolveProbeEnabled / resolveProbeMaxUsd pin the dual-plane rule (same
// precedent as `mcp.publish_skills` in serve-http.ts): DB row wins when
// present, file plane is the fallback.
import { describe, expect, test } from 'bun:test';
import {
resolveProbeEnabled,
resolveProbeMaxUsd,
} from '../src/core/cycle/nightly-quality-probe.ts';
describe('resolveProbeEnabled — dual-plane flag resolution', () => {
test('DB plane "true" enables regardless of file plane (the doctor hint path)', () => {
expect(resolveProbeEnabled('true', undefined)).toBe(true);
expect(resolveProbeEnabled('true', false)).toBe(true);
});
test('explicit DB "false" wins over file-plane true (config set off sticks)', () => {
expect(resolveProbeEnabled('false', true)).toBe(false);
});
test('file plane is the fallback when no DB row exists', () => {
expect(resolveProbeEnabled(null, true)).toBe(true);
expect(resolveProbeEnabled(undefined, true)).toBe(true);
expect(resolveProbeEnabled(null, undefined)).toBe(false);
expect(resolveProbeEnabled(null, false)).toBe(false);
});
test('file plane stays strict boolean — string "true" in config.json does not enable', () => {
// Matches the pre-fix autopilot gate (`=== true`); the doctor check used
// Boolean(...) and could disagree with autopilot on a string value.
// Both call sites now share this helper, so they can no longer diverge.
expect(resolveProbeEnabled(null, 'true')).toBe(false);
expect(resolveProbeEnabled(null, 1)).toBe(false);
});
test('non-"true" DB strings are off (mcp.publish_skills semantics)', () => {
expect(resolveProbeEnabled('1', true)).toBe(false);
expect(resolveProbeEnabled('yes', true)).toBe(false);
expect(resolveProbeEnabled('', true)).toBe(false);
});
});
describe('resolveProbeMaxUsd — dual-plane cost cap resolution', () => {
test('DB plane wins when parseable', () => {
expect(resolveProbeMaxUsd('2.5', 10)).toBe(2.5);
expect(resolveProbeMaxUsd('0', 10)).toBe(0);
});
test('malformed or negative DB value falls through to file plane', () => {
expect(resolveProbeMaxUsd('banana', 3)).toBe(3);
expect(resolveProbeMaxUsd('-1', 3)).toBe(3);
});
test('file plane used when no DB row; default when both absent/invalid', () => {
expect(resolveProbeMaxUsd(null, 7)).toBe(7);
expect(resolveProbeMaxUsd(null, '4')).toBe(4);
expect(resolveProbeMaxUsd(null, undefined)).toBe(5);
expect(resolveProbeMaxUsd(null, 'banana')).toBe(5);
expect(resolveProbeMaxUsd(undefined, -2)).toBe(5);
});
test('explicit fallback override is honored', () => {
expect(resolveProbeMaxUsd(null, undefined, 12)).toBe(12);
});
});
+7 -4
View File
@@ -132,17 +132,20 @@ describe('runNightlyQualityProbe (DI stub harness)', () => {
});
});
test('enabled + recent run within 24h → outcome: rate_limited', async () => {
test('enabled + recent run within 24h → outcome: rate_limited, NO audit row', async () => {
// Pre-seed a recent audit event by running the probe once first.
await withEnv({ GBRAIN_AUDIT_DIR: auditTmp }, async () => {
// First run succeeds.
await runNightlyQualityProbe(makeDeps());
// Second run, same hour → rate_limited.
// Second run, same hour → rate_limited. A skip is a non-event: the
// autopilot loop invokes the probe every cycle (~5-10 min), so
// logging each skip would flood the audit file and flip doctor's
// any-non-pass-is-bad filter to a permanent WARN.
const r2 = await runNightlyQualityProbe(makeDeps());
expect(r2.outcome).toBe('rate_limited');
const events = await readEvents();
expect(events.length).toBe(2);
expect(events[1].outcome).toBe('rate_limited');
expect(events.length).toBe(1);
expect(events[0].outcome).toBe('pass');
});
});
+63
View File
@@ -94,12 +94,75 @@ describe('postgres-engine / search path timeout isolation', () => {
});
});
// v0.32.7 CJK branch (Postgres parity) guardrails. Live-DB coverage of the
// fallback lives in test/e2e/engine-parity.test.ts (Korean parity case);
// this block stays DB-free and locks in the source-level invariants that
// make the port faithful to pglite-engine.ts `_searchKeywordCJK`.
describe('postgres-engine / CJK keyword fallback (v0.32.7 parity)', () => {
test('searchKeyword routes CJK queries to _searchKeywordCJK', () => {
const fn = stripComments(extractMethod(SRC, 'searchKeyword'));
expect(fn).toMatch(/if\s*\(\s*hasCJK\s*\(\s*query\s*\)\s*\)/);
expect(fn).toMatch(/this\._searchKeywordCJK\s*\(/);
});
test('searchKeywordChunks routes CJK queries to _searchKeywordCJK (chunk-grain, no dedup)', () => {
const fn = stripComments(extractMethod(SRC, 'searchKeywordChunks'));
expect(fn).toMatch(/if\s*\(\s*hasCJK\s*\(\s*query\s*\)\s*\)/);
expect(fn).toMatch(/this\._searchKeywordCJK\s*\(/);
expect(fn).toMatch(/dedup:\s*false/);
});
test('_searchKeywordCJK matches via ILIKE with explicit ESCAPE, never tsquery', () => {
const fn = extractCJKHelper(SRC);
// ILIKE '%' || $1 || '%' ESCAPE '\' — the escaped-pattern binding.
expect(fn).toMatch(/ILIKE\s+'%'\s*\|\|\s*\$1\s*\|\|\s*'%'\s+ESCAPE/);
// The fallback must not route back through the english tokenizer it
// exists to bypass.
expect(stripComments(fn)).not.toMatch(/websearch_to_tsquery/);
});
test('_searchKeywordCJK keeps the two-binding discipline (qLike escaped, qRaw for ranking)', () => {
const fn = stripComments(extractCJKHelper(SRC));
// $1 comes from escapeLikePattern; $2 is the raw string used by the
// REPLACE/POSITION occurrence-count scoring. Escaped chars cannot be
// reused as ranking substrings (codex outside-voice C8).
expect(fn).toMatch(/escapeLikePattern\s*\(\s*qRaw\s*\)/);
expect(fn).toMatch(/REPLACE\(cc\.chunk_text,\s*\$2/);
expect(fn).toMatch(/POSITION\(\$2\s+IN\s+cc\.chunk_text\)/);
});
test('_searchKeywordCJK honors the pool-safety contract (sql.begin + SET LOCAL)', () => {
const fn = extractCJKHelper(SRC);
expect(fn).toMatch(/sql\.begin\s*\(\s*async\s+sql\s*=>/);
expect(fn).toMatch(/SET\s+LOCAL\s+statement_timeout/);
expect(stripComments(fn)).not.toMatch(/SET\s+statement_timeout\s*=\s*['"]?0/);
});
test('_searchKeywordCJK seals source-isolation on the fallback path (#861 parity)', () => {
const fn = stripComments(extractCJKHelper(SRC));
expect(fn).toMatch(/p\.source_id = ANY\(\$\$\{params\.length\}::text\[\]\)/);
expect(fn).toMatch(/opts\?\.sourceId/);
});
});
function stripComments(s: string): string {
return s
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/(^|\s)\/\/[^\n]*/g, '$1');
}
// _searchKeywordCJK's signature contains an object-type literal (the ctx
// param), so extractMethod's first-brace matcher would stop at the type.
// Slice from the declaration to the method's 2-space-indent closing brace
// instead (nested blocks all sit deeper).
function extractCJKHelper(source: string): string {
const start = source.indexOf('private async _searchKeywordCJK(');
if (start < 0) throw new Error('_searchKeywordCJK not found in postgres-engine.ts');
const end = source.indexOf('\n }', start);
if (end < 0) throw new Error('no closing brace for _searchKeywordCJK');
return source.slice(start, end + 4);
}
// extractMethod grabs the body of a class method by brace-matching from
// its opening line. Returns the method body up to the matching closing
// brace. Good enough for the small number of methods in this file.
+25 -1
View File
@@ -7,7 +7,7 @@
* the rule can't drift without the suite catching it.
*/
import { describe, test, expect } from 'bun:test';
import { resolveBootstrapToken } from '../src/commands/serve-http.ts';
import { resolveBootstrapToken, shouldSuppressBootstrapPrint } from '../src/commands/serve-http.ts';
describe('resolveBootstrapToken (v0.36.1.x #1024)', () => {
test('unset env → generates a fresh token via the injected RNG', () => {
@@ -72,3 +72,27 @@ describe('resolveBootstrapToken (v0.36.1.x #1024)', () => {
expect(r.kind).toBe('error');
});
});
describe('shouldSuppressBootstrapPrint (#2624 log-leak default)', () => {
const base = { suppress: false, fromEnv: false, forcePrint: false, isTty: true };
test('generated token on non-TTY (container) → hidden by default', () => {
expect(shouldSuppressBootstrapPrint({ ...base, isTty: false })).toBe(true);
});
test('generated token on interactive TTY → printed', () => {
expect(shouldSuppressBootstrapPrint({ ...base, isTty: true })).toBe(false);
});
test('--print-admin-token forces raw value on non-TTY', () => {
expect(shouldSuppressBootstrapPrint({ ...base, isTty: false, forcePrint: true })).toBe(false);
});
test('env-sourced token is never printed', () => {
expect(shouldSuppressBootstrapPrint({ ...base, fromEnv: true, isTty: true })).toBe(true);
});
test('--suppress overrides even a forced print', () => {
expect(shouldSuppressBootstrapPrint({ ...base, suppress: true, forcePrint: true, isTty: true })).toBe(true);
});
});
+28
View File
@@ -0,0 +1,28 @@
/**
* Pins `maxOutputTokensFor` the per-model output-token budget `runThink`
* passes to `client.create`. Thinking-by-default Claude 5 models
* (`anthropic:claude-*-5`) spend a large share of the budget on internal
* reasoning before emitting an answer, so the 4000 default left `think` with
* empty/truncated text. They now get 16000; everything else stays 4000.
*/
import { describe, test, expect } from 'bun:test';
import { maxOutputTokensFor } from '../src/core/think/index.ts';
describe('maxOutputTokensFor — thinking-default headroom', () => {
test('Claude 5 family gets 16000', () => {
expect(maxOutputTokensFor('anthropic:claude-sonnet-5')).toBe(16000);
expect(maxOutputTokensFor('anthropic:claude-opus-5')).toBe(16000);
expect(maxOutputTokensFor('anthropic:claude-fable-5')).toBe(16000);
expect(maxOutputTokensFor('anthropic:claude-haiku-5')).toBe(16000);
expect(maxOutputTokensFor('anthropic/claude-sonnet-5')).toBe(16000); // slash form
});
test('non-Claude-5 and non-Anthropic keep 4000', () => {
expect(maxOutputTokensFor('anthropic:claude-opus-4-8')).toBe(4000);
expect(maxOutputTokensFor('anthropic:claude-haiku-4-5')).toBe(4000);
expect(maxOutputTokensFor('anthropic:claude-sonnet-4-6')).toBe(4000);
expect(maxOutputTokensFor('anthropic:claude-3-haiku')).toBe(4000);
expect(maxOutputTokensFor('openai:gpt-4o')).toBe(4000);
expect(maxOutputTokensFor('deepseek:deepseek-reasoner')).toBe(4000);
});
});
+70
View File
@@ -0,0 +1,70 @@
import { describe, expect, test } from 'bun:test';
import type { TrajectoryPoint } from '../src/core/engine.ts';
import {
DEFAULT_REGRESSION_THRESHOLD,
detectRegressions,
} from '../src/core/trajectory.ts';
function point(args: {
id: number;
metric?: string;
value: number;
date: string;
}): TrajectoryPoint {
return {
fact_id: args.id,
valid_from: new Date(args.date),
metric: args.metric ?? 'net_income',
value: args.value,
unit: 'USD',
period: 'monthly',
event_type: null,
text: `${args.metric ?? 'net_income'} = ${args.value}`,
source_session: null,
source_markdown_slug: null,
embedding: null,
};
}
describe('detectRegressions', () => {
test('keeps existing positive-valued drop behavior', () => {
const regs = detectRegressions([
point({ id: 1, metric: 'mrr', value: 200000, date: '2026-01-01' }),
point({ id: 2, metric: 'mrr', value: 150000, date: '2026-02-01' }),
], DEFAULT_REGRESSION_THRESHOLD);
expect(regs).toHaveLength(1);
expect(regs[0]).toMatchObject({
metric: 'mrr',
from_value: 200000,
to_value: 150000,
});
expect(regs[0].delta_pct).toBeCloseTo(-0.25, 4);
});
test('does not flag a negative-valued metric improving toward zero', () => {
const regs = detectRegressions([
point({ id: 1, value: -1000, date: '2026-01-01' }),
point({ id: 2, value: -500, date: '2026-02-01' }),
], DEFAULT_REGRESSION_THRESHOLD);
expect(regs).toEqual([]);
});
test('flags a negative-valued metric worsening away from zero', () => {
const regs = detectRegressions([
point({ id: 1, value: -500, date: '2026-01-01' }),
point({ id: 2, value: -1000, date: '2026-02-01' }),
], DEFAULT_REGRESSION_THRESHOLD);
expect(regs).toHaveLength(1);
expect(regs[0]).toMatchObject({
metric: 'net_income',
from_value: -500,
to_value: -1000,
from_date: '2026-01-01',
to_date: '2026-02-01',
});
expect(regs[0].delta_pct).toBeCloseTo(-1.0, 4);
});
});