Resolves #12836
ask_knowledge_base synthesis was bound to the global modelProvider, which the post-incident config forced onto local gemma (~287s — at/past the MCP request-timeout ceiling). This gives the ask path its own askSynthesis config block so it can use a fast remote model (Gemini 2.5 Flash, ~5-10s) with built-in cost-safety, while bulk chat (miniSummary / sessionSummary / graph extraction) stays local — and a local provider remains a first-class option for privacy-mandated deployments.
Evidence: L1 (unit: pure rate-breaker + config-contract + env-only-key source check) → L1 required (result-shape + config-contract ACs are fully unit-covered; live remote-Flash latency + rate-cap-under-real-load are post-merge runtime observations). No blocking residual.
What shipped
config.template.mjs — new askSynthesis block: provider (gemini | openAiCompatible | ollama), model (default gemini-2.5-flash — ~15-22x cheaper than 3.5-flash, sufficient for RAG synthesis), apiKey (env-only NEO_KB_ASK_API_KEY, default null + security rationale), baseUrl (local-endpoint override), timeoutMs (relocated from the former top-level askSynthesisTimeoutMs, value preserved), maxCallsPerMinute (runaway breaker).
SearchService.construct builds the model from askSynthesis (key read at the use site, never inlined) instead of the global modelProvider; baseUrl overrides the host for the local providers.
SearchService.ask enforces the per-minute breaker before the (costly) synthesis call — on trip it returns the degraded references with degradedCode: 'rate_limited' (no provider call) — and reads askSynthesis.timeoutMs.
helpers/askRateLimit.mjs — the pure rolling-window rate check (no clock / config reads), so the breaker is unit-testable without constructing the singleton.
3-layer cost-safety (the scripted-runaway class — the incident hammered a shared key at ~1,200 calls/min)
maxCallsPerMinute breaker (default 20) — interactive use sits far below; a script trips it.
- dedicated env-only
NEO_KB_ASK_API_KEY → the operator hard-caps the cloud budget on just the ask path.
gemini-2.5-flash default — ~22x cheaper output shrinks any blast.
Config-template change (per mcp-config-template-change-guide)
- Changed keys (
knowledge-base/config.template.mjs): added askSynthesis.{provider, model, apiKey, baseUrl, timeoutMs, maxCallsPerMinute}; removed top-level askSynthesisTimeoutMs (relocated to askSynthesis.timeoutMs — same env var NEO_KB_ASK_SYNTHESIS_TIMEOUT_MS, value preserved). New env vars: NEO_KB_ASK_PROVIDER, NEO_KB_ASK_MODEL, NEO_KB_ASK_API_KEY, NEO_KB_ASK_BASE_URL, NEO_KB_ASK_MAX_RPM.
- Local
config.mjs follow-up (REQUIRED): each clone's gitignored config.mjs must gain the askSynthesis block (and relocate askSynthesisTimeoutMs) — regenerate from the template (npm prepare) or add it manually — otherwise SearchService.construct reads undefined.provider and throws on boot. No config.mjs is committed in this PR.
- Harness restart: recommended for the KB server to load the new config.
- A2A peer notification sent (live KB-behavior change).
Deltas from ticket
- The ticket draft showed
timeoutMs: leaf(30000…); dev had drifted to 300000 (the "un-usable-limit" stopgap). I preserved the live 300000 rather than silently re-tune — timeout re-tuning is out of scope here (env-overridable). With the gemini default it's rarely approached; it's the slow-local-fallback ceiling.
- The breaker is extracted to
helpers/askRateLimit.mjs (pure) rather than inline — mirrors the memory-core/helpers/withTimeout.mjs pattern for config-independent unit testing.
Test Evidence
npm run test-unit -- AskSynthesisConfig → 10 passed (pure breaker: cap / rolling-window / pruning / strict boundary; config contract: gemini + gemini-2.5-flash defaults, null-default leaves; source-level env-only-key check).
npm run test-unit -- SearchService → 12 passed (regression — the construct / ask rewiring + the breaker introduce no regression).
- Note: running the new spec batched with the existing SearchService specs surfaces
Namespace collision in unitTestMode for Neo.ai.Config — a known multi-spec-in-one-worker artifact (the new spec imports the canonical template; the existing specs import config.mjs — same className, shared process). Isolated runs (and CI's per-file workers) are green.
Post-Merge Validation
Related
- Related: #12740 ([Epic] Agent OS local-first AI provider defaults and cost-safety — parent)
- #12831 / #12832 — the degraded-references swallow fix + the 287s measurement (the safety-net + the why)
- #12838 / #12839 — sibling Agent-OS reliability/cost lanes this cycle
Decision Record impact: relates-to ADR 0019 (extends the reactive Provider SSOT to a per-task ask block; the env-only apiKey leaf is the sanctioned read-at-use-site form). Challenges no ADR.
Authored by Claude Opus 4.8 (Claude Code). Session 2feb15b9-1948-4553-9679-1419ed7eecf1.
Resolves #12836
ask_knowledge_basesynthesis was bound to the globalmodelProvider, which the post-incident config forced onto local gemma (~287s — at/past the MCP request-timeout ceiling). This gives the ask path its ownaskSynthesisconfig block so it can use a fast remote model (Gemini 2.5 Flash, ~5-10s) with built-in cost-safety, while bulk chat (miniSummary / sessionSummary / graph extraction) stays local — and a local provider remains a first-class option for privacy-mandated deployments.Evidence: L1 (unit: pure rate-breaker + config-contract + env-only-key source check) → L1 required (result-shape + config-contract ACs are fully unit-covered; live remote-Flash latency + rate-cap-under-real-load are post-merge runtime observations). No blocking residual.
What shipped
config.template.mjs— newaskSynthesisblock:provider(gemini|openAiCompatible|ollama),model(defaultgemini-2.5-flash— ~15-22x cheaper than 3.5-flash, sufficient for RAG synthesis),apiKey(env-onlyNEO_KB_ASK_API_KEY, defaultnull+ security rationale),baseUrl(local-endpoint override),timeoutMs(relocated from the former top-levelaskSynthesisTimeoutMs, value preserved),maxCallsPerMinute(runaway breaker).SearchService.constructbuilds the model fromaskSynthesis(key read at the use site, never inlined) instead of the globalmodelProvider;baseUrloverrides the host for the local providers.SearchService.askenforces the per-minute breaker before the (costly) synthesis call — on trip it returns the degraded references withdegradedCode: 'rate_limited'(no provider call) — and readsaskSynthesis.timeoutMs.helpers/askRateLimit.mjs— the pure rolling-window rate check (no clock / config reads), so the breaker is unit-testable without constructing the singleton.3-layer cost-safety (the scripted-runaway class — the incident hammered a shared key at ~1,200 calls/min)
maxCallsPerMinutebreaker (default 20) — interactive use sits far below; a script trips it.NEO_KB_ASK_API_KEY→ the operator hard-caps the cloud budget on just the ask path.gemini-2.5-flashdefault — ~22x cheaper output shrinks any blast.Config-template change (per mcp-config-template-change-guide)
knowledge-base/config.template.mjs): addedaskSynthesis.{provider, model, apiKey, baseUrl, timeoutMs, maxCallsPerMinute}; removed top-levelaskSynthesisTimeoutMs(relocated toaskSynthesis.timeoutMs— same env varNEO_KB_ASK_SYNTHESIS_TIMEOUT_MS, value preserved). New env vars:NEO_KB_ASK_PROVIDER,NEO_KB_ASK_MODEL,NEO_KB_ASK_API_KEY,NEO_KB_ASK_BASE_URL,NEO_KB_ASK_MAX_RPM.config.mjsfollow-up (REQUIRED): each clone's gitignoredconfig.mjsmust gain theaskSynthesisblock (and relocateaskSynthesisTimeoutMs) — regenerate from the template (npm prepare) or add it manually — otherwiseSearchService.constructreadsundefined.providerand throws on boot. Noconfig.mjsis committed in this PR.Deltas from ticket
timeoutMs: leaf(30000…); dev had drifted to300000(the "un-usable-limit" stopgap). I preserved the live 300000 rather than silently re-tune — timeout re-tuning is out of scope here (env-overridable). With the gemini default it's rarely approached; it's the slow-local-fallback ceiling.helpers/askRateLimit.mjs(pure) rather than inline — mirrors thememory-core/helpers/withTimeout.mjspattern for config-independent unit testing.Test Evidence
npm run test-unit -- AskSynthesisConfig→ 10 passed (pure breaker: cap / rolling-window / pruning / strict boundary; config contract:gemini+gemini-2.5-flashdefaults, null-default leaves; source-level env-only-key check).npm run test-unit -- SearchService→ 12 passed (regression — theconstruct/askrewiring + the breaker introduce no regression).Namespace collision in unitTestMode for Neo.ai.Config— a known multi-spec-in-one-worker artifact (the new spec imports the canonical template; the existing specs importconfig.mjs— same className, shared process). Isolated runs (and CI's per-file workers) are green.Post-Merge Validation
ask_knowledge_basewithNEO_KB_ASK_PROVIDER=gemini+ a spend-cappedNEO_KB_ASK_API_KEYreturns a synthesized answer in ~5-10s (not the ~287s local path).maxCallsPerMinutebreaker trips under a synthetic burst → degradedrate_limitedreferences, no provider call.NEO_KB_ASK_PROVIDER=ollama|openAiCompatible+NEO_KB_ASK_BASE_URL) synthesizes locally end-to-end.Related
Decision Record impact: relates-to ADR 0019 (extends the reactive Provider SSOT to a per-task ask block; the env-only
apiKeyleaf is the sanctioned read-at-use-site form). Challenges no ADR.Authored by Claude Opus 4.8 (Claude Code). Session 2feb15b9-1948-4553-9679-1419ed7eecf1.