@zerowidth/workbench-sdk 2.1.1 → 2.1.2

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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/index.js +113 -27
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerowidth/workbench-sdk",
3
- "version": "2.1.1",
3
+ "version": "2.1.2",
4
4
  "dependencies": {
5
5
  "adm-zip": "^0.5.16",
6
6
  "ajv": "^8.17.1",
package/src/index.js CHANGED
@@ -20,6 +20,92 @@ import { sanitizeAPICallEvent } from "./utilities/sanitizeAPICall.js";
20
20
  * Workbench - Core class for executing node-based flows
21
21
  * Handles node loading, input/output validation, and flow execution
22
22
  */
23
+
24
+ /**
25
+ * The single `role:"tool"` message for one tool result. MCP-style image
26
+ * content blocks ({type:'image', data, mimeType} with string data) are
27
+ * replaced in place by a short text note — the pixels are delivered to
28
+ * the model separately as an ephemeral vision input (see
29
+ * toolResultToMessages / callLLMWithTools). This same message is used on
30
+ * the wire, in the accumulated multi-round history, and therefore in the
31
+ * final conversation output, so all three carry an identical record with
32
+ * no base64 payload. Image blocks whose data is not a string are left
33
+ * untouched and stringify as before.
34
+ */
35
+ function toolResultToDurableMessage(toolResult) {
36
+ const result = toolResult.result;
37
+ let durable = result;
38
+ if (result && Array.isArray(result.content)) {
39
+ const hasImage = result.content.some(
40
+ (c) => c && c.type === 'image' && typeof c.data === 'string'
41
+ );
42
+ if (hasImage) {
43
+ durable = {
44
+ ...result,
45
+ content: result.content.map((c) =>
46
+ c && c.type === 'image' && typeof c.data === 'string'
47
+ ? {
48
+ type: 'text',
49
+ text: `[image (${c.mimeType || 'image/png'}) — delivered to the model as a vision input]`,
50
+ }
51
+ : c
52
+ ),
53
+ };
54
+ }
55
+ }
56
+ return {
57
+ role: 'tool',
58
+ tool_call_id: toolResult.tool_call_id,
59
+ name: toolResult.name,
60
+ content: typeof durable === 'string' ? durable : JSON.stringify(durable),
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Split one tool result into the wire messages for the next LLM round:
66
+ * the durable `role:"tool"` message plus, when the result carries image
67
+ * content blocks, an ephemeral `role:"user"` vision message with
68
+ * `image_url` data-URI parts. An OpenAI-style tool message is text-only —
69
+ * stringifying image blocks hands the model base64 soup and it
70
+ * confabulates what the image "probably" shows.
71
+ *
72
+ * The caller must insert vision messages ABOVE the entire trailing run of
73
+ * tool-cycle messages, not adjacent to this round's cycle: LLM nodes
74
+ * rebuild their `conversation` output by walking the wire messages
75
+ * backwards and stopping at the first message that is neither a tool
76
+ * result nor an assistant tool_calls turn. Placed above the whole run,
77
+ * a vision message is naturally excluded from the durable output while
78
+ * every tool cycle below it survives the walk.
79
+ */
80
+ function toolResultToMessages(toolResult) {
81
+ const toolMessage = toolResultToDurableMessage(toolResult);
82
+ const result = toolResult.result;
83
+ let visionMessage = null;
84
+ if (result && Array.isArray(result.content)) {
85
+ const images = result.content.filter(
86
+ (c) => c && c.type === 'image' && typeof c.data === 'string'
87
+ );
88
+ if (images.length > 0) {
89
+ visionMessage = {
90
+ role: 'user',
91
+ content: [
92
+ {
93
+ type: 'text',
94
+ text: `[Image${images.length > 1 ? 's' : ''} returned by the ${toolResult.name} tool call (${toolResult.tool_call_id}) — this is the actual content:]`,
95
+ },
96
+ ...images.map((img) => ({
97
+ type: 'image_url',
98
+ image_url: {
99
+ url: `data:${img.mimeType || 'image/png'};base64,${img.data}`,
100
+ },
101
+ })),
102
+ ],
103
+ };
104
+ }
105
+ }
106
+ return { toolMessage, visionMessage };
107
+ }
108
+
23
109
  export default class Workbench {
24
110
 
25
111
 
@@ -2171,14 +2257,7 @@ export default class Workbench {
2171
2257
  if (toolCallMessage && tool_results.length > 0) {
2172
2258
  accumulatedMessages = [...accumulatedMessages, toolCallMessage];
2173
2259
  for (const toolResult of tool_results) {
2174
- accumulatedMessages.push({
2175
- role: "tool",
2176
- tool_call_id: toolResult.tool_call_id,
2177
- name: toolResult.name,
2178
- content: typeof toolResult.result === "string"
2179
- ? toolResult.result
2180
- : JSON.stringify(toolResult.result)
2181
- });
2260
+ accumulatedMessages.push(toolResultToDurableMessage(toolResult));
2182
2261
  }
2183
2262
  }
2184
2263
 
@@ -2851,27 +2930,34 @@ export default class Workbench {
2851
2930
 
2852
2931
  // If this is a tool call response, append it to the messages array (OpenAI style)
2853
2932
  if (toolCallMessage && toolResults && Array.isArray(llmInputs.messages)) {
2854
-
2855
- //
2856
- llmInputs.messages = [
2857
- ...llmInputs.messages,
2858
- toolCallMessage
2859
- ];
2860
-
2861
- for(const toolResult of toolResults) {
2933
+ const toolMessages = [];
2934
+ const visionMessages = [];
2935
+ for (const toolResult of toolResults) {
2862
2936
  // You may need to adapt this for other LLMs
2863
- llmInputs.messages = [
2864
- ...llmInputs.messages,
2865
- {
2866
- role: "tool",
2867
- tool_call_id: toolResult.tool_call_id,
2868
- name: toolResult.name,
2869
- content: typeof toolResult.result === "string"
2870
- ? toolResult.result
2871
- : JSON.stringify(toolResult.result)
2872
- }
2873
- ];
2937
+ const { toolMessage, visionMessage } = toolResultToMessages(toolResult);
2938
+ toolMessages.push(toolMessage);
2939
+ if (visionMessage) visionMessages.push(visionMessage);
2940
+ }
2941
+ // Vision messages go ABOVE the entire trailing tool-cycle run, not
2942
+ // just this round's cycle — a vision message wedged between two
2943
+ // cycles would stop the conversation walk early and cut every
2944
+ // earlier cycle out of the final output (see toolResultToMessages).
2945
+ const base = llmInputs.messages;
2946
+ let insertAt = base.length;
2947
+ while (insertAt > 0) {
2948
+ const m = base[insertAt - 1];
2949
+ const inCycle = m && typeof m === 'object' &&
2950
+ (m.role === 'tool' || (Array.isArray(m.tool_calls) && m.tool_calls.length > 0));
2951
+ if (!inCycle) break;
2952
+ insertAt--;
2874
2953
  }
2954
+ llmInputs.messages = [
2955
+ ...base.slice(0, insertAt),
2956
+ ...visionMessages,
2957
+ ...base.slice(insertAt),
2958
+ toolCallMessage,
2959
+ ...toolMessages
2960
+ ];
2875
2961
  }
2876
2962
 
2877
2963
  // Execute using the shared core logic