@m6d/cortex-server 2.0.0 → 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 (37) hide show
  1. package/README.md +10 -0
  2. package/contracts/interactive.ts +53 -0
  3. package/contracts/rich-text.ts +207 -0
  4. package/contracts/runtime.ts +58 -1
  5. package/contracts/wire.ts +32 -0
  6. package/dist/contracts/interactive.d.ts +39 -0
  7. package/dist/contracts/rich-text.d.ts +32 -0
  8. package/dist/contracts/runtime.d.ts +119 -7
  9. package/dist/contracts/wire.d.ts +30 -0
  10. package/dist/src/lib/adapters/database/index.d.ts +7 -0
  11. package/dist/src/lib/adapters/database/message-content.d.ts +2 -0
  12. package/dist/src/lib/adapters/database/mssql/index.d.ts +1 -0
  13. package/dist/src/lib/adapters/database/mssql/messages.d.ts +1 -0
  14. package/dist/src/lib/adapters/database/postgres/index.d.ts +1 -0
  15. package/dist/src/lib/adapters/database/postgres/messages.d.ts +1 -0
  16. package/dist/src/lib/ai/cc-runtime.d.ts +5 -4
  17. package/dist/src/lib/ai/interactive.d.ts +62 -0
  18. package/dist/src/lib/ai/tools/search-common.d.ts +1 -1
  19. package/dist/src/lib/ai/turn-tools.d.ts +14 -0
  20. package/dist/src/lib/cc/client.d.ts +27 -3
  21. package/dist/src/lib/cc/config-cache.d.ts +2 -2
  22. package/dist/src/lib/cc/registry.d.ts +14 -1
  23. package/dist/src/lib/cc/types.d.ts +1 -1
  24. package/package.json +5 -2
  25. package/src/lib/adapters/database/index.ts +12 -0
  26. package/src/lib/adapters/database/mssql/messages.ts +28 -1
  27. package/src/lib/adapters/database/postgres/messages.ts +22 -1
  28. package/src/lib/ai/cc-runtime.ts +16 -1
  29. package/src/lib/ai/index.ts +41 -5
  30. package/src/lib/ai/interactive.ts +364 -0
  31. package/src/lib/ai/tools/search-tools.tool.ts +6 -2
  32. package/src/lib/ai/turn-tools.ts +23 -0
  33. package/src/lib/cc/client.ts +5 -1
  34. package/src/lib/cc/format.ts +8 -9
  35. package/src/lib/cc/registry.ts +16 -2
  36. package/src/lib/cc/types.ts +1 -0
  37. package/src/lib/routes/chat.ts +23 -0
@@ -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) =>
@@ -190,6 +212,13 @@ export async function startTurn(
190
212
  config: cc.config,
191
213
  resolved: ccResolved,
192
214
  variables: buildPromptVariables(cc.config.promptVariables, {
215
+ // Computed per request; consumer-supplied context
216
+ // with the same key wins via the spreads below. No
217
+ // such default for `timezone`: the server's zone is
218
+ // not the session's, so guessing would feed the
219
+ // model wrong user context — only the consumer can
220
+ // supply it.
221
+ utcTime: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
193
222
  ...session,
194
223
  ...requestContext,
195
224
  locale: cc.locale,
@@ -205,8 +234,13 @@ export async function startTurn(
205
234
  const stream = chat({
206
235
  adapter: model,
207
236
  // Client tool declarations ride in on every request, so the server
208
- // never re-declares them.
209
- 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
+ ]),
210
244
  messages: convertMessagesToModelMessages(
211
245
  fitToContextWindow(contextMessages, systemPrompt, tools, config),
212
246
  ),
@@ -258,6 +292,9 @@ export async function startTurn(
258
292
  modelId: inspector.modelId,
259
293
  isAborted: run.abortController.signal.aborted,
260
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),
261
298
  } satisfies MessageMetadata,
262
299
  };
263
300
  }
@@ -331,7 +368,6 @@ function createTurnProviders(config: ResolvedCortexAgentConfig, fetch: typeof gl
331
368
  model: createModel(config.model, { fetch }),
332
369
  embed,
333
370
  neo4j: config.neo4j && embed ? createNeo4jClient(config.neo4j, embed) : undefined,
334
- ccClient: config.controlCenter ? new ControlCenterClient(config.controlCenter) : null,
335
371
  };
336
372
  }
337
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
 
@@ -1,3 +1,5 @@
1
+ import { substituteVariables } from "@cortex/contracts/rich-text";
2
+
1
3
  import type { ResolveResponse, RuntimeAgentConfig } from "./types";
2
4
 
3
5
  type SharedShape = ResolveResponse["sharedShapes"][number];
@@ -5,12 +7,6 @@ type ToolSignature = ResolveResponse["tools"][number];
5
7
  type ServiceCard = ResolveResponse["services"][number];
6
8
  type KnowledgeChunk = ResolveResponse["knowledge"][number];
7
9
 
8
- function substitutePromptVariables(template: string, variables: Record<string, string>) {
9
- return template.replace(/\{\{(\w+)\}\}/g, function replaceVariable(match, name: string) {
10
- return variables[name] ?? match;
11
- });
12
- }
13
-
14
10
  /** Values for the published prompt's `{{variable}}` placeholders, read from session/request context. */
15
11
  export function buildPromptVariables(
16
12
  promptVariables: RuntimeAgentConfig["promptVariables"],
@@ -67,7 +63,7 @@ export type CcPromptInput = {
67
63
  export function buildCcSection(cc: CcPromptInput) {
68
64
  const parts: string[] = [];
69
65
 
70
- const prompt = substitutePromptVariables(cc.config.systemPrompt, cc.variables).trim();
66
+ const prompt = substituteVariables(cc.config.systemPrompt, cc.variables).trim();
71
67
  if (prompt) parts.push(prompt);
72
68
 
73
69
  if (cc.config.catalogBlurb) {
@@ -78,10 +74,13 @@ export function buildCcSection(cc: CcPromptInput) {
78
74
  if (cc.resolved.sharedShapes.length > 0) {
79
75
  parts.push(`## Response Shapes\n${formatSharedShapes(cc.resolved.sharedShapes)}`);
80
76
  }
81
- 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) {
82
81
  parts.push(
83
82
  "## Dynamic Tools\nCall these from executeCode via the `tools` global, e.g. `await tools.name(input)`.\n\n" +
84
- formatToolSignatures(cc.resolved.tools),
83
+ formatToolSignatures(dynamicTools),
85
84
  );
86
85
  }
87
86
  if (cc.resolved.services.length > 0) {