@m6d/cortex-server 2.1.0 → 2.3.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 (63) hide show
  1. package/README.md +23 -9
  2. package/contracts/README.md +22 -8
  3. package/contracts/src/client-tools/index.ts +56 -0
  4. package/contracts/src/graph/index.ts +30 -0
  5. package/contracts/{runtime.ts → src/runtime/index.ts} +7 -12
  6. package/contracts/{wire.ts → src/wire/index.ts} +25 -13
  7. package/dist/contracts/{graph.d.ts → src/graph/index.d.ts} +8 -8
  8. package/dist/contracts/{runtime.d.ts → src/runtime/index.d.ts} +9 -43
  9. package/dist/contracts/{wire.d.ts → src/wire/index.d.ts} +23 -13
  10. package/dist/src/lib/adapters/database/index.d.ts +0 -7
  11. package/dist/src/lib/adapters/database/message-content.d.ts +1 -2
  12. package/dist/src/lib/adapters/database/mssql/index.d.ts +0 -1
  13. package/dist/src/lib/adapters/database/mssql/messages.d.ts +0 -1
  14. package/dist/src/lib/adapters/database/postgres/index.d.ts +0 -1
  15. package/dist/src/lib/adapters/database/postgres/messages.d.ts +0 -1
  16. package/dist/src/lib/ai/cc-runtime.d.ts +2 -3
  17. package/dist/src/lib/ai/client-tools.d.ts +84 -0
  18. package/dist/src/lib/ai/tools/query-graph.tool.d.ts +1 -1
  19. package/dist/src/lib/ai/turn-tools.d.ts +8 -6
  20. package/dist/src/lib/cc/client.d.ts +4 -11
  21. package/dist/src/lib/cc/config-cache.d.ts +0 -1
  22. package/dist/src/lib/cc/registry.d.ts +6 -6
  23. package/dist/src/lib/cc/types.d.ts +1 -1
  24. package/dist/src/lib/config.d.ts +9 -4
  25. package/dist/src/lib/graph/index.d.ts +1 -1
  26. package/dist/src/lib/graph/resolver.d.ts +1 -1
  27. package/dist/src/lib/index.d.ts +2 -1
  28. package/dist/src/lib/types.d.ts +2 -2
  29. package/dist/src/lib/ws/connections.d.ts +1 -1
  30. package/package.json +2 -2
  31. package/src/lib/adapters/database/index.ts +0 -12
  32. package/src/lib/adapters/database/mssql/messages.ts +1 -28
  33. package/src/lib/adapters/database/postgres/messages.ts +1 -22
  34. package/src/lib/ai/cc-runtime.ts +7 -7
  35. package/src/lib/ai/client-tools.ts +288 -0
  36. package/src/lib/ai/index.ts +60 -47
  37. package/src/lib/ai/tools/search-tools.tool.ts +2 -2
  38. package/src/lib/ai/turn-tools.ts +7 -8
  39. package/src/lib/cc/client.ts +1 -1
  40. package/src/lib/cc/format.ts +2 -6
  41. package/src/lib/cc/registry.ts +7 -7
  42. package/src/lib/cc/types.ts +1 -1
  43. package/src/lib/config.ts +8 -3
  44. package/src/lib/index.ts +4 -0
  45. package/src/lib/routes/chat.ts +5 -5
  46. package/tsconfig.json +1 -1
  47. package/contracts/graph.ts +0 -36
  48. package/contracts/interactive.ts +0 -53
  49. package/dist/contracts/interactive.d.ts +0 -39
  50. package/dist/src/lib/ai/interactive.d.ts +0 -62
  51. package/src/lib/ai/interactive.ts +0 -364
  52. /package/contracts/{graph → src/graph/clients}/embed.ts +0 -0
  53. /package/contracts/{graph → src/graph/clients}/neo4j.ts +0 -0
  54. /package/contracts/{graph → src/graph}/helpers.ts +0 -0
  55. /package/contracts/{graph → src/graph}/schema.ts +0 -0
  56. /package/contracts/{graph → src/graph}/types.ts +0 -0
  57. /package/contracts/{rich-text.ts → src/rich-text/index.ts} +0 -0
  58. /package/dist/contracts/{graph → src/graph/clients}/embed.d.ts +0 -0
  59. /package/dist/contracts/{graph → src/graph/clients}/neo4j.d.ts +0 -0
  60. /package/dist/contracts/{graph → src/graph}/helpers.d.ts +0 -0
  61. /package/dist/contracts/{graph → src/graph}/schema.d.ts +0 -0
  62. /package/dist/contracts/{graph → src/graph}/types.d.ts +0 -0
  63. /package/dist/contracts/{rich-text.d.ts → src/rich-text/index.d.ts} +0 -0
