@codemation/core-nodes 0.4.3 → 1.0.1

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.
@@ -1,19 +1,19 @@
1
1
  import type {
2
2
  AgentGuardrailConfig,
3
3
  AgentToolCall,
4
+ ChatLanguageModel,
5
+ ChatLanguageModelCallOptions,
4
6
  ChatModelConfig,
5
7
  ChatModelFactory,
6
8
  Item,
7
9
  Items,
8
10
  JsonValue,
9
- LangChainChatModelLike,
10
11
  NodeExecutionContext,
11
12
  NodeInputsByPort,
12
13
  RunnableNode,
13
14
  RunnableNodeExecuteArgs,
14
15
  Tool,
15
16
  ToolConfig,
16
- LangChainStructuredOutputModelLike,
17
17
  ZodSchemaAny,
18
18
  } from "@codemation/core";
19
19
 
@@ -34,7 +34,15 @@ import {
34
34
  type NodeResolver,
35
35
  } from "@codemation/core";
36
36
 
37
- import { AIMessage, type BaseMessage } from "@langchain/core/messages";
37
+ import type { AssistantModelMessage, GenerateTextResult, LanguageModel, ModelMessage, ToolSet } from "ai";
38
+ import { Output, generateText, jsonSchema } from "ai";
39
+
40
+ /**
41
+ * OUTPUT generic must extend AI SDK's `Output<OUTPUT, PARTIAL, ELEMENT>` which is parametric on
42
+ * `any`; there is no narrower concrete type we can substitute that accepts both text-only and
43
+ * structured turns uniformly.
44
+ */
45
+ type AnyGenerateTextResult = GenerateTextResult<ToolSet, any>;
38
46
  import { z } from "zod";
39
47
 
40
48
  import type { AIAgent } from "./AIAgentConfig";
@@ -60,25 +68,35 @@ type ResolvedGuardrails = Required<Pick<AgentGuardrailConfig, "maxTurns" | "onTu
60
68
  /** Everything needed to run the agent loop for one item (shared across items in the same activation). */
61
69
  interface PreparedAgentExecution {
62
70
  readonly ctx: NodeExecutionContext<AIAgent<any, any>>;
63
- readonly model: LangChainChatModelLike;
71
+ readonly model: ChatLanguageModel;
64
72
  readonly resolvedTools: ReadonlyArray<ResolvedTool>;
65
73
  readonly guardrails: ResolvedGuardrails;
66
74
  readonly languageModelConnectionNodeId: string;
67
75
  }
68
76
 
77
+ /** Result of one `generateText` turn with tools disabled for auto-execution. */
78
+ interface TurnResult {
79
+ readonly assistantMessage: AssistantModelMessage | undefined;
80
+ readonly text: string;
81
+ readonly toolCalls: ReadonlyArray<AgentToolCall>;
82
+ readonly usage: ModelUsage;
83
+ }
84
+
85
+ interface ModelUsage {
86
+ readonly inputTokens?: number;
87
+ readonly outputTokens?: number;
88
+ readonly totalTokens?: number;
89
+ readonly cachedInputTokens?: number;
90
+ readonly reasoningTokens?: number;
91
+ }
92
+
69
93
  @node({ packageName: "@codemation/core-nodes" })
70
94
  export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
71
95
  kind = "node" as const;
72
96
  outputPorts = ["main"] as const;
73
- /**
74
- * Engine validates {@link RunnableNodeConfig.inputSchema} (Zod) on {@code item.json} before enqueue, then resolves
75
- * per-item **`itemExpr`** leaves on config before {@link #execute}. Prefer modeling prompts as
76
- * {@code { messages: [{ role, content }, ...] }} (on input or config) so persisted inputs are visible in the UI.
77
- */
78
97
  readonly inputSchema = z.unknown();
79
98
 
80
99
  private readonly connectionCredentialExecutionContextFactory: ConnectionCredentialExecutionContextFactory;
81
- /** One resolved model/tools bundle per activation context (same ctx across items in a batch). */
82
100
  private readonly preparedByExecutionContext = new WeakMap<
83
101
  NodeExecutionContext<AIAgent<any, any>>,
84
102
  Promise<PreparedAgentExecution>
@@ -123,9 +141,6 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
123
141
  }
124
142
  }
125
143
 
