@unoverse-platform/studio 0.1.115 → 0.1.117

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 (72) hide show
  1. package/local/catalog.ts +3 -0
  2. package/local/plugin.ts +12 -9
  3. package/local/scaffold.d.mts +1 -0
  4. package/local/scaffold.mjs +12 -0
  5. package/package.json +1 -1
  6. package/screens/AgentChain.tsx +232 -0
  7. package/screens/AgentsView.tsx +85 -6
  8. package/screens/SkillsView.tsx +21 -3
  9. package/screens/TemplatesLibraryView.tsx +32 -3
  10. package/screens/TemplatesView.tsx +2 -2
  11. package/screens/index.ts +4 -1
  12. package/src/host.ts +5 -0
  13. package/src/index.css +11 -0
  14. package/vendor/base/src/agent-mcp/discovery.ts +5 -2
  15. package/vendor/base/src/agent-mcp/index.ts +3 -1
  16. package/vendor/base/src/agent-mcp/invoke.ts +199 -25
  17. package/vendor/base/src/agent-mcp/progressive.ts +124 -0
  18. package/vendor/base/src/agent-mcp/tools.ts +18 -11
  19. package/vendor/base/src/agent-mcp/types.ts +8 -0
  20. package/vendor/base/src/auth/agentSwitch.ts +52 -0
  21. package/vendor/base/src/auth/agentToken.ts +142 -0
  22. package/vendor/base/src/auth/auth.ts +7 -0
  23. package/vendor/base/src/definitions/load/expand/inherit.ts +1 -1
  24. package/vendor/base/src/definitions/load/expand/refs.ts +1 -1
  25. package/vendor/base/src/definitions/load/expand/walk.ts +1 -1
  26. package/vendor/base/src/definitions/load/load.ts +6 -1
  27. package/vendor/base/src/definitions/registry/agentFolders.ts +113 -0
  28. package/vendor/base/src/definitions/registry/catalog.ts +1 -1
  29. package/vendor/base/src/definitions/registry/objects.ts +109 -5
  30. package/vendor/base/src/definitions/registry/pipelines.ts +24 -5
  31. package/vendor/base/src/definitions/registry/taskStarts.ts +76 -0
  32. package/vendor/base/src/definitions/registry/taskUri.ts +67 -0
  33. package/vendor/base/src/http/uwsFetchAdapter.ts +39 -3
  34. package/vendor/base/src/items/collect.ts +10 -0
  35. package/vendor/base/src/items/publish.ts +10 -0
  36. package/vendor/base/src/lint/design/index.mjs +14 -3
  37. package/vendor/base/src/lint/design/vocabulary.mjs +1 -1
  38. package/vendor/base/src/lint/objects/index.d.mts +1 -0
  39. package/vendor/base/src/lint/objects/index.mjs +121 -3
  40. package/vendor/base/src/lint/objects/rules.d.mts +3 -0
  41. package/vendor/base/src/lint/objects/rules.mjs +106 -7
  42. package/vendor/base/src/manifests/compose.ts +3 -0
  43. package/vendor/base/src/manifests/executor/callback.ts +3 -2
  44. package/vendor/base/src/manifests/executor/memory.ts +27 -0
  45. package/vendor/base/src/manifests/executor/toolBridge.ts +79 -13
  46. package/vendor/base/src/manifests/marketplaceMcp.ts +20 -0
  47. package/vendor/base/src/manifests/runtime/auth/oauth.ts +43 -10
  48. package/vendor/base/src/manifests/runtime/events.ts +1 -1
  49. package/vendor/base/src/manifests/runtime/http/request.ts +47 -4
  50. package/vendor/base/src/manifests/runtime/loops/poll.ts +27 -5
  51. package/vendor/base/src/manifests/runtime/tools/toolloop.ts +59 -24
  52. package/vendor/base/src/manifests/runtime/usage.ts +13 -5
  53. package/vendor/base/src/platform/agentBudget.ts +65 -0
  54. package/vendor/base/src/platform/agentCalendar.ts +87 -0
  55. package/vendor/base/src/platform/inbound.ts +67 -0
  56. package/vendor/base/src/platform/taskChanged.ts +25 -0
  57. package/vendor/base/src/platform/tokenCost.ts +414 -0
  58. package/vendor/base/src/security/permissions.ts +10 -2
  59. package/vendor/sdk/appEngine.ts +42 -1
  60. package/vendor/sdk/lib/core/actions.ts +10 -0
  61. package/vendor/sdk/lib/core/apps.ts +2 -2
  62. package/vendor/sdk/lib/core/client.ts +11 -0
  63. package/vendor/sdk/lib/core/store.ts +1 -1
  64. package/vendor/sdk/lib/core/types.ts +2 -0
  65. package/vendor/sdk/lib/dom/app.ts +3 -2
  66. package/vendor/sdk/lib/dom/component.ts +38 -1
  67. package/vendor/sdk/lib/dom/element.ts +23 -0
  68. package/vendor/sdk/lib/dom/markdown.ts +6 -2
  69. package/vendor/sdk/lib/dom/primitives.ts +97 -6
  70. package/vendor/sdk/lib/voiceService.ts +3 -1
  71. package/vendor/sdk/main.ts +1 -1
  72. package/vendor/sdk/pageInterfaces.ts +109 -8
@@ -140,3 +140,30 @@ export function sessionTurnRecorder(
140
140
  flush: save,
141
141
  };
142
142
  }
