@m6d/cortex-server 1.7.0 → 2.0.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 +8 -3
- package/contracts/README.md +23 -0
- package/contracts/graph/embed.ts +50 -0
- package/{src → contracts}/graph/helpers.ts +4 -10
- package/{src → contracts}/graph/neo4j.ts +14 -27
- package/contracts/graph/schema.ts +65 -0
- package/{src → contracts}/graph/types.ts +2 -29
- package/contracts/graph.ts +36 -0
- package/contracts/runtime.ts +208 -0
- package/contracts/wire.ts +143 -0
- package/dist/contracts/graph/embed.d.ts +22 -0
- package/dist/{src → contracts}/graph/helpers.d.ts +14 -12
- package/dist/{src → contracts}/graph/neo4j.d.ts +5 -5
- package/dist/contracts/graph/schema.d.ts +62 -0
- package/dist/{src → contracts}/graph/types.d.ts +1 -1
- package/dist/contracts/graph.d.ts +14 -0
- package/dist/contracts/runtime.d.ts +278 -0
- package/dist/contracts/wire.d.ts +120 -0
- package/dist/index.d.ts +1 -1
- package/dist/src/lib/adapters/database/index.d.ts +61 -0
- package/dist/src/lib/adapters/database/message-content.d.ts +30 -0
- package/dist/src/lib/adapters/database/mssql/attachments.d.ts +100 -0
- package/dist/src/lib/adapters/database/mssql/client.d.ts +8 -0
- package/dist/src/lib/adapters/database/mssql/index.d.ts +194 -0
- package/dist/src/lib/adapters/database/mssql/llm-requests.d.ts +17 -0
- package/dist/src/lib/adapters/database/mssql/messages.d.ts +37 -0
- package/dist/src/lib/adapters/database/mssql/threads.d.ts +50 -0
- package/dist/src/lib/adapters/database/postgres/attachments.d.ts +113 -0
- package/dist/src/lib/adapters/database/postgres/client.d.ts +8 -0
- package/dist/src/lib/adapters/database/postgres/index.d.ts +207 -0
- package/dist/src/lib/adapters/database/postgres/llm-requests.d.ts +17 -0
- package/dist/src/lib/adapters/database/postgres/messages.d.ts +37 -0
- package/dist/src/lib/adapters/database/postgres/threads.d.ts +50 -0
- package/{src/adapters/storage.ts → dist/src/lib/adapters/storage/index.d.ts} +1 -1
- package/dist/src/{adapters → lib/adapters/storage}/minio.d.ts +1 -1
- package/dist/src/lib/ai/active-runs.d.ts +63 -0
- package/dist/src/lib/ai/attachments.d.ts +30 -0
- package/dist/src/lib/ai/backend-url.d.ts +16 -0
- package/dist/src/lib/ai/cc-runtime.d.ts +61 -0
- package/dist/src/lib/ai/commit-gate.d.ts +21 -0
- package/dist/src/{ai → lib/ai}/context/builder.d.ts +6 -7
- package/dist/src/lib/ai/context/compression-middleware.d.ts +15 -0
- package/dist/src/lib/ai/context/compressor.d.ts +7 -0
- package/dist/src/lib/ai/context/intra-turn-compressor.d.ts +24 -0
- package/dist/src/lib/ai/context/optimize-thread-context.d.ts +9 -0
- package/dist/src/lib/ai/context/summarizer.d.ts +5 -0
- package/dist/src/{ai → lib/ai}/context/token-estimator.d.ts +5 -8
- package/dist/src/{ai → lib/ai}/context/types.d.ts +2 -0
- package/dist/src/{ai → lib/ai}/fetch.d.ts +1 -1
- package/dist/src/lib/ai/finish-turn.d.ts +23 -0
- package/dist/src/lib/ai/helpers.d.ts +38 -0
- package/dist/src/lib/ai/index.d.ts +7 -0
- package/dist/src/lib/ai/inspector.d.ts +41 -0
- package/dist/src/lib/ai/interceptors/request-interceptor.d.ts +13 -0
- package/dist/src/{ai → lib/ai}/prompt.d.ts +5 -4
- package/dist/src/lib/ai/redis-runs.d.ts +41 -0
- package/dist/src/lib/ai/redis-stream-log.d.ts +39 -0
- package/dist/src/lib/ai/tools/execute-code.tool.d.ts +18 -0
- package/dist/src/lib/ai/tools/query-graph.tool.d.ts +13 -0
- package/dist/src/lib/ai/tools/read-attachment.tool.d.ts +21 -0
- package/dist/src/lib/ai/tools/search-common.d.ts +17 -0
- package/dist/src/lib/ai/tools/search-knowledge.tool.d.ts +15 -0
- package/dist/src/lib/ai/tools/search-services.tool.d.ts +12 -0
- package/dist/src/lib/ai/tools/search-tools.tool.d.ts +12 -0
- package/dist/src/lib/ai/turn-tools.d.ts +39 -0
- package/dist/src/lib/ai/vision/rasterize-pdf.d.ts +26 -0
- package/dist/src/lib/ai/vision/rasterize-pdf.worker.d.ts +1 -0
- package/dist/src/lib/ai/vision/vision-reader.d.ts +45 -0
- package/dist/src/lib/cc/client.d.ts +183 -0
- package/dist/src/lib/cc/config-cache.d.ts +20 -0
- package/dist/src/lib/cc/format.d.ts +23 -0
- package/dist/src/lib/cc/registry.d.ts +34 -0
- package/dist/src/lib/cc/types.d.ts +27 -0
- package/dist/src/lib/config.d.ts +170 -0
- package/dist/src/lib/db/drizzle.config.mssql.d.ts +2 -0
- package/dist/src/lib/db/drizzle.config.pg.d.ts +2 -0
- package/dist/src/lib/db/migrate.d.ts +7 -0
- package/dist/src/{db/schema.d.ts → lib/db/schema.mssql.d.ts} +236 -24
- package/dist/src/lib/db/schema.pg.d.ts +546 -0
- package/dist/src/{factory.d.ts → lib/factory.d.ts} +2 -12
- package/dist/src/lib/graph/index.d.ts +10 -0
- package/dist/src/{graph → lib/graph}/resolver.d.ts +2 -3
- package/dist/src/{index.d.ts → lib/index.d.ts} +3 -4
- package/dist/src/lib/redis.d.ts +19 -0
- package/dist/src/{routes → lib/routes}/chat.d.ts +1 -1
- package/dist/src/{routes → lib/routes}/files.d.ts +1 -1
- package/dist/src/lib/routes/owned-thread.d.ts +17 -0
- package/dist/src/{routes → lib/routes}/threads.d.ts +1 -1
- package/dist/src/{routes → lib/routes}/ws.d.ts +1 -1
- package/dist/src/lib/types.d.ts +58 -0
- package/dist/src/{ws → lib/ws}/connections.d.ts +2 -1
- package/index.ts +1 -1
- package/package.json +35 -15
- package/src/lib/adapters/database/index.ts +78 -0
- package/src/lib/adapters/database/message-content.ts +39 -0
- package/src/lib/adapters/database/mssql/attachments.ts +90 -0
- package/src/lib/adapters/database/mssql/client.ts +11 -0
- package/src/lib/adapters/database/mssql/index.ts +23 -0
- package/src/lib/adapters/database/mssql/llm-requests.ts +43 -0
- package/src/lib/adapters/database/mssql/messages.ts +92 -0
- package/src/lib/adapters/database/mssql/threads.ts +83 -0
- package/src/lib/adapters/database/postgres/attachments.ts +88 -0
- package/src/lib/adapters/database/postgres/client.ts +11 -0
- package/src/lib/adapters/database/postgres/index.ts +23 -0
- package/src/lib/adapters/database/postgres/llm-requests.ts +45 -0
- package/src/lib/adapters/database/postgres/messages.ts +92 -0
- package/src/lib/adapters/database/postgres/threads.ts +84 -0
- package/{dist/src/adapters/storage.d.ts → src/lib/adapters/storage/index.ts} +1 -1
- package/src/{adapters → lib/adapters/storage}/minio.ts +7 -6
- package/src/lib/ai/active-runs.ts +157 -0
- package/src/lib/ai/attachments.ts +144 -0
- package/src/lib/ai/backend-url.ts +27 -0
- package/src/lib/ai/cc-runtime.ts +63 -0
- package/src/lib/ai/commit-gate.ts +116 -0
- package/src/{ai → lib/ai}/context/builder.ts +12 -14
- package/src/lib/ai/context/compression-middleware.ts +44 -0
- package/src/lib/ai/context/compressor.ts +267 -0
- package/src/lib/ai/context/intra-turn-compressor.ts +181 -0
- package/src/lib/ai/context/optimize-thread-context.ts +50 -0
- package/src/lib/ai/context/summarizer.ts +48 -0
- package/src/lib/ai/context/token-estimator.ts +65 -0
- package/src/{ai → lib/ai}/context/types.ts +4 -1
- package/src/{ai → lib/ai}/fetch.ts +10 -5
- package/src/lib/ai/finish-turn.ts +75 -0
- package/src/lib/ai/helpers.ts +100 -0
- package/src/lib/ai/index.ts +408 -0
- package/src/lib/ai/inspector.ts +112 -0
- package/src/lib/ai/interceptors/request-interceptor.ts +89 -0
- package/src/lib/ai/prompt.ts +355 -0
- package/src/lib/ai/redis-runs.ts +167 -0
- package/src/lib/ai/redis-stream-log.ts +219 -0
- package/src/lib/ai/tools/execute-code.tool.ts +267 -0
- package/src/{ai → lib/ai}/tools/query-graph.tool.ts +7 -8
- package/src/lib/ai/tools/read-attachment.tool.ts +59 -0
- package/src/lib/ai/tools/search-common.ts +15 -0
- package/src/lib/ai/tools/search-knowledge.tool.ts +33 -0
- package/src/lib/ai/tools/search-services.tool.ts +31 -0
- package/src/lib/ai/tools/search-tools.tool.ts +29 -0
- package/src/lib/ai/turn-tools.ts +154 -0
- package/src/lib/ai/vision/rasterize-pdf.ts +69 -0
- package/src/lib/ai/vision/rasterize-pdf.worker.ts +81 -0
- package/src/lib/ai/vision/vision-reader.ts +130 -0
- package/src/{auth → lib/auth}/middleware.ts +12 -3
- package/src/lib/cc/client.ts +273 -0
- package/src/lib/cc/config-cache.ts +32 -0
- package/src/lib/cc/format.ts +96 -0
- package/src/lib/cc/registry.ts +62 -0
- package/src/lib/cc/types.ts +44 -0
- package/src/lib/config.ts +212 -0
- package/src/lib/db/drizzle.config.mssql.ts +9 -0
- package/src/lib/db/drizzle.config.pg.ts +9 -0
- package/src/lib/db/migrate.ts +56 -0
- package/src/{db/migrations/20260309012148_cloudy_maria_hill → lib/db/migrations/mssql/20260326231647_nice_speedball}/migration.sql +12 -0
- package/src/{db/migrations/20260309012148_cloudy_maria_hill → lib/db/migrations/mssql/20260326231647_nice_speedball}/snapshot.json +106 -1
- package/src/lib/db/migrations/mssql/20260729092726_talented_wilson_fisk/migration.sql +20 -0
- package/src/lib/db/migrations/mssql/20260729092726_talented_wilson_fisk/snapshot.json +497 -0
- package/src/lib/db/migrations/mssql/20260801003652_perpetual_blue_shield/migration.sql +1 -0
- package/src/lib/db/migrations/mssql/20260801003652_perpetual_blue_shield/snapshot.json +516 -0
- package/src/lib/db/migrations/mssql/20260812174642_user_id_text/migration.sql +2 -0
- package/src/lib/db/migrations/mssql/20260812174642_user_id_text/snapshot.json +516 -0
- package/src/lib/db/migrations/pg/20260801084117_thick_ben_grimm/migration.sql +53 -0
- package/src/lib/db/migrations/pg/20260801084117_thick_ben_grimm/snapshot.json +584 -0
- package/src/lib/db/migrations/pg/20260812174622_user_id_text/migration.sql +2 -0
- package/src/lib/db/migrations/pg/20260812174622_user_id_text/snapshot.json +584 -0
- package/src/lib/db/schema.mssql.ts +94 -0
- package/src/lib/db/schema.pg.ts +95 -0
- package/src/lib/factory.ts +127 -0
- package/src/lib/graph/index.ts +19 -0
- package/src/lib/graph/resolver.ts +387 -0
- package/src/{index.ts → lib/index.ts} +13 -5
- package/src/lib/redis.ts +44 -0
- package/src/lib/routes/chat.ts +115 -0
- package/src/lib/routes/files.ts +129 -0
- package/src/lib/routes/owned-thread.ts +17 -0
- package/src/{routes → lib/routes}/threads.ts +22 -31
- package/src/{routes → lib/routes}/ws.ts +9 -3
- package/src/lib/types.ts +76 -0
- package/src/{ws → lib/ws}/connections.ts +9 -1
- package/tsconfig.json +48 -0
- package/dist/src/adapters/database.d.ts +0 -30
- package/dist/src/adapters/mssql.d.ts +0 -3
- package/dist/src/ai/active-streams.d.ts +0 -14
- package/dist/src/ai/context/compressor.d.ts +0 -7
- package/dist/src/ai/context/index.d.ts +0 -15
- package/dist/src/ai/context/summarizer.d.ts +0 -5
- package/dist/src/ai/helpers.d.ts +0 -5
- package/dist/src/ai/index.d.ts +0 -4
- package/dist/src/ai/interceptors/request-interceptor.d.ts +0 -12
- package/dist/src/ai/tools/call-endpoint.tool.d.ts +0 -7
- package/dist/src/ai/tools/capture-files.tool.d.ts +0 -6
- package/dist/src/ai/tools/execute-code.tool.d.ts +0 -4
- package/dist/src/ai/tools/query-graph.tool.d.ts +0 -5
- package/dist/src/cli/extract-endpoints.d.ts +0 -6
- package/dist/src/config.d.ts +0 -165
- package/dist/src/db/migrate.d.ts +0 -1
- package/dist/src/graph/expand-domains.d.ts +0 -2
- package/dist/src/graph/generate-cypher.d.ts +0 -22
- package/dist/src/graph/index.d.ts +0 -11
- package/dist/src/graph/seed.d.ts +0 -19
- package/dist/src/graph/validate.d.ts +0 -2
- package/dist/src/routes/index.d.ts +0 -4
- package/dist/src/types.d.ts +0 -74
- package/dist/src/ws/events.d.ts +0 -39
- package/dist/src/ws/index.d.ts +0 -3
- package/dist/src/ws/notify.d.ts +0 -2
- package/src/adapters/database.ts +0 -29
- package/src/adapters/mssql.ts +0 -195
- package/src/ai/active-streams.ts +0 -123
- package/src/ai/context/compressor.ts +0 -47
- package/src/ai/context/index.ts +0 -75
- package/src/ai/context/summarizer.ts +0 -50
- package/src/ai/context/token-estimator.ts +0 -60
- package/src/ai/helpers.ts +0 -32
- package/src/ai/index.ts +0 -277
- package/src/ai/interceptors/request-interceptor.ts +0 -61
- package/src/ai/prompt.ts +0 -126
- package/src/ai/tools/call-endpoint.tool.ts +0 -89
- package/src/ai/tools/capture-files.tool.ts +0 -20
- package/src/ai/tools/execute-code.tool.ts +0 -153
- package/src/cli/extract-endpoints.ts +0 -550
- package/src/config.ts +0 -182
- package/src/db/migrate.ts +0 -21
- package/src/db/migrations/20260315000000_add_context_meta/migration.sql +0 -1
- package/src/db/schema.ts +0 -70
- package/src/factory.ts +0 -171
- package/src/graph/expand-domains.ts +0 -276
- package/src/graph/generate-cypher.ts +0 -192
- package/src/graph/index.ts +0 -47
- package/src/graph/resolver.ts +0 -357
- package/src/graph/seed.ts +0 -162
- package/src/graph/validate.ts +0 -78
- package/src/routes/chat.ts +0 -86
- package/src/routes/files.ts +0 -100
- package/src/routes/index.ts +0 -4
- package/src/types.ts +0 -74
- package/src/ws/events.ts +0 -39
- package/src/ws/index.ts +0 -11
- package/src/ws/notify.ts +0 -9
- /package/dist/src/{auth → lib/auth}/middleware.d.ts +0 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { ResolvedCortexAgentConfig } from "@/config";
|
|
2
|
+
import type { ChatMessage, Thread } from "@/types";
|
|
3
|
+
import { notify } from "@/ws/connections";
|
|
4
|
+
import { toThreadSummary } from "@/types";
|
|
5
|
+
import { optimizeThreadContext } from "./context/optimize-thread-context";
|
|
6
|
+
import type { CapturedStep } from "./inspector";
|
|
7
|
+
|
|
8
|
+
type FinishTurnOptions = {
|
|
9
|
+
/** What this run produced — the only messages the turn writes. */
|
|
10
|
+
messages: ChatMessage[];
|
|
11
|
+
/** The thread including them, which is what a summary has to be judged against. */
|
|
12
|
+
threadMessages: ChatMessage[];
|
|
13
|
+
isAborted: boolean;
|
|
14
|
+
assistantMessageId: string | undefined;
|
|
15
|
+
thread: Thread;
|
|
16
|
+
userId: string;
|
|
17
|
+
capturedSteps: CapturedStep[];
|
|
18
|
+
config: ResolvedCortexAgentConfig;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Persists everything a completed turn produced, then tells the client to refetch.
|
|
23
|
+
*
|
|
24
|
+
* The thread is re-read first: it may have been deleted mid-stream, in which case
|
|
25
|
+
* there is nothing to write to and the whole turn is discarded.
|
|
26
|
+
*/
|
|
27
|
+
export async function finishTurn(options: FinishTurnOptions) {
|
|
28
|
+
const { messages, threadMessages, isAborted, assistantMessageId, thread, userId } = options;
|
|
29
|
+
const { capturedSteps, config } = options;
|
|
30
|
+
|
|
31
|
+
const persistedThread = await config.db.threads.getById(userId, thread.id);
|
|
32
|
+
if (!persistedThread) return;
|
|
33
|
+
|
|
34
|
+
await config.db.messages.upsert(thread.id, messages);
|
|
35
|
+
await persistCapturedSteps(capturedSteps, assistantMessageId, config);
|
|
36
|
+
|
|
37
|
+
config.onStreamFinish?.({ messages: threadMessages, isAborted });
|
|
38
|
+
|
|
39
|
+
// The turn's terminal event is held back until this write lands, so a client
|
|
40
|
+
// that refetches on stream end already sees committed rows. This event exists
|
|
41
|
+
// for the other tabs and devices, which have no stream of their own.
|
|
42
|
+
notify(userId, persistedThread.agentId, {
|
|
43
|
+
type: "thread:messages-updated",
|
|
44
|
+
payload: { threadId: thread.id, thread: toThreadSummary(persistedThread, false) },
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Fire-and-forget: the terminal event is waiting on this function, and
|
|
48
|
+
// summarizing the thread must not be what a user waits for.
|
|
49
|
+
optimizeThreadContext(thread, threadMessages, config).catch((err: unknown) => {
|
|
50
|
+
console.error("[cortex-server] Context optimization failed:", err);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Debug data only — losing it must never fail an otherwise-good turn. */
|
|
55
|
+
async function persistCapturedSteps(
|
|
56
|
+
capturedSteps: CapturedStep[],
|
|
57
|
+
messageId: string | undefined,
|
|
58
|
+
config: ResolvedCortexAgentConfig,
|
|
59
|
+
) {
|
|
60
|
+
if (capturedSteps.length === 0 || !messageId) return;
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
await config.db.llmRequests.insert(
|
|
64
|
+
capturedSteps.map((step, index) => ({
|
|
65
|
+
messageId,
|
|
66
|
+
stepNumber: index,
|
|
67
|
+
prompt: step.prompt,
|
|
68
|
+
output: step.output,
|
|
69
|
+
tokenUsage: step.tokenUsage,
|
|
70
|
+
})),
|
|
71
|
+
);
|
|
72
|
+
} catch (err) {
|
|
73
|
+
console.error("[cortex-server] Failed to persist AI requests:", err);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { OpenAICompatibleChatAdapter } from "@tanstack/ai-openai/compatible";
|
|
2
|
+
import OpenAI from "openai";
|
|
3
|
+
|
|
4
|
+
/** Everything cortex needs to reach one OpenAI-compatible chat endpoint. */
|
|
5
|
+
export type ProviderConfig = {
|
|
6
|
+
baseURL: string;
|
|
7
|
+
apiKey: string;
|
|
8
|
+
modelName: string;
|
|
9
|
+
providerName?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `openaiCompatible()` drops streamed reasoning: its `extractReasoning` hook is
|
|
14
|
+
* a no-op and nothing shipped overrides it. Cortex renders thinking parts, so it
|
|
15
|
+
* takes the sanctioned subclass seam and reads the `reasoning_content` delta.
|
|
16
|
+
*/
|
|
17
|
+
class ReasoningChatAdapter extends OpenAICompatibleChatAdapter<string> {
|
|
18
|
+
protected override extractReasoning(chunk: unknown) {
|
|
19
|
+
const delta = (chunk as { choices?: { delta?: { reasoning_content?: string } }[] })
|
|
20
|
+
.choices?.[0]?.delta;
|
|
21
|
+
return delta?.reasoning_content ? { text: delta.reasoning_content } : undefined;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* `fetch` is how the caller observes or rewrites the raw HTTP exchange — the LLM
|
|
27
|
+
* inspector captures through it, the vision model adapts `max_tokens` through it.
|
|
28
|
+
*/
|
|
29
|
+
export function createModel(
|
|
30
|
+
config: ProviderConfig,
|
|
31
|
+
options: { name?: string; fetch?: typeof globalThis.fetch } = {},
|
|
32
|
+
) {
|
|
33
|
+
const client = new OpenAI({
|
|
34
|
+
baseURL: config.baseURL,
|
|
35
|
+
apiKey: config.apiKey,
|
|
36
|
+
...(options.fetch ? { fetch: options.fetch } : {}),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
return new ReasoningChatAdapter(
|
|
40
|
+
client,
|
|
41
|
+
config.modelName,
|
|
42
|
+
config.providerName ?? options.name ?? "default",
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const visionModels = new WeakMap<object, ReturnType<typeof createModel>>();
|
|
47
|
+
|
|
48
|
+
/** Vision calls cap output tokens, so they need the max_tokens parameter shim. */
|
|
49
|
+
export function createVisionModel(config: ProviderConfig) {
|
|
50
|
+
const cached = visionModels.get(config);
|
|
51
|
+
if (cached) return cached;
|
|
52
|
+
|
|
53
|
+
const model = createModel(config, {
|
|
54
|
+
name: "vision",
|
|
55
|
+
fetch: createMaxTokensAdaptingFetch(),
|
|
56
|
+
});
|
|
57
|
+
visionModels.set(config, model);
|
|
58
|
+
return model;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function streamToBase64(stream: ReadableStream<Uint8Array>) {
|
|
62
|
+
return new Response(stream)
|
|
63
|
+
.arrayBuffer()
|
|
64
|
+
.then((buffer) => Buffer.from(buffer).toString("base64"));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* OpenAI's newer models reject `max_tokens` in favor of `max_completion_tokens`,
|
|
69
|
+
* while most OpenAI-compatible gateways only accept `max_tokens`. Retry once on
|
|
70
|
+
* that specific rejection and remember the answer for this fetch's lifetime.
|
|
71
|
+
*/
|
|
72
|
+
export function createMaxTokensAdaptingFetch() {
|
|
73
|
+
let renameMaxTokens = false;
|
|
74
|
+
|
|
75
|
+
function renamed(init?: RequestInit) {
|
|
76
|
+
if (typeof init?.body !== "string") return init;
|
|
77
|
+
const body = JSON.parse(init.body) as Record<string, unknown>;
|
|
78
|
+
if (!("max_tokens" in body)) return init;
|
|
79
|
+
body["max_completion_tokens"] = body["max_tokens"];
|
|
80
|
+
delete body["max_tokens"];
|
|
81
|
+
return { ...init, body: JSON.stringify(body) };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function adaptiveFetch(input: string | URL | Request, init?: RequestInit) {
|
|
85
|
+
if (renameMaxTokens) return fetch(input, renamed(init));
|
|
86
|
+
|
|
87
|
+
const response = await fetch(input, init);
|
|
88
|
+
if (response.status === 400) {
|
|
89
|
+
const text = await response.clone().text();
|
|
90
|
+
if (text.includes("max_completion_tokens")) {
|
|
91
|
+
renameMaxTokens = true;
|
|
92
|
+
return fetch(input, renamed(init));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return response;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Bun's fetch type carries preconnect; delegate so the provider accepts the shim.
|
|
99
|
+
return Object.assign(adaptiveFetch, { preconnect: fetch.preconnect });
|
|
100
|
+
}
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import {
|
|
2
|
+
chat,
|
|
3
|
+
chatParamsFromRequest,
|
|
4
|
+
convertMessagesToModelMessages,
|
|
5
|
+
generateMessageId,
|
|
6
|
+
maxIterations,
|
|
7
|
+
mergeAgentTools,
|
|
8
|
+
normalizeToUIMessage,
|
|
9
|
+
resumeServerSentEventsResponse,
|
|
10
|
+
toServerSentEventsResponse,
|
|
11
|
+
type UIMessage,
|
|
12
|
+
} from "@tanstack/ai";
|
|
13
|
+
import { HTTPException } from "hono/http-exception";
|
|
14
|
+
import type { ResolvedCortexAgentConfig, ToolSet } from "@/config";
|
|
15
|
+
import type { ChatMessage, MessageMetadata, Thread } from "@/types";
|
|
16
|
+
import { createModel } from "./helpers";
|
|
17
|
+
import { buildSystemPrompt, resolveSession } from "./prompt";
|
|
18
|
+
import { ControlCenterClient } from "@/cc/client";
|
|
19
|
+
import { getControlCenterConfig } from "@/cc/config-cache";
|
|
20
|
+
import { buildPromptVariables } from "@/cc/format";
|
|
21
|
+
import { getRecentCcTools } from "@/cc/registry";
|
|
22
|
+
import { createEmbedder, createNeo4jClient } from "@cortex/contracts/graph";
|
|
23
|
+
import { resolveFromGraph } from "@/graph/resolver";
|
|
24
|
+
import { notify } from "@/ws/connections";
|
|
25
|
+
import { buildContextMessages, trimMessagesToFit } from "./context/builder";
|
|
26
|
+
import { estimateTokens } from "./context/token-estimator";
|
|
27
|
+
import { createCompressionMiddleware } from "./context/compression-middleware";
|
|
28
|
+
import { createCcRuntime } from "./cc-runtime";
|
|
29
|
+
import {
|
|
30
|
+
buildTurnTools,
|
|
31
|
+
createToolInstrumentation,
|
|
32
|
+
hasDefaultAttachmentInterceptor,
|
|
33
|
+
} from "./turn-tools";
|
|
34
|
+
import { createInspector } from "./inspector";
|
|
35
|
+
import { commitBeforeTerminal } from "./commit-gate";
|
|
36
|
+
import { finishTurn } from "./finish-turn";
|
|
37
|
+
import { endRun, isRunning, joinLog, producerLog, startRun } from "./active-runs";
|
|
38
|
+
import { toThreadSummary } from "@/types";
|
|
39
|
+
import { buildAttachmentListing, prepareAttachmentsForTurn } from "./attachments";
|
|
40
|
+
|
|
41
|
+
/** The AG-UI request body, as the route parsed it. */
|
|
42
|
+
export type TurnParams = Awaited<ReturnType<typeof chatParamsFromRequest>>;
|
|
43
|
+
|
|
44
|
+
/** Cortex's agentic loop cap; the SDK's own default is 5. */
|
|
45
|
+
const MAX_ITERATIONS = 25;
|
|
46
|
+
|
|
47
|
+
export async function startTurn(
|
|
48
|
+
params: TurnParams,
|
|
49
|
+
thread: Thread,
|
|
50
|
+
userId: string,
|
|
51
|
+
token: string,
|
|
52
|
+
requestContext: Record<string, unknown>,
|
|
53
|
+
config: ResolvedCortexAgentConfig,
|
|
54
|
+
) {
|
|
55
|
+
const run = await startRun(thread.id);
|
|
56
|
+
try {
|
|
57
|
+
// The client owns its own messages, and the assistant turn it sends back
|
|
58
|
+
// once a client-side tool has answered — without that answer stored, the
|
|
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");
|
|
69
|
+
const prompt = textOf(lastUserMessage);
|
|
70
|
+
if (thread.title === null && prompt) {
|
|
71
|
+
void generateTitle(thread.id, prompt, userId, config);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const updatedThread = await config.db.threads.touch(thread.id);
|
|
75
|
+
|
|
76
|
+
// A stop can land while the claim exists but before the run is
|
|
77
|
+
// announced; announcing anyway would put run-started after the abort's
|
|
78
|
+
// run-finished and pin the thread as running on every other tab. No
|
|
79
|
+
// await sits between this check and the notify, so once it passes the
|
|
80
|
+
// abort's run-finished can only follow the announcement.
|
|
81
|
+
if (run.abortController.signal.aborted) {
|
|
82
|
+
releaseRun(thread.id, run.runId);
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
notify(userId, thread.agentId, {
|
|
87
|
+
type: "thread:run-started",
|
|
88
|
+
payload: { thread: toThreadSummary(updatedThread, true) },
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const threadAttachments = lastUserMessage
|
|
92
|
+
? await prepareAttachmentsForTurn({
|
|
93
|
+
config,
|
|
94
|
+
threadId: thread.id,
|
|
95
|
+
userId,
|
|
96
|
+
message: lastUserMessage,
|
|
97
|
+
abortSignal: run.abortController.signal,
|
|
98
|
+
})
|
|
99
|
+
: await config.db.attachments.listByThread(thread.id, userId);
|
|
100
|
+
|
|
101
|
+
// Claiming rewrites the message the user just sent, and the tabs that did
|
|
102
|
+
// not send it are already showing that message — with the run underway,
|
|
103
|
+
// this is their only chance to learn which files it carries.
|
|
104
|
+
if (threadAttachments.some((attachment) => attachment.messageId === lastUserMessage?.id)) {
|
|
105
|
+
notify(userId, thread.agentId, {
|
|
106
|
+
type: "thread:messages-updated",
|
|
107
|
+
payload: { threadId: thread.id, thread: toThreadSummary(updatedThread, true) },
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const turnKey = lastUserMessage?.id ?? "0";
|
|
112
|
+
|
|
113
|
+
const inspector = createInspector();
|
|
114
|
+
const { model, embed, neo4j, ccClient } = createTurnProviders(config, inspector.fetch);
|
|
115
|
+
|
|
116
|
+
// Run independent operations in parallel
|
|
117
|
+
const [contextResult, resolved, session, ccConfig, ccResolved] = await Promise.all([
|
|
118
|
+
// Branch A: Load messages + build token-aware context window
|
|
119
|
+
buildContextMessages(userId, thread, config.db, config.context),
|
|
120
|
+
// Branch B: Resolve graph context (400-2000ms, the bottleneck)
|
|
121
|
+
neo4j && embed
|
|
122
|
+
? resolveFromGraph(prompt, { neo4j, embed, reranker: config.reranker })
|
|
123
|
+
: Promise.resolve(null),
|
|
124
|
+
// Branch C: Resolve session data
|
|
125
|
+
resolveSession(config, thread, token),
|
|
126
|
+
// Branch D: Control Center config (ETag-revalidated, last-known-good on outage)
|
|
127
|
+
ccClient
|
|
128
|
+
? getControlCenterConfig(ccClient, config.agentId, run.abortController.signal)
|
|
129
|
+
: Promise.resolve(null),
|
|
130
|
+
// Branch E: Control Center /resolve — on failure the turn degrades to
|
|
131
|
+
// the search meta-tools, it never blocks
|
|
132
|
+
ccClient && prompt
|
|
133
|
+
? ccClient.resolve(
|
|
134
|
+
config.agentId,
|
|
135
|
+
{
|
|
136
|
+
query: prompt,
|
|
137
|
+
hints: {
|
|
138
|
+
threadId: thread.id,
|
|
139
|
+
recentToolNames: getRecentCcTools(thread.id),
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
run.abortController.signal,
|
|
143
|
+
)
|
|
144
|
+
: Promise.resolve(null),
|
|
145
|
+
]);
|
|
146
|
+
|
|
147
|
+
const cc = createCcRuntime({
|
|
148
|
+
ccClient,
|
|
149
|
+
ccConfig,
|
|
150
|
+
ccResolved,
|
|
151
|
+
config,
|
|
152
|
+
thread,
|
|
153
|
+
userId,
|
|
154
|
+
token,
|
|
155
|
+
turnKey,
|
|
156
|
+
abortSignal: run.abortController.signal,
|
|
157
|
+
getStepIndex: () => inspector.steps.length,
|
|
158
|
+
onToolProgress: (toolName, toolCallId, status) =>
|
|
159
|
+
emitToolProgress(userId, thread, toolName, toolCallId, status),
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const { messages: contextMessages, allMessages } = contextResult;
|
|
163
|
+
|
|
164
|
+
const turnToolsOptions = {
|
|
165
|
+
config,
|
|
166
|
+
cc,
|
|
167
|
+
neo4j,
|
|
168
|
+
thread,
|
|
169
|
+
userId,
|
|
170
|
+
token,
|
|
171
|
+
session,
|
|
172
|
+
requestContext,
|
|
173
|
+
threadAttachments,
|
|
174
|
+
};
|
|
175
|
+
const tools = buildTurnTools(turnToolsOptions);
|
|
176
|
+
const instrumentation = createToolInstrumentation({
|
|
177
|
+
...turnToolsOptions,
|
|
178
|
+
onToolStart: (toolName, toolCallId) =>
|
|
179
|
+
emitToolProgress(userId, thread, toolName, toolCallId, "started"),
|
|
180
|
+
onToolFinish: (toolName, toolCallId) =>
|
|
181
|
+
emitToolProgress(userId, thread, toolName, toolCallId, "finished"),
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const systemPrompt = await buildSystemPrompt(
|
|
185
|
+
config,
|
|
186
|
+
resolved,
|
|
187
|
+
{ session, requestContext },
|
|
188
|
+
cc
|
|
189
|
+
? {
|
|
190
|
+
config: cc.config,
|
|
191
|
+
resolved: ccResolved,
|
|
192
|
+
variables: buildPromptVariables(cc.config.promptVariables, {
|
|
193
|
+
...session,
|
|
194
|
+
...requestContext,
|
|
195
|
+
locale: cc.locale,
|
|
196
|
+
}),
|
|
197
|
+
}
|
|
198
|
+
: undefined,
|
|
199
|
+
buildAttachmentListing(threadAttachments, lastUserMessage?.id, {
|
|
200
|
+
readAttachment: tools.some((tool) => tool.name === "readAttachment"),
|
|
201
|
+
attachmentSentinels: hasDefaultAttachmentInterceptor(config),
|
|
202
|
+
}),
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
const stream = chat({
|
|
206
|
+
adapter: model,
|
|
207
|
+
// Client tool declarations ride in on every request, so the server
|
|
208
|
+
// never re-declares them.
|
|
209
|
+
tools: mergeAgentTools(tools, params.tools),
|
|
210
|
+
messages: convertMessagesToModelMessages(
|
|
211
|
+
fitToContextWindow(contextMessages, systemPrompt, tools, config),
|
|
212
|
+
),
|
|
213
|
+
systemPrompts: [systemPrompt],
|
|
214
|
+
agentLoopStrategy: maxIterations(MAX_ITERATIONS),
|
|
215
|
+
abortController: run.abortController,
|
|
216
|
+
middleware: [
|
|
217
|
+
createCompressionMiddleware(config),
|
|
218
|
+
...(instrumentation ? [instrumentation] : []),
|
|
219
|
+
inspector.middleware,
|
|
220
|
+
],
|
|
221
|
+
threadId: thread.id,
|
|
222
|
+
runId: run.runId,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const producer = toServerSentEventsResponse(
|
|
226
|
+
commitBeforeTerminal(stream, {
|
|
227
|
+
isAborted: () => run.abortController.signal.aborted,
|
|
228
|
+
commit: async function (produced) {
|
|
229
|
+
const messages = produced.map(toChatMessage).map(withTurnMetadata);
|
|
230
|
+
await finishTurn({
|
|
231
|
+
messages,
|
|
232
|
+
// Summarization and the consumer hook judge the thread, not
|
|
233
|
+
// the turn — the run's own output is only its tail.
|
|
234
|
+
threadMessages: [...allMessages, ...messages],
|
|
235
|
+
isAborted: run.abortController.signal.aborted,
|
|
236
|
+
assistantMessageId: messages.at(-1)?.id,
|
|
237
|
+
thread,
|
|
238
|
+
userId,
|
|
239
|
+
capturedSteps: inspector.steps,
|
|
240
|
+
config,
|
|
241
|
+
});
|
|
242
|
+
},
|
|
243
|
+
}),
|
|
244
|
+
{
|
|
245
|
+
// Append per chunk rather than per batch of 32: a client joining
|
|
246
|
+
// the log is waiting on real tokens, not on a buffer filling up.
|
|
247
|
+
durability: { adapter: producerLog(run.runId), batch: 1 },
|
|
248
|
+
abortController: run.abortController,
|
|
249
|
+
},
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
function withTurnMetadata(message: ChatMessage, index: number, all: ChatMessage[]) {
|
|
253
|
+
if (index !== all.length - 1 || message.role !== "assistant") return message;
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
...message,
|
|
257
|
+
metadata: {
|
|
258
|
+
modelId: inspector.modelId,
|
|
259
|
+
isAborted: run.abortController.signal.aborted,
|
|
260
|
+
tokenUsage: inspector.tokenUsage,
|
|
261
|
+
} satisfies MessageMetadata,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Generation outlives the request that started it, so a reload mid-turn
|
|
266
|
+
// rejoins the log instead of restarting the model. Draining into a sink
|
|
267
|
+
// that never cancels is what detaches the producer from this response;
|
|
268
|
+
// the client gets a reader on the same log.
|
|
269
|
+
void producer
|
|
270
|
+
.body!.pipeTo(new WritableStream())
|
|
271
|
+
.catch(() => undefined)
|
|
272
|
+
.finally(() => releaseRun(thread.id, run.runId));
|
|
273
|
+
|
|
274
|
+
return resumeServerSentEventsResponse({ adapter: joinLog(thread.id, run.runId) });
|
|
275
|
+
} catch (error) {
|
|
276
|
+
releaseRun(thread.id, run.runId);
|
|
277
|
+
throw error;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Releasing the claim must never mask the turn's own error or reject unhandled. */
|
|
282
|
+
function releaseRun(threadId: string, runId: string) {
|
|
283
|
+
endRun(threadId, runId).catch((error: unknown) =>
|
|
284
|
+
console.error("[cortex-server] Failed to release run claim:", error),
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Drops the SDK's transport shape into the envelope cortex persists and serves. */
|
|
289
|
+
function toChatMessage(message: UIMessage) {
|
|
290
|
+
return { id: message.id, role: message.role, parts: message.parts } satisfies ChatMessage;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* An assistant turn the client is sending back because it resolved a tool call.
|
|
295
|
+
*
|
|
296
|
+
* The wire fans that turn out into a `role: "tool"` message per result, and each
|
|
297
|
+
* of those normalizes back into an assistant message carrying only the result,
|
|
298
|
+
* under a freshly generated id. Stored as rows of their own they would replay as
|
|
299
|
+
* assistant turns answering calls that no message makes, so the turn is picked
|
|
300
|
+
* out by holding both halves of the exchange.
|
|
301
|
+
*/
|
|
302
|
+
function answersToolCalls(message: ChatMessage) {
|
|
303
|
+
return (
|
|
304
|
+
message.role === "assistant" &&
|
|
305
|
+
message.parts.some((part) => part.type === "tool-call") &&
|
|
306
|
+
message.parts.some((part) => part.type === "tool-result")
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function textOf(message: ChatMessage | undefined) {
|
|
311
|
+
const part = message?.parts.find((candidate) => candidate.type === "text");
|
|
312
|
+
return part?.type === "text" ? part.content : "";
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Builds the clients one turn needs. Neo4j runs vector search through an embedding
|
|
317
|
+
* model, so configuring a graph without one is a misconfiguration we surface
|
|
318
|
+
* loudly rather than quietly serving every turn with no graph context.
|
|
319
|
+
*/
|
|
320
|
+
function createTurnProviders(config: ResolvedCortexAgentConfig, fetch: typeof globalThis.fetch) {
|
|
321
|
+
if (config.neo4j && !config.embedding) {
|
|
322
|
+
throw new HTTPException(500, {
|
|
323
|
+
message: "embedding config is required when neo4j is configured",
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const embed = config.embedding ? createEmbedder(config.embedding) : undefined;
|
|
328
|
+
|
|
329
|
+
return {
|
|
330
|
+
// The inspector's fetch is per-turn, so the model is too.
|
|
331
|
+
model: createModel(config.model, { fetch }),
|
|
332
|
+
embed,
|
|
333
|
+
neo4j: config.neo4j && embed ? createNeo4jClient(config.neo4j, embed) : undefined,
|
|
334
|
+
ccClient: config.controlCenter ? new ControlCenterClient(config.controlCenter) : null,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* The context builder reserved a static token budget for the system prompt and
|
|
340
|
+
* tools before either existed. Now that both are real, drop the oldest messages
|
|
341
|
+
* if the reserve fell short.
|
|
342
|
+
*/
|
|
343
|
+
function fitToContextWindow(
|
|
344
|
+
contextMessages: ChatMessage[],
|
|
345
|
+
systemPrompt: string,
|
|
346
|
+
tools: ToolSet,
|
|
347
|
+
config: ResolvedCortexAgentConfig,
|
|
348
|
+
) {
|
|
349
|
+
const actualFixedCost = estimateTokens(systemPrompt) + estimateTokens(JSON.stringify(tools));
|
|
350
|
+
const { reservedTokenBudget, maxContextTokens } = config.context;
|
|
351
|
+
if (actualFixedCost <= reservedTokenBudget) return contextMessages;
|
|
352
|
+
|
|
353
|
+
return trimMessagesToFit(contextMessages, maxContextTokens - actualFixedCost);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const TITLE_SYSTEM_PROMPT = `You are an expert in generating titles for threads of chats
|
|
357
|
+
given the first message in that thread.
|
|
358
|
+
|
|
359
|
+
When asked, only respond with the title without saying you're
|
|
360
|
+
going to do so or any other speech. Spit out only the title.`;
|
|
361
|
+
|
|
362
|
+
export async function generateTitle(
|
|
363
|
+
threadId: string,
|
|
364
|
+
prompt: string,
|
|
365
|
+
userId: string,
|
|
366
|
+
config: ResolvedCortexAgentConfig,
|
|
367
|
+
) {
|
|
368
|
+
const title = await chat({
|
|
369
|
+
adapter: createModel(config.model, { name: "title" }),
|
|
370
|
+
stream: false,
|
|
371
|
+
systemPrompts: [TITLE_SYSTEM_PROMPT],
|
|
372
|
+
messages: [
|
|
373
|
+
{
|
|
374
|
+
id: generateMessageId(),
|
|
375
|
+
role: "user",
|
|
376
|
+
parts: [{ type: "text", content: `Generate a title for this prompt: ${prompt}` }],
|
|
377
|
+
},
|
|
378
|
+
],
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
// A failed generation resolves to empty text rather than throwing, and the
|
|
382
|
+
// only trigger for a retry is the title still being null — so writing "" here
|
|
383
|
+
// would leave the thread permanently unnamed.
|
|
384
|
+
if (!title) return;
|
|
385
|
+
|
|
386
|
+
await config.db.threads.updateTitle(threadId, title);
|
|
387
|
+
|
|
388
|
+
const thread = await config.db.threads.getById(userId, threadId);
|
|
389
|
+
if (!thread) return;
|
|
390
|
+
|
|
391
|
+
notify(userId, thread.agentId, {
|
|
392
|
+
type: "thread:title-updated",
|
|
393
|
+
payload: { thread: toThreadSummary(thread, await isRunning(thread.id)) },
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function emitToolProgress(
|
|
398
|
+
userId: string,
|
|
399
|
+
thread: Thread,
|
|
400
|
+
toolName: string,
|
|
401
|
+
toolCallId: string,
|
|
402
|
+
status: "started" | "finished",
|
|
403
|
+
) {
|
|
404
|
+
notify(userId, thread.agentId, {
|
|
405
|
+
type: "thread:tool-progress",
|
|
406
|
+
payload: { threadId: thread.id, toolCallId, toolName, status },
|
|
407
|
+
});
|
|
408
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { ChatMiddleware, TokenUsage as ProviderTokenUsage } from "@tanstack/ai";
|
|
2
|
+
import type { TokenUsage } from "@/types";
|
|
3
|
+
|
|
4
|
+
/** One model call's raw exchange, as the LLM request inspector renders it. */
|
|
5
|
+
export type CapturedStep = {
|
|
6
|
+
prompt: string;
|
|
7
|
+
output: string | null;
|
|
8
|
+
tokenUsage: TokenUsage | null;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Watches one turn's model calls. The SDK's `debug` logger reports call metadata
|
|
13
|
+
* but never raw bodies, so the raw exchange is captured where it actually
|
|
14
|
+
* exists — on the HTTP client — while usage arrives through middleware.
|
|
15
|
+
*/
|
|
16
|
+
export function createInspector() {
|
|
17
|
+
const steps: CapturedStep[] = [];
|
|
18
|
+
let modelId = "";
|
|
19
|
+
let tokenUsage: TokenUsage | undefined;
|
|
20
|
+
|
|
21
|
+
async function capturingFetch(input: string | URL | Request, init?: RequestInit) {
|
|
22
|
+
const step: CapturedStep = {
|
|
23
|
+
prompt: typeof init?.body === "string" ? init.body : "",
|
|
24
|
+
output: null,
|
|
25
|
+
tokenUsage: null,
|
|
26
|
+
};
|
|
27
|
+
steps.push(step);
|
|
28
|
+
|
|
29
|
+
const response = await fetch(input, init);
|
|
30
|
+
// Drain a tee rather than the response itself: the model call must keep
|
|
31
|
+
// streaming at full speed, and losing debug data must never fail a turn.
|
|
32
|
+
void response
|
|
33
|
+
.clone()
|
|
34
|
+
.text()
|
|
35
|
+
.then(function (text) {
|
|
36
|
+
step.output = text;
|
|
37
|
+
})
|
|
38
|
+
.catch(() => undefined);
|
|
39
|
+
|
|
40
|
+
return response;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const middleware: ChatMiddleware = {
|
|
44
|
+
name: "cortex-inspector",
|
|
45
|
+
onUsage(ctx, usage) {
|
|
46
|
+
modelId = ctx.model;
|
|
47
|
+
const stepUsage = toTokenUsage(usage);
|
|
48
|
+
const lastStep = steps.at(-1);
|
|
49
|
+
if (lastStep) lastStep.tokenUsage = stepUsage;
|
|
50
|
+
tokenUsage = addTokenUsage(tokenUsage, stepUsage);
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
steps,
|
|
56
|
+
middleware,
|
|
57
|
+
// Bun's fetch type carries preconnect; delegate so the client accepts the shim.
|
|
58
|
+
fetch: Object.assign(capturingFetch, { preconnect: fetch.preconnect }),
|
|
59
|
+
get modelId() {
|
|
60
|
+
return modelId;
|
|
61
|
+
},
|
|
62
|
+
get tokenUsage() {
|
|
63
|
+
return tokenUsage;
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Flattens the provider's usage shape into ours. The provider reports cached and
|
|
70
|
+
* cache-written prompt tokens but no uncached counter, so that dimension is the
|
|
71
|
+
* remainder — which is what makes the client's breakdown add up to the total.
|
|
72
|
+
*/
|
|
73
|
+
export function toTokenUsage(usage: ProviderTokenUsage) {
|
|
74
|
+
const cacheRead = usage.promptTokensDetails?.cachedTokens ?? 0;
|
|
75
|
+
const cacheWrite = usage.promptTokensDetails?.cacheWriteTokens ?? 0;
|
|
76
|
+
const reasoning = usage.completionTokensDetails?.reasoningTokens ?? 0;
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
input: {
|
|
80
|
+
noCache: Math.max(usage.promptTokens - cacheRead - cacheWrite, 0),
|
|
81
|
+
cacheRead,
|
|
82
|
+
cacheWrite,
|
|
83
|
+
total: usage.promptTokens,
|
|
84
|
+
},
|
|
85
|
+
output: {
|
|
86
|
+
reasoning,
|
|
87
|
+
text: usage.completionTokensDetails?.textTokens ?? usage.completionTokens - reasoning,
|
|
88
|
+
total: usage.completionTokens,
|
|
89
|
+
},
|
|
90
|
+
total: usage.totalTokens,
|
|
91
|
+
} satisfies TokenUsage;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The agent loop reports usage per iteration; a turn's cost is their sum. */
|
|
95
|
+
function addTokenUsage(left: TokenUsage | undefined, right: TokenUsage) {
|
|
96
|
+
if (!left) return right;
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
input: {
|
|
100
|
+
noCache: left.input.noCache + right.input.noCache,
|
|
101
|
+
cacheRead: left.input.cacheRead + right.input.cacheRead,
|
|
102
|
+
cacheWrite: left.input.cacheWrite + right.input.cacheWrite,
|
|
103
|
+
total: left.input.total + right.input.total,
|
|
104
|
+
},
|
|
105
|
+
output: {
|
|
106
|
+
reasoning: left.output.reasoning + right.output.reasoning,
|
|
107
|
+
text: left.output.text + right.output.text,
|
|
108
|
+
total: left.output.total + right.output.total,
|
|
109
|
+
},
|
|
110
|
+
total: left.total + right.total,
|
|
111
|
+
} satisfies TokenUsage;
|
|
112
|
+
}
|