@codemation/core-nodes 0.1.1 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codemation/core-nodes",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
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.5.0"
34
+ "@codemation/core": "0.6.0"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/node": "^25.3.5",
@@ -0,0 +1,46 @@
1
+ import type { ChatModelConfig, ChatModelStructuredOutputOptions } from "@codemation/core";
2
+ import { injectable } from "@codemation/core";
3
+
4
+ import { OpenAIChatModelFactory } from "./OpenAIChatModelFactory";
5
+
6
+ @injectable()
7
+ export class OpenAIStructuredOutputMethodFactory {
8
+ private static readonly isoDatePattern = /^\d{4}-\d{2}-\d{2}$/;
9
+
10
+ create(chatModelConfig: ChatModelConfig): ChatModelStructuredOutputOptions | undefined {
11
+ if (chatModelConfig.type !== OpenAIChatModelFactory) {
12
+ return undefined;
13
+ }
14
+ const model = this.readModelName(chatModelConfig);
15
+ if (!model) {
16
+ return { method: "functionCalling", strict: true };
17
+ }
18
+ return {
19
+ method: this.supportsJsonSchema(model) ? "jsonSchema" : "functionCalling",
20
+ strict: true,
21
+ };
22
+ }
23
+
24
+ private readModelName(chatModelConfig: ChatModelConfig): string | undefined {
25
+ const candidate = chatModelConfig as Readonly<{ model?: unknown }>;
26
+ return typeof candidate.model === "string" ? candidate.model : undefined;
27
+ }
28
+
29
+ private supportsJsonSchema(model: string): boolean {
30
+ if (model === "gpt-4o" || model === "gpt-4o-mini") {
31
+ return true;
32
+ }
33
+ return (
34
+ this.supportsSnapshotAtOrAfter(model, "gpt-4o-", "2024-08-06") ||
35
+ this.supportsSnapshotAtOrAfter(model, "gpt-4o-mini-", "2024-07-18")
36
+ );
37
+ }
38
+
39
+ private supportsSnapshotAtOrAfter(model: string, prefix: string, minimumSnapshotDate: string): boolean {
40
+ if (!model.startsWith(prefix)) {
41
+ return false;
42
+ }
43
+ const snapshotDate = model.slice(prefix.length);
44
+ return OpenAIStructuredOutputMethodFactory.isoDatePattern.test(snapshotDate) && snapshotDate >= minimumSnapshotDate;
45
+ }
46
+ }
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from "./canvasIconName";
2
2
  export * from "./chatModels/OpenAIChatModelFactory";
3
+ export * from "./chatModels/OpenAIStructuredOutputMethodFactory";
3
4
  export * from "./chatModels/OpenAiCredentialSession";
4
5
  export * from "./chatModels/openAiChatModelConfig";
5
6
  export * from "./chatModels/OpenAiChatModelPresetsFactory";
@@ -23,6 +23,7 @@ export interface AIAgentOptions<TInputJson = unknown, _TOutputJson = unknown> {
23
23
  readonly guardrails?: AgentGuardrailConfig;
24
24
  /** Engine applies with {@link RunnableNodeConfig.inputSchema} before {@link AIAgentNode.execute}. */
25
25
  readonly inputSchema?: ZodType<TInputJson>;
26
+ readonly outputSchema?: ZodType<_TOutputJson>;
26
27
  }
27
28
 
