@codemation/core-nodes 0.4.1 → 0.4.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codemation/core-nodes",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -31,7 +31,7 @@
31
31
  "@langchain/core": "^1.1.31",
32
32
  "@langchain/openai": "^1.2.12",
33
33
  "lucide-react": "^0.577.0",
34
- "@codemation/core": "0.7.0"
34
+ "@codemation/core": "0.8.1"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/node": "^25.3.5",
@@ -6,6 +6,8 @@ import { OpenAIChatModelFactory } from "./OpenAIChatModelFactory";
6
6
  export class OpenAIChatModelConfig implements ChatModelConfig {
7
7
  readonly type = OpenAIChatModelFactory;
8
8
  readonly presentation: AgentCanvasPresentation<CanvasIconName>;
9
+ readonly provider = "openai";
10
+ readonly modelName: string;
9
11
 
10
12
  constructor(
11
13
  public readonly name: string,
@@ -17,6 +19,7 @@ export class OpenAIChatModelConfig implements ChatModelConfig {
17
19
  maxTokens?: number;
18
20
  }>,
19
21
  ) {
22
+ this.modelName = model;
20
23
  this.presentation = presentationIn ?? { icon: "builtin:openai", label: name };
21
24
  }
22
25
 
@@ -22,9 +22,12 @@ import {
22
22
  AgentGuardrailDefaults,
23
23
  AgentMessageConfigNormalizer,
24
24
  CallableToolConfig,
25
+ CodemationTelemetryAttributeNames,
26
+ CodemationTelemetryMetricNames,
25
27
  ConnectionInvocationIdFactory,
26
28
  ConnectionNodeIdFactory,
27
29
  CoreTokens,
30
+ GenAiTelemetryAttributeNames,
28
31
  NodeBackedToolConfig,
29
32
  inject,
30
33
  node,
@@ -36,6 +39,7 @@ import { z } from "zod";
36
39
 
37
40
  import type { AIAgent } from "./AIAgentConfig";
38
41
  import { AIAgentExecutionHelpersFactory } from "./AIAgentExecutionHelpersFactory";
42
+ import { AgentToolExecutionCoordinator } from "./AgentToolExecutionCoordinator";
39
43
  import { ConnectionCredentialExecutionContextFactory } from "./ConnectionCredentialExecutionContextFactory";
40
44
  import { AgentMessageFactory } from "./AgentMessageFactory";
41
45
  import { AgentOutputFactory } from "./AgentOutputFactory";
@@ -91,6 +95,8 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
91
95
  private readonly executionHelpers: AIAgentExecutionHelpersFactory,
92
96
  @inject(AgentStructuredOutputRunner)
93
97
  private readonly structuredOutputRunner: AgentStructuredOutputRunner,
98
+ @inject(AgentToolExecutionCoordinator)
99
+ private readonly toolExecutionCoordinator: AgentToolExecutionCoordinator,
94
100
  ) {
95
101
  this.connectionCredentialExecutionContextFactory =
96
102
  this.executionHelpers.createConnectionCredentialExecutionContextFactory(credentialSessions);
@@ -178,21 +184,28 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
178
184
  prepared.guardrails.modelInvocationOptions,
179
185
  ),
180
186
  });
187
+ await ctx.telemetry.recordMetric({ name: CodemationTelemetryMetricNames.agentTurns, value: 1 });
188
+ await ctx.telemetry.recordMetric({ name: CodemationTelemetryMetricNames.agentToolCalls, value: 0 });
181
189
  return this.buildOutputItem(item, structuredOutput);
182
190
  }
183
191
  const modelWithTools = this.bindToolsToModel(prepared.model, itemScopedTools);