126
- /**
127
- * Resolves the chat model and tools once per activation, then reuses for every item in the batch.
128
- */
129
144
  private async prepareExecution(ctx: NodeExecutionContext<AIAgent<any, any>>): Promise<PreparedAgentExecution> {
130
145
  const chatModelFactory = this.nodeResolver.resolve(ctx.config.chatModel.type) as ChatModelFactory<ChatModelConfig>;
131
146
  const languageModelCredentialContext = this.connectionCredentialExecutionContextFactory.forConnectionNode(ctx, {
@@ -144,9 +159,6 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
144
159
  };
145
160
  }
146
161
 
147
- /**
148
- * One item: build prompts, optionally bind tools, run the multi-turn loop, map the final model message to workflow JSON.
149
- */
150
162
  private async runAgentForItem(
151
163
  prepared: PreparedAgentExecution,
152
164
  item: Item,
@@ -156,7 +168,7 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
156
168
  const { ctx } = prepared;
157
169
  const itemInputsByPort = AgentItemPortMap.fromItem(item);
158
170
  const itemScopedTools = this.createItemScopedTools(prepared.resolvedTools, ctx, item, itemIndex, items);
159
- const conversation: BaseMessage[] = [...this.createPromptMessages(item, itemIndex, items, ctx)];
171
+ const conversation: ModelMessage[] = [...this.createPromptMessages(item, itemIndex, items, ctx)];
160
172
  if (ctx.config.outputSchema && itemScopedTools.length === 0) {
161
173
  const structuredOutput = await this.structuredOutputRunner.resolve({
162
174
  model: prepared.model,
@@ -165,36 +177,19 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
165
177
  conversation,
166
178
  agentName: this.getAgentDisplayName(ctx),
167
179
  nodeId: ctx.nodeId,
168
- invokeTextModel: async (messages) =>
169
- await this.invokeModel(
170
- prepared.model,
171
- prepared.languageModelConnectionNodeId,
172
- messages,
173
- ctx,
174
- itemInputsByPort,
175
- prepared.guardrails.modelInvocationOptions,
176
- ),
177
- invokeStructuredModel: async (structuredModel, messages) =>
178
- await this.invokeStructuredModel(
179
- structuredModel,
180
- prepared.languageModelConnectionNodeId,
181
- messages,
182
- ctx,
183
- itemInputsByPort,
184
- prepared.guardrails.modelInvocationOptions,
185
- ),
180
+ invokeTextModel: async (messages) => await this.invokeTextTurn(prepared, itemInputsByPort, messages, []),
181
+ invokeStructuredModel: async (schema, messages, structuredOptions) =>
182
+ await this.invokeStructuredTurn(prepared, itemInputsByPort, schema, messages, structuredOptions),
186
183
  });
187
184
  await ctx.telemetry.recordMetric({ name: CodemationTelemetryMetricNames.agentTurns, value: 1 });
188
185
  await ctx.telemetry.recordMetric({ name: CodemationTelemetryMetricNames.agentToolCalls, value: 0 });
189
186
  return this.buildOutputItem(item, structuredOutput);
190
187
  }
191
- const modelWithTools = this.bindToolsToModel(prepared.model, itemScopedTools);
192
188
  const loopResult = await this.runTurnLoopUntilFinalAnswer({
193
189
  prepared,
194
190
  itemInputsByPort,
195
191
  itemScopedTools,
196
192
  conversation,
197
- modelWithTools,
198
193
  });
199
194
  await ctx.telemetry.recordMetric({ name: CodemationTelemetryMetricNames.agentTurns, value: loopResult.turnCount });
200
195
  await ctx.telemetry.recordMetric({
@@ -205,44 +200,40 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
205
200
  prepared,
206
201
  itemInputsByPort,
207
202
  conversation,
208
- loopResult.finalResponse,
203
+ loopResult.finalText,
209
204
  itemScopedTools.length > 0,
210
205
  );
211
206
  return this.buildOutputItem(item, outputJson);
212
207
  }
213
208
 
214
209
  /**
215
- * Repeatedly invokes the model until it returns without tool calls, or guardrails end the loop.
210
+ * Multi-turn loop:
211
+ * - Each turn is a single `generateText` call with tools exposed but **not auto-executed**
212
+ * (we control tool dispatch so that {@link AgentToolExecutionCoordinator} drives repair /
213
+ * connection-invocation recording / transient-error handling exactly like before).
214
+ * - When the model returns no tool calls the loop ends with the model's text as the final answer.
215
+ * - Respects `guardrails.maxTurns` and `guardrails.onTurnLimitReached`.
216
216
  */
217
217
  private async runTurnLoopUntilFinalAnswer(args: {
218
218
  prepared: PreparedAgentExecution;
219
219
  itemInputsByPort: NodeInputsByPort;
220
220
  itemScopedTools: ReadonlyArray<ItemScopedToolBinding>;
221
- conversation: BaseMessage[];
222
- modelWithTools: LangChainChatModelLike;
223
- }): Promise<Readonly<{ finalResponse: AIMessage; turnCount: number; toolCallCount: number }>> {
224
- const { prepared, itemInputsByPort, itemScopedTools, conversation, modelWithTools } = args;
225
- const { ctx, guardrails, languageModelConnectionNodeId } = prepared;
221
+ conversation: ModelMessage[];
222
+ }): Promise<Readonly<{ finalText: string; turnCount: number; toolCallCount: number }>> {
223
+ const { prepared, itemInputsByPort, itemScopedTools, conversation } = args;
224
+ const { ctx, guardrails } = prepared;
226
225
 
227
- let finalResponse: AIMessage | undefined;
226
+ let finalText = "";
228
227
  let toolCallCount = 0;
229
228
  let turnCount = 0;
230
229
  const repairAttemptsByToolName = new Map<string, number>();
231
230
 
232
231
  for (let turn = 1; turn <= guardrails.maxTurns; turn++) {
233
232
  turnCount = turn;
234
- const response = await this.invokeModel(
235
- modelWithTools,
236
- languageModelConnectionNodeId,
237
- conversation,
238
- ctx,
239
- itemInputsByPort,
240
- guardrails.modelInvocationOptions,
241
- );
242
- finalResponse = response;
233
+ const result = await this.invokeTextTurn(prepared, itemInputsByPort, conversation, itemScopedTools);
234
+ finalText = result.text;
243
235
 
244
- const toolCalls = AgentMessageFactory.extractToolCalls(response);
245
- if (toolCalls.length === 0) {
236
+ if (result.toolCalls.length === 0) {
246
237
  break;
247
238
  }
248
239
 
@@ -251,7 +242,7 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
251
242
  break;
252
243
  }
253
244
 
254
- const plannedToolCalls = this.planToolCalls(itemScopedTools, toolCalls, ctx.nodeId);
245
+ const plannedToolCalls = this.planToolCalls(itemScopedTools, result.toolCalls, ctx.nodeId);
255
246
  toolCallCount += plannedToolCalls.length;
256
247
  await this.markQueuedTools(plannedToolCalls, ctx);
257
248
  const executedToolCalls = await this.toolExecutionCoordinator.execute({
@@ -260,14 +251,17 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
260
251
  agentName: this.getAgentDisplayName(ctx),
261
252
  repairAttemptsByToolName,
262
253
  });
263
- this.appendAssistantAndToolMessages(conversation, response, executedToolCalls);
254
+ this.appendAssistantAndToolMessages(
255
+ conversation,
256
+ result.assistantMessage,
257
+ result.text,
258
+ result.toolCalls,
259
+ executedToolCalls,
260
+ );
264
261
  }
265
262
 
266
- if (!finalResponse) {
267
- throw new Error(`AIAgent "${ctx.config.name ?? ctx.nodeId}" did not produce a model response.`);
268
- }
269
263
  return {
270
- finalResponse,
264
+ finalText,
271
265
  turnCount,
272
266
  toolCallCount,
273
267
  };
@@ -290,54 +284,42 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
290
284
  }
291
285
 
292
286
  private appendAssistantAndToolMessages(
293
- conversation: BaseMessage[],
294
- assistantMessage: AIMessage,
287
+ conversation: ModelMessage[],
288
+ assistantMessage: AssistantModelMessage | undefined,
289
+ text: string,
290
+ toolCalls: ReadonlyArray<AgentToolCall>,
295
291
  executedToolCalls: ReadonlyArray<ExecutedToolCall>,
296
292
  ): void {
297
293
  conversation.push(
298
- assistantMessage,
299
- ...executedToolCalls.map((toolCall) =>
300
- AgentMessageFactory.createToolMessage(toolCall.toolCallId, toolCall.serialized),
301
- ),
294
+ assistantMessage ?? AgentMessageFactory.createAssistantWithToolCalls(text, toolCalls),
295
+ AgentMessageFactory.createToolResultsMessage(executedToolCalls),
302
296
  );
303
297
  }
304
298
 
305
299
  private async resolveFinalOutputJson(
306
300
  prepared: PreparedAgentExecution,
307
301
  itemInputsByPort: NodeInputsByPort,
308
- conversation: ReadonlyArray<BaseMessage>,
309
- finalResponse: AIMessage,
302
+ conversation: ReadonlyArray<ModelMessage>,
303
+ finalText: string,
310
304
  wasToolEnabledRun: boolean,
311
305
  ): Promise<unknown> {
312
306
  if (!prepared.ctx.config.outputSchema) {
313
- return AgentOutputFactory.fromAgentContent(AgentMessageFactory.extractContent(finalResponse));
307
+ return AgentOutputFactory.fromAgentContent(finalText);
314
308
  }
309
+ const conversationWithFinal: ReadonlyArray<ModelMessage> = wasToolEnabledRun
310
+ ? [...conversation, { role: "assistant", content: finalText }]
311
+ : conversation;
315
312
  return await this.structuredOutputRunner.resolve({
316
313
  model: prepared.model,
317
314
  chatModelConfig: prepared.ctx.config.chatModel,
318
315
  schema: prepared.ctx.config.outputSchema,
319
- conversation: wasToolEnabledRun ? [...conversation, finalResponse] : conversation,
320
- rawFinalResponse: finalResponse,
316
+ conversation: conversationWithFinal,
317
+ rawFinalText: finalText,
321
318
  agentName: this.getAgentDisplayName(prepared.ctx),
322
319
  nodeId: prepared.ctx.nodeId,
323
- invokeTextModel: async (messages) =>
324
- await this.invokeModel(
325
- prepared.model,
326
- prepared.languageModelConnectionNodeId,
327
- messages,
328
- prepared.ctx,
329
- itemInputsByPort,
330
- prepared.guardrails.modelInvocationOptions,
331
- ),
332
- invokeStructuredModel: async (structuredModel, messages) =>
333
- await this.invokeStructuredModel(
334
- structuredModel,
335
- prepared.languageModelConnectionNodeId,
336
- messages,
337
- prepared.ctx,
338
- itemInputsByPort,
339
- prepared.guardrails.modelInvocationOptions,
340
- ),
320
+ invokeTextModel: async (messages) => await this.invokeTextTurn(prepared, itemInputsByPort, messages, []),
321
+ invokeStructuredModel: async (schema, messages, structuredOptions) =>
322
+ await this.invokeStructuredTurn(prepared, itemInputsByPort, schema, messages, structuredOptions),
341
323
  });
342
324
  }
343
325
 
@@ -345,16 +327,6 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
345
327
  return AgentOutputFactory.replaceJson(item, outputJson);
346
328
  }