143
+
144
+ /**
145
+ * THE TASK THE APP'S ADDRESS NAMES, in the call before it starts (docs/mcp/MCP_TASKS.md § 5c).
146
+ * A task found in Spatial lands in the conversation as the search's result, so the model knows
147
+ * it; a task opened by an address has no search, so what it holds is replayed instead, read by
148
+ * its id with the official client (`tasks/get`), the same way the memory block above is. Only an
149
+ * id: a URI (`<org>/<name>`, which a task's own run also carries) is not an opened task.
150
+ */
151
+ export async function hydrateOpenedTask(config: any, executionContext: any): Promise<any> {
152
+ const id = executionContext?.publishingContext?.task;
153
+ if (typeof id !== "string" || !id || id.includes("/")) return config;
154
+ try {
155
+ const harness: any = await import("../../agent-mcp/index.js");
156
+ const task = await harness.readTask(id, { accessToken: executionContext?.auth?.accessToken });
157
+ const block =
158
+ `This conversation was opened on a task (${task.status}).` +
159
+ (task.statusMessage ? ` ${task.statusMessage}` : "") +
160
+ (task.asks ? `\nIt asks: ${JSON.stringify(task.asks)}` : "") +
161
+ (task.result ? `\nWhat it holds:\n${task.result}` : "");
162
+ const history = Array.isArray(config?.conversationHistory) ? config.conversationHistory : [];
163
+ console.log(`[manifests] opened task ${id} replayed into the call (${task.status})`);
164
+ return { ...config, conversationHistory: [...history, { role: "user", content: block }] };
165
+ } catch (e: any) {
166
+ console.log(`[manifests] opened task ${id} could not be read (non-blocking): ${e?.message ?? e}`);
167
+ return config;
168
+ }
169
+ }
@@ -12,6 +12,7 @@
12
12
  import type { ToolBridge } from "../runtime/index.js";
13
13
  import { engineUrl } from "./context.js";
14
14
  import { saveAvailableTools } from "../../platform/serviceCalls.js";
15
+ import { recordCall } from "../runtime/tools/toolloop.js";
15
16
 
16
17
  /** The screen's verbs on an app with places (docs/unoverse/design/screen/UNOVERSE_SCREEN.md §3.3). */
17
18
  const SCREEN_VERBS = {
@@ -60,6 +61,8 @@ export async function toolBridgeFor(executionContext: any): Promise<ToolBridge |
60
61
  userId: pub?.userId,
61
62
  chatId: pub?.chatId,
62
63
  accessToken: executionContext?.auth?.accessToken,
64
+ // The run's stop signal: a task this Agent follows is cancelled when its run stops.
65
+ signal: executionContext?.abortSignal as AbortSignal | undefined,
63
66
  };
64
67
  /**
65
68
  * THE CALLER, for a tool the MODEL calls: each records its own call on the trace under this
@@ -79,7 +82,11 @@ export async function toolBridgeFor(executionContext: any): Promise<ToolBridge |
79
82
  * would silently drop elicitation — the app would render and the model would never
80
83
  * learn what the human said.
81
84
  */
82
- const appInvokers = new Map<string, (input: unknown) => Promise<unknown>>();
85
+ const appInvokers = new Map<string, (input: unknown, hooks?: { onStatus?: (statusMessage: string) => void }) => Promise<unknown>>();
86
+ // A WIRED TASK (a Task Start a line is drawn from, docs/mcp/MCP_TASKS.md § 2) names its MCP
87
+ // tool: it is called through the front door and followed as an MCP task, like a found one.
88
+ for (const [name, m] of Object.entries<any>(schema.methods))
89
+ if (typeof m?.taskTool === "string" && m.taskTool) appInvokers.set(name, harness.workflowTaskInvoker({ taskTool: m.taskTool }, called));
83
90
 
