@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.
- package/local/catalog.ts +3 -0
- package/local/plugin.ts +12 -9
- package/local/scaffold.d.mts +1 -0
- package/local/scaffold.mjs +12 -0
- package/package.json +1 -1
- package/screens/AgentChain.tsx +232 -0
- package/screens/AgentsView.tsx +85 -6
- package/screens/SkillsView.tsx +21 -3
- package/screens/TemplatesLibraryView.tsx +32 -3
- package/screens/TemplatesView.tsx +2 -2
- package/screens/index.ts +4 -1
- package/src/host.ts +5 -0
- package/src/index.css +11 -0
- package/vendor/base/src/agent-mcp/discovery.ts +5 -2
- package/vendor/base/src/agent-mcp/index.ts +3 -1
- package/vendor/base/src/agent-mcp/invoke.ts +199 -25
- package/vendor/base/src/agent-mcp/progressive.ts +124 -0
- package/vendor/base/src/agent-mcp/tools.ts +18 -11
- package/vendor/base/src/agent-mcp/types.ts +8 -0
- package/vendor/base/src/auth/agentSwitch.ts +52 -0
- package/vendor/base/src/auth/agentToken.ts +142 -0
- package/vendor/base/src/auth/auth.ts +7 -0
- package/vendor/base/src/definitions/load/expand/inherit.ts +1 -1
- package/vendor/base/src/definitions/load/expand/refs.ts +1 -1
- package/vendor/base/src/definitions/load/expand/walk.ts +1 -1
- package/vendor/base/src/definitions/load/load.ts +6 -1
- package/vendor/base/src/definitions/registry/agentFolders.ts +113 -0
- package/vendor/base/src/definitions/registry/catalog.ts +1 -1
- package/vendor/base/src/definitions/registry/objects.ts +109 -5
- package/vendor/base/src/definitions/registry/pipelines.ts +24 -5
- package/vendor/base/src/definitions/registry/taskStarts.ts +76 -0
- package/vendor/base/src/definitions/registry/taskUri.ts +67 -0
- package/vendor/base/src/http/uwsFetchAdapter.ts +39 -3
- package/vendor/base/src/items/collect.ts +10 -0
- package/vendor/base/src/items/publish.ts +10 -0
- package/vendor/base/src/lint/design/index.mjs +14 -3
- package/vendor/base/src/lint/design/vocabulary.mjs +1 -1
- package/vendor/base/src/lint/objects/index.d.mts +1 -0
- package/vendor/base/src/lint/objects/index.mjs +121 -3
- package/vendor/base/src/lint/objects/rules.d.mts +3 -0
- package/vendor/base/src/lint/objects/rules.mjs +106 -7
- package/vendor/base/src/manifests/compose.ts +3 -0
- package/vendor/base/src/manifests/executor/callback.ts +3 -2
- package/vendor/base/src/manifests/executor/memory.ts +27 -0
- package/vendor/base/src/manifests/executor/toolBridge.ts +79 -13
- package/vendor/base/src/manifests/marketplaceMcp.ts +20 -0
- package/vendor/base/src/manifests/runtime/auth/oauth.ts +43 -10
- package/vendor/base/src/manifests/runtime/events.ts +1 -1
- package/vendor/base/src/manifests/runtime/http/request.ts +47 -4
- package/vendor/base/src/manifests/runtime/loops/poll.ts +27 -5
- package/vendor/base/src/manifests/runtime/tools/toolloop.ts +59 -24
- package/vendor/base/src/manifests/runtime/usage.ts +13 -5
- package/vendor/base/src/platform/agentBudget.ts +65 -0
- package/vendor/base/src/platform/agentCalendar.ts +87 -0
- package/vendor/base/src/platform/inbound.ts +67 -0
- package/vendor/base/src/platform/taskChanged.ts +25 -0
- package/vendor/base/src/platform/tokenCost.ts +414 -0
- package/vendor/base/src/security/permissions.ts +10 -2
- package/vendor/sdk/appEngine.ts +42 -1
- package/vendor/sdk/lib/core/actions.ts +10 -0
- package/vendor/sdk/lib/core/apps.ts +2 -2
- package/vendor/sdk/lib/core/client.ts +11 -0
- package/vendor/sdk/lib/core/store.ts +1 -1
- package/vendor/sdk/lib/core/types.ts +2 -0
- package/vendor/sdk/lib/dom/app.ts +3 -2
- package/vendor/sdk/lib/dom/component.ts +38 -1
- package/vendor/sdk/lib/dom/element.ts +23 -0
- package/vendor/sdk/lib/dom/markdown.ts +6 -2
- package/vendor/sdk/lib/dom/primitives.ts +97 -6
- package/vendor/sdk/lib/voiceService.ts +3 -1
- package/vendor/sdk/main.ts +1 -1
- 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
|
-
|
|
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
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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 (
|
|
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.
|
|
3
|
-
* every node that needs
|
|
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(
|
|
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
|
|
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(
|
|
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 (
|
|
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
|
-
|
|
432
|
-
|
|
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,
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
/**
|
|
36
|
-
|
|
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
|
|
249
|
-
//
|
|
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 (
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
}
|