Changelog
All notable changes to @nhtio/adk are documented in this file.
The format is based on Keep a Changelog.
This project does not use strict Semantic Versioning. Versions are <major>.<YYYYMMDD>.<n> — a hybrid of one SemVer-like signal and CalVer: the major version increases only when the core contract breaks (the primitives every assembly depends on — the runners, the callback contracts, the artifact/retrievable model); the date is the release day; <n> counts same-day releases from zero. Everything else — including breaking changes to individual batteries — ships under the same major, called out explicitly in the entries below. So within a major, the version tells you when you got it, not what changed: a ^ range will float across battery-level breaking changes, so pin an exact version if you need stability and read the entry before upgrading.
2026-07-18
Added
- New generation engine:
local_diffusion— a BYO-inference-subprocess image-generation engine over a stdio line protocol. The Node-onlyLocalDiffusionGenerationAdapter(@nhtio/adk/batteries/generation/local_diffusion, subpath-only — excluded from the environment-neutral generation aggregate) drives a user-supplied inference subprocess over a stdin/stdout line protocol (modeled on DiffusionBee), so a consumer can run a local Stable-Diffusion checkpoint without the ADK bundling Python/torch. Samegenerate/edit→Promise<GeneratedMediaOutput[]>contract as the other generation engines, plus streamed per-step progress (dnpr→ thegeneratinglifecycle phase), best-effort cancellation (AbortSignal→ advisory__stop__, withreset()/dispose()as the hard stop), single-flight admission,outputDir-contained cleanup of backend-written files, and both inline-base64 and file-path image results. Ships the protocol + a documented Python reference backend; the consumer supplies the process.
2026-07-13
Added
- New battery domain:
tts— text-to-speech synthesis generating audio from text. Two new engines under the shared contractsynthesize(text, opts?) → Promise<GeneratedMediaOutput>: a model-backedTransformersJsTtsAdapter(@nhtio/adk/batteries/tts/transformers_js, MMS-VITS / SpeechT5 via@huggingface/transformers), and a zero-config, node-onlyNativeTtsAdapter(@nhtio/adk/batteries/tts/native) that shells out to macOSsay, Linuxespeak-ng, or Windows PowerShell. - New embeddings battery: Ollama. A fourth engine ships under the shared embeddings shape, targeting Ollama's native
/api/embedendpoint. The newOllamaEmbeddingsAdapter(@nhtio/adk/batteries/embeddings/ollama) supportsbaseURL,truncate,keepAlive, and runtimeoptions, with zero environment constraints (runs in Node, browser, edge).
2026-07-12
Added
- New battery domain:
specialists— on-device speech-to-text, OCR, and image captioning, each a narrow single-purpose model turning one modality into TEXT for any text-only LLM. Three adapters:TransformersJsSttAdapter(@nhtio/adk/batteries/specialists/stt/transformers_js, Whisper-family ASR via@huggingface/transformers,transcribe(input, opts?) → { text, segments? }),TesseractJsOcrAdapter(@nhtio/adk/batteries/specialists/ocr/tesseract_js, pure-WASMtesseract.js,recognize(input, opts?) → { text, confidence? }), andTransformersJsCaptionAdapter(@nhtio/adk/batteries/specialists/caption/transformers_js, transformers.js'simage-to-textpipeline,describe(input, opts?) → { text }). All three mirror the embeddings adapters' construct-once/preload/reset/disposeshape and are ENVIRONMENT-NEUTRAL —isAvailable()is alwaystrue, no WebGPU or platform gate — proven against real weights in both Node and a headed, real-GPU Chromium session (tests/functional/batteries/specialists/specialists.webgpu.spec.ts).- Zero-core-import at the structural-contract layer, the same posture as the
thrift/compactcontext batteries:SpecialistMediaLike/SpecialistAudioInput/SpecialistImageInput(src/batteries/specialists/_shared) are locally-declared duck types a real@nhtio/adkMediasatisfies without either side importing the other. STT resamples any input — pre-decoded PCM at any sample rate, or an encoded container via an injectableDecodeAudioFn(default: lazyaudio-decodepeer + downmix-to-mono) — to 16kHz mono before the pipeline call; the linear-interpolation resampler and the mono-downmix helper were lifted into a sharedlib/utils/audiomodule rather than duplicated per adapter. - OCR's cached-worker posture is a deliberate divergence from the media battery's own
tesseract_jsengine: oneTesseractJsOcrAdapterholds a single warm worker across everyrecognize()call (construct-once, single-flight resolution) instead of booting a fresh worker per call. tesseract.js v7 has no safe way to re-language an already-booted worker, so a per-calllanguagesoverride that doesn't match the constructor's set throwsE_TESSERACT_JS_OCR_ENGINE_ERRORrather than silently switching;reset()anddispose()are aliases here (both terminate the worker — no lighter tier exists for a live WASM worker). - Composition, proven:
tests/functional/batteries/specialists/specialist_compose.node.spec.tsfeeds Whisper's transcript ofspeech.wav("The quick brown fox jumps over the lazy dog.") and Tesseract's OCR ofsample_ocr.png("HELLO OCR\n123") to a separate text-only Llama-3.2-1B, which grounds its answer ("Fox") on that text alone — the pattern this domain exists to enable, not just each specialist's own accuracy. - Deliberately no agent integration and no cloud engines. Same posture as the embeddings batteries: the adapter is the whole product — no
Toolclass, no forged tool, noTurnRunnerConfigwiring. And unlike the LLM/vector batteries (which abstract a real, converged wire contract), the cloud STT/OCR/vision landscape has no such convergence — every vendor's API has its own auth model, request shape, and SDK, with nothing worth abstracting — so this domain draws the line at on-device only, on all three adapters, full stop. New docs sectiondocs/batteries/specialists/(overview + one reference page per adapter) covers the thesis and the three ways a consumer actually wires one in: a BYOToolover the adapter (byo-tools pattern), a direct call outside the tool-call loop, or a courtesy write toMedia.stashthat an LLM battery'sfallback-stashUnsupportedMediaPolicyreads automatically.
- Zero-core-import at the structural-contract layer, the same posture as the
- New battery domain:
isolation— a transport-agnostic protocol substrate for running heavy or untrusted work off the main thread (Web Worker) or out of process (nodechild_process), instead of hand-rolling the spawn/request-response/crash-recovery plumbing per callsite. Declare a service once withdefineIsolatedService(methods/streams/events), implement it guest-side viaserveIsolated/serveIsolatedOverPort, and drive it host-side viacreateIsolatedService— over a real browser Web Worker (spawnIsolated/createWorkerTransport,@nhtio/adk/batteries/isolation) or a real nodechild_process(forkIsolated/createChildProcessTransport, the node-only deep import@nhtio/adk/batteries/isolation/child_process— not re-exported from the main barrel or the batteries aggregate because it importsnode:child_processdirectly).child_processoverworker_threads, deliberately: threads share the host's address space, so a native-addon segfault or V8 fatal error inside one can take the whole host process down with it; a real OS child process only kills itself, surfacing to the host as an ordinary'exit'/'error'event.forkIsolatedpinsserialization: 'advanced'by default so the tiered codec's opaque containers (TypedArray/ArrayBuffer/DataView/Date/RegExp/Map/Set) round-trip faithfully rather than silently degrading under node's default JSON-style IPC serialization.- Crash containment and recovery: a transport-reported crash rejects in-flight calls/streams with
E_ISOLATED_CRASHED, flipsstateto'crashed', and fans out to.onCrash(...)subscribers; recover via manual.recycle()orautoRespawn: { policy }, a sliding-windowcreateCrashPolicygeneralizing the flagship agent's hand-rolledGpuLossPolicyinto a domain-neutral decider either transport's crash can consult. - This exact pattern was hand-rolled three separate times in this repo before the battery shipped: the flagship's 582-line LiteRT-LM worker pair, the media battery's
BinaryExecutorseam, and the specialists'createPipelinefactory seams — the battery generalizes all three into one spec-first substrate. Proven by refit, not by assertion: aCreateLiteRtLmEngine-typed factory overforkIsolateddrives a LiteRT-shaped guest end-to-end (tests/functional/batteries/isolation/litert_refit.node.spec.ts, plus a Web Worker variant), and the REAL, unmodifiedTransformersJsEmbeddingsAdapterruns its feature-extraction pipeline out-of-process through its publiccreatePipelineinjection seam (tests/functional/batteries/isolation/embeddings_pipeline.node.spec.ts). isolateFunctionis a separate Blob-URL escape hatch for running an in-memory function value in a throwaway Worker without writing a guest file — an explicit, opt-in,eval-equivalent trust surface gated behind the literal{ allowSourceRehydration: true }acknowledgement, both at the type level and at runtime.- New docs section
docs/batteries/isolation/(hub, browser, node, recipes) covers the thesis, both transports' full option surfaces, and four end-to-end recipes: the LiteRT-LM worker pair refit, isolated embeddings over a real adapter unchanged, custom classes crossing the wire via@nhtio/encoder's custom-encodable protocol, and wiring an observability dashboard.
- New battery domain:
generation— text-to-image generation and image editing, three engines behind one shared contract, for agents that PRODUCE media instead of just consuming it. Every engine exposes the samegenerate(prompt, opts?)/edit(inputs, prompt, opts?)→Promise<GeneratedMediaOutput[]>contract over the sameBaseGenerationAdapterOptions{ model: string }base (required, no default, mirroring the embeddings batteries):OpenAIGenerationAdapter(@nhtio/adk/batteries/generation/openai, rawfetchagainst/v1/images/generations+/v1/images/edits, multipart edits,responseFormatModetri-state for thedall-e/gpt-imagesplit),GeminiGenerationAdapter(@nhtio/adk/batteries/generation/gemini, rawfetchagainst the nativegenerateContentREST surface, probe-confirmed image-parts-first/text-last part ordering foredit(), refusals surfaced as a thrown malformed-response error with the refusal text embedded), andTransformersJsGenerationAdapter(@nhtio/adk/batteries/generation/transformers_js, EXPERIMENTAL on-device text→image via DeepSeek Janus'sMultiModalityCausalLM.generate_images()— the only image-generation surface transformers.js exposes, nopipeline('text-to-image')task exists — real knobs cross-verified against the installed package's own sampler/config source).edit()support is not uniform across engines: OpenAI and Gemini both support it (multipart form data vs. inline base64 image parts, respectively); the transformers.js engine always throwsE_TRANSFORMERS_JS_GENERATION_UNSUPPORTED_OPERATION(['edit', reason])— Janus is text-conditioned generation only, with no image-conditioned edit/inpaint entry point in the installed API.- Live-verified through the same LB gateway topology the other cloud batteries use: both engines'
.cross.spec.tsspecs route through the repo's own polyglot LB rather than each vendor's API directly, authenticating via a gateway-styleAuthorization: Bearerheader instead of each adapter's own native scheme (x-goog-api-keyfor Gemini, its ownAuthorization: Bearerfor OpenAI). The OpenAI-shaped live spec only exercisesgenerate()— probing the gateway's/v1/images/editsroute returned a 404 (the gateway does not implement OpenAI-shaped image edits, only generations) — while the Gemini live spec proves bothgenerate()andedit()(including a real pixel-level recolor of a fixture image) through that same gateway. - Deliberately no agent integration, the same posture as every other domain in this family: the adapter is the whole product — no
Toolclass, no forged tool, noTurnRunnerConfigwiring.GenerationImageInputaccepts a realMediainstance structurally ({ mimeType, asBytes() }), zero import coupling either direction. New docs sectiondocs/batteries/generation/(hub, one reference page per engine, recipes) covers the shared contract, each engine's full wire behavior, and how to wire agenerate/editcall into a BYOTool, an edit-tool consuming an inboundMediaattachment, a courtesyMedia.stashcaption, and running the on-device engine behindforkIsolated.
2026-07-07
Security
- Prototype pollution in the
data.*media steps (GHSA-xwg2-cvvj-3w4v).data.setanddata.deletewalk a caller-supplied dot/bracket path (a.b[2].c) with plain bracket access (container[seg]); a path segment of__proto__,prototype, orconstructorresolved through the real prototype chain instead of stopping at an own property, sodata.setwithpath: "__proto__.polluted"reachedObject.prototypeand poisoned every plain object in the process for the remainder of its lifetime — reachable from LLM tool-call arguments via the forgedmedia_query/data_settool surface.parsePathnow rejects any segment in that denylist beforewalkToParentever runs, closing both verbs (they share the same parser).data.mergeused a different code path ({ ...target }spread) that is not independently exploitable — spreading onto a fresh object literal makes__proto__an inert own property, not a prototype reassignment — but a newassertSafeObjectKeysguard now rejects the same three keys anywhere in a merge fragment's tree before either the shallow or deep merge strategy runs, so the verb can't be used to smuggle a poisoned key back out through a round-tripped document. - Defense-in-depth: reject the same three keys in vector-adapter metadata. An audit for the same vulnerability class found five adapters (
pinecone,s3vectors,qdrant,redis,cloudflare) that spread caller-suppliedrecord.metadataonto a fresh object literal before upsert. None of these were independently exploitable for the reason above — the spread target is always a new{}, never a walked reference — but a__proto__-keyed metadata object would otherwise round-trip verbatim through storage and back out to a caller. A newsanitizeMetadatahelper (src/batteries/vector/helpers.ts) strips__proto__,prototype, andconstructorkeys before each adapter's upsert path builds its stored record. - Audited the rest of the codebase for the same reachable-prototype-chain pattern — the
data_structureandstructured_datatool batteries, theapply_patchmedia step, the data-formatMediaEngine, andRegistry'sdset-backed path storage — and found no further instances; everything else either only reads, or only ever writes to a freshly constructed object.
Added
Tokenizableaccepts a dynamic evaluator, resolved at prompt-assembly time. Alongside a plain string, the constructor now takes aTokenizableEvaluator— a(ctx?) => string— so wrapped content can compute itself coherent with the liveDispatchContextit ships in (e.g. an instruction that adapts to whether a tool survived the subtractive-context pass). Resolutions are cached per-context in aWeakMapso repeated measures of the same dispatch (subtractive pass + overflow guard) don't re-invoke the evaluator. Arender(ctx)method is the explicit, context-aware read; the standard string-coercion protocol (toString/valueOf/toJSON) resolves with no context, hitting the evaluator's ownundefined-branch fallback. An evaluator that throws or returns a non-string raises the newE_TOKENIZABLE_EVALUATOR_INVALID— loud, no silent coercion. New'gemma'encoding identifier forTokenizable.estimateTokens(Gemma 2/3/4, backed by the same@lenml/tokenizer-geminiSentencePiece vocabulary as'gemini'— deliberate reuse, distinct name). Files:src/lib/classes/tokenizable.ts,src/lib/exceptions/runtime.ts.Token-estimation failures degrade instead of silently returning
Infinity. A real-tokenizer failure (e.g. a special-token literal, an encoder bug) inside aTurnRunnerrun orDispatchRunnerdispatch now emits awarningand falls back to a char-based guesstimate, rather than the previous silentNumber.POSITIVE_INFINITY— which, combined with the overflow guards, could spuriously trip anE_*_CONTEXT_OVERFLOWon ordinary text. Outside any runner execution, the failure still re-throws (a genuine bug in non-runner code must surface). The ambient channel a runner publishes for the duration of its run issrc/lib/utils/estimation_context.ts(new) — a LIFO stack of warn-emit sinks so a dispatch nested inside a turn routes to its own (richer) emitter.WebGPU memory observability for the on-device LLM batteries.
probeGpuBudget()(src/batteries/llm/chat_common/gpu_budget.ts, new) reads the WebGPU adapter's buffer-size limits and adapter info, non-invasively — observability only, allocates nothing. A new opt-ininstrumentGpuBuffers()wrapsGPUDevice.prototype.createBufferto track live/peak GPU buffer bytes against that budget, for an application that wants a live "you're at X of Y GiB" gauge. Paired with a new typedE_LLM_GPU_OUT_OF_MEMORY(chat_common/exceptions.ts, new) —isGpuOutOfMemoryError()matches ORT-web's several GPU-exhaustion and WASM-linear-memory-exhaustion error signatures and both thetransformers_jsandlitert_lmbatteries translate a raw provider throw into this one typed, catchable error, surfaced via a non-fatalctx.nack(...)rather than a throw. A newgpuBudgetfield on the battery lifecycle report carries the probed snapshot. Consistent with the ADK's surface-don't-impose stance: the batteries never auto-cap the caller's context window.Shared tool-call parser layer expanded and hardened. The
gemmaparser is rewritten as a string-aware balanced-brace scanner — correctly handles nested argument objects and the curly smart quotes (“…”,‘…’) small models emit in place of ASCII quotes, instead of the previous lazy-regex approach. Two new parser families:'bare_pythonic'and'loose_keyed'(toolCallParser/ToolCallParserName). New observer seams on every LLM battery's options:onRawGeneration(the raw model text for a completed generation, after envelope-stripping but before persistence — reasoning / tool-call parser bring-up, live abstention debugging, fixture capture) andonPromptAssembled(the fully-assembled request about to ship, the mirror tap on the way in). Both are purely observational, default-absent, and consumed by all five LLM batteries (chat_common/tool_parsers.ts,chat_common/lifecycle.ts,chat_common/types.ts).litert_lmbattery: engine hosted in a disposable Web Worker. New standalone worker build configs —litert-lm-worker.vite.config.mtsandwebllm-worker.vite.config.mts— with matchingbuild:litert-lm-worker/build:webllm-workerpackage scripts, compiling the LiteRT-LM (IIFE, classic-worker-compatible — LiteRT's Emscripten glue callsimportScripts(), illegal in a module worker) and WebLLM (ES module worker) engine handlers as separate bundles co-located with their wasm assets, so a long-lived session can recover from a browser-level WebGPU device loss by terminating and respawning the worker rather than reusing a deadGPUAdapter. The adapter's existingcreateEngineinjection seam (LiteRtLmAdapterOptions.createEngine) is what a host wires a worker-backed engine through; the battery itself stays runtime-agnostic.DispatchRunner/TurnRunneremit aWarningEventobservability payload — non-fatal conditions (starting with the token-estimation degrade above) surfaced through the same observability bus asLogEvent/GenerationStatsEvent, carryingdispatchId/iteration, asource, and akind. Executor-thrown and nacked errors also now preserve a meaningfulError-shapedcauseeven when the thrown/nacked value is not itself a strictError(a raw string or cross-realm error no longer collapses to a cause-less generic wrapper) —toErrorCauseinsrc/lib/dispatch_runner.ts.Documentation: the "Punching Above Its Weights" showcase family. The flagship agent showcase (
docs/showcase/punching-above-its-weights.md) demonstrates building a real tool-using agent under hostile conditions — Gemma-4 E2B viaLiteRtLmAdapterin a browser tab, a 4GB GPU ceiling, a live-draggable context window — technique by technique (planner book-end, subtractive pass over the shipped context battery, gate cascade, artifact handles, GPU survival), each with real code embeds and field-note receipts, closing on the blind-judged 5-cell evaluation matrix. Its companion "The Agent, In Full" (docs/showcase/punching-above-its-weights-source.md) exposes the complete 31-file agent source in a read-only in-page Monaco viewer, with an LLM-consumable full-source mirror emitted through the docs pipeline for coding agents to port from. Method-side pages: Token Thrift (docs/the-loop/token-thrift.md, the context-discipline lever), Behavioral Rails (docs/the-loop/behavioral-rails.md, gates/own-voice nudges/the planner contract), Read the Wire (docs/the-loop/read-the-wire.md, evidence-directed agent debugging), and Runtime Loading (docs/assembly/runtime-loading.md, the@nhtio/adk/shimsconsumption guide). The underlying evaluation/research harness (corpus runs, floor calibration, adversarial threads, the LiteRT worker Step-0 probe) is committed underresearch/.New context battery domain:
thriftandcompact, two strategies for what goes into one dispatch's window.src/batteries/context/thriftis the subtractive strategy already backing the Token Thrift work above —subtractToFit,stripPriorTurnThoughts, and the calibratedselectRelevantTurns/scaledRelevanceFloorrelevance-based turn selection (floor constantsRELEVANCE_FLOOR_MIN/MAX/CURVEcalibrated against a triple-oracle, 94-turn stress corpus) are now a standalone, importable battery rather than flagship-agent-only code.src/batteries/context/compactis new: a faithful extraction of the flagship agent's own Claude-Code-style auto-compaction (assembleCompactedTurns,summariseTurns,COMPACTION_SYSTEM_PROMPT) — keep the newest turns verbatim, fold everything older into a rolling summary once it crosses a token threshold. Both batteries are built entirely on injected resolvers rather than bundled capabilities:EstimateTokensFn(no default tokenizer) and, forcompact,SummarizeFn(no default model transport) — with zero imports from@nhtio/adkcore at the structural-contract layer (WorkingMessage,WorkingMemory,WorkingRetrievable, and friends are locally-declared, duck-typed shapes a real core object satisfies structurally without either side importing the other). This decoupling is practical against real models because of the token-estimator registry added above (registerTokenEstimator) — a caller can register a custom encoding's estimator without editingTokenizable's internal switch, sothrift/compactwork against any encoding a project uses, built-in or not. The two batteries are composable:thrift'sisSummaryMessagepredicate (default id'__compact-summary', matchingcompact'sDEFAULT_SUMMARY_MESSAGE_ID) protectscompact's rolling summary message from being shed like an ordinary old turn when both run in the same pipeline. Evaluated head-to-head against a naive-recency baseline across five model/window cells on a shared 94-turn corpus: thrift is the lightest arm nearly everywhere and never collapses, while compact tops the two cells where real context pressure meets a paid summarizer budget (kimi-k2.5 @ 128k, 1.48 vs. 1.13; gemma-31b @ 128k, 1.48 vs. 1.35 — both 3-judge) — documented in full, including the naive baseline's 0.08 collapse on the kimi cell and per-cell dispatch/summarizer- overhead tables, in the newdocs/batteries/context/pages.@nhtio/adk/shims— an async-resolver seam for binding a runtime-loaded ADK bundle without importing core into the consumer's module graph.createAdkShimwraps a consumer-suppliedAdkResolverFn(all environment knowledge —fetch+ dynamicimport(), a Worker handshake, a host-injected global — lives in that one function; the shim ships no loading policy of its own) and returns{ resolve, get, resolved, proxy }: single-flightresolve(), a synchronousget()for already-resolved reads, a liveresolvedboolean, and aproxythat replaces the hand-rolledexport let Foo: typeof Module.Fooholder pattern with one destructurable object. Memoization is GC-safe — the resolved bundle is held viaWeakRef(never strongly retained by the shim itself), falling back to a plain strong reference only whereWeakRefis unavailable. A module-scope ambient variant (registerAdkResolver+adk) covers the "many files, one shared binding" case. Three typed exceptions cover the failure modes:E_SHIM_NOT_RESOLVED(a sync read before anything resolved),E_SHIM_RESOLUTION_FAILED(the resolver rejected or threw, cause preserved), andE_SHIM_RESOLVER_ALREADY_RESOLVED(re-registering the ambient resolver after it already resolved once — a split-brain guard).src/shims/index.tsis a leaf module — proven at dist level (shims.mjs, 17.7KB) to import only the exceptions chunk plus@nhtio/validationandfast-printf, zero core graph — and deliberately not re-exported from the root@nhtio/adkbarrel, since doing so would drag the very module graph this subpath exists to let you avoid back into the import. The docs site itself now dogfoods this exact seam:docs/.vitepress/theme/components/quickstart_demo_runtime.tsreplaced its own four-times-hand-rolled memoizing loader (the one that exists because importing ADK source into the VitePress module graph overflows the JS call stack on iOS WebKit) withcreateAdkShim(resolver), the resolver supplying only the docs app's URL-resolving policy.
Changed
@sqlite.org/sqlite-wasmandkyselyare now docs-site devDependencies — used by the docs site's in-browser SQLite demo tooling, never shipped in the published package.katexis now a runtimedependency(previously absent) — it backs the math tools battery (src/batteries/tools/math/index.ts).
2026-06-26
Added
- Portable generation vocabulary shared by the two text-out on-device batteries (
transformers_js,litert_lm). Both now accept one canonicalChatGenerationOptionssurface —maxTokens,sampler('greedy'|'top-k'|'top-p'),temperature,topK,topP,seed,enableThinking,multimodal: { image, audio }— and each adapter maps it onto its own runtime API. Precedence is canonical-wins: the canonical field is honored and the battery's native field (transformers.jsmaxNewTokens, LiteRTmaxOutputTokens/samplerParams) is the fallback consulted only when the canonical one is absent, so existing native-field config keeps working. Defaults are identical across both batteries and chosen for reproducibility (sampler: 'greedy',enableThinking: false— many reasoning templates default thinking on and burn the token budget before the answer; this turns it off unless asked). - Normalized lifecycle hook surface across all on-device batteries (
transformers_js,litert_lm,webllm_chat_completions, and the transformers.js embeddings battery). A new opt-inBatteryLifecycleHooksblock: anonLifecyclefirehose plus per-phase hooksonLoading→onCompiling→onReady→onGenerating→onComplete(oronError), each handed a normalizedBatteryLifecycleReport({ phase, battery, model, at, detail?, progress?, raw?, error? }).progressis normalized to0..1duringloadingwhen the provider reports it; thecompilingphase marks the WebGPU/wasm shader/graph build between download and first token — often the slowest part of a cold start, and previously invisible. Purely additive — omit the hooks and behavior is byte-for-byte unchanged; a throwing consumer hook can never abort a load or a turn. The existing per-provideronInitProgressis untouched. - Multimodal INPUT for the transformers.js battery. Image and audio flow through the model's processor (called positionally,
_call(text, images, audio)); a multimodal model genuinely perceives them. For the common audio case — uncompressed PCM WAV — the battery decodes the RIFF itself with aDataView, dependency-free and env-neutral, before importing the heavy peer (transformers.js's ownread_audioneeds the Web Audio API'sAudioContext, which does not exist in Node; compressed containers still fall back to it, browser-only). Enable per kind via the canonicalmultimodal: { image, audio }. - Opt-in media-OUTPUT seam (
extractMediaOutputs) on thetransformers_jsandlitert_lmbatteries. Default absent → text-out, byte-for-byte unchanged. Supply the hook and a wrapped media-emitting model's generated audio/image is persisted viactx.storeMediaBytes, wrapped as a first-partyMedia.toolGenerated(...), and surfaced as an assistantMessage.attachmentsentry (a media-only turn — empty text + attachment — is legitimate). The batteries remain multimodal-in / text-out by default; this is for an LLM turn that produces media alongside or instead of text. 'phi'tool-call parser added to the shared parser layer'stoolCallParserset and the'auto'priority order (hermes → gemma → gpt_oss → phi → pythonic → llama3_json → mistral → qwen3_coder). Anchored on the literalfunctoolstoken (verified against vLLM'sphi4_mini_jsonparser), so it runs with the other marker-anchored families ahead of the weak-signal JSON/pythonic forms.EmbeddingGemma 300M(onnx-community/embeddinggemma-300m-ONNX) verified through thetransformers_jsembeddings battery — 768-dim, unit-norm, deterministic — alongside the existing MiniLM / BGE-small / Arctic-S entries.- The real-model test matrix (
tests/_fixtures/model_matrix.ts, gated onTEST_MODEL_MATRIX=1): loads each real ONNX /.litertlmmodel, drives one dispatch turn, and asserts the expected parser family or multimodal grounding is extracted — because a small model may not emit the format its chat template implies (Gemma 4 E2B emits the decoder-strippedcall:NAME{k:v}, not the template's<|tool_call>…). A Node half (pnpm run test:matrix) and a headed-WebGPU browser half (pnpm run test:matrix:browser, local-only — CI runners have no GPU).bin/capture_tool_outputs.ts(pnpm run capture:tools) captures real raw output from hosted big-only families (qwen3_coder,gpt_oss,mistral) via any OpenAI-compatible proxy into committed parser fixtures — configured with--base-url/--api-keyor the genericCAPTURE_BASE_URL/CAPTURE_API_KEYenv vars; never run in CI. - Documentation: a dedicated "LLM Batteries" section under Featured Batteries — an overview hub, a "Shared Contract" page (the three
chat_commonpillars: parser layer, portable generation vocabulary, lifecycle hooks), and a reference page per battery (OpenAI, Ollama, WebLLM, LiteRT-LM, Transformers.js) each carrying a tested-model table grounded in the matrix. Plus a showcase, "Building the On-Device Batteries," documenting the parser archaeology, the false-green fixtures, and the one wall we could not engineer around: LiteRT-LM in the browser runs Gemma and only Gemma, proven at the file-format level (tf_lite_prefill_decodevstf_lite_artisan_text_decoder) — converting a non-Gemma model to a browser-runnable.litertlmis a dead end with the public toolchain. The deadconvert_model/deploy_modelscripts (which described a fictional CLI) were removed and the conversion doc rewritten as "The Real-Model Matrix." Docs are bundled in the npm package and served by the ADK Assembly MCP, so this ships with the release.
Changed
bin/capture_tool_outputs.tsnow reads its proxy URL/key fromCAPTURE_BASE_URL/CAPTURE_API_KEY(or the existing--base-url/--api-keyflags), replacing internal-specific env-var names. Dev tool only; not part of the published runtime surface.
Fixed
- Fixed the documentation release pipeline.
2026-06-25
Added
- New opt-in LLM battery
@nhtio/adk/batteries/llm/transformers_js— on-device ONNX text generation, in Node AND the browser. ShipsTransformersJsAdapter, a one-lineDispatchExecutorFnwrapping@huggingface/transformers(an optional peer, already present for the media ASR engine). Unlike the WebLLM and LiteRT-LM batteries (WebGPU/browser-only), transformers.js is environment-neutral — it auto-selectsonnxruntime-node(native, plain Node, no GPU) oronnxruntime-web(WASM + WebGPU) — so this battery runs server-side and client-side from one codepath and does not gate onnavigator.gpu.device/dtypepick the backend and quantization.STASH_KEYis'transformersJs'. - New opt-in embeddings battery
@nhtio/adk/batteries/embeddings/transformers_js. ShipsTransformersJsEmbeddingsAdapter— on-devicefeature-extractionembeddings with the sameembed/embedMany/dimensions/preload/reset/isAvailablesurface andnumber[]return shape as the OpenAI and WebLLM embedders, pluspooling(default'mean') andnormalize(defaulttrue). Being environment-neutral, it is surfaced from the@nhtio/adk/batteries/embeddingsaggregate barrel (alongside OpenAI; WebLLM stays deep-import-only). - New shared, configurable tool-call + reasoning text-parser layer (re-exported from both
transformers_jsandlitert_lm). Text-only on-device runtimes inject tool definitions into the chat template but emit tool calls and reasoning as family-specific raw text, not structured fields — so the battery parses them out, the way vLLM/SGLang/Ollama do (post-hoc, per-family, flag-selected). Two options, both defaulting to'auto'(try the bundled family parsers in priority order, first match wins):toolCallParser:'auto'·'hermes'·'gemma'(E2B/E4B) ·'gpt_oss'(Harmony) ·'pythonic'·'llama3_json'·'mistral'·'qwen3_coder'·'none'· a customToolCallParserFn.reasoningParser:'auto'·'think_tag'(<think>…</think>) ·'harmony_analysis'·'gemma_channel'·'none'· a customReasoningParserFn. Marker-anchored families run first (no cross-family false positives); weak-signal JSON/pythonic forms are gated on the callee being a real tool. Parsed reasoning becomes ADK Thoughts; cleaned prose is the assistant Message; tool-callargumentsare a plain object (noJSON.parse). Bundled defaults target the small ONNX / Ollama-Cloud-tier open-weight families (Gemma 4 E2B/E4B, gpt-oss:20b, Qwen3-Instruct, Llama 3.2, SmolLM). Gemma's tool-call + reasoning delimiters were verified byte-exact against the model's owntokenizer_config.json; both batteries were validated end-to-end against real ONNX models (MiniLM embeddings, SmolLM2-135M generation).
Fixed
- LiteRT-LM tool calling and reasoning extraction now actually work (
@nhtio/adk/batteries/llm/litert_lm). The battery as first shipped (v1.20260625.0) readMessage.tool_callsandMessage.channelsoff model output — but the@litert-lm/corev0.13.1 JS runtime is text-in / text-out and never populates those fields on output (they are input-only wire fields; the package README confirms text-only I/O). So the prior tool-calling and reasoning support was non-functional against real models (the mocked tests passed because the fakes populated the fields). The adapter now parses tool calls and reasoning out of the model's text via the new shared parser layer, with the sametoolCallParser/reasoningParseroptions (default'auto'). If you relied on LiteRT-LM tool calls or thoughts before, they begin functioning with this release.
2026-06-24
Added
- New opt-in LLM battery
@nhtio/adk/batteries/llm/litert_lm— on-device WebGPU inference of Google's.litertlmmodels. ShipsLiteRtLmAdapter, a one-lineDispatchExecutorFnwrapping@litert-lm/core(browser/WebGPU + a bundled wasm runtime). Unlike the WebLLM battery it is standalone, not an OpenAI-wire subclass: it drives LiteRT's nativeEngine.create() → createConversation({ preface }) → sendMessageStreaming(): ReadableStream<Message>API, takes tool-callargumentsas a parsed object (noJSON.parse), and surfaces "thinking" viaMessage.channels→ ADK thoughts. Full parity with the other batteries: text, streaming, thoughts, tool use, sampler/limit controls (samplerParams,maxOutputTokens,maxNumTokens,backend), and the typed multimodal contract (audioModalityEnabled/visionModalityEnabled). It reuses the format-agnostic render helpers; only the wire-shape mappers are LiteRT-native (buildLiteRtConversationInput,toolsToLiteRtTools,renderLiteRtToolResult, the streaming accumulator), each swappable viahelpers.STASH_KEYis'liteRtLm'; exceptions are theE_LITERT_LM_*family plusE_INVALID_LITERT_LM_OPTIONSandE_UNSUPPORTED_MEDIA_MODALITY.@litert-lm/coreis an optional peer dependency, pinned exact (0.13.1) — it ships its own ~19 MB wasm and is not bundled into@nhtio/adk. Install it yourself (pnpm add @litert-lm/core) when you want this battery; it is never required for type-checking a consumer.- The published
@litert-lm/coredocs lag the library — tool use, channels, sampler controls, and multimodality are typed but undocumented. The adapter is mapped against the installed.d.ts(the source of truth); re-verify on upgrade. Preview.litertlmmodels are text-in/text-out today, so the native multimodal path is built-to-contract but not yet exercisable end-to-end.
- Serialization: the ADK primitives now round-trip through
@nhtio/encoder. Encode an entire conversation graph —Messages with nestedIdentity,Tokenizable, andMedia;ToolCalls with their results;Memory,Thought,Retrievable,Registry— to a string withencode()and rebuild it (instances, not plain objects) withdecode(). Every primitive implements the encoder's custom-class contract via rawSymbol.for()keys, so the contract adds zero dependency to the core —@nhtio/encoderis an optional peer, pulled in only by the new battery. - New opt-in battery
@nhtio/adk/batteries/encoding. CallregisterAdkEncodables()once at startup, before your firstdecode()— it registers every primitive with the decoder and auto-registers the binding-free reader resolvers (in-memory, fetch). This is the only code that imports@nhtio/encoder. - Reader handles round-trip, not bytes.
MediaandSpooledArtifactserialize the handle — a tagged, re-openable locator — via a new optionaldescribe()method on theMediaReader/SpoolReadercontracts. On decode, a tag→reader resolver registry (registerMediaReaderResolver/registerSpoolReaderResolver) re-binds the handle to a live reader; for durable stores (flydrive, OPFS) the consumer registers the resolver carrying the liveDisk/root the locator cannot itself carry. In-memory readers inline their buffer; the fetch reader captures its URL. - New exceptions
E_READER_NOT_DESCRIBABLE(encoding a primitive whose reader has nodescribe()— e.g. afromWebFile-backedMedia) andE_NO_READER_RESOLVER(decoding a handle whose tag has no registered resolver), both exported from@nhtio/adk/exceptions.
Not encodable, by design
TurnGatewraps a live pending Promise +AbortController— there is no serialized form of "a thing some caller is awaiting", so it deliberately does not implement the contract.Toolhandlers serialize by source text only. A handler that closes overctx, service clients, or config loses those bindings on decode (the captured variables read backundefined); a.bind()-ed or native handler cannot be serialized at all.Tool.inputSchemaround-trips losslessly via@nhtio/validation's ownencode/decode. Tools are rarely serialized; when they are, reconstruct dependencies inside the handler body rather than closing over them.fromWebFile-backedMediais not encodable — a browserBlobhas no re-openable locator and the synchronous encoder cannot drain it. Persist to a media/spool store and wrap in a describable reader first.
This release is additive — no major bump (per the <major>.<YYYYMMDD>.<n> scheme, the major moves only on core-contract breaks). The symbol methods are new surface; the new optional describe() on the reader contracts is backward-compatible (optional method, duck-typed schemas unchanged); no existing primitive constructor or field changed.
Fixed
Messagewith emptyattachmentsis no longer mis-rejected by its own serializer. The newly-addedMessageencode path emittedattachments: []for text-only messages, which the message schema's "at least one of content/attachments, and a presentattachmentsmust be non-empty" cross-field rule then rejected ondecode(). The encode snapshot now omitsattachmentsentirely when empty. Only reachable via the new serialization path — no impact on existing construction.
2026-06-23
Fixed
Tokenizablenow caches the tiktoken encoder instead of rebuilding it per call (reported against1.20260612.0from a Node/AdonisJS host embedding the ADK).js-tiktoken'sgetEncodinghas no internal cache — every call doesnew Tiktoken(<ranks>), parsing the full BPE rank table (~800 ms foro200k_base), which is ~1000× the cost of theencode()that follows. The tiktoken backend was the one estimator that never got the lazy-singleton treatment the Gemini and Llama backends already had, so a fresh encoder was constructed on everyestimateTokensinvocation that missed the per-value memo. On a tool-heavy turn — where a battery re-measures an accumulating dispatch context once per iteration — this rebuilt the BPE table O(results × iterations) times, saturating a single-threaded host's event loop (CPU pegged, RSS oscillating multi-GB under GC of the repeatedly-allocated vocabulary, co-tenant HTTP starved). The encoder is now memoized in a module-levelMap<TokenEncoding, Tiktoken>, mirroring the existing Gemini/Llama singletons; construction drops to once per encoding per process. Behaviour-preserving (Tiktokeninstances are stateless and reusable), benefits every tokenization path ADK-wide, and is the single highest-leverage change against the reported event-loop starvation.
2026-06-12
Added
- Media generation: the
empty:<format>sentinel. Agents can now CREATE media, not just derive it.media_id: "empty:xlsx"(orempty:png,empty:json, …) mints a brand-new blank file and runs the statement against it — creation and population in one round-trip;@empty:<format>works as a statement ref too (merge with=@empty:xlsx). Strictly additive: harness ids are UUIDs, so everyempty:*value was previously a guaranteedMEDIA_NOT_FOUND. Under the hood, generation is one new convert edge — the virtual source MIMEEMPTY_MIME(application/x-adk-empty) — declared per engine; the creatable set is pure graph reachability (convertTargets(EMPTY_MIME), multi-hop included), never a policy list. Deterministic generation (blank workbook/canvas/silence) ships bundled; model-based semantic generation (diffusion/TTS) is BYO via the same edge. Generation edges landed on jimp + sharp (1024×1024 white canvas), audio_decode (1 s of 16-bit mono silence at 44100 Hz, dependency-free), soffice (its whole matrix, via zero-byte seed files — LibreOffice treats an empty seed as an empty document; pinned by a binary-gated spec), and the three new engines below. edits— a third engine capability kind (additive:MediaEngine.edits?,EditCapability/EditRequest/EditResult,registry.edit()/hasEdit(), selection middleware seeskind: 'edit'). Structural document ops are now declared, dispatched, and swappable like converts and mutates — and two engines may declare the same ops with different fidelity, with supply order picking the winner.- New engine
engines/sheetjs(sheetjsEngine(), optional peerxlsx>=0.20.2 — install from the SheetJS CDN, the npm registry copy is frozen at 0.18.5 with CVE-2023-30533 and CVE-2024-22363): the in-process, cross-env spreadsheet engine. Reads xlsx/xlsm/xlsb/xls(all BIFFs)/ods/fods/csv/NUMBERS/sylk/dif/dbf; writes those plus txt/html/rtf/json; generates any write target fromEMPTY_MIME; edits everysheet.*op over its whole read matrix. SheetJS CE strips styling — documented loudly, asserted in tests, and the reason exceljs exists alongside it. - New engine
engines/exceljs(exceljsEngine()): workbook editing promoted out of thesheet.*steps into a fleet-visible engine. Edits everysheet.*op over xlsx with styling preserved (bold/fills/comments/formulas survive untouched — the fidelity pin is a test), and generates blank xlsx fromEMPTY_MIME. Compose it before sheetjs when formatting matters; sheetjs alone covers data-only workloads without the extra peer. - New engine
engines/data(dataEngine()): the deterministic text/data engine. Generates txt/md/json/yaml/csv/html seeds fromEMPTY_MIME; converts json⇄yaml, json⇄csv (papaparse peer, lazy), json→txt. - New verbs:
append,data.set,data.merge,data.delete. The lossy text family is first-class media now: append a line to txt/md/csv/yaml, set/merge/delete at a JSON or YAML path (output format follows the input).empty:json | data set path=… value=…is a complete create-then-populate chain with zero engine requirements. - Structured
apply_patchenvelope — the GitHub Copilot apply_patch dialect (*** Begin Patch, Add/Delete/Update File,*** Move to:,@@context hunks), preserved exactly because models already know it. Multi-file viawith=@refs; Add File can grow the workspace, so the result may be multiple media. Ambiguous hunk context fails rather than guessing. The unified-diff path is untouched, and the diff→apply_patch round-trip is now a tested contract (diff A with=@Bapplied to A reproduces B byte-exact). redactandupdate_texton ODF (odt/ods/odp — in-placecontent.xmledits with the same paragraph-aggregation matching the OOXML path uses) andredacton PDF — VISUAL redaction via pdf-lib (draw-over on matching pages + metadata strip). The caveat ships in the verb description and the docs because it is a trust boundary: content streams keep the original text; for content-level redaction, extract text first.- Spreadsheet vocabulary expansion: xlsm/xlsb/fods/sylk/dif/dbf/numbers/yaml join the format tables and
convert to=targets; all spreadsheet-family MIMEs normalize to xlsx forsheet.*edits whenever any configured engine declares the conversion (sheetjs in-process, or soffice).
Changed
sheet.*verbs now require a registered edit-capable engine (requires: { capability: 'edit' }). Previously the steps lazy-imported exceljs directly, so merely installing the peer lit the verbs up; now the consumer registersexceljsEngine()(orsheetjsEngine()) in the engines array like every other capability. This is a behavior change for deployments that installed exceljs without declaring it — the failure message names the exact fix, and the engines docs carry a migration note.convertmay newly appear in image-only deployments: jimp/sharp now declare generation convert edges, sohasConvert()turns true. A model attempting an unreachable conversion still gets the existing model-actionable reachable-targets failure.MediaPipeline.capabilitiesis now typed as the fullEngineRegistry(the runtime value always was);CapabilityProbegains an optionalhasEdit().dist/package.jsongainsmcpName: "io.nht/adk-assembly"and the build emitsdist/server.jsonfor the MCP Registry. No behavioral change for existing consumers.
2026-06-11
Security
- npm trusted publishing is live — releases no longer use a long-lived token. Completing the groundwork below: the package's Trusted Publisher is configured on npmjs.com (GitLab CI/CD → this project's
.gitlab-ci.yml,npm publishonly), and both npm deploy jobs now authenticate exclusively with the short-lived OIDCid_token— everyNPM_TOKENreference is gone from this repository's CI. Verified live twice before removal: npm preferred the OIDC exchange even with the token fallback still present (publisher identityGitLab CI/CD <npm-oidc-no-reply@github.com>), and the first fully tokenless publish succeeded with the same identity. A stolen CI token — the credential class behind most of the recent registry-compromise worms — can no longer publish this package; publish rights are bound to this repository's pipeline identity instead of a bearer secret. - Supply-chain hardening across the build and dependency pipeline (prompted by the recent npm worm campaigns; none of these change the published API):
- Release cooldown: pnpm now refuses to resolve any dependency version published less than 3 days ago (
minimumReleaseAgeinpnpm-workspace.yaml). Compromised releases in recent supply-chain attacks were typically yanked within hours-to-days; the cooldown means a poisoned version ages out of the registry before it can enter our lockfile. - Frozen lockfile in CI: every pipeline job now installs with
pnpm install --frozen-lockfile, so CI can never silently resolve packages that aren't in the committed, cooldown-vetted lockfile. - Dependency floors for transitive advisories in the dev tree:
dompurify >=3.4.0(XSS bypasses; pinned older by monaco-editor),lodash-es >=4.18.1(_.templatecode injection; via chevrotain), anduuid ^11.1.1under exceljs (buffer-bounds advisory). - Dropped
@xenova/transformers(dev) in favor of the already-present@huggingface/transformersfor the Ask ADK embedder, reranker, and index builder. The abandoned v2 line dragged inprotobufjs ≤7.5.5viaonnxruntime-web, which carries a critical arbitrary-code-execution advisory plus seven others — all gone. Both the build-time index embedder and the browser query embedder migrated together (same runtime, sameq8weights), so index and query vectors stay comparable. - Trusted-publishing groundwork: the npm deploy job now requests a GitLab OIDC
id_tokenwith the npm registry audience. Once the package's Trusted Publisher is configured on npmjs.com, the long-livedNPM_TOKENCI secret — the artifact stolen in most registry-compromise incidents — can be deleted outright. - Net effect: consumer-facing prod tree remains at zero known vulnerabilities; the dev-tree audit drops from 27 advisories to 7 (all in the docs-site toolchain: vitepress's vite 5 line and markdown-it, not reachable from any published code path).
- Housekeeping from the lockfile rebuild:
@nhtio/eslint-configis pinned to exactly1.20260518.0— its1.20260609.0successor ships stricter jsdoc rules that fail the current tree (~1,300 errors inbin/and the docs theme). Dev-only; upgrading the config is a separate chore with that cleanup attached.
- Release cooldown: pnpm now refuses to resolve any dependency version published less than 3 days ago (
Changed
- The Cloudflare Vectorize conformance suite is now opt-in and out of CI. Vectorize's public endpoint is aggressively eventually-consistent — its query index flaps for seconds after a write or delete — and even with the conformance harness's retries the read-after-write race lost often enough to red-flag otherwise-green releases (it had been carried as an
allow_failurejob, which is just noise that trains you to ignore red). It now requires an explicitTEST_VECTOR_CLOUDFLARE_ENABLED=1opt-in on top of its credentials and skips otherwise. Run it by hand when you want to exercise the adapter against live Vectorize. Thecloudflareadapter itself is unchanged and still shipped.
Fixed
- Vector adapter query-construction hardening (from an internal security review; neither issue crossed a privilege or data boundary, both are belt-and-braces):
- The Milvus adapter's
nearIdseed-vector lookup now serializes the id withJSON.stringifyinstead of raw template interpolation, matching the adapter's own delete path — an id containing a double quote can no longer alter the filter expression. - The Redis adapter's numeric range filters (
gt/gte/lt/lte, and numericeq/ne) now coerce the bound throughNumber()and throwE_VECTOR_STORE_UNSUPPORTED_FILTER_OPERATORon non-finite results, so a non-numeric string can no longer break out of the RediSearch[lo hi]bracket and append query clauses. Numeric strings ('2024') still work.
- The Milvus adapter's
2026-06-10
Added
- The Media Pipeline battery (
@nhtio/adk/batteries/media) — a knex-inspired local media pipeline: one declarativeMediaPlanwith three front-ends (a chainable thenable builder, a pipe-string DSL, and JSON ops) compiling identically, executed as an@nhtio/middlewareonion over in-memory bytes. Most stacks process media by shipping bytes to an external API or flooding the context window; this is the third option — local processing, no external APIs by default, your data stays in your infrastructure unless an engine you composed says otherwise. Verbs cover documents (select/split/merge/reorder/redact/sanitize/normalize/update_text/diff/ apply_patch/convert/extract assets), unified text extraction (extract textroutes PDF, DOCX, XLSX, ODT/ODS/ODP, PPTX, plain text, and images through one verb, with OCR fallback for scanned input), chunking and metadata, tensheet.*mutations (ExcelJS), eightslides.*mutations (JSZip OOXML surgery), fusedimage.*transforms (adjacent steps cost one decode/encode), andaudio.transcribe(decode → 16 kHz mono resample → ASR). - The pipe DSL — the LLM-facing surface:
select pages=2-5 | redact match=/…/ | convert to=pdf. Named args only, separator-insensitive verb folding, 1-based indices, bare-number-is- index/quoted-string-is-name targeting, quoted-JSON structured payloads, inline@idmedia refs, and two-layer model-actionable errors (position-bearing syntax errors plus semantic did-you-mean narrowed to the deployment's configured engines, every message ending in a corrective exemplar). Round-trip is fixed-point and pipe/ops forms produce identical plans. - Engines as self-declaring capability providers (
@nhtio/adk/batteries/media/contracts+ one subpath per implementation): aMediaEngineis{ id, converts?, mutates? }— exactly two capability shapes, because a media engine only ever changes the format or changes the content.ConvertCapabilitydeclares uniform from×to blocks over MIME patterns and format tokens (OCR isimage/*→txt, transcription ispcm→txt/srt/vtt/json, audio decoding isaudio/*→pcm, PDF embedded-image extraction ispdf→images multi-output); a new capability is a new edge in the data, never a new contract. Engines are supplied as a flat ordered array (engines: [resolver, …]) resolved eagerly at construction — declarations drive verb narrowing; heavy peers still lazy-load inside capability methods. Dispatch is one rule everywhere: capability filter, then an optionalselectionmiddleware onion (stages may exclude or reorder candidates, never add — the seam for content-dependent quality rules like routing complex workbooks past a pure-JS converter to LibreOffice), then array order among survivors. Convert computes shortest multi-hop paths (up to three hops) through the declared format graph when no direct edge exists, with lossy/virtual tokens (txt, json, srt,pcm,images) as endpoints, never intermediates.ConvertRequest.optionsis a typed, consumer-augmentableConvertOptionsinterface (declaration merging against the contracts subpath). Bundled:engines/jimp(cross-env image mutate),engines/sharp(Node mutate incl. webp/avif +fromSharpBYO adapter),engines/tesseract_js(cross-env OCR convert; languages required),engines/audio_decode(cross-env audio→pcm, no ffmpeg),engines/transformers_asr(cross-env Whisper pcm→text; model id required — no silent multi-hundred-MB downloads), andengines/soffice(the LibreOffice convert matrix, which now also covers ODS/legacy-xls→xlsx — sheet normalization is just a conversion edge, not a separate engine). Binary-backed engines compose two further BYO contracts:BinaryExecutor(bundledengines/execa_executor) andScratchWorkspace(bundledengines/fs_workspace; explicit root, noos.tmpdir()default) — process execution and filesystem access are movable seams, not Node assumptions. The registry is exported (buildEngineRegistry) for standalone dispatch. - A battery-scoped ESLint plugin for the media pipeline (
@nhtio/adk/batteries/media/lint, namespaceadk-media) — battery-specific contracts ship with the battery, not the core@nhtio/adk/eslintplugin. Three rules:adk-media/prefer-engine-resolver(static value imports of bundled engine subpaths — the canonical supply form is the dynamic-import resolver; type-only imports pass),adk-media/no-shadowed-engine(an engine whose statically-known capabilities are fully covered by an earlier engine in the array is dead code under first-capable-wins dispatch), andadk-media/augment-contracts-module(ConvertOptionsdeclaration merging that targets any module other thanbatteries/media/contractssilently never merges). - Forged agent tools (
@nhtio/adk/batteries/media/forge):forgeMediaTools(mp, { surface })mints either the composite surface (onemedia_querytool taking{ media_id, q | ops }, its description embedding the engine-narrowed grammar with toPipe-generated examples, pluslist_media) or the granular surface (one tool per available verb). Outputs persist viactx.storeMediaBytesand return first-partyMedia; processing and DSL failures render as readableError (CODE): …strings the model can repair from. An optionalgate?: ToolGateFnruns before every execution — the human-approval/RBAC seam built onctx.waitFor. - Inline media id-markers in every LLM battery. Rendered media (attachments and tool results) is now preceded by a harness-authored
[media id: <id> | <filename>]text block so models can reference media by id in tool calls without a discovery round-trip. The marker is structural reference data from the harness-controlledMedia.id— no authority, fixed phrasing, outside the untrusted envelope. OpenAI is the reference implementation (WebLLM inherits); Ollama emits the same shape on its text channel. - Gate seam retrofit for SearXNG and Scrapper. Both factory batteries now accept the same optional
gate?: ToolGateFn, run before the HTTP request — network side effects deserve the approval seam too. Additive and backward-compatible. - New optional peer dependencies (pulled only by the engine/parser that needs them):
moo,pdf-lib,pdf-parse,mammoth,exceljs,jszip,jimp,sharp,audio-decode,@huggingface/transformers,tesseract.js,execa.
Fixed
- API documentation gaps closed. Several types referenced by public API surfaces were not themselves exported, so their doc pages didn't exist and links to them dangled:
EngineSummary(referenced by the media lint plugin'sBUNDLED_SUMMARIES),ChainExecutor(the media chain's executor seam), andAudioDecodeFn/AudioBufferLike(the audio-decode engine's override surface) are now exported and documented. The web-retrieval docs' links toRawRetrievablenow point at@nhtio/adk/common, where the type actually lives, andScrapperBaseConfigis re-exported from the scrapper barrel. Cosmetic prose fixes in the media docs ride along. No runtime behavior changes.
2026-06-09
Fixed
OpenAI Chat Completions battery now accepts
reasoning_effort: 'none'. The request validator constrainedreasoning_effortto['minimal', 'low', 'medium', 'high']and rejects unknown top-level keys (.unknown(false)), so there was no way to sendnone— the documented value Ollama's OpenAI-compatible/v1/chat/completionsneeds to turn a thinking model's (e.g. Gemma's) reasoning off.'none'is now in the enum (and thereasoning_efforttype union); it flows to the wire through the existing body-assembly passthrough, and the strict-unknown-key protection is unchanged. The WebLLM battery is unaffected — upstream WebLLM has noreasoning_effortfield and disables thinking viaextra_body.enable_thinking, already an open passthrough there.OpenAI Chat Completions battery now retries transport failures (HTTP status 0). When
fetchrejected before any HTTP response arrived (DNS failure, connection refused, TLS error, socket drop), the adapter immediatelynack'd with status 0 without consultingretry.maxAttempts— so a single transient network blip killed the turn even when retries were configured. The transport-failure branch now retries with backoff up tomaxAttemptsbefore surfacing the error, matching the request-timeout branch beside it and the sibling embeddings adapter. Governed by the existingretry.maxAttemptsknob;retriableStatusesis untouched (it gates HTTP responses, which transport errors never produce).Bundled deterministic tools now do exactly what their descriptions say. A correctness audit of the 17 deterministic tool batteries (
src/batteries/tools/*) — driven by a schema-fuzzing invariant harness and two independent model reviews, with every finding verified against the running tool — surfaced a class of defects where a tool would throw an unexpected runtime error, refuse work it advertised, or silently return a wrong value. All are fixed; each tool was changed to meet its label (no description or test was weakened to match broken behaviour):json_transform—top_nreturned the wrong end of the range (comparator inverted;descnow returns the largest n,ascthe smallest);unique_bynever deduplicated object/array key values (reference-identitySet→ now value-serialised);sumover a non-numeric array silently returned0(now a clear error); anulloperation entry crashed the dispatch (now a clean schema rejection).compare_records— a nested array and an integer-keyed object ([1,2]vs{"0":1,"1":2}) were reported equal; they are now distinct.color_contrast/color_scheme/color_adjust—hexToRgbaccepted hex strings with trailing non-hex characters (#1Z2Z3Z→ silentrgb(1,2,3)); invalid hex is now rejected.string_transform—reversesplit astral characters/emoji into broken surrogate halves (A💥Bnow reverses toB💥A);slugdestroyed non-decomposing Latin-1 letters (føtex→f-tex), now transliterated (fotex).parse_yaml— an empty/whitespace/BOM-only document returned a non-string (undefined), nownull;.NaN/.inf/-.infwere silently corrupted tonull, now preserved.format_table— null/primitive rows threw; they now render empty cells or return a clear "provide columns" error.format_list— an unboundedindentthrewRangeError; it is now clamped to 100.evaluate_katex— scientific notation (2e3) misparsed, and\log_b(x)change-of-base produced malformed output; both now evaluate correctly.encode_text— HTML-entity decoding of astral code points usedString.fromCharCode(truncating to 16 bits);🎉/🎉now decode to 🎉 viaString.fromCodePoint.date_period— fiscal-quarter boundaries spanning the calendar-year boundary were computed in the wrong year (e.g. FY-Feb,2024-01-15→ now correctly2023-11-01).convert_unit— temperatures below absolute zero are now rejected instead of silently returned.calculate— a non-finite scalar result (1/0,2^5000) now returns a clear error rather than printingResult: Infinity.
Updated three stale functional tests to the corrected
stats_describecontract. Thestatistics/flydrivethrough-runner tests still passedstats_describe'snumbersas a JSON string and asserted numericmean/sum— both invalidated by the tool-correctness pass above, which retypednumbersto a real array (restoring NaN/∞/>2^53rejection) and emits computed aggregates as precision-formatted BigNumber strings. The tests now pass actual arrays and assert the string-valued aggregates; no production behaviour changed.
Added
Scrapper web-extraction tool battery (
@nhtio/adk/batteries/tools/scrapper). Tools for any Scrapper instance — a headless-browser service that gives an agent browser-grade page reading (JS-rendered pages a plain fetch can't see) as a stateless HTTP call: fresh incognito context per request, no stored session/cookies/credentials. Two verbs, each with an async factory (accepts a dynamic-importartifactresolver) and a sync variant:createScrapperArticleTool/…Sync(/api/article) andcreateScrapperLinksTool/…Sync(/api/links). Like the SearXNG battery these are factories (not constants) and must not be bulk-registered viaObject.values(batteries).- Per-parameter disposition — for every modeled knob the factory chooses:
fixed(pinned; sent always, removed from the model schema),defaults(model-overridable), or open (model-settable).urlis always required;fixedQueryis a raw kebab passthrough for un-modeled params, keeping the battery generic across instances/versions. - Two distinct header channels —
config.headers(static or sync/async resolver) authenticates to the Scrapper instance; theextra_http_headersparam ('K:v;K2:v2') is what the scraper's browser sends to the target site. - Same SearXNG-style two-level output (
resultFormatnormalized/raw/either),artifactresolver, and input/output middleware pipelines (shortCircuit, fresh runner per call). Errors degrade toError:strings (parses Scrapper's{detail:[{msg}]}; missingurl→ HTTP 422); bad config →E_INVALID_SCRAPPER_CONFIG. Documented as a featured-battery page with TSDoc@warnings for thescroll_down-needs-sleepand instance-relative-URI gotchas. Cross-env unit spec (stubbedfetch, disposition, resolver, all-three-artifact round-trips) + env-gated live integration spec (TEST_SCRAPPER_URL/TEST_SCRAPPER_HEADERS).
- Per-parameter disposition — for every modeled knob the factory chooses:
Web-retrieval RAG glue (
@nhtio/adk/batteries/tools/web_retrieval). The shared seam from search/scrape results to turnRetrievables, used by both the Scrapper and SearXNG batteries. Pure converters —searxngResultsToRetrievables,scrapperArticleToRetrievable,scrapperLinksToRetrievables— return plainRawRetrievable[](zero core-class instantiation; core referenced asimport typeonly).storeRetrievables(ctx, raws, { retrievable })constructs and stores records via a resolver-injectedRetrievableconstructor (ctor / sync / async / dynamic-import), so the module never value-imports core. Long page text becomes a reader-backedSpooledArtifactvia a callerspoolhook (the converter recommends an openArtifactConstructorResolverfor the content — markdown/json/text — so a consumer's own subclass works unchanged; no chunker). Web content defaults totrustTier: 'third-party-public'(a constant, not URL inference — CONTRIBUTING DD#12).Shared tool-battery helpers (
@nhtio/adk/batteries/tools/_shared). Internal building blocks for the configured-HTTP tool batteries:resolveArtifact/resolveArtifactSync(resolver → sync() => Ctor), the onion middleware-pipeline runners (fresh runner per call, short-circuit + non-terminal detection), header resolution, and theArtifactResolver/SyncArtifactResolvertypes. SearXNG and Scrapper both build on it instead of carrying copies.SearXNG search tool battery (
@nhtio/adk/batteries/tools/searxng). A web-search tool for any SearXNG instance, exposed via factories — asynccreateSearxngSearchTool(config)and synccreateSearxngSearchToolSync(config)— rather than a ready-made constant. It is the first factory-style tool battery: a search tool has to know which instance to query and is usually behind custom authentication, so it needs per-deployment config that cannot be baked in at module load. Because it exports factories (not aTool), they must not be bulk-registered viaObject.values(batteries)— call a factory first, then register the returned tool.- Custom-header auth —
config.headersaccepts a staticRecord<string,string>or a sync/async resolver (() => headers | Promise<headers>); the resolver runs on every search, so refreshable bearer tokens work. Caller headers override the defaultAccept/User-Agent. - Two-level output-format control —
config.resultFormat: 'normalized' | 'raw' | 'either'(default'either'). Pinning it forces the shape AND removes the model-facingformatarg from the schema; leaving it neutral lets the model choose per call.normalizedtrims each result to{title,url,content,engine,score,publishedDate}plus non-emptyanswers/infoboxes/suggestions/corrections;rawreturns the full SearXNG JSON. - Input/output middleware pipelines —
config.inputPipeline/config.outputPipelineare onion middleware(ctx, next)built on@nhtio/middleware. Input stages mutate the query/params/headers before the request orctx.shortCircuit(string)to skip the fetch (cache hit); output stages filter/re-rankctx.results, mutatectx.raw, or setctx.outputverbatim (e.g. rendered markdown). Actx.stashMap carries across both; a fresh runner is minted per invocation (middleware runners are single-use). - Configurable spool artifact (resolver) —
config.artifact(default() => SpooledJsonArtifact) is an openArtifactConstructorResolver: a ctor, a sync resolver, or — via the async factory — an async/dynamic-import resolver (() => import('@nhtio/adk/spooled_artifact').then(m => m.SpooledMarkdownArtifact)), so a consumer's ownSpooledArtifactsubclass works with no battery change. The async factory resolves it before building theTool(whoseartifactConstructormust be sync); the sync factory accepts only the sync subset. - Graceful failures — a disabled-JSON instance (SearXNG disables JSON by default → HTTP 403), network errors, timeouts, and thrown pipeline stages all return
Error:strings the model can react to; only malformed args throw (E_INVALID_TOOL_ARGS). Invalid config throws the battery-scopedE_INVALID_SEARXNG_CONFIGat factory-call time. - Documented as a featured-battery page, with a TypeDoc
@warningrecording the upstream quirk that SearXNG'snumber_of_resultsis frequently0even when results exist (searxng#2987, searxng#2457) — the tool passes it through verbatim; useresults.length. Covered by a cross-env unit spec (stubbedfetch, all three artifact types round-tripped) and an env-gated live integration spec (TEST_SEARXNG_URL/TEST_SEARXNG_HEADERS).
- Custom-header auth —
Documentation-coverage gate (
bin/doc_coverage.ts,pnpm run doc:coverage). A standalone helper that bootstraps TypeDoc read-only over the same entrypoints the published docs use (bin/utils/index.tsgetEntries) and reports every public API symbol missing a TSDoc comment, grouped by its deepest@modulesubmodule. Modes: a human report (default),--json,--ci(non-zero exit when any non-allowlisted symbol is undocumented — wired into CI as a job, currentlyallow_failure: true),--hook(emits a Claude CodeadditionalContextenvelope and always exits 0), and--primary(audits@primaryExportplacement). The sharedblockTagslist moved to an exportedBLOCK_TAGSconst so the helper andmakeApiDocsnever drift. The entire public API surface is now documented — the gate reports zero undocumented symbols. Every interface, type, class member, options field, wire shape, and exported function across the LLM, vector, embeddings, storage, and ESLint-rule batteries carries an accurate TSDoc comment.The API-doc build is also link-clean: every TypeDoc cross-reference now resolves. Types that documented symbols referenced but that were not themselves exported are now public —
ArtifactConstructorResolver(@nhtio/adk/forge), the fourDispatchRetrievable*Fncallback types (@nhtio/adk/types), and the pgvector / sqlite-vec adapter options interfaces, renamed for consistency with the other 24 adapters toPgVectorStoreOptionsandSqliteVecVectorStoreOptions. Vendor types referenced in comments (BigNumber,Set,Disk) now link to their upstream docs viaexternalSymbolLinkMappings, and broken{@link}targets (wrong or non-exported names) were corrected. The internal, sentinel-gatedDispatchRunnerconstructor is marked@internal(construct via the staticDispatchRunner.dispatch).Native Ollama LLM battery (
@nhtio/adk/batteries/llm/ollama). ShipsOllamaAdapter, an executor targeting Ollama's native/api/chatendpoint — distinct from pointing the OpenAI Chat Completions battery at/v1, which it complements rather than replaces. Works with both local Ollama (http://localhost:11434, no auth — the defaultbaseURL) and cloud Ollama (https://ollama.com,apiKey→Authorization: Bearer); onlybaseURLplus the auth header differ. Native-only capabilities the/v1compat layer cannot express are first-class: per-request context size via the nestedoptions.num_ctx, native reasoning viathink(boolean | 'low' | 'medium' | 'high') surfaced asmessage.thinking, structured output viaformat('json'or a JSON schema), and model lifecycle viakeep_alive. Generation params live in a nestedoptionsblock (not at the top level, unlike the OpenAI wire). The adapter parses NDJSON streaming (terminated bydone: true, no[DONE]sentinel), takes tool-callargumentsas a JSON object (noJSON.parse), labels tool-result history messages withtool_name(nottool_call_id), and follows every cross-battery design rule (trust-framed envelopes, per-tool trust, swappable helpers,ctx.stash.ollamaper-iteration overrides,ToolCall.inlinehandling, trust-tier-distinct buckets). Native/api/chatcarries images only; other modalities route throughunsupportedMediaPolicy.tool_choiceis intentionally unsupported (native/api/chathas no such field). Ollama is HTTP-only — Unix-socket deployments are reached via a bridge or a customfetch.Dedicated generation-stats observability channel on
DispatchRunner. Executors can emit provider-agnostic generation accounting (token counts, nanosecond durations, finish reason, model, provider, plus the raw provider object) via a newhelpers.reportGenerationStats(stats)method; the runner enriches each record withdispatchId/iteration/emittedAtand fires it on a newgenerationStatsobservability hook (subscribe throughobservers.generationStats). This is additive and non-breaking —DispatchExecutorHelpersis runner-produced, so existing executors gain the method without change. The native Ollama battery emits its terminal-chunk stats through this channel; the newGenerationStats/GenerationStatsEventtypes are exported from@nhtio/adk/dispatch_runner.Shared Chat-family helper submodule. The wire-shape-agnostic translation helpers (trust envelopes, memory/retrievable/standing-instruction rendering, system-prompt assembly, JSON-schema and function-tool conversion, thought filtering) were extracted to an internal
src/batteries/llm/chat_commonmodule shared by the OpenAI Chat Completions and native Ollama batteries. Behaviour-preserving: every existing@nhtio/adk/batteries/llm/openai_chat_completionshelper export keeps its name and value identity (the battery re-exports the shared names), and the WebLLM battery is untouched. The shared module is internal — not a public package subpath.NDJSON cassette support in the cross-env test harness.
tests/_fixtures/cassette.tsgained anndjsonresponse mode (parallel to the existing SSEssemode) plus Ollama-native programmatic builders (buildOllamaChatResponse,buildOllamaStreamFrames,singleOllamaResponseCassette,singleOllamaStreamCassette) for deterministic native-wire replay.Arbitrary-precision numeric handling across the math tools. A shared
src/lib/helpers/bignum.ts(a BigNumber-configuredmathjsinstance) backs the numeric batteries so float64 limitations no longer corrupt results: large in-range sums stay exact instead of overflowing toInfinity, tiny ratios don't underflow to0, and precision is preserved end-to-end (sum([0.1, 0.2]) → 0.3).statistics,data_structure, andunit_conversionnow compute aggregates/conversions through it. Thestatisticstools take typed number arrays (validator.array().items(validator.number())) instead of JSON strings — restoring schema rejection ofNaN/Infinity/> 2^53at the boundary and removing the prior silent-drop behaviour. Tools that format numeric output gained an optionalprecisionargument (significant digits, default 8). This changes those tool signatures and some output shapes (computed aggregates may be precision-formatted strings).Tool correctness test infrastructure. A
callToolhelper intests/_fixtures/tool_ctx_stub.tscaptures a tool invocation's resolve-vs-throw outcome as a value (making the no-crash contract directly assertable), and a newtests/unit/batteries/tools/fuzz.node.spec.tsinvariant harness introspects every bundled tool's schema, feeds adversarial input, and asserts each call either resolves to a string/Uint8Arrayor rejects withE_INVALID_TOOL_ARGS— never any other throw.
2026-06-07
Added
SoDK — a mental-model doc that teaches the loop in human terms. A new page,
docs/sodk.md("Society Development Kit"), retells How agents work and What ADK is with exactly one noun swapped: where ADK says model, SoDK says person. It is a teaching device for the reader who can't yet see why an agent is the loop, not the LLM — role↔agent, task↔turn, briefing↔context, request↔tool, process↔middleware, "say where things get filed"↔the required storage callbacks. The human-facing prose plays it straight; the<llm-only>block names the metaphor outright and carries the full 1:1 map, so an agent answering a question can explain a concept through the framing or translate either way. Wired into the sidebar and home listing, and cross-linked from both source docs. Docs only — no code, types, or package surface change.Importable ESLint plugin (
@nhtio/adk/eslint). The harness's documented contracts are now machine-checkable: a flat-config plugin that flags footguns the TypeScript compiler cannot see because they live in runtime validators or conventions, not types. Five rules ship —require-validator-any-required(avalidator.any()chain with no explicit.required()/.optional()/.default()/.forbidden()silently admits null/undefined),thought-payload-requires-replay-tag(aThoughtwith a vendorpayloadbut noreplayCompatibilitycan never be safely replayed),token-encoding-requires-context-window(a Chat Completions adapter that counts tokens with no budget never runs its overflow guard),artifact-tool-forbids-artifact-constructor(anArtifactToolthat wraps another artifact recurses forever), andno-model-in-tool-handler(a model call inside a tool handler hides an unmanaged dispatch — unless the handler runs its own scoped sub-agent vianew TurnRunner(...)orDispatchRunner.dispatch(...)). Import the assembled plugin from@nhtio/adk/eslint(oradk.configs.recommendedfor all five), or individual rules from@nhtio/adk/eslint/rules/<name>.eslintand@typescript-eslint/utilsare optional peers — installed only by consumers who lint with the plugin. Rules are report-only with inlineeslint-disablecarve-outs. See the new Developer Tools docs section, which also now houses the ADK Assembly MCP guide.Vector conformance harness is now public (
@nhtio/adk/batteries/vector/conformance). TherunVectorStoreConformancesuite (plusstubEncoder/paddedStubEncoder) that the 29 shipped adapters test against is now an exported, deep-import-only subpath, so anyone writing their own adapter can prove it against the exact same contract. The subpath importsvitest, declared as an optional peer (peerDependenciesMeta) — install it to run the suite; it is never pulled in by the@nhtio/adk/batteries/vectorbarrel, so acreateVectorStoreconsumer takes on no test-runner dependency. Seedocs/batteries/vector/custom-adapter.md.Query-builder grouping callbacks — mix AND and OR. The
VectorQueryBuilderfilter methods (.where/.andWhere/.orWhere/.whereNot/ new.orWhereNot) now accept a callback that receives a filter-onlyFilterBuilder, so you can expressA AND (B OR C)and negated groups ({ not: <group> }) at any nesting depth — previously the builder could only emit flat DNF. The scalar forms are unchanged (.whereNot('f', v)is still→ ne). Groups compile to the neutralFilterGrouptree; the 6 native filter translators recurse over it and the over-fetch adapters JS-evaluate it. Chroma rejects anotgroup withE_VECTOR_STORE_UNSUPPORTED_FILTER_OPERATOR(consistent with its existingexists/containslimits); nested AND/OR works on all 29. Seedocs/batteries/vector/query-builder.md.
Fixed
.orWhere()no longer silently drops its branch.where(A).where(B).orWhere(C)previously compiled to(A AND B) OR (A AND B AND C), which collapses to just(A AND B)— the.orWhere(C)was a no-op. It now correctly yields(A AND B) OR C, matching the documented knex semantics.Chroma multi-row filter-scan. A filter-scan (no
.near*()) that matched more than one record returned only the first row: the adapter unwrappedquery()'s nested result arrays on theget()path too. Fixed to unwrap only on the similarity path.
2026-06-06
Added
Cloudflare Vectorize adapter (
@nhtio/adk/batteries/vector/cloudflare). Managed, serverless vector store over the Vectorize V2 REST API — purefetch, no driver/peer dependency. A logical collection maps to a Vectorize index (indexNamePrefixisolates per use). Upserts use the NDJSON multipart endpoint (fieldvectors); query/get/delete use JSON. Dimensions must be 32–1536. KNNscoreis recomputed locally from the returned values to the[0,1]contract; the document rides in a reserved__documentmetadata key. Native metadata filtering needs pre-created metadata indexes and lacks$and/$or, so the adapter over-fetches (topK 50, the service cap when returning values/metadata) and JS-filters via the neutralevaluateFilterfor full cross-adapter parity. Cloudflare Vectorize is aggressively eventually-consistent — a fresh index takes ~8–34s before its first write is queryable and the query index flaps for seconds after writes/deletes; the adapter settle-polls the query index for stability, and the integration spec additionally uses vitestretryto ride out the flap deterministically (slow, ~8 min, but green). Managed, so no docker/CI matrix entry (like Pinecone / S3 Vectors). Verified 7/7 conformance against live Cloudflare Vectorize. This also adds an optionalretry/timeoutparameter to the sharedrunVectorStoreConformanceharness (defaults preserve existing behavior).Oracle 23ai AI Vector Search adapter (
@nhtio/adk/batteries/vector/oracle23ai). Each collection is a table with a nativeVECTOR(dims, FLOAT32)column; vectors are bound/read asFloat32Arrayvia theoracledbdriver in thin mode (no Instant Client). KNN usesVECTOR_DISTANCE(vec, :q, COSINE|EUCLIDEAN|DOT) ORDER BY … FETCH APPROX FIRST k ROWS ONLY; the raw distance only orders candidates — the[0,1]score is recomputed locally from the stored vector. Metadata is a JSON-string CLOB (read viafetchInfoSTRING) filtered with the neutralevaluateFilter; identifiers are double-quoted andtablePrefixisolates collections. Strongly consistent (commit per write). NB: VECTOR columns are rejected in the SYSTEM tablespace — the connecting user must default to a normal tablespace (e.g. USERS) and have CREATE TABLE; the dockeroracleprofile provisions such a user viaAPP_USER. Verified 7/7 conformance ×3 against a live Oracle Free 23ai. Closes the Oracle 23ai gap in the Open WebUI minimum-support set.AWS S3 Vectors adapter (
@nhtio/adk/batteries/vector/s3vectors). Managed, serverless vector store (no container, like Pinecone). The vector bucket is provisioned out-of-band; a logical collection maps to an index inside the bucket (indexPrefixisolates per use — index names must be 3–63 chars). KNN viaQueryVectors(the returneddistanceis converted to the battery's normalized[0,1]score — cosinesim = 1 - distance);PutVectors/GetVectors/DeleteVectorsfor upsert/fetch/delete by key; metadata is native JSON with the document under a reserved__documentkey.topKis capped at the service max of 100, so filtered/scan reads over-fetch to that ceiling and JS-filter via the neutralevaluateFilterfor cross-adapter parity; eventual-consistency settle-polling makes read-after-write deterministic. Metrics:cosine/euclidean(S3 Vectors has no dot-product —dotthrows at createCollection). Driver:@aws-sdk/client-s3vectors(lazy; credentials from the ambient AWS chain). Verified 7/7 conformance ×3 against a live bucket in eu-west-1. Closes the S3 Vector Bucket gap in the Open WebUI minimum-support set.Elasticsearch 8 vector adapter (
@nhtio/adk/batteries/vector/elasticsearch). A dedicated adapter for the Elasticsearch 8 dialect — each collection is an index with adense_vectorfield, and KNN uses ES8's top-levelknnsearch clause with an optionalfilter. This is distinct from the existingopensearchadapter, which speaks OpenSearch'sknn_vector/query.knndialect (an ES client cannot drive it). The neutral filter tree compiles to ES bool/term/range overmetadata.*(.keywordfor strings) via the exportedtranslateElasticsearchFilter; writes usebulk({ refresh: true })for strong consistency; cosine_score(already(sim+1)/2 ∈ [0,1]) is normalized defensively. Driver:@elastic/elasticsearch(lazy; use the v8 client against an 8.x server — a v9 client sends a compatibility header an 8.x server rejects). BYO client supported viaconnection.client. Verified 7/7 conformance ×3 against a live Elasticsearch 8.18.Vespa vector adapter (
@nhtio/adk/batteries/vector/vespa). Vespa has no runtime collection creation — a collection is a document type declared in a deployed application package. The adapter holds the package state in memory and rebuilds + redeploys it (via a dependency-free, store-only ZIP writer — no zip lib needed) to the config server'sprepareandactivateendpoint on eachcreateCollection/dropCollection, generatingservices.xml,hosts.xml, avalidation-overrides.xml(≤30-day window, for schema-removal / type-change), and aschemas/<collection>.sdper collection with an HNSW tensor field. KNN uses a YQLnearestNeighborquery with aclosenessrank profile; filter-scan/delete use YQL + document-API; scores are re-computed locally from the stored vector vianormalizeScorefor the [0,1] contract guarantee (metric maps cosine→angular, dot→dotproduct, euclidean→euclidean). No npm driver — pure HTTP/fetch. Metadata is a JSON string field filtered with the neutral evaluator. Verified 7/7 conformance ×3 against a live Vespa.Couchbase vector adapter (
@nhtio/adk/batteries/vector/couchbase). Enterprise Edition only — vector search is an EE feature; Community throws "vector typed fields not supported". A logical collection maps to a Couchbase scope.collection. KV operations (upsert/get/remove) are strongly consistent and serve point reads; the scoped FTS vector index is async, so it is settle-polled after writes and used only to retrieve the KNN candidate id set — scores are then re-computed locally from the stored vector vianormalizeScore, guaranteeing the [0,1] contract regardless of the backend metric (cosine/dot_product/l2_norm). Filter-scan, enumerate and delete-by-filter use N1QL withRequestPlusfor strong reads.collectionPrefixisolates collections (avoids per-test FTS-index rebuild churn). Metadata is a JSON string field filtered with the neutral evaluator. Driver:couchbase. Cluster/bucket are provisioned non-interactively (RESTclusterInit+ bucket create — see the docker-composecouchbaseprofile's init sidecar); the adapter manages scopes/collections + the FTS vector index. Omitted from the CI matrix (its two-step init can't be expressed as a single service alias); verified 7/7 conformance ×3 against a live Couchbase EE 8.0.
2026-06-05
Added
MongoDB Atlas Vector Search adapter (
@nhtio/adk/batteries/vector/mongodb). Each collection is a MongoDB collection with an AtlasvectorSearchindex onvec; KNN uses the$vectorSearchaggregation stage (cosinevectorSearchScore, [0,1]). Because the Atlas vector index updates asynchronously (~1s) while the document store is strongly consistent, filter-scans / fetch-by-id / delete read-back use a plainfind()(immediate) and only KNN goes through$vectorSearch— with a post-write settle polling until the inserted ids are index-visible.collectionPrefixisolates collections (avoids per-test index rebuild churn). Metadata is a JSON string field filtered with the neutral evaluator. Driver:mongodb; works againstmongodb/mongodb-atlas-localor a real Atlas cluster. Verified 7/7 conformance against a live atlas-local.Apache Solr vector adapter (
@nhtio/adk/batteries/vector/solr). Dense-vector / kNN query parser (Solr 9+): a collection maps to a Solr core, the adapter ensures aDenseVectorField(vec) +document/metadatafields in the core schema, and searches with{!knn f=vec topK=N}[…](cosine score already [0,1]). Metadata is a JSON string field filtered with the neutral filter tree's JS reference evaluator. No driver dependency — plain HTTP/JSON viafetch. The target core must already exist (solr-precreate <core>); the adapter manages its schema, not the core. Verified 7/7 conformance against a live Solr 9.HNSWLib vector adapter (
@nhtio/adk/batteries/vector/hnswlib). Embedded, in-process (no server). Wraps thehnswlib-nodenative ANN index for KNN, paired with a JS sidecar that owns id↔label mapping and the document/metadata records (hnswlib stores vectors only); metadata filtering, filter-scans, projection, and delete are served from the sidecar via the neutral filter tree's JS reference evaluator. Native build must be approved (pnpm-workspace.yamlallowBuilds). Verified 7/7 conformance in-process.ArangoDB vector adapter (
@nhtio/adk/batteries/vector/arangodb). Each collection is an ArangoDB document collection keyed by_key; KNN uses the exact AQLCOSINE_SIMILARITY/L2_DISTANCEfunctions (no index required, always correct), with the experimental IVFvectorindex created lazily on first upsert for production-scale ANN. Metadata in a JSON string attribute filtered with the neutral filter tree's JS reference evaluator. Driver:arangojs. Verified 7/7 conformance against a live ArangoDB 3.12 backend.Neo4j vector adapter (
@nhtio/adk/batteries/vector/neo4j). Native vector index (5.13+): each collection is a node label with aVECTOR INDEXonvec; KNN viadb.index.vector.queryNodes(cosine score already [0,1]). Metadata is a JSON string property filtered with the neutral filter tree's JS reference evaluator. Upsert viaMERGE; integer params wrapped withneo4j.int(). Driver:neo4j-driver. Verified 7/7 conformance against a live Neo4j 5 backend.SurrealDB vector adapter (
@nhtio/adk/batteries/vector/surrealdb). Multi-model; each collection is a SurrealDB table storing the vector as an array field, KNN viavector::similarity::cosine/vector::distance::euclideanordered appropriately. Metadata in a JSON string field filtered with the neutral filter tree's JS reference evaluator. All queries parameterized (type::thing,$bindings). Upsert viaUPSERT. Driver:surrealdb. Verified 7/7 conformance against a live SurrealDB v2 backend.LanceDB vector adapter (
@nhtio/adk/batteries/vector/lancedb). Embedded, no server (file-based, like sqlite-vec/duckdb). Each collection is a Lance table with an explicit Arrow schema (vecasFixedSizeList<Float32>); KNN viatable.search(vector).distanceType(...), metadata in a JSON string column filtered with the neutral filter tree's JS reference evaluator. Upsert via merge-insert onid. Drivers:@lancedb/lancedb+apache-arrow(prebuilt binary, no native compile). Verified 7/7 conformance in-process (temp dir).MariaDB vector adapter (
@nhtio/adk/batteries/vector/mariadb). NativeVECTOR(N)columns (MariaDB 11.7+): vectors written withVEC_FromText/ read withVEC_ToText, KNN viaVEC_DISTANCE_COSINE/VEC_DISTANCE_EUCLIDEAN; metadata in aJSONcolumn filtered with the neutral filter tree's JS reference evaluator. SQL backend → transactions + rawSql. Upsert viaON DUPLICATE KEY UPDATE. Driver:mariadb. Verified 7/7 conformance against a live MariaDB 11.7.Meilisearch vector adapter (
@nhtio/adk/batteries/vector/meilisearch). Each collection is a Meilisearch index with auserProvidedembedder (BYO vectors under_vectors.default); KNN via semantic search (vector+hybrid.semanticRatio = 1),_rankingScoremaps directly to the [0,1] score contract. Metadata is a JSON string field filtered with the neutral filter tree's JS reference evaluator. Writes await task completion (strongly consistent). Enables thevectorStoreexperimental feature on connect. Driver:meilisearch. Verified 7/7 conformance against a live Meilisearch backend.Typesense vector adapter (
@nhtio/adk/batteries/vector/typesense). Each collection is a Typesense collection with a nativefloat[]vector field (KNN viavector_query); metadata is a JSON string field filtered with the neutral filter tree's JS reference evaluator. Native upsert by id; strongly consistent (writes searchable on resolve). Driver:typesense. Verified 7/7 conformance against a live Typesense backend.Elasticsearch / OpenSearch vector adapter (
@nhtio/adk/batteries/vector/opensearch). One adapter for the whole family — they share the kNN_searchdata model. Each collection is an index with aknn_vector(HNSW/Lucene) field; the neutral filter tree compiles to a bool-query overmetadata.*keyword/numeric sub-fields. Writes userefresh: truefor read-after-write consistency. Driver:@opensearch-project/opensearchby default; pass an@elastic/elasticsearchclient viaconnection.clientto target Elasticsearch. Verified 7/7 conformance against a live OpenSearch backend.ClickHouse vector adapter (
@nhtio/adk/batteries/vector/clickhouse). Vectors in anArray(Float32)column, KNN viacosineDistance/L2Distance/ negative-inner-product ordered ascending; metadata in a JSONStringcolumn. MergeTree allows duplicate keys, so upsert is delete-then-insert, and writes are made read-after-write consistent withmutations_sync = 2. Driver:@clickhouse/client. Verified 7/7 conformance against a live ClickHouse backend.DuckDB vector adapter (
@nhtio/adk/batteries/vector/duckdb). In-process, no server (like sqlite-vec) — uses thevsscommunity extension'sarray_*_distancefunctions over aFLOAT[N]column for KNN, with metadata in aJSONcolumn. Driver:@duckdb/node-api. Verified 7/7 conformance in-process (:memory:).Redis / Valkey vector adapter (
@nhtio/adk/batteries/vector/redis). One adapter for the whole Redis family via the RediSearch module (redis/redis-stack-server, or any Redis/Valkey with RediSearch loaded). Vectors are stored as FLOAT32 blobs on Redis hashes and searched withFT.SEARCH ... KNN; the neutral filter tree compiles to RediSearch query syntax (TAG/NUMERIC). Verified 7/7 conformance against a live RediSearch backend.The
evaluate_katexmath tool now evaluates calculus numerically. It previously mangled any calculus input —\int_{0}^{1} x dxhad its bounds stripped by the LaTeX flattener and produced a crypticSyntax error in part "\int^(1) x dx". The tool now detects calculus on the raw LaTeX before flattening and computes it numerically with the bundled mathjs (no new dependency): definite integrals (\int_{a}^{b} f \,dx) via Simpson quadrature, derivatives at a point (\frac{d}{dx} f \big|_{x=a}) via central finite difference, and limits (\lim_{x \to a} f, includinga = \pm\infty) via a two-sided approach. Results are rounded and labelledResult (numeric):to flag the approximation. Genuinely uncomputable inputs (indefinite integrals, derivatives without a point, infinite integration bounds, singular integrands, divergent limits) return a specific, guiding error instead of a garbled one. mathjs has no symbolic integration and its symbolicderivativeis intentionally blocklisted here, so these are numeric methods.
Fixed
evaluate_katexnow maps inverse trig to the correct mathjs names.\arcsin,\arccos, and\arctanwere passed through asarcsin/arccos/arctan, which mathjs does not define, so every inverse-trig expression errored withUndefined function. They now translate toasin/acos/atan.
Changed
Replaced the hand-rolled LaTeX regex parser with evaluatex. The
evaluate_katextool's LaTeX-to-mathjs translator (latexToMathjs) used brittle regex substitutions that could not handle nested braces, causing expressions like\frac{\sqrt{100}}{2}to produce a Syntax Error. It is replaced by the evaluatex library (v2.2.0, zero deps, ~56KB, works in Node.js and all browsers), which parses LaTeX with a proper recursive parser. The scalar evaluation path now uses evaluatex directly; the numeric calculus path (integrals, derivatives, limits) still uses mathjs for per-point evaluation via a shared lightweight LaTeX-to-string translator. evaluatex is an optional peer dependency, following the existing battery pattern.ToolRegistrynow supports hidden tools. A tool can be registered and callable without being immediately visible to the model — hidden state lives on the registry, not the tool. New methods:hide(...names),unhide(...names),setHidden(...names),clearHidden(),visible(), andhidden(). The LLM batteries now readvisible()instead ofall()when building the tool definition list, so hidden tools are excluded from the rendered tool list but still resolve when called by name. Hidden state propagates throughToolRegistry.merge, and unregistering a tool automatically cleans up its hidden state. This enables discovery patterns where an agent has a tool that enumerates available tools, and the model picks one to call in a subsequent iteration without listing everything upfront.
2026-06-04
Fixed
- LLM batteries now surface reasoning from providers that use the
reasoningfield. The OpenAI and WebLLM Chat Completions batteries read onlyreasoning_content, so thinking output from endpoints that emitreasoning(Ollama's/v1, post-rename vLLM, OpenRouter) produced no thought events in either streaming or non-streaming mode. Reasoning is not part of OpenAI's official Chat Completions spec, so OpenAI-compatible providers disagree on the field name; both batteries now readreasoningandreasoning_contentacross both the streaming delta and non-streaming message shapes. Verified live against a per-model matrix of real endpoints (claude-haiku-4-5, gemini-3.5-flash, gemma4, deepseek-v4-flash, glm-5.1, gpt-oss:20b, kimi-k2.6, and a workstation Ollama tag).
Added
reasoningFieldPrecedenceoption on the Chat Completions batteries. An ordered, de-duplicating control over which provider reasoning field wins. When more than one listed field is present with identical content (or only one is present) a single thought is emitted, attributed to the highest-precedence field; when they diverge, each surfaces as its own thought rather than silently dropping one (in streaming mode both stream live and are de-duplicated by content at persistence). Defaults to['reasoning', 'reasoning_content']. A typedreasoningfield was added to theChatCompletionsChunkDeltaandChatCompletionsResponseMessagewire shapes, and the newReasoningField/ReasoningFieldPrecedence/ReasoningExtracttypes plus theextractReasoningFieldshelper are exported from both batteries.
2026-06-03
Changed
- MCP install examples now render the current package version at docs build time. The ADK MCP guide uses a
1.20260719.0token for pinned@nhtio/adk@...examples, and the docs build rewrites it frompackage.jsonfor VitePress pages, LLM artifacts, the Ask ADK index, and the packaged MCP corpus. Release docs now stay aligned with the published package version without hand-editing install snippets before every tag.
2026-06-02
Fixed
- Corrected the
callIddocumentation on the tool-execution events.ToolExecutionStartEvent.callIdandToolExecutionEndEvent.callIdwere documented as correlating withToolCall.id. They do not:callIdissha256({ tool, args })— the same value asTurnToolCallContent.checksumandToolCall.checksum. The two buses join ontoolCall.checksum === toolExecution*.callId, never ontoolCall.id. The hash collides by design for identical(tool, args)(that is whatDispatchContext.toolCallCountcounts), so order or disambiguate repeated calls by theDateTimefields (createdAt/updatedAt,startedAt/endedAt). TSDoc and the Events guides now state this contract; no runtime behavior changed.
2026-06-01
Added
- Embeddings batteries (
@nhtio/adk/batteries/embeddings/openai,@nhtio/adk/batteries/embeddings/webllm) — two opt-in embedders that share one shape and differ only in their engine.OpenAIEmbeddingsAdapterPOSTs to any OpenAI-/v1/embeddings-compatible endpoint over rawfetch(Node/browser/edge/workers);WebLLMEmbeddingsAdapterembeds in-process on WebGPU via@mlc-ai/web-llm. Both exposeembed/embedMany/dimensions/preload/reset/isAvailable, return wire-nativenumber[]/number[][], require an explicitmodel(no default), and handle query/document instruction prefixes identically via a sharedkind: 'query' | 'document'option. The environment-neutral OpenAI battery is re-exported from@nhtio/adk/batteries/embeddings; the WebGPU-only WebLLM battery is reachable only via its own subpath. Embedders are tools you call from your own retrieval middleware — they do not plug into an executor slot. See the newdocs/assembly/batteries-embeddings.md.
Fixed
E_INVALID_TURN_RUNNER_CONFIGnow names the offending field. A misconfiguredTurnRunnerpreviously threw a generic "cannot be instantiated with the provided configuration" with no indication of which field failed. The exception now carries the validator's field-level detail (e.g.…: storeMediaBytesCallback is required) and attaches the rawValidationErroroncause.- Unknown-tool errors now list the available tools. When the model calls a tool that is not in the registry, the OpenAI and WebLLM Chat Completions batteries persist a tool-call error reading
Tool not found: <name>. Available tools: <a, b, c>.(orNo tools are available this turn.) so the model can self-correct on the next iteration instead of dead-ending on an opaque "not found".
2026-05-31
Added
- Packaged ADK Assembly MCP server (
src/mcp/server.ts) —@nhtio/adknow ships a local stdio MCP server that can be launched withnpx -y @nhtio/adk. The server exposes ADK assembly guidance, packaged documentation search, document reads, generated API lookup, and pasted-code assembly review through MCP tools, resources, and prompts. - Version-aligned MCP documentation corpus (
dist/mcp/adk-docs-corpus.json) — package generation now copies hand-written docs, generated TypeDoc API pages, changelog content, and the ADK assembly Skill into a read-only corpus for the MCP server. The corpus is built from the docs available at package time so MCP answers match the installed package version. - ADK MCP documentation page (
docs/mcp.md) — added a VitePress guide for installing and using the ADK MCP across common coding-agent clients, including VS Code / Copilot, Claude Code, Claude Desktop, Cursor, Windsurf, Cline / Roo Code, and Continue. - Unified
ByteStore<R>storage contract (src/lib/contracts/byte_store.ts) — the single low-level "give bytes, get a reader" shape every storage layer implements, withSpoolStore(ByteStore<SpoolReader>) andMediaStore(ByteStore<MediaReader>) semantic aliases.writeacceptsstring | Uint8Array | ReadableStream<Uint8Array>; string input is UTF-8-encoded. Exported alongsideimplementsByteStoreandbyteStoreSchema. - Injectable
spoolStoreoption on the OpenAI and WebLLM Chat Completions batteries — back tool-output artifacts with durable storage (OpfsSpoolStore, a Flydrive-backed store) instead of the default per-dispatch in-memory store. Durable stores also stream large/binary tool output to disk rather than buffering it in memory. ctx.storeMediaBytes(id, bytes)→MediaReaderandctx.storeRetrievableBytes(id, bytes)→SpoolReader— handler-reachable byte-persistence conduits that route tool-generated media and large extracted RAG text into consumer storage. Both accept aReadableStream. Exposed onTurnContextandDispatchContext;ConduitBytesis exported from the public API.- Reader-backed
Retrievable.content—contentnow accepts aSpooledArtifactin addition tostring | Tokenizable, so large extracted RAG text can live in a consumerByteStoreinstead of permanently on the heap. NewRetrievable.estimateTokens(encoding)andRetrievable.contentString()accessors. (Note: token estimation and render still materialise the body transiently; reader-backing removes permanent heap residency, not the transient allocation.)
Fixed
InMemorySpoolStoreno longer corrupts binary tool output. It previously UTF-8-decoded everyUint8Arrayat write time, mangling non-text bytes (PDFs, images). Bytes are now stored byte-faithfully;InMemorySpoolReaderdecodes on demand for line/text reads and reports the true stored byte length.
Changed (BREAKING)
- Documentation now builds before the library package in CI. The package build consumes the generated docs, API reference, and changelog artifact so the npm package always includes the MCP documentation corpus when built from tagged/default-branch CI jobs.
- The generated npm package now exposes an
adkbinary.bin/package.tswritesbin.adk = "./adk-mcp.mjs"into the packaged manifest and bundles the MCP SDK/Zod-backed server entry while keeping those MCP implementation dependencies out of the published runtime dependency list. - Render helpers are now async.
renderFirstPartyRetrievables,renderThirdPartyPublicRetrievables,renderThirdPartyPrivateRetrievables,renderRetrievables, andrenderChatCompletionsSystemPromptonChatCompletionsHelpersnow returnPromise<string>(previouslystring). Consumers who override these helpers must update their signatures. TurnRunnerConfiggains two required callbacks —storeMediaBytesCallbackandstoreRetrievableBytesCallback(both arity 3).RawDispatchContextgains the matching requiredstoreMediaBytes/storeRetrievableBytesfields.- Tool-output spool writes are now awaited — a custom
spoolStore.write()may return aPromise(required forReadableStreaminput).