184
- const finalResponse = await this.runTurnLoopUntilFinalAnswer({
192
+ const loopResult = await this.runTurnLoopUntilFinalAnswer({
185
193
  prepared,
186
194
  itemInputsByPort,
187
195
  itemScopedTools,
188
196
  conversation,
189
197
  modelWithTools,
190
198
  });
199
+ await ctx.telemetry.recordMetric({ name: CodemationTelemetryMetricNames.agentTurns, value: loopResult.turnCount });
200
+ await ctx.telemetry.recordMetric({
201
+ name: CodemationTelemetryMetricNames.agentToolCalls,
202
+ value: loopResult.toolCallCount,
203
+ });
191
204
  const outputJson = await this.resolveFinalOutputJson(
192
205
  prepared,
193
206
  itemInputsByPort,
194
207
  conversation,
195
- finalResponse,
208
+ loopResult.finalResponse,
196
209
  itemScopedTools.length > 0,
197
210
  );
198
211
  return this.buildOutputItem(item, outputJson);
@@ -207,13 +220,17 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
207
220
  itemScopedTools: ReadonlyArray<ItemScopedToolBinding>;
208
221
  conversation: BaseMessage[];
209
222
  modelWithTools: LangChainChatModelLike;
210
- }): Promise<AIMessage> {
223
+ }): Promise<Readonly<{ finalResponse: AIMessage; turnCount: number; toolCallCount: number }>> {
211
224
  const { prepared, itemInputsByPort, itemScopedTools, conversation, modelWithTools } = args;
212
225
  const { ctx, guardrails, languageModelConnectionNodeId } = prepared;
213
226
 
214
227
  let finalResponse: AIMessage | undefined;
228
+ let toolCallCount = 0;
229
+ let turnCount = 0;
230
+ const repairAttemptsByToolName = new Map<string, number>();
215
231
 
216
232
  for (let turn = 1; turn <= guardrails.maxTurns; turn++) {
233
+ turnCount = turn;
217
234
  const response = await this.invokeModel(
218
235
  modelWithTools,
219
236
  languageModelConnectionNodeId,
@@ -235,15 +252,25 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
235
252
  }
236
253
 
237
254
  const plannedToolCalls = this.planToolCalls(itemScopedTools, toolCalls, ctx.nodeId);
255
+ toolCallCount += plannedToolCalls.length;
238
256
  await this.markQueuedTools(plannedToolCalls, ctx);
239
- const executedToolCalls = await this.executeToolCalls(plannedToolCalls, ctx);
257
+ const executedToolCalls = await this.toolExecutionCoordinator.execute({
258
+ plannedToolCalls,
259
+ ctx,
260
+ agentName: this.getAgentDisplayName(ctx),
261
+ repairAttemptsByToolName,
262
+ });
240
263
  this.appendAssistantAndToolMessages(conversation, response, executedToolCalls);
241
264
  }
242
265
 
243
266
  if (!finalResponse) {
244
267
  throw new Error(`AIAgent "${ctx.config.name ?? ctx.nodeId}" did not produce a model response.`);
245
268
  }
246
- return finalResponse;
269
+ return {
270
+ finalResponse,
271
+ turnCount,
272
+ toolCallCount,
273
+ };
247
274
  }
248
275
 
249
276
  private cannotExecuteAnotherToolRound(turn: number, guardrails: ResolvedGuardrails): boolean {
@@ -374,10 +401,15 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
374
401
  inputsByPort: NodeInputsByPort,
375
402
  options?: AgentGuardrailConfig["modelInvocationOptions"],
376
403
  ): Promise<AIMessage> {
404
+ const invocationId = ConnectionInvocationIdFactory.create();
405
+ const startedAt = new Date();
406
+ const summarizedInput = this.summarizeLlmMessages(messages);
407
+ const span = this.createModelInvocationSpan(ctx, invocationId, startedAt);
377
408
  await ctx.nodeState?.markQueued({ nodeId, activationId: ctx.activationId, inputsByPort });
378
409
  await ctx.nodeState?.markRunning({ nodeId, activationId: ctx.activationId, inputsByPort });
379
410
  try {
380
411
  const response = (await model.invoke(messages, options)) as AIMessage;
412
+ const finishedAt = new Date();
381
413
  await ctx.nodeState?.markCompleted({
382
414
  nodeId,
383
415
  activationId: ctx.activationId,
@@ -387,19 +419,46 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
387
419
  }),
388
420
  });
389
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);
433
+ await span.end({ status: "ok", endedAt: finishedAt });
390
434
  await ctx.nodeState?.appendConnectionInvocation({
391
- invocationId: ConnectionInvocationIdFactory.create(),
435
+ invocationId,
392
436
  connectionNodeId: nodeId,
393
437
  parentAgentNodeId: ctx.nodeId,
394
438
  parentAgentActivationId: ctx.activationId,
395
439
  status: "completed",
396
- managedInput: this.summarizeLlmMessages(messages),
440
+ managedInput: summarizedInput,
397
441
  managedOutput: content,
398
- finishedAt: new Date().toISOString(),
442
+ queuedAt: startedAt.toISOString(),
443
+ startedAt: startedAt.toISOString(),
444
+ finishedAt: finishedAt.toISOString(),
399
445
  });