347
329
 
348
- private bindToolsToModel(
349
- model: LangChainChatModelLike,
350
- itemScopedTools: ReadonlyArray<ItemScopedToolBinding>,
351
- ): LangChainChatModelLike {
352
- if (itemScopedTools.length === 0 || !model.bindTools) {
353
- return model;
354
- }
355
- return model.bindTools(itemScopedTools.map((entry) => entry.langChainTool));
356
- }
357
-
358
330
  private resolveTools(toolConfigs: ReadonlyArray<ToolConfig>): ReadonlyArray<ResolvedTool> {
359
331
  const resolvedTools = toolConfigs.map((config) => ({
360
332
  config,
@@ -381,69 +353,117 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
381
353
  connectionNodeId: ConnectionNodeIdFactory.toolConnectionNodeId(ctx.nodeId, entry.config.name),
382
354
  getCredentialRequirements: () => entry.config.getCredentialRequirements?.() ?? [],
383
355
  });
384
- const langChainTool = this.executionHelpers.createDynamicStructuredTool(
385
- entry,
386
- toolCredentialContext,
387
- item,
388
- itemIndex,
389
- items,
390
- );
391
-
392
- return { config: entry.config, langChainTool };
356
+ return {
357
+ config: entry.config,
358
+ inputSchema: entry.runtime.inputSchema,
359
+ execute: async (input: unknown): Promise<unknown> => {
360
+ const validated = entry.runtime.inputSchema.parse(input) as unknown;
361
+ return await entry.runtime.execute({
362
+ config: entry.config,
363
+ input: validated,
364
+ ctx: toolCredentialContext,
365
+ item,
366
+ itemIndex,
367
+ items,
368
+ });
369
+ },
370
+ } satisfies ItemScopedToolBinding;
393
371
  });
