@m6d/cortex-server 2.0.1 → 2.1.0

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 (33) hide show
  1. package/README.md +10 -0
  2. package/contracts/interactive.ts +53 -0
  3. package/contracts/runtime.ts +22 -0
  4. package/contracts/wire.ts +32 -0
  5. package/dist/contracts/interactive.d.ts +39 -0
  6. package/dist/contracts/runtime.d.ts +80 -0
  7. package/dist/contracts/wire.d.ts +30 -0
  8. package/dist/src/lib/adapters/database/index.d.ts +7 -0
  9. package/dist/src/lib/adapters/database/message-content.d.ts +2 -0
  10. package/dist/src/lib/adapters/database/mssql/index.d.ts +1 -0
  11. package/dist/src/lib/adapters/database/mssql/messages.d.ts +1 -0
  12. package/dist/src/lib/adapters/database/postgres/index.d.ts +1 -0
  13. package/dist/src/lib/adapters/database/postgres/messages.d.ts +1 -0
  14. package/dist/src/lib/ai/cc-runtime.d.ts +2 -1
  15. package/dist/src/lib/ai/interactive.d.ts +62 -0
  16. package/dist/src/lib/ai/turn-tools.d.ts +14 -0
  17. package/dist/src/lib/cc/client.d.ts +24 -0
  18. package/dist/src/lib/cc/registry.d.ts +14 -1
  19. package/dist/src/lib/cc/types.d.ts +1 -1
  20. package/package.json +5 -2
  21. package/src/lib/adapters/database/index.ts +12 -0
  22. package/src/lib/adapters/database/mssql/messages.ts +28 -1
  23. package/src/lib/adapters/database/postgres/messages.ts +22 -1
  24. package/src/lib/ai/cc-runtime.ts +16 -1
  25. package/src/lib/ai/index.ts +34 -5
  26. package/src/lib/ai/interactive.ts +364 -0
  27. package/src/lib/ai/tools/search-tools.tool.ts +6 -2
  28. package/src/lib/ai/turn-tools.ts +23 -0
  29. package/src/lib/cc/client.ts +5 -1
  30. package/src/lib/cc/format.ts +5 -2
  31. package/src/lib/cc/registry.ts +16 -2
  32. package/src/lib/cc/types.ts +1 -0
  33. package/src/lib/routes/chat.ts +23 -0
@@ -42,6 +42,18 @@ export type DatabaseAdapter = {
42
42
  messages: ChatMessage[],
43
43
  options?: { replaceAttachments?: boolean },
44
44
  ): Promise<void>;
45
+ /**
46
+ * Atomically merges one anchored interactive reference into the
47
+ * message's metadata as a single-statement JSON merge — safe under
48
+ * concurrent writers across replicas, unlike a read-modify-write of
49
+ * the whole message.
50
+ */
51
+ mergeInteractiveReference(
52
+ threadId: string,
53
+ messageId: string,
54
+ toolCallId: string,
55
+ reference: string,
56
+ ): Promise<void>;
45
57
  };
46
58
  llmRequests: {
47
59
  insert(
@@ -1,5 +1,5 @@
1
1
  // fallow-ignore-file duplicate-export -- mssql twin of the postgres repository, same factory name by design
2
- import { and, desc, eq, getColumns, inArray, type InferInsertModel } from "drizzle-orm";
2
+ import { and, desc, eq, getColumns, inArray, sql, type InferInsertModel } from "drizzle-orm";
3
3
  import { threads, messages } from "@/db/schema.mssql";
4
4
  import type { ChatMessage } from "@/types";
5
5
  import type { DatabaseAdapter } from "@/adapters/database/index";
@@ -88,5 +88,32 @@ export function createMessagesRepository(db: MssqlDb) {
88
88
  .execute();
89
89
  }
90
90
  },
91
+
92
+ async mergeInteractiveReference(threadId, messageId, toolCallId, reference) {
93
+ // JSON path keys are quoted strings; the id lands inside one.
94
+ const escapedKey = toolCallId.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
95
+ const anchorPath = `lax $.metadata.interactiveReferences."${escapedKey}"`;
96
+ await db
97
+ .update(messages)
98
+ .set({
99
+ // All JSON_QUERY reads see the row's pre-update value, so
100
+ // the statement is one atomic merge.
101
+ content: sql`JSON_MODIFY(
102
+ JSON_MODIFY(
103
+ JSON_MODIFY(
104
+ ${messages.content},
105
+ 'lax $.metadata',
106
+ JSON_QUERY(COALESCE(JSON_QUERY(${messages.content}, '$.metadata'), '{}'))
107
+ ),
108
+ 'lax $.metadata.interactiveReferences',
109
+ JSON_QUERY(COALESCE(JSON_QUERY(${messages.content}, '$.metadata.interactiveReferences'), '{}'))
110
+ ),
111
+ ${anchorPath},
112
+ ${reference}
113
+ )`,
114
+ })
115
+ .where(and(eq(messages.id, messageId), eq(messages.threadId, threadId)))
116
+ .execute();
117
+ },
91
118
  } satisfies DatabaseAdapter["messages"];
