@k2b/nessi 0.10.0 → 0.12.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
@@ -64,6 +64,61 @@ Every outbound event from one `nessi()` run carries the same `loopId`. Pass your
64
64
 
65
65
  `turn_end` reports each internal provider turn. The final `loop_end` event includes `aggregate`, which groups assistant turns, executable tool calls, tool results, validation/execution errors, malformed or cancelled tool streams, summed usage, and timing for the complete logical loop. `aggregate.timing.totalElapsedMs` is model generation plus active tool execution; approval/client-tool waits are tracked separately as `aggregate.timing.actionWaitMs`. Helper exports such as `mergeUsage()`, `cloneLoopAggregate()`, and `mergeLoopAggregates()` are available from `@k2b/nessi`.
66
66
 
67
+ ## Dynamic tools
68
+
69
+ Pass a resolver when the active tools depend on application state:
70
+
71
+ ```ts
72
+ const loop = nessi({
73
+ provider,
74
+ systemPrompt,
75
+ input,
76
+ store,
77
+ tools: async () => toolRegistry.activeFor(userId),
78
+ });
79
+ ```
80
+
81
+ Nessi resolves dynamic tools before every provider turn. One copied and
82
+ validated snapshot supplies both the provider schemas and all tool execution
83
+ for that turn. Changes made during tool execution therefore become visible on
84
+ the following provider turn, while calls already emitted by the provider remain
85
+ executable from their original snapshot. Duplicate names and resolver failures
86
+ end the loop with a `runtime_error` issue.
87
+
88
+ Pending tool calls restored from history resolve one fresh snapshot before
89
+ execution because an in-memory snapshot cannot survive a restart. Keep the
90
+ resolver side-effect free; persistence, discovery, authorization, and cleanup
91
+ remain application-owned.
92
+
93
+ Server tools receive the provider tool call ID through `ctx.callId`:
94
+
95
+ ```ts
96
+ const inspect = defineTool({
97
+ name: "inspect",
98
+ description: "Inspect an item",
99
+ inputSchema: z.object({ id: z.string() }),
100
+ }).server(async ({ id }, ctx) => {
101
+ return inspectItem(id, { callId: ctx.callId, signal: ctx.signal });
102
+ });
103
+ ```
104
+
105
+ `nessi.structured()` accepts the same pattern for server tools:
106
+
107
+ ```ts
108
+ const result = await nessi.structured({
109
+ provider,
110
+ input: "Resolve the current task.",
111
+ output: taskSchema,
112
+ tools: () => toolRegistry.activeServerTools(),
113
+ });
114
+ ```
115
+
116
+ A structured tool resolver always selects `tool_loop` mode, even when a
117
+ snapshot is empty, because later turns may add tools. Nessi appends its internal
118
+ `submit_result` tool to every snapshot. Client tools, approval tools, and a
119
+ user-defined `submit_result` remain unsupported. A static empty array keeps the
120
+ direct native/fallback structured-output path.
121
+
67
122
  ## Historical tool results
68
123
 
69
124
  Verbose tool output can remain fully persisted without being sent to the model
@@ -210,11 +265,62 @@ for await (const event of provider.stream({ messages })) {
210
265
  }