@@ -0,0 +1,288 @@
1
+ import { HTTPException } from "hono/http-exception";
2
+ import { CLIENT_TOOL_ANSWERS_KEY } from "@cortex/contracts/wire";
3
+ import type {
4
+ ClientToolAnswer,
5
+ ClientToolBinding,
6
+ ClientToolInitiateResult,
7
+ } from "@cortex/contracts/wire";
8
+ import type { ResolvedCortexAgentConfig } from "@/config";
9
+ import type { ChatMessage, Thread } from "@/types";
10
+ import { ControlCenterClient } from "@/cc/client";
11
+ import type { CcRuntime } from "@/cc/registry";
12
+ import { recordRecentCcTool } from "@/cc/registry";
13
+
14
+ /**
15
+ * The wire-shape bindings stamped on the turn's final assistant message, so a
16
+ * parked client tool call can be initiated after any restart. Only names that
17
+ * actually won declaration merging are stamped — a call to a colliding
18
+ * consumer-owned tool must never be treated as a client tool. Undefined when
19
+ * nothing qualifies, keeping metadata lean.
20
+ */
21
+ export function clientToolBindings(cc: CcRuntime | undefined, takenNames: ReadonlySet<string>) {
22
+ if (!cc) return undefined;
23
+ const entries = [...cc.clientTools]
24
+ .filter(([name]) => !takenNames.has(name))
25
+ .map(
26
+ ([name, tool]) =>
27
+ [name, { toolId: tool.toolId, ...tool.embed } satisfies ClientToolBinding] as const,
28
+ );
29
+ return entries.length > 0 ? Object.fromEntries(entries) : undefined;
30
+ }
31
+
32
+ /** Null when the agent has no Control Center — every caller treats that as "skip". */
33
+ export function createCcClient(config: ResolvedCortexAgentConfig) {
34
+ return config.controlCenter ? new ControlCenterClient(config.controlCenter) : null;
35
+ }
36
+
37
+ /**
38
+ * The `clientToolAnswers` a continuation request carried under
39
+ * `forwardedProps`, validated structurally — the widget is not the only
40
+ * possible author of a request body. All-or-nothing: `[]` means the request
41
+ * carried no answers at all, while `null` means it carried a payload with any
42
+ * malformed entry — the caller must not act on such a request, neither by
43
+ * settling its valid remainder nor by running a turn for it.
44
+ */
45
+ export function clientToolAnswersFrom(forwardedProps: Record<string, unknown>) {
46
+ const raw = forwardedProps[CLIENT_TOOL_ANSWERS_KEY];
47
+ if (raw === undefined) return [];
48
+ if (!Array.isArray(raw)) return null;
49
+
50
+ const answers = raw.flatMap((item) => {
51
+ const record = asRecord(item);
52
+ if (!record || typeof record.toolCallId !== "string" || !("output" in record)) return [];
53
+ const state = record.state;
54
+ if (state !== "complete" && state !== "error") return [];
55
+ return [
56
+ {
57
+ toolCallId: record.toolCallId,
58
+ output: record.output,
59
+ state,
60
+ } satisfies ClientToolAnswer,
61
+ ];
62
+ });
63
+ return answers.length === raw.length ? answers : null;
64
+ }
65
+
66
+ /**
67
+ * Applies client tool answers to the stored messages carrying their calls,
68
+ * mirroring the SDK's own settle shape (call part gains output + state, a
69
+ * tool-result part is appended). Already-settled calls are skipped, so a
70
+ * replayed continuation is a no-op. Returns only the messages that changed.
71
+ */
72
+ export function answerStoredToolCalls(stored: ChatMessage[], answers: ClientToolAnswer[]) {
73
+ const byCallId = new Map(answers.map((answer) => [answer.toolCallId, answer]));
74
+
75
+ return stored.flatMap((message) => {
76
+ const pending = message.parts.filter(
77
+ (part) =>
78
+ part.type === "tool-call" &&
79
+ byCallId.has(part.id) &&
80
+ part.state !== "complete" &&
81
+ part.state !== "error",
82
+ );
83
+ if (pending.length === 0) return [];
84
+
85
+ let parts = message.parts;
86
+ for (const part of pending) {
87
+ if (part.type !== "tool-call") continue;
88
+ const answer = byCallId.get(part.id)!;
89
+ const content =
90
+ typeof answer.output === "string" ? answer.output : JSON.stringify(answer.output);
91
+ parts = parts
92
+ .map((candidate) =>
93
+ candidate === part
94
+ ? { ...candidate, output: answer.output, state: answer.state }
95
+ : candidate,
96
+ )
97
+ .concat([
98
+ { type: "tool-result", toolCallId: part.id, content, state: answer.state },
99
+ ]);
100
+ }
101
+ return [{ ...message, parts }];
102
+ });
103
+ }
104
+
105
+ /**
106
+ * Whether an assistant reply already follows the message carrying the
107
+ * answered calls — the mark of a continuation that completed. A settled call
108
+ * with nothing after it means the continuation died before replying, so a
109
+ * retried delivery must run rather than be discarded as a replay. Ids that
110
+ * match no stored call count as replied: there is nothing to run for them.
111
+ */
112
+ function repliedAfterAnswers(stored: ChatMessage[], answers: ClientToolAnswer[]) {
113
+ const ids = new Set(answers.map((answer) => answer.toolCallId));
114
+ const index = stored.findLastIndex((message) =>
115
+ message.parts.some((part) => part.type === "tool-call" && ids.has(part.id)),
116
+ );
117
+ if (index === -1) return true;
118
+ return stored.slice(index + 1).some((message) => message.role === "assistant");
119
+ }
120
+
121
+ /**
122
+ * The server-authoritative half of a client tool continuation: the widget
123
+ * sends `messages: []` plus the answers, and the transcript is updated in
124
+ * place — the answered assistant message never round-trips, so the id the
125
+ * SDK's park-boundary snapshot failed to preserve no longer matters.
126
+ * `settled` is how many messages the answers changed; with `replied` it lets
127
+ * the caller tell a true replay (nothing settled, reply already stored) from
128
+ * a retry after a continuation that died before replying.
129
+ */
130
+ export async function applyClientToolAnswers(
131
+ config: ResolvedCortexAgentConfig,
132
+ userId: string,
133
+ threadId: string,
134
+ answers: ClientToolAnswer[],
135
+ ) {
136
+ const stored = (await config.db.messages.list(userId, threadId)).map((row) => row.content);
137
+
138
+ // An id matching no stored call — or repeated within the payload — voids
139
+ // the whole request: nothing settles, not even a valid remainder. A
140
+ // retry's ids always exist (settled by the attempt that died) and never
141
+ // repeat, so only a broken or forged request looks like this.
142
+ const storedCallIds = new Set(
143
+ stored.flatMap((message) =>
144
+ message.parts.flatMap((part) => (part.type === "tool-call" ? [part.id] : [])),
145
+ ),
146
+ );
147
+ const ids = answers.map((answer) => answer.toolCallId);
148
+ if (new Set(ids).size !== ids.length || !ids.every((id) => storedCallIds.has(id))) {
149
+ return { settled: 0, replied: true };
150
+ }
151
+
152
+ const updated = answerStoredToolCalls(stored, answers);
153
+ if (updated.length) await config.db.messages.upsert(threadId, updated);
154
+ return { settled: updated.length, replied: repliedAfterAnswers(stored, answers) };
155
+ }
156
+
157
+ /** How long initiate waits out the turn-commit race before giving up. */
158
+ const PENDING_CALL_RETRIES = 6;
159
+ const PENDING_CALL_RETRY_MS = 700;
160
+
161
+ /**
162
+ * The widget initiates the moment its stream goes idle, which can beat the
163
+ * turn's commit to the database — so a missing call is retried briefly before
164
+ * it becomes a 404. An *answered* call 404s immediately: it was settled
165
+ * elsewhere (another tab) and the widget should fall back to its pill.
166
+ */
167
+ async function findPendingClientToolCall(
168
+ config: ResolvedCortexAgentConfig,
169
+ userId: string,
170
+ threadId: string,
171
+ toolCallId: string,
172
+ ) {
173
+ for (let attempt = 0; ; attempt += 1) {
174
+ const stored = await config.db.messages.list(userId, threadId);
175
+ const messages = stored.map((row) => row.content);
176
+ const answered = messages.some((candidate) =>
177
+ candidate.parts.some(
178
+ (part) => part.type === "tool-result" && part.toolCallId === toolCallId,
179
+ ),
180
+ );
181
+ if (answered) return null;
182
+
183
+ for (const message of messages) {
184
+ const call = message.parts.find(
185
+ (part) => part.type === "tool-call" && part.id === toolCallId,
186
+ );
187
+ if (call?.type === "tool-call") return { message, call };
188
+ }
189
+
190
+ if (attempt >= PENDING_CALL_RETRIES) return null;
191
+ await new Promise((resolve) => setTimeout(resolve, PENDING_CALL_RETRY_MS));
192
+ }
193
+ }
194
+
195
+ /**
196
+ * `POST /chat/:chatId/tools/:toolCallId/initiate` — runs the interactive
197
+ * tool's initiate call (the tool's own endpoint) and hands the widget its
198
+ * embed payload. The model never sees this payload. The idempotency key is
199
+ * pinned to the tool call, so a reload mid-flow reuses the created session
200
+ * instead of opening a second one. The result the page later posts back is
201
+ * relayed to the agent as-is — verifying it is the integrating backend's job.
202
+ */
203
+ export async function initiateClientTool(options: {
204
+ config: ResolvedCortexAgentConfig;
205
+ thread: Thread;
206
+ userId: string;
207
+ token: string;
208
+ toolCallId: string;
209
+ }) {
210
+ const { config, thread, userId, token, toolCallId } = options;
211
+ const pending = await findPendingClientToolCall(config, userId, thread.id, toolCallId);
212
+ if (!pending) {
213
+ throw new HTTPException(404, { message: "No pending tool call with this id" });
214
+ }
215
+ const { message, call } = pending;
216
+
217
+ const binding = message.metadata?.clientTools?.[call.name];
218
+ const ccClient = createCcClient(config);
219
+ if (!binding || !ccClient) {
220
+ return { embed: false } satisfies ClientToolInitiateResult;
221
+ }
222
+
223
+ const result = await ccClient.execute(
224
+ config.agentId,
225
+ binding.toolId,
226
+ {
227
+ input: asRecord(call.input) ?? asRecord(parseJson(call.arguments)) ?? {},
228
+ context: { threadId: thread.id, userId },
229
+ },
230
+ {
231
+ readOnly: false,
232
+ endUserToken: token,
233
+ threadId: thread.id,
234
+ turnKey: toolCallId,
235
+ stepIndex: 0,
236
+ callIndex: 0,
237
+ idempotencyKey: `${thread.id}:${toolCallId}:initiate`,
238
+ },
239
+ );
240
+ if (!result) {
241
+ throw new HTTPException(502, { message: "The tool is temporarily unavailable" });
242
+ }
243
+ if ("error" in result) {
244
+ throw new HTTPException(502, { message: `initiate failed: ${result.error.kind}` });
245
+ }
246
+
247
+ const embedUrl = asRecord(result.output)?.embedUrl;
248
+ if (typeof embedUrl !== "string" || !isOnOrigin(embedUrl, binding.embedOrigin)) {
249
+ throw new HTTPException(502, {
250
+ message: "initiate did not return an embedUrl on the configured origin",
251
+ });
252
+ }
253
+
254
+ recordRecentCcTool(thread.id, call.name);
255
+
256
+ return {
257
+ embed: true,
258
+ embedUrl,
259
+ surface: binding.surface,
260
+ embedOrigin: binding.embedOrigin,
261
+ } satisfies ClientToolInitiateResult;
262
+ }
263
+
264
+ function parseJson(value: string | undefined) {
265
+ if (value === undefined) return undefined;
266
+ try {
267
+ return JSON.parse(value) as unknown;
268
+ } catch {
269
+ return undefined;
270
+ }
271
+ }
272
+
273
+ function asRecord(value: unknown) {
274
+ return typeof value === "object" && value !== null && !Array.isArray(value)
275
+ ? (value as Record<string, unknown>)
276
+ : undefined;
277
+ }
278
+
279
+ function isOnOrigin(url: string, origin: string) {
280
+ try {
281
+ const parsed = new URL(url);
282
+ // http(s) only: javascript:/data: URLs normalize to origin "null",
283
+ // which a bare equality check could otherwise be tricked into passing.
284
+ return ["http:", "https:"].includes(parsed.protocol) && parsed.origin === origin;
285
+ } catch {
286
+ return false;
287
+ }
288
+ }
@@ -29,13 +29,14 @@ import {
29
29
  buildTurnTools,
30
30
  createToolInstrumentation,
31
31
  hasDefaultAttachmentInterceptor,
32
- interactiveToolDeclarations,
32
+ clientToolDeclarations,
33
33
  } from "./turn-tools";
