@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
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
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
227
|
+
{
|
|
228
|
+
const result = (await callFollowingTasks(
|
|
229
|
+
toolId,
|
|
83
230
|
{
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
122
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|