@molecule/api-ai-agents-llm 1.0.3 → 1.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/README.md CHANGED
@@ -3,7 +3,7 @@ AUTO-GENERATED — DO NOT EDIT THIS FILE.
3
3
  Generated by `mlcl sync-docs` from the package's src/index.ts JSDoc + mlcl/registry.json.
4
4
  Edits here are overwritten on the next commit (molecule's pre-commit hook regenerates).
5
5
  To change this document, edit the module-level JSDoc in src/index.ts.
6
- Generated: 2026-10-16T23:28:05.760Z
6
+ Generated: 2026-10-27T17:09:52.462Z
7
7
  -->
8
8
 
9
9
  # @molecule/api-ai-agents-llm
@@ -173,6 +173,30 @@ prefix on each of up to `maxSteps` turns. Note `onEvent` is only an
173
173
  INCREMENTAL live hook while streaming — with `stream: false` it fires once
174
174
  per turn with the whole response.
175
175
 
176
+ `input.thinking` forwards the core chat contract's native thinking settings
177
+ on every turn. Streamed reasoning is retained in `ChatMessage.reasoning`
178
+ across tool calls, separately from assistant text and tool arguments.
179
+ Omitting the option does not enable thinking or manufacture reasoning.
180
+
181
+ Two further options are OPT-IN and change nothing when omitted:
182
+
183
+ - `input.maxToolResultChars` bounds the `tool_result` copy sent to the model
184
+ (a whole-file or log dump otherwise crowds out the instruction that asked
185
+ for it) and appends an explicit truncation note telling the model the value
186
+ was cut and how to recover. The step record keeps the complete tool result.
187
+ - `input.finalSynthesisOnBudgetExhaustion` spends ONE tool-free turn after
188
+ the `maxSteps` budget is exhausted, so a run that ended on a tool
189
+ invocation still returns a final answer instead of the budget note. That
190
+ turn carries the same history, system, model, thinking, cache, streaming
191
+ and signal settings plus one short generic instruction to answer now from
192
+ the evidence already gathered (without it a model mid-investigation can
193
+ keep planning). Its events reach `onEvent`, its usage is metered, and it
194
+ cannot call tools: a tool request on that turn is never executed and is not
195
+ accepted as a final answer. If the turn produces no answer text, `run()`
196
+ rejects with `AgentRunError` instead of returning the budget note, and the
197
+ message says whether the turn requested another tool or returned no
198
+ content (e.g. only reasoning).
199
+
176
200
  Failure semantics (how to tell failure modes apart):
177
201
 
178
202
  - **Tool failure** (throwing `execute()`, unknown tool name) — NON-fatal:
@@ -187,7 +211,10 @@ Failure semantics (how to tell failure modes apart):
187
211
  `result.output` means the model genuinely produced no text, not that the
188
212
  API failed.
189
213
  - **Step budget exhausted** — resolves normally; `output` is the last
190
- assistant text or an explicit "step budget exhausted" note.
214
+ assistant text or an explicit "step budget exhausted" note. With
215
+ `input.finalSynthesisOnBudgetExhaustion` the output is instead the answer
216
+ from one final tool-free turn, and a synthesis that produced no answer
217
+ rejects (as an `AgentRunError`) rather than resolving with the note.
191
218
 
192
219
  Swappable like any bond: replace this LLM agent with your own
193
220
  `AIAgentsProvider` via `bond('ai-agents', myProvider)` — nothing else changes.
package/dist/index.d.ts CHANGED
@@ -62,6 +62,30 @@
62
62
  * INCREMENTAL live hook while streaming — with `stream: false` it fires once
63
63
  * per turn with the whole response.
64
64
  *
65
+ * `input.thinking` forwards the core chat contract's native thinking settings
66
+ * on every turn. Streamed reasoning is retained in `ChatMessage.reasoning`
67
+ * across tool calls, separately from assistant text and tool arguments.
68
+ * Omitting the option does not enable thinking or manufacture reasoning.
69
+ *
70
+ * Two further options are OPT-IN and change nothing when omitted:
71
+ *
72
+ * - `input.maxToolResultChars` bounds the `tool_result` copy sent to the model
73
+ * (a whole-file or log dump otherwise crowds out the instruction that asked
74
+ * for it) and appends an explicit truncation note telling the model the value
75
+ * was cut and how to recover. The step record keeps the complete tool result.
76
+ * - `input.finalSynthesisOnBudgetExhaustion` spends ONE tool-free turn after
77
+ * the `maxSteps` budget is exhausted, so a run that ended on a tool
78
+ * invocation still returns a final answer instead of the budget note. That
79
+ * turn carries the same history, system, model, thinking, cache, streaming
80
+ * and signal settings plus one short generic instruction to answer now from
81
+ * the evidence already gathered (without it a model mid-investigation can
82
+ * keep planning). Its events reach `onEvent`, its usage is metered, and it
83
+ * cannot call tools: a tool request on that turn is never executed and is not
84
+ * accepted as a final answer. If the turn produces no answer text, `run()`
85
+ * rejects with `AgentRunError` instead of returning the budget note, and the
86
+ * message says whether the turn requested another tool or returned no
87
+ * content (e.g. only reasoning).
88
+ *
65
89
  * Failure semantics (how to tell failure modes apart):
66
90
  * - **Tool failure** (throwing `execute()`, unknown tool name) — NON-fatal:
67
91
  * recorded on the step with `isError: true` and fed back to the model.
@@ -75,7 +99,10 @@
75
99
  * `result.output` means the model genuinely produced no text, not that the
76
100
  * API failed.
77
101
  * - **Step budget exhausted** — resolves normally; `output` is the last
78
- * assistant text or an explicit "step budget exhausted" note.
102
+ * assistant text or an explicit "step budget exhausted" note. With
103
+ * `input.finalSynthesisOnBudgetExhaustion` the output is instead the answer
104
+ * from one final tool-free turn, and a synthesis that produced no answer
105
+ * rejects (as an `AgentRunError`) rather than resolving with the note.
79
106
  *
80
107
  * Swappable like any bond: replace this LLM agent with your own
81
108
  * `AIAgentsProvider` via `bond('ai-agents', myProvider)` — nothing else changes.
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmFG;AAEH,cAAc,oBAAoB,CAAA;AAClC,cAAc,aAAa,CAAA;AAC3B,cAAc,eAAe,CAAA;AAC7B,cAAc,YAAY,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8GG;AAEH,cAAc,oBAAoB,CAAA;AAClC,cAAc,aAAa,CAAA;AAC3B,cAAc,eAAe,CAAA;AAC7B,cAAc,YAAY,CAAA"}
package/dist/index.js CHANGED
@@ -62,6 +62,30 @@
62
62
  * INCREMENTAL live hook while streaming — with `stream: false` it fires once
63
63
  * per turn with the whole response.
64
64
  *
65
+ * `input.thinking` forwards the core chat contract's native thinking settings
66
+ * on every turn. Streamed reasoning is retained in `ChatMessage.reasoning`
67
+ * across tool calls, separately from assistant text and tool arguments.
68
+ * Omitting the option does not enable thinking or manufacture reasoning.
69
+ *
70
+ * Two further options are OPT-IN and change nothing when omitted:
71
+ *
72
+ * - `input.maxToolResultChars` bounds the `tool_result` copy sent to the model
73
+ * (a whole-file or log dump otherwise crowds out the instruction that asked
74
+ * for it) and appends an explicit truncation note telling the model the value
75
+ * was cut and how to recover. The step record keeps the complete tool result.
76
+ * - `input.finalSynthesisOnBudgetExhaustion` spends ONE tool-free turn after
77
+ * the `maxSteps` budget is exhausted, so a run that ended on a tool
78
+ * invocation still returns a final answer instead of the budget note. That
79
+ * turn carries the same history, system, model, thinking, cache, streaming
80
+ * and signal settings plus one short generic instruction to answer now from
81
+ * the evidence already gathered (without it a model mid-investigation can
82
+ * keep planning). Its events reach `onEvent`, its usage is metered, and it
83
+ * cannot call tools: a tool request on that turn is never executed and is not
84
+ * accepted as a final answer. If the turn produces no answer text, `run()`
85
+ * rejects with `AgentRunError` instead of returning the budget note, and the
86
+ * message says whether the turn requested another tool or returned no
87
+ * content (e.g. only reasoning).
88
+ *
65
89
  * Failure semantics (how to tell failure modes apart):
66
90
  * - **Tool failure** (throwing `execute()`, unknown tool name) — NON-fatal:
67
91
  * recorded on the step with `isError: true` and fed back to the model.
@@ -75,7 +99,10 @@
75
99
  * `result.output` means the model genuinely produced no text, not that the
76
100
  * API failed.
77
101
  * - **Step budget exhausted** — resolves normally; `output` is the last
78
- * assistant text or an explicit "step budget exhausted" note.
102
+ * assistant text or an explicit "step budget exhausted" note. With
103
+ * `input.finalSynthesisOnBudgetExhaustion` the output is instead the answer
104
+ * from one final tool-free turn, and a synthesis that produced no answer
105
+ * rejects (as an `AgentRunError`) rather than resolving with the note.
79
106
  *
80
107
  * Swappable like any bond: replace this LLM agent with your own
81
108
  * `AIAgentsProvider` via `bond('ai-agents', myProvider)` — nothing else changes.
@@ -1 +1 @@
1
- {"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../src/provider.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAKH,OAAO,YAAY,CAAA;AAOnB,OAAO,KAAK,EAKV,gBAAgB,EACjB,MAAM,yBAAyB,CAAA;AAkHhC;;;;;;;GAOG;AACH,eAAO,MAAM,QAAQ,EAAE,gBA2KtB,CAAA"}
1
+ {"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../src/provider.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAIH,OAAO,YAAY,CAAA;AAOnB,OAAO,KAAK,EAKV,gBAAgB,EACjB,MAAM,yBAAyB,CAAA;AAkLhC;;;;;;;GAOG;AACH,eAAO,MAAM,QAAQ,EAAE,gBA0QtB,CAAA"}
package/dist/provider.js CHANGED
@@ -11,9 +11,8 @@
11
11
  *
12
12
  * @module
13
13
  */
14
- // Side-effect import: registers the `stream`/`cacheControl` module
15
- // augmentation on `AgentRunInput` (see types.ts) so `input.stream` and
16
- // `input.cacheControl` below are typed, not `any`.
14
+ // Side-effect import: augments AgentRunInput with this bond's typed request
15
+ // options (see types.ts).
17
16
  import './types.js';
18
17
  import { getProviderByName as getAiProviderByName, requireProvider as requireAiProvider, } from '@molecule/api-ai';
19
18
  import { t } from '@molecule/api-i18n';
@@ -56,6 +55,57 @@ function stringifyResult(result) {
56
55
  return String(result);
57
56
  }
58
57
  }
