@codemation/core-nodes 0.4.3 → 0.7.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.
Files changed (55) hide show
  1. package/CHANGELOG.md +237 -0
  2. package/dist/index.cjs +3541 -470
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +1843 -685
  5. package/dist/index.d.ts +1843 -685
  6. package/dist/index.js +3498 -465
  7. package/dist/index.js.map +1 -1
  8. package/package.json +8 -5
  9. package/src/authoring/defineRestNode.types.ts +204 -0
  10. package/src/chatModels/OpenAIChatModelFactory.ts +17 -8
  11. package/src/chatModels/OpenAiStrictJsonSchemaFactory.ts +123 -0
  12. package/src/credentials/ApiKeyCredentialType.ts +60 -0
  13. package/src/credentials/BasicAuthCredentialType.ts +51 -0
  14. package/src/credentials/BearerTokenCredentialType.ts +40 -0
  15. package/src/credentials/OAuth2ClientCredentialsTypeFactory.ts +117 -0
  16. package/src/credentials/OAuth2TokenExchangeFactory.ts +52 -0
  17. package/src/credentials/index.ts +4 -0
  18. package/src/http/HttpBodyBuilder.ts +118 -0
  19. package/src/http/HttpRequestExecutor.ts +153 -0
  20. package/src/http/HttpUrlBuilder.ts +22 -0
  21. package/src/http/httpRequest.types.ts +96 -0
  22. package/src/index.ts +10 -1
  23. package/src/nodes/AIAgentExecutionHelpersFactory.ts +45 -59
  24. package/src/nodes/AIAgentNode.ts +391 -288
  25. package/src/nodes/AgentMessageFactory.ts +57 -49
  26. package/src/nodes/AgentStructuredOutputRunner.ts +65 -71
  27. package/src/nodes/AgentToolExecutionCoordinator.ts +31 -16
  28. package/src/nodes/AssertionNode.ts +42 -0
  29. package/src/nodes/CronTriggerFactory.ts +45 -0
  30. package/src/nodes/CronTriggerNode.ts +40 -0
  31. package/src/nodes/HttpRequestNodeFactory.ts +160 -16
  32. package/src/nodes/IsTestRunNode.ts +25 -0
  33. package/src/nodes/NodeBackedToolRuntime.ts +40 -4
  34. package/src/nodes/TestTriggerNode.ts +33 -0
  35. package/src/nodes/WebhookTriggerFactory.ts +1 -1
  36. package/src/nodes/aggregate.ts +1 -1
  37. package/src/nodes/aiAgentSupport.types.ts +22 -2
  38. package/src/nodes/assertion.ts +42 -0
  39. package/src/nodes/collections/collectionDeleteNode.types.ts +23 -0
  40. package/src/nodes/collections/collectionFindOneNode.types.ts +26 -0
  41. package/src/nodes/collections/collectionGetNode.types.ts +26 -0
  42. package/src/nodes/collections/collectionInsertNode.types.ts +22 -0
  43. package/src/nodes/collections/collectionListNode.types.ts +30 -0
  44. package/src/nodes/collections/collectionUpdateNode.types.ts +23 -0
  45. package/src/nodes/collections/index.ts +6 -0
  46. package/src/nodes/httpRequest.ts +106 -1
  47. package/src/nodes/if.ts +1 -1
  48. package/src/nodes/isTestRun.ts +24 -0
  49. package/src/nodes/mapData.ts +1 -0
  50. package/src/nodes/merge.ts +1 -1
  51. package/src/nodes/noOp.ts +1 -0
  52. package/src/nodes/split.ts +1 -1
  53. package/src/nodes/testTrigger.ts +72 -0
  54. package/src/nodes/wait.ts +1 -0
  55. package/src/chatModels/OpenAIStructuredOutputMethodFactory.ts +0 -46
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Performs an OAuth2 `client_credentials` token exchange against a token endpoint
3
+ * and returns the resulting access token.
4
+ *
5
+ * Lives in a Factory file so the body URLSearchParams construction is allowed at
6
+ * the composition root.
7
+ */
8
+ export type OAuth2ClientCredentialsArgs = Readonly<{
9
+ tokenUrl: string;
10
+ clientId: string;
11
+ clientSecret: string;
12
+ scopes: string;
13
+ audience: string;
14
+ }>;
15
+
16
+ export class OAuth2TokenExchangeFactory {
17
+ async create(args: OAuth2ClientCredentialsArgs): Promise<string> {
18
+ const body = new URLSearchParams({
19
+ grant_type: "client_credentials",
20
+ client_id: args.clientId,
21
+ });
22
+ if (args.scopes) {
23
+ body.set("scope", args.scopes);
24
+ }
25
+ if (args.audience) {
26
+ body.set("audience", args.audience);
27
+ }
28
+
29
+ const encoded = Buffer.from(`${args.clientId}:${args.clientSecret}`).toString("base64");
30
+
31
+ const response = await globalThis.fetch(args.tokenUrl, {
32
+ method: "POST",
33
+ headers: {
34
+ "content-type": "application/x-www-form-urlencoded",
35
+ authorization: `Basic ${encoded}`,
36
+ },
37
+ body: body.toString(),
38
+ });
39
+
40
+ if (!response.ok) {
41
+ const text = await response.text().catch(() => "");
42
+ throw new Error(`Token exchange failed (${response.status} ${response.statusText}): ${text}`);
43
+ }
44
+
45
+ const json = (await response.json()) as Record<string, unknown>;
46
+ const token = String(json["access_token"] ?? "");
47
+ if (!token) {
48
+ throw new Error("Token exchange response did not include an access_token.");
49
+ }
50
+ return token;
51
+ }
52
+ }
@@ -0,0 +1,4 @@
1
+ export { apiKeyCredentialType } from "./ApiKeyCredentialType";
2
+ export { basicAuthCredentialType } from "./BasicAuthCredentialType";
3
+ export { bearerTokenCredentialType } from "./BearerTokenCredentialType";
4
+ export { oauth2ClientCredentialsType } from "./OAuth2ClientCredentialsTypeFactory";
@@ -0,0 +1,118 @@
1
+ import type { ReadableStream as NodeReadableStream } from "node:stream/web";
2
+
3
+ import type { Item, NodeExecutionContext } from "@codemation/core";
4
+ import type { RunnableNodeConfig } from "@codemation/core";
5
+ import type { HttpBodySpec } from "./httpRequest.types";
6
+
7
+ export type EncodedBody = Readonly<{
8
+ body: NonNullable<RequestInit["body"]>;
9
+ /**
10
+ * Desired Content-Type header. Empty string means `fetch` should set it automatically
11
+ * (used for multipart/form-data so the boundary is set correctly by the browser/Node runtime).
12
+ */
13
+ contentType: string;
14
+ }>;
15
+
16
+ /**
17
+ * Builds a fetch-compatible `BodyInit` + Content-Type pair from an {@link HttpBodySpec}.
18
+ * Multipart binaries are read from `item.binary` via `ctx.binary.openReadStream`.
19
+ */
20
+ export class HttpBodyBuilder {
21
+ async build(
22
+ spec: HttpBodySpec | undefined,
23
+ item: Item,
24
+ ctx: NodeExecutionContext<RunnableNodeConfig<unknown, unknown>>,
25
+ ): Promise<EncodedBody | undefined> {
26
+ if (!spec || spec.kind === "none") {
27
+ return undefined;
28
+ }
29
+
30
+ if (spec.kind === "json") {
31
+ return {
32
+ body: JSON.stringify(spec.data),
33
+ contentType: "application/json",
34
+ };
35
+ }
36
+
37
+ if (spec.kind === "form") {
38
+ const params = new URLSearchParams();
39
+ for (const [key, value] of Object.entries(spec.data)) {
40
+ params.append(key, value);
41
+ }
42
+ return {
43
+ body: params.toString(),
44
+ contentType: "application/x-www-form-urlencoded",
45
+ };
46
+ }
47
+
48
+ if (spec.kind === "multipart") {
49
+ const formData = new FormData();
50
+ for (const [key, value] of Object.entries(spec.fields)) {
51
+ formData.append(key, value);
52
+ }
53
+ if (spec.binaries) {
54
+ for (const [fieldName, binaryRef] of Object.entries(spec.binaries)) {
55
+ const attachment = item.binary?.[binaryRef];
56
+ if (attachment) {
57
+ const readResult = await ctx.binary.openReadStream(attachment);
58
+ if (readResult) {
59
+ const merged = await this.readStreamToBuffer(readResult.body);
60
+ const blob = new Blob([merged], { type: attachment.mimeType });
61
+ formData.append(fieldName, blob, attachment.filename ?? binaryRef);
62
+ }
63
+ }
64
+ }
65
+ }
66
+ // FormData sets its own Content-Type with boundary; empty string signals that
67
+ // fetch should set it automatically.
68
+ return {
69
+ body: formData,
70
+ contentType: "",
71
+ };
72
+ }
73
+
74
+ if (spec.kind === "binary") {
75
+ const attachment = item.binary?.[spec.slot];
76
+ if (!attachment) {
77
+ throw new Error(
78
+ `HttpRequest bodyFormat "binary": no binary attachment found at slot "${spec.slot}". ` +
79
+ `Ensure a previous node attached binary data at that slot.`,
80
+ );
81
+ }
82
+ const readResult = await ctx.binary.openReadStream(attachment);
83
+ if (!readResult) {
84
+ throw new Error(`HttpRequest bodyFormat "binary": could not open read stream for slot "${spec.slot}".`);
85
+ }
86
+ // Pass the stream straight to fetch — no buffering. fetch's BodyInit
87
+ // accepts a ReadableStream natively, so big attachments upload without
88
+ // ever materialising the full payload in memory.
89
+ return {
90
+ body: readResult.body as unknown as NonNullable<RequestInit["body"]>,
91
+ contentType: attachment.mimeType,
92
+ };
93
+ }
94
+
95
+ return undefined;
96
+ }
97
+
98
+ private async readStreamToBuffer(stream: NodeReadableStream<Uint8Array>): Promise<Uint8Array<ArrayBuffer>> {
99
+ const reader = stream.getReader();
100
+ const chunks: Uint8Array[] = [];
101
+ let done = false;
102
+ while (!done) {
103
+ const result = await reader.read();
104
+ done = result.done;
105
+ if (result.value) {
106
+ chunks.push(result.value);
107
+ }
108
+ }
109
+ const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0);
110
+ const merged = new Uint8Array(new ArrayBuffer(totalLength));
111
+ let offset = 0;
112
+ for (const chunk of chunks) {
113
+ merged.set(chunk, offset);
114
+ offset += chunk.length;
115
+ }
116
+ return merged;
117
+ }
118
+ }
@@ -0,0 +1,153 @@
1
+ import type { Item } from "@codemation/core";
2
+ import type { HttpRequestResult, HttpRequestSpec } from "./httpRequest.types";
3
+ import type { HttpBodyBuilder } from "./HttpBodyBuilder";
4
+ import type { HttpUrlBuilder } from "./HttpUrlBuilder";
5
+
6
+ /**
7
+ * Executes a single HTTP request described by {@link HttpRequestSpec}.
8
+ *
9
+ * - Credential sessions provide header/query deltas via `applyToRequest`.
10
+ * - Body encoding is delegated to {@link HttpBodyBuilder}.
11
+ * - URL query merging is delegated to {@link HttpUrlBuilder}.
12
+ * - Binary response bodies: when `download.mode` triggers binary attach, the
13
+ * `bodyBinaryName` field is set in the result but the body is NOT read here.
14
+ * Callers that need binary attachment should use `buildRequest` to get the
15
+ * resolved URL + init and make the fetch + binary attach themselves.
16
+ *
17
+ * Collaborators (`fetch`, body builder, url builder) are injected so callers
18
+ * own construction at composition roots and tests can supply deterministic stubs.
19
+ */
20
+ export class HttpRequestExecutor {
21
+ constructor(
22
+ private readonly fetchFn: typeof globalThis.fetch,
23
+ private readonly bodyBuilder: HttpBodyBuilder,
24
+ private readonly urlBuilder: HttpUrlBuilder,
25
+ ) {}
26
+
27
+ /**
28
+ * Builds the fetch init (headers, query, body) from the spec + credential delta,
29
+ * returning both the resolved URL and the RequestInit so callers can make the
30
+ * actual fetch call themselves (useful for streaming / binary attach).
31
+ */
32
+ async buildRequest(spec: HttpRequestSpec, item: Item): Promise<Readonly<{ url: string; init: RequestInit }>> {
33
+ const credentialDelta = spec.credential?.applyToRequest(spec) ?? {};
34
+
35
+ const mergedHeaders: Record<string, string> = {
36
+ ...(spec.headers ?? {}),
37
+ ...(credentialDelta.headers ?? {}),
38
+ };
39
+
40
+ const mergedQuery: Record<string, string | string[]> = {
41
+ ...(spec.query ?? {}),
42
+ ...(credentialDelta.query ?? {}),
43
+ };
44
+
45
+ const encodedBody = await this.bodyBuilder.build(spec.body, item, spec.ctx);
46
+
47
+ // Only set Content-Type from the encoded body when it is non-empty
48
+ // (empty string = FormData will set it automatically).
49
+ // Explicit headers always win — only apply the body-derived content-type
50
+ // when the caller has not already set one (case-insensitive check).
51
+ const hasExplicitContentType = Object.keys(mergedHeaders).some((k) => k.toLowerCase() === "content-type");
52
+ if (encodedBody && encodedBody.contentType && !hasExplicitContentType) {
53
+ mergedHeaders["content-type"] = encodedBody.contentType;
54
+ }
55
+
56
+ const resolvedUrl = this.urlBuilder.build(spec.url, mergedQuery);
57
+
58
+ const init: RequestInit = {
59
+ method: spec.method,
60
+ headers: mergedHeaders,
61
+ ...(encodedBody ? { body: encodedBody.body } : {}),
62
+ };
63
+
64
+ return { url: resolvedUrl, init };
65
+ }
66
+
67
+ /**
68
+ * Executes an HTTP request and returns parsed result.
69
+ * For binary downloads (when `shouldAttachBody` is true), the body is NOT consumed
70
+ * and callers must call `ctx.binary.attach` directly using the resolved URL + init
71
+ * (available via `buildRequest`).
72
+ */
73
+ async execute(spec: HttpRequestSpec, item: Item): Promise<HttpRequestResult> {
74
+ const { url: resolvedUrl, init } = await this.buildRequest(spec, item);
75
+
76
+ const response = await this.fetchFn(resolvedUrl, init);
77
+
78
+ const responseHeaders = this.readHeaders(response.headers);
79
+ const mimeType = this.resolveMimeType(responseHeaders);
80
+
81
+ const downloadMode = spec.download?.mode ?? "auto";
82
+ const binaryName = spec.download?.binaryName ?? "body";
83
+ const shouldDownload = this.shouldAttachBody(downloadMode, mimeType);
84
+
85
+ const isJson = this.isJsonMimeType(mimeType);
86
+
87
+ let json: unknown | undefined;
88
+ let text: string | undefined;
89
+ let bodyBinaryName: string | undefined;
90
+
91
+ if (shouldDownload) {
92
+ // Signal to caller that binary attachment is needed.
93
+ bodyBinaryName = binaryName;
94
+ // Do NOT read the body here — the caller must handle binary attach separately.
95
+ } else if (isJson) {
96
+ try {
97
+ json = await response.json();
98
+ } catch {
99
+ text = await response.text();
100
+ }
101
+ } else {
102
+ text = await response.text();
103
+ }
104
+
105
+ return {
106
+ url: resolvedUrl,
107
+ method: spec.method.toUpperCase(),
108
+ status: response.status,
109
+ ok: response.ok,
110
+ statusText: response.statusText,
111
+ mimeType,
112
+ headers: responseHeaders,
113
+ ...(json !== undefined ? { json } : {}),
114
+ ...(text !== undefined ? { text } : {}),
115
+ ...(bodyBinaryName !== undefined ? { bodyBinaryName } : {}),
116
+ };
117
+ }
118
+
119
+ private readHeaders(headers: Headers): Readonly<Record<string, string>> {
120
+ const values: Record<string, string> = {};
121
+ headers.forEach((value, key) => {
122
+ values[key] = value;
123
+ });
124
+ return values;
125
+ }
126
+
127
+ private resolveMimeType(headers: Readonly<Record<string, string>>): string {
128
+ const contentType = headers["content-type"];
129
+ if (!contentType) {
130
+ return "application/octet-stream";
131
+ }
132
+ return contentType.split(";")[0]?.trim() || "application/octet-stream";
133
+ }
134
+
135
+ private isJsonMimeType(mimeType: string): boolean {
136
+ return mimeType === "application/json" || mimeType.endsWith("+json");
137
+ }
138
+
139
+ private shouldAttachBody(mode: "auto" | "always" | "never", mimeType: string): boolean {
140
+ if (mode === "always") {
141
+ return true;
142
+ }
143
+ if (mode === "never") {
144
+ return false;
145
+ }
146
+ return (
147
+ mimeType.startsWith("image/") ||
148
+ mimeType.startsWith("audio/") ||
149
+ mimeType.startsWith("video/") ||
150
+ mimeType === "application/pdf"
151
+ );
152
+ }
153
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Merges query parameters into a base URL.
3
+ * Handles both scalar and array values, and preserves any existing params.
4
+ */
5
+ export class HttpUrlBuilder {
6
+ build(baseUrl: string, query?: Readonly<Record<string, string | string[]>>): string {
7
+ if (!query || Object.keys(query).length === 0) {
8
+ return baseUrl;
9
+ }
10
+ const parsed = new URL(baseUrl);
11
+ for (const [key, value] of Object.entries(query)) {
12
+ if (Array.isArray(value)) {
13
+ for (const entry of value) {
14
+ parsed.searchParams.append(key, entry);
15
+ }
16
+ } else {
17
+ parsed.searchParams.append(key, value);
18
+ }
19
+ }
20
+ return parsed.toString();
21
+ }
22
+ }
@@ -0,0 +1,96 @@
1
+ import type { NodeExecutionContext } from "@codemation/core";
2
+ import type { RunnableNodeConfig } from "@codemation/core";
3
+
4
+ /**
5
+ * Binary reference key into `item.binary`.
6
+ */
7
+ export type BinaryRef = string;
8
+
9
+ /**
10
+ * Discriminated union for the HTTP request body.
11
+ */
12
+ export type HttpBodySpec =
13
+ | Readonly<{ kind: "none" }>
14
+ | Readonly<{ kind: "json"; data: unknown }>
15
+ | Readonly<{ kind: "form"; data: Readonly<Record<string, string>> }>
16
+ | Readonly<{
17
+ kind: "multipart";
18
+ fields: Readonly<Record<string, string>>;
19
+ binaries?: Readonly<Record<string, BinaryRef>>;
20
+ }>
21
+ | Readonly<{
22
+ /**
23
+ * Send raw bytes from a binary slot as the request body.
24
+ * The binary attachment's `mimeType` is used as `Content-Type` unless
25
+ * the request `headers` map already contains `content-type`.
26
+ */
27
+ kind: "binary";
28
+ /** Key into `item.binary` to read the request body bytes from. */
29
+ slot: string;
30
+ }>;
31
+
32
+ /**
33
+ * Session interface that credential types implement.
34
+ * Returns header/query deltas so the executor can merge them without
35
+ * mutating the immutable HttpRequestSpec.
36
+ */
37
+ export interface CredentialSession {
38
+ applyToRequest(spec: HttpRequestSpec): HttpCredentialDelta;
39
+ }
40
+
41
+ /**
42
+ * Mutations the credential session wants to apply to the outgoing request.
43
+ */
44
+ export type HttpCredentialDelta = Readonly<{
45
+ headers?: Readonly<Record<string, string>>;
46
+ query?: Readonly<Record<string, string>>;
47
+ }>;
48
+
49
+ /**
50
+ * Full specification of one HTTP request. All URLs are fully resolved before
51
+ * being passed here (template substitution already applied by the caller).
52
+ */
53
+ export type HttpRequestSpec = Readonly<{
54
+ url: string;
55
+ method: string;
56
+ headers?: Readonly<Record<string, string>>;
57
+ query?: Readonly<Record<string, string | string[]>>;
58
+ body?: HttpBodySpec;
59
+ credential?: CredentialSession;
60
+ download?: Readonly<{ mode: "auto" | "always" | "never"; binaryName: string }>;
61
+ /**
62
+ * When set to `"binary"`, the response body is written to a binary slot
63
+ * instead of being parsed as JSON/text. Overrides `download` mode.
64
+ */
65
+ responseFormat?: "json" | "text" | "binary";
66
+ /** Binary slot name for the response body when `responseFormat === "binary"`. Defaults to `"response"`. */
67
+ responseBinarySlot?: string;
68
+ /** Maximum allowed response size in bytes (checked against Content-Length before allocating). Defaults to 100 MiB. */
69
+ responseSizeCapBytes?: number;
70
+ /** Execution context — needed for binary attach. */
71
+ ctx: NodeExecutionContext<RunnableNodeConfig<unknown, unknown>>;
72
+ }>;
73
+
74
+ /**
75
+ * Result of executing an HTTP request.
76
+ */
77
+ export type HttpRequestResult = Readonly<{
78
+ url: string;
79
+ method: string;
80
+ status: number;
81
+ ok: boolean;
82
+ statusText: string;
83
+ mimeType: string;
84
+ headers: Readonly<Record<string, string>>;
85
+ json?: unknown;
86
+ text?: string;
87
+ bodyBinaryName?: string;
88
+ /** Set when `responseFormat === "binary"`. Name of the binary slot the response body was written to. */
89
+ binarySlot?: string;
90
+ /** Set when `responseFormat === "binary"`. The MIME type of the stored response. */
91
+ contentType?: string;
92
+ /** Set when `responseFormat === "binary"`. Size in bytes of the stored response. */
93
+ size?: number;
94
+ /** Set when `responseFormat === "binary"`. Filename inferred from URL or Content-Disposition. */
95
+ filename?: string;
96
+ }>;
package/src/index.ts CHANGED
@@ -1,22 +1,30 @@
1
1
  export * from "./canvasIconName";
2
+ export * from "./credentials/index";
3
+ export * from "./http/httpRequest.types";
4
+ export * from "./authoring/defineRestNode.types";
2
5
  export * from "./chatModels/OpenAIChatModelFactory";
3
- export * from "./chatModels/OpenAIStructuredOutputMethodFactory";
6
+ export * from "./chatModels/OpenAiStrictJsonSchemaFactory";
4
7
  export * from "./chatModels/OpenAiCredentialSession";
5
8
  export * from "./chatModels/openAiChatModelConfig";
6
9
  export * from "./chatModels/OpenAiChatModelPresetsFactory";
7
10
  export * from "./nodes/aiAgent";
11
+ export * from "./nodes/assertion";
8
12
  export * from "./nodes/CallbackNodeFactory";
9
13
  export * from "./nodes/httpRequest";
10
14
  export * from "./nodes/aggregate";
11
15
  export * from "./nodes/filter";
12
16
  export * from "./nodes/if";
17
+ export * from "./nodes/isTestRun";
13
18
  export * from "./nodes/switch";
14
19
  export * from "./nodes/split";
20
+ export * from "./nodes/CronTriggerFactory";
21
+ export * from "./nodes/CronTriggerNode";
15
22
  export * from "./nodes/ManualTriggerFactory";
16
23
  export * from "./nodes/mapData";
17
24
  export * from "./nodes/merge";
18
25
  export * from "./nodes/noOp";
19
26
  export * from "./nodes/subWorkflow";
27
+ export * from "./nodes/testTrigger";
20
28
  export * from "./nodes/wait";
21
29
  export * from "./nodes/webhookRespondNowAndContinueError";
22
30
  export * from "./nodes/webhookRespondNowError";
@@ -30,3 +38,4 @@ export * from "./nodes/ConnectionCredentialNode";
30
38
  export * from "./nodes/ConnectionCredentialNodeConfig";
31
39
  export * from "./nodes/ConnectionCredentialNodeConfigFactory";
32
40
  export * from "./nodes/ConnectionCredentialExecutionContextFactory";
41
+ export * from "./nodes/collections";
@@ -1,17 +1,30 @@
1
- import type { CredentialSessionService, Item, Items, NodeExecutionContext, ZodSchemaAny } from "@codemation/core";
1
+ import type { CredentialSessionService, ZodSchemaAny } from "@codemation/core";
2
2
  import { injectable } from "@codemation/core";
3
3
 
4
- import { isInteropZodSchema } from "@langchain/core/utils/types";
5
- import { toJsonSchema } from "@langchain/core/utils/json_schema";
6
- import { DynamicStructuredTool } from "@langchain/core/tools";
7
- import { toJSONSchema } from "zod/v4/core";
4
+ import { toJSONSchema as frameworkToJSONSchema } from "zod/v4/core";
8
5
 
9
6
  import { ConnectionCredentialExecutionContextFactory } from "./ConnectionCredentialExecutionContextFactory";
10
- import type { ResolvedTool } from "./aiAgentSupport.types";
11
7
 
12
8
  /**
13
- * LangChain adapters and credential context wiring for {@link AIAgentNode}.
14
- * Lives in a `*Factory.ts` composition-root module so construction stays explicit and testable.
9
+ * Shape of the instance-level `toJSONSchema` method that Zod v4 schemas expose. Conversions must go
10
+ * through this instance method (see {@link AIAgentExecutionHelpersFactory#createJsonSchemaRecord})
11
+ * rather than the module-level `toJSONSchema` import because the consumer's workflow-loader (see
12
+ * `CodemationConsumerConfigLoader.toNamespace`) can load Zod under a separate tsx namespace. That
13
+ * produces two runtime copies of Zod whose internal class / symbol identities don't overlap, so the
14
+ * framework-side module-level `toJSONSchema` throws "Cannot read properties of undefined (reading
15
+ * 'def')" on consumer-created schemas. The instance method is bound inside the schema's own module
16
+ * and therefore uses the matching Zod internals.
17
+ */
18
+ type ZodInstanceToJsonSchema = (params?: Readonly<{ target: "draft-07" | "draft-7" | "draft-2020-12" }>) => unknown;
19
+
20
+ /**
21
+ * Helper utilities shared by {@link AIAgentNode} and supporting runners.
22
+ *
23
+ * Responsibilities:
24
+ * - {@link #createConnectionCredentialExecutionContextFactory} centralizes credential-context wiring.
25
+ * - {@link #createJsonSchemaRecord} is a pure Zod → draft-07 converter used by both
26
+ * `OpenAiStrictJsonSchemaFactory` (to feed OpenAI-strict structured output) and the
27
+ * `AgentStructuredOutputRepairPromptFactory` (to show a required-schema reminder).
15
28
  */
16
29
  @injectable()
17
30
  export class AIAgentExecutionHelpersFactory {
@@ -21,47 +34,14 @@ export class AIAgentExecutionHelpersFactory {
21
34
  return new ConnectionCredentialExecutionContextFactory(credentialSessions);
22
35
  }
23
36
 
24
- createDynamicStructuredTool(
25
- entry: ResolvedTool,
26
- toolCredentialContext: NodeExecutionContext<any>,
27
- item: Item,
28
- itemIndex: number,
29
- items: Items,
30
- ): DynamicStructuredTool {
31
- if (entry.runtime.inputSchema == null) {
32
- throw new Error(
33
- `Cannot create LangChain tool "${entry.config.name}": missing inputSchema (broken tool runtime resolution).`,
34
- );
35
- }
36
- const schemaForOpenAi = this.createJsonSchemaRecord(entry.runtime.inputSchema, {
37
- schemaName: entry.config.name,
38
- requireObjectRoot: true,
39
- });
40
- return new DynamicStructuredTool({
41
- name: entry.config.name,
42
- description: entry.config.description ?? entry.runtime.defaultDescription,
43
- schema: schemaForOpenAi as unknown as ZodSchemaAny,
44
- func: async (input) => {
45
- const result = await entry.runtime.execute({
46
- config: entry.config,
47
- input,
48
- ctx: toolCredentialContext,
49
- item,
50
- itemIndex,
51
- items,
52
- });
53
- return JSON.stringify(result);
54
- },
55
- });
56
- }
57
-
58
37
  /**
59
- * Produces a plain JSON Schema object for OpenAI tool parameters and LangChain tool invocation:
60
- * - **Zod** `toJSONSchema(..., { target: "draft-07" })` so shapes match what `@cfworker/json-schema`
61
- * expects (`required` must be an array; draft 2020-12 output can break validation).
62
- * - Otherwise LangChain `toJsonSchema` (Standard Schema + JSON passthrough); if the result is still Zod
63
- * (duplicate `zod` copies), fall back to Zod `toJSONSchema` with draft-07.
64
- * - Strip root `$schema` for OpenAI; normalize invalid `required` keywords for cfworker; ensure `properties`.
38
+ * Produces a plain JSON Schema object (`draft-07`) from a Zod schema, as needed by
39
+ * OpenAI tool-parameter schemas and the structured-output repair prompt.
40
+ * - Prefers the schema's **instance** `toJSONSchema(...)` method so we stay inside the Zod
41
+ * instance that created the schema (works across consumer/framework tsx namespaces see
42
+ * {@link ZodInstanceToJsonSchema}). Falls back to the framework-imported module function.
43
+ * - Strips root `$schema` (OpenAI ignores it).
44
+ * - Sanitizes `required` for cfworker json-schema compatibility (must be a string array or absent).
65
45
  */
66
46
  createJsonSchemaRecord(
67
47
  inputSchema: ZodSchemaAny,
@@ -71,20 +51,12 @@ export class AIAgentExecutionHelpersFactory {
71
51
  }>,
72
52
  ): Record<string, unknown> {
73
53
  const draft07Params = { target: "draft-07" as const };
74
- let converted: unknown;
75
- if (isInteropZodSchema(inputSchema)) {
76
- converted = toJSONSchema(inputSchema as unknown as Parameters<typeof toJSONSchema>[0], draft07Params);
77
- } else {
78
- converted = toJsonSchema(inputSchema);
79
- if (isInteropZodSchema(converted)) {
80
- converted = toJSONSchema(inputSchema as unknown as Parameters<typeof toJSONSchema>[0], draft07Params);
81
- }
82
- }
54
+ const converted = this.convertZodSchemaToJsonSchema(inputSchema, draft07Params);
83
55
  const record = converted as Record<string, unknown>;
84
56
  const { $schema: _draftSchemaOmitted, ...rest } = record;
85
57
  if (options.requireObjectRoot && rest.type !== "object") {
86
58
  throw new Error(
87
- `Cannot create LangChain tool "${options.schemaName}": tool input schema must be a JSON Schema object type (got type=${String(rest.type)}).`,
59
+ `Cannot create tool "${options.schemaName}": tool input schema must be a JSON Schema object type (got type=${String(rest.type)}).`,
88
60
  );
89
61
  }
90
62
  if (
@@ -93,7 +65,7 @@ export class AIAgentExecutionHelpersFactory {
93
65
  (typeof rest.properties !== "object" || Array.isArray(rest.properties))
94
66
  ) {
95
67
  throw new Error(
96
- `Cannot create LangChain tool "${options.schemaName}": tool input schema "properties" must be an object (got ${JSON.stringify(rest.properties)}).`,
68
+ `Cannot create tool "${options.schemaName}": tool input schema "properties" must be an object (got ${JSON.stringify(rest.properties)}).`,
97
69
  );
98
70
  }
99
71
  if (options.requireObjectRoot && rest.properties === undefined) {
@@ -103,6 +75,20 @@ export class AIAgentExecutionHelpersFactory {
103
75
  return rest;
104
76
  }
105
77
 
78
+ /**
79
+ * Runs Zod's `toJSONSchema` via the schema's own instance method when available, so consumer
80
+ * schemas loaded under a different tsx namespace still convert correctly. If the caller handed us
81
+ * a payload that lacks that method (e.g. a plain JSON Schema record or a Zod instance whose
82
+ * prototype was stripped), we fall back to the framework-bundled module function.
83
+ */
84
+ private convertZodSchemaToJsonSchema(inputSchema: ZodSchemaAny, params: Readonly<{ target: "draft-07" }>): unknown {
85
+ const candidate = (inputSchema as unknown as { toJSONSchema?: ZodInstanceToJsonSchema }).toJSONSchema;
86
+ if (typeof candidate === "function") {
87
+ return candidate.call(inputSchema, params);
88
+ }
89
+ return frameworkToJSONSchema(inputSchema as unknown as Parameters<typeof frameworkToJSONSchema>[0], params);
90
+ }
91
+
106
92
  /**
107
93
  * `@cfworker/json-schema` iterates `schema.required` with `for...of`; it must be a string array or absent.
108
94
  */