400
446
  return response;
401
447
  } catch (error) {
402
- throw await this.failTrackedNodeInvocation(error, nodeId, ctx, inputsByPort, this.summarizeLlmMessages(messages));
448
+ await span.end({
449
+ status: "error",
450
+ statusMessage: error instanceof Error ? error.message : String(error),
451
+ endedAt: new Date(),
452
+ });
453
+ throw await this.failTrackedNodeInvocation({
454
+ error,
455
+ invocationId,
456
+ startedAt,
457
+ nodeId,
458
+ ctx,
459
+ inputsByPort,
460
+ managedInput: this.summarizeLlmMessages(messages),
461
+ });
403
462
  }
404
463
  }
405
464
 
@@ -411,30 +470,222 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
411
470
  inputsByPort: NodeInputsByPort,
412
471
  options?: AgentGuardrailConfig["modelInvocationOptions"],
413
472
  ): Promise<unknown> {
473
+ const invocationId = ConnectionInvocationIdFactory.create();
474
+ const startedAt = new Date();
475
+ const summarizedInput = this.summarizeLlmMessages(messages);
476
+ const span = this.createModelInvocationSpan(ctx, invocationId, startedAt);
414
477
  await ctx.nodeState?.markQueued({ nodeId, activationId: ctx.activationId, inputsByPort });
415
478
  await ctx.nodeState?.markRunning({ nodeId, activationId: ctx.activationId, inputsByPort });
416
479
  try {
417
480
  const response = await model.invoke(messages, options);
481
+ const finishedAt = new Date();
418
482
  await ctx.nodeState?.markCompleted({
419
483
  nodeId,
420
484
  activationId: ctx.activationId,
421
485
  inputsByPort,
422
486
  outputs: AgentOutputFactory.fromUnknown(response),
423
487
  });
488
+ await span.attachArtifact({
489
+ kind: "ai.messages",
490
+ contentType: "application/json",
491
+ previewJson: summarizedInput,
492
+ });
493
+ await span.attachArtifact({
494
+ kind: "ai.response.structured",
495
+ contentType: "application/json",
496
+ previewJson: this.resultToJsonValue(response),
497
+ });
498
+ await this.recordModelUsageMetrics(span, response, ctx);
499
+ await span.end({ status: "ok", endedAt: finishedAt });
424
500
  await ctx.nodeState?.appendConnectionInvocation({
425
- invocationId: ConnectionInvocationIdFactory.create(),
501
+ invocationId,
426
502
  connectionNodeId: nodeId,
427
503
  parentAgentNodeId: ctx.nodeId,
428
504
  parentAgentActivationId: ctx.activationId,
429
505
  status: "completed",
430
- managedInput: this.summarizeLlmMessages(messages),
506
+ managedInput: summarizedInput,
431
507
  managedOutput: this.resultToJsonValue(response),
432
- finishedAt: new Date().toISOString(),
508
+ queuedAt: startedAt.toISOString(),
509
+ startedAt: startedAt.toISOString(),
510
+ finishedAt: finishedAt.toISOString(),
433
511
  });
434
512
  return response;
435
513
  } catch (error) {
436
- throw await this.failTrackedNodeInvocation(error, nodeId, ctx, inputsByPort, this.summarizeLlmMessages(messages));
514
+ await span.end({
515
+ status: "error",
516
+ statusMessage: error instanceof Error ? error.message : String(error),
517
+ endedAt: new Date(),
518
+ });
519
+ throw await this.failTrackedNodeInvocation({
520
+ error,
521
+ invocationId,
522
+ startedAt,
523
+ nodeId,
524
+ ctx,
525
+ inputsByPort,
526
+ managedInput: this.summarizeLlmMessages(messages),
527
+ });
528
+ }
529
+ }
530
+
531
+ private createModelInvocationSpan(
532
+ ctx: NodeExecutionContext<AIAgent<any, any>>,
533
+ invocationId: string,
534
+ startedAt: Date,
535
+ ) {
536
+ return ctx.telemetry.startChildSpan({
537
+ name: "gen_ai.chat.completion",
538
+ kind: "client",
539
+ startedAt,
540
+ attributes: {
541
+ [CodemationTelemetryAttributeNames.connectionInvocationId]: invocationId,
542
+ [GenAiTelemetryAttributeNames.operationName]: "chat",
543
+ [GenAiTelemetryAttributeNames.requestModel]: this.resolveChatModelName(ctx.config.chatModel),
544
+ },
545
+ });
546
+ }
547
+
548
+ private async recordModelUsageMetrics(
549
+ span: ReturnType<NodeExecutionContext["telemetry"]["startChildSpan"]>,
550
+ response: unknown,
551
+ ctx: NodeExecutionContext<AIAgent<any, any>>,
552
+ ) {
553
+ const usage = this.extractModelUsageMetrics(response);
554
+ for (const [name, value] of Object.entries(usage)) {
555
+ if (value === undefined) {
556
+ continue;
557
+ }
558
+ await span.recordMetric({ name, value });
437
559
  }
560
+ await this.captureCostTrackingUsage(span, ctx, usage);
561
+ }
562
+
563
+ private async captureCostTrackingUsage(
564
+ span: ReturnType<NodeExecutionContext["telemetry"]["startChildSpan"]>,
565
+ ctx: NodeExecutionContext<AIAgent<any, any>>,
566
+ usage: Readonly<Record<string, number | undefined>>,
567
+ ): Promise<void> {
568
+ const costTracking = span.costTracking;
569
+ if (!costTracking) {
570
+ return;
571
+ }
572
+ const provider = ctx.config.chatModel.provider;
573
+ 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) {
580
+ await costTracking.captureUsage({
581
+ component: "chat",
582
+ provider,
583
+ operation: "completion.input",
584
+ pricingKey,
585
+ usageUnit: "input_tokens",
586
+ quantity: inputTokens,
587
+ modelName: pricingKey,
588
+ });
589
+ }
590
+ if (outputTokens !== undefined) {
591
+ await costTracking.captureUsage({
592
+ component: "chat",
593
+ provider,
594
+ operation: "completion.output",
595
+ pricingKey,
596
+ usageUnit: "output_tokens",
597
+ quantity: outputTokens,
598
+ modelName: pricingKey,
599
+ });
600
+ }
601
+ }
602
+
603
+ private resolveChatModelName(chatModel: ChatModelConfig): string {
604
+ return chatModel.modelName ?? chatModel.name;
605
+ }
606
+
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;
438
689
  }
