@codemation/core-nodes 0.12.0 → 0.13.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "packageName": "@codemation/core-nodes",
4
- "packageVersion": "0.12.0",
4
+ "packageVersion": "0.13.0",
5
5
  "description": "",
6
6
  "kind": "nodes",
7
7
  "nodes": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codemation/core-nodes",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -58,6 +58,12 @@ export interface AIAgentOptions<TInputJson = unknown, _TOutputJson = unknown> {
58
58
  * entirely (the node then behaves as if no binaries were present).
59
59
  */
60
60
  readonly passBinariesToModel?: boolean;
61
+ /**
62
+ * Whether binaries returned by a tool (e.g. an MCP tool returning a PDF or image) are passed to
63
+ * the chat model as native multimodal tool-result blocks. Defaults to `true`. Set to `false` to
64
+ * keep tool results as inert JSON text (the model then never "sees" the document).
65
+ */
66
+ readonly passToolBinariesToModel?: boolean;
61
67
  /**
62
68
  * Explicit binaries to pass to the chat model, instead of the ones on the current item.
63
69
  * Either a static array or a function resolved per item (so an author can forward binaries
@@ -96,6 +102,7 @@ export class AIAgent<TInputJson = unknown, TOutputJson = unknown>
96
102
  readonly pinnedMcpTools?: readonly string[];
97
103
  readonly untrustedSources?: ReadonlyArray<string>;
98
104
  readonly passBinariesToModel?: boolean;
105
+ readonly passToolBinariesToModel?: boolean;
99
106
  readonly binaries?:
100
107
  | ReadonlyArray<BinaryAttachment>
101
108
  | ((args: AgentMessageBuildArgs<TInputJson>) => ReadonlyArray<BinaryAttachment>);
@@ -115,6 +122,7 @@ export class AIAgent<TInputJson = unknown, TOutputJson = unknown>
115
122
  this.pinnedMcpTools = options.pinnedMcpTools;
116
123
  this.untrustedSources = options.untrustedSources;
117
124
  this.passBinariesToModel = options.passBinariesToModel;
125
+ this.passToolBinariesToModel = options.passToolBinariesToModel;
118
126
  this.binaries = options.binaries;
119
127
  }
120
128
 
@@ -210,7 +210,7 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
210
210
  };
211
211
  const conversation: ModelMessage[] = [
212
212
  ...checkpoint.conversation,
213
- AgentMessageFactory.createToolResultsMessage([toolResultEntry]),
213
+ AgentMessageFactory.createToolResultsMessage([toolResultEntry], ctx.config.passToolBinariesToModel !== false),
214
214
  ];
215
215
 
216
216
  const loopResult = await this.runTurnLoopUntilFinalAnswer({
@@ -490,6 +490,7 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
490
490
  result.text,
491
491
  result.toolCalls,
492
492
  allExecutedCalls,
493
+ ctx.config.passToolBinariesToModel !== false,
493
494
  );
494
495
  }
495
496
 
@@ -522,10 +523,11 @@ export class AIAgentNode implements RunnableNode<AIAgent<any, any>> {
522
523
  text: string,
523
524
  toolCalls: ReadonlyArray<AgentToolCall>,
524
525
  executedToolCalls: ReadonlyArray<ExecutedToolCall>,
526
+ passToolBinariesToModel: boolean,
525
527
  ): void {
526
528
  conversation.push(
527
529
  assistantMessage ?? AgentMessageFactory.createAssistantWithToolCalls(text, toolCalls),
528
- AgentMessageFactory.createToolResultsMessage(executedToolCalls),
530
+ AgentMessageFactory.createToolResultsMessage(executedToolCalls, passToolBinariesToModel),
529
531
  );
530
532
  }
531
533
 
@@ -1,7 +1,8 @@
1
1
  import type { AgentMessageDto, AgentToolCall } from "@codemation/core";
2
2
 
3
- import type { AssistantModelMessage, ModelMessage, ToolModelMessage } from "ai";
3
+ import type { AssistantModelMessage, ModelMessage, ToolModelMessage, ToolResultPart } from "ai";
4
4
 
5
+ import { AgentToolResultContentFactory } from "./AgentToolResultContentFactory";
5
6
  import type { ExecutedToolCall } from "./aiAgentSupport.types";
6
7
 
7
8
  /**
@@ -41,21 +42,36 @@ export class AgentMessageFactory {
41
42
  * Builds the `{ role: "tool", content: [{ type: "tool-result", ... }, ...] }` message returned
42
43
  * to the model after each tool round.
43
44
  */