28
29
  /**
@@ -44,6 +45,7 @@ export class AIAgent<TInputJson = unknown, TOutputJson = unknown>
44
45
  readonly retryPolicy: RetryPolicySpec;
45
46
  readonly guardrails?: AgentGuardrailConfig;
46
47
  readonly inputSchema?: ZodType<TInputJson>;
48
+ readonly outputSchema?: ZodType<TOutputJson>;
47
49
 
48
50
  constructor(options: AIAgentOptions<TInputJson, TOutputJson>) {
49
51
  this.name = options.name;
@@ -54,5 +56,6 @@ export class AIAgent<TInputJson = unknown, TOutputJson = unknown>
54
56
  this.retryPolicy = options.retryPolicy ?? RetryPolicy.defaultForAiAgent;
55
57
  this.guardrails = options.guardrails;
56
58
  this.inputSchema = options.inputSchema;
59
+ this.outputSchema = options.outputSchema;
57
60
  }
58
61
  }
@@ -33,10 +33,10 @@ export class AIAgentExecutionHelpersFactory {
33
33
  `Cannot create LangChain tool "${entry.config.name}": missing inputSchema (broken tool runtime resolution).`,
34
34
  );
35
35
  }
36
- const schemaForOpenAi = this.normalizeToolInputSchemaForOpenAiDynamicStructuredTool(
37
- entry.config.name,
38
- entry.runtime.inputSchema,
39
- );
36
+ const schemaForOpenAi = this.createJsonSchemaRecord(entry.runtime.inputSchema, {
37
+ schemaName: entry.config.name,
38
+ requireObjectRoot: true,
39
+ });
40
40
  return new DynamicStructuredTool({
41
41
  name: entry.config.name,
42
42
  description: entry.config.description ?? entry.runtime.defaultDescription,
@@ -63,9 +63,12 @@ export class AIAgentExecutionHelpersFactory {
63
63
  * (duplicate `zod` copies), fall back to Zod `toJSONSchema` with draft-07.
64
64
  * - Strip root `$schema` for OpenAI; normalize invalid `required` keywords for cfworker; ensure `properties`.
65
65
  */
