melchizedek-agents 0.9.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/AGENT_SETUP.md +182 -0
- package/DOCUMENTATION.md +614 -0
- package/LICENSE +21 -0
- package/QUICKSTART.md +164 -0
- package/README.md +151 -0
- package/config/agents/README.md +27 -0
- package/config/agents/examples/ares.yaml +74 -0
- package/config/agents/examples/cartographers.yaml +115 -0
- package/config/agents/examples/claude.yaml +17 -0
- package/config/agents/examples/council.yaml +65 -0
- package/config/agents/examples/critic.yaml +74 -0
- package/config/agents/examples/delegation.yaml +29 -0
- package/config/agents/examples/hierarchical.yaml +26 -0
- package/config/agents/examples/image_production.yaml +112 -0
- package/config/agents/examples/librarian.yaml +75 -0
- package/config/agents/examples/model_zoo.yaml +81 -0
- package/config/agents/examples/patient_advocate.yaml +107 -0
- package/config/agents/examples/scriptorium.yaml +96 -0
- package/config/agents/examples/style_council.yaml +83 -0
- package/config/agents/examples/syndicate.yaml +34 -0
- package/config/agents/examples/tutor.yaml +66 -0
- package/config/agents/syndicateSchema.yaml +291 -0
- package/dist/lib/config.d.ts +108 -0
- package/dist/lib/config.js +110 -0
- package/dist/lib/dispatch.d.ts +133 -0
- package/dist/lib/dispatch.js +182 -0
- package/dist/lib/grounding.d.ts +28 -0
- package/dist/lib/grounding.js +61 -0
- package/dist/lib/index.d.ts +14 -0
- package/dist/lib/index.js +13 -0
- package/dist/lib/loadEnv.d.ts +21 -0
- package/dist/lib/loadEnv.js +58 -0
- package/dist/lib/loadSyndicate.d.ts +177 -0
- package/dist/lib/loadSyndicate.js +264 -0
- package/dist/lib/memory/supabaseMemoryService.d.ts +118 -0
- package/dist/lib/memory/supabaseMemoryService.js +499 -0
- package/dist/lib/models/claudeLlm.d.ts +93 -0
- package/dist/lib/models/claudeLlm.js +332 -0
- package/dist/lib/models/gptLlm.d.ts +110 -0
- package/dist/lib/models/gptLlm.js +497 -0
- package/dist/lib/models/grokLlm.d.ts +64 -0
- package/dist/lib/models/grokLlm.js +86 -0
- package/dist/lib/models/ollamaLlm.d.ts +105 -0
- package/dist/lib/models/ollamaLlm.js +134 -0
- package/dist/lib/models/openAiCompatibleLlm.d.ts +125 -0
- package/dist/lib/models/openAiCompatibleLlm.js +331 -0
- package/dist/lib/models/providerMap.d.ts +28 -0
- package/dist/lib/models/providerMap.js +45 -0
- package/dist/lib/models/registry.d.ts +71 -0
- package/dist/lib/models/registry.js +159 -0
- package/dist/lib/models/schemaNormalize.d.ts +22 -0
- package/dist/lib/models/schemaNormalize.js +58 -0
- package/dist/lib/observability/supabaseSpanExporter.d.ts +35 -0
- package/dist/lib/observability/supabaseSpanExporter.js +115 -0
- package/dist/lib/observability/tracer.d.ts +44 -0
- package/dist/lib/observability/tracer.js +242 -0
- package/dist/lib/persistence/supabaseProvider.d.ts +44 -0
- package/dist/lib/persistence/supabaseProvider.js +88 -0
- package/dist/lib/session/supabaseSessionService.d.ts +26 -0
- package/dist/lib/session/supabaseSessionService.js +193 -0
- package/dist/lib/session/transcript.d.ts +193 -0
- package/dist/lib/session/transcript.js +309 -0
- package/dist/lib/toolRegistry.d.ts +15 -0
- package/dist/lib/toolRegistry.js +58 -0
- package/dist/lib/tools/collectionsSearchTool.d.ts +55 -0
- package/dist/lib/tools/collectionsSearchTool.js +86 -0
- package/dist/lib/tools/generateImageTool.d.ts +16 -0
- package/dist/lib/tools/generateImageTool.js +119 -0
- package/dist/lib/tools/inspectImageTool.d.ts +21 -0
- package/dist/lib/tools/inspectImageTool.js +97 -0
- package/dist/lib/tools/mcpToolFactory.d.ts +2 -0
- package/dist/lib/tools/mcpToolFactory.js +116 -0
- package/dist/lib/tools/toolContract.d.ts +78 -0
- package/dist/lib/tools/toolContract.js +129 -0
- package/dist/lib/tools/webExtractTool.d.ts +70 -0
- package/dist/lib/tools/webExtractTool.js +461 -0
- package/dist/lib/tools/webSearchTool.d.ts +53 -0
- package/dist/lib/tools/webSearchTool.js +126 -0
- package/dist/lib/tools/wikiTools.d.ts +112 -0
- package/dist/lib/tools/wikiTools.js +697 -0
- package/dist/lib/tools/xSearchTool.d.ts +61 -0
- package/dist/lib/tools/xSearchTool.js +125 -0
- package/dist/lib/wiki/agentRun.d.ts +39 -0
- package/dist/lib/wiki/agentRun.js +79 -0
- package/dist/lib/wiki/builder.d.ts +71 -0
- package/dist/lib/wiki/builder.js +165 -0
- package/dist/lib/wiki/dive.d.ts +56 -0
- package/dist/lib/wiki/dive.js +208 -0
- package/dist/lib/wiki/entities.d.ts +220 -0
- package/dist/lib/wiki/entities.js +661 -0
- package/dist/lib/wiki/extract.d.ts +97 -0
- package/dist/lib/wiki/extract.js +305 -0
- package/dist/lib/wiki/fill.d.ts +37 -0
- package/dist/lib/wiki/fill.js +175 -0
- package/dist/lib/wiki/format.d.ts +132 -0
- package/dist/lib/wiki/format.js +177 -0
- package/dist/lib/wiki/graph.d.ts +80 -0
- package/dist/lib/wiki/graph.js +147 -0
- package/dist/lib/wiki/lint.d.ts +58 -0
- package/dist/lib/wiki/lint.js +267 -0
- package/dist/lib/wiki/markdown.d.ts +127 -0
- package/dist/lib/wiki/markdown.js +336 -0
- package/dist/lib/wiki/pipeline.d.ts +69 -0
- package/dist/lib/wiki/pipeline.js +119 -0
- package/dist/lib/wiki/vault.d.ts +84 -0
- package/dist/lib/wiki/vault.js +194 -0
- package/dist/scripts/a2a_server.d.ts +2 -0
- package/dist/scripts/a2a_server.js +997 -0
- package/dist/scripts/syndicate_chat.d.ts +2 -0
- package/dist/scripts/syndicate_chat.js +485 -0
- package/package.json +96 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-agent transcript sharing.
|
|
3
|
+
*
|
|
4
|
+
* ── The problem this solves ───────────────────────────────────────────────
|
|
5
|
+
* Under plan-dispatch every route runs as its OWN root agent (XScout,
|
|
6
|
+
* Conversationalist, FinancialAnalyst) against ONE shared session. Sharing
|
|
7
|
+
* the session is necessary but not sufficient: ADK decides how a stored
|
|
8
|
+
* event is rendered into a prompt by comparing `event.author` against the
|
|
9
|
+
* agent that is running, and every prior event fails that comparison the
|
|
10
|
+
* moment the route changes. `convertForeignEvent` then rewrites each one to
|
|
11
|
+
* `role: "user"` prefixed with "For context:".
|
|
12
|
+
*
|
|
13
|
+
* Replaying a real production session — a four-turn thread that had passed
|
|
14
|
+
* through three routes — through ADK's own content processor showed what the
|
|
15
|
+
* last route actually received: 15 contents, 118,013 bytes, and EVERY ONE of them
|
|
16
|
+
* `role: "user"`. Not a single `role: "model"` turn in the prompt. The
|
|
17
|
+
* history was all there — and unreadable as a conversation, because nothing
|
|
18
|
+
* in it was marked as having been said by the desk. That is the "it doesn't
|
|
19
|
+
* know what I'm referring to" failure, and no amount of session sharing
|
|
20
|
+
* fixes it, because sharing was never the broken part.
|
|
21
|
+
*
|
|
22
|
+
* Two further leaks rode along in the same conversion. `convertForeignEvent`
|
|
23
|
+
* clones any part it does not recognise verbatim, so the previous route's
|
|
24
|
+
* private chain-of-thought ("**My Micron Monday Decision Strategy** Okay, so
|
|
25
|
+
* the question is…", `thought: true`) arrived as USER speech. And raw tool
|
|
26
|
+
* payloads were inlined as text — one `load_memory` result alone was 23,502
|
|
27
|
+
* characters, one `get_company_fundamentals` 8,953 — all of it handed to a
|
|
28
|
+
* 400-character, tool-less flash-lite route that could do nothing with it.
|
|
29
|
+
*
|
|
30
|
+
* ── The fix ───────────────────────────────────────────────────────────────
|
|
31
|
+
* Project the stored transcript before ADK ever sees it: rewrite each past
|
|
32
|
+
* agent turn to the CURRENT agent's name so it survives as a real
|
|
33
|
+
* `role: "model"` turn, label it with the desk that spoke, and drop what no
|
|
34
|
+
* successor can use — thoughts, tool calls, tool results.
|
|
35
|
+
*
|
|
36
|
+
* The projection is a READ-TIME view. The stored session keeps everything,
|
|
37
|
+
* verbatim, because long-term memory ingestion reads the real record.
|
|
38
|
+
*/
|
|
39
|
+
import { BaseSessionService } from '@google/adk';
|
|
40
|
+
const DEFAULT_MAX_HISTORY_CHARS = 40_000;
|
|
41
|
+
const DEFAULT_MAX_HISTORY_TURNS = 16;
|
|
42
|
+
const DEFAULT_MAX_TURN_CHARS = 4_000;
|
|
43
|
+
/**
|
|
44
|
+
* Longest tool result kept in the DURABLE record. See {@link trimEventForStorage}.
|
|
45
|
+
*/
|
|
46
|
+
export const DEFAULT_MAX_STORED_PAYLOAD_CHARS = 2_000;
|
|
47
|
+
/**
|
|
48
|
+
* The text a successor can actually read: spoken output only.
|
|
49
|
+
*
|
|
50
|
+
* Excluded, deliberately:
|
|
51
|
+
* - `thought: true` parts — another agent's reasoning is not conversation,
|
|
52
|
+
* and ADK's own foreign-event conversion leaks it as user speech.
|
|
53
|
+
* - function calls and their results — the successor cannot re-enter that
|
|
54
|
+
* tool loop, and the payloads dwarf the answers they produced.
|
|
55
|
+
* - `toolCall` / `toolResponse` parts (the camelCase shape some providers
|
|
56
|
+
* emit) — invisible to ADK's `part.functionCall` checks, so they survive
|
|
57
|
+
* its conversion untouched and arrive as empty noise.
|
|
58
|
+
*/
|
|
59
|
+
function spokenText(event) {
|
|
60
|
+
const parts = (event.content?.parts ?? []);
|
|
61
|
+
return parts
|
|
62
|
+
.filter(p => !p.thought && !p.functionCall && !p.functionResponse && !p.toolCall && !p.toolResponse)
|
|
63
|
+
.map(p => (typeof p.text === 'string' ? p.text : ''))
|
|
64
|
+
.join('')
|
|
65
|
+
.trim();
|
|
66
|
+
}
|
|
67
|
+
function elide(text, limit) {
|
|
68
|
+
return text.length <= limit ? text : `${text.slice(0, limit)}\n[…turn truncated]`;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Render a stored transcript as history the given agent can read.
|
|
72
|
+
*
|
|
73
|
+
* User events pass through untouched. Every agent event becomes a
|
|
74
|
+
* `role: "model"` turn ATTRIBUTED to `forAgent`, which is what keeps ADK
|
|
75
|
+
* from rewriting it into "For context:" user text — with the original
|
|
76
|
+
* author kept as a visible `[Name]` label when it was a different desk, so
|
|
77
|
+
* attribution survives without costing the turn structure.
|
|
78
|
+
*
|
|
79
|
+
* Events that said nothing out loud (a pure tool call, a pure tool result)
|
|
80
|
+
* disappear entirely. Calls and their responses are dropped together, so
|
|
81
|
+
* ADK's function-response pairing never sees a widowed half.
|
|
82
|
+
*
|
|
83
|
+
* Pure and synchronous — see tests/transcript.test.ts.
|
|
84
|
+
*/
|
|
85
|
+
export function projectTranscript(events, forAgent, options = {}) {
|
|
86
|
+
const maxHistory = options.maxHistoryChars ?? DEFAULT_MAX_HISTORY_CHARS;
|
|
87
|
+
const maxTurns = options.maxHistoryTurns ?? DEFAULT_MAX_HISTORY_TURNS;
|
|
88
|
+
const maxTurn = options.maxTurnChars ?? DEFAULT_MAX_TURN_CHARS;
|
|
89
|
+
const kept = [];
|
|
90
|
+
let budget = maxHistory;
|
|
91
|
+
// Backwards: whichever ceiling binds first, it is the OLDEST context that
|
|
92
|
+
// goes. The newest exchange is always the one the user is talking about.
|
|
93
|
+
for (let i = events.length - 1; i >= 0 && kept.length < maxTurns; i--) {
|
|
94
|
+
const event = events[i];
|
|
95
|
+
if (event.author === 'user') {
|
|
96
|
+
const text = spokenText(event);
|
|
97
|
+
if (!text)
|
|
98
|
+
continue;
|
|
99
|
+
budget -= text.length;
|
|
100
|
+
kept.push(event);
|
|
101
|
+
if (budget <= 0)
|
|
102
|
+
break;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const text = spokenText(event);
|
|
106
|
+
if (!text)
|
|
107
|
+
continue;
|
|
108
|
+
const label = event.author && event.author !== forAgent ? `[${event.author}] ` : '';
|
|
109
|
+
const body = `${label}${elide(text, maxTurn)}`;
|
|
110
|
+
budget -= body.length;
|
|
111
|
+
kept.push({
|
|
112
|
+
...event,
|
|
113
|
+
author: forAgent,
|
|
114
|
+
content: { role: 'model', parts: [{ text: body }] },
|
|
115
|
+
});
|
|
116
|
+
if (budget <= 0)
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
return kept.reverse();
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Strip from an event what no reader of the STORED session will ever use.
|
|
123
|
+
*
|
|
124
|
+
* ── What the durable record is actually made of ───────────────────────────
|
|
125
|
+
* Measured across 128 live sessions, by part field:
|
|
126
|
+
*
|
|
127
|
+
* thoughtSignature 14.14 MB 73.3%
|
|
128
|
+
* functionResponse.response 2.07 MB 10.8%
|
|
129
|
+
* text (the conversation) 1.86 MB 9.6%
|
|
130
|
+
* toolResponse.response 1.06 MB 5.5%
|
|
131
|
+
*
|
|
132
|
+
* Nine-tenths of every byte stored is machinery. `thoughtSignature` is the
|
|
133
|
+
* opaque base64 blob Gemini attaches for thought continuity — a single part
|
|
134
|
+
* of it reached 115 KB — and tool payloads are the rest, the largest being
|
|
135
|
+
* Gemini's grounding `search_suggestions`, an HTML and CSS widget attached
|
|
136
|
+
* to every web-search result.
|
|
137
|
+
*
|
|
138
|
+
* Beware the shape of this data when measuring it: bucketing a part by
|
|
139
|
+
* whichever tool key it carries attributes the whole part to the tool
|
|
140
|
+
* response, when the weight is in a sibling field. That misattribution said
|
|
141
|
+
* "76% is tool results" and predicted a 70% saving from eliding payloads
|
|
142
|
+
* alone; the real saving from that change was 12%.
|
|
143
|
+
*
|
|
144
|
+
* ── Why none of it is needed ──────────────────────────────────────────────
|
|
145
|
+
* The stored row has exactly two readers. {@link projectTranscript} drops
|
|
146
|
+
* thought parts and tool traffic entirely on the way into a prompt, so a
|
|
147
|
+
* stored signature is never replayed to a model. The memory service's
|
|
148
|
+
* `serializeEvents` walks `part.text` alone, so a payload has never
|
|
149
|
+
* contributed one extracted fact. Everything above is written, re-uploaded
|
|
150
|
+
* on every later event in the turn (this service re-upserts the whole
|
|
151
|
+
* array), and re-fetched whole next turn — for nothing. One live session had
|
|
152
|
+
* reached 1.17 MB; a 60-turn conversation uploaded ~288 MB getting there.
|
|
153
|
+
*
|
|
154
|
+
* ── What survives ─────────────────────────────────────────────────────────
|
|
155
|
+
* A trimmed response KEEPS its shape: `id` and `name` are preserved and only
|
|
156
|
+
* the body is replaced, because ADK pairs calls to responses by id
|
|
157
|
+
* (`rearrangeEventsForLatestFunctionResponse` throws on a widowed half), so
|
|
158
|
+
* an elided result must remain a result. The marker records what was dropped
|
|
159
|
+
* and how big it was — what an operator reading the row needs. Small results
|
|
160
|
+
* pass through untouched: they are cheap, and a short one is occasionally
|
|
161
|
+
* the only record of what a number was.
|
|
162
|
+
*
|
|
163
|
+
* ── The one constraint on changing this ───────────────────────────────────
|
|
164
|
+
* Dropping `thoughtSignature` is safe BECAUSE the projection never feeds
|
|
165
|
+
* stored events back to a model. Anything that starts replaying raw stored
|
|
166
|
+
* events into Gemini would need the signature preserved, or lose thought
|
|
167
|
+
* continuity across a resumed session.
|
|
168
|
+
*
|
|
169
|
+
* Pure: returns a new event, never mutates the input. The caller applies it
|
|
170
|
+
* to the SERIALIZED COPY only — the live in-memory session must keep both
|
|
171
|
+
* signature and payloads, or the agent's own tool loop breaks mid-turn.
|
|
172
|
+
*/
|
|
173
|
+
export function trimEventForStorage(event, maxPayloadChars = DEFAULT_MAX_STORED_PAYLOAD_CHARS) {
|
|
174
|
+
const parts = event.content?.parts;
|
|
175
|
+
if (!parts?.length)
|
|
176
|
+
return event;
|
|
177
|
+
let trimmedAny = false;
|
|
178
|
+
const next = parts.map(part => {
|
|
179
|
+
let out = part;
|
|
180
|
+
// The dominant cost, and never read back from storage.
|
|
181
|
+
if (out.thoughtSignature !== undefined) {
|
|
182
|
+
const { thoughtSignature, ...rest } = out;
|
|
183
|
+
out = rest;
|
|
184
|
+
trimmedAny = true;
|
|
185
|
+
}
|
|
186
|
+
// Both spellings occur: `functionResponse` is ADK's, `toolResponse` comes
|
|
187
|
+
// from providers whose parts ADK passes through untouched.
|
|
188
|
+
const key = out.functionResponse ? 'functionResponse' : out.toolResponse ? 'toolResponse' : null;
|
|
189
|
+
if (!key)
|
|
190
|
+
return out;
|
|
191
|
+
const holder = out[key];
|
|
192
|
+
const body = holder?.response;
|
|
193
|
+
if (body === undefined)
|
|
194
|
+
return out;
|
|
195
|
+
const size = typeof body === 'string' ? body.length : JSON.stringify(body).length;
|
|
196
|
+
if (size <= maxPayloadChars)
|
|
197
|
+
return out;
|
|
198
|
+
trimmedAny = true;
|
|
199
|
+
return {
|
|
200
|
+
...out,
|
|
201
|
+
[key]: {
|
|
202
|
+
...holder,
|
|
203
|
+
response: {
|
|
204
|
+
elided: `${size.toLocaleString()} chars dropped before storage — tool results are not read back by any consumer (lib/session/transcript.ts)`,
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
});
|
|
209
|
+
if (!trimmedAny)
|
|
210
|
+
return event;
|
|
211
|
+
return { ...event, content: { ...event.content, parts: next } };
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* A compact plain-text digest of the exchange, for an agent that must be
|
|
215
|
+
* given history as INPUT rather than as conversation.
|
|
216
|
+
*
|
|
217
|
+
* The dispatch classifier is that agent. It runs in its own lane so its JSON
|
|
218
|
+
* verdicts never pollute the user-facing transcript — and the cost of that
|
|
219
|
+
* isolation, in production, was a router that had never seen a single answer
|
|
220
|
+
* it routed. On 2026-08-15 its lane held four user messages and four of its
|
|
221
|
+
* own verdicts, nothing else; a user's counter-argument to the desk's
|
|
222
|
+
* MU/SNDK/NVDA/AMD call therefore read as a remark with no request behind
|
|
223
|
+
* it, and went to small talk. Its own rules ("follow-ups on prior analysis →
|
|
224
|
+
* FinancialAnalyst") were unusable, because no prior analysis was in view.
|
|
225
|
+
*
|
|
226
|
+
* @param maxTurns newest N exchanges to show
|
|
227
|
+
* @param maxCharsPerTurn per-line budget — the router classifies, it does not
|
|
228
|
+
* read; the shape of an answer is enough
|
|
229
|
+
*/
|
|
230
|
+
export function renderTranscriptDigest(events, { maxTurns = 8, maxCharsPerTurn = 400 } = {}) {
|
|
231
|
+
const lines = [];
|
|
232
|
+
for (let i = events.length - 1; i >= 0 && lines.length < maxTurns; i--) {
|
|
233
|
+
const event = events[i];
|
|
234
|
+
const text = spokenText(event);
|
|
235
|
+
if (!text)
|
|
236
|
+
continue;
|
|
237
|
+
// The per-message date marker is harness plumbing, not conversation, and
|
|
238
|
+
// it would consume a quarter of every line's budget.
|
|
239
|
+
const clean = text.replace(/^\[System Context:[^\]]*\]\s*/, '').replace(/\s+/g, ' ').trim();
|
|
240
|
+
if (!clean)
|
|
241
|
+
continue;
|
|
242
|
+
const speaker = event.author === 'user' ? 'user' : event.author || 'desk';
|
|
243
|
+
lines.push(`${speaker}: ${clean.length > maxCharsPerTurn ? `${clean.slice(0, maxCharsPerTurn)}…` : clean}`);
|
|
244
|
+
}
|
|
245
|
+
return lines.reverse().join('\n');
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* A read-time projection over another session service.
|
|
249
|
+
*
|
|
250
|
+
* `getSession` returns the transcript as {@link projectTranscript} renders it
|
|
251
|
+
* for one named agent; every write goes to the REAL session held underneath,
|
|
252
|
+
* so the durable record keeps thoughts, tool calls and full payloads for
|
|
253
|
+
* long-term memory ingestion and for operators reading the row.
|
|
254
|
+
*
|
|
255
|
+
* Both views are kept live during a turn: the runner appends to the
|
|
256
|
+
* projected session it is holding (it re-reads `session.events` before every
|
|
257
|
+
* model call, so the agent's own in-flight tool calls must stay visible and
|
|
258
|
+
* unprojected), while the same event is appended and persisted to the real
|
|
259
|
+
* one. Only history is projected; the current turn never is.
|
|
260
|
+
*
|
|
261
|
+
* Construct one per turn — it is bound to a single agent name.
|
|
262
|
+
*/
|
|
263
|
+
export class ProjectedSessionService extends BaseSessionService {
|
|
264
|
+
inner;
|
|
265
|
+
forAgent;
|
|
266
|
+
options;
|
|
267
|
+
/** Real sessions handed out by `getSession`, keyed by identity. */
|
|
268
|
+
stored = new Map();
|
|
269
|
+
constructor(inner, forAgent, options = {}) {
|
|
270
|
+
super();
|
|
271
|
+
this.inner = inner;
|
|
272
|
+
this.forAgent = forAgent;
|
|
273
|
+
this.options = options;
|
|
274
|
+
}
|
|
275
|
+
key(appName, userId, sessionId) {
|
|
276
|
+
return `${appName}:${userId}:${sessionId}`;
|
|
277
|
+
}
|
|
278
|
+
async createSession(request) {
|
|
279
|
+
return this.inner.createSession(request);
|
|
280
|
+
}
|
|
281
|
+
async listSessions(request) {
|
|
282
|
+
return this.inner.listSessions(request);
|
|
283
|
+
}
|
|
284
|
+
async deleteSession(request) {
|
|
285
|
+
this.stored.delete(this.key(request.appName, request.userId, request.sessionId));
|
|
286
|
+
return this.inner.deleteSession(request);
|
|
287
|
+
}
|
|
288
|
+
async getSession(request) {
|
|
289
|
+
const real = await this.inner.getSession(request);
|
|
290
|
+
if (!real)
|
|
291
|
+
return undefined;
|
|
292
|
+
this.stored.set(this.key(request.appName, request.userId, request.sessionId), real);
|
|
293
|
+
return {
|
|
294
|
+
...real,
|
|
295
|
+
events: projectTranscript(real.events, this.forAgent, this.options),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
async appendEvent(request) {
|
|
299
|
+
const { session, event } = request;
|
|
300
|
+
if (event.partial)
|
|
301
|
+
return event;
|
|
302
|
+
// The runner's view (projected) — base class merges state deltas.
|
|
303
|
+
await super.appendEvent({ session, event });
|
|
304
|
+
// The durable record (real), persisted by the wrapped service.
|
|
305
|
+
const real = this.stored.get(this.key(session.appName, session.userId, session.id));
|
|
306
|
+
await this.inner.appendEvent({ session: real && real !== session ? real : session, event });
|
|
307
|
+
return event;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/toolRegistry.ts — single source of truth mapping YAML tool names to
|
|
3
|
+
* live ADK tool instances.
|
|
4
|
+
*
|
|
5
|
+
* WHY this exists:
|
|
6
|
+
* a2a_server.ts and syndicate_chat.ts each carried an identical `resolveTools`
|
|
7
|
+
* switch. The two copies had already begun to drift. Centralising the mapping
|
|
8
|
+
* here means a newly added tool is available to every entrypoint at once.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Resolve an array of tool-name strings to live ADK tool instances.
|
|
12
|
+
* Unknown names are skipped; `onUnknown` (if provided) is invoked for each so
|
|
13
|
+
* callers can log in their own format.
|
|
14
|
+
*/
|
|
15
|
+
export declare function resolveTools(toolNames?: string[], onUnknown?: (name: string) => void): any[];
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/toolRegistry.ts — single source of truth mapping YAML tool names to
|
|
3
|
+
* live ADK tool instances.
|
|
4
|
+
*
|
|
5
|
+
* WHY this exists:
|
|
6
|
+
* a2a_server.ts and syndicate_chat.ts each carried an identical `resolveTools`
|
|
7
|
+
* switch. The two copies had already begun to drift. Centralising the mapping
|
|
8
|
+
* here means a newly added tool is available to every entrypoint at once.
|
|
9
|
+
*/
|
|
10
|
+
import { GOOGLE_SEARCH, LOAD_MEMORY, PRELOAD_MEMORY, } from '@google/adk';
|
|
11
|
+
import { COLLECTIONS_SEARCH } from "./tools/collectionsSearchTool.js";
|
|
12
|
+
import { generateImageTool } from "./tools/generateImageTool.js";
|
|
13
|
+
import { inspectImageTool } from "./tools/inspectImageTool.js";
|
|
14
|
+
import { toFunctionTool } from "./tools/toolContract.js";
|
|
15
|
+
import { WEB_SEARCH } from "./tools/webSearchTool.js";
|
|
16
|
+
import { WIKI_AGENT_TOOL_CONTRACTS } from "./tools/wikiTools.js";
|
|
17
|
+
import { X_SEARCH } from "./tools/xSearchTool.js";
|
|
18
|
+
// Knowledge-bundle tools, derived from their contracts so the YAML names
|
|
19
|
+
// can never drift from the definitions. The agentic composites
|
|
20
|
+
// (wiki_query/wiki_garden) are deliberately absent: a syndicate reaches
|
|
21
|
+
// that behavior by being an agent WITH these primitives.
|
|
22
|
+
const WIKI_TOOLS = Object.fromEntries(WIKI_AGENT_TOOL_CONTRACTS.map((contract) => [
|
|
23
|
+
contract.name,
|
|
24
|
+
toFunctionTool(contract),
|
|
25
|
+
]));
|
|
26
|
+
const TOOL_MAP = {
|
|
27
|
+
...WIKI_TOOLS,
|
|
28
|
+
// Provider-agnostic web search: routes to the model's NATIVE search
|
|
29
|
+
// (Gemini grounding / Anthropic / OpenAI / xAI); omitted with a warning
|
|
30
|
+
// for local models. Prefer this in new YAMLs.
|
|
31
|
+
web_search: WEB_SEARCH,
|
|
32
|
+
x_search: X_SEARCH,
|
|
33
|
+
// xAI-only: semantic search over hosted Collections (XAI_COLLECTION_IDS).
|
|
34
|
+
collections_search: COLLECTIONS_SEARCH,
|
|
35
|
+
// Gemini-only ADK grounding tool, kept for backward compatibility.
|
|
36
|
+
google_search: GOOGLE_SEARCH,
|
|
37
|
+
generate_image: generateImageTool,
|
|
38
|
+
inspect_image: inspectImageTool,
|
|
39
|
+
load_memory: LOAD_MEMORY,
|
|
40
|
+
preload_memory: PRELOAD_MEMORY,
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Resolve an array of tool-name strings to live ADK tool instances.
|
|
44
|
+
* Unknown names are skipped; `onUnknown` (if provided) is invoked for each so
|
|
45
|
+
* callers can log in their own format.
|
|
46
|
+
*/
|
|
47
|
+
export function resolveTools(toolNames = [], onUnknown) {
|
|
48
|
+
return toolNames
|
|
49
|
+
.map((name) => {
|
|
50
|
+
const tool = TOOL_MAP[name];
|
|
51
|
+
if (tool === undefined) {
|
|
52
|
+
onUnknown?.(name);
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
return tool;
|
|
56
|
+
})
|
|
57
|
+
.filter(Boolean);
|
|
58
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/tools/collectionsSearchTool.ts — xAI Collections search, sentinel-style.
|
|
3
|
+
*
|
|
4
|
+
* WHY this file exists:
|
|
5
|
+
* xAI's Agent Tools API can search "Collections" — hosted document stores
|
|
6
|
+
* (PDFs, text, CSVs) uploaded at console.x.ai or via the management API —
|
|
7
|
+
* semantic RAG run SERVER-SIDE, with citations back to source files
|
|
8
|
+
* (collections://<collection_id>/files/<file_id>). Declaring
|
|
9
|
+
* `collections_search` in an agent's YAML tools list turns it on for
|
|
10
|
+
* grok-* agents; xAI decides retrieval, we never ship documents in the
|
|
11
|
+
* prompt.
|
|
12
|
+
*
|
|
13
|
+
* HOW IT WORKS (mirrors xSearchTool.ts exactly):
|
|
14
|
+
* The sentinel self-registers in llmRequest.toolsDict; _getDeclaration()
|
|
15
|
+
* returns undefined so it is never sent as a client-side function tool.
|
|
16
|
+
* The GptLlm/GrokLlm request builder calls wantsCollectionsSearch() and
|
|
17
|
+
* appends the OpenAI-compatible wire shape:
|
|
18
|
+
* { type: 'file_search', vector_store_ids: [...], max_num_results? }
|
|
19
|
+
*
|
|
20
|
+
* WHICH COLLECTIONS: ids come from the environment, not YAML —
|
|
21
|
+
* XAI_COLLECTION_IDS (comma-separated) names the collections, and
|
|
22
|
+
* XAI_COLLECTIONS_MAX_RESULTS optionally caps retrieved chunks. Ids are
|
|
23
|
+
* deployment configuration (they differ per account/stage), which is
|
|
24
|
+
* what .env is for; the YAML stays shareable. Declared with no ids
|
|
25
|
+
* configured, the tool is omitted with a warning — never fatal.
|
|
26
|
+
*
|
|
27
|
+
* SCOPE: xAI-only. OpenAI's Responses API shares the file_search wire
|
|
28
|
+
* shape but searches OPENAI vector stores — a different product with
|
|
29
|
+
* different ids — so this sentinel gates to grok-* models and is a
|
|
30
|
+
* silent no-op everywhere else (same non-throwing routing contract as
|
|
31
|
+
* web_search / x_search).
|
|
32
|
+
*/
|
|
33
|
+
import { BaseTool } from '@google/adk';
|
|
34
|
+
import type { LlmRequest } from '@google/adk';
|
|
35
|
+
export declare const COLLECTIONS_SEARCH_TOOL_NAME = "collections_search";
|
|
36
|
+
export declare class CollectionsSearchTool extends BaseTool {
|
|
37
|
+
constructor();
|
|
38
|
+
/** Never a client-side function tool — xAI runs retrieval server-side. */
|
|
39
|
+
_getDeclaration(): undefined;
|
|
40
|
+
/** Server-side tool: nothing to execute locally. */
|
|
41
|
+
runAsync(): Promise<unknown>;
|
|
42
|
+
processLlmRequest({ llmRequest }: {
|
|
43
|
+
llmRequest: LlmRequest;
|
|
44
|
+
}): Promise<void>;
|
|
45
|
+
}
|
|
46
|
+
/** Shared instance, mirroring WEB_SEARCH / X_SEARCH. */
|
|
47
|
+
export declare const COLLECTIONS_SEARCH: CollectionsSearchTool;
|
|
48
|
+
/** True when the agent requested collections search (xAI sentinel path). */
|
|
49
|
+
export declare function wantsCollectionsSearch(llmRequest: LlmRequest): boolean;
|
|
50
|
+
/** Sentinel test used by the request builder to skip function-tool emission. */
|
|
51
|
+
export declare function isCollectionsSearchSentinel(tool: unknown): boolean;
|
|
52
|
+
/** Collection ids from XAI_COLLECTION_IDS (comma-separated, trimmed). */
|
|
53
|
+
export declare function collectionIdsFromEnv(): string[];
|
|
54
|
+
/** Optional retrieval cap from XAI_COLLECTIONS_MAX_RESULTS (positive int). */
|
|
55
|
+
export declare function collectionsMaxResultsFromEnv(): number | undefined;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/tools/collectionsSearchTool.ts — xAI Collections search, sentinel-style.
|
|
3
|
+
*
|
|
4
|
+
* WHY this file exists:
|
|
5
|
+
* xAI's Agent Tools API can search "Collections" — hosted document stores
|
|
6
|
+
* (PDFs, text, CSVs) uploaded at console.x.ai or via the management API —
|
|
7
|
+
* semantic RAG run SERVER-SIDE, with citations back to source files
|
|
8
|
+
* (collections://<collection_id>/files/<file_id>). Declaring
|
|
9
|
+
* `collections_search` in an agent's YAML tools list turns it on for
|
|
10
|
+
* grok-* agents; xAI decides retrieval, we never ship documents in the
|
|
11
|
+
* prompt.
|
|
12
|
+
*
|
|
13
|
+
* HOW IT WORKS (mirrors xSearchTool.ts exactly):
|
|
14
|
+
* The sentinel self-registers in llmRequest.toolsDict; _getDeclaration()
|
|
15
|
+
* returns undefined so it is never sent as a client-side function tool.
|
|
16
|
+
* The GptLlm/GrokLlm request builder calls wantsCollectionsSearch() and
|
|
17
|
+
* appends the OpenAI-compatible wire shape:
|
|
18
|
+
* { type: 'file_search', vector_store_ids: [...], max_num_results? }
|
|
19
|
+
*
|
|
20
|
+
* WHICH COLLECTIONS: ids come from the environment, not YAML —
|
|
21
|
+
* XAI_COLLECTION_IDS (comma-separated) names the collections, and
|
|
22
|
+
* XAI_COLLECTIONS_MAX_RESULTS optionally caps retrieved chunks. Ids are
|
|
23
|
+
* deployment configuration (they differ per account/stage), which is
|
|
24
|
+
* what .env is for; the YAML stays shareable. Declared with no ids
|
|
25
|
+
* configured, the tool is omitted with a warning — never fatal.
|
|
26
|
+
*
|
|
27
|
+
* SCOPE: xAI-only. OpenAI's Responses API shares the file_search wire
|
|
28
|
+
* shape but searches OPENAI vector stores — a different product with
|
|
29
|
+
* different ids — so this sentinel gates to grok-* models and is a
|
|
30
|
+
* silent no-op everywhere else (same non-throwing routing contract as
|
|
31
|
+
* web_search / x_search).
|
|
32
|
+
*/
|
|
33
|
+
import { BaseTool } from '@google/adk';
|
|
34
|
+
import { providerForModel } from "../models/providerMap.js";
|
|
35
|
+
export const COLLECTIONS_SEARCH_TOOL_NAME = 'collections_search';
|
|
36
|
+
export class CollectionsSearchTool extends BaseTool {
|
|
37
|
+
constructor() {
|
|
38
|
+
super({
|
|
39
|
+
name: COLLECTIONS_SEARCH_TOOL_NAME,
|
|
40
|
+
description: 'Semantic search over xAI Collections (hosted document stores) via ' +
|
|
41
|
+
'Agent Tools — grok-* models only; a no-op sentinel on every other ' +
|
|
42
|
+
'provider. Collections are selected by XAI_COLLECTION_IDS.',
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
/** Never a client-side function tool — xAI runs retrieval server-side. */
|
|
46
|
+
_getDeclaration() {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
/** Server-side tool: nothing to execute locally. */
|
|
50
|
+
async runAsync() {
|
|
51
|
+
return Promise.resolve();
|
|
52
|
+
}
|
|
53
|
+
async processLlmRequest({ llmRequest }) {
|
|
54
|
+
if (!llmRequest.model)
|
|
55
|
+
return;
|
|
56
|
+
if (providerForModel(llmRequest.model) !== 'xai')
|
|
57
|
+
return; // no-op elsewhere
|
|
58
|
+
llmRequest.toolsDict[COLLECTIONS_SEARCH_TOOL_NAME] = this;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** Shared instance, mirroring WEB_SEARCH / X_SEARCH. */
|
|
62
|
+
export const COLLECTIONS_SEARCH = new CollectionsSearchTool();
|
|
63
|
+
/** True when the agent requested collections search (xAI sentinel path). */
|
|
64
|
+
export function wantsCollectionsSearch(llmRequest) {
|
|
65
|
+
return (llmRequest.toolsDict?.[COLLECTIONS_SEARCH_TOOL_NAME] instanceof
|
|
66
|
+
CollectionsSearchTool);
|
|
67
|
+
}
|
|
68
|
+
/** Sentinel test used by the request builder to skip function-tool emission. */
|
|
69
|
+
export function isCollectionsSearchSentinel(tool) {
|
|
70
|
+
return tool instanceof CollectionsSearchTool;
|
|
71
|
+
}
|
|
72
|
+
/** Collection ids from XAI_COLLECTION_IDS (comma-separated, trimmed). */
|
|
73
|
+
export function collectionIdsFromEnv() {
|
|
74
|
+
return (process.env.XAI_COLLECTION_IDS ?? '')
|
|
75
|
+
.split(',')
|
|
76
|
+
.map((id) => id.trim())
|
|
77
|
+
.filter(Boolean);
|
|
78
|
+
}
|
|
79
|
+
/** Optional retrieval cap from XAI_COLLECTIONS_MAX_RESULTS (positive int). */
|
|
80
|
+
export function collectionsMaxResultsFromEnv() {
|
|
81
|
+
const raw = process.env.XAI_COLLECTIONS_MAX_RESULTS?.trim();
|
|
82
|
+
if (!raw)
|
|
83
|
+
return undefined;
|
|
84
|
+
const n = Number(raw);
|
|
85
|
+
return Number.isInteger(n) && n > 0 ? n : undefined;
|
|
86
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* generateImageTool — native ADK FunctionTool for real image generation.
|
|
3
|
+
*
|
|
4
|
+
* WHY a FunctionTool instead of an AgentTool subagent:
|
|
5
|
+
* AgentTool converts all subagent output to a text function-response before
|
|
6
|
+
* returning it to the orchestrator. Binary inlineData (the base64 image bytes
|
|
7
|
+
* from an image-generation model) is therefore lost in transit.
|
|
8
|
+
*
|
|
9
|
+
* A FunctionTool runs arbitrary TypeScript: we call @google/genai directly,
|
|
10
|
+
* receive the raw response including inlineData, write the file to disk, and
|
|
11
|
+
* return a plain-text confirmation with the saved file path. The orchestrator
|
|
12
|
+
* sees the path and relays it to the user.
|
|
13
|
+
*/
|
|
14
|
+
import { FunctionTool } from '@google/adk';
|
|
15
|
+
import { type Schema } from '@google/genai';
|
|
16
|
+
export declare const generateImageTool: FunctionTool<Schema>;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* generateImageTool — native ADK FunctionTool for real image generation.
|
|
3
|
+
*
|
|
4
|
+
* WHY a FunctionTool instead of an AgentTool subagent:
|
|
5
|
+
* AgentTool converts all subagent output to a text function-response before
|
|
6
|
+
* returning it to the orchestrator. Binary inlineData (the base64 image bytes
|
|
7
|
+
* from an image-generation model) is therefore lost in transit.
|
|
8
|
+
*
|
|
9
|
+
* A FunctionTool runs arbitrary TypeScript: we call @google/genai directly,
|
|
10
|
+
* receive the raw response including inlineData, write the file to disk, and
|
|
11
|
+
* return a plain-text confirmation with the saved file path. The orchestrator
|
|
12
|
+
* sees the path and relays it to the user.
|
|
13
|
+
*/
|
|
14
|
+
import { FunctionTool } from '@google/adk';
|
|
15
|
+
import { GoogleGenAI } from '@google/genai';
|
|
16
|
+
import { writeFileSync, mkdirSync } from 'node:fs';
|
|
17
|
+
import { join, resolve } from 'node:path';
|
|
18
|
+
const IMAGE_MODEL = 'gemini-3.1-flash-image-preview';
|
|
19
|
+
export const generateImageTool = new FunctionTool({
|
|
20
|
+
name: 'generate_image',
|
|
21
|
+
description: 'Generates a real image from a structured payload and saves it to the outputs/ directory. ' +
|
|
22
|
+
'Returns the saved file path. Call this only after the user has approved the image payload.',
|
|
23
|
+
parameters: {
|
|
24
|
+
type: 'OBJECT',
|
|
25
|
+
properties: {
|
|
26
|
+
prompt: {
|
|
27
|
+
type: 'STRING',
|
|
28
|
+
description: 'The full image prompt describing the scene and subject.',
|
|
29
|
+
},
|
|
30
|
+
style: {
|
|
31
|
+
type: 'STRING',
|
|
32
|
+
description: 'Visual style (e.g. "Ansel Adams", "impressionist", "photorealistic").',
|
|
33
|
+
},
|
|
34
|
+
aspect_ratio: {
|
|
35
|
+
type: 'STRING',
|
|
36
|
+
description: 'Desired aspect ratio (e.g. "3:2", "16:9", "1:1").',
|
|
37
|
+
},
|
|
38
|
+
color_palette: {
|
|
39
|
+
type: 'STRING',
|
|
40
|
+
description: 'Color palette guidance (e.g. "monochromatic", "warm tones", "vivid").',
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
required: ['prompt'],
|
|
44
|
+
},
|
|
45
|
+
execute: async (input) => {
|
|
46
|
+
console.log("[ImageTool] Starting image generation execution...");
|
|
47
|
+
const { prompt, style, aspect_ratio, color_palette } = input;
|
|
48
|
+
const apiKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_GENAI_API_KEY;
|
|
49
|
+
if (!apiKey) {
|
|
50
|
+
const err = new Error('Gemini API Key is not configured (neither GEMINI_API_KEY nor GOOGLE_GENAI_API_KEY was found in environment).');
|
|
51
|
+
console.error(`[ImageTool ERROR] Initialization failed: ${err.message}`);
|
|
52
|
+
throw err;
|
|
53
|
+
}
|
|
54
|
+
console.log(`[ImageTool] Parameters - Prompt: "${prompt}", Style: "${style ?? 'none'}", Aspect Ratio: "${aspect_ratio ?? 'none'}", Color Palette: "${color_palette ?? 'none'}"`);
|
|
55
|
+
const ai = new GoogleGenAI({ apiKey });
|
|
56
|
+
// Build a rich prompt that bakes in the structured parameters.
|
|
57
|
+
const parts = [prompt];
|
|
58
|
+
if (style)
|
|
59
|
+
parts.push(`Style: ${style}`);
|
|
60
|
+
if (color_palette)
|
|
61
|
+
parts.push(`Color palette: ${color_palette}`);
|
|
62
|
+
if (aspect_ratio)
|
|
63
|
+
parts.push(`Aspect ratio: ${aspect_ratio}`);
|
|
64
|
+
const fullPrompt = parts.join('. ');
|
|
65
|
+
try {
|
|
66
|
+
console.log(`[ImageTool] Sending generateContent request to Gemini API (Model: ${IMAGE_MODEL})...`);
|
|
67
|
+
const response = await ai.models.generateContent({
|
|
68
|
+
model: IMAGE_MODEL,
|
|
69
|
+
contents: [{ role: 'user', parts: [{ text: fullPrompt }] }],
|
|
70
|
+
config: {
|
|
71
|
+
responseModalities: ['IMAGE', 'TEXT'],
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
console.log("[ImageTool] Received response from Gemini API.");
|
|
75
|
+
const candidates = response.candidates ?? [];
|
|
76
|
+
console.log(`[ImageTool] Candidates found: ${candidates.length}`);
|
|
77
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
78
|
+
const candidate = candidates[i];
|
|
79
|
+
console.log(`[ImageTool] Candidate ${i}: FinishReason = ${candidate.finishReason ?? 'none'}`);
|
|
80
|
+
const partsList = candidate.content?.parts ?? [];
|
|
81
|
+
console.log(`[ImageTool] Candidate ${i} has ${partsList.length} parts.`);
|
|
82
|
+
for (let j = 0; j < partsList.length; j++) {
|
|
83
|
+
const part = partsList[j];
|
|
84
|
+
const inlineData = part.inlineData;
|
|
85
|
+
console.log(`[ImageTool] Part ${j}: hasText = ${!!part.text}, hasInlineData = ${!!inlineData}`);
|
|
86
|
+
if (inlineData?.data) {
|
|
87
|
+
const { mimeType, data } = inlineData;
|
|
88
|
+
const ext = mimeType?.split('/')[1] ?? 'png';
|
|
89
|
+
// OUTPUTS_DIR env overrides the cwd default (package consumers).
|
|
90
|
+
const outputDir = process.env.OUTPUTS_DIR
|
|
91
|
+
? resolve(process.env.OUTPUTS_DIR)
|
|
92
|
+
: join(process.cwd(), 'outputs');
|
|
93
|
+
console.log(`[ImageTool] Found inline image data. MimeType: ${mimeType}, Data Length: ${data.length} chars.`);
|
|
94
|
+
console.log(`[ImageTool] Ensuring output directory exists: ${outputDir}`);
|
|
95
|
+
mkdirSync(outputDir, { recursive: true });
|
|
96
|
+
const filename = `image_${Date.now()}.${ext}`;
|
|
97
|
+
const filepath = join(outputDir, filename);
|
|
98
|
+
console.log(`[ImageTool] Saving image to disk: ${filepath}`);
|
|
99
|
+
writeFileSync(filepath, Buffer.from(data, 'base64'));
|
|
100
|
+
return `[ImageTool Success] Image saved to outputs/${filename}`;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// Fallback: model returned text only (no image data in response)
|
|
105
|
+
console.warn("[ImageTool WARNING] No image data found in any of the response parts.");
|
|
106
|
+
const textFallback = candidates[0]?.content?.parts
|
|
107
|
+
?.map((p) => p.text)
|
|
108
|
+
.filter(Boolean)
|
|
109
|
+
.join('');
|
|
110
|
+
return textFallback || `No image data returned. Model used: ${IMAGE_MODEL}`;
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
// Surface the raw API error so the orchestrator can relay it accurately.
|
|
114
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
115
|
+
console.error(`[ImageTool ERROR] Execution failed: ${msg}`);
|
|
116
|
+
return `[generate_image ERROR] Model: ${IMAGE_MODEL} - ${msg}`;
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
});
|