@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.
- package/CHANGELOG.md +17 -0
- package/dist/index.cjs +175 -9
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +14 -1
- package/dist/index.d.ts +14 -1
- package/dist/index.js +175 -9
- package/dist/index.js.map +1 -1
- package/dist/metadata.json +1 -1
- package/package.json +1 -1
- package/src/nodes/AIAgentConfig.ts +8 -0
- package/src/nodes/AIAgentNode.ts +4 -2
- package/src/nodes/AgentMessageFactory.ts +22 -6
- package/src/nodes/AgentToolResultContentFactory.ts +155 -0
package/dist/metadata.json
CHANGED
package/package.json
CHANGED
|
@@ -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
|
|
package/src/nodes/AIAgentNode.ts
CHANGED
|
@@ -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(
|
|
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
|
+
}
|