@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
package/screens/index.ts CHANGED
@@ -25,7 +25,10 @@ export { TemplatesLibraryView } from "./TemplatesLibraryView";
25
25
  export { DesignSystemView } from "./DesignSystemView";
26
26
  export { SkillsView } from "./SkillsView";
27
27
  // Agents: the one file authored for the map (docs.unoverse.ai/design/tasks), one lane in both Studios.
28
- export { AgentsView } from "./AgentsView";
28
+ export { AgentsView, agentForCanvas } from "./AgentsView";
29
+ // An Agent drawn off its agent.yaml: Studio's lane, and Signal's Agent page beside its runs.
30
+ export { AgentChain } from "./AgentChain";
31
+ export type { AgentFolderItem } from "./AgentChain";
29
32
  export { IdentityView } from "./IdentityView";
30
33
  export { PromptBlocksView } from "./PromptBlocksView";
31
34
  export { IdentityHeader } from "./IdentityHeader";
package/src/host.ts CHANGED
@@ -110,6 +110,11 @@ class LocalUnoverseClient {
110
110
  );
111
111
  }
112
112
 
113
+ /** A component's task runs in a universe. A folder runs none, so it fills nothing. */
114
+ async runTask(): Promise<Record<string, unknown>> {
115
+ return {};
116
+ }
117
+
113
118
  /**
114
119
  * Read a definition, and STAMP EVERY NODE WITH ITS PATH.
115
120
  *
package/src/index.css CHANGED
@@ -5,3 +5,14 @@ body,
5
5
  #root {
6
6
  height: 100%;
7
7
  }
8
+
9
+ /* PRINT: only the template stage prints, one 1280x720 page per slide; Studio's chrome stays out.
10
+ The page is the stage's own size, so a stage drawn at Desktop width (1280) prints at scale 1. */
11
+ @media print {
12
+ @page { size: 1280px 720px; margin: 0; }
13
+ html, body, #root, #root * { height: auto !important; min-height: 0 !important; overflow: visible !important; }
14
+ body *:not([data-print-stage]):not([data-print-stage] *):not(:has([data-print-stage])) { display: none !important; }
15
+ :has(> [data-print-stage]), :has([data-print-stage]) { display: block !important; padding: 0 !important; margin: 0 !important; border: 0 !important; }
16
+ [data-print-stage] { padding: 0 !important; }
17
+ [data-print-stage] > div { width: 1280px !important; max-width: none !important; margin: 0 !important; }
18
+ }
@@ -5,6 +5,7 @@
5
5
  * kinds into one shape, so a model gets one tool per row regardless of which kind it was.
6
6
  * Also the etiquette an interactive app carries, and the anchoring a follow-up search needs.
7
7
  */
8
+ import { taskUri } from "../definitions/registry/taskUri.js";
8
9
  import type { AgentToolExchange, DiscoveredMCP, AppInvocationContext, OfferedFunction } from "./types.js";
9
10
 
10
11
  /** The discovery tools whose results can carry `object_type: "mcp"` rows. */
@@ -71,7 +72,7 @@ export function parseDiscoveredMCPs(calls: AgentToolExchange[]): DiscoveredMCP[]
71
72
  // A TASK IS A TOOL, DESCRIBED LIKE ONE (owner ruling 2026-09-08). This used to close
72
73
  // with TASK_ETIQUETTE — "calling this runs an Agent and holds until it completes while
73
74
  // the person watches its progress on screen; say in one short sentence what you are
74
- // doing" — and the only thing it told the model to DO was speak. Measured on SAB
75
+ // doing" — and the only thing it told the model to DO was speak. Measured on a bank
75
76
  // (2026-09-08): across four runs of one question the model called once and, three
76
77
  // times, emitted exactly one sentence of intent and ended the turn, with no
77
78
  // function-call events in the stream at all.
@@ -89,7 +90,9 @@ export function parseDiscoveredMCPs(calls: AgentToolExchange[]): DiscoveredMCP[]
89
90
  workflowId: item.metadata.workflow || item.workflow_id,
90
91
  nodeId: item.metadata.trigger,
91
92
  methodName,
92
- taskTool: String(methodName),
93
+ // The task's one URI when the map row holds it (docs/mcp/MCP_TASKS.md § 2a), else its name;
94
+ // the wire call names the tool from either by the one rule (invoke.ts).
95
+ taskTool: String(taskUri(item.metadata?.task) ?? methodName),
93
96
  schema: { methods: { [methodName]: { description: taskDescription, input } } },
94
97
  });
