melchizedek-agents 0.9.3 → 0.9.5
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 +2 -2
- package/DOCUMENTATION.md +7 -5
- package/QUICKSTART.md +4 -3
- package/README.md +13 -13
- package/config/agents/examples/augustin.yaml +2 -2
- package/config/agents/examples/cartographers.yaml +4 -4
- package/config/agents/examples/council.yaml +1 -1
- package/config/agents/examples/critic.yaml +3 -3
- package/config/agents/examples/delegation.yaml +3 -3
- package/config/agents/examples/hierarchical.yaml +3 -3
- package/config/agents/examples/image_production.yaml +2 -2
- package/config/agents/examples/librarian.yaml +3 -3
- package/config/agents/examples/patient_advocate.yaml +3 -3
- package/config/agents/examples/scriptorium.yaml +5 -5
- package/config/agents/examples/style_council.yaml +7 -7
- package/config/agents/examples/tutor.yaml +63 -33
- package/dist/lib/config.d.ts +3 -3
- package/dist/lib/config.js +3 -3
- package/dist/lib/models/claudeLlm.js +9 -3
- package/dist/lib/models/gptLlm.js +1 -1
- package/dist/lib/models/ollamaLlm.js +1 -1
- package/dist/lib/models/openAiCompatibleLlm.d.ts +47 -0
- package/dist/lib/models/openAiCompatibleLlm.js +219 -4
- package/dist/lib/models/registry.js +1 -1
- package/dist/lib/observability/supabaseSpanExporter.d.ts +5 -0
- package/dist/lib/observability/supabaseSpanExporter.js +14 -3
- package/dist/lib/observability/tracer.d.ts +8 -0
- package/dist/lib/observability/tracer.js +67 -4
- package/dist/lib/tools/inspectImageTool.js +1 -1
- package/dist/lib/wiki/agentRun.d.ts +1 -1
- package/dist/lib/wiki/agentRun.js +1 -1
- package/dist/scripts/a2a_server.d.ts +10 -0
- package/dist/scripts/a2a_server.js +75 -3
- package/dist/scripts/syndicate_chat.js +41 -10
- package/package.json +1 -1
|
@@ -102,7 +102,7 @@ export class ClaudeLlm extends BaseLlm {
|
|
|
102
102
|
* llm.request OpenTelemetry span (provider/model/tokens/latency).
|
|
103
103
|
*/
|
|
104
104
|
async *generateContentAsync(llmRequest, stream = false) {
|
|
105
|
-
yield* traceLlmGeneration({ provider: 'anthropic', model: this.model }, this.generateInner(llmRequest, stream));
|
|
105
|
+
yield* traceLlmGeneration({ provider: 'anthropic', model: this.model, llmRequest }, this.generateInner(llmRequest, stream));
|
|
106
106
|
}
|
|
107
107
|
async *generateInner(llmRequest, stream) {
|
|
108
108
|
const apiKey = this.apiKey || process.env.ANTHROPIC_API_KEY;
|
|
@@ -254,9 +254,15 @@ export class ClaudeLlm extends BaseLlm {
|
|
|
254
254
|
}
|
|
255
255
|
}
|
|
256
256
|
}
|
|
257
|
-
// Final message: tool_use blocks, thinking stash,
|
|
257
|
+
// Final message: full text, tool_use blocks, thinking stash, usage.
|
|
258
|
+
//
|
|
259
|
+
// WHY includeText is true even though the text was just streamed:
|
|
260
|
+
// ADK's runner persists only NON-partial events, so a final without
|
|
261
|
+
// text would render the reply on screen and lose it from session
|
|
262
|
+
// history — the next turn would have no record the model answered.
|
|
263
|
+
// Printers drop text on a turnComplete event they already streamed.
|
|
258
264
|
const final = await streamResponse.finalMessage();
|
|
259
|
-
yield this.finalResponse(final, /*includeText=*/
|
|
265
|
+
yield this.finalResponse(final, /*includeText=*/ true);
|
|
260
266
|
}
|
|
261
267
|
else {
|
|
262
268
|
// Non-streaming path
|
|
@@ -243,7 +243,7 @@ export class GptLlm extends BaseLlm {
|
|
|
243
243
|
}
|
|
244
244
|
// ── Generation ─────────────────────────────────────────────────────────────
|
|
245
245
|
async *generateContentAsync(llmRequest, stream = false) {
|
|
246
|
-
yield* traceLlmGeneration({ provider: this.providerId(), model: this.model }, this.generateInner(llmRequest, stream));
|
|
246
|
+
yield* traceLlmGeneration({ provider: this.providerId(), model: this.model, llmRequest }, this.generateInner(llmRequest, stream));
|
|
247
247
|
}
|
|
248
248
|
async *generateInner(llmRequest, stream) {
|
|
249
249
|
const apiKey = this.apiKey || this.apiKeyFromEnv();
|
|
@@ -109,7 +109,7 @@ export class OllamaLlm extends OpenAiCompatibleLlm {
|
|
|
109
109
|
httpError(status, detail) {
|
|
110
110
|
return {
|
|
111
111
|
errorCode: 'OLLAMA_HTTP_ERROR',
|
|
112
|
-
errorMessage: `Ollama returned ${status}: ${detail.slice(0,
|
|
112
|
+
errorMessage: `Ollama returned ${status}: ${detail.slice(0, 4000)}. Is the model pulled? Try: ollama pull ${this.wireModelName()}`,
|
|
113
113
|
};
|
|
114
114
|
}
|
|
115
115
|
unreachable(message) {
|
|
@@ -66,6 +66,33 @@ export declare function splitThinkBlocks(text: string): {
|
|
|
66
66
|
reasoning: string;
|
|
67
67
|
answer: string;
|
|
68
68
|
};
|
|
69
|
+
/**
|
|
70
|
+
* Incremental counterpart to splitThinkBlocks, for the SSE path.
|
|
71
|
+
*
|
|
72
|
+
* WHY a state machine rather than the regex: on a stream the closing
|
|
73
|
+
* `</think>` has usually NOT arrived yet, so the regex matches nothing and
|
|
74
|
+
* the whole scratchpad would be printed as the reply. A tag can also be
|
|
75
|
+
* split mid-delta ("<thi" + "nk>"), so any tail that could still grow into
|
|
76
|
+
* a tag is held back instead of being emitted as answer text.
|
|
77
|
+
*
|
|
78
|
+
* Providers that report reasoning in a discrete field (Ollama's
|
|
79
|
+
* `delta.reasoning`, others' `reasoning_content`) never open a think block
|
|
80
|
+
* and pass straight through this untouched.
|
|
81
|
+
*/
|
|
82
|
+
export declare class ThinkStreamSplitter {
|
|
83
|
+
private inThink;
|
|
84
|
+
private held;
|
|
85
|
+
/** Routes one content delta into reasoning/answer text. */
|
|
86
|
+
push(chunk: string): {
|
|
87
|
+
reasoning: string;
|
|
88
|
+
answer: string;
|
|
89
|
+
};
|
|
90
|
+
/** Releases the held tail; the stream ended, so it was never a tag. */
|
|
91
|
+
flush(): {
|
|
92
|
+
reasoning: string;
|
|
93
|
+
answer: string;
|
|
94
|
+
};
|
|
95
|
+
}
|
|
69
96
|
export declare abstract class OpenAiCompatibleLlm extends BaseLlm {
|
|
70
97
|
private webSearchWarned;
|
|
71
98
|
/** Provider id for telemetry, e.g. 'ollama', 'xai'. */
|
|
@@ -79,6 +106,11 @@ export declare abstract class OpenAiCompatibleLlm extends BaseLlm {
|
|
|
79
106
|
/** Provider-specific request body fields (merged last). */
|
|
80
107
|
/** Whether the endpoint accepts response_format json_schema (strict). */
|
|
81
108
|
protected supportsJsonSchemaFormat(): boolean;
|
|
109
|
+
/**
|
|
110
|
+
* Whether the endpoint honors stream_options.include_usage. Without it an
|
|
111
|
+
* SSE turn reports no token counts at all; Ollama and xAI both support it.
|
|
112
|
+
*/
|
|
113
|
+
protected supportsStreamUsage(): boolean;
|
|
82
114
|
protected extraBodyFields(_llmRequest: LlmRequest): Record<string, unknown>;
|
|
83
115
|
/**
|
|
84
116
|
* Body fields enabling the provider's NATIVE web search, or null when the
|
|
@@ -107,6 +139,21 @@ export declare abstract class OpenAiCompatibleLlm extends BaseLlm {
|
|
|
107
139
|
protected unreachable(message: string): LlmResponse;
|
|
108
140
|
generateContentAsync(llmRequest: LlmRequest, stream?: boolean): AsyncGenerator<LlmResponse, void>;
|
|
109
141
|
private generateInner;
|
|
142
|
+
/**
|
|
143
|
+
* SSE path: display-only partials as the tokens land, then ONE final
|
|
144
|
+
* non-partial response carrying the complete text, tool calls and usage.
|
|
145
|
+
*
|
|
146
|
+
* WHY the final repeats text already streamed: ADK's runner persists only
|
|
147
|
+
* non-partial events (`if (!event.partial) appendEvent(...)`), so a final
|
|
148
|
+
* without text would show the reply on screen and lose it from session
|
|
149
|
+
* history. Printers therefore skip text on a turnComplete event whose
|
|
150
|
+
* partials they already rendered — see scripts/syndicate_chat.ts.
|
|
151
|
+
*
|
|
152
|
+
* Thinking is deliberately NOT carried on the final: the scratchpad is
|
|
153
|
+
* display-only and stays out of history, which is what keeps it from
|
|
154
|
+
* being replayed to the model on the next turn.
|
|
155
|
+
*/
|
|
156
|
+
private streamResponse;
|
|
110
157
|
/** ADK Contents (Google GenAI format) → OpenAI-compatible chat messages. */
|
|
111
158
|
protected buildMessages(llmRequest: LlmRequest): OpenAiMessage[];
|
|
112
159
|
/** ADK toolsDict → OpenAI function-tool definitions (lowercase schemas). */
|
|
@@ -42,6 +42,101 @@ export function splitThinkBlocks(text) {
|
|
|
42
42
|
.trimStart();
|
|
43
43
|
return { reasoning: blocks.join('\n\n'), answer };
|
|
44
44
|
}
|
|
45
|
+
const OPEN_THINK = '<think>';
|
|
46
|
+
const CLOSE_THINK = '</think>';
|
|
47
|
+
/** Length of the longest suffix of `s` that is a proper prefix of `tag`. */
|
|
48
|
+
function partialTagSuffix(s, tag) {
|
|
49
|
+
const max = Math.min(s.length, tag.length - 1);
|
|
50
|
+
for (let n = max; n > 0; n--) {
|
|
51
|
+
if (s.endsWith(tag.slice(0, n)))
|
|
52
|
+
return n;
|
|
53
|
+
}
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Incremental counterpart to splitThinkBlocks, for the SSE path.
|
|
58
|
+
*
|
|
59
|
+
* WHY a state machine rather than the regex: on a stream the closing
|
|
60
|
+
* `</think>` has usually NOT arrived yet, so the regex matches nothing and
|
|
61
|
+
* the whole scratchpad would be printed as the reply. A tag can also be
|
|
62
|
+
* split mid-delta ("<thi" + "nk>"), so any tail that could still grow into
|
|
63
|
+
* a tag is held back instead of being emitted as answer text.
|
|
64
|
+
*
|
|
65
|
+
* Providers that report reasoning in a discrete field (Ollama's
|
|
66
|
+
* `delta.reasoning`, others' `reasoning_content`) never open a think block
|
|
67
|
+
* and pass straight through this untouched.
|
|
68
|
+
*/
|
|
69
|
+
export class ThinkStreamSplitter {
|
|
70
|
+
inThink = false;
|
|
71
|
+
held = '';
|
|
72
|
+
/** Routes one content delta into reasoning/answer text. */
|
|
73
|
+
push(chunk) {
|
|
74
|
+
let buf = this.held + chunk;
|
|
75
|
+
let reasoning = '';
|
|
76
|
+
let answer = '';
|
|
77
|
+
for (;;) {
|
|
78
|
+
const tag = this.inThink ? CLOSE_THINK : OPEN_THINK;
|
|
79
|
+
const at = buf.indexOf(tag);
|
|
80
|
+
if (at === -1)
|
|
81
|
+
break;
|
|
82
|
+
if (this.inThink)
|
|
83
|
+
reasoning += buf.slice(0, at);
|
|
84
|
+
else
|
|
85
|
+
answer += buf.slice(0, at);
|
|
86
|
+
buf = buf.slice(at + tag.length);
|
|
87
|
+
this.inThink = !this.inThink;
|
|
88
|
+
}
|
|
89
|
+
// Hold back only what could still become the tag we're watching for.
|
|
90
|
+
const pending = this.inThink ? CLOSE_THINK : OPEN_THINK;
|
|
91
|
+
const keep = partialTagSuffix(buf, pending);
|
|
92
|
+
this.held = keep > 0 ? buf.slice(buf.length - keep) : '';
|
|
93
|
+
const emit = keep > 0 ? buf.slice(0, buf.length - keep) : buf;
|
|
94
|
+
if (this.inThink)
|
|
95
|
+
reasoning += emit;
|
|
96
|
+
else
|
|
97
|
+
answer += emit;
|
|
98
|
+
return { reasoning, answer };
|
|
99
|
+
}
|
|
100
|
+
/** Releases the held tail; the stream ended, so it was never a tag. */
|
|
101
|
+
flush() {
|
|
102
|
+
const rest = this.held;
|
|
103
|
+
this.held = '';
|
|
104
|
+
return this.inThink
|
|
105
|
+
? { reasoning: rest, answer: '' }
|
|
106
|
+
: { reasoning: '', answer: rest };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/** Yields parsed `data:` payloads from an SSE response body. */
|
|
110
|
+
async function* sseChunks(res) {
|
|
111
|
+
const reader = res.body?.getReader?.();
|
|
112
|
+
if (!reader)
|
|
113
|
+
return;
|
|
114
|
+
const decoder = new TextDecoder();
|
|
115
|
+
let buf = '';
|
|
116
|
+
for (;;) {
|
|
117
|
+
const { value, done } = await reader.read();
|
|
118
|
+
if (done)
|
|
119
|
+
break;
|
|
120
|
+
buf += decoder.decode(value, { stream: true });
|
|
121
|
+
let nl;
|
|
122
|
+
while ((nl = buf.indexOf('\n')) !== -1) {
|
|
123
|
+
const line = buf.slice(0, nl).trim();
|
|
124
|
+
buf = buf.slice(nl + 1);
|
|
125
|
+
// Blank separators and ":" keep-alive comments carry no payload.
|
|
126
|
+
if (!line.startsWith('data:'))
|
|
127
|
+
continue;
|
|
128
|
+
const payload = line.slice('data:'.length).trim();
|
|
129
|
+
if (payload === '[DONE]')
|
|
130
|
+
return;
|
|
131
|
+
try {
|
|
132
|
+
yield JSON.parse(payload);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
/* a chunk that isn't valid JSON is not worth killing the turn for */
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
45
140
|
// ── OpenAiCompatibleLlm ──────────────────────────────────────────────────────
|
|
46
141
|
export class OpenAiCompatibleLlm extends BaseLlm {
|
|
47
142
|
webSearchWarned = false;
|
|
@@ -54,6 +149,13 @@ export class OpenAiCompatibleLlm extends BaseLlm {
|
|
|
54
149
|
supportsJsonSchemaFormat() {
|
|
55
150
|
return true;
|
|
56
151
|
}
|
|
152
|
+
/**
|
|
153
|
+
* Whether the endpoint honors stream_options.include_usage. Without it an
|
|
154
|
+
* SSE turn reports no token counts at all; Ollama and xAI both support it.
|
|
155
|
+
*/
|
|
156
|
+
supportsStreamUsage() {
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
57
159
|
extraBodyFields(_llmRequest) {
|
|
58
160
|
return {};
|
|
59
161
|
}
|
|
@@ -86,7 +188,7 @@ export class OpenAiCompatibleLlm extends BaseLlm {
|
|
|
86
188
|
httpError(status, detail) {
|
|
87
189
|
return {
|
|
88
190
|
errorCode: `${this.providerId().toUpperCase()}_HTTP_ERROR`,
|
|
89
|
-
errorMessage: `${this.providerId()} returned ${status}: ${detail.slice(0,
|
|
191
|
+
errorMessage: `${this.providerId()} returned ${status}: ${detail.slice(0, 4000)}`,
|
|
90
192
|
};
|
|
91
193
|
}
|
|
92
194
|
/** Error response when the endpoint can't be reached at all. */
|
|
@@ -98,9 +200,9 @@ export class OpenAiCompatibleLlm extends BaseLlm {
|
|
|
98
200
|
}
|
|
99
201
|
// ── Generation ─────────────────────────────────────────────────────────────
|
|
100
202
|
async *generateContentAsync(llmRequest, stream = false) {
|
|
101
|
-
yield* traceLlmGeneration({ provider: this.providerId(), model: this.model }, this.generateInner(llmRequest, stream));
|
|
203
|
+
yield* traceLlmGeneration({ provider: this.providerId(), model: this.model, llmRequest }, this.generateInner(llmRequest, stream));
|
|
102
204
|
}
|
|
103
|
-
async *generateInner(llmRequest,
|
|
205
|
+
async *generateInner(llmRequest, stream) {
|
|
104
206
|
const missing = this.missingRequirement();
|
|
105
207
|
if (missing) {
|
|
106
208
|
yield missing;
|
|
@@ -112,12 +214,24 @@ export class OpenAiCompatibleLlm extends BaseLlm {
|
|
|
112
214
|
const body = {
|
|
113
215
|
model: this.wireModelName(),
|
|
114
216
|
messages,
|
|
115
|
-
stream
|
|
217
|
+
stream,
|
|
218
|
+
// SSE reports token usage only if asked, in a final choices-less chunk.
|
|
219
|
+
...(stream && this.supportsStreamUsage()
|
|
220
|
+
? { stream_options: { include_usage: true } }
|
|
221
|
+
: {}),
|
|
116
222
|
...(cfg.temperature !== undefined ? { temperature: cfg.temperature } : {}),
|
|
117
223
|
...(cfg.topP !== undefined ? { top_p: cfg.topP } : {}),
|
|
118
224
|
...(cfg.maxOutputTokens !== undefined
|
|
119
225
|
? { max_tokens: cfg.maxOutputTokens }
|
|
120
226
|
: {}),
|
|
227
|
+
// Reasoning budget. Qwen3-family models think before answering, and on
|
|
228
|
+
// a constraint-dense prompt they can spend the whole budget doing it
|
|
229
|
+
// and return an empty turn. Ollama's OpenAI-compatible endpoint honors
|
|
230
|
+
// `reasoning_effort` and IGNORES the native `think` field, so this is
|
|
231
|
+
// the only lever on this path. Opt-in per agent; omitted = unchanged.
|
|
232
|
+
...(cfg.reasoningEffort !== undefined
|
|
233
|
+
? { reasoning_effort: cfg.reasoningEffort }
|
|
234
|
+
: {}),
|
|
121
235
|
...(openAiTools.length > 0 ? { tools: openAiTools } : {}),
|
|
122
236
|
// Structured output: an ADK outputSchema becomes a strict json_schema
|
|
123
237
|
// response_format where the endpoint supports it (xAI does; Ollama's
|
|
@@ -157,6 +271,10 @@ export class OpenAiCompatibleLlm extends BaseLlm {
|
|
|
157
271
|
yield this.httpError(res.status, detail);
|
|
158
272
|
return;
|
|
159
273
|
}
|
|
274
|
+
if (stream) {
|
|
275
|
+
yield* this.streamResponse(res);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
160
278
|
const data = await res.json();
|
|
161
279
|
const message = data.choices?.[0]?.message ?? {};
|
|
162
280
|
const usageMetadata = mapUsage(data.usage);
|
|
@@ -201,6 +319,103 @@ export class OpenAiCompatibleLlm extends BaseLlm {
|
|
|
201
319
|
yield this.unreachable(msg);
|
|
202
320
|
}
|
|
203
321
|
}
|
|
322
|
+
/**
|
|
323
|
+
* SSE path: display-only partials as the tokens land, then ONE final
|
|
324
|
+
* non-partial response carrying the complete text, tool calls and usage.
|
|
325
|
+
*
|
|
326
|
+
* WHY the final repeats text already streamed: ADK's runner persists only
|
|
327
|
+
* non-partial events (`if (!event.partial) appendEvent(...)`), so a final
|
|
328
|
+
* without text would show the reply on screen and lose it from session
|
|
329
|
+
* history. Printers therefore skip text on a turnComplete event whose
|
|
330
|
+
* partials they already rendered — see scripts/syndicate_chat.ts.
|
|
331
|
+
*
|
|
332
|
+
* Thinking is deliberately NOT carried on the final: the scratchpad is
|
|
333
|
+
* display-only and stays out of history, which is what keeps it from
|
|
334
|
+
* being replayed to the model on the next turn.
|
|
335
|
+
*/
|
|
336
|
+
async *streamResponse(res) {
|
|
337
|
+
const splitter = new ThinkStreamSplitter();
|
|
338
|
+
const toolCalls = new Map();
|
|
339
|
+
let answer = '';
|
|
340
|
+
let answerStarted = false;
|
|
341
|
+
let usage;
|
|
342
|
+
const partial = (text, thought) => ({
|
|
343
|
+
content: {
|
|
344
|
+
role: 'model',
|
|
345
|
+
parts: [thought ? { text, thought: true } : { text }],
|
|
346
|
+
},
|
|
347
|
+
partial: true,
|
|
348
|
+
});
|
|
349
|
+
// The scratchpad is usually trailed by blank lines; the reply must not
|
|
350
|
+
// open with them, but only the FIRST answer text may be trimmed.
|
|
351
|
+
const openAnswer = (text) => answerStarted ? text : text.trimStart();
|
|
352
|
+
for await (const chunk of sseChunks(res)) {
|
|
353
|
+
if (chunk.usage)
|
|
354
|
+
usage = chunk.usage;
|
|
355
|
+
const delta = chunk.choices?.[0]?.delta;
|
|
356
|
+
if (!delta)
|
|
357
|
+
continue;
|
|
358
|
+
// Reasoning as a discrete field (Ollama `reasoning`, others
|
|
359
|
+
// `reasoning_content`) — already separated, no tag parsing needed.
|
|
360
|
+
const fieldReasoning = delta.reasoning ?? delta.reasoning_content ?? '';
|
|
361
|
+
if (fieldReasoning)
|
|
362
|
+
yield partial(fieldReasoning, true);
|
|
363
|
+
if (typeof delta.content === 'string' && delta.content) {
|
|
364
|
+
const split = splitter.push(delta.content);
|
|
365
|
+
if (split.reasoning)
|
|
366
|
+
yield partial(split.reasoning, true);
|
|
367
|
+
if (split.answer) {
|
|
368
|
+
const text = openAnswer(split.answer);
|
|
369
|
+
if (text) {
|
|
370
|
+
answerStarted = true;
|
|
371
|
+
answer += text;
|
|
372
|
+
yield partial(text, false);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
// Tool arguments arrive as fragments keyed by index, not whole.
|
|
377
|
+
for (const call of delta.tool_calls ?? []) {
|
|
378
|
+
const index = call.index ?? 0;
|
|
379
|
+
const acc = toolCalls.get(index) ?? { args: '' };
|
|
380
|
+
if (call.id)
|
|
381
|
+
acc.id = call.id;
|
|
382
|
+
if (call.function?.name)
|
|
383
|
+
acc.name = call.function.name;
|
|
384
|
+
if (call.function?.arguments)
|
|
385
|
+
acc.args += call.function.arguments;
|
|
386
|
+
toolCalls.set(index, acc);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
const tail = splitter.flush();
|
|
390
|
+
if (tail.reasoning)
|
|
391
|
+
yield partial(tail.reasoning, true);
|
|
392
|
+
if (tail.answer) {
|
|
393
|
+
const text = openAnswer(tail.answer);
|
|
394
|
+
if (text) {
|
|
395
|
+
answer += text;
|
|
396
|
+
yield partial(text, false);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
const parts = [];
|
|
400
|
+
if (answer)
|
|
401
|
+
parts.push({ text: answer });
|
|
402
|
+
for (const [, acc] of [...toolCalls.entries()].sort((a, b) => a[0] - b[0])) {
|
|
403
|
+
let args = {};
|
|
404
|
+
try {
|
|
405
|
+
args = JSON.parse(acc.args || '{}');
|
|
406
|
+
}
|
|
407
|
+
catch {
|
|
408
|
+
args = { raw: acc.args };
|
|
409
|
+
}
|
|
410
|
+
parts.push({ functionCall: { name: acc.name, args, id: acc.id } });
|
|
411
|
+
}
|
|
412
|
+
const usageMetadata = mapUsage(usage);
|
|
413
|
+
yield {
|
|
414
|
+
content: { role: 'model', parts },
|
|
415
|
+
turnComplete: true,
|
|
416
|
+
...(usageMetadata ? { usageMetadata } : {}),
|
|
417
|
+
};
|
|
418
|
+
}
|
|
204
419
|
// ── Translation helpers ────────────────────────────────────────────────────
|
|
205
420
|
/** ADK Contents (Google GenAI format) → OpenAI-compatible chat messages. */
|
|
206
421
|
buildMessages(llmRequest) {
|
|
@@ -40,7 +40,7 @@ export class TracedGemini extends Gemini {
|
|
|
40
40
|
// built-in Gemini entries instead of adding shadowed duplicates.
|
|
41
41
|
static supportedModels = Gemini.supportedModels;
|
|
42
42
|
async *generateContentAsync(llmRequest, stream) {
|
|
43
|
-
yield* traceLlmGeneration({ provider: 'gemini', model: this.model }, this.tagAndGenerate(llmRequest, stream));
|
|
43
|
+
yield* traceLlmGeneration({ provider: 'gemini', model: this.model, llmRequest }, this.tagAndGenerate(llmRequest, stream));
|
|
44
44
|
}
|
|
45
45
|
/** Tags the llm.request span (web_search = Gemini grounding), then
|
|
46
46
|
* delegates to ADK's Gemini. Runs inside the span context. */
|
|
@@ -65,6 +65,11 @@ export interface TurnRow {
|
|
|
65
65
|
user_id: string | null;
|
|
66
66
|
task_id: string | null;
|
|
67
67
|
invocation_id: string | null;
|
|
68
|
+
/** Where the turn came from, when the caller named it (X-Surface-* headers). */
|
|
69
|
+
surface: string | null;
|
|
70
|
+
surface_guild: string | null;
|
|
71
|
+
surface_channel: string | null;
|
|
72
|
+
surface_user: string | null;
|
|
68
73
|
syndicate: string;
|
|
69
74
|
stage: string;
|
|
70
75
|
agent: string | null;
|
|
@@ -105,7 +105,14 @@ export function isRootSpan(span) {
|
|
|
105
105
|
return span.name.startsWith(ROOT_PREFIX);
|
|
106
106
|
}
|
|
107
107
|
export function isPayloadSpan(span) {
|
|
108
|
-
|
|
108
|
+
if (span.name === 'call_llm' && scopeName(span) === ADK_SCOPE)
|
|
109
|
+
return true;
|
|
110
|
+
// Errored calls: ADK's call_llm span is unreliable on error (its end() is
|
|
111
|
+
// skipped when the consumer stops at the error event, and traceCallLlm
|
|
112
|
+
// never ran for a thrown error), so traceLlmGeneration attaches the
|
|
113
|
+
// request + error body to its own llm.request span instead — but only on
|
|
114
|
+
// error, so clean turns never produce a second payload row.
|
|
115
|
+
return span.name === 'llm.request' && !!attrsOf(span)['llm.payload.response'];
|
|
109
116
|
}
|
|
110
117
|
/** `syndicate.input` is JSON.stringify of either a string or A2A parts. */
|
|
111
118
|
export function parseInputText(raw) {
|
|
@@ -197,6 +204,10 @@ export function toTurnRow(span) {
|
|
|
197
204
|
user_id: str(attrs, 'user.id'),
|
|
198
205
|
task_id: str(attrs, 'a2a.task_id'),
|
|
199
206
|
invocation_id: str(attrs, 'adk.invocation_id'),
|
|
207
|
+
surface: str(attrs, 'surface.name'),
|
|
208
|
+
surface_guild: str(attrs, 'surface.guild'),
|
|
209
|
+
surface_channel: str(attrs, 'surface.channel'),
|
|
210
|
+
surface_user: str(attrs, 'surface.user'),
|
|
200
211
|
syndicate: syndicateOf(span) ?? 'unknown',
|
|
201
212
|
stage: str(attrs, 'syndicate.stage') ?? 'delegate',
|
|
202
213
|
agent: str(attrs, 'syndicate.agent'),
|
|
@@ -242,8 +253,8 @@ export function turnFacts(span) {
|
|
|
242
253
|
/** Map an ADK call_llm span into an adk_payloads row. */
|
|
243
254
|
export function toPayloadRow(span, reason, ttlDays, turn) {
|
|
244
255
|
const attrs = attrsOf(span);
|
|
245
|
-
const request = str(attrs, 'gcp.vertex.agent.llm_request') ?? '';
|
|
246
|
-
const response = str(attrs, 'gcp.vertex.agent.llm_response') ?? '';
|
|
256
|
+
const request = str(attrs, 'gcp.vertex.agent.llm_request') ?? str(attrs, 'llm.payload.request') ?? '';
|
|
257
|
+
const response = str(attrs, 'gcp.vertex.agent.llm_response') ?? str(attrs, 'llm.payload.response') ?? '';
|
|
247
258
|
const started = span.startTime[0] * 1000 + span.startTime[1] / 1e6;
|
|
248
259
|
return {
|
|
249
260
|
ts: new Date(started).toISOString(),
|
|
@@ -97,6 +97,14 @@ export interface LlmCallMeta {
|
|
|
97
97
|
provider: string;
|
|
98
98
|
/** The model id as declared in the agent YAML (e.g. 'ollama/qwen3:8b'). */
|
|
99
99
|
model: string;
|
|
100
|
+
/**
|
|
101
|
+
* The full LlmRequest, attached to the span ONLY when the call errors.
|
|
102
|
+
* ADK's own call_llm payload capture is lost on error — the consumer stops
|
|
103
|
+
* pulling after the error event, so that span's end() (not in a finally)
|
|
104
|
+
* never runs and the exporter never sees it. This span always ends, so an
|
|
105
|
+
* errored call keeps its request + error body in adk_payloads regardless.
|
|
106
|
+
*/
|
|
107
|
+
llmRequest?: unknown;
|
|
100
108
|
}
|
|
101
109
|
/**
|
|
102
110
|
* Wraps one adapter `generateContentAsync` invocation in an `llm.request`
|
|
@@ -68,6 +68,13 @@ export function onSpanEnd(listener) {
|
|
|
68
68
|
// on a server they would put every prompt into the log stream.
|
|
69
69
|
const ADK_SCOPE = 'gcp.vertex.agent';
|
|
70
70
|
const PRINT_ALL_SPANS = process.env.OTEL_CONSOLE_ALL_SPANS === 'true';
|
|
71
|
+
// OTEL_CONSOLE_SPANS=false silences the [OTEL_SPAN_JSON] lines entirely.
|
|
72
|
+
// The in-process listeners below still fire and the Supabase sink still
|
|
73
|
+
// records, so this costs no telemetry — it only unclutters a chat session.
|
|
74
|
+
// Read at export time rather than module load, so an entry point can set
|
|
75
|
+
// its own default in main() (syndicate_chat.ts defaults to silent) after
|
|
76
|
+
// loadEnv() has run and before the first span ends.
|
|
77
|
+
const printConsoleSpans = () => process.env.OTEL_CONSOLE_SPANS !== 'false';
|
|
71
78
|
function spanScopeName(span) {
|
|
72
79
|
const s = span;
|
|
73
80
|
return s.instrumentationScope?.name ?? s.instrumentationLibrary?.name ?? '';
|
|
@@ -83,6 +90,8 @@ class JsonConsoleExporter {
|
|
|
83
90
|
/* a listener bug must not break the export pipeline */
|
|
84
91
|
}
|
|
85
92
|
}
|
|
93
|
+
if (!printConsoleSpans())
|
|
94
|
+
continue;
|
|
86
95
|
if (!PRINT_ALL_SPANS && spanScopeName(span) === ADK_SCOPE)
|
|
87
96
|
continue;
|
|
88
97
|
// Create a clean JSON representation of the span
|
|
@@ -101,7 +110,7 @@ class JsonConsoleExporter {
|
|
|
101
110
|
})),
|
|
102
111
|
status: span.status,
|
|
103
112
|
};
|
|
104
|
-
// Print to stdout with a
|
|
113
|
+
// Print to stdout with a fixed prefix so a wrapper can extract spans from a transcript
|
|
105
114
|
console.log(`[OTEL_SPAN_JSON] ${JSON.stringify(jsonSpan)}`);
|
|
106
115
|
}
|
|
107
116
|
resultCallback({ code: ExportResultCode.SUCCESS });
|
|
@@ -367,7 +376,10 @@ export async function* traceAgentRun(stream, metadata) {
|
|
|
367
376
|
if (event.content && event.content.parts) {
|
|
368
377
|
for (const part of event.content.parts) {
|
|
369
378
|
const p = part;
|
|
370
|
-
|
|
379
|
+
// Under SSE the reply streams as partials and is then repeated
|
|
380
|
+
// whole on the final event. Count it once, from the final, or
|
|
381
|
+
// syndicate.output records every answer twice.
|
|
382
|
+
if (p.text && !p.thought && !ev.partial) {
|
|
371
383
|
outputText += p.text;
|
|
372
384
|
if (ev.author)
|
|
373
385
|
respondingAgent = String(ev.author);
|
|
@@ -409,7 +421,7 @@ export async function* traceAgentRun(stream, metadata) {
|
|
|
409
421
|
span.setAttribute('syndicate.agent', respondingAgent);
|
|
410
422
|
if (errorCode) {
|
|
411
423
|
span.setAttribute('syndicate.error.code', errorCode);
|
|
412
|
-
span.setAttribute('syndicate.error.message', errorMessage.slice(0,
|
|
424
|
+
span.setAttribute('syndicate.error.message', errorMessage.slice(0, 4000));
|
|
413
425
|
}
|
|
414
426
|
turnContexts.delete(span.spanContext().traceId);
|
|
415
427
|
const stats = takeTraceStats(span.spanContext().traceId);
|
|
@@ -432,6 +444,37 @@ export async function* traceAgentRun(stream, metadata) {
|
|
|
432
444
|
}
|
|
433
445
|
}
|
|
434
446
|
const THINKING_EVENT_MAX_CHARS = 600;
|
|
447
|
+
const ERROR_PAYLOAD_MAX_CHARS = 200_000;
|
|
448
|
+
const ERROR_MESSAGE_MAX_CHARS = 4_000;
|
|
449
|
+
/** JSON.stringify that never throws and never exceeds the cap. */
|
|
450
|
+
function safePayloadJson(value) {
|
|
451
|
+
let json;
|
|
452
|
+
try {
|
|
453
|
+
json = JSON.stringify(value) ?? String(value);
|
|
454
|
+
}
|
|
455
|
+
catch {
|
|
456
|
+
json = String(value);
|
|
457
|
+
}
|
|
458
|
+
return json.length > ERROR_PAYLOAD_MAX_CHARS
|
|
459
|
+
? json.slice(0, ERROR_PAYLOAD_MAX_CHARS)
|
|
460
|
+
: json;
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Providers throw errors whose message IS the API's JSON error body
|
|
464
|
+
* (Gemini: {"error":{"code":503,"message":...,"status":...}}). Pull the
|
|
465
|
+
* numeric code out so llm.error_code reads "503", not "THROWN".
|
|
466
|
+
*/
|
|
467
|
+
function refineErrorCode(code, message) {
|
|
468
|
+
try {
|
|
469
|
+
const parsed = JSON.parse(message);
|
|
470
|
+
if (parsed?.error?.code)
|
|
471
|
+
return String(parsed.error.code);
|
|
472
|
+
}
|
|
473
|
+
catch {
|
|
474
|
+
/* not a JSON body — keep the code we have */
|
|
475
|
+
}
|
|
476
|
+
return code;
|
|
477
|
+
}
|
|
435
478
|
/**
|
|
436
479
|
* Wraps one adapter `generateContentAsync` invocation in an `llm.request`
|
|
437
480
|
* span — one span per model call, for EVERY provider. Records provider,
|
|
@@ -472,6 +515,9 @@ export async function* traceLlmGeneration(meta, inner) {
|
|
|
472
515
|
let outputTokens = 0;
|
|
473
516
|
let thinkingTokens = 0;
|
|
474
517
|
let thinkingPreview = '';
|
|
518
|
+
let errorCode = '';
|
|
519
|
+
let errorMessage = '';
|
|
520
|
+
let errorResponse;
|
|
475
521
|
const ctx = trace.setSpan(context.active(), span);
|
|
476
522
|
try {
|
|
477
523
|
while (true) {
|
|
@@ -486,7 +532,9 @@ export async function* traceLlmGeneration(meta, inner) {
|
|
|
486
532
|
thinkingTokens = resp.usageMetadata.thoughtsTokenCount ?? thinkingTokens;
|
|
487
533
|
}
|
|
488
534
|
if (resp.errorCode) {
|
|
489
|
-
|
|
535
|
+
errorCode = String(resp.errorCode);
|
|
536
|
+
errorMessage = String(resp.errorMessage ?? '');
|
|
537
|
+
errorResponse = resp;
|
|
490
538
|
}
|
|
491
539
|
for (const part of resp.content?.parts ?? []) {
|
|
492
540
|
const p = part;
|
|
@@ -499,12 +547,27 @@ export async function* traceLlmGeneration(meta, inner) {
|
|
|
499
547
|
}
|
|
500
548
|
catch (error) {
|
|
501
549
|
span.recordException(error);
|
|
550
|
+
// Gemini (and any adapter that throws instead of yielding an error
|
|
551
|
+
// response) lands here; the message is often the raw API error body.
|
|
552
|
+
errorMessage = error instanceof Error ? error.message : String(error);
|
|
553
|
+
errorCode = refineErrorCode(errorCode || 'THROWN', errorMessage);
|
|
554
|
+
errorResponse = { errorCode, errorMessage };
|
|
502
555
|
throw error;
|
|
503
556
|
}
|
|
504
557
|
finally {
|
|
505
558
|
if (thinkingPreview) {
|
|
506
559
|
span.addEvent('llm.thinking', { 'thinking.preview': thinkingPreview });
|
|
507
560
|
}
|
|
561
|
+
if (errorCode) {
|
|
562
|
+
span.setAttribute('llm.error_code', errorCode);
|
|
563
|
+
span.setAttribute('llm.error_message', errorMessage.slice(0, ERROR_MESSAGE_MAX_CHARS));
|
|
564
|
+
// Make this span a payload candidate (see supabaseSpanExporter's
|
|
565
|
+
// isPayloadSpan): the request as sent and the error as received.
|
|
566
|
+
if (meta.llmRequest !== undefined) {
|
|
567
|
+
span.setAttribute('llm.payload.request', safePayloadJson(meta.llmRequest));
|
|
568
|
+
}
|
|
569
|
+
span.setAttribute('llm.payload.response', safePayloadJson(errorResponse ?? { errorCode, errorMessage }));
|
|
570
|
+
}
|
|
508
571
|
span.setAttribute('llm.tokens.input', inputTokens);
|
|
509
572
|
span.setAttribute('llm.tokens.output', outputTokens);
|
|
510
573
|
span.setAttribute('llm.tokens.thinking', thinkingTokens);
|
|
@@ -20,7 +20,7 @@ import { FunctionTool } from '@google/adk';
|
|
|
20
20
|
import { GoogleGenAI } from '@google/genai';
|
|
21
21
|
import { readFileSync } from 'node:fs';
|
|
22
22
|
import { resolve, extname, sep } from 'node:path';
|
|
23
|
-
const VISION_MODEL = 'gemini-3.
|
|
23
|
+
const VISION_MODEL = 'gemini-3.8-flash';
|
|
24
24
|
const INVENTORY_PROTOCOL = `You are a blind visual inventory agent. You know NOTHING about what this image was supposed to be, who requested it, or why. Enumerate ONLY what is actually present:
|
|
25
25
|
|
|
26
26
|
1. SUBJECTS — every distinct subject and object, with EXACT counts.
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* three call sites can't drift.
|
|
12
12
|
*
|
|
13
13
|
* Model routing goes through lib/models/registry.ts, so the YAML-style
|
|
14
|
-
* model string ('gemini-3.
|
|
14
|
+
* model string ('gemini-3.8-flash', 'claude-*', 'ollama/*'…) picks the
|
|
15
15
|
* provider — wiki operations inherit the framework's full model
|
|
16
16
|
* optionality. A missing provider key degrades to a clear error string;
|
|
17
17
|
* nothing here throws for want of credentials.
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* three call sites can't drift.
|
|
12
12
|
*
|
|
13
13
|
* Model routing goes through lib/models/registry.ts, so the YAML-style
|
|
14
|
-
* model string ('gemini-3.
|
|
14
|
+
* model string ('gemini-3.8-flash', 'claude-*', 'ollama/*'…) picks the
|
|
15
15
|
* provider — wiki operations inherit the framework's full model
|
|
16
16
|
* optionality. A missing provider key degrades to a clear error string;
|
|
17
17
|
* nothing here throws for want of credentials.
|
|
@@ -1,2 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
/** One human-readable line naming WHY a turn failed, for the failed task's
|
|
3
|
+
* status message — the text the Discord surface renders verbatim. Provider
|
|
4
|
+
* errors sometimes arrive as a raw JSON blob (Gemini ApiError:
|
|
5
|
+
* `{"error":{"code":503,"message":"...","status":"UNAVAILABLE"}}`), so the
|
|
6
|
+
* inner message is dug out before falling back to the string itself. An
|
|
7
|
+
* empty message keeps the old pointer at the server logs. */
|
|
8
|
+
export declare function describeTurnError(error: {
|
|
9
|
+
code: string;
|
|
10
|
+
message: string;
|
|
11
|
+
}): string;
|
|
2
12
|
export declare function startServer(syndicateName?: string): Promise<void>;
|