66
- private normalizeToolInputSchemaForOpenAiDynamicStructuredTool(
67
- toolName: string,
66
+ createJsonSchemaRecord(
68
67
  inputSchema: ZodSchemaAny,
68
+ options: Readonly<{
69
+ schemaName: string;
70
+ requireObjectRoot: boolean;
71
+ }>,
69
72
  ): Record<string, unknown> {
70
73
  const draft07Params = { target: "draft-07" as const };
71
74
  let converted: unknown;
@@ -79,17 +82,21 @@ export class AIAgentExecutionHelpersFactory {
79
82
  }
80
83
  const record = converted as Record<string, unknown>;
81
84
  const { $schema: _draftSchemaOmitted, ...rest } = record;
82
- if (rest.type !== "object") {
85
+ if (options.requireObjectRoot && rest.type !== "object") {
83
86
  throw new Error(
84
- `Cannot create LangChain tool "${toolName}": tool input schema must be a JSON Schema object type (got type=${String(rest.type)}).`,
87
+ `Cannot create LangChain tool "${options.schemaName}": tool input schema must be a JSON Schema object type (got type=${String(rest.type)}).`,
85
88
  );
86
89
  }
87
- if (rest.properties !== undefined && (typeof rest.properties !== "object" || Array.isArray(rest.properties))) {
90
+ if (
91
+ options.requireObjectRoot &&
92
+ rest.properties !== undefined &&
93
+ (typeof rest.properties !== "object" || Array.isArray(rest.properties))
94
+ ) {
88
95
  throw new Error(
89
- `Cannot create LangChain tool "${toolName}": tool input schema "properties" must be an object (got ${JSON.stringify(rest.properties)}).`,
96
+ `Cannot create LangChain tool "${options.schemaName}": tool input schema "properties" must be an object (got ${JSON.stringify(rest.properties)}).`,
90
97
  );
91
98
  }
92
- if (rest.properties === undefined) {
99
+ if (options.requireObjectRoot && rest.properties === undefined) {
93
100
  rest.properties = {};
94
101
  }
95
102
  this.sanitizeJsonSchemaRequiredKeywordsForCfworker(rest);
@@ -13,6 +13,7 @@ import type {
13
13
  RunnableNodeExecuteArgs,
14
14
  Tool,
15
15
  ToolConfig,
16
+ LangChainStructuredOutputModelLike,
16
17
  ZodSchemaAny,
17
18
  } from "@codemation/core";
18
19
 
@@ -20,6 +21,7 @@ import type { CredentialSessionService } from "@codemation/core";
20
21
  import {
21
22
  AgentGuardrailDefaults,
22
23
  AgentMessageConfigNormalizer,
24
+ CallableToolConfig,
23
25
  ConnectionInvocationIdFactory,
24
26
  ConnectionNodeIdFactory,
25
27
  CoreTokens,
@@ -37,6 +39,7 @@ import { AIAgentExecutionHelpersFactory } from "./AIAgentExecutionHelpersFactory
37
39
  import { ConnectionCredentialExecutionContextFactory } from "./ConnectionCredentialExecutionContextFactory";
38
40
  import { AgentMessageFactory } from "./AgentMessageFactory";
39
41
  import { AgentOutputFactory } from "./AgentOutputFactory";
42
+ import { AgentStructuredOutputRunner } from "./AgentStructuredOutputRunner";
40
43
  import { AgentToolCallPortMap } from "./AgentToolCallPortMapFactory";
41
44
  import { NodeBackedToolRuntime } from "./NodeBackedToolRuntime";
42
45
  import {
@@ -86,6 +89,8 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
86
89
  private readonly nodeBackedToolRuntime: NodeBackedToolRuntime,
87
90
  @inject(AIAgentExecutionHelpersFactory)
88
91
  private readonly executionHelpers: AIAgentExecutionHelpersFactory,
92
+ @inject(AgentStructuredOutputRunner)
93
+ private readonly structuredOutputRunner: AgentStructuredOutputRunner,
89
94
  ) {
90
95
  this.connectionCredentialExecutionContextFactory =
91
96
  this.executionHelpers.createConnectionCredentialExecutionContextFactory(credentialSessions);
@@ -146,6 +151,35 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
146
151
  const itemInputsByPort = AgentItemPortMap.fromItem(item);
147
152
  const itemScopedTools = this.createItemScopedTools(prepared.resolvedTools, ctx, item, itemIndex, items);
148
153
  const conversation: BaseMessage[] = [...this.createPromptMessages(item, itemIndex, items, ctx)];
154
+ if (ctx.config.outputSchema && itemScopedTools.length === 0) {
155
+ const structuredOutput = await this.structuredOutputRunner.resolve({
156
+ model: prepared.model,
157
+ chatModelConfig: ctx.config.chatModel,
158
+ schema: ctx.config.outputSchema,
159
+ conversation,
160
+ agentName: this.getAgentDisplayName(ctx),
161
+ nodeId: ctx.nodeId,
162
+ invokeTextModel: async (messages) =>
163
+ await this.invokeModel(
164
+ prepared.model,
165
+ prepared.languageModelConnectionNodeId,
166
+ messages,
167
+ ctx,
168
+ itemInputsByPort,
169
+ prepared.guardrails.modelInvocationOptions,
170
+ ),
171
+ invokeStructuredModel: async (structuredModel, messages) =>
172
+ await this.invokeStructuredModel(
173
+ structuredModel,
174
+ prepared.languageModelConnectionNodeId,
175
+ messages,
176
+ ctx,
177
+ itemInputsByPort,
178
+ prepared.guardrails.modelInvocationOptions,
179
+ ),
180
+ });
181
+ return this.buildOutputItem(item, structuredOutput);
182
+ }
149
183
  const modelWithTools = this.bindToolsToModel(prepared.model, itemScopedTools);
150
184
  const finalResponse = await this.runTurnLoopUntilFinalAnswer({
151
185
  prepared,
@@ -154,7 +188,14 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
154
188
  conversation,
155
189
  modelWithTools,
156
190
  });
157
- return this.buildOutputItem(item, finalResponse);
191
+ const outputJson = await this.resolveFinalOutputJson(
192
+ prepared,
193
+ itemInputsByPort,
194
+ conversation,
195
+ finalResponse,
196
+ itemScopedTools.length > 0,
197
+ );
198
+ return this.buildOutputItem(item, outputJson);
158
199
  }
159
200
 
160
201
  /**
@@ -234,11 +275,47 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
234
275
  );
235
276
  }
236
277
 
237
- private buildOutputItem(item: Item, finalResponse: AIMessage): Item {
238
- return AgentOutputFactory.replaceJson(
239
- item,
240
- AgentOutputFactory.fromAgentContent(AgentMessageFactory.extractContent(finalResponse)),
241
- );
278
+ private async resolveFinalOutputJson(
279
+ prepared: PreparedAgentExecution,
280
+ itemInputsByPort: NodeInputsByPort,
281
+ conversation: ReadonlyArray<BaseMessage>,
282
+ finalResponse: AIMessage,
283
+ wasToolEnabledRun: boolean,
284
+ ): Promise<unknown> {
285
+ if (!prepared.ctx.config.outputSchema) {
286
+ return AgentOutputFactory.fromAgentContent(AgentMessageFactory.extractContent(finalResponse));
287
+ }
288
+ return await this.structuredOutputRunner.resolve({
289
+ model: prepared.model,
290
+ chatModelConfig: prepared.ctx.config.chatModel,
291
+ schema: prepared.ctx.config.outputSchema,
292
+ conversation: wasToolEnabledRun ? [...conversation, finalResponse] : conversation,
293
+ rawFinalResponse: finalResponse,
294
+ agentName: this.getAgentDisplayName(prepared.ctx),
295
+ nodeId: prepared.ctx.nodeId,
296
+ invokeTextModel: async (messages) =>
297
+ await this.invokeModel(
298
+ prepared.model,
299
+ prepared.languageModelConnectionNodeId,
300
+ messages,
301
+ prepared.ctx,
302
+ itemInputsByPort,
303
+ prepared.guardrails.modelInvocationOptions,
304
+ ),
305
+ invokeStructuredModel: async (structuredModel, messages) =>
306
+ await this.invokeStructuredModel(
307
+ structuredModel,
308
+ prepared.languageModelConnectionNodeId,
309
+ messages,
310
+ prepared.ctx,
311
+ itemInputsByPort,
312
+ prepared.guardrails.modelInvocationOptions,
313
+ ),
314
+ });
315
+ }
316
+
317
+ private buildOutputItem(item: Item, outputJson: unknown): Item {
318
+ return AgentOutputFactory.replaceJson(item, outputJson);
242
319
  }
243
320
 
244
321
  private bindToolsToModel(
@@ -326,6 +403,40 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
326
403
  }
327
404
  }
328
405
 
406
+ private async invokeStructuredModel(
407
+ model: LangChainStructuredOutputModelLike,
408
+ nodeId: string,
409
+ messages: ReadonlyArray<BaseMessage>,
410
+ ctx: NodeExecutionContext<AIAgent<any, any>>,
411
+ inputsByPort: NodeInputsByPort,
412
+ options?: AgentGuardrailConfig["modelInvocationOptions"],
413
+ ): Promise<unknown> {
414
+ await ctx.nodeState?.markQueued({ nodeId, activationId: ctx.activationId, inputsByPort });
415
+ await ctx.nodeState?.markRunning({ nodeId, activationId: ctx.activationId, inputsByPort });
416
+ try {
417
+ const response = await model.invoke(messages, options);
418
+ await ctx.nodeState?.markCompleted({
419
+ nodeId,
420
+ activationId: ctx.activationId,
421
+ inputsByPort,
422
+ outputs: AgentOutputFactory.fromUnknown(response),
423
+ });
424
+ await ctx.nodeState?.appendConnectionInvocation({
425
+ invocationId: ConnectionInvocationIdFactory.create(),
426
+ connectionNodeId: nodeId,
427
+ parentAgentNodeId: ctx.nodeId,
428
+ parentAgentActivationId: ctx.activationId,
429
+ status: "completed",
430
+ managedInput: this.summarizeLlmMessages(messages),
431
+ managedOutput: this.resultToJsonValue(response),
432
+ finishedAt: new Date().toISOString(),
433
+ });
434
+ return response;
435
+ } catch (error) {
436
+ throw await this.failTrackedNodeInvocation(error, nodeId, ctx, inputsByPort, this.summarizeLlmMessages(messages));
437
+ }
438
+ }
439
+
329
440
  private async markQueuedTools(
330
441
  plannedToolCalls: ReadonlyArray<PlannedToolCall>,
331
442
  ctx: NodeExecutionContext<AIAgent<any, any>>,
@@ -514,6 +625,20 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
514
625
  execute: async (args) => await this.nodeBackedToolRuntime.execute(config, args),
515
626
  };
516
627
  }
628
+ if (this.isCallableToolConfig(config)) {
629
+ const inputSchema = config.getInputSchema();
630
+ if (inputSchema == null) {
631
+ throw new Error(
632
+ `AIAgent tool "${config.name}": callable tool is missing inputSchema (cannot build LangChain tool).`,
633
+ );
634
+ }
635
+ return {
636
+ defaultDescription: config.description ?? `Callable tool "${config.name}".`,
637
+ inputSchema,
638
+ execute: async (args) =>
639
+ await config.executeTool({ ...args, config: config as CallableToolConfig<ZodSchemaAny, ZodSchemaAny> }),
640
+ };
641
+ }
517
642
  const tool = this.nodeResolver.resolve(config.type) as Tool<ToolConfig, ZodSchemaAny, ZodSchemaAny>;
518
643
  if (tool.inputSchema == null) {
519
644
  throw new Error(`AIAgent tool "${config.name}": plugin tool "${String(config.type)}" is missing inputSchema.`);
@@ -537,6 +662,16 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
537
662
  );
538
663
  }
539
664
 
665
+ /**
666
+ * Callable tools use {@link CallableToolConfig#toolKind} for cross-package / JSON round-trip safety.
667
+ */
668
+ private isCallableToolConfig(config: ToolConfig): config is CallableToolConfig<ZodSchemaAny, ZodSchemaAny> {
669
+ return (
670
+ config instanceof CallableToolConfig ||
671
+ (typeof config === "object" && config !== null && (config as { toolKind?: unknown }).toolKind === "callable")
672
+ );
673
+ }
674
+
540
675
  private resolveGuardrails(guardrails: AgentGuardrailConfig | undefined): ResolvedGuardrails {
541
676
  const maxTurns = guardrails?.maxTurns ?? AgentGuardrailDefaults.maxTurns;
542
677
  if (!Number.isInteger(maxTurns) || maxTurns < 1) {
@@ -548,4 +683,8 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
548
683
  modelInvocationOptions: guardrails?.modelInvocationOptions,
549
684
  };
550
685
  }
686
+
687
+ private getAgentDisplayName(ctx: NodeExecutionContext<AIAgent<any, any>>): string {
688
+ return ctx.config.name ?? ctx.nodeId;
689
+ }
551
690
  }
@@ -0,0 +1,61 @@
1
+ import type { AgentMessageDto, ZodSchemaAny } from "@codemation/core";
2
+ import { inject, injectable } from "@codemation/core";
3
+
4
+ import { AIAgentExecutionHelpersFactory } from "./AIAgentExecutionHelpersFactory";
5
+
6
+ @injectable()
7
+ export class AgentStructuredOutputRepairPromptFactory {
8
+ private static readonly maxSchemaLength = 8000;
9
+ private static readonly maxInvalidContentLength = 4000;
10
+ private static readonly maxValidationErrorLength = 4000;
11
+
12
+ constructor(
13
+ @inject(AIAgentExecutionHelpersFactory)
14
+ private readonly executionHelpers: AIAgentExecutionHelpersFactory,
15
+ ) {}
16
+
17
+ create(
18
+ args: Readonly<{
19
+ schema: ZodSchemaAny;
20
+ invalidContent: string;
21
+ validationError: string;
22
+ }>,
23
+ ): ReadonlyArray<AgentMessageDto> {
24
+ return [
25
+ {
26
+ role: "system",
27
+ content:
28
+ "Return only JSON that matches the required schema exactly. Do not include markdown fences, commentary, or prose.",
29
+ },
30
+ {
31
+ role: "user",
32
+ content: JSON.stringify({
33
+ requiredSchema: this.truncate(
34
+ JSON.stringify(
35
+ this.executionHelpers.createJsonSchemaRecord(args.schema, {
36
+ schemaName: "agent_output",
37
+ requireObjectRoot: false,
38
+ }),
39
+ ),
40
+ AgentStructuredOutputRepairPromptFactory.maxSchemaLength,
41
+ ),
42
+ invalidModelOutput: this.truncate(
43
+ args.invalidContent,
44
+ AgentStructuredOutputRepairPromptFactory.maxInvalidContentLength,
45
+ ),
46
+ validationError: this.truncate(
47
+ args.validationError,
48
+ AgentStructuredOutputRepairPromptFactory.maxValidationErrorLength,
49
+ ),
50
+ }),
51
+ },
52
+ ];
53
+ }
54
+
55
+ private truncate(value: string, maxLength: number): string {
56
+ if (value.length <= maxLength) {
57
+ return value;
58
+ }
59
+ return `${value.slice(0, maxLength)}...(truncated)`;
60
+ }
61
+ }
@@ -0,0 +1,222 @@
1
+ import type {
2
+ ChatModelConfig,
3
+ ChatModelStructuredOutputOptions,
4
+ LangChainChatModelLike,
5
+ LangChainStructuredOutputModelLike,
6
+ ZodSchemaAny,
7
+ } from "@codemation/core";
8
+ import { inject, injectable } from "@codemation/core";
9
+
10
+ import { AIMessage, type BaseMessage } from "@langchain/core/messages";
11
+ import { ZodError } from "zod";
12
+
13
+ import { OpenAIStructuredOutputMethodFactory } from "../chatModels/OpenAIStructuredOutputMethodFactory";
14
+ import { AgentMessageFactory } from "./AgentMessageFactory";
15
+ import { AgentStructuredOutputRepairPromptFactory } from "./AgentStructuredOutputRepairPromptFactory";
16
+
17
+ interface ParsedStructuredOutputSuccess<TValue> {
18
+ readonly ok: true;
19
+ readonly value: TValue;
20
+ }
21
+
22
+ interface ParsedStructuredOutputFailure {
23
+ readonly ok: false;
24
+ readonly invalidContent: string;
25
+ readonly validationError: string;
26
+ }
27
+
28
+ type ParsedStructuredOutputResult<TValue> = ParsedStructuredOutputSuccess<TValue> | ParsedStructuredOutputFailure;
29
+
30
+ @injectable()
31
+ export class AgentStructuredOutputRunner {
32
+ private static readonly repairAttemptCount = 2;
33
+
34
+ constructor(
35
+ @inject(AgentStructuredOutputRepairPromptFactory)
36
+ private readonly repairPromptFactory: AgentStructuredOutputRepairPromptFactory,
37
+ @inject(OpenAIStructuredOutputMethodFactory)
38
+ private readonly openAiStructuredOutputMethodFactory: OpenAIStructuredOutputMethodFactory,
39
+ ) {}
40
+
41
+ async resolve<TOutput>(
42
+ args: Readonly<{
43
+ model: LangChainChatModelLike;
44
+ chatModelConfig: ChatModelConfig;
45
+ schema: ZodSchemaAny;
46
+ conversation: ReadonlyArray<BaseMessage>;
47
+ rawFinalResponse?: AIMessage;
48
+ agentName: string;
49
+ nodeId: string;
50
+ invokeTextModel: (messages: ReadonlyArray<BaseMessage>) => Promise<AIMessage>;
51
+ invokeStructuredModel: (
52
+ model: LangChainStructuredOutputModelLike,
53
+ messages: ReadonlyArray<BaseMessage>,
54
+ ) => Promise<unknown>;
55
+ }>,
56
+ ): Promise<TOutput> {
57
+ let lastFailure: ParsedStructuredOutputFailure | undefined;
58
+
59
+ if (args.rawFinalResponse) {
60
+ const directResult = this.tryParseAndValidate<TOutput>(
61
+ AgentMessageFactory.extractContent(args.rawFinalResponse),
62
+ args.schema,
63
+ );
64
+ if (directResult.ok) {
65
+ return directResult.value;
66
+ }
67
+ lastFailure = directResult;
68
+ } else if (!this.supportsNativeStructuredOutput(args.model)) {
69
+ const rawResponse = await args.invokeTextModel(args.conversation);
70
+ const directResult = this.tryParseAndValidate<TOutput>(
71
+ AgentMessageFactory.extractContent(rawResponse),
72
+ args.schema,
73
+ );
74
+ if (directResult.ok) {
75
+ return directResult.value;
76
+ }
77
+ lastFailure = directResult;
78
+ }
79
+
80
+ try {
81
+ const nativeStructuredModel = this.createStructuredOutputModel(args.model, args.chatModelConfig, args.schema);
82
+ if (nativeStructuredModel) {
83
+ const nativeResult = this.tryValidateStructuredValue<TOutput>(
84
+ await args.invokeStructuredModel(nativeStructuredModel, args.conversation),
85
+ args.schema,
86
+ );
87
+ if (nativeResult.ok) {
88
+ return nativeResult.value;
89
+ }
90
+ lastFailure = nativeResult;
91
+ }
92
+ } catch (error) {
93
+ lastFailure = {
94
+ ok: false,
95
+ invalidContent: "",
96
+ validationError: `Native structured output failed: ${this.summarizeError(error)}`,
97
+ };
98
+ }
99
+
100
+ return await this.retryWithRepairPrompt<TOutput>({
101
+ ...args,
102
+ lastFailure:
103
+ lastFailure ??
104
+ ({
105
+ ok: false,
106
+ invalidContent: "",
107
+ validationError: "Structured output was required but no valid structured response was produced.",
108
+ } satisfies ParsedStructuredOutputFailure),
109
+ });
110
+ }
111
+
112
+ private async retryWithRepairPrompt<TOutput>(
113
+ args: Readonly<{
114
+ schema: ZodSchemaAny;
115
+ conversation: ReadonlyArray<BaseMessage>;
116
+ lastFailure: ParsedStructuredOutputFailure;
117
+ agentName: string;
118
+ nodeId: string;
119
+ invokeTextModel: (messages: ReadonlyArray<BaseMessage>) => Promise<AIMessage>;
120
+ }>,
121
+ ): Promise<TOutput> {
122
+ let failure = args.lastFailure;
123
+ for (let attempt = 1; attempt <= AgentStructuredOutputRunner.repairAttemptCount; attempt++) {
124
+ const repairMessages = [
125
+ ...args.conversation,
126
+ ...AgentMessageFactory.createPromptMessages(
127
+ this.repairPromptFactory.create({
128
+ schema: args.schema,
129
+ invalidContent: failure.invalidContent,
130
+ validationError: failure.validationError,
131
+ }),
132
+ ),
133
+ ];
134
+ const repairResponse = await args.invokeTextModel(repairMessages);
135
+ const repairResult = this.tryParseAndValidate<TOutput>(
136
+ AgentMessageFactory.extractContent(repairResponse),
137
+ args.schema,
138
+ );
139
+ if (repairResult.ok) {
140
+ return repairResult.value;
141
+ }
142
+ failure = repairResult;
143
+ }
144
+ throw new Error(
145
+ `Structured output required for AIAgent "${args.agentName}" (${args.nodeId}) but validation still failed after ${AgentStructuredOutputRunner.repairAttemptCount} repair attempts: ${failure.validationError}`,
146
+ );
147
+ }
148
+
149
+ private createStructuredOutputModel(
150
+ model: LangChainChatModelLike,
151
+ chatModelConfig: ChatModelConfig,
152
+ schema: ZodSchemaAny,
153
+ ): LangChainStructuredOutputModelLike | undefined {
154
+ if (!this.supportsNativeStructuredOutput(model)) {
155
+ return undefined;
156
+ }
157
+ const options = this.getStructuredOutputOptions(chatModelConfig);
158
+ return model.withStructuredOutput(schema, options);
159
+ }
160
+
161
+ private getStructuredOutputOptions(chatModelConfig: ChatModelConfig): ChatModelStructuredOutputOptions | undefined {
162
+ return this.openAiStructuredOutputMethodFactory.create(chatModelConfig) ?? { strict: true };
163
+ }
164
+
165
+ private supportsNativeStructuredOutput(model: LangChainChatModelLike): model is LangChainChatModelLike & {
166
+ withStructuredOutput: (
167
+ outputSchema: ZodSchemaAny,
168
+ config?: ChatModelStructuredOutputOptions,
169
+ ) => LangChainStructuredOutputModelLike;
170
+ } {
171
+ return typeof model.withStructuredOutput === "function";
172
+ }
173
+
174
+ private tryParseAndValidate<TOutput>(content: string, schema: ZodSchemaAny): ParsedStructuredOutputResult<TOutput> {
175
+ try {
176
+ return this.tryValidateStructuredValue<TOutput>(JSON.parse(content) as unknown, schema, content);
177
+ } catch (error) {
178
+ return {
179
+ ok: false,
180
+ invalidContent: content,
181
+ validationError: `Response was not valid JSON: ${this.summarizeError(error)}`,
182
+ };
183
+ }
184
+ }
185
+
186
+ private tryValidateStructuredValue<TOutput>(
187
+ value: unknown,
188
+ schema: ZodSchemaAny,
189
+ invalidContent?: string,
190
+ ): ParsedStructuredOutputResult<TOutput> {
191
+ try {
192
+ return {
193
+ ok: true,
194
+ value: schema.parse(value) as TOutput,
195
+ };
196
+ } catch (error) {
197
+ return {
198
+ ok: false,
199
+ invalidContent: invalidContent ?? this.toJson(value),
200
+ validationError: this.summarizeError(error),
201
+ };
202
+ }
203
+ }
204
+
205
+ private summarizeError(error: unknown): string {
206
+ if (error instanceof ZodError) {
207
+ return error.issues.map((issue) => `${issue.path.join(".") || "<root>"}: ${issue.message}`).join("; ");
208
+ }
209
+ if (error instanceof Error) {
210
+ return error.message;
211
+ }
212
+ return String(error);
213
+ }
214
+
215
+ private toJson(value: unknown): string {
216
+ try {
217
+ return JSON.stringify(value);
218
+ } catch (error) {
219
+ return `<<unserializable: ${this.summarizeError(error)}>>`;
220
+ }
221
+ }
222
+ }