95
98
  } else if (item.metadata?.schema?.methods) {
@@ -22,6 +22,7 @@
22
22
  * lean projecting a result down to what the model needs
23
23
  * tools minting a model tool from a discovered app
24
24
  * invoke THE ONE WIRE CALL, where our code ends and native MCP begins
25
+ * progressive many tools held behind search_tools and call_tool (MCP Client Best Practices)
25
26
  * memory* conversation ingest and user-memory hydration
26
27
  */
27
28
 
@@ -38,7 +39,8 @@ export { parseToolResult, isTurnEndingHandoff } from "./handoff.js";
38
39
  export { rowComponentsFromResults, renderRowComponents, rankedRowIds, componentTypeFromAppUri, onScreenRead, onScreenPlaces, placesOn, placesLine, rememberRows, rowsById, cardIdOf, screenOf, openedOn, type ScreenItem, openTasksOn, onScreenLine, ABOUT_THE_PAGE, ABOUT_THE_OPENED } from "./rowComponents.js";
39
40
  export { leanToolResultForModel } from "./lean.js";
40
41
  export { toolDefFromDiscoveredMCP, resolveToolDefNative, isBindinglessComponentApp, componentAppInvoker, isWorkflowTask, workflowTaskInvoker, workflowAppInvoker, modelToolName, objectFunctionToolDef, objectFunctionInvoker } from "./tools.js";
41
- export { invokeTaskNative, connectPlatformMcp, TRACE_META } from "./invoke.js";
42
+ export { invokeTaskNative, connectPlatformMcp, TRACE_META, readTask, answerTask } from "./invoke.js";
43
+ export { shouldDefer, metaTools, searchAnswer, searchTools, estimateTokens, DEFER_ABOVE_TOKENS, SEARCH_TOOLS, CALL_TOOL, type HeldTool } from "./progressive.js";
42
44
 
43
45
  export { ingestConversationTurn, type ConversationTurn } from "./memoryIngest.js";
44
46
  export { fetchUserMemoryContext, hydratePromptWithUserMemory } from "./memoryContext.js";
@@ -11,6 +11,7 @@
11
11
  * Node-MCP rows still route in-process via the caller's `api.callService`: they have a
12
12
  * node to run. Renamed from `invokeComponentAppNative` when workflow tasks joined it.
13
13
  */
14
+ import { taskToolName } from "../definitions/registry/taskUri.js";
14
15
  import type { AppInvocationContext, DiscoveredMCP } from "./types.js";
15
16
 
16
17
  /**
@@ -73,35 +74,174 @@ export async function connectPlatformMcp(
73
74
  };
74
75
  }
75
76
 
77
+ /** The tasks extension's id (`io.modelcontextprotocol/tasks`), as the official library names it. */
78
+ const TASKS_EXTENSION = "io.modelcontextprotocol/tasks";
79
+ const MODERN = "2026-07-28";
80
+
81
+ type CallResult = {
82
+ isError?: boolean;
83
+ content?: Array<{ type: string; text?: string }>;
84
+ structuredContent?: Record<string, any>;
85
+ };
86
+
87
+ /** A 2026-07-28 client on the platform's /mcp that declared the tasks extension, and the official
88
+ * task session over it: what every native task call below is made on. Close the client after. */
89
+ async function openTaskSession(ctx: Pick<AppInvocationContext, "baseUrl" | "accessToken">) {
90
+ const { Client, StreamableHTTPClientTransport } = await import("@modelcontextprotocol/client");
91
+ const { createTaskSessionFromClient } = await import("@modelcontextprotocol/ext-tasks/client");
92
+ const base = (ctx.baseUrl || process.env.UNOVERSE_SERVICE_URL || "http://localhost:4106").replace(/\/$/, "");
93
+ const auth: Record<string, string> = ctx.accessToken ? { Authorization: `Bearer ${ctx.accessToken}` } : {};
94
+ const clientInfo = { name: "unoverse-agent-mcp", version: "1.0.0" };
95
+ const clientCapabilities = { extensions: { [TASKS_EXTENSION]: {} } };
96
+ const client = new Client(clientInfo, { versionNegotiation: { mode: { pin: MODERN } }, capabilities: clientCapabilities } as never);
97
+ await client.connect(
98
+ new StreamableHTTPClientTransport(new URL(`${base}/mcp`), {
99
+ fetch: (url: string | URL, init?: RequestInit) => fetch(url, { ...init, headers: { ...Object.fromEntries(new Headers(init?.headers)), ...auth } }),
100
+ }),
101
+ );
102
+ let next = 0;
103
+ const rawDispatch = async (request: { method: string; params?: Record<string, unknown> }) => {
104
+ const id = `task-${++next}`;
105
+ const name = request.method === "tools/call" ? request.params?.name : request.method.startsWith("tasks/") ? request.params?.taskId : undefined;
106
+ const res = await fetch(`${base}/mcp`, {
107
+ method: "POST",
108
+ headers: {
109
+ "content-type": "application/json",
110
+ accept: "application/json, text/event-stream",
111
+ "mcp-protocol-version": MODERN,
112
+ "mcp-method": request.method,
113
+ ...(typeof name === "string" ? { "mcp-name": name } : {}),
114
+ ...auth,
115
+ },
116
+ body: JSON.stringify({ jsonrpc: "2.0", id, ...request }),
117
+ });
118
+ const text = await res.text();
119
+ // A JSON answer, or a stream whose last message for this id is the answer.
120
+ if (!text.includes("data: ")) return JSON.parse(text);
121
+ const answers = text.split("\n").filter((l) => l.startsWith("data: ")).map((l) => JSON.parse(l.slice(6)));
122
+ return answers.filter((m) => m.id === id).pop() ?? answers.pop();
123
+ };
124
+ const session = createTaskSessionFromClient(client as never, {
125
+ endpointId: `${base}/mcp`,
126
+ rawDispatch: rawDispatch as never,
127
+ v2RequestFraming: { protocolVersion: MODERN, clientInfo, clientCapabilities },
128
+ } as never);
129
+ return { client, session };
130
+ }
131
+
132
+ /**
133
+ * ONE CALL, FOLLOWED AS AN MCP TASK WHEN IT IS ONE (docs/mcp/MCP_TASKS.md steps 6 and 7). Every
134
+ * Task is an MCP task: a workflow, a form, an app that asks. The harness calls as a 2026-07-28
135
+ * client that declared the tasks extension, so a Task answers at once with its task and the
136
+ * official client (`@modelcontextprotocol/ext-tasks/client`) follows it to its result; a call
137
+ * with nothing to wait for answers at once, through the same client. Several calls in one turn
138
+ * each follow their own task, side by side.
139
+ *
140
+ * The one piece of host code the library asks for is `rawDispatch`: it sends one JSON-RPC request
141
+ * on the 2026 framing and hands back the response. The host owns request ids, so it adds them.
142
+ */
143
+ async function callFollowingTasks(toolId: string, args: Record<string, unknown>, ctx: AppInvocationContext): Promise<CallResult> {
144
+ const { resultFromTaskOutcome } = await import("@modelcontextprotocol/ext-tasks/client");
145
+ const { client, session } = await openTaskSession(ctx);
146
+ try {
147
+ const execution = await session.callTool(
148
+ // THE TOOL NAME FROM THE TASK'S ONE URI (docs/mcp/MCP_TASKS.md § 2a): a task URI or its short
149
+ // form `<org>/<name>` is called as `<org>.<name>`; anything else (a bare app id) as given.
150
+ taskToolName(toolId) ?? toolId,
151
+ args as never,
152
+ {
153
+ ...(ctx.trace?.executionId && ctx.trace?.nodeId ? { metadata: { [TRACE_META]: ctx.trace as never } } : {}),
154
+ // A person completing a form, or an Agent working: either may take many minutes; the run's own cap ends it first.
155
+ requestTimeoutMs: 24 * 60 * 60 * 1000,
156
+ },
157
+ );
158
+ if (execution.kind === "task") console.log(`[unoverse:task] FOLLOW ${toolId}: followed as an MCP task to its result`);
159
+ // THE CALLING RUN STOPPED: the task it follows is cancelled (native `tasks/cancel`), so it does
160
+ // not run on for nobody. The outcome then reads cancelled, and the call fails with it.
161
+ const stop = () => {
162
+ if (execution.kind !== "task") return;
163
+ console.log(`[unoverse:task] CANCEL ${toolId}: its caller's run stopped`);
164
+ void execution.cancel().catch(() => {});
165
+ };
166
+ if (ctx.signal?.aborted) stop();
167
+ else ctx.signal?.addEventListener("abort", stop, { once: true });
168
+ try {
169
+ // WHAT THE TASK IS DOING, AS IT CHANGES (docs/mcp/MCP_TASKS.md § 5). The extension's own
170
+ // settle hands every task update to `onEvent`; each new statusMessage goes to the caller,
171
+ // once per change, and a caller that did not ask for them is never told.
172
+ let lastStatus = "";
173
+ const onEvent = ctx.onStatus
174
+ ? (event: { type: string; task?: { statusMessage?: string } }) => {
175
+ const message = event.type === "task" ? String(event.task?.statusMessage ?? "").trim() : "";
176
+ if (!message || message === lastStatus) return;
177
+ lastStatus = message;
178
+ try {
179
+ ctx.onStatus!(message);
180
+ } catch {
181
+ /* a listener's failure never fails the task it watches */
182
+ }
183
+ }
184
+ : undefined;
185
+ const { outcome } = await execution.settle(onEvent ? ({ onEvent } as never) : undefined);
186
+ return resultFromTaskOutcome(outcome) as CallResult;
187
+ } finally {
188
+ ctx.signal?.removeEventListener("abort", stop);
189
+ }
190
+ } finally {
191
+ await client.close().catch(() => {});
192
+ }
193
+ }
194
+
195
+ /** A workflow Task: its result comes back as `output.result`, the run's result as text. */
196
+ export async function invokeWorkflowTask(
197
+ toolId: string,
198
+ ctx: AppInvocationContext,
199
+ ): Promise<{ status: "completed"; output?: Record<string, unknown>; conversationId?: string; message?: string }> {
200
+ const result = await callFollowingTasks(
201
+ toolId,
202
+ {
203
+ message: ctx.message ?? "",
204
+ ...(ctx.conversationId ? { conversationId: ctx.conversationId } : {}),
205
+ ...(ctx.userId ? { userId: ctx.userId } : {}),
206
+ ...(ctx.chatId ? { chatId: ctx.chatId } : {}),
207
+ ...((ctx.args ?? {}) as Record<string, never>),
208
+ },
209
+ ctx,
210
+ );
211
+ const text = result.content?.find((c) => c.type === "text")?.text ?? "";
212
+ if (result.isError) throw new Error(text || `${toolId} did not complete`);
213
+ return {
214
+ status: "completed",
215
+ output: { result: text },
216
+ conversationId: ctx.conversationId,
217
+ message:
218
+ "The Agent completed. `output.result` is what it found, already shown to the person on screen. " +
219
+ "Continue the task with it: answer from it, and do not repeat it verbatim unless asked.",
220
+ };
221
+ }
222
+
76
223
  export async function invokeTaskNative(
77
224
  toolId: string,
78
225
  ctx: AppInvocationContext,
79
226
  ): Promise<{ status: "completed"; output?: Record<string, unknown>; conversationId?: string; action?: string; message?: string }> {
80
- const { client, close } = await connectPlatformMcp(ctx);
81
- try {
82
- const result = (await client.callTool(
227
+ {
228
+ const result = (await callFollowingTasks(
229
+ toolId,
83
230
  {
84
- // Org-qualified refs are served as dotted aliases (no "/" in MCP tool names).
85
- name: toolId.replace(/\//g, "."),
86
- arguments: {
87
- message: ctx.message ?? "",
88
- ...(ctx.conversationId ? { conversationId: ctx.conversationId } : {}),
89
- ...(ctx.userId ? { userId: ctx.userId } : {}),
90
- ...(ctx.chatId ? { chatId: ctx.chatId } : {}),
91
- ...(ctx.props ? { props: ctx.props } : {}),
92
- ...(ctx.instanceId ? { instanceId: ctx.instanceId } : {}),
93
- ...(ctx.fromRow ? { fromRow: true } : {}),
94
- ...(ctx.shell ? { shell: true } : {}),
95
- ...(typeof ctx.rank === "number" ? { rank: ctx.rank } : {}),
96
- ...(ctx.batch ? { batch: ctx.batch } : {}),
97
- // A workflow task's own arguments, as the model wrote them against the task's schema.
98
- ...(ctx.args ?? {}),
99
- },
100
- ...(ctx.trace?.executionId && ctx.trace?.nodeId ? { _meta: { [TRACE_META]: ctx.trace } } : {}),
231
+ message: ctx.message ?? "",
232
+ ...(ctx.conversationId ? { conversationId: ctx.conversationId } : {}),
233
+ ...(ctx.userId ? { userId: ctx.userId } : {}),
234
+ ...(ctx.chatId ? { chatId: ctx.chatId } : {}),
235
+ ...(ctx.props ? { props: ctx.props } : {}),
236
+ ...(ctx.instanceId ? { instanceId: ctx.instanceId } : {}),
237
+ ...(ctx.fromRow ? { fromRow: true } : {}),
238
+ ...(ctx.shell ? { shell: true } : {}),
239
+ ...(typeof ctx.rank === "number" ? { rank: ctx.rank } : {}),
240
+ ...(ctx.batch ? { batch: ctx.batch } : {}),
241
+ // A workflow task's own arguments, as the model wrote them against the task's schema.
242
+ ...(ctx.args ?? {}),
101
243
  },
102
- undefined,
103
- // A human is completing the wizard, or an Agent is working — lift the SDK's 60s default to 24h.
104
- { timeout: 24 * 60 * 60 * 1000 },
244
+ ctx,
105
245
  )) as {
106
246
  isError?: boolean;
107
247
  content?: Array<{ type: string; text?: string }>;
@@ -125,6 +265,9 @@ export async function invokeTaskNative(
125
265
  return {
126
266
  status: "completed",
127
267
  output: { result: result.structuredContent.result },
268
+ // THE TASK'S OWN ID (owner 2026-10-24: "save the task, show it in the timeline, click it
269
+ // to see it"): the run that did this, so the caller can record it where the work belongs.
270
+ ...(result.structuredContent?.executionId ? { run: result.structuredContent.executionId } : {}),
128
271
  conversationId: result.structuredContent?.conversationId,
129
272
  message:
130
273
  "The Agent completed. `output.result` is what it found, already shown to the person on screen. " +
@@ -167,9 +310,40 @@ export async function invokeTaskNative(
167
310
  "The component is now displayed on the user's screen. Continue your answer for the user now — the render is a visual aid, not your reply.",
168
311
  }),
169
312
  };
170
- } finally {
171
- await close();
172
313
  }
173
314
  }
174
315
 
175
316
  // Memory ingest — every agent family saves completed turns the same way.
317
+
318
+ /**
319
+ * A TASK THAT ALREADY EXISTS, by its id (docs/mcp/MCP_TASKS.md § 5c): the official client's
320
+ * `session.task(id)`. Read gives where it is and, once it has finished, what it produced; answer
321
+ * replies to what a waiting task asks (`tasks/update`). Nothing else: MCP offers nothing else.
322
+ */
323
+ export async function readTask(taskId: string, ctx: Pick<AppInvocationContext, "baseUrl" | "accessToken">): Promise<{ taskId: string; status: string; statusMessage?: string; asks?: unknown; result?: string }> {
324
+ const { client, session } = await openTaskSession(ctx);
325
+ try {
326
+ const task = (session as any).task(taskId);
327
+ const view = await task.snapshot();
328
+ const raw = (view.raw ?? {}) as Record<string, any>;
329
+ const done = (raw.result?.content ?? []).find((c: any) => c?.type === "text")?.text;
330
+ return {
331
+ taskId: view.taskId,
332
+ status: view.status,
333
+ ...(view.statusMessage ? { statusMessage: view.statusMessage } : {}),
334
+ ...(raw.inputRequests && Object.keys(raw.inputRequests).length ? { asks: raw.inputRequests } : {}),
335
+ ...(typeof done === "string" && done ? { result: done } : {}),
336
+ };
337
+ } finally {
338
+ await client.close().catch(() => {});
339
+ }
340
+ }
341
+
342
+ export async function answerTask(taskId: string, inputResponses: Record<string, unknown>, ctx: Pick<AppInvocationContext, "baseUrl" | "accessToken">): Promise<void> {
343
+ const { client, session } = await openTaskSession(ctx);
344
+ try {
345
+ await (session as any).task(taskId).updateJson(inputResponses);
346
+ } finally {
347
+ await client.close().catch(() => {});
348
+ }
349
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * PROGRESSIVE TOOL DISCOVERY (MCP 2026-07-28, Client Best Practices; docs/mcp/MCP_2026-07-28.md
3
+ * § 4, 3e; packages/docs/nodes/mcp-services.md, "Many tools").
4
+ *
5
+ * A host with many tools does not hand the model every definition. It keeps them, gives the
6
+ * model a `search_tools` meta-tool, and loads a definition only when the job needs it. Our
7
+ * harness is the host for every Agent, so this is decided here, once, for every model.
8
+ *
9
+ * THE TOOL LIST NEVER CHANGES DURING A RUN. A found tool is not added to the list: it is called
10
+ * through `call_tool`, MCP's "single stable call_tool({name, args}) meta-tool so the array never
11
+ * changes". Adding it would break the model's prompt cache at that turn.
12
+ *
13
+ * Pure: the tools come in, the offer and the answers go out. The bridge does the calling.
14
+ */
15
+
16
+ /** A tool as the bridge holds it: what the model would be offered, plus the server it is from. */
17
+ export interface HeldTool {
18
+ name: string;
19
+ description: string;
20
+ parameters: unknown;
21
+ server?: string;
22
+ }
23
+
24
+ /**
25
+ * THE THRESHOLD, in estimated tokens of definitions. MCP: switch "once the tool definitions take
26
+ * up a significant part of the available context window", for example 1% to 5%. 2,000 is 1% of a
27
+ * 200k window and under 2% of a 128k one. Below it, every tool is offered as before.
28
+ */
29
+ export const DEFER_ABOVE_TOKENS = 2000;
30
+
31
+ /** How many tools one search hands back: a few that fit, never the catalogue. */
32
+ const MATCHES = 5;
33
+
34
+ export const SEARCH_TOOLS = "search_tools";
35
+ export const CALL_TOOL = "call_tool";
36
+
37
+ /** About four characters a token: the usual estimate, and close enough to pick a side of 2,000. */
38
+ export function estimateTokens(tools: HeldTool[]): number {
39
+ return Math.ceil(JSON.stringify(tools.map(({ name, description, parameters }) => ({ name, description, parameters }))).length / 4);
40
+ }
41
+
42
+ /** Should these tools be held back behind search? */
43
+ export function shouldDefer(tools: HeldTool[]): boolean {
44
+ return estimateTokens(tools) > DEFER_ABOVE_TOKENS;
45
+ }
46
+
47
+ /** A tool in one line: its name and the first sentence of what it does, never its schema. */
48
+ function oneLine(t: HeldTool): string {
49
+ const first = String(t.description ?? "").split(/(?<=[.!?:])\s/)[0].trim();
50
+ return `- ${t.name}: ${first.length > 140 ? `${first.slice(0, 137)}...` : first}`;
51
+ }
52
+
53
+ /**
54
+ * THE CATALOGUE: every held tool in one line, grouped by server (MCP: "names and one-line
55
+ * descriptions", "group tools by server"). The model sees what it has and loads a definition only
56
+ * when it needs it; without the lines an Agent never knew a tool existed, so it never searched and
57
+ * named tools it did not have (Bob, 2026-10-11). Stable for the run, so the prompt cache holds.
58
+ */
59
+ export function catalogue(held: HeldTool[]): string {
60
+ const groups = new Map<string, HeldTool[]>();
61
+ for (const t of held) {
62
+ const key = t.server ?? "";
63
+ groups.set(key, [...(groups.get(key) ?? []), t]);
64
+ }
65
+ return [...groups.entries()].map(([server, tools]) => [server ? `${server}:` : "", ...tools.map(oneLine)].filter(Boolean).join("\n")).join("\n");
66
+ }
67
+
68
+ /** The two meta-tools, offered in place of the held ones. The search carries the catalogue. */
69
+ export function metaTools(held: HeldTool[]): HeldTool[] {
70
+ return [
71
+ {
72
+ name: SEARCH_TOOLS,
73
+ description:
74
+ `Load the full definition (input schema) of the tools you need before you call them. Describe the job in plain words, or give a tool's name; the answer is the tools that fit, each with its server and full input schema. Call it before ${CALL_TOOL} for any tool whose schema you do not yet hold. The tools you have:\n${catalogue(held)}`,
75
+ parameters: {
76
+ type: "object",
77
+ properties: { query: { type: "string", description: "What you need to do, e.g. 'save a person's email address'." } },
78
+ required: ["query"],
79
+ },
80
+ },
81
+ {
82
+ name: CALL_TOOL,
83
+ description: `Call one tool that ${SEARCH_TOOLS} found, by its exact name, with arguments matching its input schema.`,
84
+ parameters: {
85
+ type: "object",
86
+ properties: {
87
+ name: { type: "string", description: `The tool's exact name, as ${SEARCH_TOOLS} gave it.` },
88
+ arguments: { type: "object", description: "The tool's arguments, matching its input schema." },
89
+ },
90
+ required: ["name"],
91
+ },
92
+ },
93
+ ];
94
+ }
95
+
96
+ const words = (s: string): string[] => s.toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 1);
97
+
98
+ /**
99
+ * KEYWORD SEARCH, the first of MCP's strategies ("simple and effective, particularly for descriptive
100
+ * tool names and descriptions"). A word of the query in the tool's name counts most, then in its
101
+ * server, then in its description. A name's own parts count: `send_message` is "send" and "message".
102
+ */
103
+ export function searchTools(held: HeldTool[], query: string): Array<{ name: string; server?: string; description: string; inputSchema: unknown }> {
104
+ const asked = [...new Set(words(query))];
105
+ const scored = held
106
+ .map((t) => {
107
+ const name = new Set(words(t.name.replace(/([a-z0-9])([A-Z])/g, "$1 $2")));
108
+ const server = new Set(words((t.server ?? "").replace(/([a-z0-9])([A-Z])/g, "$1 $2")));
109
+ const text = new Set(words(t.description));
110
+ const score = asked.reduce((s, w) => s + (name.has(w) ? 3 : 0) + (server.has(w) ? 2 : 0) + (text.has(w) ? 1 : 0), 0);
111
+ return { t, score };
112
+ })
113
+ .filter((x) => x.score > 0)
114
+ .sort((a, b) => b.score - a.score);
115
+ return scored.slice(0, MATCHES).map(({ t }) => ({ name: t.name, ...(t.server ? { server: t.server } : {}), description: t.description, inputSchema: t.parameters }));
116
+ }
117
+
118
+ /** The answer to a search the model reads: the matches, or what to do when there are none. */
119
+ export function searchAnswer(held: HeldTool[], query: string): string {
120
+ const found = searchTools(held, String(query ?? ""));
121
+ if (found.length) return JSON.stringify({ tools: found });
122
+ const servers = [...new Set(held.map((t) => t.server).filter(Boolean))];
123
+ return JSON.stringify({ tools: [], note: `No tool matched "${query}". Describe the job in other words${servers.length ? `, or name a server: ${servers.join(", ")}` : ""}.` });
124
+ }
@@ -5,7 +5,7 @@
5
5
  * the row at all, so its input schema is read LIVE from the app's own resource at mint time.
6
6
  */
7
7
  import type { AgentToolDef, DiscoveredMCP, AppInvocationContext, OfferedFunction } from "./types.js";
8
- import { connectPlatformMcp, invokeTaskNative } from "./invoke.js";
8
+ import { connectPlatformMcp, invokeTaskNative, invokeWorkflowTask } from "./invoke.js";
9
9
  import { APP_ETIQUETTE } from "./discovery.js";
10
10
 
11
11
  /**
@@ -109,30 +109,35 @@ export function isBindinglessComponentApp(mcp: DiscoveredMCP): boolean {
109
109
  return !!mcp.component && !mcp.workflowId;
110
110
  }
111
111
 
112
- /** A workflow task (2026-09-06): a task-augmented native tool, held until the Agent's run completes. */
112
+ /** A workflow task (2026-09-06): a native tool the harness follows as an MCP task to its result (step 6). */
113
113
  export function isWorkflowTask(mcp: DiscoveredMCP): boolean {
114
114
  return !!mcp.taskTool;
115
115
  }
116
116
 
117
+ /** What a caller may hear while a task it called runs: its status, each time it changes. */
118
+ export type TaskCallHooks = Pick<AppInvocationContext, "onStatus">;
119
+
117
120
  /**
118
121
  * THE ONE workflow-task adapter every agent family wires for a discovered workflow task.
119
122
  * The model's arguments go to the tool as they are (the task's own input schema shaped
120
123
  * them); the identity rides beside them so the server pushes the face into THIS
121
- * conversation and runs the Agent as THIS caller. The held call resolves with the run's
122
- * result in `output.result`.
124
+ * conversation and runs the Agent as THIS caller. The task is followed to its result
125
+ * (docs/mcp/MCP_TASKS.md step 6), which comes back in `output.result`.
123
126
  */
124
127
  export function workflowTaskInvoker(
125
128
  mcp: DiscoveredMCP,
126
- identity: Pick<AppInvocationContext, "conversationId" | "userId" | "chatId" | "accessToken" | "baseUrl" | "trace">,
127
- ): (input: unknown) => ReturnType<typeof invokeTaskNative> {
129
+ identity: Pick<AppInvocationContext, "conversationId" | "userId" | "chatId" | "accessToken" | "baseUrl" | "trace" | "signal">,
130
+ ): (input: unknown, hooks?: TaskCallHooks) => ReturnType<typeof invokeWorkflowTask> {
128
131
  const toolId = mcp.taskTool as string;
129
- return (input: unknown) => {
132
+ return (input: unknown, hooks?: TaskCallHooks) => {
130
133
  const { message, ...rest } = (input ?? {}) as Record<string, unknown>;
131
- return invokeTaskNative(toolId, {
134
+ return invokeWorkflowTask(toolId, {
135
+ ...(hooks?.onStatus ? { onStatus: hooks.onStatus } : {}),
132
136
  conversationId: identity.conversationId,
133
137
  userId: identity.userId,
134
138
  accessToken: identity.accessToken,
135
139
  chatId: identity.chatId,
140
+ ...(identity.signal ? { signal: identity.signal } : {}),
136
141
  ...(identity.trace ? { trace: { ...identity.trace, kind: "task" } } : {}),
137
142
  ...(identity.baseUrl ? { baseUrl: identity.baseUrl } : {}),
138
143
  message: typeof message === "string" ? message : "",
@@ -157,7 +162,7 @@ export function workflowTaskInvoker(
157
162
  */
158
163
  export function componentAppInvoker(
159
164
  mcp: DiscoveredMCP,
160
- identity: Pick<AppInvocationContext, "conversationId" | "userId" | "chatId" | "accessToken" | "trace">,
165
+ identity: Pick<AppInvocationContext, "conversationId" | "userId" | "chatId" | "accessToken" | "trace" | "signal">,
161
166
  ): (input: unknown) => ReturnType<typeof invokeTaskNative> {
162
167
  const toolId = mcp.component as string;
163
168
  return (input: unknown) => {
@@ -167,6 +172,7 @@ export function componentAppInvoker(
167
172
  userId: identity.userId,
168
173
  accessToken: identity.accessToken,
169
174
  chatId: identity.chatId,
175
+ ...(identity.signal ? { signal: identity.signal } : {}),
170
176
  ...(identity.trace ? { trace: { ...identity.trace, kind: mcp.task ? "task" : "tool" } } : {}),
171
177
  message: typeof message === "string" ? message : "",
172
178
  ...(Object.keys(hydrated).length ? { props: hydrated } : {}),
@@ -186,13 +192,13 @@ export function componentAppInvoker(
186
192
  * `handoffTools` and nothing else, so `bridge.call` found no entry in `appInvokers` and fell
187
193
  * through to `api.callService`, which addresses SERVICE methods: the platform answered
188
194
  * "SpatialSearch has no service method \"sab-voice\"" and the call failed in 10ms while the
189
- * run still reported success. Observed live on SAB (2026-09-07): the app was found, minted
195
+ * run still reported success. Observed live on a bank (2026-09-07): the app was found, minted
190
196
  * and CALLED, and the person watched a blank panel. Every workflow-bound app shared this —
191
197
  * the path-B sibling three lines above had an invoker from the day it was written.
192
198
  */
193
199
  export function workflowAppInvoker(
194
200
  mcp: DiscoveredMCP,
195
- identity: Pick<AppInvocationContext, "conversationId" | "userId" | "chatId" | "accessToken" | "baseUrl" | "trace">,
201
+ identity: Pick<AppInvocationContext, "conversationId" | "userId" | "chatId" | "accessToken" | "baseUrl" | "trace" | "signal">,
196
202
  ): (input: unknown) => ReturnType<typeof invokeTaskNative> {
197
203
  const toolId = String(mcp.methodName);
198
204
  return (input: unknown) => {
@@ -202,6 +208,7 @@ export function workflowAppInvoker(
202
208
  userId: identity.userId,
203
209
  accessToken: identity.accessToken,
204
210
  chatId: identity.chatId,
211
+ ...(identity.signal ? { signal: identity.signal } : {}),
205
212
  ...(identity.trace ? { trace: { ...identity.trace, kind: mcp.task ? "task" : "tool" } } : {}),
206
213
  ...(identity.baseUrl ? { baseUrl: identity.baseUrl } : {}),
207
214
  message: typeof message === "string" ? message : "",
@@ -111,6 +111,14 @@ export interface AppInvocationContext {
111
111
  * Sent as the call's `_meta`, never as an argument; absent for a call no model made.
112
112
  */
113
113
  trace?: { executionId?: string; nodeId?: string; kind?: "tool" | "task" };
114
+ /** The calling run's stop signal: when the run stops, a task it follows is cancelled (`tasks/cancel`). */
115
+ signal?: AbortSignal;
116
+ /**
117
+ * WHAT THE TASK IS DOING NOW, as it changes: each new `statusMessage` of a task this call
118
+ * follows, from the tasks extension's own updates (docs/mcp/MCP_TASKS.md § 5, "what reaches
119
+ * the model"). The caller decides what to do with it; an Agent narrates it.
120
+ */
121
+ onStatus?: (statusMessage: string) => void;
114
122
  /** The caller's stream session — where the wizard renders and elicitation is answered. */
115
123
  conversationId?: string;
116
124
  userId?: string;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * SWITCHING AN AGENT ON (docs/architecture/signal/AGENTS.md § 7, § 7a; UNO-221).
3
+ *
4
+ * An Agent folder says the most it may ever do (`access`). It does nothing until a person switches
5
+ * it on in this environment; that person is responsible for it (`act` on its token). Three rules:
6
+ *
7
+ * - no daily budget, no Agent (§ 9): switching on is refused;
8
+ * - the person must hold everything in `access`: refused, naming what they lack;
9
+ * - its token carries `access` capped at what the owner holds, never more.
10
+ *
11
+ * Pure: who the person is and what they hold come in; the engine keeps the switch
12
+ * (`engine/src/agentKeys/agentSwitch.ts`).
13
+ */
14
+ import { grantedTo } from "../security/permissions.js";
15
+
16
+ /** What switching on needs from the Agent's folder. */
17
+ export interface SwitchableAgent {
18
+ /** `<org>/<name>`. */
19
+ ref: string;
20
+ access: string[];
21
+ budget?: { perDay?: string };
22
+ }
23
+
24
+ /** The person switching it on, as the gate read them. */
25
+ export interface SwitchingPerson {
26
+ id: string;
27
+ roles?: string[];
28
+ permissions?: string[];
29
+ }
30
+
31
+ export type SwitchCheck =
32
+ | { ok: true; ownerPermissions: string[] }
33
+ | { ok: false; reason: "no-budget" | "lacks-access"; missing: string[]; message: string };
34
+
35
+ /** May this person switch this Agent on? When yes, what they hold is kept as the cap. */
36
+ export function checkSwitchOn(agent: SwitchableAgent, person: SwitchingPerson): SwitchCheck {
37
+ if (!agent.budget?.perDay?.trim()) {
38
+ return { ok: false, reason: "no-budget", missing: [], message: `${agent.ref} has no daily budget: set budget.perDay in its agent.yaml` };
39
+ }
40
+ const held = grantedTo(person);
41
+ const missing = agent.access.filter((p) => !held.has(p as never));
42
+ if (missing.length) {
43
+ return { ok: false, reason: "lacks-access", missing, message: `To switch on ${agent.ref} you need ${missing.join(", ")}` };
44
+ }
45
+ return { ok: true, ownerPermissions: [...held].sort() };
46
+ }
47
+
48
+ /** What its token carries: its `access`, never more than the owner holds. */
49
+ export function agentPermissions(access: string[], ownerPermissions: string[]): string[] {
50
+ const owner = new Set(ownerPermissions);
51
+ return access.filter((p) => owner.has(p));
52
+ }