92
119
  }
@@ -1,6 +1,6 @@
1
1
  // fallow-ignore-file code-duplication -- mirrors ../mssql/messages.ts by design;
2
2
  // Drizzle types query builders per dialect. See DatabaseAdapter in ../index.ts.
3
- import { and, desc, eq, getColumns, inArray, type InferInsertModel } from "drizzle-orm";
3
+ import { and, desc, eq, getColumns, inArray, sql, type InferInsertModel } from "drizzle-orm";
4
4
  import { threads, messages } from "@/db/schema.pg";
5
5
  import type { ChatMessage } from "@/types";
6
6
  import type { DatabaseAdapter } from "@/adapters/database/index";
@@ -88,5 +88,26 @@ export function createMessagesRepository(db: PostgresDb) {
88
88
  .execute();
89
89
  }
90
90
  },
91
+
92
+ async mergeInteractiveReference(threadId, messageId, toolCallId, reference) {
93
+ await db
94
+ .update(messages)
95
+ .set({
96
+ content: sql`jsonb_set(
97
+ jsonb_set(
98
+ ${messages.content},
99
+ '{metadata}',
100
+ coalesce(${messages.content}->'metadata', '{}'::jsonb),
101
+ true
102
+ ),
103
+ '{metadata,interactiveReferences}',
104
+ coalesce(${messages.content}->'metadata'->'interactiveReferences', '{}'::jsonb)
105
+ || jsonb_build_object(${toolCallId}::text, ${reference}::text),
106
+ true
107
+ )`,
108
+ })
109
+ .where(and(eq(messages.id, messageId), eq(messages.threadId, threadId)))
110
+ .execute();
111
+ },
91
112
  } satisfies DatabaseAdapter["messages"];
92
113
  }
@@ -1,7 +1,7 @@
1
1
  import type { ResolvedCortexAgentConfig } from "@/config";
2
2
  import type { Thread } from "@/types";
3
3
  import type { ControlCenterClient } from "@/cc/client";
4
- import type { CcRuntime, CcToolRegistry } from "@/cc/registry";
4
+ import type { CcInteractiveTool, CcRuntime, CcToolRegistry } from "@/cc/registry";
5
5
  import { registerCcTools } from "@/cc/registry";
6
6
  import type { ResolveResponse, RuntimeAgentConfig } from "@/cc/types";
7
7
 