211
266
  ```
212
267
 
268
+ ## Audio transcription
269
+
270
+ Use `openAICompatibleTranscription()` to upload an audio file to a service that
271
+ implements the OpenAI-compatible `/audio/transcriptions` endpoint. Configure
272
+ the service URL, API key and transcription model explicitly:
273
+
274
+ ```ts
275
+ import { openAICompatibleTranscription } from "@k2b/nessi/ai";
276
+
277
+ const speech = openAICompatibleTranscription("whisper-large-v3", {
278
+ baseURL: "https://api.scaleway.ai/v1",
279
+ apiKey: process.env.SCW_SECRET_KEY,
280
+ });
281
+
282
+ const result = await speech.transcribe({
283
+ file: Bun.file("./aufnahme.mp3"),
284
+ filename: "aufnahme.mp3",
285
+ language: "de",
286
+ signal: AbortSignal.timeout(120_000),
287
+ });
288
+
289
+ console.log(result.text);
290
+ ```
291
+
292
+ For OpenAI, set `baseURL: "https://api.openai.com/v1"`, use your OpenAI key and
293
+ a supported transcription model such as `whisper-1`. Local compatible services
294
+ can omit `apiKey`. No environment variable is read automatically. Optional
295
+ `headers` support gateways; `apiKey` overrides their Authorization header.
296
+
297
+ `file` accepts a `Blob`, `File` or `Bun.file()`. Use `filename` to supply an
298
+ extension for an unnamed Blob or override the filename. Automatically derived
299
+ filenames omit local directories. Omit `language`
300
+ for automatic detection. Optional `prompt` supplies vocabulary or context
301
+ when supported by the model. File formats, size limits and optional parameter
302
+ support depend on the service; Nessi does not convert or split audio.
303
+
304
+ The result is `{ text: string }`, including an empty string for an empty
305
+ transcript. HTTP, connection and malformed-response errors reject the promise.
306
+ Pass `signal` for cancellation or a timeout; cancellation preserves the signal's
307
+ reason. There are no automatic retries or default timeout.
308
+
309
+ Transcription uses its own `TranscriptionProvider` contract with `name`, `model`
310
+ and `transcribe(request)`. Custom adapters can implement that interface for other
311
+ protocols. It is separate from the chat provider passed to `nessi()`; pass the
312
+ resulting text into the agent when needed. Streaming transcription, timestamps
313
+ and speaker identification are not exposed.
314
+
315
+ The example follows [Scaleway's audio API documentation](https://www.scaleway.com/en/docs/generative-apis/how-to/query-audio-models/).
316
+ Keep API keys on the server when integrating a browser application.
317
+
213
318
  ## Focused provider imports
214
319
 
215
320
  ```ts
216
321
  import { anthropic } from "@k2b/nessi/ai/providers/anthropic";
217
322
  import { openai } from "@k2b/nessi/ai/providers/openai";