34
34
  import {
35
+ applyClientToolAnswers,
36
+ clientToolAnswersFrom,
35
37
  createCcClient,
36
- interactiveToolBindings,
37
- settleInteractiveToolResults,
38
- } from "./interactive";
38
+ clientToolBindings,
39
+ } from "./client-tools";
39
40
  import { createInspector } from "./inspector";
40
41
  import { commitBeforeTerminal } from "./commit-gate";
41
42
  import { finishTurn } from "./finish-turn";
@@ -57,33 +58,12 @@ export async function startTurn(
57
58
  requestContext: Record<string, unknown>,
58
59
  config: ResolvedCortexAgentConfig,
59
60
  ) {
61
+ if (await absorbContinuationAnswers(params, config, userId, thread.id)) return null;
62
+
60
63
  const run = await startRun(thread.id);
61
64
  try {
62
- // The client owns its own messages, and the assistant turn it sends back
63
- // once a client-side tool has answered — without that answer stored, the
64
- // next turn replays an assistant message whose tool calls nothing ever
65
- // resolved, and the provider rejects the thread from there on. Everything
66
- // else the server generated stays the server's; the repository keeps the
67
- // usage and model id only it ever knew.
68
- const incoming = params.messages
69
- .map((message) => toChatMessage(normalizeToUIMessage(message, generateMessageId)))
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.
65
+ const lastUserMessage = await absorbIncoming(params, config, userId, thread.id);
75
66
  const ccClient = createCcClient(config);
76
- await settleInteractiveToolResults({
77
- messages: incoming,
78
- thread,
79
- userId,
80
- token,
81
- config,
82
- ccClient,
83
- });
84
- await config.db.messages.upsert(thread.id, incoming);
85
-
86
- const lastUserMessage = incoming.findLast((message) => message.role === "user");
87
67
  const prompt = textOf(lastUserMessage);
88
68
  if (thread.title === null && prompt) {
89
69
  void generateTitle(thread.id, prompt, userId, config);
@@ -234,12 +214,12 @@ export async function startTurn(
234
214
  const stream = chat({
235
215
  adapter: model,
236
216
  // Client tool declarations ride in on every request, so the server
237
- // never re-declares them. Interactive CC tools join them: also
217
+ // never re-declares them. CC client tools join them: also
238
218
  // executor-less, answered by the widget. Consumer-owned names win
239
- // the collision, and only names that won are treated as interactive.
219
+ // the collision, and only names that won are treated as a client tool.
240
220
  tools: mergeAgentTools(tools, [
241
221
  ...params.tools,
242
- ...interactiveToolDeclarations(cc, consumerToolNames),
222
+ ...clientToolDeclarations(cc, consumerToolNames),
243
223
  ]),
244
224
  messages: convertMessagesToModelMessages(
245
225
  fitToContextWindow(contextMessages, systemPrompt, tools, config),
@@ -292,9 +272,9 @@ export async function startTurn(
292
272
  modelId: inspector.modelId,
293
273
  isAborted: run.abortController.signal.aborted,
294
274
  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),
275
+ // Stamped so a parked client tool call can be initiated
276
+ // after any reload or server restart.
277
+ clientTools: clientToolBindings(cc, consumerToolNames),
298
278
  } satisfies MessageMetadata,
299
279
  };
300
280
  }
@@ -328,20 +308,53 @@ function toChatMessage(message: UIMessage) {
328
308
  }
329
309
 
330
310
  /**
331
- * An assistant turn the client is sending back because it resolved a tool call.
332
- *
333
- * The wire fans that turn out into a `role: "tool"` message per result, and each
334
- * of those normalizes back into an assistant message carrying only the result,
335
- * under a freshly generated id. Stored as rows of their own they would replay as
336
- * assistant turns answering calls that no message makes, so the turn is picked
337
- * out by holding both halves of the exchange.
311
+ * Applies a parked client tool's answers to the stored messages carrying the
312
+ * calls — before the thread is claimed, because applying is idempotent and
313
+ * needs no run. True means the request was a stale replay: it settled
314
+ * nothing, the reply it would produce is already stored, and it carries no
315
+ * user message so there is no turn to run, and claiming would only abort
316
+ * the legitimate run it duplicates. A retry whose earlier continuation died
317
+ * before replying settles nothing too, but has no stored reply, so it runs.
318
+ * A malformed payload voids the whole request the same way: nothing settles
319
+ * and no turn runs, so a broken client can neither partially settle a turn's
320
+ * calls nor mint an empty-message model turn.
338
321
  */
339
- function answersToolCalls(message: ChatMessage) {
340
- return (
341
- message.role === "assistant" &&
342
- message.parts.some((part) => part.type === "tool-call") &&
343
- message.parts.some((part) => part.type === "tool-result")
344
- );
322
+ async function absorbContinuationAnswers(
323
+ params: TurnParams,
324
+ config: ResolvedCortexAgentConfig,
325
+ userId: string,
326
+ threadId: string,
327
+ ) {
328
+ const answers = clientToolAnswersFrom(params.forwardedProps);
329
+ if (answers === null) return true;
330
+ if (!answers.length) return false;
331
+
332
+ const { settled, replied } = await applyClientToolAnswers(config, userId, threadId, answers);
333
+ return settled === 0 && replied && !params.messages.some((message) => message.role === "user");
334
+ }
335
+
336
+ /**
337
+ * What a request contributes to the stored transcript: the user messages the
338
+ * client wrote — nothing else, because the server owns history (a parked
339
+ * client tool's answers go through `absorbContinuationAnswers`; the answered
340
+ * assistant turn itself never crosses the wire, because the SDK's
341
+ * park-boundary snapshot does not preserve its message id and round-tripping
342
+ * it duplicated the stored message). Returns the newest user message, the
343
+ * turn's prompt, when the request carried one.
344
+ */
345
+ async function absorbIncoming(
346
+ params: TurnParams,
347
+ config: ResolvedCortexAgentConfig,
348
+ userId: string,
349
+ threadId: string,
350
+ ) {
351
+ const incoming = params.messages
352
+ .map((message) => toChatMessage(normalizeToUIMessage(message, generateMessageId)))
353
+ .filter((message) => message.role === "user");
354
+
355
+ if (incoming.length) await config.db.messages.upsert(threadId, incoming);
356
+
357
+ return incoming.findLast((message) => message.role === "user");
345
358
  }
346
359
 
347
360
  function textOf(message: ChatMessage | undefined) {
@@ -20,10 +20,10 @@ 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
- // Interactive tools cannot join a turn already underway (the model's
23
+ // Client tools cannot join a turn already underway (the model's
24
24
  // tool set is fixed at turn start), so surfacing them here would only
25
25
  // advertise dead ends.
26
- const callable = result.tools.filter((tool) => !tool.interaction);
26
+ const callable = result.tools.filter((tool) => !tool.embed);
27
27
  if (callable.length === 0) return "No matching tools found.";
28
28
 
29
29
  const shapes =
@@ -29,7 +29,9 @@ type TurnToolsOptions = {
29
29
  * configured, then the agent's own tools.
30
30
  *
31
31
  * User tools are appended last and deliberately win on name collision — an agent
32
- * must be able to replace a built-in it doesn't want.
32
+ * must be able to replace a built-in it doesn't want. A user tool without an
33
+ * `execute` function is a static client tool: it passes through to the model
34
+ * declaration untouched, and the runtime streams its calls to the widget.
33
35
  */
34
36
  export function buildTurnTools(options: TurnToolsOptions) {
35
37
  const { config, cc, neo4j, thread, userId, token, session, requestContext, threadAttachments } =
@@ -111,20 +113,17 @@ export function createToolInstrumentation(
111
113
  }
112
114
 
113
115
  /**
114
- * Interactive CC tools are declared like the request's client tools: no
116
+ * CC client tools are declared like the request's client tools: no
115
117
  * executor, so the call streams to the widget as a tool-call part and the run
116
118
  * parks until the widget answers. The declaration carries the tool's published
117
119
  * input schema — function-calling models ignore schemas described only in
118
120
  * prose — with a permissive object as the fallback for older Control Centers.
119
121
  * 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.
122
+ * consumer's tool, and must neither be declared nor stamped as client tools.
121
123
  */
122
- export function interactiveToolDeclarations(
123
- cc: CcRuntime | undefined,
124
- takenNames: ReadonlySet<string>,
125
- ) {
124
+ export function clientToolDeclarations(cc: CcRuntime | undefined, takenNames: ReadonlySet<string>) {
126
125
  if (!cc) return [];
127
- return [...cc.interactiveTools]
126
+ return [...cc.clientTools]
128
127
  .filter(([name]) => !takenNames.has(name))
129
128
  .map(([name, tool]) => ({
130
129
  name,
@@ -22,7 +22,7 @@ export type ExecuteOptions = {
22
22
  stepIndex: number;
23
23
  callIndex: number;
24
24
  /** Overrides the composed key — used where stability must outlive the turn
25
- * counters, e.g. one interactive initiate per tool call across reloads. */
25
+ * counters, e.g. one initiate per tool call across reloads. */
26
26
  idempotencyKey?: string;
27
27
  timeoutMs?: number;
28
28
  abortSignal?: AbortSignal;
@@ -66,17 +66,13 @@ export function buildCcSection(cc: CcPromptInput) {
66
66
  const prompt = substituteVariables(cc.config.systemPrompt, cc.variables).trim();
67
67
  if (prompt) parts.push(prompt);
68
68
 
69
- if (cc.config.catalogBlurb) {
70
- parts.push(`## Capabilities\n${cc.config.catalogBlurb}`);
71
- }
72
-
73
69
  if (cc.resolved) {
74
70
  if (cc.resolved.sharedShapes.length > 0) {
75
71
  parts.push(`## Response Shapes\n${formatSharedShapes(cc.resolved.sharedShapes)}`);
76
72
  }
77
- // Interactive tools are declared to the model as real tools, so they
73
+ // Client tools are declared to the model as real tools, so they
78
74
  // stay out of the sandbox-only Dynamic Tools section.
79
- const dynamicTools = cc.resolved.tools.filter((tool) => !tool.interaction);
75
+ const dynamicTools = cc.resolved.tools.filter((tool) => !tool.embed);
80
76
  if (dynamicTools.length > 0) {
81
77
  parts.push(
82
78
  "## Dynamic Tools\nCall these from executeCode via the `tools` global, e.g. `await tools.name(input)`.\n\n" +
@@ -1,5 +1,5 @@
1
1
  import type { ControlCenterClient } from "./client";
2
- import type { RuntimeAgentConfig, ToolInteraction } from "./types";
2
+ import type { RuntimeAgentConfig, ToolEmbed } from "./types";
3
3
 
4
4
  export type CcToolBinding = {
5
5
  toolId: string;
@@ -7,14 +7,14 @@ export type CcToolBinding = {
7
7
  };
8
8
 
9
9
  /**
10
- * An interactive tool resolved for this turn. Declared to the model as a real
10
+ * A client tool resolved for this turn. Declared to the model as a real
11
11
  * client-executed tool — never a sandbox binding — so its call streams to the
12
12
  * widget as a tool-call part and parks the run until the flow settles.
13
13
  */
14
- export type CcInteractiveTool = {
14
+ export type CcClientTool = {
15
15
  toolId: string;
16
16
  signature: string;
17
- interaction: ToolInteraction;
17
+ embed: ToolEmbed;
18
18
  inputSchema?: Record<string, unknown>;
19
19
  };
20
20
 
@@ -26,10 +26,10 @@ export type CcToolRegistry = Map<string, CcToolBinding>;
26
26
 
27
27
  export function registerCcTools(
28
28
  registry: CcToolRegistry,
29
- tools: { name: string; toolId: string; readOnly: boolean; interaction?: ToolInteraction }[],
29
+ tools: { name: string; toolId: string; readOnly: boolean; embed?: ToolEmbed }[],
30
30
  ) {
31
31
  for (const tool of tools) {
32
- if (tool.interaction) continue;
32
+ if (tool.embed) continue;
33
33
  registry.set(tool.name, { toolId: tool.toolId, readOnly: tool.readOnly });
34
34
  }
35
35
  }
@@ -63,7 +63,7 @@ export type CcRuntime = {
63
63
  agentId: string;
64
64
  config: RuntimeAgentConfig;
65
65
  registry: CcToolRegistry;
66
- interactiveTools: Map<string, CcInteractiveTool>;
66
+ clientTools: Map<string, CcClientTool>;
67
67
  threadId: string;
68
68
  turnKey: string;
69
69
  userId: string;
@@ -17,7 +17,7 @@ export {
17
17
  type ResolveResponse,
18
18
  type RuntimeAgentConfig,
19
19
  type SearchRequest,
20
- type ToolInteraction,
20
+ type ToolEmbed,
21
21
  } from "@cortex/contracts/runtime";
22
22
 
23
23
  const knownRuntimeErrorKinds = new Set<string>(RUNTIME_ERROR_KINDS);
package/src/lib/config.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { AnyServerTool } from "@tanstack/ai";
1
+ import type { AnyClientTool, AnyServerTool } from "@tanstack/ai";
2
2
  import type { DatabaseAdapter } from "./adapters/database/index";
3
3
  import type { StorageAdapter } from "./adapters/storage/index";
4
4
  import type { DomainDef } from "@cortex/contracts/graph";
@@ -6,8 +6,13 @@ import type { RequestInterceptorOptions } from "./ai/interceptors/request-interc
6
6
  import type { ContextConfig } from "./ai/context/types";
7
7
  import type { ChatMessage, Thread } from "./types";
8
8
 
9
- /** An agent's tools, as `chat()` takes them. */
10
- export type ToolSet = ReadonlyArray<AnyServerTool>;
9
+ /**
10
+ * An agent's tools, as `chat()` takes them. A tool with an `execute` function
11
+ * runs on the server; one without is a static client tool — declared to the
12
+ * model, streamed to the widget as a tool-call part, and answered by the host
13
+ * app (`hooks.onToolCall` or a `toolComponents` entry with `setOutput`).
14
+ */
15
+ export type ToolSet = ReadonlyArray<AnyServerTool | AnyClientTool>;
11
16
 
12
17
  type ModelConfig = {
13
18
  baseURL: string;
package/src/lib/index.ts CHANGED
@@ -33,6 +33,10 @@ export type {
33
33
  } from "./ai/interceptors/request-interceptor";
34
34
  export { createRequestInterceptor } from "./ai/interceptors/request-interceptor";
35
35
 
36
+ // Re-exported so consumers define tools (server or static client) without a
37
+ // direct @tanstack/ai import.
38
+ export { toolDefinition } from "@tanstack/ai";
39
+
36
40
  // Tools (consumers may register custom tools or use built-in ones)
37
41
  export { createQueryGraphTool } from "./ai/tools/query-graph.tool";
38
42
  export { createExecuteCodeTool } from "./ai/tools/execute-code.tool";
@@ -6,7 +6,7 @@ import { HTTPException } from "hono/http-exception";
6
6
  import type { CortexAppEnv } from "@/types";
7
7
  import { requireAuth } from "@/auth/middleware";
8
8
  import { startTurn } from "@/ai/index";
9
- import { initiateInteractiveTool } from "@/ai/interactive";
9
+ import { initiateClientTool } from "@/ai/client-tools";
10
10
  import { abortRun, getRunId, joinLog, offsetBelongsToRun } from "@/ai/active-runs";
11
11
  import { notify } from "@/ws/connections";
12
12
  import { requireOwnedThread } from "./owned-thread";
@@ -93,9 +93,9 @@ export function createChatRoutes() {
93
93
  });
94
94
 
95
95
  /**
96
- * Runs an interactive tool's initiate call for a pending tool call and
97
- * returns the widget's embed payload. `interactive: false` tells the
98
- * widget the call is not an interactive CC tool — fall back to its
96
+ * Runs a client tool's initiate call for a pending tool call and
97
+ * returns the widget's embed payload. `embed: false` tells the
98
+ * widget the call is not an CC client tool — fall back to its
99
99
  * default rendering. The payload never reaches the model.
100
100
  */
101
101
  app.post("/chat/:chatId/tools/:toolCallId/initiate", requireAuth, async function (c) {
@@ -104,7 +104,7 @@ export function createChatRoutes() {
104
104
  const thread = await requireOwnedThread(c, c.req.param("chatId"));
105
105
 
106
106
  return c.json(
107
- await initiateInteractiveTool({
107
+ await initiateClientTool({
108
108
  config,
109
109
  thread,
110
110
  userId,
package/tsconfig.json CHANGED
@@ -23,7 +23,7 @@
23
23
  // `vendor` copies them into ./contracts for build and pack. The first
24
24
  // candidate exists only then (and in the published tarball) — inside
25
25
  // the repo resolution falls through to the workspace source.
26
- "@cortex/contracts/*": ["./contracts/*", "../../internal/contracts/*"],
26
+ "@cortex/contracts/*": ["./contracts/src/*", "../../internal/contracts/src/*"],
27
27
  // Internal-only. tsc does not rewrite aliases on emit, so `build` runs
28
28
  // tsc-alias to turn these back into relative paths in dist/**/*.d.ts —
29
29
  // a consumer's tsc knows nothing about our `@/`. The published raw src