mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 00:48:18 +00:00
Four verified-open issues in the subagent-orchestration family, one PR: - #1594: dream synthesize subagent job/wait timeouts promoted from hardcoded 30/35-min constants to config keys dream.synthesize.subagent_timeout_ms / subagent_wait_timeout_ms. Approach ported from stale PR #1596 (credit @ai920wisco). - #2778: add_timeline_entry joins the subagent brain-tool allowlist, fenced fail-closed by the shared enforceSubagentSlugFence (extracted from put_page's inline check — same trusted-workspace allow-list / wiki/agents/<id>/ namespace policy). The per-turn output cap is now resolveMaxOutputTokens (data.max_tokens → agent.max_output_tokens → 8192, was hardcoded 4096); a max_tokens stop surfaces as stop_reason 'max_tokens' instead of a silent end_turn, and a mid-tool-round cap hit injects a truncation note so the model re-issues the dropped call. - #2782: patterns phase status now reflects the child outcome — non-complete outcome with zero writes → fail (PATTERNS_CHILD_<OUTCOME>), partial writes → warn. Patterns timeouts get the same config-key pair (dream.patterns.subagent_timeout_ms / subagent_wait_timeout_ms). - #2113: facts extraction cap is config facts.extraction_max_tokens (default 4000, was hardcoded 1500); stopReason 'length' is checked, retried once at 2x the cap, and surfaced on stderr instead of silently extracting zero facts. Co-authored-by: Sinabina <sinabina@Sinabinas-MacBook-Pro-4.local> Co-authored-by: ai920wisco <ai920wisco@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
133 lines
4.6 KiB
TypeScript
133 lines
4.6 KiB
TypeScript
/**
|
|
* #2113 — facts extraction must not silently extract zero facts on truncation.
|
|
*
|
|
* Pre-fix, extractFactsFromTurn hardcoded maxTokens:1500 and never checked
|
|
* the finish reason. Mandatory-reasoning models spend thinking tokens inside
|
|
* the same cap, so the JSON payload got cut off, parse failed, and extraction
|
|
* returned [] with no signal.
|
|
*
|
|
* Post-fix: the cap is configurable (`facts.extraction_max_tokens`, default
|
|
* 4000), a stopReason:'length' response is retried once at double the cap,
|
|
* and a still-truncated retry is surfaced on stderr.
|
|
*
|
|
* Uses the gateway chat-transport test seam — no API key, no network.
|
|
*/
|
|
import { afterAll, describe, test, expect, beforeEach } from 'bun:test';
|
|
import {
|
|
configureGateway,
|
|
resetGateway,
|
|
__setChatTransportForTests,
|
|
} from '../src/core/ai/gateway.ts';
|
|
import type { ChatOpts, ChatResult } from '../src/core/ai/gateway.ts';
|
|
import {
|
|
extractFactsFromTurn,
|
|
getFactsExtractionMaxTokens,
|
|
DEFAULT_EXTRACTION_MAX_TOKENS,
|
|
} from '../src/core/facts/extract.ts';
|
|
import type { BrainEngine } from '../src/core/engine.ts';
|
|
|
|
beforeEach(() => {
|
|
resetGateway();
|
|
__setChatTransportForTests(null);
|
|
configureGateway({
|
|
chat_model: 'anthropic:claude-sonnet-4-6',
|
|
env: { ANTHROPIC_API_KEY: 'sk-ant-test' },
|
|
});
|
|
});
|
|
|
|
// Shard hygiene (same rationale as facts-extract-silent-no-op.test.ts):
|
|
// restore the legacy 1536-d embedding pin so later fresh-schema files in
|
|
// this shard don't inherit a dimensionless gateway.
|
|
afterAll(() => {
|
|
__setChatTransportForTests(null);
|
|
configureGateway({
|
|
embedding_model: 'openai:text-embedding-3-large',
|
|
embedding_dimensions: 1536,
|
|
env: { ...process.env },
|
|
});
|
|
});
|
|
|
|
function chatResult(text: string, stopReason: ChatResult['stopReason']): ChatResult {
|
|
return {
|
|
text,
|
|
blocks: [{ type: 'text', text }],
|
|
stopReason,
|
|
usage: { input_tokens: 1, output_tokens: 1, cache_read_tokens: 0, cache_creation_tokens: 0 },
|
|
model: 'anthropic:claude-sonnet-4-6',
|
|
providerId: 'anthropic',
|
|
} as ChatResult;
|
|
}
|
|
|
|
const GOOD_JSON = '{"facts":[{"fact":"user gave up alcohol","kind":"commitment",' +
|
|
'"entity":null,"confidence":1.0,"notability":"high",' +
|
|
'"metric":null,"value":null,"unit":null,"period":null}]}';
|
|
|
|
describe('getFactsExtractionMaxTokens (#2113)', () => {
|
|
test('defaults to 4000 without an engine', async () => {
|
|
expect(await getFactsExtractionMaxTokens()).toBe(DEFAULT_EXTRACTION_MAX_TOKENS);
|
|
expect(DEFAULT_EXTRACTION_MAX_TOKENS).toBe(4000);
|
|
});
|
|
|
|
test('reads facts.extraction_max_tokens from the engine', async () => {
|
|
const engine = { getConfig: async () => '9000' } as unknown as BrainEngine;
|
|
expect(await getFactsExtractionMaxTokens(engine)).toBe(9000);
|
|
});
|
|
|
|
test('invalid config values fall back to the default', async () => {
|
|
for (const bad of ['garbage', '0', '-5', '']) {
|
|
const engine = { getConfig: async () => bad } as unknown as BrainEngine;
|
|
expect(await getFactsExtractionMaxTokens(engine)).toBe(DEFAULT_EXTRACTION_MAX_TOKENS);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('extractFactsFromTurn truncation handling (#2113)', () => {
|
|
test('default call carries maxTokens=4000 (was hardcoded 1500)', async () => {
|
|
const seen: ChatOpts[] = [];
|
|
__setChatTransportForTests(async (opts) => {
|
|
seen.push(opts);
|
|
return chatResult(GOOD_JSON, 'end');
|
|
});
|
|
const facts = await extractFactsFromTurn({
|
|
turnText: 'I gave up alcohol.',
|
|
source: 'test:truncation',
|
|
});
|
|
expect(seen).toHaveLength(1);
|
|
expect(seen[0]!.maxTokens).toBe(4000);
|
|
expect(facts).toHaveLength(1);
|
|
});
|
|
|
|
test("stopReason 'length' retries ONCE at double the cap and recovers the facts", async () => {
|
|
const seen: ChatOpts[] = [];
|
|
__setChatTransportForTests(async (opts) => {
|
|
seen.push(opts);
|
|
// First call: truncated garbage. Retry: full JSON.
|
|
return seen.length === 1
|
|
? chatResult('{"facts":[{"fact":"user gave up alco', 'length')
|
|
: chatResult(GOOD_JSON, 'end');
|
|
});
|
|
const facts = await extractFactsFromTurn({
|
|
turnText: 'I gave up alcohol.',
|
|
source: 'test:truncation',
|
|
});
|
|
expect(seen).toHaveLength(2);
|
|
expect(seen[1]!.maxTokens).toBe(seen[0]!.maxTokens! * 2);
|
|
expect(facts).toHaveLength(1);
|
|
expect(facts[0]!.fact).toContain('alcohol');
|
|
});
|
|
|
|
test('still-truncated retry does not retry again (bounded at one retry)', async () => {
|
|
let calls = 0;
|
|
__setChatTransportForTests(async () => {
|
|
calls++;
|
|
return chatResult('{"facts":[{"fac', 'length');
|
|
});
|
|
const facts = await extractFactsFromTurn({
|
|
turnText: 'I gave up alcohol.',
|
|
source: 'test:truncation',
|
|
});
|
|
expect(calls).toBe(2);
|
|
expect(facts).toEqual([]);
|
|
});
|
|
});
|