@unoverse-platform/studio 0.1.90 → 0.1.92
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/local/plugin.ts +21 -65
- package/package.json +13 -3
- package/vendor/base/src/agent-mcp/discovery.ts +203 -0
- package/vendor/base/src/agent-mcp/handoff.ts +56 -0
- package/vendor/base/src/agent-mcp/index.ts +44 -0
- package/vendor/base/src/agent-mcp/invoke.ts +153 -0
- package/vendor/base/src/agent-mcp/lean.ts +114 -0
- package/vendor/base/src/agent-mcp/memoryContext.ts +104 -0
- package/vendor/base/src/agent-mcp/memoryIngest.ts +88 -0
- package/vendor/base/src/agent-mcp/rowComponents.ts +234 -0
- package/vendor/base/src/agent-mcp/tools.ts +134 -0
- package/vendor/base/src/agent-mcp/types.ts +138 -0
- package/vendor/base/src/auth/auth.ts +307 -0
- package/vendor/base/src/auth/authConfig.ts +73 -0
- package/vendor/base/src/boot.ts +245 -0
- package/vendor/base/src/definitions/definitions.ts +18 -0
- package/vendor/base/src/definitions/dsPackage.ts +45 -0
- package/vendor/base/src/definitions/fsCache.ts +155 -0
- package/vendor/base/src/definitions/index.ts +19 -0
- package/vendor/base/src/definitions/inputs.ts +30 -0
- package/vendor/base/src/definitions/load/compile/component.ts +80 -0
- package/vendor/base/src/definitions/load/compile/index.ts +12 -0
- package/vendor/base/src/definitions/load/compile/sections.ts +132 -0
- package/vendor/base/src/definitions/load/compile/template.ts +153 -0
- package/vendor/base/src/definitions/load/expand/includes.ts +27 -0
- package/vendor/base/src/definitions/load/expand/index.ts +17 -0
- package/vendor/base/src/definitions/load/expand/inherit.ts +169 -0
- package/vendor/base/src/definitions/load/expand/referenced.ts +164 -0
- package/vendor/base/src/definitions/load/expand/refs.ts +106 -0
- package/vendor/base/src/definitions/load/expand/walk.ts +91 -0
- package/vendor/base/src/definitions/load/load.ts +487 -0
- package/vendor/base/src/definitions/model.ts +238 -0
- package/vendor/base/src/definitions/registry/catalog.ts +659 -0
- package/vendor/base/src/definitions/registry/homes.ts +252 -0
- package/vendor/base/src/definitions/theme.ts +383 -0
- package/vendor/base/src/executor.ts +284 -0
- package/vendor/base/src/http/uwsFetchAdapter.ts +397 -0
- package/vendor/base/src/items/baseVersion.ts +96 -0
- package/vendor/base/src/items/collect.ts +246 -0
- package/vendor/base/src/items/fingerprint.ts +34 -0
- package/vendor/base/src/items/publish.ts +212 -0
- package/vendor/base/src/lint/context.mjs +56 -0
- package/vendor/base/src/lint/design/defs.mjs +46 -0
- package/vendor/base/src/lint/design/file.mjs +737 -0
- package/vendor/base/src/lint/design/index.d.mts +30 -0
- package/vendor/base/src/lint/design/index.mjs +996 -0
- package/vendor/base/src/lint/design/tokens.mjs +128 -0
- package/vendor/base/src/lint/design/vocabulary.mjs +181 -0
- package/vendor/base/src/lint/design/walk.mjs +444 -0
- package/vendor/base/src/lint/index.mjs +28 -0
- package/vendor/base/src/lint/nodes/blocks.mjs +63 -0
- package/vendor/base/src/lint/nodes/index.d.mts +28 -0
- package/vendor/base/src/lint/nodes/index.mjs +82 -0
- package/vendor/base/src/lint/nodes/load.mjs +126 -0
- package/vendor/base/src/lint/nodes/node.mjs +764 -0
- package/vendor/base/src/lint/nodes/package.mjs +112 -0
- package/vendor/base/src/lint/nodes/schema.mjs +75 -0
- package/vendor/base/src/logger.ts +33 -0
- package/vendor/base/src/manifests/compose.ts +539 -0
- package/vendor/base/src/manifests/executor/callback.ts +107 -0
- package/vendor/base/src/manifests/executor/context.ts +110 -0
- package/vendor/base/src/manifests/executor/index.ts +47 -0
- package/vendor/base/src/manifests/executor/lookup.ts +21 -0
- package/vendor/base/src/manifests/executor/memory.ts +62 -0
- package/vendor/base/src/manifests/executor/promise.ts +71 -0
- package/vendor/base/src/manifests/executor/refire.ts +34 -0
- package/vendor/base/src/manifests/executor/send.ts +65 -0
- package/vendor/base/src/manifests/executor/session.ts +46 -0
- package/vendor/base/src/manifests/executor/toolBridge.ts +266 -0
- package/vendor/base/src/manifests/index.ts +280 -0
- package/vendor/base/src/manifests/integrity.ts +93 -0
- package/vendor/base/src/manifests/rows.ts +33 -0
- package/vendor/base/src/manifests/runtime/auth/aws.ts +240 -0
- package/vendor/base/src/manifests/runtime/auth/oauth.ts +76 -0
- package/vendor/base/src/manifests/runtime/authorize.ts +159 -0
- package/vendor/base/src/manifests/runtime/channels/final.ts +171 -0
- package/vendor/base/src/manifests/runtime/channels/service.ts +138 -0
- package/vendor/base/src/manifests/runtime/channels/workflow.ts +310 -0
- package/vendor/base/src/manifests/runtime/context.ts +156 -0
- package/vendor/base/src/manifests/runtime/docstore/handlers.ts +547 -0
- package/vendor/base/src/manifests/runtime/docstore/index.ts +135 -0
- package/vendor/base/src/manifests/runtime/docstore/sectionizer.ts +184 -0
- package/vendor/base/src/manifests/runtime/docstore/store.ts +172 -0
- package/vendor/base/src/manifests/runtime/docstore/types.ts +60 -0
- package/vendor/base/src/manifests/runtime/duplex/audioLane.ts +165 -0
- package/vendor/base/src/manifests/runtime/duplex/session.ts +511 -0
- package/vendor/base/src/manifests/runtime/events.ts +223 -0
- package/vendor/base/src/manifests/runtime/helpers.ts +53 -0
- package/vendor/base/src/manifests/runtime/http/allowedHosts.ts +118 -0
- package/vendor/base/src/manifests/runtime/http/request.ts +637 -0
- package/vendor/base/src/manifests/runtime/http/response.ts +162 -0
- package/vendor/base/src/manifests/runtime/index.ts +57 -0
- package/vendor/base/src/manifests/runtime/loops/chunk.ts +98 -0
- package/vendor/base/src/manifests/runtime/loops/loop.ts +191 -0
- package/vendor/base/src/manifests/runtime/loops/paginate.ts +143 -0
- package/vendor/base/src/manifests/runtime/loops/poll.ts +169 -0
- package/vendor/base/src/manifests/runtime/narrate.ts +53 -0
- package/vendor/base/src/manifests/runtime/state.ts +171 -0
- package/vendor/base/src/manifests/runtime/templating.ts +110 -0
- package/vendor/base/src/manifests/runtime/tools/regionSkills.ts +125 -0
- package/vendor/base/src/manifests/runtime/tools/toolloop.ts +550 -0
- package/vendor/base/src/manifests/runtime/usage.ts +87 -0
- package/vendor/base/src/manifests/source.ts +196 -0
- package/vendor/base/src/manifests/watch.ts +67 -0
- package/vendor/base/src/markdown/document.ts +141 -0
- package/vendor/base/src/paths.ts +180 -0
- package/vendor/base/src/platform/audioManager.ts +100 -0
- package/vendor/base/src/platform/clientTransport.ts +65 -0
- package/vendor/base/src/platform/executionContext.ts +157 -0
- package/vendor/base/src/platform/index.ts +10 -0
- package/vendor/base/src/platform/pluginAPI.ts +146 -0
- package/vendor/base/src/platform/redis.ts +54 -0
- package/vendor/base/src/platform/serviceCalls.ts +222 -0
- package/vendor/base/src/pluginBase.ts +248 -0
- package/vendor/base/src/plugins/README.md +57 -0
- package/vendor/base/src/plugins/discovery.ts +87 -0
- package/vendor/base/src/plugins/index.ts +16 -0
- package/vendor/base/src/plugins/loader.ts +179 -0
- package/vendor/base/src/plugins/redis.ts +36 -0
- package/vendor/base/src/plugins/state.ts +53 -0
- package/vendor/base/src/registry.ts +95 -0
- package/vendor/base/src/security/permissions.ts +93 -0
- package/vendor/base/src/security/rules.ts +73 -0
- package/vendor/base/src/template/SafeExpression.ts +556 -0
- package/vendor/base/src/template/StringTemplateResolver.ts +75 -0
- package/vendor/base/src/template/index.ts +22 -0
- package/vendor/base/src/types.ts +50 -0
- package/vendor/base/src/workflow/GetMemoryService.ts +158 -0
- package/vendor/base/src/workflow/PlatformMemoryCore.ts +449 -0
- package/vendor/base/src/workflow/routing.ts +122 -0
- package/vendor/sdk/lib/core/store.ts +26 -6
- package/local/mcp.ts +0 -265
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tool loop. Counting and stopping, nothing else.
|
|
3
|
+
*
|
|
4
|
+
* NOTHING HERE IS VENDOR-SPECIFIC, and that is now literally true rather than a claim I
|
|
5
|
+
* made while hardcoding one vendor's protocol. How a tool is offered, how a call arrives,
|
|
6
|
+
* how a result goes back, how turns chain: all of it describes an API, so all of it lives
|
|
7
|
+
* in the node's api.yaml. A different vendor is a different manifest, not another adapter
|
|
8
|
+
* file in the platform.
|
|
9
|
+
*
|
|
10
|
+
* What is left is the part data cannot express:
|
|
11
|
+
* - how many turns, and stopping at the bound
|
|
12
|
+
* - spotting a stuck loop (the same call with the same arguments)
|
|
13
|
+
* - minting what discovery unlocks, via the harness
|
|
14
|
+
* - narrator timing: a line at 0ms, never awaited, fall back, drop late ones
|
|
15
|
+
*
|
|
16
|
+
* Every MCP judgement belongs to plugin-base/agent-mcp and arrives through the
|
|
17
|
+
* ToolBridge. That is the harness, and the reason agent families drifted apart is that
|
|
18
|
+
* each one re-made those judgements itself.
|
|
19
|
+
*/
|
|
20
|
+
import { saveMCPTraceToWorkflow } from "../../../platform/serviceCalls.js";
|
|
21
|
+
import type { ComposedNode } from "../../compose.js";
|
|
22
|
+
import type { RunContext } from "../context.js";
|
|
23
|
+
import { evaluate, render } from "../templating.js";
|
|
24
|
+
import { sendRequest } from "../http/request.js";
|
|
25
|
+
import { readSse, assertOk } from "../http/response.js";
|
|
26
|
+
import type { Emitter } from "../events.js";
|
|
27
|
+
import { applyInForce, renderSkillsText, type InForceSkill } from "./regionSkills.js";
|
|
28
|
+
import type { StateStore } from "../state.js";
|
|
29
|
+
import { makeUsageCollector } from "../usage.js";
|
|
30
|
+
|
|
31
|
+
/** How the loop reaches MCP tools. Injected, so the loop never touches a platform global. */
|
|
32
|
+
export interface ToolBridge {
|
|
33
|
+
/** Core tools from the provider's getSchema, as { name, description, parameters }. */
|
|
34
|
+
discover(): Promise<any[]>;
|
|
35
|
+
/** Execute one call; returns the raw result content. */
|
|
36
|
+
call(name: string, args: any): Promise<string>;
|
|
37
|
+
/**
|
|
38
|
+
* Absorb one tool reply: unlock + shell-open any apps it discovered, and hand back the
|
|
39
|
+
* MODEL-FACING form of it plus any tools to register.
|
|
40
|
+
*
|
|
41
|
+
* ONE method, not a mint call and a lean call, because they are one step. Splitting them
|
|
42
|
+
* is what let the shell-open between them go missing without anything noticing.
|
|
43
|
+
*/
|
|
44
|
+
absorb(
|
|
45
|
+
toolName: string,
|
|
46
|
+
resultContent: string,
|
|
47
|
+
): Promise<{ content: string; minted: any[]; skills?: Array<{ name: string; title?: string; instructions: string; regionId?: string; regionName?: string | null; ms?: number }> }>;
|
|
48
|
+
/** Does this exchange end the turn? Depends on which tools were WIRED. */
|
|
49
|
+
endsTurn(calls: { name: string; resultContent: string }[]): boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Best-effort parse of a tool result. MCP carries results as TEXT, so `bridge.call`
|
|
54
|
+
* hands back a string even when the tool returned structured data. Anywhere a HUMAN
|
|
55
|
+
* or a template reads the result (the execution trace, the `from: tool` connector
|
|
56
|
+
* emissions), the parsed object is the readable form; a tool that legitimately
|
|
57
|
+
* returns prose keeps its string. The MODEL-facing lanes (toolExchange /
|
|
58
|
+
* function_call_output) must stay strings per the vendor wire — never feed them this.
|
|
59
|
+
*/
|
|
60
|
+
export function parseMaybeJson(result: unknown): unknown {
|
|
61
|
+
if (typeof result !== "string") return result;
|
|
62
|
+
try {
|
|
63
|
+
return JSON.parse(result);
|
|
64
|
+
} catch {
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* ONE TOOL CALL, recorded for the execution timeline.
|
|
71
|
+
*
|
|
72
|
+
* FIRE AND FORGET. Observability must never slow a tool loop or fail a run because the
|
|
73
|
+
* analytics hop was busy, so nothing here is awaited and every error is swallowed to a warn.
|
|
74
|
+
*
|
|
75
|
+
* SKIPPED without an executionId, which is the node-test and headless case: there is no
|
|
76
|
+
* execution to attach a bar to, and posting a trace with no parent would create an orphan
|
|
77
|
+
* row nothing renders.
|
|
78
|
+
*
|
|
79
|
+
* EXPORTED so the duplex session draws the same bar. Shared, not copied: two implementations
|
|
80
|
+
* of "what a tool call looks like on a timeline" drift the first time either is touched.
|
|
81
|
+
*/
|
|
82
|
+
export function recordToolTrace(
|
|
83
|
+
node: ComposedNode,
|
|
84
|
+
ctx: RunContext,
|
|
85
|
+
toolName: string,
|
|
86
|
+
args: unknown,
|
|
87
|
+
result: unknown,
|
|
88
|
+
startTime: number,
|
|
89
|
+
success: boolean,
|
|
90
|
+
/**
|
|
91
|
+
* WHAT SORT OF STEP, defaulting to a tool call. A skill put in force by the platform is
|
|
92
|
+
* on the same timeline but is not a tool the model chose, so it is told apart by a stated
|
|
93
|
+
* kind rather than by the viewer sniffing its name.
|
|
94
|
+
*/
|
|
95
|
+
kind: "tool" | "skill" = "tool",
|
|
96
|
+
): void {
|
|
97
|
+
const executionId = (ctx as any)?.scope?.executionId;
|
|
98
|
+
const parentNodeId = (ctx as any)?.scope?.nodeId;
|
|
99
|
+
if (!executionId || !parentNodeId) return;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* PARSED, so the trace viewer shows a readable object rather than one escaped string.
|
|
103
|
+
*
|
|
104
|
+
* A tool result crosses MCP as text, so `bridge.call` hands back a string. Storing it as
|
|
105
|
+
* one made the timeline's output pane a wall of `"[{\"universal_id\":\"...` that nobody
|
|
106
|
+
* can read — which defeats the point of recording it. The parse is best-effort: a tool
|
|
107
|
+
* that legitimately returns prose keeps its string.
|
|
108
|
+
*/
|
|
109
|
+
const parsed = parseMaybeJson(result);
|
|
110
|
+
|
|
111
|
+
const endTime = Date.now();
|
|
112
|
+
void saveMCPTraceToWorkflow({
|
|
113
|
+
executionId,
|
|
114
|
+
parentNodeId,
|
|
115
|
+
toolName,
|
|
116
|
+
arguments: args,
|
|
117
|
+
result: parsed,
|
|
118
|
+
startTime,
|
|
119
|
+
endTime,
|
|
120
|
+
duration: endTime - startTime,
|
|
121
|
+
success,
|
|
122
|
+
kind,
|
|
123
|
+
}).catch((err: any) => console.warn(`[manifests] ${node.type}: could not save MCP trace: ${err?.message ?? err}`));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function runToolLoop(
|
|
127
|
+
node: ComposedNode,
|
|
128
|
+
ctx: RunContext,
|
|
129
|
+
bridge: ToolBridge,
|
|
130
|
+
/** The one path out. The loop emits a tool RESULT through it; the rest is the stream. */
|
|
131
|
+
emitter: Emitter,
|
|
132
|
+
/** Narration is owned by the caller: it fires on every run, tools or not. */
|
|
133
|
+
narrate: (event: Record<string, unknown>) => void = () => {},
|
|
134
|
+
/**
|
|
135
|
+
* The call the loop drives, which is the node's LAST one. Passed in rather than read off
|
|
136
|
+
* the node, because any earlier calls have already been made once and their replies are
|
|
137
|
+
* in `ctx.calls`: a tool loop re-sends the model call, never the lookups that set it up.
|
|
138
|
+
*
|
|
139
|
+
* Named modelCall and not `call`, because `call` below is one TOOL call in a turn. Two
|
|
140
|
+
* unrelated things with one name in one function is how a wrong-variable bug gets in.
|
|
141
|
+
*/
|
|
142
|
+
modelCall: any = node.api?.run?.[node.api.run.length - 1],
|
|
143
|
+
/**
|
|
144
|
+
* Only `continuity.remember` reads this. Optional because most nodes declare no chain to
|
|
145
|
+
* remember, and a run with no store degrades to "the chain is not remembered" rather than
|
|
146
|
+
* failing — the same way every other state read here degrades to cold.
|
|
147
|
+
*/
|
|
148
|
+
store?: StateStore,
|
|
149
|
+
): Promise<number> {
|
|
150
|
+
const api = node.api!;
|
|
151
|
+
// Rendered: the turn budget is what makes a node an agent rather than a one-shot
|
|
152
|
+
// generator, so it is a dial a person sets, not a constant the manifest bakes in.
|
|
153
|
+
const te = render(api.toolExchange, ctx);
|
|
154
|
+
const maxTurns = Number(te.maxTurns) || 10;
|
|
155
|
+
const stuckAfter = Number(te.stuckAfterRepeats) || 3;
|
|
156
|
+
const connector = te.from ?? "mcpService";
|
|
157
|
+
|
|
158
|
+
// Grows during the run: a later turn sees what an earlier turn unlocked.
|
|
159
|
+
const offer = (t: any) => evaluate(te.tool, { tool: t });
|
|
160
|
+
const live: any[] = await offerDiscovered(te, bridge);
|
|
161
|
+
|
|
162
|
+
const seen = new Map<string, number>();
|
|
163
|
+
let chainId: string | undefined;
|
|
164
|
+
let results: unknown[] | null = null;
|
|
165
|
+
let turn = 0;
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* THE TRANSCRIPT, for vendors that have no chain id.
|
|
169
|
+
*
|
|
170
|
+
* OpenAI's Responses API hands back a `previous_response_id` and the next turn just names it, which
|
|
171
|
+
* is what `continuity` models. Chat Completions — GLM, Grok, and most OpenAI-COMPATIBLE vendors —
|
|
172
|
+
* has no such thing: the whole `messages` array is resent every turn, growing by the assistant's
|
|
173
|
+
* tool-call turn and one message per result.
|
|
174
|
+
*
|
|
175
|
+
* So the loop keeps that array and exposes it to the request as `transcript`. The manifest spreads
|
|
176
|
+
* it into its own messages, which keeps the vendor's shape in the manifest where it belongs:
|
|
177
|
+
*
|
|
178
|
+
* messages: [{ role: 'system', … }, { role: 'user', … }, ...transcript]
|
|
179
|
+
*
|
|
180
|
+
* Empty on turn one, so a node that never calls a tool sends exactly what it always did.
|
|
181
|
+
*/
|
|
182
|
+
const transcript: unknown[] = [];
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* SKILLS IN FORCE, keyed by name so a skill is never loaded twice.
|
|
186
|
+
*
|
|
187
|
+
* A conversation that searches five times in the same region resolves the same skill five
|
|
188
|
+
* times; it must be added once. The map IS the "already loaded" test, which is why the
|
|
189
|
+
* decision lives in one place rather than at each call site.
|
|
190
|
+
*
|
|
191
|
+
* SEEDED FROM STATE, so a skill survives the run that loaded it. Every user message is a
|
|
192
|
+
* fresh execution and a fresh loop: held in memory alone, a skill would be re-applied from
|
|
193
|
+
* nothing on the next message and its event would fire again as though it were new.
|
|
194
|
+
*/
|
|
195
|
+
const skills = new Map<string, InForceSkill>();
|
|
196
|
+
const skillsKey = te.skills?.remember?.key ? String(te.skills.remember.key) : null;
|
|
197
|
+
if (skillsKey && store) {
|
|
198
|
+
const held = await store.read(skillsKey).catch(() => ({}) as Record<string, unknown>);
|
|
199
|
+
for (const s of (held?.skills as any[]) ?? []) if (s?.name && s?.instructions) skills.set(s.name, s);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* What the manifest spreads into its own system prompt, the same way it spreads the
|
|
204
|
+
* transcript into its messages. The platform owns WHICH skills apply; the vendor's shape
|
|
205
|
+
* stays in the manifest:
|
|
206
|
+
*
|
|
207
|
+
* instructions: "{{ config.systemPrompt }}{{ skillsText }}"
|
|
208
|
+
*
|
|
209
|
+
* The wording lives in `regionSkills.ts` so the socket runtime says the identical thing:
|
|
210
|
+
* two copies of a preamble are two things to drift, and a voice call and a typed one
|
|
211
|
+
* describing a skill's authority differently would be a real behavioural difference.
|
|
212
|
+
*/
|
|
213
|
+
const renderSkills = (): string => renderSkillsText(skills);
|
|
214
|
+
|
|
215
|
+
/** Loaded on THIS run, for the caller to record. Only ever the newly in-force ones. */
|
|
216
|
+
const newSkills: InForceSkill[] = [];
|
|
217
|
+
/** Added OR dropped this run. A clear is as much a change as a load, and both have to be
|
|
218
|
+
* written, or the held set would keep a skill the conversation has walked away from. */
|
|
219
|
+
let skillsChanged = false;
|
|
220
|
+
|
|
221
|
+
// Token usage across ALL turns of this run — each turn is its own model call with its
|
|
222
|
+
// own usage block; the collector sums them and posts once when the loop settles.
|
|
223
|
+
const usage = makeUsageCollector(node, ctx);
|
|
224
|
+
|
|
225
|
+
for (turn = 1; turn <= maxTurns; turn++) {
|
|
226
|
+
// THE CALLER HUNG UP. The engine reaps a run by aborting the node's request; a loop
|
|
227
|
+
// that did not look was observed spending a dozen model turns against a run that no
|
|
228
|
+
// longer existed (2026-09-05). Checked between turns: a turn in flight completes,
|
|
229
|
+
// the next never starts.
|
|
230
|
+
if (ctx.abort?.aborted) {
|
|
231
|
+
console.warn(`[manifests] ${node.type}: run aborted by the caller after ${turn - 1} turn(s) — stopping`);
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
const scoped: RunContext = {
|
|
235
|
+
...ctx,
|
|
236
|
+
services: { ...ctx.services, [connector]: { tools: live } },
|
|
237
|
+
// Read by the body expression as `...transcript`. A copy, so a manifest cannot mutate the loop's.
|
|
238
|
+
transcript: [...transcript],
|
|
239
|
+
// Read by the body expression as `{{ skillsText }}`. Rebuilt each turn, because a
|
|
240
|
+
// search on THIS turn can put a skill in force for the next one.
|
|
241
|
+
skillsText: renderSkills(),
|
|
242
|
+
} as RunContext;
|
|
243
|
+
|
|
244
|
+
const extra: Record<string, unknown> = { [te.toolsInto ?? "tools"]: live };
|
|
245
|
+
if (te.choiceInto) extra[te.choiceInto] = te.choice ?? "auto";
|
|
246
|
+
if (chainId && te.continuity) extra[te.continuity.into] = chainId;
|
|
247
|
+
// A TRANSCRIPT vendor carries its results INSIDE the transcript, so injecting them separately
|
|
248
|
+
// would send each result twice — once as a message and once in a field the vendor ignores.
|
|
249
|
+
if (results && !te.transcript) extra[te.resultsInto ?? "input"] = results;
|
|
250
|
+
|
|
251
|
+
const res = await sendRequest(node, withExtra(modelCall, extra), scoped, `${node.type} (turn ${turn})`);
|
|
252
|
+
|
|
253
|
+
// Two readers of one stream: the events table decides what leaves the node, and the
|
|
254
|
+
// loop separately watches for a tool call and a continuity id, neither of which is an
|
|
255
|
+
// output. Collected by event TYPE here; the manifest's expressions read them below.
|
|
256
|
+
const raw: any[] = [];
|
|
257
|
+
let chainPayload: any;
|
|
258
|
+
/**
|
|
259
|
+
* The assistant's OWN WORDS this turn, for the transcript.
|
|
260
|
+
*
|
|
261
|
+
* A model with preambles enabled explains why before it calls a tool, and that explanation belongs
|
|
262
|
+
* in the history it is sent next turn — drop it and the model loses its own reasoning thread. Read
|
|
263
|
+
* by an expression because which field carries text is the vendor's business (`delta.content` for
|
|
264
|
+
* Chat Completions), and accumulated because it arrives a token at a time.
|
|
265
|
+
*/
|
|
266
|
+
let spoken = "";
|
|
267
|
+
await readSse(res, modelCall.terminator, async (payload) => {
|
|
268
|
+
await assertOk(node, modelCall, payload, `${node.type} (turn ${turn})`);
|
|
269
|
+
usage.see(payload);
|
|
270
|
+
await emitter.response(payload, payload.type);
|
|
271
|
+
if (te.transcript?.text) {
|
|
272
|
+
const piece = await evaluate(te.transcript.text, { response: payload });
|
|
273
|
+
if (piece) spoken += String(piece);
|
|
274
|
+
}
|
|
275
|
+
// `match` is OPTIONAL. It selects by event TYPE, which only works for a vendor whose SSE
|
|
276
|
+
// events carry one — the Responses API does. A Chat Completions chunk has no `type` at all, so
|
|
277
|
+
// with no match declared every payload is considered and `when` does the filtering.
|
|
278
|
+
if (te.call.match === undefined || payload.type === te.call.match) raw.push(payload);
|
|
279
|
+
if (te.continuity && payload.type === te.continuity.match) chainPayload = payload;
|
|
280
|
+
});
|
|
281
|
+
if (chainPayload) chainId = String(await evaluate(te.continuity.from, { response: chainPayload }));
|
|
282
|
+
|
|
283
|
+
const calls = await readCalls(te, raw);
|
|
284
|
+
|
|
285
|
+
// Answered rather than called. Done.
|
|
286
|
+
if (!calls.length) break;
|
|
287
|
+
|
|
288
|
+
const exchanges: { name: string; resultContent: string }[] = [];
|
|
289
|
+
/**
|
|
290
|
+
* The MODEL-FACING form of each result, alongside the raw one.
|
|
291
|
+
*
|
|
292
|
+
* Two arrays because two consumers want different things. `exchanges` keeps the RAW
|
|
293
|
+
* reply, which is what handoff detection reads. `absorbed` holds what the harness leaned,
|
|
294
|
+
* which is what goes into the conversation. Leaning `exchanges` in place would silently
|
|
295
|
+
* change what `endsTurn` sees.
|
|
296
|
+
*/
|
|
297
|
+
const absorbed: string[] = [];
|
|
298
|
+
let stuck = false;
|
|
299
|
+
|
|
300
|
+
for (const call of calls) {
|
|
301
|
+
const signature = `${call.name}:${call.arguments}`;
|
|
302
|
+
const count = (seen.get(signature) ?? 0) + 1;
|
|
303
|
+
seen.set(signature, count);
|
|
304
|
+
if (count >= stuckAfter) {
|
|
305
|
+
console.warn(`[manifests] ${node.type}: "${call.name}" repeated ${count}x with identical arguments — stopping`);
|
|
306
|
+
stuck = true;
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const args = JSON.parse(call.arguments || "{}");
|
|
311
|
+
narrate({ kind: "toolCall", toolName: call.name, args });
|
|
312
|
+
|
|
313
|
+
let content: string;
|
|
314
|
+
/**
|
|
315
|
+
* THE TIMELINE BAR, and it is the AGENT's job to record it, not the tool's.
|
|
316
|
+
*
|
|
317
|
+
* Every legacy agent did this — OpenAIAgent, Grok, Realtime — and the manifest loop
|
|
318
|
+
* that replaced them did not, so the nested `findIntent MCP (1 of 5 tools) 1.51s` row
|
|
319
|
+
* vanished the day `openai` migrated. It looked like a fault in whichever node was
|
|
320
|
+
* being called, which is where I went looking, twice.
|
|
321
|
+
*
|
|
322
|
+
* `success` is the part that matters most. A tool that fails still returns a string to
|
|
323
|
+
* the model (see the catch below), so from the outside a broken tool and an empty
|
|
324
|
+
* result are identical: the workflow completes, four green bars, and the model answers
|
|
325
|
+
* from its own knowledge. That happened three times in one session before anyone could
|
|
326
|
+
* see it. This flag is what turns it red.
|
|
327
|
+
*/
|
|
328
|
+
const startedAt = Date.now();
|
|
329
|
+
let ok = true;
|
|
330
|
+
try {
|
|
331
|
+
content = await bridge.call(call.name, args);
|
|
332
|
+
} catch (err: any) {
|
|
333
|
+
// A failed tool is information for the model, not a crash: it can try another.
|
|
334
|
+
ok = false;
|
|
335
|
+
content = JSON.stringify({ error: err?.message ?? String(err) });
|
|
336
|
+
}
|
|
337
|
+
recordToolTrace(node, ctx, call.name, args, content, startedAt, ok);
|
|
338
|
+
exchanges.push({ name: call.name, resultContent: content });
|
|
339
|
+
// The RESULT, and only now that it exists. It is never in the HTTP stream: our loop
|
|
340
|
+
// produced it, which is why it cannot be a `from: response` row. PARSED for the
|
|
341
|
+
// connector lane (humans and templates read it there — same reason the trace above
|
|
342
|
+
// parses); the model-facing lane below reads `absorbed`, untouched strings.
|
|
343
|
+
await emitter.tool({ name: call.name, args, output: parseMaybeJson(content) });
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* ONE ABSORBER, which is the harness's own rule and the one this loop used to break.
|
|
347
|
+
*
|
|
348
|
+
* `handleDiscoveryResult` does three things to a discovery reply — UNLOCK the app,
|
|
349
|
+
* SHELL-OPEN its page, and LEAN the rows — and its comment says, in as many words,
|
|
350
|
+
* that it is "never re-implemented in an adapter". This loop re-implemented two of the
|
|
351
|
+
* three (a mint call here, a lean call after the loop) and dropped the middle one.
|
|
352
|
+
*
|
|
353
|
+
* The dropped step was the shell-open: an empty native invoke that paints the page
|
|
354
|
+
* skeleton the moment an app is discovered, seeded with `__draftRefs` — the ids of the
|
|
355
|
+
* image-carrying rows the search just returned — so the guest watches a real page
|
|
356
|
+
* hydrate instead of waiting on a blank panel while the model composes. Nothing failed
|
|
357
|
+
* when it went missing. The tool was minted, the model answered, and the page simply
|
|
358
|
+
* never appeared until the model got round to it.
|
|
359
|
+
*
|
|
360
|
+
* Every legacy agent called this (runOpenAIAgent.ts:580). After `openai` migrated to
|
|
361
|
+
* YAML, nothing did: the function stayed exported, and dead.
|
|
362
|
+
*/
|
|
363
|
+
const { content: forModel, minted, skills: inForce } = await bridge.absorb(call.name, content);
|
|
364
|
+
absorbed.push(forModel);
|
|
365
|
+
|
|
366
|
+
// WHAT IS IN FORCE IS WHERE YOU ARE, and the rule plus its logging and its timeline
|
|
367
|
+
// row live in `regionSkills.ts` because the socket runtime needs all three too. The
|
|
368
|
+
// loop's only remaining job is what is genuinely its own: remembering that something
|
|
369
|
+
// changed, so the held set is persisted once when the run settles.
|
|
370
|
+
const moved = applyInForce(skills, inForce as InForceSkill[] | null, node, ctx);
|
|
371
|
+
if (moved.changed) {
|
|
372
|
+
skillsChanged = true;
|
|
373
|
+
newSkills.push(...moved.added);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
for (const tool of minted)
|
|
377
|
+
if (tool?.name && !live.some((t) => t?.name === tool.name)) {
|
|
378
|
+
live.push(await offer(tool));
|
|
379
|
+
console.log(`[manifests] ${node.type}: minted "${tool.name}" from ${call.name}`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (stuck) break;
|
|
384
|
+
// A handoff ends the turn even though tools were called.
|
|
385
|
+
if (bridge.endsTurn(exchanges)) break;
|
|
386
|
+
|
|
387
|
+
// Already leaned, by the absorber above. A search result carries far more than the model
|
|
388
|
+
// needs, and the full rows were consumed by the card lane and the unlock pass first.
|
|
389
|
+
results = [];
|
|
390
|
+
for (let i = 0; i < calls.length; i++)
|
|
391
|
+
results.push(await evaluate(te.result, { call: { ...calls[i], output: absorbed[i] ?? "{}" } }));
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* GROW THE TRANSCRIPT, for a vendor that has no chain id.
|
|
395
|
+
*
|
|
396
|
+
* ORDER MATTERS AND IS NOT NEGOTIABLE: the assistant's tool-call turn must precede its results, or
|
|
397
|
+
* the vendor rejects the request — a tool message with no preceding tool_call is an error, not a
|
|
398
|
+
* warning. That is why this appends both here rather than letting the manifest assemble it.
|
|
399
|
+
*
|
|
400
|
+
* `te.transcript.assistant` builds that turn from the calls, because its shape is the vendor's:
|
|
401
|
+
* Chat Completions wants { role: 'assistant', tool_calls: [...] }. The results are already in the
|
|
402
|
+
* vendor's message shape — `te.result` produced them — so they go in as they are.
|
|
403
|
+
*/
|
|
404
|
+
if (te.transcript) {
|
|
405
|
+
if (te.transcript.assistant) transcript.push(await evaluate(te.transcript.assistant, { calls, text: spoken }));
|
|
406
|
+
transcript.push(...results);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* REMEMBER THE CHAIN, so the next run can pick this conversation back up.
|
|
412
|
+
*
|
|
413
|
+
* The write lives here and nowhere else for two reasons, and neither is a preference.
|
|
414
|
+
* A streaming call sets no `calls.<name>` value on purpose (`channels/final.ts`), so no
|
|
415
|
+
* expression in the manifest can see the id the vendor just minted; and this loop drives
|
|
416
|
+
* the node's LAST call, so there is no later step a state write could be put in. The one
|
|
417
|
+
* place that holds the settled id is the loop that computed it.
|
|
418
|
+
*
|
|
419
|
+
* AFTER the turns, not inside them: an id written per turn would leave the key holding
|
|
420
|
+
* whichever turn happened to be last to write rather than the last turn there was. Only
|
|
421
|
+
* the final id can resume the chain — an earlier one resumes a conversation that stops
|
|
422
|
+
* short.
|
|
423
|
+
*
|
|
424
|
+
* Soft-fail. Losing continuity next time is a worse answer later; failing the run is a
|
|
425
|
+
* broken answer now, after the model has already spoken and the caller has read it.
|
|
426
|
+
*/
|
|
427
|
+
if (chainId && te.continuity?.remember && store) {
|
|
428
|
+
const remember = te.continuity.remember;
|
|
429
|
+
const wanted = remember.when
|
|
430
|
+
? !!(await evaluate(remember.when, ctx as unknown as Record<string, unknown>))
|
|
431
|
+
: true;
|
|
432
|
+
if (wanted) {
|
|
433
|
+
// Already rendered: `te` is the whole toolExchange put through `render` at the top of
|
|
434
|
+
// this function, so the key's {{ }} are resolved. Rendering it again here would read
|
|
435
|
+
// as though it still had some, and it does not.
|
|
436
|
+
await store
|
|
437
|
+
.merge(String(remember.key), { id: chainId })
|
|
438
|
+
.catch((err: any) =>
|
|
439
|
+
console.warn(`[manifests] ${node.type}: could not remember the chain id: ${err?.message ?? err}`),
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* HOLD THE SKILLS FOR THE NEXT MESSAGE.
|
|
446
|
+
*
|
|
447
|
+
* Written only when the set actually CHANGED this run — added or dropped. A conversation
|
|
448
|
+
* that stayed put has nothing to say, and rewriting an unchanged set would touch the key
|
|
449
|
+
* on every turn of every thread for no reason.
|
|
450
|
+
*
|
|
451
|
+
* The whole set, not the additions, because this key IS the answer to "what is in force" —
|
|
452
|
+
* the next run seeds itself from it and must not have to replay history to find out.
|
|
453
|
+
*/
|
|
454
|
+
if (skillsKey && store && skillsChanged) {
|
|
455
|
+
await store
|
|
456
|
+
.merge(skillsKey, { skills: [...skills.values()] })
|
|
457
|
+
.catch((err: any) =>
|
|
458
|
+
console.warn(`[manifests] ${node.type}: could not hold the loaded skills: ${err?.message ?? err}`),
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
usage.save();
|
|
463
|
+
return 200;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* The tool calls a turn asked for, from however many events carried them.
|
|
468
|
+
*
|
|
469
|
+
* TWO SHAPES, because vendors genuinely differ and the difference is not cosmetic:
|
|
470
|
+
*
|
|
471
|
+
* WHOLE one event carries one complete call. OpenAI's Responses API does this — a
|
|
472
|
+
* `response.output_item.done` has the name and the full arguments string.
|
|
473
|
+
* FRAGMENTS one call arrives across MANY events, keyed by an index, with `arguments`
|
|
474
|
+
* concatenated. Chat Completions does this — GLM sends
|
|
475
|
+
* `{ index: 0, function: { arguments: '{"qu' } }` then `'ery":"x"}' }` — so reading
|
|
476
|
+
* `arguments` off any single event yields a fragment of JSON that cannot be parsed.
|
|
477
|
+
*
|
|
478
|
+
* A manifest picks by declaring `call.each`: an expression returning the partial calls in one
|
|
479
|
+
* event. Without it nothing changes and the whole-call path runs exactly as before.
|
|
480
|
+
*
|
|
481
|
+
* Merging by INDEX and not by id is deliberate: the id arrives on the first fragment only, so a
|
|
482
|
+
* later fragment has nothing else to join on. Parallel tool calls are precisely why the vendor
|
|
483
|
+
* sends an index at all.
|
|
484
|
+
*/
|
|
485
|
+
async function readCalls(te: any, raw: any[]): Promise<{ id: string; name: string; arguments: string }[]> {
|
|
486
|
+
if (!te.call.each) {
|
|
487
|
+
const out: { id: string; name: string; arguments: string }[] = [];
|
|
488
|
+
for (const payload of raw) {
|
|
489
|
+
if (te.call.when && !(await evaluate(te.call.when, { response: payload }))) continue;
|
|
490
|
+
out.push({
|
|
491
|
+
id: String(await evaluate(te.call.id, { response: payload })),
|
|
492
|
+
name: String(await evaluate(te.call.name, { response: payload })),
|
|
493
|
+
arguments: String((await evaluate(te.call.arguments, { response: payload })) ?? "{}"),
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
return out;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// FRAGMENTS. `each` yields the partials in this event; id/name/arguments are read PER PARTIAL,
|
|
500
|
+
// with `part` in scope, and any of them may be absent on any given fragment.
|
|
501
|
+
const acc = new Map<string, { id: string; name: string; arguments: string }>();
|
|
502
|
+
const order: string[] = [];
|
|
503
|
+
for (const payload of raw) {
|
|
504
|
+
if (te.call.when && !(await evaluate(te.call.when, { response: payload }))) continue;
|
|
505
|
+
const parts = (await evaluate(te.call.each, { response: payload })) as any[];
|
|
506
|
+
if (!Array.isArray(parts)) continue;
|
|
507
|
+
for (const part of parts) {
|
|
508
|
+
const scope = { response: payload, part };
|
|
509
|
+
const key = String(te.call.index ? await evaluate(te.call.index, scope) : 0);
|
|
510
|
+
if (!acc.has(key)) {
|
|
511
|
+
acc.set(key, { id: "", name: "", arguments: "" });
|
|
512
|
+
order.push(key);
|
|
513
|
+
}
|
|
514
|
+
const cur = acc.get(key)!;
|
|
515
|
+
const id = te.call.id ? await evaluate(te.call.id, scope) : undefined;
|
|
516
|
+
const name = te.call.name ? await evaluate(te.call.name, scope) : undefined;
|
|
517
|
+
const args = te.call.arguments ? await evaluate(te.call.arguments, scope) : undefined;
|
|
518
|
+
if (id) cur.id = String(id);
|
|
519
|
+
if (name) cur.name = String(name);
|
|
520
|
+
// CONCATENATED, which is the whole point. `=` here would keep only the last fragment and
|
|
521
|
+
// produce a JSON.parse error on a truncated object.
|
|
522
|
+
if (args) cur.arguments += String(args);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
// A slot with no NAME never became a call: some vendors open an index before naming the function.
|
|
526
|
+
return order.map((k) => acc.get(k)!).filter((c) => c.name).map((c) => ({ ...c, arguments: c.arguments || "{}" }));
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** Merge the loop's fields into the request body, object or expression alike. */
|
|
530
|
+
function withExtra(request: any, extra: Record<string, unknown>): any {
|
|
531
|
+
return typeof request.body === "string"
|
|
532
|
+
? { ...request, body: `return Object.assign({}, (${request.body.replace(/^return\s+/, "")}), ${JSON.stringify(extra)})` }
|
|
533
|
+
: { ...request, body: { ...request.body, ...extra } };
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* The discovered tools, in the VENDOR'S ENVELOPE, shared by the HTTP loop and the socket.
|
|
538
|
+
*
|
|
539
|
+
* `te.tool` is the description half — OpenAI wants { type: 'function', name, ... } — and this
|
|
540
|
+
* applies it to whatever the provider discovered. Extracted so a duplex session offers tools the
|
|
541
|
+
* same way an HTTP turn does: the alternative was the socket hand-writing the envelope in its
|
|
542
|
+
* handshake, which is the same protocol described twice, free to drift the moment a vendor changes.
|
|
543
|
+
*
|
|
544
|
+
* Returns [] when nothing is wired, which is the normal case and must degrade rather than fail.
|
|
545
|
+
*/
|
|
546
|
+
export async function offerDiscovered(te: any, bridge: ToolBridge): Promise<any[]> {
|
|
547
|
+
const out: any[] = [];
|
|
548
|
+
for (const t of await bridge.discover()) out.push(await evaluate(te.tool, { tool: t }));
|
|
549
|
+
return out;
|
|
550
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token usage — restored for the manifest runtime (2026-08-07).
|
|
3
|
+
*
|
|
4
|
+
* Every LEGACY agent reported its vendor usage block to the engine
|
|
5
|
+
* (`POST /analytics/tokens` → the `token_usage` table → the execution's Token Usage
|
|
6
|
+
* tab). The YAML migration replaced those agents with this runtime and nothing here
|
|
7
|
+
* reported usage, so the tab read "No token usage data" for every migrated node while
|
|
8
|
+
* the route, the table, and the UI all sat working.
|
|
9
|
+
*
|
|
10
|
+
* WHERE usage lives is the vendor's business, but every wire this runtime speaks puts
|
|
11
|
+
* it in one of three homes:
|
|
12
|
+
* payload.usage Chat Completions (final chunk / settled body), LlamaParse-style REST
|
|
13
|
+
* payload.response.usage OpenAI Responses API (`response.completed` event)
|
|
14
|
+
* payload.metadata.usage Bedrock converse stream (`metadata` event)
|
|
15
|
+
* Sniffing those three is a wire-format rule, not a per-node rule — no manifest changes.
|
|
16
|
+
*
|
|
17
|
+
* A run may hold SEVERAL model calls (the tool loop's turns), each with its own usage
|
|
18
|
+
* block; the collector SUMS numeric fields across them, which is what "this execution
|
|
19
|
+
* cost N tokens" means. Fire-and-forget on save, same contract as the MCP trace:
|
|
20
|
+
* observability must never slow a run or fail it.
|
|
21
|
+
*/
|
|
22
|
+
import type { ComposedNode } from "../compose.js";
|
|
23
|
+
import type { RunContext } from "./context.js";
|
|
24
|
+
import { saveTokenUsageToWorkflow } from "../../platform/serviceCalls.js";
|
|
25
|
+
|
|
26
|
+
/** The vendor's usage block, wherever this wire puts it. */
|
|
27
|
+
export function sniffUsage(payload: any): Record<string, unknown> | undefined {
|
|
28
|
+
const u = payload?.usage ?? payload?.response?.usage ?? payload?.metadata?.usage;
|
|
29
|
+
return u && typeof u === "object" && !Array.isArray(u) ? (u as Record<string, unknown>) : undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface UsageCollector {
|
|
33
|
+
/** Look at one payload (streamed event or settled body); remember any usage block. */
|
|
34
|
+
see(payload: any): void;
|
|
35
|
+
/** Post the summed usage to the engine. No usage seen, or no execution scope → no-op. */
|
|
36
|
+
save(): void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Add one usage block into the running totals, field by field, AT ANY DEPTH.
|
|
41
|
+
*
|
|
42
|
+
* Summing one level down dropped `input_token_details.cached_tokens_details.*` — the split that
|
|
43
|
+
* says how cached tokens divide across text and audio. Without it a cached token cannot be
|
|
44
|
+
* priced, and Canvas billed them all at the full fresh rate (found 2026-08-12). Depth limits
|
|
45
|
+
* here are bugs waiting for the next vendor to nest a field.
|
|
46
|
+
*/
|
|
47
|
+
function addInto(totals: Record<string, any>, block: Record<string, unknown>): void {
|
|
48
|
+
for (const [k, v] of Object.entries(block)) {
|
|
49
|
+
if (typeof v === "number") totals[k] = ((totals[k] as number) ?? 0) + v;
|
|
50
|
+
// Arrays excluded deliberately: a list in a usage block is not a quantity to add up.
|
|
51
|
+
else if (v && typeof v === "object" && !Array.isArray(v)) {
|
|
52
|
+
addInto((totals[k] = (totals[k] as Record<string, any>) ?? {}), v as Record<string, unknown>);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function makeUsageCollector(node: ComposedNode, ctx: RunContext): UsageCollector {
|
|
58
|
+
const totals: Record<string, number | Record<string, number>> = {};
|
|
59
|
+
let seen = false;
|
|
60
|
+
let model: string | undefined;
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
see(payload: any) {
|
|
64
|
+
const u = sniffUsage(payload);
|
|
65
|
+
if (!u) return;
|
|
66
|
+
seen = true;
|
|
67
|
+
// Numeric fields sum across calls; detail objects sum field by field, at ANY depth.
|
|
68
|
+
addInto(totals, u);
|
|
69
|
+
model ??= payload?.response?.model ?? payload?.model ?? undefined;
|
|
70
|
+
},
|
|
71
|
+
save() {
|
|
72
|
+
if (!seen) return;
|
|
73
|
+
const { workflowId, executionId, nodeId } = ctx.scope ?? {};
|
|
74
|
+
// Node-test / headless: no execution to attach to (same rule as the MCP trace).
|
|
75
|
+
if (!workflowId || !executionId || !nodeId) return;
|
|
76
|
+
void saveTokenUsageToWorkflow({
|
|
77
|
+
workflowId,
|
|
78
|
+
executionId,
|
|
79
|
+
nodeId,
|
|
80
|
+
nodeType: node.type,
|
|
81
|
+
model: model ?? (ctx.config as any)?.model ?? "unknown",
|
|
82
|
+
usage: totals,
|
|
83
|
+
timestamp: new Date().toISOString(),
|
|
84
|
+
});
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|