@unoverse-platform/studio 0.1.96 → 0.1.98
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/bin/studio.mjs +3 -3
- package/local/authoring.ts +1 -1
- package/local/catalog.ts +22 -2
- package/local/keys.ts +1 -1
- package/local/nodes.ts +29 -42
- package/local/plugin.ts +75 -13
- package/local/sample-identity/brand.yaml +24 -0
- package/local/sample-identity/organisation.yaml +14 -0
- package/local/sample-identity/purpose.yaml +14 -0
- package/local/sample-identity/story.yaml +18 -0
- package/local/scaffold.mjs +51 -22
- package/local/source.ts +4 -9
- package/package.json +1 -1
- package/screens/AgentsView.tsx +173 -0
- package/screens/ComponentsView.tsx +2 -2
- package/screens/EmptyLibrary.tsx +1 -1
- package/screens/IdentityHeader.tsx +1 -1
- package/screens/IdentityView.tsx +206 -0
- package/screens/PromptBlocksView.tsx +1 -1
- package/screens/SourceView.tsx +1 -1
- package/screens/TemplatesLibraryView.tsx +91 -6
- package/screens/TemplatesView.tsx +24 -9
- package/screens/index.ts +5 -2
- package/src/App.tsx +14 -4
- package/src/NodeKeys.tsx +1 -1
- package/src/host.ts +3 -16
- package/vendor/base/src/agent-mcp/discovery.ts +76 -18
- package/vendor/base/src/agent-mcp/handoff.ts +11 -31
- package/vendor/base/src/agent-mcp/index.ts +5 -5
- package/vendor/base/src/agent-mcp/invoke.ts +37 -19
- package/vendor/base/src/agent-mcp/lean.ts +19 -0
- package/vendor/base/src/agent-mcp/memoryContext.ts +15 -9
- package/vendor/base/src/agent-mcp/memoryIngest.ts +8 -3
- package/vendor/base/src/agent-mcp/rowComponents.ts +184 -72
- package/vendor/base/src/agent-mcp/tools.ts +136 -7
- package/vendor/base/src/agent-mcp/types.ts +47 -10
- package/vendor/base/src/definitions/load/compile/component.ts +59 -4
- package/vendor/base/src/definitions/load/compile/sections.ts +7 -1
- package/vendor/base/src/definitions/load/compile/template.ts +19 -15
- package/vendor/base/src/definitions/load/expand/inherit.ts +5 -1
- package/vendor/base/src/definitions/load/expand/referenced.ts +21 -2
- package/vendor/base/src/definitions/load/expand/refs.ts +14 -0
- package/vendor/base/src/definitions/load/expand/walk.ts +88 -38
- package/vendor/base/src/definitions/load/load.ts +37 -28
- package/vendor/base/src/definitions/model.ts +8 -12
- package/vendor/base/src/definitions/registry/agents.ts +111 -0
- package/vendor/base/src/definitions/registry/catalog.ts +28 -12
- package/vendor/base/src/definitions/registry/datasetSql.ts +149 -0
- package/vendor/base/src/definitions/registry/homes.ts +69 -5
- package/vendor/base/src/definitions/registry/objects.ts +413 -0
- package/vendor/base/src/definitions/registry/orgDocuments.ts +107 -0
- package/vendor/base/src/definitions/registry/orgPack.ts +50 -0
- package/vendor/base/src/definitions/registry/pipelines.ts +197 -0
- package/vendor/base/src/definitions/theme.ts +1 -2
- package/vendor/base/src/items/collect.ts +33 -25
- package/vendor/base/src/items/publish.ts +31 -7
- package/vendor/base/src/lint/context.mjs +17 -0
- package/vendor/base/src/lint/design/file.mjs +199 -2
- package/vendor/base/src/lint/design/index.mjs +66 -73
- package/vendor/base/src/lint/design/vocabulary.mjs +6 -3
- package/vendor/base/src/lint/design/walk.mjs +14 -4
- package/vendor/base/src/lint/index.mjs +15 -9
- package/vendor/base/src/lint/nodes/blocks.mjs +6 -2
- package/vendor/base/src/lint/nodes/index.mjs +6 -1
- package/vendor/base/src/lint/nodes/node.mjs +157 -7
- package/vendor/base/src/lint/objects/index.d.mts +32 -0
- package/vendor/base/src/lint/objects/index.mjs +324 -0
- package/vendor/base/src/lint/objects/rules.d.mts +44 -0
- package/vendor/base/src/lint/objects/rules.mjs +446 -0
- package/vendor/base/src/lint/prompts/index.d.mts +34 -0
- package/vendor/base/src/lint/prompts/index.mjs +198 -0
- package/vendor/base/src/manifests/compose.ts +81 -10
- package/vendor/base/src/manifests/executor/callback.ts +15 -4
- package/vendor/base/src/manifests/executor/context.ts +12 -1
- package/vendor/base/src/manifests/executor/memory.ts +83 -3
- package/vendor/base/src/manifests/executor/session.ts +50 -0
- package/vendor/base/src/manifests/executor/toolBridge.ts +114 -49
- package/vendor/base/src/manifests/index.ts +1 -1
- package/vendor/base/src/manifests/runtime/auth/aws.ts +14 -1
- package/vendor/base/src/manifests/runtime/channels/final.ts +46 -34
- package/vendor/base/src/manifests/runtime/channels/service.ts +30 -32
- package/vendor/base/src/manifests/runtime/channels/workflow.ts +6 -11
- package/vendor/base/src/manifests/runtime/context.ts +18 -6
- package/vendor/base/src/manifests/runtime/duplex/audioLane.ts +159 -3
- package/vendor/base/src/manifests/runtime/duplex/session.ts +304 -9
- package/vendor/base/src/manifests/runtime/events.ts +39 -3
- package/vendor/base/src/manifests/runtime/http/concurrency.ts +44 -0
- package/vendor/base/src/manifests/runtime/http/request.ts +36 -7
- package/vendor/base/src/manifests/runtime/index.ts +3 -2
- package/vendor/base/src/manifests/runtime/loops/repeat.ts +86 -0
- package/vendor/base/src/manifests/runtime/tools/toolloop.ts +30 -3
- package/vendor/base/src/manifests/runtime/usage.ts +10 -0
- package/vendor/base/src/manifests/source.ts +1 -1
- package/vendor/base/src/paths.ts +16 -8
- package/vendor/base/src/platform/audioManager.ts +32 -0
- package/vendor/base/src/platform/executionContext.ts +14 -0
- package/vendor/base/src/platform/liveInput.ts +52 -0
- package/vendor/base/src/plugins/README.md +1 -1
- package/vendor/base/src/plugins/discovery.ts +1 -1
- package/vendor/base/src/template/SafeExpression.ts +6 -0
- package/vendor/base/src/workflow/GetMemoryService.ts +12 -11
- package/vendor/base/src/workflow/PlatformMemoryCore.ts +23 -10
- package/vendor/sdk/appEngine.tsx +31 -12
- package/vendor/sdk/lib/UnoverseComponent.tsx +8 -4
- package/vendor/sdk/lib/app.tsx +70 -95
- package/vendor/sdk/lib/connection.tsx +47 -57
- package/vendor/sdk/lib/core/actions.ts +6 -0
- package/vendor/sdk/lib/core/apps.ts +66 -81
- package/vendor/sdk/lib/core/client.ts +5 -14
- package/vendor/sdk/lib/core/connection.ts +30 -0
- package/vendor/sdk/lib/core/index.ts +2 -2
- package/vendor/sdk/lib/core/store.ts +90 -15
- package/vendor/sdk/lib/core/types.ts +9 -8
- package/vendor/sdk/lib/primitives.tsx +113 -4
- package/vendor/sdk/lib/realtime/useAudioPlayback.ts +8 -1
- package/vendor/sdk/lib/render.tsx +3 -3
- package/vendor/sdk/lib/voice.tsx +27 -13
- package/vite.config.ts +1 -1
|
@@ -10,6 +10,29 @@
|
|
|
10
10
|
* Returns undefined when nothing granted tools, and the node then runs as a single request.
|
|
11
11
|
*/
|
|
12
12
|
import type { ToolBridge } from "../runtime/index.js";
|
|
13
|
+
import { engineUrl } from "./context.js";
|
|
14
|
+
|
|
15
|
+
/** The screen's verbs on an app with places (docs/unoverse/design/screen/UNOVERSE_SCREEN.md §3.3). */
|
|
16
|
+
const SCREEN_VERBS = {
|
|
17
|
+
show: {
|
|
18
|
+
description:
|
|
19
|
+
"Set what the user sees. Give the ids of everything that should be on screen now: the cards your answer is about, as the search result lists them, and anything already on screen that should stay (the ON SCREEN line gives their ids). Whatever is on screen and not in your list closes, and an empty list clears the screen. If you do not call it, the screen stays as it is. Call it straight after the search, before reading details, so the user sees the cards at once. Do not list cards you will hand to a task, such as a comparison: a task you call opens by itself.",
|
|
20
|
+
parameters: {
|
|
21
|
+
type: "object",
|
|
22
|
+
properties: { ids: { type: "array", items: { type: "string" } } },
|
|
23
|
+
required: ["ids"],
|
|
24
|
+
additionalProperties: false,
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
read: {
|
|
28
|
+
description: "What the user is looking at: every place on screen and each interface in it, with its id, type, state and props.",
|
|
29
|
+
parameters: { type: "object", properties: {}, additionalProperties: false },
|
|
30
|
+
},
|
|
31
|
+
close: {
|
|
32
|
+
description: "Take one interface off the screen, by its id.",
|
|
33
|
+
parameters: { type: "object", properties: { id: { type: "string" } }, required: ["id"], additionalProperties: false },
|
|
34
|
+
},
|
|
35
|
+
} as const;
|
|
13
36
|
|
|
14
37
|
export async function toolBridgeFor(executionContext: any): Promise<ToolBridge | undefined> {
|
|
15
38
|
const api = executionContext?.api;
|
|
@@ -25,8 +48,6 @@ export async function toolBridgeFor(executionContext: any): Promise<ToolBridge |
|
|
|
25
48
|
}
|
|
26
49
|
if (!schema?.methods) return undefined;
|
|
27
50
|
|
|
28
|
-
const core = new Set<string>(Object.keys(schema.methods));
|
|
29
|
-
|
|
30
51
|
// Identity for a HELD app call: it renders on the caller's live session and elicits
|
|
31
52
|
// from that human, so it needs the conversation, the turn, and the caller's token.
|
|
32
53
|
// Using auth HERE is correct and different from exposing it to a manifest: this is the
|
|
@@ -62,6 +83,18 @@ export async function toolBridgeFor(executionContext: any): Promise<ToolBridge |
|
|
|
62
83
|
*/
|
|
63
84
|
const discoveredApps = new Set<string>();
|
|
64
85
|
|
|
86
|
+
/**
|
|
87
|
+
* TOOLS MINTED FROM A TASK ROW, by minted name. A call to one of these is a task step on
|
|
88
|
+
* the execution timeline, not a bare tool call; `kindOf` answers from this set.
|
|
89
|
+
*/
|
|
90
|
+
const taskTools = new Set<string>();
|
|
91
|
+
/**
|
|
92
|
+
* TOOLS WHOSE CALL HANDS THE TURN OFF: a workflow-bound app fired through the service
|
|
93
|
+
* lane (`workflow@trigger`, fire-and-forget). Only these can end the loop; a component
|
|
94
|
+
* app, a workflow task (held, its result comes back) or a platform tool never does.
|
|
95
|
+
*/
|
|
96
|
+
const handoffTools = new Set<string>();
|
|
97
|
+
|
|
65
98
|
/**
|
|
66
99
|
* THE GUEST'S OWN WORDS, from the search that is about to unlock a page.
|
|
67
100
|
*
|
|
@@ -79,19 +112,36 @@ export async function toolBridgeFor(executionContext: any): Promise<ToolBridge |
|
|
|
79
112
|
const stringify = (r: unknown): string => (typeof r === "string" ? r : JSON.stringify(r));
|
|
80
113
|
|
|
81
114
|
/**
|
|
82
|
-
* PAGES ALREADY OPEN IN THIS CONVERSATION (
|
|
115
|
+
* PAGES ALREADY OPEN IN THIS CONVERSATION (2026-09-02).
|
|
83
116
|
*
|
|
84
|
-
* A follow-up send
|
|
117
|
+
* A follow-up send while a page is open starts a FRESH agent, and the page's tool
|
|
85
118
|
* was minted in an earlier run and died with it — so the agent could only chat at the
|
|
86
|
-
* page (observed live: three consecutive turns of clarifying questions at
|
|
119
|
+
* page (observed live: three consecutive turns of clarifying questions at an open
|
|
87
120
|
* page it had no tool to edit). At bridge build the open pages are read from the
|
|
88
121
|
* server's open-apps resource and their tools minted exactly like an unlock mint,
|
|
89
|
-
* minus the shell-open — the page is already on screen.
|
|
90
|
-
*
|
|
91
|
-
* current page rather than rendered blind. Fail-soft: any error means nothing
|
|
92
|
-
* pre-minted, which is exactly the old behaviour.
|
|
122
|
+
* minus the shell-open — the page is already on screen. Fail-soft: any error means
|
|
123
|
+
* nothing pre-minted, which is exactly the old behaviour.
|
|
93
124
|
*/
|
|
94
125
|
const preMinted: any[] = [];
|
|
126
|
+
/**
|
|
127
|
+
* THE SCREEN VERBS (docs/unoverse/design/screen/UNOVERSE_SCREEN.md §3.3): every agent gets them.
|
|
128
|
+
* A search returns data and draws nothing; the verbs are the door itself: show by id, read
|
|
129
|
+
* every place, close by id. Plain platform tools on /mcp, called with the session identity.
|
|
130
|
+
*/
|
|
131
|
+
for (const verb of ["show", "read", "close"] as const) {
|
|
132
|
+
// Underscore, not dot: model providers restrict tool names to [a-zA-Z0-9_-].
|
|
133
|
+
const name = `screen_${verb}`;
|
|
134
|
+
appInvokers.set(name, async (input) => {
|
|
135
|
+
const { client, close } = await harness.connectPlatformMcp({ accessToken: identity.accessToken });
|
|
136
|
+
try {
|
|
137
|
+
const r = (await client.callTool({ name, arguments: { ...((input ?? {}) as Record<string, unknown>), userId: identity.userId, conversationId: identity.conversationId, chatId: identity.chatId } })) as { content?: Array<{ type: string; text?: string }> };
|
|
138
|
+
return r.content?.find((c) => c.type === "text")?.text ?? "";
|
|
139
|
+
} finally {
|
|
140
|
+
await close();
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
preMinted.push({ name, ...SCREEN_VERBS[verb] });
|
|
144
|
+
}
|
|
95
145
|
if (identity.conversationId) {
|
|
96
146
|
try {
|
|
97
147
|
const { client, close } = await harness.connectPlatformMcp({ accessToken: identity.accessToken });
|
|
@@ -118,9 +168,10 @@ export async function toolBridgeFor(executionContext: any): Promise<ToolBridge |
|
|
|
118
168
|
discoveredApps.add(mcp.component);
|
|
119
169
|
preMinted.push({
|
|
120
170
|
...def,
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
171
|
+
// WHAT IS ON SCREEN, said where the decision is made: a message about the page itself
|
|
172
|
+
// is answered by updating it. "About it" is narrow on purpose: read loosely, the model
|
|
173
|
+
// rebuilt a comparison for "what is the best card for travel" (observed live).
|
|
174
|
+
description: `${def.description}\n\nON SCREEN NOW: this page is open and the user is looking at it. A message about the page itself updates it through this tool.`,
|
|
124
175
|
});
|
|
125
176
|
console.log(`[manifests] open page re-minted for this turn: ${def.name}`);
|
|
126
177
|
}
|
|
@@ -190,45 +241,27 @@ export async function toolBridgeFor(executionContext: any): Promise<ToolBridge |
|
|
|
190
241
|
const def = await harness.resolveToolDefNative(mcp, { accessToken: identity.accessToken });
|
|
191
242
|
const invoke = harness.componentAppInvoker(mcp, identity);
|
|
192
243
|
appInvokers.set(def.name, invoke);
|
|
244
|
+
if (mcp.task) taskTools.add(def.name);
|
|
193
245
|
|
|
194
246
|
/**
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
* resend, ghost headings shipped). So the ORDER is now physical: the shell call is
|
|
203
|
-
* AWAITED, and only then is the tool minted — with the page's chosen places in the
|
|
204
|
-
* tool's own description. The writer cannot write blind, and nothing needs bouncing.
|
|
205
|
-
*
|
|
206
|
-
* The guest loses nothing: the skeleton and the tiles render during this await; only
|
|
207
|
-
* the model's captions start those few seconds later. A failed shell mints the tool
|
|
208
|
-
* with its plain description — a page that arrives the old way, never a dead turn.
|
|
209
|
-
*
|
|
210
|
-
* `__draftRefs` carries the UNLOCKING SEARCH'S ranked row ids (the server drafts
|
|
211
|
-
* ref-only parts from them); the execution ids trace the page's searches into the
|
|
212
|
-
* run's own timeline.
|
|
247
|
+
* A TASK OPENS WHEN THE LLM CALLS IT (UNOVERSE_RENDER_PIPELINE §4, locked
|
|
248
|
+
* 2026-09-06). This used to open the shell at MINT — every app a search unlocked
|
|
249
|
+
* was rendered before the model had read the results (observed 2026-09-07: two
|
|
250
|
+
* surfaces opened at once, neither asked for). Tasks are asked: the unlock mints the
|
|
251
|
+
* tool and nothing more. What the shell-open used to carry — the unlocking search's
|
|
252
|
+
* ranked rows for drafting, the guest's ask for the page's own searches, the run's
|
|
253
|
+
* ids for tracing — rides the model's CALL instead, beneath its own arguments.
|
|
213
254
|
*/
|
|
214
255
|
const draftRefs = harness.rankedRowIds(resultContent);
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
const msg = typeof (opened as { message?: unknown })?.message === "string" ? (opened as { message: string }).message : "";
|
|
225
|
-
const chosen = msg.match(/THE PAGE'S CONTENT IS ALREADY CHOSEN — [^\n]*?never a different plan\./)?.[0];
|
|
226
|
-
if (chosen) chosenNote = `\n\n${chosen}`;
|
|
227
|
-
console.log(`[manifests] shell opened: ${def.name}${draftRefs.length ? ` (${draftRefs.length} draft refs)` : ""}${lastAsk ? ` ask="${lastAsk}"` : ""}${chosen ? " — chosen places ride the tool description" : ""}`);
|
|
228
|
-
} catch (e: any) {
|
|
229
|
-
console.log(`[manifests] shell open failed for ${def.name} — ${e?.message ?? e}`);
|
|
230
|
-
}
|
|
231
|
-
minted.push({ name: def.name, description: `${def.description}${chosenNote}`, parameters: def.parameters });
|
|
256
|
+
const ride = {
|
|
257
|
+
__draftRefs: draftRefs,
|
|
258
|
+
...(lastAsk ? { __ask: lastAsk } : {}),
|
|
259
|
+
...(executionContext?.workflowId ? { __workflowId: executionContext.workflowId } : {}),
|
|
260
|
+
...(executionContext?.executionId ? { __executionId: executionContext.executionId } : {}),
|
|
261
|
+
...(executionContext?.nodeId ? { __nodeId: executionContext.nodeId } : {}),
|
|
262
|
+
};
|
|
263
|
+
appInvokers.set(def.name, (input: unknown) => invoke({ ...ride, ...((input ?? {}) as Record<string, unknown>) }));
|
|
264
|
+
minted.push({ name: def.name, description: def.description, parameters: def.parameters, ...(def.outputSchema ? { outputSchema: def.outputSchema } : {}) });
|
|
232
265
|
},
|
|
233
266
|
);
|
|
234
267
|
|
|
@@ -246,9 +279,37 @@ export async function toolBridgeFor(executionContext: any): Promise<ToolBridge |
|
|
|
246
279
|
for (const mcp of harness.parseDiscoveredMCPs([{ name: toolName, resultContent }]))
|
|
247
280
|
if (!harness.isBindinglessComponentApp(mcp)) {
|
|
248
281
|
const def = harness.toolDefFromDiscoveredMCP(mcp);
|
|
249
|
-
|
|
282
|
+
// A WORKFLOW TASK (2026-09-06) is called NATIVELY: the held call runs the Agent
|
|
283
|
+
// and returns its result. Registered by the minted name, like a component app.
|
|
284
|
+
if (harness.isWorkflowTask(mcp)) appInvokers.set(def.name, harness.workflowTaskInvoker(mcp, identity));
|
|
285
|
+
else if (mcp.workflowId) {
|
|
286
|
+
// A WORKFLOW-BOUND APP (path A) OPENS, then ends the turn. It used to only end
|
|
287
|
+
// the turn: nothing registered an invoker, so the model's call fell through to
|
|
288
|
+
// `api.callService` and died as an unknown SERVICE method while the run reported
|
|
289
|
+
// success and the screen stayed blank (SAB, 2026-09-07). Both, in this order —
|
|
290
|
+
// the app tool renders the surface and fires no workflow itself (server
|
|
291
|
+
// mcp/server/tools.ts), so the handoff that follows is not a second run.
|
|
292
|
+
appInvokers.set(def.name, harness.workflowAppInvoker(mcp, identity));
|
|
293
|
+
handoffTools.add(def.name);
|
|
294
|
+
}
|
|
295
|
+
if (mcp.task) taskTools.add(def.name);
|
|
296
|
+
minted.push({ name: def.name, description: def.description, parameters: def.parameters, ...(def.outputSchema ? { outputSchema: def.outputSchema } : {}) });
|
|
250
297
|
}
|
|
251
298
|
|
|
299
|
+
/**
|
|
300
|
+
* THE FOUND ROWS' OWN FUNCTIONS (docs/unoverse/data/spatial/objects-in-spatial.md, "Found
|
|
301
|
+
* objects' functions as tools", 2026-09-16). The engine names, once per name, every public
|
|
302
|
+
* function the result's objects declare; each becomes a tool here, called on a row by its
|
|
303
|
+
* id through the engine's one function door. Spatial's node keeps only its two searches:
|
|
304
|
+
* a document's getDetail, a fund's documents, a skill's readSkill all arrive this way, the
|
|
305
|
+
* moment a row of theirs is found. Deduped downstream by tool name, like the apps above.
|
|
306
|
+
*/
|
|
307
|
+
for (const fn of harness.parseOfferedFunctions(toolName, resultContent)) {
|
|
308
|
+
const def = harness.objectFunctionToolDef(fn);
|
|
309
|
+
appInvokers.set(def.name, harness.objectFunctionInvoker(fn, engineUrl()));
|
|
310
|
+
minted.push({ name: def.name, description: def.description, parameters: def.parameters });
|
|
311
|
+
}
|
|
312
|
+
|
|
252
313
|
/**
|
|
253
314
|
* SKILLS IN FORCE, lifted before the lean drops them. Handed back beside `minted`
|
|
254
315
|
* because they are the same kind of fact: something the reply UNLOCKED that the loop
|
|
@@ -260,7 +321,11 @@ export async function toolBridgeFor(executionContext: any): Promise<ToolBridge |
|
|
|
260
321
|
},
|
|
261
322
|
|
|
262
323
|
endsTurn(calls) {
|
|
263
|
-
return harness.isTurnEndingHandoff(calls,
|
|
324
|
+
return harness.isTurnEndingHandoff(calls, handoffTools);
|
|
325
|
+
},
|
|
326
|
+
|
|
327
|
+
kindOf(toolName) {
|
|
328
|
+
return taskTools.has(toolName) ? "task" : "tool";
|
|
264
329
|
},
|
|
265
330
|
};
|
|
266
331
|
}
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* updating a node should never restart a universe. When the source becomes Postgres,
|
|
12
12
|
* an install is a row write followed by this call.
|
|
13
13
|
*
|
|
14
|
-
* See docs/architecture/DECLARATIVE_NODES.md.
|
|
14
|
+
* See docs/architecture/authoring/DECLARATIVE_NODES.md.
|
|
15
15
|
*/
|
|
16
16
|
import {
|
|
17
17
|
getNodeRegistry,
|
|
@@ -123,6 +123,19 @@ export async function signAwsRequest(
|
|
|
123
123
|
return signed.headers as Record<string, string>;
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
/**
|
|
127
|
+
* ONE LINK PER HOUR, NOT PER CALL (2026-09-12: a document's picture reopened in a panel was
|
|
128
|
+
* downloaded again on every open, because every open minted a new link and a browser caches
|
|
129
|
+
* by URL). A link of two hours or more is signed as of the start of the current hour, so every
|
|
130
|
+
* ask inside that hour gets the same URL and the browser keeps what it already loaded. Such a
|
|
131
|
+
* link lives `expiresIn` from that hour's start: up to an hour less than asked, never more. A
|
|
132
|
+
* shorter link is signed at its own second, so it is never cut by a large share of its life.
|
|
133
|
+
*/
|
|
134
|
+
const HOUR_MS = 3_600_000;
|
|
135
|
+
export function signingDateOf(expiresIn: number, now = Date.now()): Date {
|
|
136
|
+
return expiresIn * 1000 >= 2 * HOUR_MS ? new Date(Math.floor(now / HOUR_MS) * HOUR_MS) : new Date(now);
|
|
137
|
+
}
|
|
138
|
+
|
|
126
139
|
/**
|
|
127
140
|
* A PRESIGNED URL: authority to fetch one object, handed to someone who has no credentials.
|
|
128
141
|
*
|
|
@@ -174,7 +187,7 @@ export async function presignAwsUrl(
|
|
|
174
187
|
// as a dependency for one header.
|
|
175
188
|
headers: { host: parsed.host, "x-amz-content-sha256": "UNSIGNED-PAYLOAD" },
|
|
176
189
|
}),
|
|
177
|
-
{ expiresIn },
|
|
190
|
+
{ expiresIn, signingDate: signingDateOf(expiresIn) },
|
|
178
191
|
);
|
|
179
192
|
|
|
180
193
|
const out = new URL(`${signed.protocol}//${signed.hostname}${signed.path}`);
|
|
@@ -21,13 +21,14 @@
|
|
|
21
21
|
import type { ComposedNode } from "../../compose.js";
|
|
22
22
|
import type { RunContext } from "../context.js";
|
|
23
23
|
import { evaluate, render } from "../templating.js";
|
|
24
|
-
import { sendRequest, buildRequest, performPresign } from "../http/request.js";
|
|
24
|
+
import { sendRequest, buildRequest, performPresign, gated, runCalls } from "../http/request.js";
|
|
25
25
|
import { readSse, readSettled, assertOk } from "../http/response.js";
|
|
26
26
|
import { runDuplexSession } from "../duplex/session.js";
|
|
27
27
|
import type { AudioLane } from "../duplex/audioLane.js";
|
|
28
28
|
import { performState, type StateStore } from "../state.js";
|
|
29
29
|
import { fetchPaginated } from "../loops/paginate.js";
|
|
30
30
|
import { fetchPolled } from "../loops/poll.js";
|
|
31
|
+
import { runRepeat } from "../loops/repeat.js";
|
|
31
32
|
import { sendChunked } from "../loops/chunk.js";
|
|
32
33
|
import { performLoop } from "../loops/loop.js";
|
|
33
34
|
import { performDoc } from "../docstore/index.js";
|
|
@@ -82,13 +83,17 @@ export async function runFinal(
|
|
|
82
83
|
if (call.presign) return settle(await performPresign(node, call, ctx));
|
|
83
84
|
|
|
84
85
|
// Chunked: many requests over one collection, one summary reply.
|
|
85
|
-
if (call.chunk) return settle(await sendChunked(node, call, ctx, node.type));
|
|
86
|
+
if (call.chunk) return settle(await gated(node, call, ctx, () => sendChunked(node, call, ctx, node.type)));
|
|
86
87
|
|
|
87
88
|
// Paginated: many requests, one accumulated reply, so the events table sees the whole set.
|
|
88
|
-
if (call.paginate) return settle(await fetchPaginated(node, call, ctx, node.type));
|
|
89
|
+
if (call.paginate) return settle(await gated(node, call, ctx, () => fetchPaginated(node, call, ctx, node.type)));
|
|
89
90
|
|
|
90
91
|
// Polled: a job, started and waited on, settling as the final status payload.
|
|
91
|
-
if (call.poll) return settle(await fetchPolled(node, call, ctx, node.type));
|
|
92
|
+
if (call.poll) return settle(await gated(node, call, ctx, () => fetchPolled(node, call, ctx, node.type)));
|
|
93
|
+
|
|
94
|
+
// Repeated: turns of ordinary calls, each built from the one before, until a reply says done.
|
|
95
|
+
// Settles on { last, turns, stopped }, so the events table sees the answer and its path.
|
|
96
|
+
if (call.repeat) return settle(await runRepeat(node, call, ctx, node.type, runCalls, store));
|
|
92
97
|
|
|
93
98
|
// DUPLEX, and it must come BEFORE sendRequest, because a socket handshake is not an HTTP
|
|
94
99
|
// request and issuing one would open a second connection to the vendor and pay for it.
|
|
@@ -112,6 +117,9 @@ export async function runFinal(
|
|
|
112
117
|
// no tokens at all. `usage.save()` sits BEFORE the throw: a call that ended badly still
|
|
113
118
|
// burned what it spoke.
|
|
114
119
|
const usage = makeUsageCollector(node, ctx);
|
|
120
|
+
// THE CALL'S LENGTH, timed here because this is what opens and closes it. Per-minute vendors
|
|
121
|
+
// (GPT-Live, Grok) bill on it; per-token ones get it shown beside their tokens.
|
|
122
|
+
const opened = Date.now();
|
|
115
123
|
const result = await runDuplexSession({
|
|
116
124
|
node,
|
|
117
125
|
call,
|
|
@@ -129,43 +137,47 @@ export async function runFinal(
|
|
|
129
137
|
void emitter.narrator(message);
|
|
130
138
|
},
|
|
131
139
|
});
|
|
140
|
+
usage.set("seconds", Math.round((Date.now() - opened) / 100) / 10);
|
|
132
141
|
usage.save();
|
|
133
142
|
if (result.reason === "error") throw new Error(`${node.type}: duplex session failed: ${result.error}`);
|
|
134
143
|
return 200;
|
|
135
144
|
}
|
|
136
145
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const payload = await readSettled(res, transport, call.encoding);
|
|
142
|
-
await assertOk(node, call, payload, node.type);
|
|
143
|
-
// Token usage: a settled vendor body carries it inline (Chat Completions puts it
|
|
144
|
-
// top-level). One payload, so see + save in one breath.
|
|
145
|
-
const usage = makeUsageCollector(node, ctx);
|
|
146
|
-
usage.see(payload);
|
|
147
|
-
usage.save();
|
|
148
|
-
// Named BEFORE emitting, so a row reading calls.<name> sees it.
|
|
149
|
-
//
|
|
150
|
-
// Settling only. A streaming call has no single reply to name: it is a sequence, and
|
|
151
|
-
// each event arrives as `response`. Inventing a value for `calls.<name>` there would be
|
|
152
|
-
// a guess about which event counted.
|
|
153
|
-
(base.calls as Record<string, unknown>)[call.name] = payload;
|
|
154
|
-
// No `match`: a settled body is one event, so every `from: response` row fires over it.
|
|
155
|
-
await emitter.response(payload);
|
|
156
|
-
return res.status;
|
|
157
|
-
}
|
|
146
|
+
// The settling or streaming request, under the call's concurrency gate when it declares one.
|
|
147
|
+
return gated(node, call, ctx, async () => {
|
|
148
|
+
const res = await sendRequest(node, call, ctx, node.type);
|
|
149
|
+
const transport = call.transport;
|
|
158
150
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
await readSse(res, call.terminator, async (payload) => {
|
|
151
|
+
if (transport === "json" || transport === "text") {
|
|
152
|
+
const payload = await readSettled(res, transport, call.encoding);
|
|
162
153
|
await assertOk(node, call, payload, node.type);
|
|
154
|
+
// Token usage: a settled vendor body carries it inline (Chat Completions puts it
|
|
155
|
+
// top-level). One payload, so see + save in one breath.
|
|
156
|
+
const usage = makeUsageCollector(node, ctx);
|
|
163
157
|
usage.see(payload);
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
158
|
+
usage.save();
|
|
159
|
+
// Named BEFORE emitting, so a row reading calls.<name> sees it.
|
|
160
|
+
//
|
|
161
|
+
// Settling only. A streaming call has no single reply to name: it is a sequence, and
|
|
162
|
+
// each event arrives as `response`. Inventing a value for `calls.<name>` there would be
|
|
163
|
+
// a guess about which event counted.
|
|
164
|
+
(base.calls as Record<string, unknown>)[call.name] = payload;
|
|
165
|
+
// No `match`: a settled body is one event, so every `from: response` row fires over it.
|
|
166
|
+
await emitter.response(payload);
|
|
167
|
+
return res.status;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (transport === "sse") {
|
|
171
|
+
const usage = makeUsageCollector(node, ctx);
|
|
172
|
+
await readSse(res, call.terminator, async (payload) => {
|
|
173
|
+
await assertOk(node, call, payload, node.type);
|
|
174
|
+
usage.see(payload);
|
|
175
|
+
await emitter.response(payload, payload.type);
|
|
176
|
+
});
|
|
177
|
+
usage.save();
|
|
178
|
+
return res.status;
|
|
179
|
+
}
|
|
169
180
|
|
|
170
|
-
|
|
181
|
+
throw new Error(`transport "${transport}" is declared in the schema but not implemented in the executor`);
|
|
182
|
+
});
|
|
171
183
|
}
|
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
import type { ComposedNode } from "../../compose.js";
|
|
10
10
|
import type { RunContext } from "../context.js";
|
|
11
11
|
import { evaluate } from "../templating.js";
|
|
12
|
-
import { runCalls } from "../http/request.js";
|
|
12
|
+
import { runCalls, resolveBody } from "../http/request.js";
|
|
13
13
|
import type { StateStore } from "../state.js";
|
|
14
|
-
import { rowComponentsFromResults,
|
|
14
|
+
import { rowComponentsFromResults, placesOn, placesLine, rememberRows } from "../../../agent-mcp/rowComponents.js";
|
|
15
15
|
import { withHelpers } from "../helpers.js";
|
|
16
16
|
|
|
17
17
|
/**
|
|
@@ -47,6 +47,16 @@ export async function performService(
|
|
|
47
47
|
return withHelpers(node, () => serviceMethod(node, method, params, ctx, store, session));
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* A METHOD'S REPLY: one `return` expression, or an object whose values may be expressions, the
|
|
52
|
+
* way a call's `body` already is (docs/unoverse/data/spatial/objects-in-spatial.md). What forced
|
|
53
|
+
* it: `getSchema` returns a large fixed schema by `$ref`, and the Spatial node adds one value to
|
|
54
|
+
* it computed per run (the objects the agent may turn on). A literal value is returned as written.
|
|
55
|
+
*/
|
|
56
|
+
function reply(returns: unknown, scope: Record<string, unknown>): Promise<unknown> {
|
|
57
|
+
return returns && typeof returns === "object" ? resolveBody(returns, scope as unknown as RunContext) : evaluate(returns, scope);
|
|
58
|
+
}
|
|
59
|
+
|
|
50
60
|
async function serviceMethod(
|
|
51
61
|
node: ComposedNode,
|
|
52
62
|
method: string,
|
|
@@ -88,7 +98,7 @@ async function serviceMethod(
|
|
|
88
98
|
* manifest declaring this stays pure everywhere it is not wanted, with no flag to juggle.
|
|
89
99
|
*/
|
|
90
100
|
if (spec.renderComponents) {
|
|
91
|
-
const rows = await evaluate(spec.renderComponents, { response: last, calls: results, params
|
|
101
|
+
const rows = await evaluate(spec.renderComponents, { response: last, calls: results, params });
|
|
92
102
|
const found = rowComponentsFromResults(rows);
|
|
93
103
|
// SAY WHAT HAPPENED, always: this lane no-ops on no component rows or no live session,
|
|
94
104
|
// and silence must not read as success. `renderRowComponents` logs its own settles.
|
|
@@ -99,40 +109,28 @@ async function serviceMethod(
|
|
|
99
109
|
accessToken: session?.accessToken,
|
|
100
110
|
};
|
|
101
111
|
const log = (m: string) => console.log(`[manifests] ${node.type}: ${m}`);
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
* Card-only deliveries stay fire-and-forget, as fast as ever.
|
|
114
|
-
*/
|
|
115
|
-
if (found.some((i) => i.shell)) {
|
|
116
|
-
const screen = screenLine(await outcomes);
|
|
117
|
-
const result = await evaluate(spec.returns, { response: last, calls: results, params, config: ctx.config, user: ctx.user });
|
|
118
|
-
return screen && result && typeof result === "object" && !Array.isArray(result) ? { ...(result as Record<string, unknown>), screen } : result;
|
|
119
|
-
}
|
|
120
|
-
} else {
|
|
121
|
-
const n = Array.isArray(rows) ? rows.length : 0;
|
|
122
|
-
console.log(`[manifests] ${node.type}: no row components in ${n} row(s)`);
|
|
123
|
-
// The empty delivery is announced (§12e mechanical spent rule): this search
|
|
124
|
-
// ran and surfaced nothing, and the standing set's fate hangs on that fact.
|
|
125
|
-
void renderRowComponents(found, sessionCtx, log);
|
|
126
|
-
}
|
|
112
|
+
/**
|
|
113
|
+
* AN APP WITH PLACES (docs/unoverse/design/screen/UNOVERSE_SCREEN.md §3.1): a search returns
|
|
114
|
+
* data and draws nothing. Its cards are remembered for the turn and listed for the model by
|
|
115
|
+
* id; the model shows the ones its answer is about with screen_show.
|
|
116
|
+
*/
|
|
117
|
+
if (session?.conversationId && session?.userId) rememberRows(session.conversationId, session.chatId ?? session.conversationId, found);
|
|
118
|
+
else if (found.length) console.log(`[manifests] ${node.type}: ${found.length} row component(s) not remembered — no live session on this run`);
|
|
119
|
+
const result = await reply(spec.returns, { response: last, calls: results, params, user: ctx.user });
|
|
120
|
+
const task = result && typeof result === "object" && (result as any).task && typeof (result as any).task === "object" ? (result as any).task : null;
|
|
121
|
+
const screen = placesLine(session?.conversationId, placesOn(session?.conversationId), found, task);
|
|
122
|
+
return result && typeof result === "object" && !Array.isArray(result) ? { ...(result as Record<string, unknown>), screen } : result;
|
|
127
123
|
}
|
|
128
124
|
|
|
129
|
-
// `params
|
|
130
|
-
//
|
|
131
|
-
|
|
125
|
+
// `params` and `user` are in scope so a method can shape its result from what it was
|
|
126
|
+
// asked for and who asked, not only from what came back. `config` is NOT (owner ruling
|
|
127
|
+
// 2026-09-20, docs/unoverse/mail/README.md "who sees what"): a method's reply goes to a
|
|
128
|
+
// MODEL, and a box's settings are the author's, never the model's. The calls still read
|
|
129
|
+
// config to build their requests; that happens here, server-side, and never leaves.
|
|
130
|
+
return reply(spec.returns, {
|
|
132
131
|
response: last,
|
|
133
132
|
calls: results,
|
|
134
133
|
params,
|
|
135
|
-
config: ctx.config,
|
|
136
134
|
user: ctx.user,
|
|
137
135
|
});
|
|
138
136
|
}
|
|
@@ -23,7 +23,7 @@ import { runToolLoop, type ToolBridge } from "../tools/toolloop.js";
|
|
|
23
23
|
import { runFinal } from "./final.js";
|
|
24
24
|
import { withHelpers } from "../helpers.js";
|
|
25
25
|
import { clientTransport } from "../../../platform/clientTransport.js";
|
|
26
|
-
import { rowComponentsFromResults,
|
|
26
|
+
import { rowComponentsFromResults, rememberRows } from "../../../agent-mcp/rowComponents.js";
|
|
27
27
|
import type { CallerSession } from "./service.js";
|
|
28
28
|
|
|
29
29
|
export interface RunResult {
|
|
@@ -272,16 +272,11 @@ async function renderRunComponents(
|
|
|
272
272
|
accessToken: session?.accessToken,
|
|
273
273
|
};
|
|
274
274
|
const found = rowComponentsFromResults(rows);
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
log(`no row components in ${n} row(s)`);
|
|
281
|
-
// The empty delivery is announced (§12e mechanical spent rule): this search
|
|
282
|
-
// ran and surfaced nothing, and the standing set's fate hangs on that fact.
|
|
283
|
-
renderRowComponents(found, sessionCtx, log);
|
|
284
|
-
}
|
|
275
|
+
// A SEARCH RETURNS DATA AND DRAWS NOTHING (docs/unoverse/design/screen/UNOVERSE_SCREEN.md §3.1):
|
|
276
|
+
// its cards are remembered for the turn; the model shows the ones its answer is about with
|
|
277
|
+
// screen_show.
|
|
278
|
+
if (session?.conversationId && session?.userId) rememberRows(session.conversationId, session.chatId ?? session.conversationId, found);
|
|
279
|
+
log(found.length ? `${found.length} row component(s) remembered for screen_show` : `no row components in ${Array.isArray(rows) ? rows.length : 0} row(s)`);
|
|
285
280
|
}
|
|
286
281
|
|
|
287
282
|
/**
|
|
@@ -29,6 +29,12 @@ export interface RunContext {
|
|
|
29
29
|
* phrase for two different things.
|
|
30
30
|
*/
|
|
31
31
|
calls: Record<string, any>;
|
|
32
|
+
/**
|
|
33
|
+
* THE TURN BEFORE THIS ONE, inside a `repeat` (loops/repeat.ts): that turn's calls by name.
|
|
34
|
+
* Null on a repeat's first turn, so an expression can ask whether there is one; absent
|
|
35
|
+
* everywhere outside a repeat.
|
|
36
|
+
*/
|
|
37
|
+
previous?: Record<string, any> | null;
|
|
32
38
|
/**
|
|
33
39
|
* The CALLER'S DISCONNECT, when the node runs over a stream. Set from the request's
|
|
34
40
|
* own signal; a turn loop checks it between turns and stops. Absent for a node that
|
|
@@ -85,7 +91,7 @@ export interface RunContext {
|
|
|
85
91
|
* its own address. Loopback, because :4106 is the ungated internal listener and is published to
|
|
86
92
|
* 127.0.0.1 only.
|
|
87
93
|
*/
|
|
88
|
-
scope: { userId?: string; workflowId?: string; conversationId?: string; chatId?: string; executionId?: string; nodeId?: string; platformUrl?: string; engineUrl?: string };
|
|
94
|
+
scope: { userId?: string; workflowId?: string; org?: string; conversationId?: string; chatId?: string; debug?: boolean; executionId?: string; nodeId?: string; platformUrl?: string; engineUrl?: string };
|
|
89
95
|
/**
|
|
90
96
|
* THE CONVERSATION SO FAR, for a tool loop against a vendor with no chain id.
|
|
91
97
|
*
|
|
@@ -132,13 +138,19 @@ export const RESOLVERS: Record<string, (value: any, node: ComposedNode) => any>
|
|
|
132
138
|
modelTier(value: any, node: ComposedNode) {
|
|
133
139
|
const field = node.definition.configSchema?.properties?.model;
|
|
134
140
|
const options: string[] = field?.enum ?? [];
|
|
135
|
-
|
|
141
|
+
// THE LARGEST MODEL IS NEVER TAKEN BY ACCIDENT (owner, 2026-09-12: "we should never have
|
|
142
|
+
// been using Sol in the first place"; a pipeline that named no model ran every call on the
|
|
143
|
+
// frontier tier, first in the list, for days). A node that names no model runs on its own
|
|
144
|
+
// declared default; the large tier only ever runs because someone chose it.
|
|
145
|
+
const fallback: string | undefined = typeof field?.default === "string" && options.includes(field.default) ? field.default : options[1] ?? options[0];
|
|
146
|
+
if (!value) return fallback;
|
|
147
|
+
if (options.includes(value)) return value;
|
|
136
148
|
const m = String(value).toLowerCase();
|
|
137
|
-
// Tier words cover both families the packages ship: GPT (luna/terra) and Claude
|
|
138
|
-
// (haiku/sonnet).
|
|
139
|
-
const tier = /luna|mini|small|nano|haiku/.test(m) ? 0 : /terra|balanced|medium|sonnet/.test(m) ? 1 : 2;
|
|
149
|
+
// Tier words cover both families the packages ship: GPT (luna/terra/sol) and Claude
|
|
150
|
+
// (haiku/sonnet/opus). A retired id keeps its tier; one naming no tier takes the default.
|
|
151
|
+
const tier = /luna|mini|small|nano|haiku/.test(m) ? 0 : /terra|balanced|medium|sonnet/.test(m) ? 1 : /sol|large|frontier|opus/.test(m) ? 2 : -1;
|
|
140
152
|
// Options are listed large first, so index from the end for small.
|
|
141
|
-
const picked = [options[options.length - 1], options[1], options[0]][tier] ??
|
|
153
|
+
const picked = tier < 0 ? fallback : ([options[options.length - 1], options[1], options[0]][tier] ?? fallback);
|
|
142
154
|
console.warn(`[manifests] model "${value}" is no longer available — using "${picked}". Update this node.`);
|
|
143
155
|
return picked;
|
|
144
156
|
},
|