84
91
  /**
85
92
  * Which apps this run has already unlocked, by component name.
@@ -176,24 +183,82 @@ export async function toolBridgeFor(executionContext: any): Promise<ToolBridge |
176
183
  }
177
184
  }
178
185
 
179
- return {
186
+ /**
187
+ * THE TASK THE APP'S ADDRESS NAMES, by its id (docs/mcp/MCP_TASKS.md § 5c). An edge or Spatial
188
+ * hands the model a task to start; an id names one that already exists, connected through the
189
+ * official client's `session.task(id)`. The model can read it, and answer it while it waits:
190
+ * the two things MCP offers on an existing task. A URI (`<org>/<name>`, which a task's own run
191
+ * also carries) is not this. Fail-soft: a task that cannot be read adds nothing.
192
+ */
193
+ const named = pub?.task;
194
+ if (typeof named === "string" && named && !named.includes("/")) {
195
+ try {
196
+ const now = await harness.readTask(named, identity);
197
+ appInvokers.set("read_task", () => harness.readTask(named, identity));
198
+ preMinted.push({
199
+ name: "read_task",
200
+ description: `The task this conversation was opened on (${now.status}). Read it for where it is, what it asks and what it produced.`,
201
+ parameters: { type: "object", properties: {} },
202
+ });
203
+ if (now.status === "input_required") {
204
+ appInvokers.set("answer_task", async (input: any) => {
205
+ await harness.answerTask(named, (input?.answers ?? {}) as Record<string, unknown>, identity);
206
+ return harness.readTask(named, identity);
207
+ });
208
+ preMinted.push({
209
+ name: "answer_task",
210
+ description: "Answer what the task this conversation was opened on asks, each answer under the key it asks with.",
211
+ parameters: { type: "object", properties: { answers: { type: "object", description: "Each answer under the key the task asks with." } }, required: ["answers"] },
212
+ });
213
+ }
214
+ console.log(`[manifests] task ${named} connected (${now.status})`);
215
+ } catch (e: any) {
216
+ console.log(`[manifests] task ${named} could not be read: ${e?.message ?? e}`);
217
+ }
218
+ }
219
+
220
+ /** The tools held behind search_tools on this run, or null when every tool is offered. */
221
+ let held: Array<{ name: string; description: string; parameters: unknown; server?: string }> | null = null;
222
+
223
+ const bridge: ToolBridge = {
180
224
  async discover() {
181
- const tools = [
182
- ...Object.entries<any>(schema.methods).map(([name, m]) => ({
183
- name,
184
- description: m.description || `Execute ${name}`,
185
- parameters: m.input || { type: "object", properties: {} },
186
- })),
187
- ...preMinted,
188
- ];
225
+ const wired = Object.entries<any>(schema.methods).map(([name, m]) => ({
226
+ name,
227
+ description: m.description || `Execute ${name}`,
228
+ parameters: m.input || { type: "object", properties: {} },
229
+ ...(m.server ? { server: String(m.server) } : {}),
230
+ }));
231
+ // MANY TOOLS ARE HELD BEHIND SEARCH (harness.shouldDefer, MCP Client Best Practices). Spatial
232
+ // search stays offered: it is how an Agent finds what it does not hold, and what it finds is
233
+ // minted as a tool. The screen and open pages (preMinted) are never held.
234
+ const deferrable = wired.filter((t) => !harness.DISCOVERY_TOOL_NAMES.includes(t.name));
235
+ held = harness.shouldDefer(deferrable) ? deferrable : null;
236
+ if (held) console.log(`[manifests] ${held.length} tools held behind ${harness.SEARCH_TOOLS} (about ${harness.estimateTokens(held)} tokens of definitions)`);
237
+ const strip = ({ server: _server, ...t }: any) => t;
238
+ const tools = [...(held ? [...wired.filter((t) => !held!.includes(t)), ...harness.metaTools(held)] : wired).map(strip), ...preMinted];
189
239
  // Everything the model starts with, the screen and open pages included.
190
240
  if (caller.executionId && caller.nodeId) saveAvailableTools(caller.executionId, caller.nodeId, tools.map((t) => t.name));
191
241
  return tools;
192
242
  },
193
243
 
194
- async call(name, args) {
244
+ async call(name, args, hooks): Promise<string> {
245
+ // A SEARCH IS A CALL LIKE ANY OTHER, recorded on the run with what it asked and what it found:
246
+ // without it nobody could see which tools an Agent looked for, or why it never used one.
247
+ if (held && name === harness.SEARCH_TOOLS) {
248
+ const startTime = Date.now();
249
+ const answer = harness.searchAnswer(held, args?.query);
250
+ recordCall({ executionId: caller.executionId, parentNodeId: caller.nodeId, toolName: harness.SEARCH_TOOLS, args, result: answer, startTime, success: true, who: "harness" });
251
+ return answer;
252
+ }
253
+ // A held tool, called by name. Only a tool this Agent was given: the list is its limit.
254
+ if (held && name === harness.CALL_TOOL) {
255
+ const target = String(args?.name ?? "");
256
+ if (!held.some((t) => t.name === target)) return JSON.stringify({ error: `No tool named "${target}". Find it with ${harness.SEARCH_TOOLS} first.` });
257
+ return bridge.call(target, args?.arguments ?? {}, hooks);
258
+ }
195
259
  const app = appInvokers.get(name);
196
- if (app) return stringify(await app(args));
260
+ // A followed task's status, as it changes, goes to whoever asked (the tool loop narrates it).
261
+ if (app) return stringify(await app(args, hooks));
197
262
  // THE ARGUMENTS GO OUT AS THE MODEL WROTE THEM. A wire-level rewrite used to strip
198
263
  // queries the conversation had already searched; it was removed 2026-08-16 because it
199
264
  // silently competed with the tool descriptions for the same decision, and the
@@ -269,7 +334,7 @@ export async function toolBridgeFor(executionContext: any): Promise<ToolBridge |
269
334
  // A WORKFLOW-BOUND APP (path A) OPENS, then ends the turn. It used to only end
270
335
  // the turn: nothing registered an invoker, so the model's call fell through to
271
336
  // `api.callService` and died as an unknown SERVICE method while the run reported
272
- // success and the screen stayed blank (SAB, 2026-09-07). Both, in this order —
337
+ // success and the screen stayed blank (a bank, 2026-09-07). Both, in this order —
273
338
  // the app tool renders the surface and fires no workflow itself (server
274
339
  // mcp/server/tools.ts), so the handoff that follows is not a second run.
275
340
  appInvokers.set(def.name, harness.workflowAppInvoker(mcp, called));
@@ -308,4 +373,5 @@ export async function toolBridgeFor(executionContext: any): Promise<ToolBridge |
308
373
  return harness.isTurnEndingHandoff(calls, handoffTools);
309
374
  },
310
375
  };
376
+ return bridge;
311
377
  }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * A MARKETPLACE MCP IS A TASK (docs/mcp/MCP_TASKS.md § 2a): `unoverse://tasks/marketplace/<node>`,