323
+ import { openAICompatibleTranscription } from "@k2b/nessi/ai/providers/openai-compatible-transcription";
218
324
  ```
219
325
 
220
326
  ## Features
@@ -235,6 +341,7 @@ import { openai } from "@k2b/nessi/ai/providers/openai";
235
341
  - Standalone `compact()` loop with `loop_start`, `compaction_start`, `compaction_end`, `issue`, and `loop_end` events
236
342
  - Optional token-credit budgeting
237
343
  - Provider adapters with shared `complete()` and `stream()` APIs
344
+ - Audio transcription through configurable OpenAI-compatible services
238
345
  - Native adapters for OpenAI, OpenRouter, vLLM, Ollama, Anthropic, Mistral, and Gemini
239
346
 
240
347
  ## Package layout
@@ -244,7 +351,7 @@ import { openai } from "@k2b/nessi/ai/providers/openai";
244
351
  Agent loop, structured task helper, tools, stores, compaction, shared types
245
352
 
246
353
  @k2b/nessi/ai
247
- Provider factories, provider types, complete(), stream(), responseFormat
354
+ Provider factories, provider types, complete(), stream(), responseFormat, transcribe()
248
355
 
249
356
  @k2b/nessi/ai/providers/*
250
357
  Focused provider entrypoints
package/ai/index.d.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  export { completeFromStream } from "./complete-from-stream.js";
2
+ export { openAICompatibleTranscription } from "./providers/openai-compatible-transcription.js";
3
+ export type { OpenAICompatibleTranscriptionOptions } from "./providers/openai-compatible-transcription.js";
4
+ export type { TranscriptionProvider, TranscriptionRequest, TranscriptionResult } from "./transcription.js";
2
5
  export { openAICompatible } from "./providers/openai-compatible.js";
3
6
  export { openai } from "./providers/openai.js";
4
7
  export { openrouter } from "./providers/openrouter.js";
package/ai/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { completeFromStream } from "./complete-from-stream.js";
2
+ export { openAICompatibleTranscription } from "./providers/openai-compatible-transcription.js";
2
3
  export { openAICompatible } from "./providers/openai-compatible.js";
3
4
  export { openai } from "./providers/openai.js";
4
5
  export { openrouter } from "./providers/openrouter.js";
@@ -0,0 +1,8 @@
1
+ import type { TranscriptionProvider } from "../transcription.js";
2
+ export type OpenAICompatibleTranscriptionOptions = {
3
+ baseURL: string;
4
+ apiKey?: string;
5
+ headers?: Record<string, string>;
6
+ name?: string;
7
+ };
8
+ export declare const openAICompatibleTranscription: (model: string, options: OpenAICompatibleTranscriptionOptions) => TranscriptionProvider;
@@ -0,0 +1,60 @@
1
+ import { formatConnectionError, normalizeHttpError } from "../shared/errors.js";
2
+ export const openAICompatibleTranscription = (model, options) => {
3
+ const baseURL = options.baseURL.replace(/\/+$/, "");
4
+ const name = options.name ?? "openai-compatible";
5
+ const headers = new Headers(options.headers);
6
+ // Fetch must supply the multipart boundary, including with custom headers.
7
+ headers.delete("Content-Type");
8
+ if (options.apiKey)
9
+ headers.set("Authorization", `Bearer ${options.apiKey}`);
10
+ return {
11
+ name,
12
+ model,
13
+ async transcribe(request) {
14
+ request.signal?.throwIfAborted();
15
+ const body = new FormData();
16
+ // Bun.file().name can contain a local path; only upload the basename.
17
+ const fileName = "name" in request.file && typeof request.file.name === "string"
18
+ ? request.file.name.split(/[\\/]/).at(-1)
19
+ : undefined;
20
+ const filename = request.filename ?? fileName;
21
+ if (filename !== undefined)
22
+ body.append("file", request.file, filename);
23
+ else
24
+ body.append("file", request.file);
25
+ body.append("model", model);
26
+ body.append("response_format", "json");
27
+ if (request.language !== undefined)
28
+ body.append("language", request.language);
29
+ if (request.prompt !== undefined)
30
+ body.append("prompt", request.prompt);
31
+ const response = await fetch(`${baseURL}/audio/transcriptions`, {
32
+ method: "POST",
33
+ headers,
34
+ body,
35
+ signal: request.signal,
36
+ }).catch((error) => {
37
+ request.signal?.throwIfAborted();
38
+ throw new Error(formatConnectionError(name, error));
39
+ });
40
+ if (!response.ok) {
41
+ const normalized = await normalizeHttpError(name, response);
42
+ request.signal?.throwIfAborted();
43
+ throw new Error(normalized.error);
44
+ }
45
+ const raw = await response.text();
46
+ let payload;
47
+ try {
48
+ payload = JSON.parse(raw);
49
+ }
50
+ catch {
51
+ throw new Error(`${name} returned invalid transcription JSON.`);
52
+ }
53
+ if (typeof payload !== "object" || payload === null
54
+ || !("text" in payload) || typeof payload.text !== "string") {
55
+ throw new Error(`${name} returned a transcription without a string text field.`);
56
+ }
57
+ return { text: payload.text };
58
+ },
59
+ };
60
+ };
@@ -0,0 +1,18 @@
1
+ export type TranscriptionRequest = {
2
+ file: Blob;
3
+ /** Override the file name, or supply one for an unnamed Blob. */
4
+ filename?: string;
5
+ /** ISO-639-1 language code, for example "de". Omit for automatic detection. */
6
+ language?: string;
7
+ /** Optional vocabulary or context hint, subject to model support. */
8
+ prompt?: string;
9
+ signal?: AbortSignal;
10
+ };
11
+ export type TranscriptionResult = {
12
+ text: string;
13
+ };
14
+ export type TranscriptionProvider = {
15
+ name: string;
16
+ model: string;
17
+ transcribe(request: TranscriptionRequest): Promise<TranscriptionResult>;
18
+ };
File without changes
package/index.d.ts CHANGED
@@ -8,4 +8,4 @@ export { defineTool, toolToJsonSchema, toolToSpec } from "./tools.js";
8
8
  export { memoryStore } from "./stores.js";
9
9
  export { estimateTokens, truncateMiddle, truncateToolResults } from "./utils.js";
10
10
  export { cloneLoopAggregate, cloneUsage, mergeLoopAggregates, mergeUsage } from "./aggregates.js";
11
- export type { NessiOptions, NessiLoop, SteeringContext, SteeringFn, StructuredInput, StructuredMeta, StructuredMode, StructuredOptions, StructuredResult, ContentPart, JsonSchemaObject, Input, OutboundEvent, InboundEvent, DoneReason, LoopAggregate, LoopTimingAggregate, LoopTurnAggregate, LoopToolCallAggregate, LoopToolIssueAggregate, Message, UserMessage, AssistantMessage, AssistantStopReason, HistoricalToolResult, ToolResultMessage, AssistantContentBlock, TextBlock, ThinkingBlock, ToolCallBlock, ToolStreamIssue, ToolStreamIssueKind, ToolStreamIssueReason, ToolHistoricalResultIssue, Usage, ToolDefinition, HistoricalToolResultContext, ServerTool, ClientTool, Tool, ToolContext, Provider, ProviderRequest, ProviderEvent, ResponseFormat, StoreEntry, SessionStore, CompactFn, CompactContext, CompactOptions, CompactResult, CompactDoneReason, CompactEvent, CompactLoop, CreditStore, } from "./types.js";
11
+ export type { NessiOptions, NessiLoop, SteeringContext, SteeringFn, StructuredInput, StructuredMeta, StructuredMode, StructuredOptions, StructuredResult, StructuredToolResolver, ContentPart, JsonSchemaObject, Input, OutboundEvent, InboundEvent, DoneReason, LoopAggregate, LoopTimingAggregate, LoopTurnAggregate, LoopToolCallAggregate, LoopToolIssueAggregate, Message, UserMessage, AssistantMessage, AssistantStopReason, HistoricalToolResult, ToolResultMessage, AssistantContentBlock, TextBlock, ThinkingBlock, ToolCallBlock, ToolStreamIssue, ToolStreamIssueKind, ToolStreamIssueReason, ToolHistoricalResultIssue, Usage, ToolDefinition, HistoricalToolResultContext, ServerTool, ClientTool, Tool, ToolResolver, ToolContext, Provider, ProviderRequest, ProviderEvent, ResponseFormat, StoreEntry, SessionStore, CompactFn, CompactContext, CompactOptions, CompactResult, CompactDoneReason, CompactEvent, CompactLoop, CreditStore, } from "./types.js";
package/nessi.js CHANGED
@@ -5,6 +5,20 @@ import { aggregateFromTurns, buildLoopTiming, cloneUsage } from "./aggregates.js
5
5
  import { appendAssistantContentBlock, buildAssistantMessageFromContent } from "./ai/shared/messages.js";
6
6
  import { toolToSpec } from "./tools.js";
7
7
  import { createLoopId, projectHistoricalToolResults, toErrorMessage, truncateToolResults, zeroUsage, } from "./utils.js";
8
+ const createToolSnapshot = (value) => {
9
+ if (!Array.isArray(value))
10
+ throw new Error("Tool resolver must return an array");
11
+ const tools = [...value];
12
+ const names = tools.map((tool) => tool.def.name);
13
+ if (new Set(names).size !== names.length) {
14
+ const duplicate = names.find((name, index) => names.indexOf(name) !== index);
15
+ throw new Error(`Duplicate tool name: ${duplicate}`);
16
+ }
17
+ return {
18
+ providerTools: tools.map(toolToSpec),
19
+ toolMap: new Map(tools.map((tool) => [tool.def.name, tool])),
20
+ };
21
+ };
8
22
  class PullCancelledError extends Error {
9
23
  constructor() {
10
24
  super("channel pull cancelled");
@@ -320,7 +334,7 @@ const coalesceOutboundEvents = async function* (source, options) {
320
334
  // nessi()
321
335
  // ----------------------------------------------------------------------------
322
336
  export const nessi = (options) => {
323
- const { agentId = "main", loopId: requestedLoopId, input, provider, systemPrompt, tools = [], store, creditStore, compact, steering, maxTurns = Infinity, temperature, maxOutputTokens, disableReasoning, coalesce, maxToolResultChars, signal: externalSignal, } = options;
337
+ const { agentId = "main", loopId: requestedLoopId, input, provider, systemPrompt, tools: toolSource = [], store, creditStore, compact, steering, maxTurns = Infinity, temperature, maxOutputTokens, disableReasoning, coalesce, maxToolResultChars, signal: externalSignal, } = options;
324
338
  const channel = createChannel();
325
339
  const deferredInbound = [];
326
340
  const steerQueue = [];
@@ -432,6 +446,9 @@ export const nessi = (options) => {
432
446
  externalSignal.addEventListener("abort", () => abortController.abort(), { once: true });
433
447
  }
434
448
  const signal = abortController.signal;
449
+ const toolResolver = typeof toolSource === "function" ? toolSource : undefined;
450
+ const staticToolSnapshot = toolResolver ? undefined : createToolSnapshot(toolSource);
451
+ const resolveToolSnapshot = async () => staticToolSnapshot ?? createToolSnapshot(await toolResolver());
435
452
  async function* applyPendingSteering() {
436
453
  const pending = steerQueue.splice(0);
437
454
  const supplied = await steering?.({ agentId, loopId, signal });
@@ -451,13 +468,6 @@ export const nessi = (options) => {
451
468
  }
452
469
  return applied;
453
470
  }
454
- const names = tools.map((tool) => tool.def.name);
455
- if (new Set(names).size !== names.length) {
456
- const dup = names.find((name, index) => names.indexOf(name) !== index);
457
- throw new Error(`Duplicate tool name: ${dup}`);
458
- }
459
- const toolMap = new Map(tools.map((tool) => [tool.def.name, tool]));
460
- const isTerminalTool = (name) => Boolean(toolMap.get(name)?.def?.terminal);
461
471
  const appendToolResult = async (callId, name, result, isError = false) => {
462
472
  const msg = { role: "tool_result", callId, name, result, isError };
463
473
  await store.append(msg);
@@ -516,10 +526,10 @@ export const nessi = (options) => {
516
526
  toolExecutionMs: timing.toolExecutionMs,
517
527
  actionWaitMs: timing.actionWaitMs,
518
528
  }, usage);
519
- async function* executeToolCall(tc, turnCtx, updateAggregateToolCall, turnIssues) {
529
+ async function* executeToolCall(tc, toolSnapshot, turnCtx, updateAggregateToolCall, turnIssues) {
520
530
  const eventFields = { agentId, loopId, ...turnCtx };
521
531
  yield { type: "tool_execution_start", ...eventFields, callId: tc.id, name: tc.name, args: tc.args };
522
- const tool = toolMap.get(tc.name);
532
+ const tool = toolSnapshot.toolMap.get(tc.name);
523
533
  if (!tool) {
524
534
  yield* failToolCall(tc, turnCtx, updateAggregateToolCall, toolExecutionIssue("unknown_tool", `Unknown tool: ${tc.name}`, tc), turnIssues);
525
535
  return;
@@ -640,6 +650,7 @@ export const nessi = (options) => {
640
650
  let approvalCounter = 0;
641
651
  let clientToolCounter = 0;
642
652
  const ctx = {
653
+ callId: tc.id,
643
654
  signal: toolAbort.signal,
644
655
  requestApproval(message) {
645
656
  return new Promise((resolve) => {
@@ -734,7 +745,7 @@ export const nessi = (options) => {
734
745
  }
735
746
  while (clientToolQueue.length > 0) {
736
747
  const req = clientToolQueue.shift();
737
- const bridgeTool = toolMap.get(req.name);
748
+ const bridgeTool = toolSnapshot.toolMap.get(req.name);
738
749
  let requestArgs = req.args;
739
750
  if (bridgeTool) {
740
751
  if (bridgeTool.kind !== "client") {
@@ -872,6 +883,7 @@ export const nessi = (options) => {
872
883
  const pending = toolCallBlocks.filter((block) => !resolvedCallIds.has(block.id));
873
884
  if (pending.length === 0)
874
885
  return;
886
+ const toolSnapshot = await resolveToolSnapshot();
875
887
  const aggregateTurn = loopTurns.findLast((turn) => turn.message === assistantMessage);
876
888
  const aggregateToolCallMap = new Map((aggregateTurn?.toolCalls ?? []).map((toolCall) => [toolCall.callId, toolCall]));
877
889
  const updateAggregateToolCall = aggregateTurn
@@ -886,7 +898,7 @@ export const nessi = (options) => {
886
898
  for (const tc of pending) {
887
899
  if (signal.aborted)
888
900
  return;
889
- yield* executeToolCall(tc, turnCtx, updateAggregateToolCall, turnIssues);
901
+ yield* executeToolCall(tc, toolSnapshot, turnCtx, updateAggregateToolCall, turnIssues);
890
902
  }
891
903
  if (aggregateTurn && turnIssues.issues.length > 0) {
892
904
  aggregateTurn.issues = [...(aggregateTurn.issues ?? []), ...turnIssues.issues.map((issue) => ({ ...issue }))];
@@ -901,7 +913,6 @@ export const nessi = (options) => {
901
913
  let providerTurn = 0;
902
914
  let eventTurnIndex = 0;
903
915
  let compactionRetried = false;
904
- const providerTools = tools.map(toolToSpec);
905
916
  const prepareProviderMessages = (sourceEntries) => {
906
917
  const rawMessages = sourceEntries.map((entry) => entry.message);
907
918
  const projectedMessages = projectHistoricalToolResults(rawMessages, loopId);
@@ -950,6 +961,8 @@ export const nessi = (options) => {
950
961
  yield loopEndEvent("max_turns");
951
962
  return;
952
963
  }
964
+ const toolSnapshot = await resolveToolSnapshot();
965
+ const providerTools = toolSnapshot.providerTools;
953
966
  let entries = await store.load();
954
967
  let messages = prepareProviderMessages(entries);
955
968
  const contextWindow = provider.contextWindow;
@@ -1168,9 +1181,10 @@ export const nessi = (options) => {
1168
1181
  };
1169
1182
  let terminalToolCompleted = false;
1170
1183
  for (const tc of toolCalls) {
1171
- yield* executeToolCall(tc, turnCtx, updateAggregateToolCall, turnIssues);
1184
+ yield* executeToolCall(tc, toolSnapshot, turnCtx, updateAggregateToolCall, turnIssues);
1172
1185
  const aggregateToolCall = aggregateToolCallMap.get(tc.id);
1173
- if (isTerminalTool(tc.name) && aggregateToolCall && !aggregateToolCall.isError) {
1186
+ const isTerminalTool = Boolean(toolSnapshot.toolMap.get(tc.name)?.def?.terminal);
1187
+ if (isTerminalTool && aggregateToolCall && !aggregateToolCall.isError) {
1174
1188
  terminalToolCompleted = true;
1175
1189
  break;
1176
1190
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k2b/nessi",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Minimal agent loop and provider adapters for nessi.",
5
5
  "main": "index.js",
6
6
  "module": "index.js",
@@ -16,6 +16,10 @@
16
16
  "import": "./ai/index.js",
17
17
  "types": "./ai/index.d.ts"
18
18
  },
19
+ "./ai/providers/openai-compatible-transcription": {
20
+ "import": "./ai/providers/openai-compatible-transcription.js",
21
+ "types": "./ai/providers/openai-compatible-transcription.d.ts"
22
+ },
19
23
  "./ai/providers/openai-compatible": {
20
24
  "import": "./ai/providers/openai-compatible.js",
21
25
  "types": "./ai/providers/openai-compatible.d.ts"
package/structured.js CHANGED
@@ -220,6 +220,7 @@ const wrapStructuredTool = (tool) => ({
220
220
  def: tool.def,
221
221
  execute(input, ctx) {
222
222
  return tool.execute(input, {
223
+ callId: ctx.callId,
223
224
  signal: ctx.signal,
224
225
  async requestApproval() {
225
226
  throw new Error("nessi.structured() does not support tool approvals. Use nessi() for interactive tools.");
@@ -324,8 +325,7 @@ const loopErrorCode = (reason) => {
324
325
  return "loop_failed";
325
326
  };
326
327
  const toolLoopStructured = async (options, inputMessage, jsonSchema, loopId) => {
327
- const tools = options.tools ?? [];
328
- validateStructuredTools(tools);
328
+ const toolSource = options.tools ?? [];
329
329
  let submitted;
330
330
  const submitDef = defineTool({
331
331
  name: SUBMIT_RESULT_TOOL_NAME,
@@ -338,6 +338,24 @@ const toolLoopStructured = async (options, inputMessage, jsonSchema, loopId) =>
338
338
  return { accepted: true };
339
339
  });
340
340
  submitTool.def.terminal = true;
341
+ let structuredToolError;
342
+ const prepareTools = (value) => {
343
+ if (!Array.isArray(value))
344
+ throw new Error("Structured tool resolver must return an array");
345
+ const tools = [...value];
346
+ try {
347
+ validateStructuredTools(tools);
348
+ }
349
+ catch (error) {
350
+ if (error instanceof StructuredOutputError)
351
+ structuredToolError = error;
352
+ throw error;
353
+ }
354
+ return [...tools.map(wrapStructuredTool), submitTool];
355
+ };
356
+ const tools = typeof toolSource === "function"
357
+ ? async () => prepareTools(await toolSource())
358
+ : prepareTools(toolSource);
341
359
  const store = memoryStore();
342
360
  await store.append(inputMessage);
343
361
  const loop = nessi({
@@ -352,7 +370,7 @@ const toolLoopStructured = async (options, inputMessage, jsonSchema, loopId) =>
352
370
  "Do not return the final structured result as normal assistant text.",
353
371
  schemaInstruction(jsonSchema, options.outputName),
354
372
  ].join("\n\n"),
355
- tools: [...tools.map(wrapStructuredTool), submitTool],
373
+ tools,
356
374
  maxTurns: options.maxTurns ?? 8,
357
375
  temperature: options.temperature,
358
376
  maxOutputTokens: options.maxOutputTokens,
@@ -372,6 +390,11 @@ const toolLoopStructured = async (options, inputMessage, jsonSchema, loopId) =>
372
390
  throw new StructuredOutputError("Structured tool loop ended without loop_end.", "loop_failed");
373
391
  }
374
392
  if (loopEnd.reason !== "stop") {
393
+ if (structuredToolError) {
394
+ throw new StructuredOutputError(structuredToolError.message, structuredToolError.code, {
395
+ aggregate: loopEnd.aggregate,
396
+ });
397
+ }
375
398
  throw new StructuredOutputError(`Structured tool loop ended with reason: ${loopEnd.reason}`, loopErrorCode(loopEnd.reason), {
376
399
  aggregate: loopEnd.aggregate,
377
400
  });
@@ -406,7 +429,7 @@ export const structured = async (options) => {
406
429
  const loopId = options.loopId?.trim() ? options.loopId : createLoopId();
407
430
  const inputMessage = normalizeStructuredInput(options.input);
408
431
  const jsonSchema = schemaFor(options.output);
409
- const hasTools = (options.tools?.length ?? 0) > 0;
432
+ const hasTools = typeof options.tools === "function" || (options.tools?.length ?? 0) > 0;
410
433
  if (hasTools)
411
434
  return toolLoopStructured(options, inputMessage, jsonSchema, loopId);
412
435
  return directStructured(options, inputMessage, jsonSchema, loopId);
package/types.d.ts CHANGED
@@ -137,7 +137,10 @@ export type ClientTool<TInput extends z.ZodType = z.ZodType, TOutput extends z.Z
137
137
  execute(input: z.infer<TInput>): z.infer<TOutput> | Promise<z.infer<TOutput>>;
138
138
  };
139
139
  export type Tool = ServerTool | ClientTool;
140
+ export type ToolResolver = () => Tool[] | Promise<Tool[]>;
140
141
  export type ToolContext = {
142
+ /** Provider-assigned ID of the tool call currently being executed. */
143
+ callId?: string;
141
144
  signal: AbortSignal;
142
145
  /** Request user approval mid-execution. Returns true if approved, false if denied. */
143
146
  requestApproval(message: string): Promise<boolean>;
@@ -158,7 +161,8 @@ export type NessiOptions = {
158
161
  input?: Input;
159
162
  provider: Provider;
160
163
  systemPrompt: string;
161
- tools?: Tool[];
164
+ /** Static tools or a resolver evaluated once before every provider turn. */
165
+ tools?: Tool[] | ToolResolver;
162
166
  store: SessionStore;
163
167
  creditStore?: CreditStore;
164
168
  compact?: CompactFn;
@@ -198,6 +202,7 @@ export type StructuredMeta = {
198
202
  attempts: number;
199
203
  usedResponseFormat: boolean;
200
204
  };
205
+ export type StructuredToolResolver = () => ServerTool[] | Promise<ServerTool[]>;
201
206
  export type StructuredOptions<TOutput extends z.ZodType = z.ZodType> = {
202
207
  agentId?: string;
203
208
  /** Correlates the internal structured task. Generated when omitted. */
@@ -207,7 +212,8 @@ export type StructuredOptions<TOutput extends z.ZodType = z.ZodType> = {
207
212
  input: StructuredInput;
208
213
  output: TOutput;
209
214
  outputName?: string;
210
- tools?: ServerTool[];
215
+ /** Static server tools or a resolver evaluated once before every provider turn. */
216
+ tools?: ServerTool[] | StructuredToolResolver;
211
217
  maxTurns?: number;
212
218
  temperature?: number;
213
219
  maxOutputTokens?: number;