58
+ /**
59
+ * Bounds a serialized tool result for the MODEL-FACING conversation and, when
60
+ * it has to cut, appends an explicit truncation + recovery note.
61
+ *
62
+ * @remarks
63
+ * Only the copy sent to the model is bounded: the step record keeps the
64
+ * complete value (see `AgentToolCall.result`). The note is appended after the
65
+ * budget, so it is not counted against it, and the cut never splits a
66
+ * surrogate pair (an unpaired half is not valid text).
67
+ *
68
+ * @param serialized - The complete serialized tool result.
69
+ * @param maxChars - The character budget, or `undefined` for unbounded.
70
+ * @returns The content for the `tool_result` block.
71
+ */
72
+ function boundToolResult(serialized, maxChars) {
73
+ if (maxChars === undefined || serialized.length <= maxChars)
74
+ return serialized;
75
+ let end = maxChars;
76
+ const lastCode = serialized.charCodeAt(end - 1);
77
+ // Drop a split surrogate pair rather than send half a code point.
78
+ if (lastCode >= 0xd800 && lastCode <= 0xdbff)
79
+ end -= 1;
80
+ return (serialized.slice(0, end) +
81
+ t('ai-agents.toolResultTruncated', { shown: end, total: serialized.length }, {
82
+ defaultValue: '\n\n[Truncated: {{shown}} of {{total}} characters shown. The complete result is retained outside this conversation; call the tool again with a narrower range or smaller scope to read the rest.]',
83
+ }));
84
+ }
85
+ /**
86
+ * The model-facing instruction appended to the ONE tool-free synthesis turn
87
+ * after the step budget is exhausted.
88
+ *
89
+ * @remarks
90
+ * A final turn carrying only the loop's own history is indistinguishable from
91
+ * any other turn — same system prompt, same conversation, no tools — so a model
92
+ * that was mid-investigation can keep planning (or emit reasoning alone)
93
+ * instead of answering. A real bounded review hit exactly that: the whole step
94
+ * budget was spent, the tool-free turn produced no answer text, and the run
95
+ * failed with nothing to show for the evidence it had gathered.
96
+ *
97
+ * The note is deliberately short and generic — it never names a reviewer,
98
+ * benchmark, project or route — and is a USER message appended AFTER the
99
+ * recorded history for that one request, so every earlier message (including
100
+ * retained native `reasoning`) is still sent unchanged.
101
+ *
102
+ * @returns The instruction text for the synthesis turn.
103
+ */
104
+ function finalSynthesisInstruction() {
105
+ return t('ai-agents.finalSynthesisInstruction', undefined, {
106
+ defaultValue: 'The step budget is exhausted and no further tool calls are allowed. Do not request more tools. Answer now, using only the evidence already gathered in this conversation, and briefly state anything you had to leave unchecked.',
107
+ });
108
+ }
59
109
  /**
60
110
  * Drains a single chat stream, accumulating text and tool calls, forwarding
61
111
  * every event to `onEvent`, and summing `done.usage`.
@@ -66,6 +116,7 @@ function stringifyResult(result) {
66
116
  */