394
372
  }
395
373
 
396
- private async invokeModel(
397
- model: LangChainChatModelLike,
398
- nodeId: string,
399
- messages: ReadonlyArray<BaseMessage>,
400
- ctx: NodeExecutionContext<AIAgent<any, any>>,
401
- inputsByPort: NodeInputsByPort,
402
- options?: AgentGuardrailConfig["modelInvocationOptions"],
403
- ): Promise<AIMessage> {
374
+ /**
375
+ * Builds an AI SDK {@link ToolSet} where every tool ships a pre-converted JSON Schema (via
376
+ * {@link jsonSchema}) — not the raw Zod schema — and carries **no** `execute`. Two reasons:
377
+ *
378
+ * 1. Codemation owns tool dispatch + the per-tool repair loop (see {@link AgentToolExecutionCoordinator}),
379
+ * so the AI SDK must surface tool calls back to us instead of auto-running them.
380
+ * 2. The AI SDK's `asSchema` helper discriminates between Zod v3 / Zod v4 / Standard Schema via
381
+ * runtime feature-detection (`~standard`, `_zod`, etc.). Handing it a pre-built
382
+ * {@link jsonSchema} record — which is tagged with `Symbol.for('vercel.ai.schema')` — skips all
383
+ * of that detection and guarantees the provider receives a draft-07 JSON Schema with
384
+ * `additionalProperties: false` at every object depth (see {@link OpenAiStrictJsonSchemaFactory}
385
+ * for the same logic applied to structured-output schemas). Codemation still runs its own Zod
386
+ * validation on tool inputs before execute — the schema handed to the model is advisory.
387
+ */
388
+ private buildToolSet(itemScopedTools: ReadonlyArray<ItemScopedToolBinding>): ToolSet | undefined {
389
+ if (itemScopedTools.length === 0) return undefined;
390
+ const toolSet: Record<string, { description?: string; inputSchema: ReturnType<typeof jsonSchema> }> = {};
391
+ for (const entry of itemScopedTools) {
392
+ const schemaRecord = this.executionHelpers.createJsonSchemaRecord(entry.inputSchema, {
393
+ schemaName: entry.config.name,
394
+ requireObjectRoot: true,
395
+ });
396
+ toolSet[entry.config.name] = {
397
+ description: entry.config.description,
398
+ inputSchema: jsonSchema(schemaRecord as Parameters<typeof jsonSchema>[0]),
399
+ };
400
+ }
401
+ return toolSet as unknown as ToolSet;
402
+ }
403
+
404
+ /**
405
+ * One `generateText` turn (no auto tool execution) with Codemation-owned child-span telemetry
406
+ * and connection-invocation state recording.
407
+ */
408
+ private async invokeTextTurn(
409
+ prepared: PreparedAgentExecution,
410
+ itemInputsByPort: NodeInputsByPort,
411
+ messages: ReadonlyArray<ModelMessage>,
412
+ itemScopedTools: ReadonlyArray<ItemScopedToolBinding>,
413
+ ): Promise<TurnResult> {
404
414
  const invocationId = ConnectionInvocationIdFactory.create();
405
415
  const startedAt = new Date();
406
416
  const summarizedInput = this.summarizeLlmMessages(messages);
417
+ const { ctx, model, languageModelConnectionNodeId, guardrails } = prepared;
407
418
  const span = this.createModelInvocationSpan(ctx, invocationId, startedAt);
408
- await ctx.nodeState?.markQueued({ nodeId, activationId: ctx.activationId, inputsByPort });
409
- await ctx.nodeState?.markRunning({ nodeId, activationId: ctx.activationId, inputsByPort });
419
+ await ctx.nodeState?.markQueued({
420
+ nodeId: languageModelConnectionNodeId,
421
+ activationId: ctx.activationId,
422
+ inputsByPort: itemInputsByPort,
423
+ });
424
+ await ctx.nodeState?.markRunning({
425
+ nodeId: languageModelConnectionNodeId,
426
+ activationId: ctx.activationId,
427
+ inputsByPort: itemInputsByPort,
428
+ });
410
429
  try {
411
- const response = (await model.invoke(messages, options)) as AIMessage;
430
+ const tools = this.buildToolSet(itemScopedTools);
431
+ const callOptions = this.resolveCallOptions(model, guardrails.modelInvocationOptions);
432
+ const result = await generateText({
433
+ model: model.languageModel as LanguageModel,
434
+ messages: [...messages],
435
+ tools,
436
+ toolChoice: tools ? "auto" : undefined,
437
+ maxOutputTokens: callOptions.maxOutputTokens,
438
+ temperature: callOptions.temperature,
439
+ providerOptions: callOptions.providerOptions as Record<string, Record<string, never>>,
440
+ maxRetries: 0,
441
+ });
442
+ const turnResult = this.extractTurnResult(result as AnyGenerateTextResult);
412
443
  const finishedAt = new Date();
413
444
  await ctx.nodeState?.markCompleted({
414
- nodeId,
445
+ nodeId: languageModelConnectionNodeId,
415
446
  activationId: ctx.activationId,
416
- inputsByPort,
417
- outputs: AgentOutputFactory.fromUnknown({
418
- content: AgentMessageFactory.extractContent(response),
419
- }),
447
+ inputsByPort: itemInputsByPort,
448
+ outputs: AgentOutputFactory.fromUnknown({ content: turnResult.text }),
420
449
  });
421
- const content = AgentMessageFactory.extractContent(response);
422
- await span.attachArtifact({
423
- kind: "ai.messages",
424
- contentType: "application/json",
425
- previewJson: summarizedInput,
426
- });
427
- await span.attachArtifact({
428
- kind: "ai.response",
429
- contentType: "application/json",
430
- previewJson: content,
431
- });
432
- await this.recordModelUsageMetrics(span, response, ctx);
450
+ await span.attachArtifact({ kind: "ai.messages", contentType: "application/json", previewJson: summarizedInput });
451
+ await span.attachArtifact({ kind: "ai.response", contentType: "application/json", previewJson: turnResult.text });
452
+ await this.recordModelUsageMetrics(span, turnResult.usage, ctx);
433
453
  await span.end({ status: "ok", endedAt: finishedAt });
434
454
  await ctx.nodeState?.appendConnectionInvocation({
435
455
  invocationId,
436
- connectionNodeId: nodeId,
456
+ connectionNodeId: languageModelConnectionNodeId,
437
457
  parentAgentNodeId: ctx.nodeId,
438
458
  parentAgentActivationId: ctx.activationId,
439
459
  status: "completed",
440
460
  managedInput: summarizedInput,
441
- managedOutput: content,
461
+ managedOutput: turnResult.text,
442
462
  queuedAt: startedAt.toISOString(),
443
463
  startedAt: startedAt.toISOString(),
444
464
  finishedAt: finishedAt.toISOString(),
445
465
  });
446
- return response;
466
+ return turnResult;
447
467
  } catch (error) {
448
468
  await span.end({
449
469
  status: "error",
@@ -454,62 +474,85 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
454
474
  error,
455
475
  invocationId,
456
476
  startedAt,
457
- nodeId,
477
+ nodeId: languageModelConnectionNodeId,
458
478
  ctx,
459
- inputsByPort,
460
- managedInput: this.summarizeLlmMessages(messages),
479
+ inputsByPort: itemInputsByPort,
480
+ managedInput: summarizedInput,
461
481
  });
462
482
  }
463
483
  }
464
484
 
465
- private async invokeStructuredModel(
466
- model: LangChainStructuredOutputModelLike,
467
- nodeId: string,
468
- messages: ReadonlyArray<BaseMessage>,
469
- ctx: NodeExecutionContext<AIAgent<any, any>>,
470
- inputsByPort: NodeInputsByPort,
471
- options?: AgentGuardrailConfig["modelInvocationOptions"],
485
+ /**
486
+ * Structured-output turn: runs `generateText({ output: Output.object({ schema }) })` via the
487
+ * structured-output runner. We keep this as a separate helper because the runner needs the raw
488
+ * validated value (not just text) back, and must be able to retry on Zod failures.
489
+ */
490
+ private async invokeStructuredTurn(
491
+ prepared: PreparedAgentExecution,
492
+ itemInputsByPort: NodeInputsByPort,
493
+ schema: ZodSchemaAny | Readonly<Record<string, unknown>>,
494
+ messages: ReadonlyArray<ModelMessage>,
495
+ structuredOptions: { readonly strict?: boolean; readonly schemaName?: string } | undefined,
472
496
  ): Promise<unknown> {
473
497
  const invocationId = ConnectionInvocationIdFactory.create();
474
498
  const startedAt = new Date();
475
499
  const summarizedInput = this.summarizeLlmMessages(messages);
500
+ const { ctx, model, languageModelConnectionNodeId, guardrails } = prepared;
476
501
  const span = this.createModelInvocationSpan(ctx, invocationId, startedAt);
477
- await ctx.nodeState?.markQueued({ nodeId, activationId: ctx.activationId, inputsByPort });
478
- await ctx.nodeState?.markRunning({ nodeId, activationId: ctx.activationId, inputsByPort });
502
+ await ctx.nodeState?.markQueued({
503
+ nodeId: languageModelConnectionNodeId,
504
+ activationId: ctx.activationId,
505
+ inputsByPort: itemInputsByPort,
506
+ });
507
+ await ctx.nodeState?.markRunning({
508
+ nodeId: languageModelConnectionNodeId,
509
+ activationId: ctx.activationId,
510
+ inputsByPort: itemInputsByPort,
511
+ });
479
512
  try {
480
- const response = await model.invoke(messages, options);
513
+ const callOptions = this.resolveCallOptions(model, guardrails.modelInvocationOptions);
514
+ const outputSchema =
515
+ structuredOptions?.strict && !this.isZodSchema(schema)
516
+ ? Output.object({ schema: jsonSchema(schema as Parameters<typeof jsonSchema>[0]) as never })
517
+ : Output.object({ schema: schema as ZodSchemaAny });
518
+ const result = await generateText({
519
+ model: model.languageModel as LanguageModel,
520
+ messages: [...messages],
521
+ experimental_output: outputSchema,
522
+ maxOutputTokens: callOptions.maxOutputTokens,
523
+ temperature: callOptions.temperature,
524
+ providerOptions: callOptions.providerOptions as Record<string, Record<string, never>>,
525
+ maxRetries: 0,
526
+ });
527
+ const turnResult = this.extractTurnResult(result as AnyGenerateTextResult);
481
528
  const finishedAt = new Date();
482
529
  await ctx.nodeState?.markCompleted({
483
- nodeId,
530
+ nodeId: languageModelConnectionNodeId,
484
531
  activationId: ctx.activationId,
485
- inputsByPort,
486
- outputs: AgentOutputFactory.fromUnknown(response),
487
- });
488
- await span.attachArtifact({
489
- kind: "ai.messages",
490
- contentType: "application/json",
491
- previewJson: summarizedInput,
532
+ inputsByPort: itemInputsByPort,
533
+ outputs: AgentOutputFactory.fromUnknown(result.experimental_output),
492
534
  });
535
+ await span.attachArtifact({ kind: "ai.messages", contentType: "application/json", previewJson: summarizedInput });
493
536
  await span.attachArtifact({
494
537
  kind: "ai.response.structured",
495
538
  contentType: "application/json",
496
- previewJson: this.resultToJsonValue(response),
539
+ previewJson: this.resultToJsonValue(result.experimental_output),
497
540
  });
498
- await this.recordModelUsageMetrics(span, response, ctx);
541
+ await this.recordModelUsageMetrics(span, turnResult.usage, ctx);
499
542
  await span.end({ status: "ok", endedAt: finishedAt });
500
543
  await ctx.nodeState?.appendConnectionInvocation({
501
544
  invocationId,
502
- connectionNodeId: nodeId,
545
+ connectionNodeId: languageModelConnectionNodeId,
503
546
  parentAgentNodeId: ctx.nodeId,
504
547
  parentAgentActivationId: ctx.activationId,
505
548
  status: "completed",
506
549
  managedInput: summarizedInput,
507
- managedOutput: this.resultToJsonValue(response),
550
+ managedOutput: this.resultToJsonValue(result.experimental_output),
508
551
  queuedAt: startedAt.toISOString(),
509
552
  startedAt: startedAt.toISOString(),
510
553
  finishedAt: finishedAt.toISOString(),
511
554
  });
512
- return response;
555
+ return result.experimental_output;
513
556
  } catch (error) {
514
557
  await span.end({
515
558
  status: "error",
@@ -520,14 +563,72 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
520
563
  error,
521
564
  invocationId,
522
565
  startedAt,
523
- nodeId,
566
+ nodeId: languageModelConnectionNodeId,
524
567
  ctx,
525
- inputsByPort,
526
- managedInput: this.summarizeLlmMessages(messages),
568
+ inputsByPort: itemInputsByPort,
569
+ managedInput: summarizedInput,
527
570
  });
528
571
  }
529
572
  }
530
573
 
574
+ private isZodSchema(schema: ZodSchemaAny | Readonly<Record<string, unknown>>): schema is ZodSchemaAny {
575
+ const candidate = schema as { parse?: unknown };
576
+ return typeof candidate.parse === "function";
577
+ }
578
+
579
+ private resolveCallOptions(
580
+ model: ChatLanguageModel,
581
+ overrides: AgentGuardrailConfig["modelInvocationOptions"] | undefined,
582
+ ): ChatLanguageModelCallOptions {
583
+ const defaults = model.defaultCallOptions ?? {};
584
+ return {
585
+ maxOutputTokens: overrides?.maxTokens ?? defaults.maxOutputTokens,
586
+ temperature: defaults.temperature,
587
+ providerOptions: (overrides?.providerOptions ??
588
+ defaults.providerOptions) as ChatLanguageModelCallOptions["providerOptions"],
589
+ };
590
+ }
591
+
592
+ private extractTurnResult(result: AnyGenerateTextResult): TurnResult {
593
+ const usage = this.extractUsageFromResult(result);
594
+ const text = result.text;
595
+ const toolCalls: ReadonlyArray<AgentToolCall> = result.toolCalls.map((toolCall) => ({
596
+ id: toolCall.toolCallId,
597
+ name: toolCall.toolName,
598
+ input: (toolCall as { input?: unknown }).input,
599
+ }));
600
+ const assistantMessage = this.extractAssistantMessage(result);
601
+ return {
602
+ assistantMessage,
603
+ text,
604
+ toolCalls,
605
+ usage,
606
+ };
607
+ }
608
+
609
+ private extractAssistantMessage(result: AnyGenerateTextResult): AssistantModelMessage | undefined {
610
+ const responseMessages: ReadonlyArray<ModelMessage> = result.response.messages;
611
+ const assistantMessages = responseMessages.filter((m) => m.role === "assistant");
612
+ return assistantMessages[assistantMessages.length - 1];
613
+ }
614
+
615
+ private extractUsageFromResult(result: AnyGenerateTextResult): ModelUsage {
616
+ const usage = result.usage;
617
+ const inputTokens = this.toFiniteNumber(usage.inputTokens);
618
+ const outputTokens = this.toFiniteNumber(usage.outputTokens);
619
+ const totalTokens =
620
+ this.toFiniteNumber(usage.totalTokens) ??
621
+ (inputTokens !== undefined && outputTokens !== undefined ? inputTokens + outputTokens : undefined);
622
+ const cachedInputTokens = this.toFiniteNumber(usage.cachedInputTokens);
623
+ const reasoningTokens = this.toFiniteNumber(usage.reasoningTokens);
624
+ return { inputTokens, outputTokens, totalTokens, cachedInputTokens, reasoningTokens };
625
+ }
626
+
627
+ private toFiniteNumber(value: unknown): number | undefined {
628
+ if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
629
+ return value;
630
+ }
631
+
531
632
  private createModelInvocationSpan(
532
633
  ctx: NodeExecutionContext<AIAgent<any, any>>,
533
634
  invocationId: string,
@@ -547,14 +648,18 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
547
648
 
548
649
  private async recordModelUsageMetrics(
549
650
  span: ReturnType<NodeExecutionContext["telemetry"]["startChildSpan"]>,
550
- response: unknown,
651
+ usage: ModelUsage,
551
652
  ctx: NodeExecutionContext<AIAgent<any, any>>,
552
653
  ) {
553
- const usage = this.extractModelUsageMetrics(response);
554
- for (const [name, value] of Object.entries(usage)) {
555
- if (value === undefined) {
556
- continue;
557
- }
654
+ const entries: ReadonlyArray<readonly [string, number | undefined]> = [
655
+ [GenAiTelemetryAttributeNames.usageInputTokens, usage.inputTokens],
656
+ [GenAiTelemetryAttributeNames.usageOutputTokens, usage.outputTokens],
657
+ [GenAiTelemetryAttributeNames.usageTotalTokens, usage.totalTokens],
658
+ [GenAiTelemetryAttributeNames.usageCacheReadInputTokens, usage.cachedInputTokens],
659
+ [GenAiTelemetryAttributeNames.usageReasoningTokens, usage.reasoningTokens],
660
+ ];
661
+ for (const [name, value] of entries) {
662
+ if (value === undefined) continue;
558
663
  await span.recordMetric({ name, value });
559
664
  }
560
665
  await this.captureCostTrackingUsage(span, ctx, usage);
@@ -563,38 +668,32 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
563
668
  private async captureCostTrackingUsage(
564
669
  span: ReturnType<NodeExecutionContext["telemetry"]["startChildSpan"]>,
565
670
  ctx: NodeExecutionContext<AIAgent<any, any>>,
566
- usage: Readonly<Record<string, number | undefined>>,
671
+ usage: ModelUsage,
567
672
  ): Promise<void> {
568
673
  const costTracking = span.costTracking;
569
- if (!costTracking) {
570
- return;
571
- }
674
+ if (!costTracking) return;
572
675
  const provider = ctx.config.chatModel.provider;
573
676
  const pricingKey = ctx.config.chatModel.modelName;
574
- if (!provider || !pricingKey) {
575
- return;
576
- }
577
- const inputTokens = usage[GenAiTelemetryAttributeNames.usageInputTokens];
578
- const outputTokens = usage[GenAiTelemetryAttributeNames.usageOutputTokens];
579
- if (inputTokens !== undefined) {
677
+ if (!provider || !pricingKey) return;
678
+ if (usage.inputTokens !== undefined) {
580
679
  await costTracking.captureUsage({
581
680
  component: "chat",
582
681
  provider,
583
682
  operation: "completion.input",
584
683
  pricingKey,
585
684
  usageUnit: "input_tokens",
586
- quantity: inputTokens,
685
+ quantity: usage.inputTokens,
587
686
  modelName: pricingKey,
588
687
  });
589
688
  }
590
- if (outputTokens !== undefined) {
689
+ if (usage.outputTokens !== undefined) {
591
690
  await costTracking.captureUsage({
592
691
  component: "chat",
593
692
  provider,
594
693
  operation: "completion.output",
595
694
  pricingKey,
596
695
  usageUnit: "output_tokens",
597
- quantity: outputTokens,
696
+ quantity: usage.outputTokens,
598
697
  modelName: pricingKey,
599
698
  });
600
699
  }
@@ -604,90 +703,6 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
604
703
  return chatModel.modelName ?? chatModel.name;
605
704
  }
606
705
 
607
- private extractModelUsageMetrics(response: unknown): Readonly<Record<string, number | undefined>> {
608
- const usage = this.extractUsageObject(response);
609
- const inputTokens = this.readUsageNumber(usage, ["input_tokens", "inputTokens", "prompt_tokens", "promptTokens"]);
610
- const outputTokens = this.readUsageNumber(usage, [
611
- "output_tokens",
612
- "outputTokens",
613
- "completion_tokens",
614
- "completionTokens",
615
- ]);
616
- const totalTokens =
617
- this.readUsageNumber(usage, ["total_tokens", "totalTokens"]) ??
618
- (inputTokens !== undefined && outputTokens !== undefined ? inputTokens + outputTokens : undefined);
619
- const cachedInputTokens = this.readUsageNumber(usage, [
620
- "cache_read_input_tokens",
621
- "cacheReadInputTokens",
622
- "input_token_details.cached_tokens",
623
- ]);
624
- const reasoningTokens = this.readUsageNumber(usage, [
625
- "reasoning_tokens",
626
- "reasoningTokens",
627
- "output_token_details.reasoning_tokens",
628
- ]);
629
- return {
630
- [GenAiTelemetryAttributeNames.usageInputTokens]: inputTokens,
631
- [GenAiTelemetryAttributeNames.usageOutputTokens]: outputTokens,
632
- [GenAiTelemetryAttributeNames.usageTotalTokens]: totalTokens,
633
- [GenAiTelemetryAttributeNames.usageCacheReadInputTokens]: cachedInputTokens,
634
- [GenAiTelemetryAttributeNames.usageReasoningTokens]: reasoningTokens,
635
- };
636
- }
637
-
638
- private extractUsageObject(response: unknown): Readonly<Record<string, unknown>> | undefined {
639
- if (!this.isRecord(response)) {
640
- return undefined;
641
- }
642
- const usageMetadata = response["usage_metadata"];
643
- if (this.isRecord(usageMetadata)) {
644
- return usageMetadata;
645
- }
646
- const responseMetadata = response["response_metadata"];
647
- if (this.isRecord(responseMetadata)) {
648
- const tokenUsage = responseMetadata["tokenUsage"];
649
- if (this.isRecord(tokenUsage)) {
650
- return tokenUsage;
651
- }
652
- const usage = responseMetadata["usage"];
653
- if (this.isRecord(usage)) {
654
- return usage;
655
- }
656
- }
657
- return undefined;
658
- }
659
-
660
- private readUsageNumber(
661
- source: Readonly<Record<string, unknown>> | undefined,
662
- keys: ReadonlyArray<string>,
663
- ): number | undefined {
664
- for (const key of keys) {
665
- const value = this.readNestedUsageValue(source, key);
666
- if (typeof value === "number" && Number.isFinite(value)) {
667
- return value;
668
- }
669
- }
670
- return undefined;
671
- }
672
-
673
- private readNestedUsageValue(source: Readonly<Record<string, unknown>> | undefined, dottedKey: string): unknown {
674
- if (!source) {
675
- return undefined;
676
- }
677
- let current: unknown = source;
678
- for (const segment of dottedKey.split(".")) {
679
- if (!this.isRecord(current)) {
680
- return undefined;
681
- }
682
- current = current[segment];
683
- }
684
- return current;
685
- }
686
-
687
- private isRecord(value: unknown): value is Record<string, unknown> {
688
- return typeof value === "object" && value !== null;
689
- }
690
-
691
706
  private async markQueuedTools(
692
707
  plannedToolCalls: ReadonlyArray<PlannedToolCall>,
693
708
  ctx: NodeExecutionContext<AIAgent<any, any>>,
@@ -760,7 +775,7 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
760
775
  return effectiveError;
761
776
  }
762
777
 
763
- private summarizeLlmMessages(messages: ReadonlyArray<BaseMessage>): JsonValue {
778
+ private summarizeLlmMessages(messages: ReadonlyArray<ModelMessage>): JsonValue {
764
779
  const last = messages[messages.length - 1];
765
780
  const preview =
766
781
  typeof last?.content === "string"
@@ -775,9 +790,7 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
775
790
  }
776
791
 
777
792
  private resultToJsonValue(value: unknown): JsonValue | undefined {
778
- if (value === undefined) {
779
- return undefined;
780
- }
793
+ if (value === undefined) return undefined;
781
794
  const json = JSON.stringify(value);
782
795
  return JSON.parse(json) as JsonValue;
783
796
  }
@@ -787,7 +800,7 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
787
800
  itemIndex: number,
788
801
  items: Items,
789
802
  ctx: NodeExecutionContext<AIAgent<any, any>>,
790
- ): ReadonlyArray<BaseMessage> {
803
+ ): ReadonlyArray<ModelMessage> {
791
804
  return AgentMessageFactory.createPromptMessages(
792
805
  AgentMessageConfigNormalizer.resolveFromInputOrConfig(item.json, ctx.config, {
793
806
  item,
@@ -803,7 +816,7 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
803
816
  const inputSchema = config.getInputSchema();
804
817
  if (inputSchema == null) {
805
818
  throw new Error(
806
- `AIAgent tool "${config.name}": node-backed tool is missing inputSchema (cannot build LangChain tool).`,
819
+ `AIAgent tool "${config.name}": node-backed tool is missing inputSchema (cannot build AI SDK tool).`,
807
820
  );
808
821
  }
809
822
  return {
@@ -816,7 +829,7 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
816
829
  const inputSchema = config.getInputSchema();
817
830
  if (inputSchema == null) {
818
831
  throw new Error(
819
- `AIAgent tool "${config.name}": callable tool is missing inputSchema (cannot build LangChain tool).`,
832
+ `AIAgent tool "${config.name}": callable tool is missing inputSchema (cannot build AI SDK tool).`,
820
833
  );
821
834
  }
822
835
  return {
@@ -837,11 +850,6 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
837
850
  };
838
851
  }
839
852
 
840
- /**
841
- * Consumer apps can resolve two copies of `@codemation/core`, breaking `instanceof NodeBackedToolConfig` and
842
- * sending node-backed tools down the plugin-tool branch with `inputSchema: undefined` (LangChain then crashes in
843
- * json-schema validation). {@link NodeBackedToolConfig#toolKind} is stable across copies.
844
- */
845
853
  private isNodeBackedToolConfig(config: ToolConfig): config is NodeBackedToolConfig<any, any, any> {
846
854
  return (
847
855
  config instanceof NodeBackedToolConfig ||
@@ -849,9 +857,6 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
849
857
  );
850
858
  }
851
859
 
852
- /**
853
- * Callable tools use {@link CallableToolConfig#toolKind} for cross-package / JSON round-trip safety.
854
- */
855
860
  private isCallableToolConfig(config: ToolConfig): config is CallableToolConfig<ZodSchemaAny, ZodSchemaAny> {
856
861
  return (
857
862
  config instanceof CallableToolConfig ||