@combycode/llm-sdk 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,212 @@ All notable changes to `@combycode/llm-sdk` are documented here. The format foll
4
4
  [Keep a Changelog](https://keepachangelog.com/) and the project adheres to
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [2.1.0] — 2026-08-17
8
+
9
+ Minor, not major: everything below is additive or a bug fix, and no export was removed or
10
+ renamed. One caveat worth reading before upgrading — see **`defineTool` optional parameters** under
11
+ Fixed, which tightens an inferred type and can therefore surface a compile error in code that was
12
+ already wrong at runtime.
13
+
14
+ ### Added
15
+
16
+ - **Lazy tool loading — register a tool without declaring it.** `lazy: true` on `defineTool`, on an
17
+ `AgentTool`, or on a whole MCP server via `connectMcp(cfg, { lazy: true })`. The tool is registered,
18
+ namespaced and collision-checked exactly as before, but is not placed in the `tools` array: the
19
+ model finds it with a built-in `tool_search`, which returns full schemas as data, and runs it
20
+ through a built-in `call_tool`. Both are declared only when at least one lazy tool exists, so an app
21
+ that never opts in sees nothing new.
22
+
23
+ Measured over 308 tools, six tasks, three reps, both providers: identical correctness, **−72%** cost
24
+ per task on `claude-haiku-4.5` and **−97%** on `gpt-5.4-nano`, for one extra round trip. The saving
25
+ is not from caching — it is from never sending the tool block. Schemas arrive in a tool RESULT,
26
+ which lands after the cached prefix, so the declared array never changes and no discovery event can
27
+ invalidate it. Promoting tools into the array instead costs **+63%** versus never deferring.
28
+
29
+ **It is not always a win.** Below roughly a hundred richly-schema'd tools it costs more than
30
+ declaring everything, because what remains in the prefix falls under the provider's minimum
31
+ cacheable size while a search round trip is still paid. There is deliberately no automatic
32
+ threshold: whether deferring pays depends on schema size, not tool count.
33
+
34
+ `ToolCallReport.toolName` names the tool that actually ran, never `call_tool`, and carries
35
+ `discoveredVia: 'search'`. A new `onToolSearch` hook reports queries, what matched, and — the field
36
+ worth alerting on — which queries matched nothing. Tuning via `lazyTools: { limit, maxSearches }`.
37
+
38
+ - **`CostSummary.unpriced` / `.unpricedModels` — a $0.00 total no longer hides a failed lookup.** A
39
+ model with no catalog entry was priced at zero and summed into every total, so a report read $0.00
40
+ when it meant "could not price this", and a budget built on that total silently never fired. The
41
+ per-entry `cost.source: 'unknown'` tag already recorded it; nothing aggregated it. The collector
42
+ now also emits one `onWarning` per unknown model (`code: 'unpriced_model'`) — once per model, not
43
+ per request. Genuinely free calls are unaffected: they are priced `'calculated'` at zero with a
44
+ note. The usual cause is a model id that reaches the provider but is not a catalog key, e.g.
45
+ `anthropic/claude-haiku-4-5` against the catalog's `anthropic/claude-haiku-4.5`.
46
+ - **`strictSupport(schema, dialect)` — ask whether a schema can satisfy a provider's strict mode.**
47
+ Returns `{ ok, reason }`, where `reason` names the property or keyword responsible. Exported
48
+ because the answer differs per provider and was otherwise only discoverable by getting a 400.
49
+ - **`complete({ seed, topK })` and `CompleteResult.error`.** All three existed on `LLMClient` and the
50
+ agent path but not on the one-shot helper, so a documented example demonstrating them did not
51
+ compile. `error` surfaces the in-band failure some providers report instead of throwing (OpenAI
52
+ Responses `status: 'failed'`), which otherwise reads as a successful empty answer.
53
+
54
+ - **`complete({ cache })` — prompt caching is reachable from the one-shot helper.** `CompleteOptions`
55
+ had no `cache` field at all, so asking for it did nothing: the option was dropped in silence, with
56
+ no error and no warning, while `LLMClient` and every adapter supported it fully. It matters most
57
+ exactly where this helper is convenient — a long system prompt or a large tool block, which sit at
58
+ the front of the request and are the cheapest part to cache.
59
+ - Found by a benchmark that reported zero cached tokens for every arm it measured.
60
+
61
+ ### Fixed
62
+
63
+ - **Any OpenAI tool with an OPTIONAL parameter was rejected outright.** The library forced
64
+ `strict: true` on every function tool while sending the schema as written. OpenAI's strict mode
65
+ requires every property to appear in `required`, at every nesting level, and answers a schema that
66
+ does not with `400 Invalid schema: 'required' is required to be supplied` — never a degraded
67
+ result. So `defineTool({ optional: [...] })`, a documented feature, could not be used on OpenAI at
68
+ all, and neither could most MCP servers. The same forcing applied to structured output on both
69
+ OpenAI APIs.
70
+
71
+ On Responses, where strict has long been the default, it is now requested only where the schema
72
+ can satisfy the provider. Elsewhere it stays OPT-IN — see the next entry. The rules differ per
73
+ provider, measured live rather than read off the docs:
74
+
75
+ | | OpenAI | Anthropic |
76
+ |---|---|---|
77
+ | optional properties (not in `required`) | rejected | fine |
78
+ | `minimum` / `maximum` / `exclusive*` / `multipleOf` / `maxItems` | fine | rejected |
79
+ | `additionalProperties: true` | rejected | rejected |
80
+ | `{ type: 'object' }` with no `properties` key | rejected | fine |
81
+ | more than 20 strict tools per request | fine | rejected |
82
+
83
+ Two consequences: a generic router tool — one whose parameter must accept any shape — can never
84
+ be strict, and past Anthropic's cap the defaulted tools give up strict together rather than the
85
+ first 20 keeping it by array order. Passing `strict` explicitly still wins in either direction,
86
+ including past the cap. A no-argument tool is unaffected: `properties: {}` is present but empty,
87
+ which both providers accept.
88
+
89
+ Nothing caught this because nothing executed it: every example declared its tool parameters as
90
+ required, and the MCP server used throughout the corpus marks everything required. Typecheck,
91
+ API snapshot, doc-snippet compilation and consumer install all passed on code the API refuses.
92
+
93
+ - **Strict stays OPT-IN on Anthropic and OpenAI Chat Completions.** It was briefly defaulted on
94
+ during this cycle and reverted before release, so behaviour on both is unchanged from 2.0.1.
95
+
96
+ What decided it: strict makes no measurable difference to argument quality — 40 of 40 calls
97
+ conformed with it and without it on both providers, including prompts written to pull away from
98
+ the schema. Its one real effect is that Anthropic then refuses to call a tool that was never
99
+ declared (10/10 undeclared without it, 0/10 with it), and that only matters when something puts
100
+ an undeclared tool in front of the model, which ordinary use does not.
101
+
102
+ Against that, Anthropic's strict mode carries limits no per-schema check can predict: at most 20
103
+ strict tools per request, at most 24 optional parameters summed across all strict schemas
104
+ (nested ones included), and an opaque complexity limit on top — 24 optional parameters spread
105
+ over four tools compiles, the same 24 in one tool answers "Schema is too complex for
106
+ compilation". Twelve ordinary tools with five optional parameters each already exceed the second.
107
+ The first two are aggregates, so they cannot live in a per-schema predicate; the third has no
108
+ published formula. Opt-in is the only honest default there.
109
+
110
+ - **`defineTool` typed optional parameters as always present.** Keys listed in `optional` inferred as
111
+ required in the `execute` args, so `args.unit.toUpperCase()` typechecked and threw at runtime on
112
+ every call where the model omitted the argument — the expected case for an optional argument. They
113
+ now infer as `| undefined`.
114
+
115
+ - **`complete()` sent different options depending on whether `tools` was passed.** The helper has two
116
+ branches, and the tools branch forwarded only `structured` — silently dropping `providerOptions`,
117
+ `audio`, `outputModalities` and `serviceTier`, which the no-tools branch honoured. Adding a tool to
118
+ a working call could therefore change behaviour that has nothing to do with tools. Both branches
119
+ now forward the same set.
120
+
121
+ ### Added
122
+
123
+ - **MCP tool definitions carry what the spec publishes.** `McpToolDef` now models `annotations`
124
+ (`readOnlyHint` / `destructiveHint` / `idempotentHint` / `openWorldHint`), `icons`, `execution`
125
+ and `_meta`. The data always arrived — `tools/list` results are passed through unparsed — but was
126
+ untyped, so a host could not act on it without a cast. These stay **host-facing**: no provider's
127
+ function-tool schema has a field that could carry them, so none is sent to the model.
128
+ - `execution.taskSupport: 'required'` means the tool MUST be invoked as a task. Task invocation is
129
+ not implemented, so such a tool cannot be called through this client — the field being visible is
130
+ what lets a caller see that instead of meeting it as a server error.
131
+ - **MCP `outputSchema` reaches the model when asked for** — `connectMcp(cfg, { validateOutput: true })`.
132
+ It was read for local validation only, while OpenAI Responses accepts `output_schema` and the
133
+ library already emitted it for hand-written tools; the two ends were never connected.
134
+ - **Opt-in, because declaring it is a promise about the RESULT.** OpenAI then rejects the turn
135
+ — *"expected a JSON string because the function declares output_schema"* — unless the result is
136
+ JSON matching that schema, so the tool result changes from prose to structured data. MCP returns
137
+ `structuredContent` exactly when a tool publishes `outputSchema`, and that is now what comes
138
+ back — under the same flag, so nothing changes for anyone who did not ask for it.
139
+ - Verified end to end against a live MCP server through OpenAI Responses, in both modes.
140
+ - **`moderate()` now returns the shape of its input.** Overloads: one input (a string, or one
141
+ content-part array forming a single multimodal item) returns a `ModerationResult`; a list of
142
+ inputs returns `ModerationResult[]`. The mapping was documented in prose from the start but the
143
+ signature returned the bare union, so `result.flagged` was a type error and **every documented
144
+ example failed to compile**. The wide-union overload is kept, so code that already narrows still
145
+ compiles. No runtime change.
146
+ - **`toolKey` / `describeTool` are exported.** `AgentTool.definition` is a `Tool` union (function
147
+ tool or builtin), so `.name` does not exist on it; reading a tool's name previously required
148
+ hand-narrowing at every call site.
149
+ - **`createEngine({ retry })` — retry policy configurable where it belongs.** The machinery existed
150
+ and worked, but the only way to reach it was hand-building an `HttpRequest`; nothing on
151
+ `createEngine()` exposed it. Retry is cross-cutting, so it is now an engine-level setting inherited
152
+ by every queue, with `createEngine({ queues })` for one provider and `HttpRequest.retry` for one
153
+ request. Nested groups (`backoff`, `perKind`) merge rather than replace, so overriding one knob
154
+ does not reset the schedule.
155
+ - New type `RetryPolicyOverride`. `Partial<RetryConfig>` only makes top-level keys optional, so it
156
+ still demanded a complete `backoff` — the partial override the merge always supported was not
157
+ expressible. Engine, queue and `mergeRetry` now take the deeper-partial type; strictly wider, so
158
+ existing config keeps compiling.
159
+ - Additive: omitting all of it is byte-identical to before.
160
+
161
+ ### Fixed
162
+
163
+ - **Docs: the per-request retry sample could never compile.** `docs/guide/network.md` showed
164
+ `complete({ retry: { attempts, initialDelay, maxDelay, expBase, httpStatusCodes } })`. `complete()`
165
+ takes no `retry` option (`TS2353`), and not one of those field names exists on
166
+ `RequestRetryOverride` — the real fields are `maxRetries` / `totalTimeoutMs` / `attemptTimeoutMs` /
167
+ `maxRetryAfterMs` / `backoff{initialMs,maxMs,multiplier,jitter}`, and the override rides on
168
+ `engine.fetch()`. The feature was always correct; only its documentation was wrong. Same class as
169
+ the 2.0.0 `agent.run()` defect, on an option object rather than a method, which is why the
170
+ consumer-surface check did not see it.
171
+
172
+ - **MCP result cache: `ttlMs: 0` now evicts.** The hint was documented as "immediately stale — not
173
+ the same as absent", but `set()` funnelled it through the same branch as a missing hint and stored
174
+ nothing. Any entry already held survived, so a server that said "cache for 60s" and later "stale
175
+ now" kept being answered from the stale entry for the rest of the original TTL — the instruction
176
+ was accepted and inert. A non-positive `ttlMs` now drops the entry for that key. Absent hints are
177
+ unchanged and still leave an existing entry alone, so pre-2026 servers behave exactly as before.
178
+ - The existing unit test asserted `set(…, { ttlMs: 0 })` returned `false` and `get` was
179
+ `undefined` — on an *empty* cache, where that holds whether or not the hint does anything. It
180
+ passed for the wrong reason. Found by writing the MCP protocol example as a consumer.
181
+
182
+ ## [2.0.1] - 2026-08-10
183
+
184
+ Three defects reported by a consumer within a day of 2.0.0 — all reachable by reading the shipped
185
+ `.d.ts`, none caught by our gate. See the note at the end.
186
+
187
+ ### Fixed
188
+
189
+ - **`agent.stream()` now carries `phase` on text events.** The raw stream event had it, and the
190
+ agent mapper *used* it internally to keep commentary out of the answer — then yielded both deltas
191
+ through one `{ type: 'text', text }` with the phase stripped. A UI streaming those straight
192
+ through put the model's thinking-aloud into the transcript **as if it were the reply**, with no
193
+ way to tell them apart. `finalAnswerText()` could not help: it takes a finished message's
194
+ `content`, not deltas.
195
+ - Additive: `phase` is **absent** (not `undefined`) when the provider reports none, so every
196
+ non-codex provider is byte-identical to before.
197
+ - **Docs: `agent.run()` does not exist.** The agent-loop guide recommended it for a non-throwing
198
+ report. The class exposes `stop` / `complete` / `structuredComplete` / `stream`; the report is
199
+ reached with `try/catch` + `agent.lastReport`. The guide now shows that.
200
+ - **Docs: the 2.0.0 changelog overstated live commentary.** It said commentary "is still yielded to
201
+ the consumer (a UI may well want to render it live)" — true only in the sense that the bytes
202
+ arrived; they were unlabelled, so a UI could not act on them. The 2.0.0 entry now says so and
203
+ points here.
204
+
205
+ ### Why this got out
206
+
207
+ The feature was verified end-to-end on the **buffered** path (`finalAnswerText`, `response.text`,
208
+ live-tested against real models) and never once from the **layer most consumers actually call**.
209
+ 1778 tests, four MCP transports and two live corpora, and no check that a shipped type was usable
210
+ from `agent.stream()`. The gate was deep where it was pointed and blind where it was not — so the
211
+ release checklist now includes a consumer-surface pass over the published `.d.ts`.
212
+
7
213
  ## [2.0.0] - 2026-08-09
8
214
 
9
215
  **Upgrading:** three things can require action, and none of them is a provider change — that is the
@@ -250,8 +456,9 @@ with `inputRequiredMaxRounds`.
250
456
  silently drop the answer.
251
457
  - Streaming carries it too. `phase` is announced once on `response.output_item.added` and belongs
252
458
  on every delta of that item, so the parser keeps per-stream item→phase state; concurrent streams
253
- cannot leak phases into each other. Commentary is still yielded to the consumer (a UI may well
254
- want to render it live) and is preserved in the assembled content as its own phase-tagged part.
459
+ cannot leak phases into each other. Commentary is yielded to the consumer and preserved in the
460
+ assembled content as its own phase-tagged part. (In 2.0.0 the agent-layer event dropped the
461
+ phase, so a UI could not act on it — corrected in 2.0.1.)
255
462
  - Nothing is inferred: a model that reports no phase produces parts with no phase, exactly as
256
463
  before.
257
464
  - **`name` + `namespace` on `function_call_output`.** The tool name is taken from the matching
@@ -417,9 +624,11 @@ interaction or an outright failure from a clean finish.
417
624
  error, so the gating is locked by unit tests and was live-verified end to end.
418
625
  - **`docs/feature-matrix.json` — the parity matrix.** Every capability an official SDK exposes, how
419
626
  each provider spells it, and where we stand, with citations into the version-pinned clones.
420
- `scripts/validate-feature-matrix.mjs` runs as part of `bun run lint` and fails the build on a
421
- broken citation, an unexplained `partial`/`beta`/`by-design`, or a duplicate id. It backs the
422
- site's comparison page and is maintained by the upstream-update cycle.
627
+
628
+ > **Corrected 2026-08-10.** This entry also described the release tooling that consumes the
629
+ > matrix. That does not belong in a library changelog — a consumer of the package cannot see it,
630
+ > run it, or care about it — and the tool it named did not exist. Only the shipped data is
631
+ > described here now.
423
632
  - **`FinishReason` gains `'pending'`** — non-terminal: the provider accepted the request but has not
424
633
  produced a completion (Google Interactions `queued`, OpenAI Responses `queued`/`in_progress` in
425
634
  background mode). Treat as "poll/retry", never as a result. *Additive union member: exhaustive
@@ -26,7 +26,13 @@ export declare const LAYER_CHAT_FACTS = "chat.facts";
26
26
  export declare const LAYER_EXECUTOR_TOOL_EXAMPLES = "executor.tool-examples";
27
27
  /** ContextGuard's compaction summary layer (replaces compacted message ranges). */
28
28
  export declare const LAYER_CONTEXT_GUARD_SUMMARY = "context-guard.summary";
29
+ /** How to reach tools that are registered but not declared. Present only while at least
30
+ * one lazy tool exists. See `writeLazyToolsProtocol`. */
31
+ export declare const LAYER_LAZY_TOOLS = "agentloop.lazy-tools";
29
32
  export declare const PRIORITY_AGENTLOOP_SYSTEM = 10;
33
+ /** Just after the agent's own system text: the model needs to know its tools are not
34
+ * listed before anything else it is told about them. */
35
+ export declare const PRIORITY_LAZY_TOOLS = 20;
30
36
  export declare const PRIORITY_LEGACY_SYSTEM = 50;
31
37
  export declare const PRIORITY_AGENTLOOP_CONTEXT = 100;
32
38
  export declare const PRIORITY_MEMORY = 200;
@@ -35,5 +41,18 @@ export declare const PRIORITY_EXECUTOR_TOOL_EXAMPLES = 280;
35
41
  export declare const PRIORITY_CONTEXT_GUARD_SUMMARY = 300;
36
42
  /** Set the AgentLoop's persona/system layer on a registry (or remove if blank). */
37
43
  export declare function writeAgentLoopSystem(registry: ContextRegistry, text: string | undefined, owner: string): void;
44
+ /** Tell the model that its tools are not all listed, and how to reach the rest.
45
+ *
46
+ * Without this the feature under-performs badly and quietly: measured over 24 live runs
47
+ * against 308 lazy tools, the model scored 8/12 and 9/12 — sometimes never searching at
48
+ * all, more often searching once for a request that needed two capabilities and
49
+ * answering from the one tool it found. With the protocol stated, the same tasks and the
50
+ * same ranker scored 18/18. The tool descriptions alone are not enough, because a model
51
+ * has no reason to suspect a tool exists that it cannot see.
52
+ *
53
+ * Deliberately a few lines and NOT a catalog. A catalog is paid for on every turn for
54
+ * the whole conversation, which is most of what makes eager exposure expensive in the
55
+ * first place; this is a fixed cost of roughly one sentence. */
56
+ export declare function writeLazyToolsProtocol(registry: ContextRegistry, active: boolean, owner: string): void;
38
57
  /** Set the AgentLoop's run-scenario context layer (or remove if blank). */
39
58
  export declare function writeAgentLoopContext(registry: ContextRegistry, text: string | undefined, owner: string): void;
@@ -0,0 +1,85 @@
1
+ /** Lazy tool loading: `tool_search` + `call_tool`.
2
+ *
3
+ * A tool registered with `lazy: true` is NOT placed in the `tools` array. The model
4
+ * finds it by searching, and calls it through `call_tool`. The point is what does NOT
5
+ * move: the declared tool array never changes, so no discovery event can invalidate the
6
+ * cached prefix. Schemas travel as tool RESULTS, which land in history after the prefix.
7
+ *
8
+ * Measured against declaring everything (308 tools, 6 tasks, 3 reps, both providers —
9
+ * `bench/lazy-tools-e2e`): identical correctness (72/72), −72% cost on claude-haiku-4.5
10
+ * and −97% on gpt-5.4-nano, at one extra round trip per task.
11
+ *
12
+ * Three details here are load-bearing and each came from a measurement that failed
13
+ * first. They are not stylistic:
14
+ *
15
+ * 1. `call_tool` is SINGULAR. A batching form — `call_tools({ calls: [...] })` — is
16
+ * returned as a JSON *string* rather than an array by claude-haiku about half the
17
+ * time (19/30 vs 30/30), because a router's `input` must be open and an open object
18
+ * cannot be strict, so no grammar holds the shape. Batching is not lost: the model
19
+ * emits several parallel `call_tool` calls in one turn instead.
20
+ *
21
+ * 2. `tool_search` REPORTS QUERIES THAT MATCHED NOTHING. Merging results silently makes
22
+ * a failed lookup indistinguishable from one whose hits were folded in with the
23
+ * others, and the model then answers confidently from the tools it did get. Measured
24
+ * on colloquial phrasing: without this, 34/36 recall and 17/18 correct; with it,
25
+ * 36/36 and 18/18.
26
+ *
27
+ * 3. Ranking is deliberately weak, and that is survivable only because the MODEL writes
28
+ * the query. Token overlap against raw user text scores 0–1 of 8 on indirect or
29
+ * colloquial phrasing (`bench/tool-ranking`). The model's rewriting is what carries
30
+ * it. Better ranking would reduce how far that rewriting has to reach; it is not what
31
+ * makes the feature work.
32
+ */
33
+ import type { AgentTool } from './types';
34
+ /** Tuning for lazy tool exposure. Every field is optional; the defaults are what the
35
+ * measurements used. */
36
+ export interface LazyToolsConfig {
37
+ /** Schemas returned per query. Default 5, capped at 20 — the cap is a real bound, not
38
+ * a formality: returning everything re-creates the cost the feature exists to avoid. */
39
+ limit?: number;
40
+ /** Searches allowed per run. Default 5. Exceeding it returns a tool result saying so
41
+ * and leaves the run alive: a model that loops on search should be told, not killed. */
42
+ maxSearches?: number;
43
+ }
44
+ /** The two names this module owns. Registered like any other tool, so the existing
45
+ * collision policy applies to them and there is no second registry. */
46
+ export declare const LAZY_SEARCH_TOOL = "tool_search";
47
+ export declare const LAZY_CALL_TOOL = "call_tool";
48
+ /** Rank candidates by token overlap over name + description + parameter names, with name
49
+ * matches weighted double — a query naming the domain should beat a filler whose long
50
+ * description happens to share vocabulary. Local and dependency-free by design: no
51
+ * embeddings, no network call on the discovery path. */
52
+ export declare function rankTools(query: string, candidates: AgentTool[], limit: number): AgentTool[];
53
+ /** Per-run search budget. Reset at the start of each run, not shared across runs.
54
+ *
55
+ * Internal: deliberately NOT exported from the package. It is wiring between this module
56
+ * and `AgentLoop`, and a consumer has no use for it — publishing it would put two types
57
+ * in the public API that can never be called usefully from outside. */
58
+ interface LazySearchState {
59
+ searches: number;
60
+ }
61
+ /** Build the two built-in tools. Returned as ordinary `AgentTool`s so they register,
62
+ * dispatch, time out and report through exactly the same path as any other tool.
63
+ *
64
+ * The dependency shape is inline rather than a named interface on purpose: a named type
65
+ * in an exported signature lands in the published `.d.ts`, and this one is wiring
66
+ * between here and `AgentLoop` that a consumer can never usefully call.
67
+ *
68
+ * `lazyTools` and `eagerNames` are functions, not arrays, because tools can be added
69
+ * after construction and search must see them. */
70
+ export declare function createLazyTools(deps: {
71
+ lazyTools: () => AgentTool[];
72
+ eagerNames: () => string[];
73
+ state: LazySearchState;
74
+ config: LazyToolsConfig;
75
+ onSearch?: (info: {
76
+ queries: string[];
77
+ matched: string[];
78
+ unmatched: string[];
79
+ }) => void;
80
+ }): AgentTool[];
81
+ /** The inner tool a `call_tool` invocation targeted, for reporting. Returns null for any
82
+ * other call. Traces that attribute every tool call to `call_tool` are useless, and this
83
+ * is the one real regression the design causes — so it is fixed at the source. */
84
+ export declare function unwrapLazyCall(toolName: string, args: Record<string, unknown>): string | null;
85
+ export {};
@@ -6,6 +6,7 @@ import type { ConversationHistory } from './history';
6
6
  import type { HistorySnapshot } from './history-types';
7
7
  import type { ReflectAndRetryConfig } from './reflect-retry';
8
8
  import type { AgentTool } from './types';
9
+ import type { LazyToolsConfig } from './lazy-tools';
9
10
  import type { Guardrail, ToolInputGuardrail } from './guardrail-types';
10
11
  import type { PermissionPolicy } from '../plugins/permissions/policy';
11
12
  import type { ApprovalRequest, ApprovalDecision } from './approval-types';
@@ -37,6 +38,14 @@ export interface AgentLoopConfig {
37
38
  * Defaults to `'warn'` so an app that unknowingly has a collision keeps working
38
39
  * (CONSTITUTION.md R4) — the collision just stops being invisible. */
39
40
  toolNameCollisionPolicy?: 'warn' | 'error';
41
+ /** Tuning for tools registered with `lazy: true`. Has no effect when none are — the
42
+ * built-in `tool_search` / `call_tool` are declared only if a lazy tool exists, so an
43
+ * app that never uses the feature never sees them.
44
+ *
45
+ * There is deliberately no `threshold` here: whether deferring pays depends on the
46
+ * SIZE of the tool schemas, not their count, so an automatic cutoff would be guessing.
47
+ * Mark tools lazy explicitly. */
48
+ lazyTools?: LazyToolsConfig;
40
49
  /** Self-healing recovery from a recoverable MODEL failure (a malformed tool call, a hallucinated
41
50
  * tool name, a truncated call). The model is given structured guidance naming the attempt and
42
51
  * told not to repeat the same call, then the step is retried within a bounded budget.
@@ -29,6 +29,13 @@ export declare class AgentLoop {
29
29
  private _systemThunk;
30
30
  private _context;
31
31
  private _tools;
32
+ private _lazyConfig;
33
+ /** Per-run search budget, reset at the start of every run. */
34
+ private _lazyState;
35
+ /** Installed on the first `lazy` registration and never removed, so the declared tool
36
+ * array stays byte-identical for the life of the conversation — which is the entire
37
+ * reason the design is cheap. */
38
+ private _lazyInstalled;
32
39
  private _history;
33
40
  private _reports;
34
41
  private _metadata;
@@ -103,7 +110,26 @@ export declare class AgentLoop {
103
110
  private runApprovalGate;
104
111
  /** Emit onToolCallComplete, push report, and return success content part. */
105
112
  private buildSuccessResult;
106
- /** Merge agent's tool definitions with caller-provided tools (caller wins on conflict). */
113
+ /** Declare `tool_search` + `call_tool`, once, on the first lazy registration.
114
+ *
115
+ * They go through `registerTool` like anything else, so the collision policy covers
116
+ * them and there is no second registry to keep in sync. They are never removed: the
117
+ * declared array must stay identical for the whole conversation or the cached prefix
118
+ * is invalidated, which is the cost the feature exists to avoid. */
119
+ private installLazyTools;
120
+ /** Publish (or remove) the "your tools are not all listed" layer.
121
+ *
122
+ * Separate from `installLazyTools` because tools are registered in the constructor
123
+ * BEFORE `_history` exists, and the layer lives in the history's registry. The
124
+ * constructor calls this again once history is built.
125
+ *
126
+ * The model has no reason to suspect a tool it cannot see, and the failure without
127
+ * this is quiet — it answers from whatever it did find. Measured at 8/12 and 9/12
128
+ * without the protocol, 18/18 with it, same tasks and same ranker. */
129
+ private syncLazyProtocol;
130
+ /** Merge agent's tool definitions with caller-provided tools (caller wins on conflict).
131
+ *
132
+ * Lazy tools are registered but NOT declared — that filter is the whole mechanism. */
107
133
  private toolDefinitions;
108
134
  private beginRun;
109
135
  private finalizeRun;
@@ -1,7 +1,7 @@
1
1
  /** Agent-layer shared types — TokenCounter contract used by ContextRegistry,
2
2
  * ConversationHistory, and the ContextMeasurer plugin.
3
3
  * Also defines AgentTool (executable tool) and run-report types. */
4
- import type { ContentPart, Message } from '../llm/types/messages';
4
+ import type { AssistantPhase, ContentPart, Message } from '../llm/types/messages';
5
5
  import type { Tool } from '../llm/types/tools';
6
6
  import type { Usage } from '../llm/types/response';
7
7
  import type { HistorySnapshot } from './history-types';
@@ -37,6 +37,16 @@ export interface LearnInput {
37
37
  export interface AgentTool {
38
38
  /** Tool schema sent to the LLM. */
39
39
  definition: Tool;
40
+ /** Register the tool but do NOT declare it: the model finds it with `tool_search` and
41
+ * invokes it through `call_tool`. Exposure only — registration, validation and
42
+ * collision checking are unchanged, and nothing happens mid-run.
43
+ *
44
+ * Worth it when the tool block is large and a run touches few of them: measured at
45
+ * −72% (claude-haiku-4.5) and −97% (gpt-5.4-nano) cost per task across 308 tools, with
46
+ * identical correctness, for one extra round trip. NOT worth it for a small tool set —
47
+ * below roughly a hundred richly-schema'd tools the saving inverts, because the
48
+ * remaining prefix falls under the provider's minimum cacheable size. */
49
+ lazy?: boolean;
40
50
  /** Execute the tool. Return string or structured content. */
41
51
  execute: (args: Record<string, unknown>, context: ToolExecutionContext) => Promise<string | ContentPart[]>;
42
52
  /** Optional: derive out-of-band metadata from a successful tool result, attached
@@ -73,6 +83,11 @@ export interface ToolCallReport {
73
83
  /** Out-of-band metadata from the tool's `customDataExtractor`, if any. Never sent
74
84
  * to the model; present only when an extractor returned a value. */
75
85
  customData?: unknown;
86
+ /** `'search'` when the model reached this tool through `call_tool` after finding it
87
+ * with `tool_search`. Absent for a normally-declared tool. `toolName` is the tool that
88
+ * actually ran either way — without that unwrapping every lazy call would report
89
+ * `call_tool` and attribution would be worthless. */
90
+ discoveredVia?: 'search';
76
91
  }
77
92
  export interface StepReport {
78
93
  index: number;
@@ -103,9 +118,15 @@ export interface AgentRunReport {
103
118
  export type AgentStreamEvent = {
104
119
  type: 'step_start';
105
120
  step: number;
106
- } | {
121
+ }
122
+ /** `phase` mirrors the raw stream event: `'commentary'` is the model narrating, anything
123
+ * else (usually absent) is the answer. Passed through so a consumer can tell them apart
124
+ * LIVE — `finalAnswerText()` only cleans a finished message and cannot touch deltas.
125
+ * Absent on every provider that reports no phase, exactly as before. */
126
+ | {
107
127
  type: 'text';
108
128
  text: string;
129
+ phase?: AssistantPhase;
109
130
  } | {
110
131
  type: 'thinking';
111
132
  text: string;
@@ -368,6 +368,19 @@ export interface CostEntry {
368
368
  providerEvidence: Record<string, unknown>;
369
369
  tags: Record<string, string | undefined>;
370
370
  }
371
+ /** One `tool_search` call by the model, when lazy tools are in use.
372
+ *
373
+ * `unmatched` is the field that matters: a query that found nothing is how a
374
+ * multi-capability request quietly becomes a partial answer. Watch it to see whether the
375
+ * catalog's descriptions actually match the words your users reach for. */
376
+ export interface ToolSearchContext {
377
+ agentId: string;
378
+ queries: string[];
379
+ /** Tool names returned to the model, deduplicated across queries. */
380
+ matched: string[];
381
+ /** Queries that matched no tool at all. */
382
+ unmatched: string[];
383
+ }
371
384
  export interface CostEntryContext {
372
385
  entry: CostEntry;
373
386
  runningTotal: number;
@@ -577,6 +590,7 @@ export interface HookMap {
577
590
  onToolCallStart: ToolCallStartContext;
578
591
  onToolCallComplete: ToolCallCompleteContext;
579
592
  onToolCallError: ToolCallErrorContext;
593
+ onToolSearch: ToolSearchContext;
580
594
  onRunComplete: RunCompleteContext;
581
595
  onRunError: RunErrorContext;
582
596
  onGuardrailTriggered: GuardrailTriggeredContext;
@@ -50,17 +50,26 @@ type InferParam<S> = S extends 'string' ? string : S extends 'number' | 'integer
50
50
  } ? number : S extends {
51
51
  type: 'boolean';
52
52
  } ? boolean : unknown;
53
- type InferArgs<P extends Record<string, ParamSpec>> = {
54
- [K in keyof P]: InferParam<P[K]>;
53
+ /** Keys named in `optional` are optional HERE TOO. Typing them as always-present is
54
+ * a lie the compiler then helps enforce: `args.unit.toUpperCase()` typechecks and
55
+ * throws at runtime whenever the model omits the argument — which, for an argument
56
+ * declared optional, is the expected case rather than the edge one. */
57
+ type InferArgs<P extends Record<string, ParamSpec>, O extends keyof P = never> = {
58
+ [K in Exclude<keyof P, O>]: InferParam<P[K]>;
59
+ } & {
60
+ [K in Extract<O, keyof P>]?: InferParam<P[K]>;
55
61
  };
56
- export interface DefineToolInput<P extends Record<string, ParamSpec>> {
62
+ export interface DefineToolInput<P extends Record<string, ParamSpec>, O extends keyof P & string = never> {
57
63
  name: string;
58
64
  description: string;
59
65
  /** Object spec — keys are arg names. All keys are treated as required by
60
66
  * default; mark optional ones via `optional: ['x']`. */
61
67
  params: P;
62
- optional?: ReadonlyArray<keyof P & string>;
63
- execute: (args: InferArgs<P>, context: ToolExecutionContext) => Promise<string | ContentPart[]> | string | ContentPart[];
68
+ optional?: readonly O[];
69
+ /** Register the tool without declaring it the model finds it via `tool_search` and
70
+ * calls it through `call_tool`. See `AgentTool.lazy`. */
71
+ lazy?: boolean;
72
+ execute: (args: InferArgs<P, O>, context: ToolExecutionContext) => Promise<string | ContentPart[]> | string | ContentPart[];
64
73
  }
65
- export declare function defineTool<P extends Record<string, ParamSpec>>(input: DefineToolInput<P>): AgentTool;
74
+ export declare function defineTool<P extends Record<string, ParamSpec>, const O extends keyof P & string = never>(input: DefineToolInput<P, O>): AgentTool;
66
75
  export {};
@@ -18,7 +18,8 @@
18
18
  import { AgentBus } from '../bus/agent-bus';
19
19
  import { HookBus } from '../bus/hook-bus';
20
20
  import type { ProviderName } from '../llm/types/provider';
21
- import { NetworkEngine } from '../network/engine';
21
+ import { NetworkEngine, type QueueSettings } from '../network/engine';
22
+ import type { RetryPolicyOverride } from '../network/queue-state-config';
22
23
  import type { EngineConnect, EngineFetch, EngineFetchStream, FetchFn } from '../network/types';
23
24
  import { Cache } from '../plugins/cache/cache';
24
25
  import { CostCollector } from '../plugins/cost-collector/collector';
@@ -94,6 +95,21 @@ export interface EngineConfig {
94
95
  /** Per-provider API keys. Helpers consult this when no apiKey is passed
95
96
  * alongside `model: 'provider/...'`. */
96
97
  apiKeys?: Partial<Record<ProviderName, string>>;
98
+ /** Retry policy for every request this engine makes.
99
+ *
100
+ * Retry is a cross-cutting concern, so it is configured once here rather than threaded through
101
+ * each call. Anything omitted falls back to the built-in policy (`DEFAULT_RETRY`).
102
+ *
103
+ * ```ts
104
+ * createEngine({ retry: { maxRetries: 5, backoff: { initialMs: 200, maxMs: 8_000 } } });
105
+ * ```
106
+ *
107
+ * Three layers, narrowest wins: `HttpRequest.retry` (one request) > `queues[name].retry`
108
+ * (one provider queue) > this (everything). */
109
+ retry?: RetryPolicyOverride;
110
+ /** Per-queue overrides, keyed by queue name (`provider/model` unless routed otherwise).
111
+ * Use when one provider needs a different policy from the rest. */
112
+ queues?: Record<string, QueueSettings>;
97
113
  /** Register this engine as the default for `coreRegistry.get()` (used by
98
114
  * helpers when the caller doesn't pass an explicit `engine`). Defaults to
99
115
  * `true` so `createEngine({ ... })` followed by helper calls just works.
@@ -40,6 +40,15 @@ export interface ConnectMcpOptions {
40
40
  roots?: McpRoot[] | (() => McpRoot[] | Promise<McpRoot[]>);
41
41
  /** Validate tool `structuredContent` against the tool's `outputSchema`. */
42
42
  validateOutput?: boolean;
43
+ /** Register this server's tools WITHOUT declaring them: the model finds them with
44
+ * `tool_search` and calls them through `call_tool`. Exposure only — every tool is
45
+ * still registered, namespaced and collision-checked exactly as today.
46
+ *
47
+ * This is the common case for the feature, since an MCP server is where a large tool
48
+ * block usually comes from. Measured over 308 tools: identical correctness, −72% cost
49
+ * per task on claude-haiku-4.5 and −97% on gpt-5.4-nano, for one extra round trip.
50
+ * Not worth it for a small server — see `AgentTool.lazy`. */
51
+ lazy?: boolean;
43
52
  /** Send a `ping` every N ms to keep the connection alive (0 = off).
44
53
  * Ignored on a 2026-07-28 session, where `ping` no longer exists. */
45
54
  keepAliveMs?: number;
@@ -9,5 +9,17 @@
9
9
  *
10
10
  * The moderations endpoint is FREE; an honest-zero cost entry is always emitted
11
11
  * so the cost ledger has a record of each call. HTTP flows through engine.fetch. */
12
- import type { ModerateOptions, ModerationResult } from './moderate-types';
12
+ import type { ModerateOptions, ModerationContentPart, ModerationResult } from './moderate-types';
13
+ /** One input in, one result out: a single string, or one content-part array that
14
+ * together forms a single multimodal item. */
15
+ export declare function moderate(opts: ModerateOptions & {
16
+ input: string | ModerationContentPart[];
17
+ }): Promise<ModerationResult>;
18
+ /** Many inputs in, one result each, in the same order. */
19
+ export declare function moderate(opts: ModerateOptions & {
20
+ input: string[] | ModerationContentPart[][];
21
+ }): Promise<ModerationResult[]>;
22
+ /** Fallback for a caller holding the wide `ModerateOptions['input']` union: the
23
+ * arity is only knowable at runtime, so the union comes back. Kept so existing
24
+ * code that already narrows the result keeps compiling. */
13
25
  export declare function moderate(opts: ModerateOptions): Promise<ModerationResult | ModerationResult[]>;