3
+ * the node's type in kebab case, names a marketplace node that offers MCP tools, and listing it
4
+ * hands over every tool that MCP offers. This is the one place that reads it, for the Agent's
5
+ * runner and for every screen that draws an Agent.
6
+ */
7
+ import { getLoadedManifestTypes, getManifestNode } from "./index.js";
8
+ import { taskParts, MARKETPLACE_ORG } from "../definitions/registry/taskUri.js";
9
+
10
+ /** A node type's task name: `SignalSave` → `signal-save`. */
11
+ export const mcpTaskName = (type: string): string => type.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
12
+
13
+ /** The marketplace MCP a task names: the node type that offers MCP tools, or null. */
14
+ export function marketplaceMcp(uri: string, types: string[] = getLoadedManifestTypes()): string | null {
15
+ const p = taskParts(uri);
16
+ if (!p || p.org !== MARKETPLACE_ORG) return null;
17
+ return (
18
+ types.find((t) => mcpTaskName(t) === p.name && ((getManifestNode(t)?.definition?.serviceConnectors ?? []) as Array<{ serviceType?: string }>).some((c) => c?.serviceType === "mcp")) ?? null
19
+ );
20
+ }
@@ -1,6 +1,6 @@
1
1
  /**
2
- * OAUTH2 CLIENT CREDENTIALS. An auth scheme, so it is written once here rather than in
3
- * every node that needs it (DECLARATIVE_NODES.md §2).
2
+ * OAUTH2 CLIENT CREDENTIALS AND REFRESH TOKENS. Auth schemes, so they are written once here
3
+ * rather than in every node that needs them (DECLARATIVE_NODES.md §2).
4
4
  *
5
5
  * Unlike a bearer key, this is not a value the manifest can template: the credential is a
6
6
  * client id and secret that must be EXCHANGED for a short-lived token, over a form-encoded
@@ -10,7 +10,15 @@
10
10
  * The token response is put back into scope as `token`, because some vendors return where
11
11
  * to talk as well as how. Salesforce returns `instance_url` and the org's API lives there,
12
12
  * not at the login host you authenticated against.
13
+ *
14
+ * A REFRESH TOKEN is the same exchange with one more secret. Some vendors (LinkedIn) never
15
+ * issue a token to an app alone: a person approves the app once, and what that leaves behind
16
+ * is a long-lived refresh token the app trades for short-lived access tokens with nobody
17
+ * signed in. The person makes it once in the vendor's developer portal and it is pasted into
18
+ * the credential, as the client secret is.
13
19
  */
20
+ import { createHash } from "node:crypto";
21
+
14
22
 