@@ -35,6 +35,20 @@ export function createCcRuntime(options: CcRuntimeOptions) {
35
35
  const registry: CcToolRegistry = new Map();
36
36
  if (ccResolved) registerCcTools(registry, ccResolved.tools);
37
37
 
38
+ // Interactive tools resolved at turn start become client-executed LLM
39
+ // tools. Mid-turn search finds are excluded on purpose: the model's tool
40
+ // set is fixed once the turn starts, so declaring them is impossible.
41
+ const interactiveTools = new Map<string, CcInteractiveTool>();
42
+ for (const tool of ccResolved?.tools ?? []) {
43
+ if (!tool.interaction) continue;
44
+ interactiveTools.set(tool.name, {
45
+ toolId: tool.toolId,
46
+ signature: tool.signature,
47
+ interaction: tool.interaction,
48
+ inputSchema: tool.inputSchema,
49
+ });
50
+ }
51
+
38
52
  let currentStep = -1;
39
53
  let callIndex = 0;
40
54
 
@@ -43,6 +57,7 @@ export function createCcRuntime(options: CcRuntimeOptions) {
43
57
  agentId: config.agentId,
44
58
  config: ccConfig,
45
59
  registry,
60
+ interactiveTools,
46
61
  threadId: thread.id,
47
62
  turnKey: options.turnKey,
48
63
  userId,
@@ -15,7 +15,6 @@ import type { ResolvedCortexAgentConfig, ToolSet } from "@/config";
15
15
  import type { ChatMessage, MessageMetadata, Thread } from "@/types";
16
16
  import { createModel } from "./helpers";
17
17
  import { buildSystemPrompt, resolveSession } from "./prompt";
18
- import { ControlCenterClient } from "@/cc/client";
19
18
  import { getControlCenterConfig } from "@/cc/config-cache";
20
19
  import { buildPromptVariables } from "@/cc/format";
21
20
  import { getRecentCcTools } from "@/cc/registry";
@@ -30,7 +29,13 @@ import {
30
29
  buildTurnTools,
31
30
  createToolInstrumentation,
32
31
  hasDefaultAttachmentInterceptor,
32
+ interactiveToolDeclarations,
33
33
  } from "./turn-tools";
34
+ import {
35
+ createCcClient,
36
+ interactiveToolBindings,
37
+ settleInteractiveToolResults,
38
+ } from "./interactive";
34
39
  import { createInspector } from "./inspector";
35
40
  import { commitBeforeTerminal } from "./commit-gate";
36
41
  import { finishTurn } from "./finish-turn";
@@ -63,6 +68,19 @@ export async function startTurn(
63
68
  const incoming = params.messages
64
69
  .map((message) => toChatMessage(normalizeToUIMessage(message, generateMessageId)))
65
70
  .filter((message) => message.role === "user" || answersToolCalls(message));
71
+
72
+ // Interactive tool answers are untrusted client input: re-establish
73
+ // them via the tool's verify call and apply result masking before
74
+ // anything — transcript or model — reads them.
75
+ const ccClient = createCcClient(config);
76
+ await settleInteractiveToolResults({
77
+ messages: incoming,
78
+ thread,
79
+ userId,
80
+ token,
81
+ config,
82
+ ccClient,
83
+ });
66
84
  await config.db.messages.upsert(thread.id, incoming);
67
85
 
68
86
  const lastUserMessage = incoming.findLast((message) => message.role === "user");
@@ -111,7 +129,7 @@ export async function startTurn(
111
129
  const turnKey = lastUserMessage?.id ?? "0";
112
130
 
113
131
  const inspector = createInspector();
114
- const { model, embed, neo4j, ccClient } = createTurnProviders(config, inspector.fetch);
132
+ const { model, embed, neo4j } = createTurnProviders(config, inspector.fetch);
115
133
 
116
134
  // Run independent operations in parallel
117
135
  const [contextResult, resolved, session, ccConfig, ccResolved] = await Promise.all([
@@ -173,6 +191,10 @@ export async function startTurn(
173
191
  threadAttachments,
174
192
  };
175
193
  const tools = buildTurnTools(turnToolsOptions);
194
+ const consumerToolNames = new Set([
195
+ ...tools.map((tool) => tool.name),
196
+ ...params.tools.map((tool) => tool.name),
197
+ ]);
176
198
  const instrumentation = createToolInstrumentation({
177
199
  ...turnToolsOptions,
178
200
  onToolStart: (toolName, toolCallId) =>
@@ -212,8 +234,13 @@ export async function startTurn(
212
234
  const stream = chat({
213
235
  adapter: model,
214
236
  // Client tool declarations ride in on every request, so the server
215
- // never re-declares them.
216
- tools: mergeAgentTools(tools, params.tools),
237
+ // never re-declares them. Interactive CC tools join them: also
238
+ // executor-less, answered by the widget. Consumer-owned names win
239
+ // the collision, and only names that won are treated as interactive.
240
+ tools: mergeAgentTools(tools, [
241
+ ...params.tools,
242
+ ...interactiveToolDeclarations(cc, consumerToolNames),
243
+ ]),
217
244
  messages: convertMessagesToModelMessages(
218
245
  fitToContextWindow(contextMessages, systemPrompt, tools, config),
219
246
  ),
@@ -265,6 +292,9 @@ export async function startTurn(
265
292
  modelId: inspector.modelId,
266
293
  isAborted: run.abortController.signal.aborted,
267
294
  tokenUsage: inspector.tokenUsage,
295
+ // Stamped so a parked interactive call can be initiated and
296
+ // verified after any reload or server restart.
297
+ interactiveTools: interactiveToolBindings(cc, consumerToolNames),
268
298
  } satisfies MessageMetadata,
269
299
  };
270
300
  }
@@ -338,7 +368,6 @@ function createTurnProviders(config: ResolvedCortexAgentConfig, fetch: typeof gl
338
368
  model: createModel(config.model, { fetch }),
339
369
  embed,
340
370
  neo4j: config.neo4j && embed ? createNeo4jClient(config.neo4j, embed) : undefined,
341
- ccClient: config.controlCenter ? new ControlCenterClient(config.controlCenter) : null,
342
371
  };
343
372
  }
344
373
 
@@ -0,0 +1,364 @@
1
+ import { HTTPException } from "hono/http-exception";
2
+ import type { InteractiveInitiateResult, MessageMetadata } from "@cortex/contracts/wire";
3
+ import { INTERACTIVE_STATUSES } from "@cortex/contracts/interactive";
4
+ import type { ResolvedCortexAgentConfig } from "@/config";
5
+ import type { ChatMessage, Thread } from "@/types";
6
+ import { ControlCenterClient } from "@/cc/client";
7
+ import type { CcRuntime } from "@/cc/registry";
8
+ import { recordRecentCcTool } from "@/cc/registry";
9
+
10
+ type InteractiveBinding = NonNullable<MessageMetadata["interactiveTools"]>[string];
11
+
12
+ /**
13
+ * The wire-shape bindings stamped on the turn's final assistant message, so a
14
+ * parked interactive call can be initiated and verified after any restart.
15
+ * Only names that actually won declaration merging are stamped — a call to a
16
+ * colliding consumer-owned tool must never be treated as interactive. Undefined
17
+ * when nothing qualifies, keeping metadata lean.
18
+ */
19
+ export function interactiveToolBindings(
20
+ cc: CcRuntime | undefined,
21
+ takenNames: ReadonlySet<string>,
22
+ ) {
23
+ if (!cc) return undefined;
24
+ const entries = [...cc.interactiveTools]
25
+ .filter(([name]) => !takenNames.has(name))
26
+ .map(
27
+ ([name, tool]) =>
28
+ [
29
+ name,
30
+ { toolId: tool.toolId, ...tool.interaction } satisfies InteractiveBinding,
31
+ ] as const,
32
+ );
33
+ return entries.length > 0 ? Object.fromEntries(entries) : undefined;
34
+ }
35
+
36
+ /** Null when the agent has no Control Center — every caller treats that as "skip". */
37
+ export function createCcClient(config: ResolvedCortexAgentConfig) {
38
+ return config.controlCenter ? new ControlCenterClient(config.controlCenter) : null;
39
+ }
40
+
41
+ /** How long initiate waits out the turn-commit race before giving up. */
42
+ const PENDING_CALL_RETRIES = 6;
43
+ const PENDING_CALL_RETRY_MS = 700;
44
+
45
+ /**
46
+ * The widget initiates the moment its stream goes idle, which can beat the
47
+ * turn's commit to the database — so a missing call is retried briefly before
48
+ * it becomes a 404. An *answered* call 404s immediately: it was settled
49
+ * elsewhere (another tab) and the widget should fall back to its pill.
50
+ */
51
+ async function findPendingInteractiveCall(
52
+ config: ResolvedCortexAgentConfig,
53
+ userId: string,
54
+ threadId: string,
55
+ toolCallId: string,
56
+ ) {
57
+ for (let attempt = 0; ; attempt += 1) {
58
+ const stored = await config.db.messages.list(userId, threadId);
59
+ const messages = stored.map((row) => row.content);
60
+ const answered = messages.some((candidate) =>
61
+ candidate.parts.some(
62
+ (part) => part.type === "tool-result" && part.toolCallId === toolCallId,
63
+ ),
64
+ );
65
+ if (answered) return null;
66
+
67
+ for (const message of messages) {
68
+ const call = message.parts.find(
69
+ (part) => part.type === "tool-call" && part.id === toolCallId,
70
+ );
71
+ if (call?.type === "tool-call") return { message, call };
72
+ }
73
+
74
+ if (attempt >= PENDING_CALL_RETRIES) return null;
75
+ await new Promise((resolve) => setTimeout(resolve, PENDING_CALL_RETRY_MS));
76
+ }
77
+ }
78
+
79
+ /**
80
+ * `POST /chat/:chatId/tools/:toolCallId/initiate` — runs the interactive
81
+ * tool's initiate call (the tool's own endpoint) and hands the widget its
82
+ * embed payload. The model never sees this payload. The idempotency key is
83
+ * pinned to the tool call, so a reload mid-flow reuses the created session
84
+ * instead of opening a second one.
85
+ */
86
+ export async function initiateInteractiveTool(options: {
87
+ config: ResolvedCortexAgentConfig;
88
+ thread: Thread;
89
+ userId: string;
90
+ token: string;
91
+ toolCallId: string;
92
+ }) {
93
+ const { config, thread, userId, token, toolCallId } = options;
94
+ const pending = await findPendingInteractiveCall(config, userId, thread.id, toolCallId);
95
+ if (!pending) {
96
+ throw new HTTPException(404, { message: "No pending tool call with this id" });
97
+ }
98
+ const { message, call } = pending;
99
+
100
+ const binding = message.metadata?.interactiveTools?.[call.name];
101
+ const ccClient = createCcClient(config);
102
+ if (!binding || !ccClient) {
103
+ return { interactive: false } satisfies InteractiveInitiateResult;
104
+ }
105
+
106
+ const result = await ccClient.execute(
107
+ config.agentId,
108
+ binding.toolId,
109
+ {
110
+ input: asRecord(call.input) ?? asRecord(parseJson(call.arguments)) ?? {},
111
+ context: { threadId: thread.id, userId },
112
+ },
113
+ {
114
+ readOnly: false,
115
+ endUserToken: token,
116
+ threadId: thread.id,
117
+ turnKey: toolCallId,
118
+ stepIndex: 0,
119
+ callIndex: 0,
120
+ idempotencyKey: `${thread.id}:${toolCallId}:initiate`,
121
+ },
122
+ );
123
+ if (!result) {
124
+ throw new HTTPException(502, { message: "The tool is temporarily unavailable" });
125
+ }
126
+ if ("error" in result) {
127
+ throw new HTTPException(502, { message: `initiate failed: ${result.error.kind}` });
128
+ }
129
+
130
+ const embedUrl = asRecord(result.output)?.embedUrl;
131
+ if (typeof embedUrl !== "string" || !isOnOrigin(embedUrl, binding.embedOrigin)) {
132
+ throw new HTTPException(502, {
133
+ message: "initiate did not return an embedUrl on the configured origin",
134
+ });
135
+ }
136
+
137
+ // Anchor the trusted reference to this call: a verified completion may
138
+ // only settle against the session this initiate created, never against
139
+ // whichever reference the browser chooses to report.
140
+ if (binding.hasVerify) {
141
+ const reference = asRecord(result.output)?.reference;
142
+ if (typeof reference !== "string" || !reference) {
143
+ throw new HTTPException(502, {
144
+ message: "initiate did not return the reference its verify endpoint needs",
145
+ });
146
+ }
147
+ await config.db.messages.mergeInteractiveReference(
148
+ thread.id,
149
+ message.id,
150
+ toolCallId,
151
+ reference,
152
+ );
153
+ }
154
+
155
+ return {
156
+ interactive: true,
157
+ embedUrl,
158
+ surface: binding.surface,
159
+ embedOrigin: binding.embedOrigin,
160
+ } satisfies InteractiveInitiateResult;
161
+ }
162
+
163
+ /**
164
+ * The trust boundary for interactive results: whatever the client submitted
165
+ * for a bound tool call is replaced wholesale — cancelled/failed pass through
166
+ * normalized, completed is re-established by the tool's verify call when it
167
+ * has one, and result delivery masking is applied before the model reads it.
168
+ * Runs before the answering turn is persisted, so both the stored transcript
169
+ * and the model context carry only settled results.
170
+ */
171
+ export async function settleInteractiveToolResults(options: {
172
+ messages: ChatMessage[];
173
+ thread: Thread;
174
+ userId: string;
175
+ token: string;
176
+ config: ResolvedCortexAgentConfig;
177
+ ccClient: ControlCenterClient | null;
178
+ }) {
179
+ // Deliberately no early-out on a missing CC client: normalization must run
180
+ // for every bound result regardless — only the verify call needs CC, and
181
+ // without it a claimed completion fails rather than passing through.
182
+ const { messages, thread, userId, token, config, ccClient } = options;
183
+ const answering = messages.filter(
184
+ (message) =>
185
+ message.role === "assistant" &&
186
+ message.parts.some((part) => part.type === "tool-result"),
187
+ );
188
+ if (answering.length === 0) return;
189
+
190
+ const stored = await config.db.messages.list(userId, thread.id);
191
+ const storedMessages = stored.map((row) => row.content);
192
+ const pendingCalls = collectPendingCalls(storedMessages);
193
+ if (pendingCalls.size === 0) return;
194
+
195
+ const context = {
196
+ ccClient,
197
+ agentId: config.agentId,
198
+ threadId: thread.id,
199
+ userId,
200
+ token,
201
+ references: collectReferences(storedMessages),
202
+ };
203
+ for (const message of answering) {
204
+ await settleMessage(message, pendingCalls, context);
205
+ }
206
+ }
207
+
208
+ async function settleMessage(
209
+ message: ChatMessage,
210
+ pendingCalls: Map<string, PendingInteractiveCall>,
211
+ context: SettleContext,
212
+ ) {
213
+ for (const part of message.parts) {
214
+ if (part.type !== "tool-result") continue;
215
+ const pending = pendingCalls.get(part.toolCallId);
216
+ if (!pending) continue;
217
+
218
+ recordRecentCcTool(context.threadId, pending.name);
219
+ const settled = await settleOne(pending.binding, part.content, part.toolCallId, context);
220
+ part.content = JSON.stringify(settled);
221
+ }
222
+ }
223
+
224
+ type PendingInteractiveCall = { name: string; binding: InteractiveBinding };
225
+
226
+ /**
227
+ * The authoritative view of what may settle: stored, unanswered tool calls
228
+ * whose own message carries a binding for their name, keyed by call id. The
229
+ * incoming message's tool-call parts are client-authored and never consulted —
230
+ * a fabricated call/result pair matches nothing here.
231
+ */
232
+ function collectPendingCalls(messages: ChatMessage[]) {
233
+ const answered = answeredCallIds(messages);
234
+ const pending = new Map<string, PendingInteractiveCall>();
235
+ for (const message of messages) {
236
+ const bindings = message.metadata?.interactiveTools;
237
+ if (!bindings) continue;
238
+ for (const part of message.parts) {
239
+ if (part.type !== "tool-call" || answered.has(part.id)) continue;
240
+ const binding = bindings[part.name];
241
+ if (binding) pending.set(part.id, { name: part.name, binding });
242
+ }
243
+ }
244
+ return pending;
245
+ }
246
+
247
+ function answeredCallIds(messages: ChatMessage[]) {
248
+ const answered = new Set<string>();
249
+ for (const message of messages) {
250
+ for (const part of message.parts) {
251
+ if (part.type === "tool-result") answered.add(part.toolCallId);
252
+ }
253
+ }
254
+ return answered;
255
+ }
256
+
257
+ /** The references initiate anchored, keyed by tool call id. */
258
+ function collectReferences(messages: ChatMessage[]) {
259
+ const references = new Map<string, string>();
260
+ for (const message of messages) {
261
+ for (const [toolCallId, reference] of Object.entries(
262
+ message.metadata?.interactiveReferences ?? {},
263
+ )) {
264
+ references.set(toolCallId, reference);
265
+ }
266
+ }
267
+ return references;
268
+ }
269
+
270
+ type SettleContext = {
271
+ ccClient: ControlCenterClient | null;
272
+ agentId: string;
273
+ threadId: string;
274
+ userId: string;
275
+ token: string;
276
+ references: Map<string, string>;
277
+ };
278
+
279
+ async function settleOne(
280
+ binding: InteractiveBinding,
281
+ content: string | unknown[],
282
+ toolCallId: string,
283
+ context: SettleContext,
284
+ ) {
285
+ const reported = parseReported(content);
286
+ if (reported.status === "cancelled") return { status: "cancelled" };
287
+ if (reported.status !== "completed") return { status: "failed" };
288
+
289
+ if (!binding.hasVerify) {
290
+ // A completion the server cannot re-establish is just the browser's
291
+ // word. Current Control Centers always configure verification, so this
292
+ // only fires against a foreign runtime — refuse rather than trust.
293
+ return { status: "failed", detail: "verification required" };
294
+ }
295
+ const anchored = context.references.get(toolCallId);
296
+ if (!anchored || reported.reference !== anchored) {
297
+ return { status: "failed", detail: "completion does not match the initiated session" };
298
+ }
299
+ if (!context.ccClient) {
300
+ return { status: "failed", detail: "verification unavailable" };
301
+ }
302
+
303
+ const result = await context.ccClient.execute(
304
+ context.agentId,
305
+ binding.toolId,
306
+ {
307
+ input: { reference: anchored },
308
+ phase: "verify",
309
+ context: { threadId: context.threadId, userId: context.userId },
310
+ },
311
+ {
312
+ readOnly: true,
313
+ endUserToken: context.token,
314
+ threadId: context.threadId,
315
+ turnKey: "verify",
316
+ stepIndex: 0,
317
+ callIndex: 0,
318
+ },
319
+ );
320
+ if (!result) return { status: "failed", detail: "verification unavailable" };
321
+ if ("error" in result) return { status: "failed", detail: result.error.kind };
322
+ return deliver(binding, reported.reference, result.output);
323
+ }
324
+
325
+ function deliver(binding: InteractiveBinding, reference: string | undefined, output: unknown) {
326
+ return binding.resultDelivery === "endpoint"
327
+ ? { status: "completed", reference }
328
+ : { status: "completed", reference, result: output };
329
+ }
330
+
331
+ function parseReported(content: string | unknown[]) {
332
+ const record = asRecord(typeof content === "string" ? parseJson(content) : content);
333
+ const status = INTERACTIVE_STATUSES.find((known) => known === record?.status);
334
+ return {
335
+ status,
336
+ reference: typeof record?.reference === "string" ? record.reference : undefined,
337
+ };
338
+ }
339
+
340
+ function parseJson(value: string | undefined) {
341
+ if (value === undefined) return undefined;
342
+ try {
343
+ return JSON.parse(value) as unknown;
344
+ } catch {
345
+ return undefined;
346
+ }
347
+ }
348
+
349
+ function asRecord(value: unknown) {
350
+ return typeof value === "object" && value !== null && !Array.isArray(value)
351
+ ? (value as Record<string, unknown>)
352
+ : undefined;
353
+ }
354
+
355
+ function isOnOrigin(url: string, origin: string) {
356
+ try {
357
+ const parsed = new URL(url);
358
+ // http(s) only: javascript:/data: URLs normalize to origin "null",
359
+ // which a bare equality check could otherwise be tricked into passing.
360
+ return ["http:", "https:"].includes(parsed.protocol) && parsed.origin === origin;
361
+ } catch {
362
+ return false;
363
+ }
364
+ }
@@ -20,10 +20,14 @@ export function createSearchToolsTool(cc: CcRuntime) {
20
20
  if (!result) return "Tool search is temporarily unavailable. Try again later.";
21
21
 
22
22
  registerCcTools(cc.registry, result.tools);
23
- if (result.tools.length === 0) return "No matching tools found.";
23
+ // Interactive tools cannot join a turn already underway (the model's
24
+ // tool set is fixed at turn start), so surfacing them here would only
25
+ // advertise dead ends.
26
+ const callable = result.tools.filter((tool) => !tool.interaction);
27
+ if (callable.length === 0) return "No matching tools found.";
24
28
 
25
29
  const shapes =
26
30
  result.sharedShapes.length > 0 ? `${formatSharedShapes(result.sharedShapes)}\n\n` : "";
27
- return shapes + formatToolSignatures(result.tools);
31
+ return shapes + formatToolSignatures(callable);
28
32
  });
29
33
  }
@@ -110,6 +110,29 @@ export function createToolInstrumentation(
110
110
  } satisfies ChatMiddleware;
111
111
  }
112
112
 
113
+ /**
114
+ * Interactive CC tools are declared like the request's client tools: no
115
+ * executor, so the call streams to the widget as a tool-call part and the run
116
+ * parks until the widget answers. The declaration carries the tool's published
117
+ * input schema — function-calling models ignore schemas described only in
118
+ * prose — with a permissive object as the fallback for older Control Centers.
119
+ * Names the consumer already claimed are skipped: those calls belong to the
120
+ * consumer's tool, and must neither be declared nor stamped as interactive.
121
+ */
122
+ export function interactiveToolDeclarations(
123
+ cc: CcRuntime | undefined,
124
+ takenNames: ReadonlySet<string>,
125
+ ) {
126
+ if (!cc) return [];
127
+ return [...cc.interactiveTools]
128
+ .filter(([name]) => !takenNames.has(name))
129
+ .map(([name, tool]) => ({
130
+ name,
131
+ description: tool.signature,
132
+ parameters: tool.inputSchema ?? { type: "object", additionalProperties: true },
133
+ }));
134
+ }
135
+
113
136
  function createCcTools(cc: CcRuntime | undefined) {
114
137
  if (!cc) return [];
115
138
 
@@ -21,6 +21,9 @@ export type ExecuteOptions = {
21
21
  turnKey: string;
22
22
  stepIndex: number;
23
23
  callIndex: number;
24
+ /** Overrides the composed key — used where stability must outlive the turn
25
+ * counters, e.g. one interactive initiate per tool call across reloads. */
26
+ idempotencyKey?: string;
24
27
  timeoutMs?: number;
25
28
  abortSignal?: AbortSignal;
26
29
  };
@@ -138,7 +141,8 @@ export class ControlCenterClient {
138
141
  if (!options.readOnly) {
139
142
  headers.set(
140
143
  "Idempotency-Key",
141
- `${options.threadId}:${options.turnKey}:${options.stepIndex}:${options.callIndex}`,
144
+ options.idempotencyKey ??
145
+ `${options.threadId}:${options.turnKey}:${options.stepIndex}:${options.callIndex}`,
142
146
  );
143
147
  }
144
148
 
@@ -74,10 +74,13 @@ export function buildCcSection(cc: CcPromptInput) {
74
74
  if (cc.resolved.sharedShapes.length > 0) {
75
75
  parts.push(`## Response Shapes\n${formatSharedShapes(cc.resolved.sharedShapes)}`);
76
76
  }
77
- if (cc.resolved.tools.length > 0) {
77
+ // Interactive tools are declared to the model as real tools, so they
78
+ // stay out of the sandbox-only Dynamic Tools section.
79
+ const dynamicTools = cc.resolved.tools.filter((tool) => !tool.interaction);
80
+ if (dynamicTools.length > 0) {
78
81
  parts.push(
79
82
  "## Dynamic Tools\nCall these from executeCode via the `tools` global, e.g. `await tools.name(input)`.\n\n" +
80
- formatToolSignatures(cc.resolved.tools),
83
+ formatToolSignatures(dynamicTools),
81
84
  );
82
85
  }
83
86
  if (cc.resolved.services.length > 0) {