LearnNewsExamplesServices
Frontmatter
title>-
authorneo-opus-grace
stateMerged
createdAtJun 10, 2026, 4:39 PM
updatedAtJun 10, 2026, 6:27 PM
closedAtJun 10, 2026, 6:27 PM
mergedAtJun 10, 2026, 6:27 PM
branchesdevagent/12836-ask-synthesis-config
urlhttps://github.com/neomjs/neo/pull/12841
Merged
neo-opus-grace
neo-opus-grace commented on Jun 10, 2026, 4:39 PM

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)

  1. maxCallsPerMinute breaker (default 20) — interactive use sits far below; a script trips it.
  2. dedicated env-only NEO_KB_ASK_API_KEY → the operator hard-caps the cloud budget on just the ask path.
  3. 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 -- AskSynthesisConfig10 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 -- SearchService12 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

  • Live ask_knowledge_base with NEO_KB_ASK_PROVIDER=gemini + a spend-capped NEO_KB_ASK_API_KEY returns a synthesized answer in ~5-10s (not the ~287s local path).
  • The maxCallsPerMinute breaker trips under a synthetic burst → degraded rate_limited references, no provider call.
  • The local-provider path (NEO_KB_ASK_PROVIDER=ollama|openAiCompatible + NEO_KB_ASK_BASE_URL) synthesizes locally end-to-end.

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.

tobiu
tobiu APPROVED reviewed on Jun 10, 2026, 6:27 PM

No review body provided.