439
690
 
440
691
  private async markQueuedTools(
@@ -450,65 +701,6 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
450
701
  }
451
702
  }
452
703
 
453
- private async executeToolCalls(
454
- plannedToolCalls: ReadonlyArray<PlannedToolCall>,
455
- ctx: NodeExecutionContext<AIAgent<any, any>>,
456
- ): Promise<ReadonlyArray<ExecutedToolCall>> {
457
- const results = await Promise.allSettled(
458
- plannedToolCalls.map(async (plannedToolCall) => {
459
- const toolCallInputsByPort = AgentToolCallPortMap.fromInput(plannedToolCall.toolCall.input ?? {});
460
- await ctx.nodeState?.markRunning({
461
- nodeId: plannedToolCall.nodeId,
462
- activationId: ctx.activationId,
463
- inputsByPort: toolCallInputsByPort,
464
- });
465
- try {
466
- const serialized = await plannedToolCall.binding.langChainTool.invoke(plannedToolCall.toolCall.input ?? {});
467
- const result = this.parseToolOutput(serialized);
468
- await ctx.nodeState?.markCompleted({
469
- nodeId: plannedToolCall.nodeId,
470
- activationId: ctx.activationId,
471
- inputsByPort: toolCallInputsByPort,
472
- outputs: AgentOutputFactory.fromUnknown(result),
473
- });
474
- await ctx.nodeState?.appendConnectionInvocation({
475
- invocationId: ConnectionInvocationIdFactory.create(),
476
- connectionNodeId: plannedToolCall.nodeId,
477
- parentAgentNodeId: ctx.nodeId,
478
- parentAgentActivationId: ctx.activationId,
479
- status: "completed",
480
- managedInput: this.toolCallInputToJson(plannedToolCall.toolCall.input),
481
- managedOutput: this.resultToJsonValue(result),
482
- finishedAt: new Date().toISOString(),
483
- });
484
- return {
485
- toolName: plannedToolCall.binding.config.name,
486
- toolCallId: plannedToolCall.toolCall.id ?? plannedToolCall.binding.config.name,
487
- serialized,
488
- result,
489
- } satisfies ExecutedToolCall;
490
- } catch (error) {
491
- throw await this.failTrackedNodeInvocation(
492
- error,
493
- plannedToolCall.nodeId,
494
- ctx,
495
- toolCallInputsByPort,
496
- this.toolCallInputToJson(plannedToolCall.toolCall.input),
497
- );
498
- }
499
- }),
500
- );
501
-
502
- const rejected = results.find((result) => result.status === "rejected");
503
- if (rejected?.status === "rejected") {
504
- throw rejected.reason instanceof Error ? rejected.reason : new Error(String(rejected.reason));
505
- }
506
-
507
- return results
508
- .filter((result): result is PromiseFulfilledResult<ExecutedToolCall> => result.status === "fulfilled")
509
- .map((result) => result.value);
510
- }
511
-
512
704
  private planToolCalls(
513
705
  bindings: ReadonlyArray<ItemScopedToolBinding>,
514
706
  toolCalls: ReadonlyArray<AgentToolCall>,
@@ -529,42 +721,41 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
529
721
  });
