melchizedek-agents 0.9.3 → 0.9.4
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 +4 -2
- package/QUICKSTART.md +3 -2
- package/README.md +13 -13
- package/config/agents/examples/cartographers.yaml +2 -2
- package/config/agents/examples/council.yaml +1 -1
- package/config/agents/examples/librarian.yaml +2 -2
- package/config/agents/examples/patient_advocate.yaml +2 -2
- package/config/agents/examples/scriptorium.yaml +2 -2
- package/config/agents/examples/style_council.yaml +3 -3
- package/config/agents/examples/tutor.yaml +63 -33
- 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/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
package/AGENT_SETUP.md
CHANGED
|
@@ -81,7 +81,7 @@ After the agent reports back:
|
|
|
81
81
|
Keyless, if you installed Ollama:
|
|
82
82
|
|
|
83
83
|
```bash
|
|
84
|
-
npm run syndicate:tutor # one open-weight agent: paste material, be taught
|
|
84
|
+
npm run syndicate:tutor # one open-weight agent: name a topic or paste material, be taught
|
|
85
85
|
npm run syndicate:council # advocate/skeptic council, all local
|
|
86
86
|
```
|
|
87
87
|
|
|
@@ -179,7 +179,7 @@ Deploying the server itself (Heroku, Fly, a VPS) needs `PUBLIC_URL` set
|
|
|
179
179
|
— at which point the server refuses to start without the bearer secret,
|
|
180
180
|
by design. Run `db/hardening.sql` before real user data arrives, and
|
|
181
181
|
read the deployment notes in `lib/memory/README.md` end to end. The
|
|
182
|
-
patient in module 2.
|
|
182
|
+
patient in module 2.02's specimen would expect no less of you.
|
|
183
183
|
|
|
184
184
|
## 5. When your agents outgrow the clone
|
|
185
185
|
|
package/DOCUMENTATION.md
CHANGED
|
@@ -331,8 +331,10 @@ Anthropic extended thinking (thinking + tool use on the same Claude
|
|
|
331
331
|
agent is not supported yet).
|
|
332
332
|
|
|
333
333
|
Every model request also emits an `llm.request` OpenTelemetry span
|
|
334
|
-
(provider, model, input/output/thinking tokens, latency)
|
|
335
|
-
`[OTEL_SPAN_JSON]` line;
|
|
334
|
+
(provider, model, input/output/thinking tokens, latency). Scripts print it
|
|
335
|
+
as an `[OTEL_SPAN_JSON]` line; `melchizedek-chat` and the `syndicate:*`
|
|
336
|
+
scripts keep those lines off unless `OTEL_CONSOLE_SPANS=true`. Set
|
|
337
|
+
`TELEMETRY_SUPABASE=true` to persist
|
|
336
338
|
spans to the `adk_telemetry` table (run `db/telemetry.sql`, then
|
|
337
339
|
re-run `db/hardening.sql` — schema below):
|
|
338
340
|
|
package/QUICKSTART.md
CHANGED
|
@@ -180,8 +180,9 @@ const config = loadSyndicate('mine.yaml'); // reads <your-repo>/config/agents
|
|
|
180
180
|
|
|
181
181
|
- Read a syndicate the way the course does: start with
|
|
182
182
|
`config/agents/examples/tutor.yaml` and map its instruction blocks —
|
|
183
|
-
identity,
|
|
184
|
-
|
|
183
|
+
identity, communication style, task, examples — then see the
|
|
184
|
+
anatomy grown to full size, with doctrines and boundaries, in
|
|
185
|
+
`patient_advocate.yaml`.
|
|
185
186
|
- The interactive curriculum walks every pattern in this repo:
|
|
186
187
|
[lyceumagents.com/curriculum](https://lyceumagents.com/curriculum/).
|
|
187
188
|
- Author your own syndicate: everything in `config/agents/examples/` is
|
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ Agent Development Kit — where the entire shape of an agent system is one
|
|
|
5
5
|
readable file.**
|
|
6
6
|
|
|
7
7
|
Melchizedek runs hierarchical agent graphs called *Syndicates*: an
|
|
8
|
-
orchestrator, its subagents, their tools, models, and communication
|
|
8
|
+
orchestrator, its subagents, their tools, models, and communication styles,
|
|
9
9
|
all declared in a single YAML document you can read, diff, and teach
|
|
10
10
|
from. The engine (`lib/`) knows no syndicate by name — `config/agents/`
|
|
11
11
|
is where YOUR syndicates go, and the ones shipped in
|
|
@@ -95,17 +95,17 @@ worked specimen for a curriculum module:
|
|
|
95
95
|
| Syndicate | Pattern | Course module |
|
|
96
96
|
|---|---|---|
|
|
97
97
|
| `tutor.yaml` — Tutor | one open-weight agent, no keys; the instruction block anatomy | [1.02 · running your own model](https://lyceumagents.com/curriculum/your-own-model/) |
|
|
98
|
-
| `council.yaml` — Council | first orchestration, fully local: advocate/skeptic council | [
|
|
99
|
-
| `critic.yaml` — Critic Review | Drafter → Critic confidence loop; quality as a parsed field | [
|
|
100
|
-
| `delegation.yaml` — Router | triage to specialists; descriptions as the routing API | [
|
|
101
|
-
| `hierarchical.yaml` — Decomposer | split one goal, delegate parts, merge | [
|
|
102
|
-
| `style_council.yaml` — Style Council | identical knowledge, three engineered voices | [
|
|
103
|
-
| `syndicate.yaml` — Global Synthesis Council | orchestrator + research subagent; grounding as architecture | [2.
|
|
104
|
-
| `ares.yaml` — Knowledge Keeper | long-term memory pipeline, exercised | [2.
|
|
105
|
-
| `patient_advocate.yaml` — Patient Advocate | memory doctrine: preload + recall, trends, the silent record | [2.
|
|
106
|
-
| `librarian.yaml` — Lyceum Librarian | MCP: tools discovered at runtime; the agent fetches and modifies catalog data | [2.
|
|
107
|
-
| `image_production.yaml` — Image Production | spec-first generation + blind inventory / spec audit | [2.
|
|
108
|
-
| `augustin.yaml` — Augustin | multi-agent, multi-modal fact-checking: X sweep + web verification under a tool-free arbiter | [2.
|
|
98
|
+
| `council.yaml` — Council | first orchestration, fully local: advocate/skeptic council | [1.05 · workflows & voice](https://lyceumagents.com/curriculum/workflows-and-voice/) |
|
|
99
|
+
| `critic.yaml` — Critic Review | Drafter → Critic confidence loop; quality as a parsed field | [1.04 · testing & refinement](https://lyceumagents.com/curriculum/testing-and-refinement/) |
|
|
100
|
+
| `delegation.yaml` — Router | triage to specialists; descriptions as the routing API | [1.05 · workflows & voice](https://lyceumagents.com/curriculum/workflows-and-voice/) |
|
|
101
|
+
| `hierarchical.yaml` — Decomposer | split one goal, delegate parts, merge | [1.05 · workflows & voice](https://lyceumagents.com/curriculum/workflows-and-voice/) |
|
|
102
|
+
| `style_council.yaml` — Style Council | identical knowledge, three engineered voices | [1.05 · workflows & voice](https://lyceumagents.com/curriculum/workflows-and-voice/) |
|
|
103
|
+
| `syndicate.yaml` — Global Synthesis Council | orchestrator + research subagent; grounding as architecture | [2.01 · the protocol](https://lyceumagents.com/curriculum/melchizedek-protocol/) |
|
|
104
|
+
| `ares.yaml` — Knowledge Keeper | long-term memory pipeline, exercised | [2.02 · memory systems](https://lyceumagents.com/curriculum/memory-systems/) |
|
|
105
|
+
| `patient_advocate.yaml` — Patient Advocate | memory doctrine: preload + recall, trends, the silent record | [2.02 · memory systems](https://lyceumagents.com/curriculum/memory-systems/) |
|
|
106
|
+
| `librarian.yaml` — Lyceum Librarian | MCP: tools discovered at runtime; the agent fetches and modifies catalog data | [2.03 · MCP](https://lyceumagents.com/curriculum/mcp-extending-reach/) |
|
|
107
|
+
| `image_production.yaml` — Image Production | spec-first generation + blind inventory / spec audit | [2.04 · multi-modal agents](https://lyceumagents.com/curriculum/multimodal-agents/) |
|
|
108
|
+
| `augustin.yaml` — Augustin | multi-agent, multi-modal fact-checking: X sweep + web verification under a tool-free arbiter | [2.05 · the fact-checking arbiter](https://lyceumagents.com/curriculum/fact-checking-agent/) |
|
|
109
109
|
| `claude.yaml` — Claude Chat | minimal single-agent config; the multi-model adapter in one file | — |
|
|
110
110
|
| `model_zoo.yaml` — Model Zoo | one lightweight agent per provider (Qwen/Claude/Grok/GPT/Gemini); model optionality proven by `npm run demo:models` | — |
|
|
111
111
|
| `scriptorium.yaml` — The Scriptorium | agents over the shipped knowledge bundle: query with citations, author through a validated save gate | — |
|
|
@@ -131,7 +131,7 @@ your own.
|
|
|
131
131
|
corrections supersede old records, which are kept as linked history.
|
|
132
132
|
- **Native tools** — web search, image generation, and a blind
|
|
133
133
|
image-inventory tool whose file-path-only signature makes expectation
|
|
134
|
-
bias structurally impossible (see module 2.
|
|
134
|
+
bias structurally impossible (see module 2.04).
|
|
135
135
|
- **A2A service mode** — serve any syndicate as a JSON-RPC
|
|
136
136
|
agent-to-agent endpoint with bearer auth and rate limiting
|
|
137
137
|
(`npm run start:a2a`).
|
|
@@ -62,11 +62,11 @@ orchestrator:
|
|
|
62
62
|
- Never record a relation the Surveyor did not propose with evidence, and never repair a rejection by inventing a different claim — send it back to the Surveyor to re-read.
|
|
63
63
|
</tool_doctrine>
|
|
64
64
|
|
|
65
|
-
<
|
|
65
|
+
<communication_style>
|
|
66
66
|
Report relations the way they read: "<source> constrains <target> — because '<quotation>'".
|
|
67
67
|
Say plainly when a proposal was rejected and why the gate rejected it. A refused edge is the system working.
|
|
68
68
|
Be concrete: node ids, relation names, document paths. No speculation about what the graph might contain.
|
|
69
|
-
</
|
|
69
|
+
</communication_style>
|
|
70
70
|
|
|
71
71
|
<strict_constraints>
|
|
72
72
|
- Structural relations are DERIVED, never asserted. If a visitor asks you to record who calls a tool or which model an agent runs on, say that the build already knows it and point at wiki_graph.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
# Council — basic orchestration on open weights
|
|
3
3
|
# ============================================================
|
|
4
4
|
#
|
|
5
|
-
# WHY: The curriculum's first multi-agent specimen (module
|
|
5
|
+
# WHY: The curriculum's first multi-agent specimen (module 1.05).
|
|
6
6
|
# Three agents, zero tools, zero keys — every model is a local
|
|
7
7
|
# ollama/qwen3:8b (lib/models/ollamaLlm.ts), so the user's first
|
|
8
8
|
# orchestration runs entirely on their own machine.
|
|
@@ -46,10 +46,10 @@ orchestrator:
|
|
|
46
46
|
- For requests that change the catalog (borrowing, annotating), state what you are about to change, delegate the change, then confirm what the Librarian reports back — including any refusal.
|
|
47
47
|
</tool_doctrine>
|
|
48
48
|
|
|
49
|
-
<
|
|
49
|
+
<communication_style>
|
|
50
50
|
Answer first, provenance second: give the patron the fact, then name the scroll or catalog entry it came from.
|
|
51
51
|
Keep responses short and concrete — titles, authors, statuses, dates. A librarian points; they do not lecture.
|
|
52
|
-
</
|
|
52
|
+
</communication_style>
|
|
53
53
|
|
|
54
54
|
<strict_constraints>
|
|
55
55
|
- Never claim a write succeeded without the Librarian's confirmation of the new state.
|
|
@@ -49,14 +49,14 @@ orchestrator:
|
|
|
49
49
|
- Track TRENDS, not just values: a creatinine of 1.4 mg/dL means one thing alone and another thing as the third rise in a row.
|
|
50
50
|
</memory_doctrine>
|
|
51
51
|
|
|
52
|
-
<
|
|
52
|
+
<communication_style>
|
|
53
53
|
LEAD WITH SUBSTANCE: The direct answer or the most critical safety information goes in the very first sentence. Never open with filler — no "That's a great question", no "I'm sorry to hear that", no restating what the user just told you.
|
|
54
54
|
SCANNABLE LAYOUTS: Use bold headers, short bulleted lists, and blockquotes for critical warnings so the user can read you instantly while standing in a hospital room. Dense, structured, skimmable.
|
|
55
55
|
SPECIFICS OVER GENERALITIES: Concrete data, physiological timelines, precise anatomy. Define every medical term contextually inline the first time it appears — "hypokalemia (low potassium)" — then use it freely.
|
|
56
56
|
ACTIONABLE SCRIPTS: Whenever a medical decision, complication, or communication gap arises, provide an exact, word-for-word script the user can say to the attending doctor or nurse to firmly and respectfully advocate for proper care. Format scripts as blockquotes.
|
|
57
57
|
RED FLAGS VS NORMAL: When discussing symptoms or recovery, explicitly separate "normal / expected" from "emergency red flags" that mean push the call button, call the doctor tonight, or go to the ER. Never leave the user guessing which bucket they are in.
|
|
58
58
|
INTERACTIVE COACHING: End every response with exactly one highly relevant follow-up question or observation that moves the strategy forward. Proactively flag systemic hurdles — weekend staffing gaps, shift changes, discharge pressure, results that post to the portal before anyone calls — and coach the user on navigating them.
|
|
59
|
-
</
|
|
59
|
+
</communication_style>
|
|
60
60
|
|
|
61
61
|
<research_doctrine>
|
|
62
62
|
Delegate to your MedScribe subagent for anything that must be current or sourced: treatment guidelines, drug information and interactions, typical recovery timelines, what a test measures, emerging research. Anchor searches to {{current_date}}.
|
|
@@ -51,10 +51,10 @@ orchestrator:
|
|
|
51
51
|
- For writes: state what you are about to have recorded, delegate, then confirm what the Illuminator reports back — including a rejection by the save gate.
|
|
52
52
|
</tool_doctrine>
|
|
53
53
|
|
|
54
|
-
<
|
|
54
|
+
<communication_style>
|
|
55
55
|
Answer first, provenance second: give the visitor the substance, then the bundle paths it came from.
|
|
56
56
|
Keep responses concrete — paths, titles, statuses, dates. A scriptorium points at pages; it does not orate.
|
|
57
|
-
</
|
|
57
|
+
</communication_style>
|
|
58
58
|
|
|
59
59
|
<strict_constraints>
|
|
60
60
|
- Never claim a document was written or changed without the Illuminator's confirmation naming the path.
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
# ============================================================
|
|
4
4
|
#
|
|
5
5
|
# WHY: A teaching syndicate built for lyceumagents.com curriculum
|
|
6
|
-
# module
|
|
7
|
-
# conducts three stylists with IDENTICAL knowledge and different
|
|
8
|
-
# communication
|
|
6
|
+
# module 1.05 (Agent workflows & conversation styles). One router
|
|
7
|
+
# conducts three stylists with IDENTICAL knowledge and a different
|
|
8
|
+
# communication style. Ask the same question of all three and the
|
|
9
9
|
# answers differ only in register — proving that an agent's voice
|
|
10
10
|
# is authored in its instruction, not emergent from the model.
|
|
11
11
|
#
|
|
@@ -2,23 +2,30 @@
|
|
|
2
2
|
# Tutor — the open-weight single agent
|
|
3
3
|
# ============================================================
|
|
4
4
|
#
|
|
5
|
-
# WHY: The curriculum's first specimen (Part 1, module 1.
|
|
5
|
+
# WHY: The curriculum's first specimen (Part 1, module 1.03). One
|
|
6
6
|
# agent, no subagents, no tools, no API key — the model is an
|
|
7
7
|
# open-weight qwen3:8b served locally by Ollama, so the whole
|
|
8
8
|
# system runs on the user's own machine (lib/models/ollamaLlm.ts).
|
|
9
9
|
#
|
|
10
|
-
# The persona is a tutor in the plainest sense: it teaches
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
10
|
+
# The persona is a tutor in the plainest sense: it teaches
|
|
11
|
+
# whatever the user brings, a topic named in a sentence or
|
|
12
|
+
# material pasted into the conversation, by questioning rather
|
|
13
|
+
# than lecturing. Pasted material, when there is any, is ground
|
|
14
|
+
# truth; without it the Tutor teaches from what is well
|
|
15
|
+
# established and says when it is unsure. Its purpose is
|
|
16
|
+
# deliberately narrow, and that narrowness is the lesson: a
|
|
17
|
+
# general model becomes an instrument when a prompt commits it
|
|
18
|
+
# to one purpose, and the prompt shapes HOW it teaches, not
|
|
19
|
+
# whether it may.
|
|
15
20
|
#
|
|
16
|
-
# The instruction
|
|
17
|
-
# curriculum: <system_identity
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
21
|
+
# The instruction is written in four blocks, the shape the
|
|
22
|
+
# curriculum teaches first: <system_identity> (who the agent is
|
|
23
|
+
# and its one purpose), <communication_style> (how it speaks,
|
|
24
|
+
# as countable rules), <execution_framework> (a procedure it runs on
|
|
25
|
+
# every turn: read, evaluate the user's state, adapt), and <examples> (one
|
|
26
|
+
# worked exchange per state, so the model has a concrete target
|
|
27
|
+
# for each branch of the framework). Later specimens grow more blocks
|
|
28
|
+
# as they gain capabilities (see patient_advocate.yaml).
|
|
22
29
|
#
|
|
23
30
|
# Run it: npm run syndicate:tutor (requires only Ollama +
|
|
24
31
|
# `ollama pull qwen3:8b` — no keys, no database)
|
|
@@ -33,31 +40,54 @@ orchestrator:
|
|
|
33
40
|
instruction: |
|
|
34
41
|
<prompt_instructions>
|
|
35
42
|
<system_identity>
|
|
36
|
-
You are the Tutor
|
|
37
|
-
The user brings you material they are trying to master — an article, lecture notes, documentation, a contract, a textbook passage — pasted directly into the conversation.
|
|
38
|
-
Your single purpose: walk the user through the material they supplied until they genuinely understand it. You are not a general assistant; requests outside teaching the supplied material get redirected to it.
|
|
43
|
+
You are the Tutor. You teach whatever the user brings: a topic named in a sentence, or material pasted into the conversation, whether an article, lecture notes, documentation, a contract, or a textbook passage. You lead by questioning rather than lecturing, so that understanding is reached instead of received. You are not a general assistant; you teach, and a request to do something else is turned back into a lesson. When the user has pasted material, it is your ground truth: when you bring in something it does not say, label it "beyond your text:", and when it is silent on something, say so plainly rather than inventing a quote, a page reference, or a figure. When there is no material, teach from what is well established about the topic, and when you are not sure of a figure, a date, a name, or a quotation, say you are not sure rather than supplying one.
|
|
39
44
|
</system_identity>
|
|
40
45
|
|
|
41
|
-
<
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
- Ground every explanation in the supplied text. When you bring in outside context to aid understanding, label it: "beyond your text:".
|
|
45
|
-
- If the material is silent on something the user asks, say "your material doesn't cover this" — then answer from general knowledge only if the user wants it, still labeled.
|
|
46
|
-
- Never invent quotes, page references, or figures that are not in the material.
|
|
47
|
-
</grounding_doctrine>
|
|
46
|
+
<communication_style>
|
|
47
|
+
Plain, warm, and brief. One idea per turn, never a lecture and never a numbered plan. Define every technical term inline the first time it appears, as in "amortization (paying a debt down on a schedule)", then use it freely. Never name your own rules, number your steps, or label the parts of a reply; the user reads teaching, not the scaffolding behind it. End every reply with exactly one short question that walks the material one step further, and let that question be the only question mark in the reply.
|
|
48
|
+
</communication_style>
|
|
48
49
|
|
|
49
|
-
<
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
EARNED ANSWERS: questions are the path, not a wall. After the user has made two honest attempts, state the idea plainly and point to where their material says it.
|
|
54
|
-
SOCRATIC CLOSE: end every response with exactly one short question that walks the material one step further.
|
|
55
|
-
</communication_laws>
|
|
50
|
+
<execution_framework>
|
|
51
|
+
On the first turn, decide first which of two cases the message is, and stay in that case:
|
|
52
|
+
PASTED MATERIAL: the message contains text to be taught from, such as a passage, notes, or an article, whatever the user says about it. Do three things and nothing else: name the material's topic in one line, subject only and never what it concludes, so a mispaste is caught at once; ask one question that draws out what the user already makes of the passage; and stop. Never state the key idea, the main takeaway, or the conclusion of pasted material in that first turn, even when the user asks for it directly. A request to be told the answer before any attempt is not frustration; it is where the teaching starts, and it gets a question, not the answer.
|
|
53
|
+
TOPIC ONLY: the message names a subject and contains no material. Do three things and nothing else: give one plain sentence that says what the subject is, a definition and no more; ask one question that draws out what the user already pictures about it; and stop. Do not ask for material, and do not cover the topic.
|
|
56
54
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
55
|
+
On every later turn, in this order:
|
|
56
|
+
1. Read the user's latest message. Read the earlier history only as far as you need it, most recent turns first.
|
|
57
|
+
2. Decide which one of three states the user is in:
|
|
58
|
+
MISUNDERSTANDING: their reading conflicts with what the material says, or, with no material, with what is established about the topic.
|
|
59
|
+
CORRECT: their reading matches it.
|
|
60
|
+
FRUSTRATION: they are stuck, annoyed, or asking to be told the answer.
|
|
61
|
+
3. Adapt your reply to that state:
|
|
62
|
+
On a misunderstanding, do not correct it outright. Point to the sentence in the material that bears on it, or, with no material, state the one fact that bears on it, and ask one question that lets the user find the gap themselves.
|
|
63
|
+
On a correct reading, say so in one line, then ask one question that moves one step deeper into the material.
|
|
64
|
+
On frustration, acknowledge it in one line, then hand over the tools for the answer: quote the passage that holds it and name the idea inside it, or, with no material, state the idea in one or two plain sentences. After the user has made two honest attempts at a point, state the idea plainly and point to where their material says it.
|
|
65
|
+
Guardrails: never give more than one idea per turn; never answer the question you just asked; never summarize the whole material or the whole topic unprompted; with pasted material, never state a conclusion the user has not reached until they have made two honest attempts at it. For material with real stakes (medical, legal, financial), teach what the text says and direct the user to the professional who can advise on it.
|
|
66
|
+
</execution_framework>
|
|
67
|
+
|
|
68
|
+
<examples>
|
|
69
|
+
Material: a paragraph explaining that compound interest is interest computed on both the principal and the interest already accrued. User: "Here is my passage. Teach me the key idea in it."
|
|
70
|
+
State: first turn, pasted material.
|
|
71
|
+
Tutor: "Your passage is about compound interest. Before I say anything about it, what do you make of the phrase 'interest already accrued'?"
|
|
72
|
+
|
|
73
|
+
No material. User: "teach me about inflation"
|
|
74
|
+
State: first turn, topic only.
|
|
75
|
+
Tutor: "Inflation is a general rise in prices across an economy over time, so the same money buys a little less than it did before. Before we go further, what do you think makes prices rise in the first place?"
|
|
76
|
+
|
|
77
|
+
Material: a paragraph explaining that compound interest is interest computed on both the principal and the interest already accrued.
|
|
78
|
+
|
|
79
|
+
User: "So compound interest just means a higher rate than simple interest."
|
|
80
|
+
State: misunderstanding.
|
|
81
|
+
Tutor: "Look again at the phrase 'interest already accrued' in your passage. If the rate never changed, what is it about the second year's calculation that makes the total grow faster than the first year's?"
|
|
82
|
+
|
|
83
|
+
User: "Oh, the second year's interest is computed on a bigger amount, because last year's interest got added in."
|
|
84
|
+
State: correct.
|
|
85
|
+
Tutor: "Exactly, and that is the whole mechanism. Your passage mentions the compounding period, so what would change if interest were added monthly instead of yearly?"
|
|
86
|
+
|
|
87
|
+
User: "I've tried twice and I still don't get it. Just tell me."
|
|
88
|
+
State: frustration.
|
|
89
|
+
Tutor: "That is a fair ask after two tries, so here it is plainly: your passage says interest is computed on the principal plus the interest already accrued, which means each period's interest is calculated on a slightly larger base than the last. Reading that sentence now, which word tells you the base is growing?"
|
|
90
|
+
</examples>
|
|
61
91
|
</prompt_instructions>
|
|
62
92
|
generateContentConfig:
|
|
63
93
|
temperature: 0.6
|
|
@@ -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);
|
|
@@ -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>;
|
|
@@ -23,6 +23,33 @@ const requestContextStorage = new AsyncLocalStorage();
|
|
|
23
23
|
const A2A_APP_NAME = 'melchizedek-a2a';
|
|
24
24
|
/** X-User-Id must be short and filesystem/key-safe; anything else is rejected. */
|
|
25
25
|
const SITE_USER_ID_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;
|
|
26
|
+
/** X-Surface-* values obey the same charset rule, for the same reason. */
|
|
27
|
+
const SURFACE_VALUE_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;
|
|
28
|
+
const SURFACE_HEADERS = [
|
|
29
|
+
['x-surface', 'name'],
|
|
30
|
+
['x-surface-guild', 'guild'],
|
|
31
|
+
['x-surface-channel', 'channel'],
|
|
32
|
+
['x-surface-user', 'user'],
|
|
33
|
+
];
|
|
34
|
+
/**
|
|
35
|
+
* Root-span attributes for the caller's surface. Absent headers produce no
|
|
36
|
+
* attributes at all, so a caller that names nothing writes NULLs rather than
|
|
37
|
+
* a row full of 'unknown' — an unnamed surface and a surface named 'unknown'
|
|
38
|
+
* are different facts and the ledger should not conflate them.
|
|
39
|
+
*/
|
|
40
|
+
function surfaceAttributes(ctx) {
|
|
41
|
+
const s = ctx?.surface;
|
|
42
|
+
if (!s)
|
|
43
|
+
return {};
|
|
44
|
+
const attrs = { 'surface.name': s.name };
|
|
45
|
+
if (s.guild)
|
|
46
|
+
attrs['surface.guild'] = s.guild;
|
|
47
|
+
if (s.channel)
|
|
48
|
+
attrs['surface.channel'] = s.channel;
|
|
49
|
+
if (s.user)
|
|
50
|
+
attrs['surface.user'] = s.user;
|
|
51
|
+
return attrs;
|
|
52
|
+
}
|
|
26
53
|
/**
|
|
27
54
|
* Memory/session silo derivation.
|
|
28
55
|
* Base silo: a hash of the caller's API key (one bucket per credential).
|
|
@@ -105,6 +132,27 @@ function publishWorking(eventBus, taskId, contextId, text) {
|
|
|
105
132
|
final: false
|
|
106
133
|
});
|
|
107
134
|
}
|
|
135
|
+
/** One human-readable line naming WHY a turn failed, for the failed task's
|
|
136
|
+
* status message — the text the Discord surface renders verbatim. Provider
|
|
137
|
+
* errors sometimes arrive as a raw JSON blob (Gemini ApiError:
|
|
138
|
+
* `{"error":{"code":503,"message":"...","status":"UNAVAILABLE"}}`), so the
|
|
139
|
+
* inner message is dug out before falling back to the string itself. An
|
|
140
|
+
* empty message keeps the old pointer at the server logs. */
|
|
141
|
+
export function describeTurnError(error) {
|
|
142
|
+
let msg = (error.message ?? '').trim();
|
|
143
|
+
if (msg.startsWith('{')) {
|
|
144
|
+
try {
|
|
145
|
+
const parsed = JSON.parse(msg);
|
|
146
|
+
const inner = parsed?.error?.message ?? parsed?.message;
|
|
147
|
+
if (typeof inner === 'string' && inner.trim())
|
|
148
|
+
msg = inner.trim();
|
|
149
|
+
}
|
|
150
|
+
catch { /* not JSON — use as-is */ }
|
|
151
|
+
}
|
|
152
|
+
if (!msg)
|
|
153
|
+
return 'see server logs for details.';
|
|
154
|
+
return msg.length > 300 ? `${msg.slice(0, 297)}...` : msg;
|
|
155
|
+
}
|
|
108
156
|
// ── Native ADK AgentExecutor Bridge ───────────────────────────────────────
|
|
109
157
|
class SyndicateExecutor {
|
|
110
158
|
config;
|
|
@@ -176,6 +224,7 @@ class SyndicateExecutor {
|
|
|
176
224
|
taskId,
|
|
177
225
|
stage: params.stage,
|
|
178
226
|
configHash: this.configHashFor(),
|
|
227
|
+
attributes: surfaceAttributes(requestContextStorage.getStore()),
|
|
179
228
|
onEnd: () => ({
|
|
180
229
|
'syndicate.relay_fallback': params.stage === 'delegate' && relayFallbackFired(),
|
|
181
230
|
}),
|
|
@@ -518,7 +567,7 @@ class SyndicateExecutor {
|
|
|
518
567
|
sessionService: new ProjectedSessionService(this.sessionService, routeCfg.name)
|
|
519
568
|
});
|
|
520
569
|
if (turn.error) {
|
|
521
|
-
failTask(`Error: [${turn.error.code}] ${resolution.route} failed to answer
|
|
570
|
+
failTask(`Error: [${turn.error.code}] ${resolution.route} failed to answer — ${describeTurnError(turn.error)}`);
|
|
522
571
|
return;
|
|
523
572
|
}
|
|
524
573
|
tokenInfo = turn.tokenInfo;
|
|
@@ -548,7 +597,7 @@ class SyndicateExecutor {
|
|
|
548
597
|
publishToolStatus: true
|
|
549
598
|
});
|
|
550
599
|
if (turn.error) {
|
|
551
|
-
failTask(`Error: [${turn.error.code}] The agent run failed
|
|
600
|
+
failTask(`Error: [${turn.error.code}] The agent run failed — ${describeTurnError(turn.error)}`);
|
|
552
601
|
return;
|
|
553
602
|
}
|
|
554
603
|
tokenInfo = turn.tokenInfo;
|
|
@@ -794,7 +843,30 @@ export async function startServer(syndicateName = 'syndicate.yaml') {
|
|
|
794
843
|
}
|
|
795
844
|
siteUserId = rawUserId;
|
|
796
845
|
}
|
|
797
|
-
|
|
846
|
+
// X-Surface-* (optional): where this request came from, for telemetry
|
|
847
|
+
// only. Same validation as X-User-Id, and a bad value is refused rather
|
|
848
|
+
// than dropped — a silently ignored header makes a dashboard lie about
|
|
849
|
+
// coverage. `X-Surface` is required for the others to mean anything.
|
|
850
|
+
let surface;
|
|
851
|
+
for (const [header, field] of SURFACE_HEADERS) {
|
|
852
|
+
const raw = req.headers[header];
|
|
853
|
+
if (raw === undefined || raw === '')
|
|
854
|
+
continue;
|
|
855
|
+
if (!SURFACE_VALUE_PATTERN.test(raw)) {
|
|
856
|
+
res.status(400).json({ error: `Invalid ${header}: must match [A-Za-z0-9._-]{1,64}` });
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
if (field === 'name') {
|
|
860
|
+
surface = { name: raw };
|
|
861
|
+
continue;
|
|
862
|
+
}
|
|
863
|
+
if (!surface) {
|
|
864
|
+
res.status(400).json({ error: `${header} requires X-Surface to name the surface` });
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
surface[field] = raw;
|
|
868
|
+
}
|
|
869
|
+
requestContextStorage.run({ apiKey, provider, siteUserId, surface }, () => {
|
|
798
870
|
next();
|
|
799
871
|
});
|
|
800
872
|
});
|
|
@@ -5,7 +5,7 @@ import { join } from 'node:path';
|
|
|
5
5
|
import { loadSyndicate, parseCliBindings } from "../lib/loadSyndicate.js";
|
|
6
6
|
import { traceAgentRun } from "../lib/observability/tracer.js";
|
|
7
7
|
// ADK Imports
|
|
8
|
-
import { LlmAgent, Runner, InMemorySessionService, getFunctionCalls, getFunctionResponses, AgentTool, setLogLevel, LogLevel } from '@google/adk';
|
|
8
|
+
import { LlmAgent, Runner, InMemorySessionService, getFunctionCalls, getFunctionResponses, AgentTool, setLogLevel, LogLevel, StreamingMode } from '@google/adk';
|
|
9
9
|
import { randomUUID } from 'node:crypto';
|
|
10
10
|
// Tool resolution (shared registry) + MCP factory
|
|
11
11
|
import { resolveTools as resolveNamedTools } from "../lib/toolRegistry.js";
|
|
@@ -94,6 +94,12 @@ function banner(config, persistence, bindings = {}, sessionId) {
|
|
|
94
94
|
}
|
|
95
95
|
async function main() {
|
|
96
96
|
loadEnv(import.meta.url);
|
|
97
|
+
// Interactive chat is for reading the agent, not its telemetry: the
|
|
98
|
+
// [OTEL_SPAN_JSON] lines are off here unless OTEL_CONSOLE_SPANS is set
|
|
99
|
+
// (in .env, which loadEnv() has just read, or in the shell). Spans still
|
|
100
|
+
// reach the in-process listeners and the Supabase sink either way.
|
|
101
|
+
if (process.env.OTEL_CONSOLE_SPANS === undefined)
|
|
102
|
+
process.env.OTEL_CONSOLE_SPANS = 'false';
|
|
97
103
|
// ── Register LLM providers ───────────────────────────────────────────────
|
|
98
104
|
// WHY: Must run AFTER loadEnv() so that API keys from .env are available.
|
|
99
105
|
// One call registers every adapter whose credentials exist (Ollama needs
|
|
@@ -119,14 +125,17 @@ async function main() {
|
|
|
119
125
|
break;
|
|
120
126
|
}
|
|
121
127
|
}
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
|
|
128
|
+
// ONLY what the caller actually asked for on the command line.
|
|
129
|
+
//
|
|
130
|
+
// WHY nothing is seeded here: loadSyndicate merges these OVER a syndicate's
|
|
131
|
+
// own `variables:` block, so a hardcoded default is not a fallback — it is
|
|
132
|
+
// an override applied to every syndicate. `headline_count: 3` used to ride
|
|
133
|
+
// along into unrelated agents (a Tutor turn announced it in the banner and
|
|
134
|
+
// recorded it in syndicate.bindings telemetry) while silently beating the
|
|
135
|
+
// 5 that syndicate.yaml declares for itself. Universal tokens belong to
|
|
136
|
+
// defaultBindings() in lib/loadSyndicate.ts, which the loader applies UNDER
|
|
137
|
+
// the YAML — that is where current_date lives, timezone-pinned.
|
|
138
|
+
const mergedBindings = parseCliBindings(argv);
|
|
130
139
|
// Load the syndicate definition
|
|
131
140
|
const config = loadSyndicate(syndicateFile, {
|
|
132
141
|
bindings: mergedBindings,
|
|
@@ -162,6 +171,9 @@ async function main() {
|
|
|
162
171
|
// people) — otherwise every CLI session shares the 'local-user' bucket.
|
|
163
172
|
const SESSION_USER_ID = process.env.MELCHIZEDEK_USER_ID?.trim() || 'local-user';
|
|
164
173
|
const SESSION_ID = randomUUID();
|
|
174
|
+
// Live token-by-token output. Off gives one block per turn — useful when
|
|
175
|
+
// piping a transcript, or for an agent whose reply is structured JSON.
|
|
176
|
+
const STREAM_REPLIES = process.env.CHAT_STREAMING !== 'false';
|
|
165
177
|
// ── Detect persistence mode ──────────────────────────────────────────────
|
|
166
178
|
const persistence = detectPersistenceConfig(config.memory_system);
|
|
167
179
|
// ── Tool resolver ─────────────────────────────────────────────────────────
|
|
@@ -327,7 +339,13 @@ async function main() {
|
|
|
327
339
|
let stream = runner.runAsync({
|
|
328
340
|
userId: SESSION_USER_ID,
|
|
329
341
|
sessionId: SESSION_ID,
|
|
330
|
-
newMessage: { role: 'user', parts: [{ text: trimmed }] }
|
|
342
|
+
newMessage: { role: 'user', parts: [{ text: trimmed }] },
|
|
343
|
+
// SSE makes the adapters emit display-only partials as tokens
|
|
344
|
+
// land, so thinking and the reply appear as they are produced
|
|
345
|
+
// rather than in one block when the call returns.
|
|
346
|
+
runConfig: {
|
|
347
|
+
streamingMode: STREAM_REPLIES ? StreamingMode.SSE : StreamingMode.NONE
|
|
348
|
+
}
|
|
331
349
|
});
|
|
332
350
|
stream = traceAgentRun(stream, {
|
|
333
351
|
syndicateName: config.syndicate_name || 'melchizedek-syndicate',
|
|
@@ -335,7 +353,13 @@ async function main() {
|
|
|
335
353
|
input: trimmed
|
|
336
354
|
});
|
|
337
355
|
let currentMode = 'none';
|
|
356
|
+
// Under SSE the reply arrives as partials and is then REPEATED whole
|
|
357
|
+
// on the final event — the one ADK persists to session history. Show
|
|
358
|
+
// the partials, skip the repeat. With streaming off nothing is
|
|
359
|
+
// partial, so this stays false and every event prints as before.
|
|
360
|
+
let streamedText = false;
|
|
338
361
|
for await (const event of stream) {
|
|
362
|
+
const isPartial = event.partial === true;
|
|
339
363
|
const calls = getFunctionCalls(event);
|
|
340
364
|
if (calls && calls.length > 0) {
|
|
341
365
|
for (const call of calls) {
|
|
@@ -371,6 +395,8 @@ async function main() {
|
|
|
371
395
|
process.stdout.write(c.dim + part.text + c.reset);
|
|
372
396
|
}
|
|
373
397
|
else if (part.text) {
|
|
398
|
+
if (!isPartial && streamedText)
|
|
399
|
+
continue;
|
|
374
400
|
if (currentMode === 'thinking') {
|
|
375
401
|
console.log(`\n\n${c.magenta}${c.bold}${event.author}${c.reset} › `);
|
|
376
402
|
currentMode = 'text';
|
|
@@ -380,6 +406,8 @@ async function main() {
|
|
|
380
406
|
currentMode = 'text';
|
|
381
407
|
}
|
|
382
408
|
process.stdout.write(part.text);
|
|
409
|
+
if (isPartial)
|
|
410
|
+
streamedText = true;
|
|
383
411
|
}
|
|
384
412
|
else if (part.inlineData) {
|
|
385
413
|
// WHY: Image generation models return binary data as base64-encoded
|
|
@@ -396,6 +424,9 @@ async function main() {
|
|
|
396
424
|
}
|
|
397
425
|
}
|
|
398
426
|
}
|
|
427
|
+
// A complete event ends the turn; the next one starts fresh.
|
|
428
|
+
if (!isPartial)
|
|
429
|
+
streamedText = false;
|
|
399
430
|
}
|
|
400
431
|
console.log('\n');
|
|
401
432
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "melchizedek-agents",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.4",
|
|
4
4
|
"description": "A multi-model, multi-agent orchestration framework on Google ADK \u2014 YAML-defined syndicates, Supabase session persistence, pgvector long-term memory. Companion repo to the lyceumagents.com curriculum.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|