15
23
  interface Minted {
16
24
  accessToken: string;
@@ -22,16 +30,23 @@ interface Minted {
22
30
  * Cached per connected-app identity, not per node or per run.
23
31
  *
24
32
  * A workflow that makes six calls must not mint six tokens, and a vendor will rate-limit or
25
- * refuse if it does. Keyed by client id AND token url so two orgs never share an entry.
33
+ * refuse if it does. Keyed by client id AND token url so two orgs never share an entry, and
34
+ * for a refresh token by that token too: two orgs may connect through the same vendor app.
26
35
  */
27
36
  const CACHE = new Map<string, Minted>();
28
37
 
29
38
  /** Default life. Salesforce client-credentials sessions are ~2h; refresh well before. */
30
39
  const DEFAULT_TTL_MS = 110 * 60 * 1000;
31
40
 
41
+ function cacheKey(tokenUrl: string, clientId: string, refreshToken?: string): string {
42
+ // A digest, never the refresh token itself, so the key holds no secret.
43
+ const who = refreshToken ? `#${createHash("sha256").update(refreshToken).digest("hex").slice(0, 16)}` : "";
44
+ return `${clientId}@${tokenUrl}${who}`;
45
+ }
46
+
32
47
  /** Drop a cached token, so the next call mints a fresh one. */
33
- export function invalidateToken(tokenUrl: string, clientId: string): void {
34
- CACHE.delete(`${clientId}@${tokenUrl}`);
48
+ export function invalidateToken(tokenUrl: string, clientId: string, refreshToken?: string): void {
49
+ CACHE.delete(cacheKey(tokenUrl, clientId, refreshToken));
35
50
  }
36
51
 
37
52
  export async function mintClientCredentials(
@@ -41,15 +56,33 @@ export async function mintClientCredentials(
41
56
  scope: string | undefined,
42
57
  force = false,
43
58
  ): Promise<Minted> {
44
- const key = `${clientId}@${tokenUrl}`;
59
+ const form = new URLSearchParams({ grant_type: "client_credentials", client_id: clientId, client_secret: clientSecret });
60
+ if (scope) form.set("scope", scope);
61
+ return mint("oauth2ClientCredentials", tokenUrl, form, cacheKey(tokenUrl, clientId), force);
62
+ }
63
+
64
+ export async function mintFromRefreshToken(
65
+ tokenUrl: string,
66
+ clientId: string,
67
+ clientSecret: string,
68
+ refreshToken: string,
69
+ force = false,
70
+ ): Promise<Minted> {
71
+ const form = new URLSearchParams({
72
+ grant_type: "refresh_token",
73
+ refresh_token: refreshToken,
74
+ client_id: clientId,
75
+ client_secret: clientSecret,
76
+ });
77
+ return mint("oauth2RefreshToken", tokenUrl, form, cacheKey(tokenUrl, clientId, refreshToken), force);
78
+ }
79
+
80
+ async function mint(scheme: string, tokenUrl: string, form: URLSearchParams, key: string, force: boolean): Promise<Minted> {
45
81
  const hit = CACHE.get(key);
46
82
  if (!force && hit && hit.expiresAt > Date.now()) return hit;
47
83
 
48
84
  // FORM ENCODED, not JSON. The OAuth2 spec says so and vendors enforce it, which is why
49
85
  // this cannot ride the ordinary body path: that one always sends JSON.
50
- const form = new URLSearchParams({ grant_type: "client_credentials", client_id: clientId, client_secret: clientSecret });
51
- if (scope) form.set("scope", scope);
52
-
53
86
  const res = await fetch(tokenUrl, {
54
87
  method: "POST",
55
88
  headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
@@ -61,7 +94,7 @@ export async function mintClientCredentials(
61
94
  // The description, not just the status: "invalid_client" tells you which of the two
62
95
  // secrets is wrong, and a bare 400 does not.
63
96
  const detail = data?.error_description ?? data?.error ?? res.statusText;
64
- throw new Error(`oauth2ClientCredentials: token request to ${tokenUrl} failed (${res.status}): ${detail}`);
97
+ throw new Error(`${scheme}: token request to ${tokenUrl} failed (${res.status}): ${detail}`);
65
98
  }
66
99
 
67
100
  const minted: Minted = {
@@ -211,7 +211,7 @@ export function makeEmitter(node: ComposedNode, onEmit: (e: Emission) => void, b
211
211
  * word after the first sitting in `pending`, and the next thing to move it is whatever
212
212
  * arrives next, however much later.
213
213
  *
214
- * Seen live (SAB Account Agent, 2026-09-08): the model said "I'm checking your account
214
+ * Seen live (a bank's account agent, 2026-09-08): the model said "I'm checking your account
215
215
  * activity for food spending last month" in a few milliseconds, the reader saw "I", and
216
216
  * the rest appeared eleven seconds afterwards — when a delta from the turn AFTER the
217
217
  * tool call finally pushed it out. It read as the stream stopping for the tool. It was
@@ -10,7 +10,7 @@ import { render, evaluate, primeTemplating } from "../templating.js";
10
10
  import { assertAllowedHost } from "./allowedHosts.js";
11
11
  import { readSettled, assertOk } from "./response.js";
12
12
  import { performState, type StateStore } from "../state.js";
13
- import { mintClientCredentials, invalidateToken } from "../auth/oauth.js";
13
+ import { mintClientCredentials, mintFromRefreshToken, invalidateToken } from "../auth/oauth.js";
14
14
  import { signAwsRequest, presignAwsUrl, encodeDynamoJson, decodeDynamoJson, type AwsSigning } from "../auth/aws.js";
15
15
  import { fetchPaginated } from "../loops/paginate.js";
16
16
  import { fetchPolled } from "../loops/poll.js";
@@ -116,6 +116,17 @@ export async function buildRequest(
116
116
  scoped = { ...ctx, token: { instanceUrl: minted.instanceUrl } } as RunContext;
117
117
  break;
118
118
  }
119
+ case "oauth2RefreshToken": {
120
+ const minted = await mintFromRefreshToken(
121
+ await required(auth.tokenUrl, "a token url"),
122
+ await required(auth.clientId, "a client id"),
123
+ await required(auth.clientSecret, "a client secret"),
124
+ await required(auth.refreshToken, "a refresh token"),
125
+ forceReauth,
126
+ );
127
+ authHeaders.Authorization = `Bearer ${minted.accessToken}`;
128
+ break;
129
+ }
119
130
  case "awsSigV4": {
120
131
  // The manifest names the SERVICE and the REGION, because those decide what the
121
132
  // signature is scoped to and they are properties of the endpoint being described.
@@ -237,6 +248,15 @@ async function resolveValue(value: any, ctx: RunContext): Promise<unknown> {
237
248
  * A timeout is an ERROR, not an empty result: it fails with a message naming the limit and the call,
238
249
  * so the manifest's own number appears in the log that reports it.
239
250
  */
251
+ /** How many times a call whose connection dropped is made again: 2s, 4s, 8s, 16s, 32s, 60s, 60s, 60s, 60s, about five minutes. */
252
+ export const DROPPED_RETRIES = 9;
253
+ /** A failure with no reply at all: the connection dropped, was refused, reset, or never resolved. Never a status. */
254
+ export function dropped(error: any): boolean {
255
+ const code = String(error?.cause?.code ?? error?.code ?? "");
256
+ const text = String(error?.message ?? error);
257
+ return /^(ECONNRESET|ECONNREFUSED|EAI_AGAIN|ENOTFOUND|EPIPE|ETIMEDOUT|UND_ERR_SOCKET|UND_ERR_CONNECT_TIMEOUT)$/.test(code) || /^fetch failed$/i.test(text) || /socket hang up|other side closed/i.test(text);
258
+ }
259
+
240
260
  export async function fetchWithTimeout(url: string, init: any, timeoutMs: unknown, label: string): Promise<Response> {
241
261
  const ms = typeof timeoutMs === "number" && timeoutMs > 0 ? timeoutMs : 0;
242
262
  if (!ms) return fetch(url, init);
@@ -408,6 +428,21 @@ export async function sendRequest(node: ComposedNode, requestSpec: any, ctx: Run
408
428
  waiter?.unref?.();
409
429
  try {
410
430
  res = await fetchWithTimeout(url, init, requestSpec?.timeoutMs, label);
431
+ } catch (error: any) {
432
+ /**
433
+ * A DROPPED CONNECTION IS ASKED AGAIN (2026-10-22). A host that scales to zero answers its first
434
+ * call by booting; the gateway in front of it drops the connection while a model loads (NuExtract
435
+ * on Beam: four and a half minutes), and the engine saw "fetch failed" with no status at all. That
436
+ * is not the vendor's answer, it is no answer, so the call is made again with a growing wait, up
437
+ * to about five minutes in all. A reply with a status, good or bad, is never retried here.
438
+ */
439
+ if (dropped(error) && attempt <= DROPPED_RETRIES) {
440
+ const wait = Math.min(60_000, 2_000 * 2 ** (attempt - 1));
441
+ console.warn(`[manifests] ${label}: the connection dropped (${String(error?.cause?.code ?? error?.message ?? error).slice(0, 80)}); asking again in ${Math.round(wait / 1000)}s (${attempt}/${DROPPED_RETRIES})`);
442
+ await new Promise((r) => setTimeout(r, wait));
443
+ continue;
444
+ }
445
+ throw error;
411
446
  } finally {
412
447
  if (waiter) clearInterval(waiter);
413
448
  }
@@ -428,8 +463,13 @@ export async function sendRequest(node: ComposedNode, requestSpec: any, ctx: Run
428
463
  // A minted token can be revoked or expire early, and the vendor says so with a 401.
429
464
  // Refresh ONCE and retry, exactly as the retired client did. Guarded by !forceReauth so
430
465
  // a genuinely rejected credential fails rather than looping.
431
- if (res.status === 401 && requestSpec?.credential?.scheme === "oauth2ClientCredentials" && !forceReauth) {
432
- invalidateToken(String(render(requestSpec.credential.tokenUrl, ctx)), String(render(requestSpec.credential.clientId, ctx)));
466
+ const scheme = requestSpec?.credential?.scheme;
467
+ if (res.status === 401 && (scheme === "oauth2ClientCredentials" || scheme === "oauth2RefreshToken") && !forceReauth) {
468
+ invalidateToken(
469
+ String(render(requestSpec.credential.tokenUrl, ctx)),
470
+ String(render(requestSpec.credential.clientId, ctx)),
471
+ scheme === "oauth2RefreshToken" ? String(render(requestSpec.credential.refreshToken, ctx)) : undefined,
472
+ );
433
473
  console.warn(`[manifests] ${label}: 401, refreshing the access token and retrying once`);
434
474
  return sendRequest(node, requestSpec, ctx, label, true);
435
475
  }
@@ -512,8 +552,11 @@ export async function runCalls(
512
552
  ctx: RunContext,
513
553
  label: string,
514
554
  store?: StateStore,
555
+ /** Replies already in hand from earlier calls the CALLER made itself (a lifecycle hook's node
556
+ * calls, server mcp/lifecycleYaml.ts), so these calls see them as `calls.<name>` too. */
557
+ earlier?: Record<string, any>,
515
558
  ): Promise<{ results: Record<string, any>; last: any }> {
516
- const results: Record<string, any> = {};
559
+ const results: Record<string, any> = { ...(earlier ?? {}) };
517
560
  let last: any = undefined;
518
561
 
519
562
  for (const call of calls) {
@@ -141,12 +141,34 @@ export async function fetchPolled(
141
141
  `[manifests] ${label}: job started — polling ${url.split("?")[0]} every ${Math.round(interval / 1000)}s (up to ${attempts} attempts)`,
142
142
  );
143
143
 
144
+ // THE FIRST CHECKS COME QUICKLY (2026-10-25): one second, then two, four, doubling up to the manifest's interval. A job
145
+ // answered from the vendor's cache is ready at once, and a fixed ten-second wait before the first check made every
146
+ // stored parse take ten seconds; a long job still settles into the full interval, so its patience is barely shorter.
147
+ let wait = Math.min(1000, interval);
144
148
  for (let attempt = 1; attempt <= attempts; attempt++) {
145
- await new Promise((r) => setTimeout(r, interval));
146
-
147
- const res = await sendRequest(node, statusCall, ctx, `${label} (poll ${attempt}/${attempts})`);
148
- const payload = await readSettled(res, call.transport, call.encoding);
149
- await assertOk(node, call, payload, `${label} (poll ${attempt}/${attempts})`);
149
+ await new Promise((r) => setTimeout(r, wait));
150
+ wait = Math.min(wait * 2, interval);
151
+
152
+ /**
153
+ * A MISSED POLL IS NOT A LOST JOB (2026-10-22). The job runs on the vendor's side; a status ask
154
+ * that cannot get through (the connection dropped, a gateway answered 502/503/504 while a
155
+ * container turned over) says nothing about the job. One such miss ended a parse that the
156
+ * vendor's own log shows finished sixteen seconds later. A miss is counted as an attempt and
157
+ * the wait goes on; only the attempts bound, or an answer that is an error, ends it.
158
+ */
159
+ let payload: any;
160
+ try {
161
+ const res = await sendRequest(node, statusCall, ctx, `${label} (poll ${attempt}/${attempts})`);
162
+ payload = await readSettled(res, call.transport, call.encoding);
163
+ await assertOk(node, call, payload, `${label} (poll ${attempt}/${attempts})`);
164
+ } catch (error: any) {
165
+ const text = String(error?.message ?? error);
166
+ if (attempt < attempts && /\b50[234]\b|fetch failed|ECONN|EAI_AGAIN|socket|timed out|aborted/i.test(text)) {
167
+ console.warn(`[manifests] ${label}: poll ${attempt}/${attempts} could not reach the job (${text.slice(0, 160)}); asking again`);
168
+ continue;
169
+ }
170
+ throw error;
171
+ }
150
172
 
151
173
  const status = typeof payload?.status === "string" ? payload.status : undefined;
152
174
  if (status !== lastStatus || attempt % heartbeat === 0)
@@ -32,8 +32,11 @@ import { makeUsageCollector } from "../usage.js";
32
32
  export interface ToolBridge {
33
33
  /** Core tools from the provider's getSchema, as { name, description, parameters }. */
34
34
  discover(): Promise<any[]>;
35
- /** Execute one call; returns the raw result content. */
36
- call(name: string, args: any): Promise<string>;
35
+ /**
36
+ * Execute one call; returns the raw result content. `onStatus` hears what a called TASK is
37
+ * doing each time its status changes (docs/mcp/MCP_TASKS.md § 5, "what reaches the model").
38
+ */
39
+ call(name: string, args: any, hooks?: { onStatus?: (statusMessage: string) => void }): Promise<string>;
37
40
  /**
38
41
  * Absorb one tool reply: unlock + shell-open any apps it discovered, and hand back the
39
42
  * MODEL-FACING form of it plus any tools to register.
@@ -92,7 +95,7 @@ export function recordToolTrace(
92
95
  * on the same timeline but is not a tool the model chose, so it is told apart by a stated
93
96
  * kind rather than by the viewer sniffing its name.
94
97
  */
95
- kind: "tool" | "skill" | "task" = "tool",
98
+ kind: "tool" | "skill" | "task" | "turn" = "tool",
96
99
  ): void {
97
100
  recordCall({ executionId: (ctx as any)?.scope?.executionId, parentNodeId: (ctx as any)?.scope?.nodeId, toolName, args, result, startTime, success, kind, who: node.type });
98
101
  }
@@ -111,7 +114,7 @@ export function recordCall(call: {
111
114
  result: unknown;
112
115
  startTime: number;
113
116
  success: boolean;
114
- kind?: "tool" | "skill" | "task";
117
+ kind?: "tool" | "skill" | "task" | "turn";
115
118
  who: string;
116
119
  }): void {
117
120
  const { executionId, parentNodeId, toolName, args, result, startTime, success, kind = "tool" } = call;
@@ -245,8 +248,8 @@ export async function runToolLoop(
245
248
  * written, or the held set would keep a skill the conversation has walked away from. */
246
249
  let skillsChanged = false;
247
250
 
248
- // Token usage across ALL turns of this run — each turn is its own model call with its
249
- // own usage block; the collector sums them and posts once when the loop settles.
251
+ // Token usage, posted after EACH turn (each turn is its own model call with its own usage
252
+ // block), so a run's spend is on record while it runs, and once more when the loop settles.
250
253
  const usage = makeUsageCollector(node, ctx);
251
254
 
252
255
  for (turn = 1; turn <= maxTurns; turn++) {
@@ -275,6 +278,9 @@ export async function runToolLoop(
275
278
  // would send each result twice — once as a message and once in a field the vendor ignores.
276
279
  if (results && !te.transcript) extra[te.resultsInto ?? "input"] = results;
277
280
 
281
+ // WHEN THE TURN STARTED, for its own row on the timeline (below): without it the gap between
282
+ // two rounds of tool calls could not be told apart into model time and everything else.
283
+ const turnStart = Date.now();
278
284
  const res = await sendRequest(node, withExtra(modelCall, extra), scoped, `${node.type} (turn ${turn})`);
279
285
 
280
286
  // Two readers of one stream: the events table decides what leaves the node, and the
@@ -291,6 +297,12 @@ export async function runToolLoop(
291
297
  * Chat Completions), and accumulated because it arrives a token at a time.
292
298
  */
293
299
  let spoken = "";
300
+ /**
301
+ * The assistant's OWN REASONING this turn, for a vendor that keeps it only when it is sent back
302
+ * (GLM's preserved thinking). Without it the model rebuilds its whole plan every turn. Read and
303
+ * accumulated exactly as `spoken` is; the manifest says where it arrives and where it goes back.
304
+ */
305
+ let thought = "";
294
306
  await readSse(res, modelCall.terminator, async (payload) => {
295
307
  await assertOk(node, modelCall, payload, `${node.type} (turn ${turn})`);
296
308
  usage.see(payload);
@@ -299,6 +311,10 @@ export async function runToolLoop(
299
311
  const piece = await evaluate(te.transcript.text, { response: payload });
300
312
  if (piece) spoken += String(piece);
301
313
  }
314
+ if (te.transcript?.thinking) {
315
+ const piece = await evaluate(te.transcript.thinking, { response: payload });
316
+ if (piece) thought += String(piece);
317
+ }
302
318
  // `match` is OPTIONAL. It selects by event TYPE, which only works for a vendor whose SSE
303
319
  // events carry one — the Responses API does. A Chat Completions chunk has no `type` at all, so
304
320
  // with no match declared every payload is considered and `when` does the filtering.
@@ -306,6 +322,7 @@ export async function runToolLoop(
306
322
  if (te.continuity && payload.type === te.continuity.match) chainPayload = payload;
307
323
  });
308
324
  if (chainPayload) chainId = String(await evaluate(te.continuity.from, { response: chainPayload }));
325
+ usage.save();
309
326
 
310
327
  // THE TURN IS OVER, SO NOTHING MORE IS COMING TO CARRY A THROTTLE OUT.
311
328
  //
@@ -314,13 +331,16 @@ export async function runToolLoop(
314
331
  // window — and if the model answered inside a single window, that is the whole answer
315
332
  // but its first token. It then surfaces on the next turn's first delta, which is after
316
333
  // any tool this turn called: the reader saw "I", then the rest eleven seconds later,
317
- // and it read as the stream stopping for the tool (SAB, 2026-09-08).
334
+ // and it read as the stream stopping for the tool (a bank, 2026-09-08).
318
335
  //
319
336
  // Here rather than in the manifest, and by the LOOP rather than a timer: only the loop
320
337
  // knows where a turn ends, which is the same reason it owns counting them.
321
338
  emitter.flush();
322
339
 
323
340
  const calls = await readCalls(te, raw);
341
+ // EACH MODEL TURN ON THE TIMELINE, beside the calls it asked for: how long the model took,
342
+ // what it asked for and how much it wrote. Recorded once the turn has streamed in full.
343
+ recordCall({ executionId: (ctx as any)?.scope?.executionId, parentNodeId: (ctx as any)?.scope?.nodeId, toolName: `turn ${turn}`, args: { toolsOffered: live.length }, result: { asked: calls.map((c) => c.name), wroteChars: spoken.length }, startTime: turnStart, success: true, kind: "turn", who: node.type });
324
344
 
325
345
  // Answered rather than called. Done.
326
346
  if (!calls.length) break;
@@ -337,6 +357,8 @@ export async function runToolLoop(
337
357
  const absorbed: string[] = [];
338
358
  let stuck = false;
339
359
 
360
+ // Which calls run: every one before the first repeated too often, in the order asked.
361
+ const toRun: { call: (typeof calls)[number]; args: any }[] = [];
340
362
  for (const call of calls) {
341
363
  const signature = `${call.name}:${call.arguments}`;
342
364
  const count = (seen.get(signature) ?? 0) + 1;
@@ -346,25 +368,38 @@ export async function runToolLoop(
346
368
  stuck = true;
347
369
  break;
348
370
  }
349
-
350
371
  const args = JSON.parse(call.arguments || "{}");
351
372
  narrate({ kind: "toolCall", toolName: call.name, args });
373
+ toRun.push({ call, args });
374
+ }
352
375
 
353
- let content: string;
354
- /**
355
- * THE TIMELINE BAR is the TOOL's to record, never the loop's (owner, 2026-10-01;
356
- * packages/docs/nodes/mcp-services.md, "Who records a call"). Every tool records its
357
- * own call wherever it runs (a node's method, a row's function, a platform tool), with
358
- * `success` false on a failure, so a call reads the same from an Agent, a voice session
359
- * or an outside host. The bar once vanished the day `openai` migrated, because it lived
360
- * in each Agent family; it lives with the tool now.
361
- */
362
- try {
363
- content = await bridge.call(call.name, args);
364
- } catch (err: any) {
365
- // A failed tool is information for the model, not a crash: it can try another.
366
- content = JSON.stringify({ error: err?.message ?? String(err) });
367
- }
376
+ /**
377
+ * THE CALLS OF ONE TURN RUN TOGETHER (UNO-190; docs/mcp/MCP_TASKS.md § 5, Orchestrated).
378
+ * A model that asks for several tools at once wants them side by side: twelve searches
379
+ * fanned out as tasks take as long as the slowest, not the sum. Everything after the call
380
+ * (recording, absorbing, skills in force, minted tools) still goes in the order asked.
381
+ *
382
+ * THE TIMELINE BAR is the TOOL's to record, never the loop's (owner, 2026-10-01;
383
+ * packages/docs/nodes/mcp-services.md, "Who records a call"). Every tool records its
384
+ * own call wherever it runs (a node's method, a row's function, a platform tool), with
385
+ * `success` false on a failure, so a call reads the same from an Agent, a voice session
386
+ * or an outside host. The bar once vanished the day `openai` migrated, because it lived
387
+ * in each Agent family; it lives with the tool now.
388
+ */
389
+ const contents = await Promise.all(
390
+ toRun.map(({ call, args }) =>
391
+ Promise.resolve()
392
+ // A TASK THAT RUNS FOR MINUTES SAYS WHAT IT IS DOING: each new status is narrated,
393
+ // so the caller (a person, a voice) hears progress before the result arrives.
394
+ .then(() => bridge.call(call.name, args, { onStatus: (status) => narrate({ kind: "taskStatus", toolName: call.name, args, status }) }))
395
+ // A failed tool is information for the model, not a crash: it can try another.
396
+ .catch((err: any) => JSON.stringify({ error: err?.message ?? String(err) })),
397
+ ),
398
+ );
399
+
400
+ for (let i = 0; i < toRun.length; i++) {
401
+ const { call, args } = toRun[i];
402
+ const content = contents[i];
368
403
  exchanges.push({ name: call.name, resultContent: content });
369
404
  // The RESULT, and only now that it exists. It is never in the HTTP stream: our loop
370
405
  // produced it, which is why it cannot be a `from: response` row. PARSED for the
@@ -432,7 +467,7 @@ export async function runToolLoop(
432
467
  * vendor's message shape — `te.result` produced them — so they go in as they are.
433
468
  */
434
469
  if (te.transcript) {
435
- if (te.transcript.assistant) transcript.push(await evaluate(te.transcript.assistant, { calls, text: spoken }));
470
+ if (te.transcript.assistant) transcript.push(await evaluate(te.transcript.assistant, { calls, text: spoken, thinking: thought }));
436
471
  transcript.push(...results);
437
472
  }
438
473
  }