@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.
Files changed (132) hide show
  1. package/local/plugin.ts +21 -65
  2. package/package.json +13 -3
  3. package/vendor/base/src/agent-mcp/discovery.ts +203 -0
  4. package/vendor/base/src/agent-mcp/handoff.ts +56 -0
  5. package/vendor/base/src/agent-mcp/index.ts +44 -0
  6. package/vendor/base/src/agent-mcp/invoke.ts +153 -0
  7. package/vendor/base/src/agent-mcp/lean.ts +114 -0
  8. package/vendor/base/src/agent-mcp/memoryContext.ts +104 -0
  9. package/vendor/base/src/agent-mcp/memoryIngest.ts +88 -0
  10. package/vendor/base/src/agent-mcp/rowComponents.ts +234 -0
  11. package/vendor/base/src/agent-mcp/tools.ts +134 -0
  12. package/vendor/base/src/agent-mcp/types.ts +138 -0
  13. package/vendor/base/src/auth/auth.ts +307 -0
  14. package/vendor/base/src/auth/authConfig.ts +73 -0
  15. package/vendor/base/src/boot.ts +245 -0
  16. package/vendor/base/src/definitions/definitions.ts +18 -0
  17. package/vendor/base/src/definitions/dsPackage.ts +45 -0
  18. package/vendor/base/src/definitions/fsCache.ts +155 -0
  19. package/vendor/base/src/definitions/index.ts +19 -0
  20. package/vendor/base/src/definitions/inputs.ts +30 -0
  21. package/vendor/base/src/definitions/load/compile/component.ts +80 -0
  22. package/vendor/base/src/definitions/load/compile/index.ts +12 -0
  23. package/vendor/base/src/definitions/load/compile/sections.ts +132 -0
  24. package/vendor/base/src/definitions/load/compile/template.ts +153 -0
  25. package/vendor/base/src/definitions/load/expand/includes.ts +27 -0
  26. package/vendor/base/src/definitions/load/expand/index.ts +17 -0
  27. package/vendor/base/src/definitions/load/expand/inherit.ts +169 -0
  28. package/vendor/base/src/definitions/load/expand/referenced.ts +164 -0
  29. package/vendor/base/src/definitions/load/expand/refs.ts +106 -0
  30. package/vendor/base/src/definitions/load/expand/walk.ts +91 -0
  31. package/vendor/base/src/definitions/load/load.ts +487 -0
  32. package/vendor/base/src/definitions/model.ts +238 -0
  33. package/vendor/base/src/definitions/registry/catalog.ts +659 -0
  34. package/vendor/base/src/definitions/registry/homes.ts +252 -0
  35. package/vendor/base/src/definitions/theme.ts +383 -0
  36. package/vendor/base/src/executor.ts +284 -0
  37. package/vendor/base/src/http/uwsFetchAdapter.ts +397 -0
  38. package/vendor/base/src/items/baseVersion.ts +96 -0
  39. package/vendor/base/src/items/collect.ts +246 -0
  40. package/vendor/base/src/items/fingerprint.ts +34 -0
  41. package/vendor/base/src/items/publish.ts +212 -0
  42. package/vendor/base/src/lint/context.mjs +56 -0
  43. package/vendor/base/src/lint/design/defs.mjs +46 -0
  44. package/vendor/base/src/lint/design/file.mjs +737 -0
  45. package/vendor/base/src/lint/design/index.d.mts +30 -0
  46. package/vendor/base/src/lint/design/index.mjs +996 -0
  47. package/vendor/base/src/lint/design/tokens.mjs +128 -0
  48. package/vendor/base/src/lint/design/vocabulary.mjs +181 -0
  49. package/vendor/base/src/lint/design/walk.mjs +444 -0
  50. package/vendor/base/src/lint/index.mjs +28 -0
  51. package/vendor/base/src/lint/nodes/blocks.mjs +63 -0
  52. package/vendor/base/src/lint/nodes/index.d.mts +28 -0
  53. package/vendor/base/src/lint/nodes/index.mjs +82 -0
  54. package/vendor/base/src/lint/nodes/load.mjs +126 -0
  55. package/vendor/base/src/lint/nodes/node.mjs +764 -0
  56. package/vendor/base/src/lint/nodes/package.mjs +112 -0
  57. package/vendor/base/src/lint/nodes/schema.mjs +75 -0
  58. package/vendor/base/src/logger.ts +33 -0
  59. package/vendor/base/src/manifests/compose.ts +539 -0
  60. package/vendor/base/src/manifests/executor/callback.ts +107 -0
  61. package/vendor/base/src/manifests/executor/context.ts +110 -0
  62. package/vendor/base/src/manifests/executor/index.ts +47 -0
  63. package/vendor/base/src/manifests/executor/lookup.ts +21 -0
  64. package/vendor/base/src/manifests/executor/memory.ts +62 -0
  65. package/vendor/base/src/manifests/executor/promise.ts +71 -0
  66. package/vendor/base/src/manifests/executor/refire.ts +34 -0
  67. package/vendor/base/src/manifests/executor/send.ts +65 -0
  68. package/vendor/base/src/manifests/executor/session.ts +46 -0
  69. package/vendor/base/src/manifests/executor/toolBridge.ts +266 -0
  70. package/vendor/base/src/manifests/index.ts +280 -0
  71. package/vendor/base/src/manifests/integrity.ts +93 -0
  72. package/vendor/base/src/manifests/rows.ts +33 -0
  73. package/vendor/base/src/manifests/runtime/auth/aws.ts +240 -0
  74. package/vendor/base/src/manifests/runtime/auth/oauth.ts +76 -0
  75. package/vendor/base/src/manifests/runtime/authorize.ts +159 -0
  76. package/vendor/base/src/manifests/runtime/channels/final.ts +171 -0
  77. package/vendor/base/src/manifests/runtime/channels/service.ts +138 -0
  78. package/vendor/base/src/manifests/runtime/channels/workflow.ts +310 -0
  79. package/vendor/base/src/manifests/runtime/context.ts +156 -0
  80. package/vendor/base/src/manifests/runtime/docstore/handlers.ts +547 -0
  81. package/vendor/base/src/manifests/runtime/docstore/index.ts +135 -0
  82. package/vendor/base/src/manifests/runtime/docstore/sectionizer.ts +184 -0
  83. package/vendor/base/src/manifests/runtime/docstore/store.ts +172 -0
  84. package/vendor/base/src/manifests/runtime/docstore/types.ts +60 -0
  85. package/vendor/base/src/manifests/runtime/duplex/audioLane.ts +165 -0
  86. package/vendor/base/src/manifests/runtime/duplex/session.ts +511 -0
  87. package/vendor/base/src/manifests/runtime/events.ts +223 -0
  88. package/vendor/base/src/manifests/runtime/helpers.ts +53 -0
  89. package/vendor/base/src/manifests/runtime/http/allowedHosts.ts +118 -0
  90. package/vendor/base/src/manifests/runtime/http/request.ts +637 -0
  91. package/vendor/base/src/manifests/runtime/http/response.ts +162 -0
  92. package/vendor/base/src/manifests/runtime/index.ts +57 -0
  93. package/vendor/base/src/manifests/runtime/loops/chunk.ts +98 -0
  94. package/vendor/base/src/manifests/runtime/loops/loop.ts +191 -0
  95. package/vendor/base/src/manifests/runtime/loops/paginate.ts +143 -0
  96. package/vendor/base/src/manifests/runtime/loops/poll.ts +169 -0
  97. package/vendor/base/src/manifests/runtime/narrate.ts +53 -0
  98. package/vendor/base/src/manifests/runtime/state.ts +171 -0
  99. package/vendor/base/src/manifests/runtime/templating.ts +110 -0
  100. package/vendor/base/src/manifests/runtime/tools/regionSkills.ts +125 -0
  101. package/vendor/base/src/manifests/runtime/tools/toolloop.ts +550 -0
  102. package/vendor/base/src/manifests/runtime/usage.ts +87 -0
  103. package/vendor/base/src/manifests/source.ts +196 -0
  104. package/vendor/base/src/manifests/watch.ts +67 -0
  105. package/vendor/base/src/markdown/document.ts +141 -0
  106. package/vendor/base/src/paths.ts +180 -0
  107. package/vendor/base/src/platform/audioManager.ts +100 -0
  108. package/vendor/base/src/platform/clientTransport.ts +65 -0
  109. package/vendor/base/src/platform/executionContext.ts +157 -0
  110. package/vendor/base/src/platform/index.ts +10 -0
  111. package/vendor/base/src/platform/pluginAPI.ts +146 -0
  112. package/vendor/base/src/platform/redis.ts +54 -0
  113. package/vendor/base/src/platform/serviceCalls.ts +222 -0
  114. package/vendor/base/src/pluginBase.ts +248 -0
  115. package/vendor/base/src/plugins/README.md +57 -0
  116. package/vendor/base/src/plugins/discovery.ts +87 -0
  117. package/vendor/base/src/plugins/index.ts +16 -0
  118. package/vendor/base/src/plugins/loader.ts +179 -0
  119. package/vendor/base/src/plugins/redis.ts +36 -0
  120. package/vendor/base/src/plugins/state.ts +53 -0
  121. package/vendor/base/src/registry.ts +95 -0
  122. package/vendor/base/src/security/permissions.ts +93 -0
  123. package/vendor/base/src/security/rules.ts +73 -0
  124. package/vendor/base/src/template/SafeExpression.ts +556 -0
  125. package/vendor/base/src/template/StringTemplateResolver.ts +75 -0
  126. package/vendor/base/src/template/index.ts +22 -0
  127. package/vendor/base/src/types.ts +50 -0
  128. package/vendor/base/src/workflow/GetMemoryService.ts +158 -0
  129. package/vendor/base/src/workflow/PlatformMemoryCore.ts +449 -0
  130. package/vendor/base/src/workflow/routing.ts +122 -0
  131. package/vendor/sdk/lib/core/store.ts +26 -6
  132. 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
+ }