44
- static createToolResultsMessage(executedToolCalls: ReadonlyArray<ExecutedToolCall>): ToolModelMessage {
45
+ static createToolResultsMessage(
46
+ executedToolCalls: ReadonlyArray<ExecutedToolCall>,
47
+ passToolBinariesToModel = true,
48
+ ): ToolModelMessage {
45
49
  return {
46
50
  role: "tool",
47
51
  content: executedToolCalls.map((executed) => ({
48
52
  type: "tool-result",
49
53
  toolCallId: executed.toolCallId,
50
54
  toolName: executed.toolName,
51
- output: {
52
- type: "json",
53
- value: AgentMessageFactory.toToolResultJson(executed.result),
54
- },
55
+ output: AgentMessageFactory.toToolResultOutput(executed.result, passToolBinariesToModel),
55
56
  })),
56
57
  };
57
58
  }
58
59
 
60
+ /**
61
+ * Routes a tool result to a native multimodal `{ type: "content" }` output when it is
62
+ * content-block-shaped (an MCP `CallToolResult`) and binary passdown is enabled; otherwise keeps
63
+ * the inert `{ type: "json" }` path.
64
+ */
65
+ private static toToolResultOutput(result: unknown, passToolBinariesToModel: boolean): ToolResultPart["output"] {
66
+ if (passToolBinariesToModel) {
67
+ const content = AgentToolResultContentFactory.tryMapToContentOutput(result);
68
+ if (content !== undefined) {
69
+ return { type: "content", value: content };
70
+ }
71
+ }
72
+ return { type: "json", value: AgentMessageFactory.toToolResultJson(result) };
73
+ }
74
+
59
75
  private static toToolResultJson(value: unknown): import("ai").JSONValue {
60
76
  if (value === undefined) return null;
61
77
  try {
@@ -0,0 +1,155 @@
1
+ import type { ToolResultPart } from "ai";
2
+
3
+ /** The `value` array of a `{ type: "content" }` tool-result output, as accepted by the AI SDK. */
4
+ type ContentOutputValue = Extract<ToolResultPart["output"], { type: "content" }>["value"];
5
+ type ContentOutputPart = ContentOutputValue[number];
6
+
7
+ /**
8
+ * Cap on raw (pre-base64) bytes inlined from a single tool result. Base64 inflates ~33% and every
9
+ * inlined byte eats model context, so oversize binaries are replaced with a text placeholder.
10
+ */
11
+ const MAX_INLINE_BYTES = 8 * 1024 * 1024;
12
+
13
+ /** MCP content-block discriminators. At least one must appear for a result to be treated as MCP-shaped. */
14
+ const KNOWN_MCP_BLOCK_TYPES: ReadonlySet<string> = new Set(["text", "image", "audio", "resource", "resource_link"]);
15
+
16
+ /**
17
+ * MCP-style content block, as returned verbatim by a `CallToolResult` (`@ai-sdk/mcp` tool `execute`).
18
+ * Only the fields this factory reads are modelled; unknown shapes fall through to a text marker.
19
+ */
20
+ type McpContentBlock = Readonly<{
21
+ type?: unknown;
22
+ text?: unknown;
23
+ data?: unknown;
24
+ mimeType?: unknown;
25
+ uri?: unknown;
26
+ name?: unknown;
27
+ resource?: Readonly<{ blob?: unknown; text?: unknown; mimeType?: unknown; uri?: unknown; name?: unknown }>;
28
+ }>;
29
+
30
+ /**
31
+ * Maps a tool result that is **content-block-shaped** (an MCP `CallToolResult` with a `content`
32
+ * array) into AI SDK `{ type: "content" }` tool-result output, so binaries reach the chat model as
33
+ * native multimodal tool-result blocks instead of being flattened to inert JSON text.
34
+ *
35
+ * The `@ai-sdk/anthropic` provider maps a `content`-output part as follows:
36
+ * `text` → text block, `image-data` → image block, `file-data` (only `application/pdf`) → document
37
+ * block. Non-PDF `file-data` is dropped by the provider, so this factory emits `image-data` for
38
+ * images, `file-data` only for PDFs, and a text marker for every other binary type.
39
+ *
40
+ * Returns `undefined` when the result is not content-block-shaped — callers keep the existing
41
+ * `{ type: "json" }` path, so plain string/object tool results are unaffected.
42
+ */
43
+ export class AgentToolResultContentFactory {
44
+ static tryMapToContentOutput(result: unknown): ContentOutputValue | undefined {
45
+ const blocks = AgentToolResultContentFactory.contentBlocks(result);
46
+ if (blocks === undefined) return undefined;
47
+
48
+ const parts: ContentOutputPart[] = [];
49
+ let inlinedBytes = 0;
50
+ for (const block of blocks) {
51
+ const mapped = AgentToolResultContentFactory.mapBlock(block, inlinedBytes);
52
+ parts.push(mapped.part);
53
+ inlinedBytes += mapped.bytes;
54
+ }
55
+ return parts;
56
+ }
57
+
58
+ /**
59
+ * Returns the `content` array iff `result` is an object whose `content` is an array of typed
60
+ * blocks AND at least one block carries a known MCP discriminator. A plain JSON result that merely
61
+ * has a `content` key of some other shape (e.g. Notion/Slack rich-text blocks) is rejected,
62
+ * preserving the `{ type: "json" }` path so its payload is never lost.
63
+ */
64
+ private static contentBlocks(result: unknown): ReadonlyArray<McpContentBlock> | undefined {
65
+ if (result === null || typeof result !== "object") return undefined;
66
+ const content = (result as { content?: unknown }).content;
67
+ if (!Array.isArray(content) || content.length === 0) return undefined;
68
+ const allTyped = content.every(
69
+ (block) => block !== null && typeof block === "object" && typeof (block as { type?: unknown }).type === "string",
70
+ );
71
+ if (!allTyped) return undefined;
72
+ const looksMcp = content.some((block) => KNOWN_MCP_BLOCK_TYPES.has((block as { type: string }).type));
73
+ return looksMcp ? (content as ReadonlyArray<McpContentBlock>) : undefined;
74
+ }
75
+
76
+ private static mapBlock(
77
+ block: McpContentBlock,
78
+ inlinedBytesSoFar: number,
79
+ ): Readonly<{ part: ContentOutputPart; bytes: number }> {
80
+ const type = block.type;
81
+ if (type === "text" && typeof block.text === "string") {
82
+ return { part: { type: "text", text: block.text }, bytes: 0 };
83
+ }
84
+ if (type === "image" && typeof block.data === "string" && typeof block.mimeType === "string") {
85
+ return AgentToolResultContentFactory.mapBinary({
86
+ base64: block.data,
87
+ mediaType: block.mimeType,
88
+ inlinedBytesSoFar,
89
+ });
90
+ }
91
+ if (type === "resource" && block.resource) {
92
+ return AgentToolResultContentFactory.mapEmbeddedResource(block.resource, inlinedBytesSoFar);
93
+ }
94
+ if (type === "resource_link" && typeof block.uri === "string") {
95
+ const mime = typeof block.mimeType === "string" ? ` (${block.mimeType})` : "";
96
+ return { part: { type: "text", text: `[linked resource: ${block.uri}${mime}]` }, bytes: 0 };
97
+ }
98
+ return { part: { type: "text", text: `[unsupported tool content block: ${String(type)}]` }, bytes: 0 };
99
+ }
100
+
101
+ private static mapEmbeddedResource(
102
+ resource: NonNullable<McpContentBlock["resource"]>,
103
+ inlinedBytesSoFar: number,
104
+ ): Readonly<{ part: ContentOutputPart; bytes: number }> {
105
+ if (typeof resource.text === "string") {
106
+ return { part: { type: "text", text: resource.text }, bytes: 0 };
107
+ }
108
+ if (typeof resource.blob === "string" && typeof resource.mimeType === "string") {
109
+ return AgentToolResultContentFactory.mapBinary({
110
+ base64: resource.blob,
111
+ mediaType: resource.mimeType,
112
+ filename: typeof resource.name === "string" ? resource.name : undefined,
113
+ inlinedBytesSoFar,
114
+ });
115
+ }
116
+ const uri = typeof resource.uri === "string" ? resource.uri : "unknown";
117
+ return { part: { type: "text", text: `[embedded resource: ${uri}]` }, bytes: 0 };
118
+ }
119
+
120
+ private static mapBinary(
121
+ args: Readonly<{ base64: string; mediaType: string; filename?: string; inlinedBytesSoFar: number }>,
122
+ ): Readonly<{ part: ContentOutputPart; bytes: number }> {
123
+ const rawBytes = Math.floor((args.base64.length * 3) / 4);
124
+ if (args.inlinedBytesSoFar + rawBytes > MAX_INLINE_BYTES) {
125
+ const name = args.filename ? ` "${args.filename}"` : "";
126
+ const kb = Math.round(rawBytes / 1024);
127
+ return {
128
+ part: {
129
+ type: "text",
130
+ text: `[binary${name} (${args.mediaType}, ~${kb} KB) omitted: exceeds the per-tool-result inline limit]`,
131
+ },
132
+ bytes: 0,
133
+ };
134
+ }
135
+ if (args.mediaType.startsWith("image/")) {
136
+ return { part: { type: "image-data", data: args.base64, mediaType: args.mediaType }, bytes: rawBytes };
137
+ }
138
+ if (args.mediaType === "application/pdf") {
139
+ return {
140
+ part: {
141
+ type: "file-data",
142
+ data: args.base64,
143
+ mediaType: args.mediaType,
144
+ ...(args.filename ? { filename: args.filename } : {}),
145
+ },
146
+ bytes: rawBytes,
147
+ };
148
+ }
149
+ const name = args.filename ? ` "${args.filename}"` : "";
150
+ return {
151
+ part: { type: "text", text: `[binary${name} (${args.mediaType}) not inlined: unsupported by the model]` },
152
+ bytes: 0,
153
+ };
154
+ }
155
+ }