@m6d/cortex-server 2.0.1 → 2.2.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.
- package/README.md +24 -0
- package/contracts/README.md +22 -8
- package/contracts/src/client-tools/index.ts +56 -0
- package/contracts/src/graph/index.ts +30 -0
- package/contracts/{runtime.ts → src/runtime/index.ts} +18 -0
- package/contracts/{wire.ts → src/wire/index.ts} +44 -0
- package/dist/contracts/{graph.d.ts → src/graph/index.d.ts} +8 -8
- package/dist/contracts/{runtime.d.ts → src/runtime/index.d.ts} +47 -0
- package/dist/contracts/{wire.d.ts → src/wire/index.d.ts} +40 -0
- package/dist/src/lib/adapters/database/message-content.d.ts +1 -0
- package/dist/src/lib/ai/cc-runtime.d.ts +2 -1
- package/dist/src/lib/ai/client-tools.d.ts +84 -0
- package/dist/src/lib/ai/tools/query-graph.tool.d.ts +1 -1
- package/dist/src/lib/ai/turn-tools.d.ts +19 -3
- package/dist/src/lib/cc/client.d.ts +18 -0
- package/dist/src/lib/cc/registry.d.ts +14 -1
- package/dist/src/lib/cc/types.d.ts +1 -1
- package/dist/src/lib/config.d.ts +9 -4
- package/dist/src/lib/graph/index.d.ts +1 -1
- package/dist/src/lib/graph/resolver.d.ts +1 -1
- package/dist/src/lib/index.d.ts +2 -1
- package/dist/src/lib/types.d.ts +2 -2
- package/dist/src/lib/ws/connections.d.ts +1 -1
- package/package.json +6 -3
- package/src/lib/ai/cc-runtime.ts +16 -1
- package/src/lib/ai/client-tools.ts +288 -0
- package/src/lib/ai/index.ts +72 -30
- package/src/lib/ai/tools/search-tools.tool.ts +6 -2
- package/src/lib/ai/turn-tools.ts +23 -1
- package/src/lib/cc/client.ts +5 -1
- package/src/lib/cc/format.ts +5 -2
- package/src/lib/cc/registry.ts +16 -2
- package/src/lib/cc/types.ts +1 -0
- package/src/lib/config.ts +8 -3
- package/src/lib/index.ts +4 -0
- package/src/lib/routes/chat.ts +23 -0
- package/tsconfig.json +1 -1
- package/contracts/graph.ts +0 -36
- /package/contracts/{graph → src/graph/clients}/embed.ts +0 -0
- /package/contracts/{graph → src/graph/clients}/neo4j.ts +0 -0
- /package/contracts/{graph → src/graph}/helpers.ts +0 -0
- /package/contracts/{graph → src/graph}/schema.ts +0 -0
- /package/contracts/{graph → src/graph}/types.ts +0 -0
- /package/contracts/{rich-text.ts → src/rich-text/index.ts} +0 -0
- /package/dist/contracts/{graph → src/graph/clients}/embed.d.ts +0 -0
- /package/dist/contracts/{graph → src/graph/clients}/neo4j.d.ts +0 -0
- /package/dist/contracts/{graph → src/graph}/helpers.d.ts +0 -0
- /package/dist/contracts/{graph → src/graph}/schema.d.ts +0 -0
- /package/dist/contracts/{graph → src/graph}/types.d.ts +0 -0
- /package/dist/contracts/{rich-text.d.ts → src/rich-text/index.d.ts} +0 -0
|
@@ -1,9 +1,20 @@
|
|
|
1
1
|
import type { ControlCenterClient } from "./client";
|
|
2
|
-
import type { RuntimeAgentConfig } from "./types";
|
|
2
|
+
import type { RuntimeAgentConfig, ToolEmbed } from "./types";
|
|
3
3
|
export type CcToolBinding = {
|
|
4
4
|
toolId: string;
|
|
5
5
|
readOnly: boolean;
|
|
6
6
|
};
|
|
7
|
+
/**
|
|
8
|
+
* A client tool resolved for this turn. Declared to the model as a real
|
|
9
|
+
* client-executed tool — never a sandbox binding — so its call streams to the
|
|
10
|
+
* widget as a tool-call part and parks the run until the flow settles.
|
|
11
|
+
*/
|
|
12
|
+
export type CcClientTool = {
|
|
13
|
+
toolId: string;
|
|
14
|
+
signature: string;
|
|
15
|
+
embed: ToolEmbed;
|
|
16
|
+
inputSchema?: Record<string, unknown>;
|
|
17
|
+
};
|
|
7
18
|
/**
|
|
8
19
|
* Turn-scoped and mutable: seeded from /resolve, extended by searchTools
|
|
9
20
|
* mid-turn so a tool discovered at step 3 is callable at step 4.
|
|
@@ -13,6 +24,7 @@ export declare function registerCcTools(registry: CcToolRegistry, tools: {
|
|
|
13
24
|
name: string;
|
|
14
25
|
toolId: string;
|
|
15
26
|
readOnly: boolean;
|
|
27
|
+
embed?: ToolEmbed;
|
|
16
28
|
}[]): void;
|
|
17
29
|
export declare function recordRecentCcTool(threadId: string, name: string): void;
|
|
18
30
|
export declare function getRecentCcTools(threadId: string): string[];
|
|
@@ -22,6 +34,7 @@ export type CcRuntime = {
|
|
|
22
34
|
agentId: string;
|
|
23
35
|
config: RuntimeAgentConfig;
|
|
24
36
|
registry: CcToolRegistry;
|
|
37
|
+
clientTools: Map<string, CcClientTool>;
|
|
25
38
|
threadId: string;
|
|
26
39
|
turnKey: string;
|
|
27
40
|
userId: string;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import z from "zod";
|
|
2
|
-
export { AGENT_SLUG_PATTERN, executeResponseSchema, resolveResponseSchema, runtimeAgentConfigSchema, searchKnowledgeResponseSchema, searchServicesResponseSchema, searchToolsResponseSchema, type ExecuteRequest, type ResolveRequest, type ResolveResponse, type RuntimeAgentConfig, type SearchRequest, } from "../../../contracts/runtime";
|
|
2
|
+
export { AGENT_SLUG_PATTERN, executeResponseSchema, resolveResponseSchema, runtimeAgentConfigSchema, searchKnowledgeResponseSchema, searchServicesResponseSchema, searchToolsResponseSchema, type ExecuteRequest, type ResolveRequest, type ResolveResponse, type RuntimeAgentConfig, type SearchRequest, type ToolEmbed, } from "../../../contracts/src/runtime";
|
|
3
3
|
/**
|
|
4
4
|
* The contract's error envelope, hardened for this consumer: a newer runtime may
|
|
5
5
|
* answer with an error kind this build has never heard of, which must degrade to a
|
package/dist/src/lib/config.d.ts
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
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
|
-
import type { DomainDef } from "../../contracts/graph";
|
|
4
|
+
import type { DomainDef } from "../../contracts/src/graph";
|
|
5
5
|
import type { RequestInterceptorOptions } from "./ai/interceptors/request-interceptor";
|
|
6
6
|
import type { ContextConfig } from "./ai/context/types";
|
|
7
7
|
import type { ChatMessage, Thread } from "./types";
|
|
8
|
-
/**
|
|
9
|
-
|
|
8
|
+
/**
|
|
9
|
+
* An agent's tools, as `chat()` takes them. A tool with an `execute` function
|
|
10
|
+
* runs on the server; one without is a static client tool — declared to the
|
|
11
|
+
* model, streamed to the widget as a tool-call part, and answered by the host
|
|
12
|
+
* app (`hooks.onToolCall` or a `toolComponents` entry with `setOutput`).
|
|
13
|
+
*/
|
|
14
|
+
export type ToolSet = ReadonlyArray<AnyServerTool | AnyClientTool>;
|
|
10
15
|
type ModelConfig = {
|
|
11
16
|
baseURL: string;
|
|
12
17
|
apiKey: string;
|
|
@@ -5,6 +5,6 @@
|
|
|
5
5
|
* re-exported here so authoring a project's domains still needs exactly one
|
|
6
6
|
* import path — nothing about the public surface moved.
|
|
7
7
|
*/
|
|
8
|
-
export * from "../../../contracts/graph";
|
|
8
|
+
export * from "../../../contracts/src/graph";
|
|
9
9
|
export type { RerankerConfig, ResolverConfig, ResolvedEndpoint, ResolvedService, ResolvedContext, } from "./resolver";
|
|
10
10
|
export { resolveFromGraph } from "./resolver";
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* All configuration is passed explicitly — no hardcoded model references
|
|
6
6
|
* or environment variables.
|
|
7
7
|
*/
|
|
8
|
-
import { type EmbedFn, type Neo4jClient } from "../../../contracts/graph";
|
|
8
|
+
import { type EmbedFn, type Neo4jClient } from "../../../contracts/src/graph";
|
|
9
9
|
export type RerankerConfig = {
|
|
10
10
|
url: string;
|
|
11
11
|
apiKey: string;
|
package/dist/src/lib/index.d.ts
CHANGED
|
@@ -7,7 +7,8 @@ export type { CortexInstance } from "./factory";
|
|
|
7
7
|
export { createCortex } from "./factory";
|
|
8
8
|
export type { ResolvedFile, RequestInterceptorOptions, } from "./ai/interceptors/request-interceptor";
|
|
9
9
|
export { createRequestInterceptor } from "./ai/interceptors/request-interceptor";
|
|
10
|
+
export { toolDefinition } from "@tanstack/ai";
|
|
10
11
|
export { createQueryGraphTool } from "./ai/tools/query-graph.tool";
|
|
11
12
|
export { createExecuteCodeTool } from "./ai/tools/execute-code.tool";
|
|
12
13
|
export * from "./graph/index";
|
|
13
|
-
export * from "../../contracts/wire";
|
|
14
|
+
export * from "../../contracts/src/wire";
|
package/dist/src/lib/types.d.ts
CHANGED
|
@@ -2,8 +2,8 @@ import type { MessagePart } from "@tanstack/ai";
|
|
|
2
2
|
import type { ResolvedCortexAgentConfig } from "./config";
|
|
3
3
|
import type { InferSelectModel } from "drizzle-orm";
|
|
4
4
|
import type { attachments, messages, threads } from "./db/schema.mssql";
|
|
5
|
-
export type { CortexMessage, MessageMetadata, ThreadSummary, TokenUsage, } from "../../contracts/wire";
|
|
6
|
-
import type { CortexMessage } from "../../contracts/wire";
|
|
5
|
+
export type { CortexMessage, MessageMetadata, ThreadSummary, TokenUsage, } from "../../contracts/src/wire";
|
|
6
|
+
import type { CortexMessage } from "../../contracts/src/wire";
|
|
7
7
|
/** A message as cortex persists and serves it: SDK parts plus cortex's envelope. */
|
|
8
8
|
export type ChatMessage = CortexMessage<MessagePart>;
|
|
9
9
|
export type Thread = InferSelectModel<typeof threads>;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { WSContext } from "hono/ws";
|
|
2
|
-
import type { WsEvent } from "../../../contracts/wire";
|
|
2
|
+
import type { WsEvent } from "../../../contracts/src/wire";
|
|
3
3
|
export declare function addConnection(userId: string, agentId: string, ws: WSContext): void;
|
|
4
4
|
export declare function removeConnection(userId: string, agentId: string, ws: WSContext): void;
|
|
5
5
|
export declare function notify(userId: string, agentId: string, event: WsEvent): void;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m6d/cortex-server",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "Reusable AI agent chat server library for Hono + Bun",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"!src/**/*.mock.ts"
|
|
25
25
|
],
|
|
26
26
|
"scripts": {
|
|
27
|
-
"build": "bun run vendor && tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json && rm -rf contracts",
|
|
27
|
+
"build": "bun run vendor && rm -rf dist && tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json && rm -rf contracts",
|
|
28
28
|
"check": "tsc --noEmit",
|
|
29
29
|
"db:generate:mssql": "drizzle-kit generate --config=src/lib/db/drizzle.config.mssql.ts",
|
|
30
30
|
"db:generate:pg": "drizzle-kit generate --config=src/lib/db/drizzle.config.pg.ts",
|
|
@@ -74,5 +74,8 @@
|
|
|
74
74
|
},
|
|
75
75
|
"publishConfig": {
|
|
76
76
|
"access": "public"
|
|
77
|
-
}
|
|
77
|
+
},
|
|
78
|
+
"releaseWatchPaths": [
|
|
79
|
+
"internal/contracts"
|
|
80
|
+
]
|
|
78
81
|
}
|
package/src/lib/ai/cc-runtime.ts
CHANGED
|
@@ -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 { CcClientTool, 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
|
+
// Client 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 clientTools = new Map<string, CcClientTool>();
|
|
42
|
+
for (const tool of ccResolved?.tools ?? []) {
|
|
43
|
+
if (!tool.embed) continue;
|
|
44
|
+
clientTools.set(tool.name, {
|
|
45
|
+
toolId: tool.toolId,
|
|
46
|
+
signature: tool.signature,
|
|
47
|
+
embed: tool.embed,
|
|
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
|
+
clientTools,
|
|
46
61
|
threadId: thread.id,
|
|
47
62
|
turnKey: options.turnKey,
|
|
48
63
|
userId,
|
|
@@ -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
|
+
}
|
package/src/lib/ai/index.ts
CHANGED
|
@@ -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,14 @@ import {
|
|
|
30
29
|
buildTurnTools,
|
|
31
30
|
createToolInstrumentation,
|
|
32
31
|
hasDefaultAttachmentInterceptor,
|
|
32
|
+
clientToolDeclarations,
|
|
33
33
|
} from "./turn-tools";
|
|
34
|
+
import {
|
|
35
|
+
applyClientToolAnswers,
|
|
36
|
+
clientToolAnswersFrom,
|
|
37
|
+
createCcClient,
|
|
38
|
+
clientToolBindings,
|
|
39
|
+
} from "./client-tools";
|
|
34
40
|
import { createInspector } from "./inspector";
|
|
35
41
|
import { commitBeforeTerminal } from "./commit-gate";
|
|
36
42
|
import { finishTurn } from "./finish-turn";
|
|
@@ -52,20 +58,12 @@ export async function startTurn(
|
|
|
52
58
|
requestContext: Record<string, unknown>,
|
|
53
59
|
config: ResolvedCortexAgentConfig,
|
|
54
60
|
) {
|
|
61
|
+
if (await absorbContinuationAnswers(params, config, userId, thread.id)) return null;
|
|
62
|
+
|
|
55
63
|
const run = await startRun(thread.id);
|
|
56
64
|
try {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
// next turn replays an assistant message whose tool calls nothing ever
|
|
60
|
-
// resolved, and the provider rejects the thread from there on. Everything
|
|
61
|
-
// else the server generated stays the server's; the repository keeps the
|
|
62
|
-
// usage and model id only it ever knew.
|
|
63
|
-
const incoming = params.messages
|
|
64
|
-
.map((message) => toChatMessage(normalizeToUIMessage(message, generateMessageId)))
|
|
65
|
-
.filter((message) => message.role === "user" || answersToolCalls(message));
|
|
66
|
-
await config.db.messages.upsert(thread.id, incoming);
|
|
67
|
-
|
|
68
|
-
const lastUserMessage = incoming.findLast((message) => message.role === "user");
|
|
65
|
+
const lastUserMessage = await absorbIncoming(params, config, userId, thread.id);
|
|
66
|
+
const ccClient = createCcClient(config);
|
|
69
67
|
const prompt = textOf(lastUserMessage);
|
|
70
68
|
if (thread.title === null && prompt) {
|
|
71
69
|
void generateTitle(thread.id, prompt, userId, config);
|
|
@@ -111,7 +109,7 @@ export async function startTurn(
|
|
|
111
109
|
const turnKey = lastUserMessage?.id ?? "0";
|
|
112
110
|
|
|
113
111
|
const inspector = createInspector();
|
|
114
|
-
const { model, embed, neo4j
|
|
112
|
+
const { model, embed, neo4j } = createTurnProviders(config, inspector.fetch);
|
|
115
113
|
|
|
116
114
|
// Run independent operations in parallel
|
|
117
115
|
const [contextResult, resolved, session, ccConfig, ccResolved] = await Promise.all([
|
|
@@ -173,6 +171,10 @@ export async function startTurn(
|
|
|
173
171
|
threadAttachments,
|
|
174
172
|
};
|
|
175
173
|
const tools = buildTurnTools(turnToolsOptions);
|
|
174
|
+
const consumerToolNames = new Set([
|
|
175
|
+
...tools.map((tool) => tool.name),
|
|
176
|
+
...params.tools.map((tool) => tool.name),
|
|
177
|
+
]);
|
|
176
178
|
const instrumentation = createToolInstrumentation({
|
|
177
179
|
...turnToolsOptions,
|
|
178
180
|
onToolStart: (toolName, toolCallId) =>
|
|
@@ -212,8 +214,13 @@ export async function startTurn(
|
|
|
212
214
|
const stream = chat({
|
|
213
215
|
adapter: model,
|
|
214
216
|
// Client tool declarations ride in on every request, so the server
|
|
215
|
-
// never re-declares them.
|
|
216
|
-
|
|
217
|
+
// never re-declares them. CC client tools join them: also
|
|
218
|
+
// executor-less, answered by the widget. Consumer-owned names win
|
|
219
|
+
// the collision, and only names that won are treated as a client tool.
|
|
220
|
+
tools: mergeAgentTools(tools, [
|
|
221
|
+
...params.tools,
|
|
222
|
+
...clientToolDeclarations(cc, consumerToolNames),
|
|
223
|
+
]),
|
|
217
224
|
messages: convertMessagesToModelMessages(
|
|
218
225
|
fitToContextWindow(contextMessages, systemPrompt, tools, config),
|
|
219
226
|
),
|
|
@@ -265,6 +272,9 @@ export async function startTurn(
|
|
|
265
272
|
modelId: inspector.modelId,
|
|
266
273
|
isAborted: run.abortController.signal.aborted,
|
|
267
274
|
tokenUsage: inspector.tokenUsage,
|
|
275
|
+
// Stamped so a parked client tool call can be initiated
|
|
276
|
+
// after any reload or server restart.
|
|
277
|
+
clientTools: clientToolBindings(cc, consumerToolNames),
|
|
268
278
|
} satisfies MessageMetadata,
|
|
269
279
|
};
|
|
270
280
|
}
|
|
@@ -298,20 +308,53 @@ function toChatMessage(message: UIMessage) {
|
|
|
298
308
|
}
|
|
299
309
|
|
|
300
310
|
/**
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
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.
|
|
308
321
|
*/
|
|
309
|
-
function
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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");
|
|
315
358
|
}
|
|
316
359
|
|
|
317
360
|
function textOf(message: ChatMessage | undefined) {
|
|
@@ -338,7 +381,6 @@ function createTurnProviders(config: ResolvedCortexAgentConfig, fetch: typeof gl
|
|
|
338
381
|
model: createModel(config.model, { fetch }),
|
|
339
382
|
embed,
|
|
340
383
|
neo4j: config.neo4j && embed ? createNeo4jClient(config.neo4j, embed) : undefined,
|
|
341
|
-
ccClient: config.controlCenter ? new ControlCenterClient(config.controlCenter) : null,
|
|
342
384
|
};
|
|
343
385
|
}
|
|
344
386
|
|
|
@@ -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
|
-
|
|
23
|
+
// Client 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.embed);
|
|
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(
|
|
31
|
+
return shapes + formatToolSignatures(callable);
|
|
28
32
|
});
|
|
29
33
|
}
|