67
117
  async function drainStream(stream, onEvent) {
68
118
  let text = '';
119
+ let reasoning;
69
120
  const toolUses = [];
70
121
  const usage = { inputTokens: 0, outputTokens: 0 };
71
122
  let errorMessage;
@@ -77,6 +128,9 @@ async function drainStream(stream, onEvent) {
77
128
  case 'text':
78
129
  text += event.content;
79
130
  break;
131
+ case 'thinking':
132
+ reasoning = (reasoning ?? '') + event.content;
133
+ break;
80
134
  case 'tool_use':
81
135
  toolUses.push({ id: event.id, name: event.name, input: event.input });
82
136
  break;
@@ -103,7 +157,7 @@ async function drainStream(stream, onEvent) {
103
157
  if (!sawDone && lastSnapshot) {
104
158
  addUsage(usage, lastSnapshot);
105
159
  }
106
- return { text, toolUses, usage, errorMessage };
160
+ return { text, reasoning, toolUses, usage, errorMessage };
107
161
  }
108
162
  /**
109
163
  * The LLM-backed AI agents provider: a tool-calling loop over the bonded `ai`
@@ -129,6 +183,15 @@ export const provider = {
129
183
  defaultValue: 'AgentRunInput accepts either `task` or `messages`, not both. Put the task in the `messages` history (as the last user message) or drop one of the two.',
130
184
  }));
131
185
  }
186
+ // Validate the opt-in tool-result budget up front: a bad number would
187
+ // otherwise surface mid-loop as a truncation bug instead of a caller error.
188
+ const toolResultLimit = input.maxToolResultChars === undefined ? undefined : Math.floor(input.maxToolResultChars);
189
+ if (toolResultLimit !== undefined &&
190
+ (!Number.isFinite(toolResultLimit) || toolResultLimit < 1)) {
191
+ throw new Error(t('ai-agents.error.invalidMaxToolResultChars', { value: String(input.maxToolResultChars) }, {
192
+ defaultValue: '`maxToolResultChars` must be a finite number of at least 1 character (received {{value}}; fractional values are floored).',
193
+ }));
194
+ }
132
195
  const ai = input.provider ? getAiProviderByName(input.provider) : requireAiProvider();
133
196
  if (!ai) {
134
197
  throw new Error(t('ai-agents.error.aiProviderNotFound', { name: input.provider ?? '' }, { defaultValue: `AI provider "${input.provider}" is not bonded.` }));
@@ -140,6 +203,57 @@ export const provider = {
140
203
  const steps = [];
141
204
  const usage = { inputTokens: 0, outputTokens: 0 };
142
205
  let lastText = '';
206
+ /**
207
+ * Sends ONE model turn over the bonded chat provider, forwarding every
208
+ * event to `onEvent`, booking its usage, and rejecting on an in-band
209
+ * provider error (so "the API failed" never looks like an empty answer).
210
+ *
211
+ * @param offerTools - When `false` the request carries NO `tools` field,
212
+ * so the provider cannot ask for a tool call (the final synthesis turn).
213
+ * @param trailingMessages - Messages appended AFTER the loop's history for
214
+ * this ONE request only; the loop's own `messages` array is untouched, so
215
+ * every other turn sends it exactly as before.
216
+ * @returns The drained turn.
217
+ */
218
+ const modelTurn = async (offerTools, trailingMessages = []) => {
219
+ const requestMessages = trailingMessages.length > 0 ? [...messages, ...trailingMessages] : messages;
220
+ const turn = await drainStream(ai.chat({
221
+ messages: requestMessages,
222
+ system: input.system,
223
+ ...(offerTools ? { tools: input.tools } : {}),
224
+ model: input.model,
225
+ maxTokens: input.maxTokens,
226
+ temperature: input.temperature,
227
+ // Keep the caller's native thinking settings on every tool turn.
228
+ ...(input.thinking === undefined ? {} : { thinking: input.thinking }),
229
+ // Defaults to streaming (see the `stream` augmentation in
230
+ // types.ts): onEvent is a LIVE hook, and non-streaming requests
231
+ // with large outputs (a big tool input at a raised maxTokens) are
232
+ // exactly what provider docs warn will time out over HTTP.
233
+ // drainStream already folds streamed events correctly, so there
234
+ // is no extra cost to defaulting streaming on. Set
235
+ // `input.stream: false` to force non-streaming.
236
+ stream: input.stream !== false,
237
+ // Prompt-cache breakpoint hint: the system+tools prefix is
238
+ // identical across turns, so passing this through avoids
239
+ // re-billing the full (growing) prompt on every one of up to
240
+ // maxSteps turns. Providers without cache support ignore it.
241
+ cacheControl: input.cacheControl,
242
+ signal: input.signal,
243
+ }), input.onEvent);
244
+ addUsage(usage, turn.usage);
245
+ // A provider API failure (rate limit, auth, overload, network) arrives
246
+ // as an in-band `error` ChatEvent — the stream ends without text or
247
+ // tools. Rejecting here disambiguates "the AI provider failed" from
248
+ // "the model legitimately answered nothing": the old fall-through
249
+ // returned { output: '', steps, usage } as if the run succeeded, and
250
+ // callers debugging the empty output had no signal that the API ever
251
+ // errored.
252
+ if (turn.errorMessage !== undefined) {
253
+ throw new Error(t('ai-agents.error.providerError', { message: turn.errorMessage }, { defaultValue: `AI provider error during agent run: ${turn.errorMessage}` }));
254
+ }
255
+ return turn;
256
+ };
143
257
  // Everything below can fail partway through a multi-turn run (a provider
144
258
  // API error, an abort, or an unexpected throw from ai.chat() itself —
145
259
  // e.g. a signal-aborted fetch). A plain rejection would silently drop
@@ -151,39 +265,7 @@ export const provider = {
151
265
  // `instanceof AgentRunError`.
152
266
  try {
153
267
  for (let step = 0; step < maxSteps; step++) {
154
- const turn = await drainStream(ai.chat({
155
- messages,
156
- system: input.system,
157
- tools: input.tools,
158
- model: input.model,
159
- maxTokens: input.maxTokens,
160
- temperature: input.temperature,
161
- // Defaults to streaming (see the `stream` augmentation in
162
- // types.ts): onEvent is a LIVE hook, and non-streaming requests
163
- // with large outputs (a big tool input at a raised maxTokens) are
164
- // exactly what provider docs warn will time out over HTTP.
165
- // drainStream already folds streamed events correctly, so there
166
- // is no extra cost to defaulting streaming on. Set
167
- // `input.stream: false` to force non-streaming.
168
- stream: input.stream !== false,
169
- // Prompt-cache breakpoint hint: the system+tools prefix is
170
- // identical across turns, so passing this through avoids
171
- // re-billing the full (growing) prompt on every one of up to
172
- // maxSteps turns. Providers without cache support ignore it.
173
- cacheControl: input.cacheControl,
174
- signal: input.signal,
175
- }), input.onEvent);
176
- addUsage(usage, turn.usage);
177
- // A provider API failure (rate limit, auth, overload, network) arrives
178
- // as an in-band `error` ChatEvent — the stream ends without text or
179
- // tools. Rejecting here disambiguates "the AI provider failed" from
180
- // "the model legitimately answered nothing": the old fall-through
181
- // returned { output: '', steps, usage } as if the run succeeded, and
182
- // callers debugging the empty output had no signal that the API ever
183
- // errored.
184
- if (turn.errorMessage !== undefined) {
185
- throw new Error(t('ai-agents.error.providerError', { message: turn.errorMessage }, { defaultValue: `AI provider error during agent run: ${turn.errorMessage}` }));
186
- }
268
+ const turn = await modelTurn(true);
187
269
  if (turn.text)
188
270
  lastText = turn.text;
189
271
  // Append the assistant turn: a text block (if any) + one tool_use block
@@ -194,7 +276,11 @@ export const provider = {
194
276
  for (const tu of turn.toolUses) {
195
277
  assistantContent.push({ type: 'tool_use', id: tu.id, name: tu.name, input: tu.input });
196
278
  }
197
- messages.push({ role: 'assistant', content: assistantContent });
279
+ messages.push({
280
+ role: 'assistant',
281
+ content: assistantContent,
282
+ ...(turn.reasoning === undefined ? {} : { reasoning: turn.reasoning }),
283
+ });
198
284
  if (turn.toolUses.length === 0) {
199
285
  // No tools requested — the model is done.
200
286
  return { output: turn.text, steps, usage };
@@ -226,17 +312,57 @@ export const provider = {
226
312
  isError = true;
227
313
  }
228
314
  }
315
+ // `result` (above) is retained COMPLETE on the step record; only the
316
+ // copy the model reads is bounded when `maxToolResultChars` is set.
229
317
  toolCalls.push({ id: tu.id, name: tu.name, input: tu.input, result, isError });
230
318
  resultBlocks.push({
231
319
  type: 'tool_result',
232
320
  tool_use_id: tu.id,
233
- content: stringifyResult(result),
321
+ content: boundToolResult(stringifyResult(result), toolResultLimit),
234
322
  });
235
323
  }
236
324
  messages.push({ role: 'user', content: resultBlocks });
237
325
  steps.push({ text: turn.text || undefined, toolCalls });
238
326
  }
239
- // Step budget exhausted while the model was still calling tools.
327
+ // Step budget exhausted while the model was still calling tools. With
328
+ // `finalSynthesisOnBudgetExhaustion`, buy exactly ONE tool-free turn to
329
+ // answer from what the run already saw — the loop ended on a tool
330
+ // invocation, so there is no final assistant text to return otherwise.
331
+ // The request also carries one short generic instruction saying the
332
+ // budget is spent and to answer now: without it the turn is just the same
333
+ // conversation with no tools, and a model mid-investigation can keep
334
+ // planning (a real bounded review produced no answer text at exactly that
335
+ // turn). The instruction is appended for this request only; the loop's
336
+ // history, system prompt, model settings and cache/usage/error contracts
337
+ // are all unchanged.
338
+ if (input.finalSynthesisOnBudgetExhaustion === true) {
339
+ const finalTurn = await modelTurn(false, [
340
+ { role: 'user', content: finalSynthesisInstruction() },
341
+ ]);
342
+ // A renewed tool request is NOT an answer. That turn offered no tools,
343
+ // so nothing it asks for can be executed, and a plan to keep going must
344
+ // not be passed off as the final output — whether or not the turn also
345
+ // emitted text. Report this cause distinctly from a content-free turn.
346
+ if (finalTurn.toolUses.length > 0) {
347
+ throw new Error(t('ai-agents.error.finalSynthesisToolRequest', undefined, {
348
+ defaultValue: 'Agent step budget exhausted and the final synthesis turn requested another tool instead of answering; no further tool calls are allowed.',
349
+ }));
350
+ }
351
+ // Content-free is its own cause: a turn that emitted only reasoning (or
352
+ // nothing at all) produced no answer, and the message says which, so a
353
+ // caller can tell "the model kept thinking" from "the model tried to
354
+ // call a tool".
355
+ if (!finalTurn.text.trim()) {
356
+ const reasoningOnly = finalTurn.reasoning?.trim()
357
+ ? ' It returned only reasoning text and no answer.'
358
+ : '';
359
+ throw new Error(t('ai-agents.error.finalSynthesisEmpty', undefined, {
360
+ defaultValue: 'Agent step budget exhausted and the final synthesis turn produced no answer.' +
361
+ reasoningOnly,
362
+ }));
363
+ }
364
+ return { output: finalTurn.text, steps, usage };
365
+ }
240
366
  const output = lastText ||
241
367
  t('ai-agents.stepBudgetExhausted', undefined, {
242
368
  defaultValue: 'Agent stopped: step budget exhausted before a final answer.',
package/dist/types.d.ts CHANGED
@@ -1,21 +1,29 @@
1
1
  /**
2
2
  * Module augmentation: extends the core `@molecule/api-ai-agents` contract's
3
- * `AgentRunInput` with two options this bond's `run()` honors.
3
+ * `AgentRunInput` with options this bond's `run()` honors.
4
4
  *
5
5
  * @remarks
6
- * Both fields are ADDITIVE ONLY — they never widen or erase an existing
6
+ * These fields are ADDITIVE ONLY — they never widen or erase an existing
7
7
  * `AgentRunInput` field — and are consumed exclusively by this bond's `run()`
8
8
  * implementation; a different `AIAgentsProvider` bond is free to ignore them.
9
9
  * Any file that imports `@molecule/api-ai-agents-llm` (directly, or
10
10
  * transitively by bonding its `provider`) sees the merged `AgentRunInput`
11
11
  * type, matching the shape `@molecule/api-ai`'s `ChatParams` already accepts
12
- * for `stream` and `cacheControl`.
12
+ * for `stream`, `cacheControl`, and `thinking`.
13
13
  *
14
14
  * @module
15
15
  */
16
16
  import '@molecule/api-ai-agents';
17
+ import type { ChatParams } from '@molecule/api-ai';
17
18
  declare module '@molecule/api-ai-agents' {
18
19
  interface AgentRunInput {
20
+ /**
21
+ * Native thinking settings forwarded unchanged on every model turn.
22
+ * Streamed thinking is retained as `ChatMessage.reasoning` when a tool
23
+ * call continues, so providers that replay reasoning receive it intact.
24
+ * Omitting this option does not enable thinking.
25
+ */
26
+ thinking?: ChatParams['thinking'];
19
27
  /**
20
28
  * Whether each model turn streams its response (default `true`).
21
29
  *
@@ -42,6 +50,70 @@ declare module '@molecule/api-ai-agents' {
42
50
  cacheControl?: {
43
51
  type: 'ephemeral';
44
52
  };
53
+ /**
54
+ * Caps how many characters of a serialized tool result are placed in the
55
+ * model-facing conversation (default: unbounded).
56
+ *
57
+ * A tool that returns a whole file, log, or search dump can consume most
58
+ * of the context window in one step and crowd out the instruction that
59
+ * asked for it, so a bounded run can "forget" what it was doing and answer
60
+ * from the dump instead. When set, each `tool_result` block sent to the
61
+ * model carries at most this many characters of the serialized result,
62
+ * followed by an explicit note stating that the value was truncated and
63
+ * how to recover (re-run the tool with a narrower range or scope). The
64
+ * step record's `AgentToolCall.result` keeps the COMPLETE value — only the
65
+ * copy the model sees is bounded, so a caller reading the steps (a report,
66
+ * a diff, an assertion) still gets everything the tool returned.
67
+ *
68
+ * The limit applies to the result body; the truncation note is appended
69
+ * after it and is not counted against the budget. Results at or under the
70
+ * limit — including the loop's own error strings — pass through unchanged.
71
+ * A cut never splits a surrogate pair. Must be a finite number of at least
72
+ * 1 character (fractional values are floored); any other value rejects the
73
+ * run before a model call is made. Omit it for the previous unbounded
74
+ * behavior.
75
+ */
76
+ maxToolResultChars?: number;
77
+ /**
78
+ * After the `maxSteps` budget is exhausted while the model was still
79
+ * calling tools, spend ONE final synthesis turn with NO tools and return
80
+ * its text as `output` (default `false`).
81
+ *
82
+ * Without it, a run that ends on a tool invocation has no final assistant
83
+ * text: `output` falls back to the "step budget exhausted" note and any
84
+ * report the model was about to write is lost, even though the work that
85
+ * filled the budget succeeded. Opting in buys the model one tool-free turn
86
+ * to answer from what it has already seen.
87
+ *
88
+ * The synthesis turn preserves everything the loop was using: the full
89
+ * message history (including retained reasoning), `system`, `model`,
90
+ * `maxTokens`, `temperature`, `thinking`, `stream`, `cacheControl`,
91
+ * `provider`, and `signal`. It also carries ONE short, generic instruction
92
+ * (a user message appended after the history, for that request only)
93
+ * stating that the step budget is exhausted, that no further tool calls
94
+ * are allowed, and to answer now from the evidence already gathered.
95
+ * Without it a tool-free turn is just the same conversation with no tools,
96
+ * and a model mid-investigation can keep planning instead of answering.
97
+ * Its events reach `onEvent` and its usage is added to `result.usage`, so
98
+ * live monitoring and metering see the extra call. The instruction is
99
+ * never sent on any other turn, so normal runs are byte- and
100
+ * cost-identical.
101
+ *
102
+ * It offers no tools and never executes a tool call: a tool request on that
103
+ * turn cannot be executed and is NOT accepted as a final answer (even when
104
+ * the turn also emitted text), because it is a plan to keep going rather
105
+ * than an answer. The recorded `steps` stay exactly the tool-invoking
106
+ * rounds — `steps.length >= maxSteps` still means "budget exhausted".
107
+ *
108
+ * The response is returned as-is (not trimmed). A turn that produces no
109
+ * answer text is NOT papered over with the exhausted-budget note: `run()`
110
+ * rejects with an `AgentRunError` carrying the accumulated `usage`/`steps`,
111
+ * and the message distinguishes a content-free turn (including one that
112
+ * returned only reasoning) from a turn that requested another tool. A
113
+ * provider error on that turn rejects the same way. Only the literal `true`
114
+ * enables this; omit it for the previous behavior and cost.
115
+ */
116
+ finalSynthesisOnBudgetExhaustion?: boolean;
45
117
  }
46
118
  }
47
119
  //# sourceMappingURL=types.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAIH,OAAO,yBAAyB,CAAA;AAEhC,OAAO,QAAQ,yBAAyB,CAAC;IACvC,UAAU,aAAa;QACrB;;;;;;;;;;;;;;WAcG;QACH,MAAM,CAAC,EAAE,OAAO,CAAA;QAChB;;;;;;WAMG;QACH,YAAY,CAAC,EAAE;YAAE,IAAI,EAAE,WAAW,CAAA;SAAE,CAAA;KACrC;CACF"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAIH,OAAO,yBAAyB,CAAA;AAEhC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAA;AAElD,OAAO,QAAQ,yBAAyB,CAAC;IACvC,UAAU,aAAa;QACrB;;;;;WAKG;QACH,QAAQ,CAAC,EAAE,UAAU,CAAC,UAAU,CAAC,CAAA;QACjC;;;;;;;;;;;;;;WAcG;QACH,MAAM,CAAC,EAAE,OAAO,CAAA;QAChB;;;;;;WAMG;QACH,YAAY,CAAC,EAAE;YAAE,IAAI,EAAE,WAAW,CAAA;SAAE,CAAA;QACpC;;;;;;;;;;;;;;;;;;;;;;WAsBG;QACH,kBAAkB,CAAC,EAAE,MAAM,CAAA;QAC3B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;WAsCG;QACH,gCAAgC,CAAC,EAAE,OAAO,CAAA;KAC3C;CACF"}
package/dist/types.js CHANGED
@@ -1,15 +1,15 @@
1
1
  /**
2
2
  * Module augmentation: extends the core `@molecule/api-ai-agents` contract's
3
- * `AgentRunInput` with two options this bond's `run()` honors.
3
+ * `AgentRunInput` with options this bond's `run()` honors.
4
4
  *
5
5
  * @remarks
6
- * Both fields are ADDITIVE ONLY — they never widen or erase an existing
6
+ * These fields are ADDITIVE ONLY — they never widen or erase an existing
7
7
  * `AgentRunInput` field — and are consumed exclusively by this bond's `run()`
8
8
  * implementation; a different `AIAgentsProvider` bond is free to ignore them.
9
9
  * Any file that imports `@molecule/api-ai-agents-llm` (directly, or
10
10
  * transitively by bonding its `provider`) sees the merged `AgentRunInput`
11
11
  * type, matching the shape `@molecule/api-ai`'s `ChatParams` already accepts
12
- * for `stream` and `cacheControl`.
12
+ * for `stream`, `cacheControl`, and `thinking`.
13
13
  *
14
14
  * @module
15
15
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@molecule/api-ai-agents-llm",
3
- "version": "1.0.3",
3
+ "version": "1.1.0",
4
4
  "description": "LLM-backed AI agents provider for molecule.dev — a tool-calling loop over the swappable @molecule/api-ai chat bond",
5
5
  "homepage": "https://www.molecule.dev/packages/api-ai-agents-llm",
6
6
  "type": "module",
@@ -41,10 +41,10 @@
41
41
  "@molecule/api-i18n": "^1.0.1"
42
42
  },
43
43
  "devDependencies": {
44
- "@molecule/api-ai": "1.3.5",
44
+ "@molecule/api-ai": "1.4.0",
45
45
  "@molecule/api-ai-agents": "1.0.3",
46
- "@molecule/api-bond": "1.0.3",
47
- "@molecule/api-i18n": "1.0.4",
46
+ "@molecule/api-bond": "1.0.4",
47
+ "@molecule/api-i18n": "1.0.5",
48
48
  "@types/node": "26.6.5",
49
49
  "typescript": "6.0.3",
50
50
  "vitest": "4.1.12"