@k2b/nessi 0.10.0 → 0.11.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
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.11.0",
4
4
  "description": "Minimal agent loop and provider adapters for nessi.",
5
5
  "main": "index.js",
6
6
  "module": "index.js",
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;