530
722
  }
531
723
 
532
- private parseToolOutput(serialized: unknown): unknown {
533
- if (typeof serialized !== "string") return serialized;
534
- try {
535
- return JSON.parse(serialized);
536
- } catch {
537
- return serialized;
538
- }
539
- }
540
-
541
724
  private async failTrackedNodeInvocation(
542
- error: unknown,
543
- nodeId: string,
544
- ctx: NodeExecutionContext<AIAgent<any, any>>,
545
- inputsByPort: NodeInputsByPort,
546
- managedInput?: JsonValue,
725
+ args: Readonly<{
726
+ error: unknown;
727
+ invocationId: string;
728
+ startedAt: Date;
729
+ nodeId: string;
730
+ ctx: NodeExecutionContext<AIAgent<any, any>>;
731
+ inputsByPort: NodeInputsByPort;
732
+ managedInput?: JsonValue;
733
+ }>,
547
734
  ): Promise<Error> {
548
- const effectiveError = error instanceof Error ? error : new Error(String(error));
549
- await ctx.nodeState?.markFailed({
550
- nodeId,
551
- activationId: ctx.activationId,
552
- inputsByPort,
735
+ const effectiveError = args.error instanceof Error ? args.error : new Error(String(args.error));
736
+ const finishedAt = new Date();
737
+ await args.ctx.nodeState?.markFailed({
738
+ nodeId: args.nodeId,
739
+ activationId: args.ctx.activationId,
740
+ inputsByPort: args.inputsByPort,
553
741
  error: effectiveError,
554
742
  });
555
- await ctx.nodeState?.appendConnectionInvocation({
556
- invocationId: ConnectionInvocationIdFactory.create(),
557
- connectionNodeId: nodeId,
558
- parentAgentNodeId: ctx.nodeId,
559
- parentAgentActivationId: ctx.activationId,
743
+ await args.ctx.nodeState?.appendConnectionInvocation({
744
+ invocationId: args.invocationId,
745
+ connectionNodeId: args.nodeId,
746
+ parentAgentNodeId: args.ctx.nodeId,
747
+ parentAgentActivationId: args.ctx.activationId,
560
748
  status: "failed",
561
- managedInput,
749
+ managedInput: args.managedInput,
562
750
  error: {
563
751
  message: effectiveError.message,
564
752
  name: effectiveError.name,
565
753
  stack: effectiveError.stack,
754
+ details: this.extractErrorDetails(effectiveError),
566
755
  },
567
- finishedAt: new Date().toISOString(),
756
+ queuedAt: args.startedAt.toISOString(),
757
+ startedAt: args.startedAt.toISOString(),
758
+ finishedAt: finishedAt.toISOString(),
568
759
  });
569
760
  return effectiveError;
570
761
  }
@@ -583,10 +774,6 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
583
774
  };
584
775
  }
585
776
 
586
- private toolCallInputToJson(input: unknown): JsonValue | undefined {
587
- return this.resultToJsonValue(input);
588
- }
589
-
590
777
  private resultToJsonValue(value: unknown): JsonValue | undefined {
591
778
  if (value === undefined) {
592
779
  return undefined;
@@ -687,4 +874,9 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
687
874
  private getAgentDisplayName(ctx: NodeExecutionContext<AIAgent<any, any>>): string {
688
875
  return ctx.config.name ?? ctx.nodeId;
689
876
  }
877
+
878
+ private extractErrorDetails(error: Error): JsonValue | undefined {
879
+ const candidate = error as Error & { details?: JsonValue };
880
+ return candidate.details;
881
+ }
690
882
  }
@@ -0,0 +1,106 @@
1
+ import type { JsonValue } from "@codemation/core";
2
+ import { injectable } from "@codemation/core";
3
+ import { ZodError } from "zod";
4
+
5
+ import type { AgentToolFailureClassification, AgentToolValidationIssue } from "./AgentToolRepair.types";
6
+
7
+ @injectable()
8
+ export class AgentToolErrorClassifier {
9
+ classify(
10
+ args: Readonly<{
11
+ error: unknown;
12
+ toolName: string;
13
+ schema?: unknown;
14
+ }>,
15
+ ): AgentToolFailureClassification {
16
+ const effectiveError = this.toError(args.error);
17
+ if (this.isRepairableValidationError(args.error, effectiveError)) {
18
+ return {
19
+ kind: "repairable_validation_error",
20
+ effectiveError,
21
+ issues: this.extractIssues(args.error, effectiveError, args.toolName),
22
+ requiredSchemaReminder: this.toJsonValue(args.schema),
23
+ };
24
+ }
25
+ if (this.isTransientExecutionError(effectiveError)) {
26
+ return {
27
+ kind: "transient_execution_error",
28
+ effectiveError,
29
+ };
30
+ }
31
+ return {
32
+ kind: "non_repairable_error",
33
+ effectiveError,
34
+ };
35
+ }
36
+
37
+ private isRepairableValidationError(rawError: unknown, effectiveError: Error): boolean {
38
+ if (rawError instanceof ZodError) {
39
+ const stage = (rawError as ZodError & { codemationToolValidationStage?: "input" | "output" })
40
+ .codemationToolValidationStage;
41
+ return stage !== "output";
42
+ }
43
+ if (effectiveError.name === "ZodError") {
44
+ return true;
45
+ }
46
+ return effectiveError.message.includes("Received tool input did not match expected schema");
47
+ }
48
+
49
+ private extractIssues(
50
+ rawError: unknown,
51
+ effectiveError: Error,
52
+ toolName: string,
53
+ ): ReadonlyArray<AgentToolValidationIssue> | undefined {
54
+ if (rawError instanceof ZodError) {
55
+ return rawError.issues.map((issue) => ({
56
+ path: issue.path.map((segment) => (typeof segment === "number" ? segment : String(segment))),
57
+ code: issue.code,
58
+ message: issue.message,
59
+ expected: this.toOptionalString("expected" in issue ? issue.expected : undefined),
60
+ received: this.toOptionalString("received" in issue ? issue.received : undefined),
61
+ }));
62
+ }
63
+ if (effectiveError.name !== "ZodError") {
64
+ return undefined;
65
+ }
66
+ return [
67
+ {
68
+ path: [],
69
+ code: "invalid_tool_input",
70
+ message: `Tool "${toolName}" input was invalid: ${effectiveError.message}`,
71
+ },
72
+ ];
73
+ }
74
+
75
+ private isTransientExecutionError(error: Error): boolean {
76
+ const summary = `${error.name} ${error.message}`.toLowerCase();
77
+ return (
78
+ summary.includes("timeout") ||
79
+ summary.includes("timed out") ||
80
+ summary.includes("rate limit") ||
81
+ summary.includes("too many requests") ||
82
+ summary.includes("temporarily unavailable") ||
83
+ summary.includes("econnreset") ||
84
+ summary.includes("etimedout") ||
85
+ summary.includes("503")
86
+ );
87
+ }
88
+
89
+ private toError(error: unknown): Error {
90
+ return error instanceof Error ? error : new Error(String(error));
91
+ }
92
+
93
+ private toJsonValue(value: unknown): JsonValue | undefined {
94
+ if (value === undefined) {
95
+ return undefined;
96
+ }
97
+ return JSON.parse(JSON.stringify(value)) as JsonValue;
98
+ }
99
+
100
+ private toOptionalString(value: unknown): string | undefined {
101
+ if (value === undefined) {
102
+ return undefined;
103
+ }
104
+ return String(value);
105
+ }
106
+ }