@m6d/cortex-server 1.1.2 → 1.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.
@@ -1,4 +1,4 @@
1
1
  import type { ResolvedCortexAgentConfig } from "../config.ts";
2
2
  import type { Thread } from "../types.ts";
3
- export declare function stream(messages: unknown[], thread: Thread, userId: string, token: string, config: ResolvedCortexAgentConfig): Promise<Response>;
3
+ export declare function stream(messages: unknown[], thread: Thread, userId: string, token: string, config: ResolvedCortexAgentConfig, abortSignal?: AbortSignal): Promise<Response>;
4
4
  export declare function generateTitle(threadId: string, prompt: string, userId: string, config: ResolvedCortexAgentConfig): Promise<void>;
@@ -4,8 +4,9 @@ export type ResolvedFile = {
4
4
  name: string;
5
5
  bytes: string;
6
6
  };
7
- export declare function createCapturedFileInterceptor(db: DatabaseAdapter, storage: StorageAdapter, options?: {
7
+ export type RequestInterceptorOptions = {
8
8
  transformFile?: (file: ResolvedFile) => unknown;
9
- }): (body: Record<string, unknown>, context: {
9
+ };
10
+ export declare function createRequestInterceptor(db: DatabaseAdapter, storage: StorageAdapter, options?: RequestInterceptorOptions): (body: Record<string, unknown>, context: {
10
11
  token: string;
11
12
  }) => Promise<Record<string, unknown>>;
@@ -1,7 +1,8 @@
1
- import type { UIMessage } from "ai";
1
+ import type { ToolSet, UIMessage } from "ai";
2
2
  import type { DatabaseAdapter } from "./adapters/database";
3
3
  import type { StorageAdapter } from "./adapters/storage";
4
4
  import type { DomainDef } from "./graph/types.ts";
5
+ import type { RequestInterceptorOptions } from "./ai/interceptors/request-interceptor.ts";
5
6
  export type KnowledgeConfig = {
6
7
  swagger?: {
7
8
  url: string;
@@ -22,7 +23,7 @@ export type StorageConfig = {
22
23
  };
23
24
  export type CortexAgentDefinition = {
24
25
  systemPrompt: string | ((session: Record<string, unknown> | null) => string | Promise<string>);
25
- tools?: Record<string, unknown>;
26
+ tools?: ToolSet;
26
27
  backendFetch?: {
27
28
  baseUrl: string;
28
29
  apiKey: string;
@@ -30,6 +31,7 @@ export type CortexAgentDefinition = {
30
31
  transformRequestBody?: (body: Record<string, unknown>, context: {
31
32
  token: string;
32
33
  }) => Promise<Record<string, unknown>>;
34
+ interceptor?: RequestInterceptorOptions;
33
35
  };
34
36
  loadSessionData?: (token: string) => Promise<Record<string, unknown>>;
35
37
  onToolCall?: (toolCall: {
@@ -89,7 +91,7 @@ export type ResolvedCortexAgentConfig = {
89
91
  apiKey: string;
90
92
  };
91
93
  systemPrompt: string | ((session: Record<string, unknown> | null) => string | Promise<string>);
92
- tools?: Record<string, unknown>;
94
+ tools?: ToolSet;
93
95
  backendFetch?: {
94
96
  baseUrl: string;
95
97
  apiKey: string;
@@ -97,6 +99,7 @@ export type ResolvedCortexAgentConfig = {
97
99
  transformRequestBody?: (body: Record<string, unknown>, context: {
98
100
  token: string;
99
101
  }) => Promise<Record<string, unknown>>;
102
+ interceptor?: RequestInterceptorOptions;
100
103
  };
101
104
  loadSessionData?: (token: string) => Promise<Record<string, unknown>>;
102
105
  onToolCall?: (toolCall: {
@@ -2,6 +2,8 @@ export type { CortexConfig, CortexAgentDefinition, KnowledgeConfig, DatabaseConf
2
2
  export type { Thread, AppEnv } from "./types";
3
3
  export type { CortexInstance } from "./factory";
4
4
  export { createCortex } from "./factory";
5
+ export type { ResolvedFile, RequestInterceptorOptions, } from "./ai/interceptors/request-interceptor";
6
+ export { createRequestInterceptor } from "./ai/interceptors/request-interceptor";
5
7
  export { captureFilesTool } from "./ai/tools/capture-files.tool";
6
8
  export { createQueryGraphTool } from "./ai/tools/query-graph.tool";
7
9
  export { createCallEndpointTool } from "./ai/tools/call-endpoint.tool";
@@ -5,4 +5,10 @@ export type ThreadTitleUpdatedEvent = {
5
5
  title: string;
6
6
  };
7
7
  };
8
- export type WsEvent = ThreadTitleUpdatedEvent;
8
+ export type ThreadMessagesUpdatedEvent = {
9
+ type: "thread:messages-updated";
10
+ payload: {
11
+ threadId: string;
12
+ };
13
+ };
14
+ export type WsEvent = ThreadTitleUpdatedEvent | ThreadMessagesUpdatedEvent;
@@ -1,3 +1,3 @@
1
1
  export { addConnection, removeConnection, getConnections } from "./connections.ts";
2
2
  export { notify } from "./notify.ts";
3
- export type { WsEvent, ThreadTitleUpdatedEvent } from "./events.ts";
3
+ export type { WsEvent, ThreadTitleUpdatedEvent, ThreadMessagesUpdatedEvent } from "./events.ts";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m6d/cortex-server",
3
- "version": "1.1.2",
3
+ "version": "1.3.0",
4
4
  "description": "Reusable AI agent chat server library for Hono + Bun",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -29,7 +29,8 @@
29
29
  "@hono/zod-validator": "^0.5.0",
30
30
  "drizzle-orm": "^1.0.0-beta.15-859cf75",
31
31
  "minio": "^8.0.7",
32
- "mssql": "^12.2.0"
32
+ "mssql": "^12.2.0",
33
+ "quickjs-emscripten": "^0.32.0"
33
34
  },
34
35
  "devDependencies": {
35
36
  "@ai-sdk/openai-compatible": "^2.0.0",
@@ -109,19 +109,15 @@ export function createMssqlAdapter(connectionString: string, storage: StorageAda
109
109
  }
110
110
 
111
111
  const existingMessages = messagesToInsert.filter((x) => existingIds.includes(x.id));
112
- if (existingMessages.length) {
113
- await db.transaction(async (tx) => {
114
- for (const message of existingMessages) {
115
- await tx
116
- .update(messages)
117
- .set({
118
- content: message,
119
- text: message.parts.find((x) => x.type === "text")?.text,
120
- })
121
- .where(eq(messages.id, message.id))
122
- .execute();
123
- }
124
- });
112
+ for (const message of existingMessages) {
113
+ await db
114
+ .update(messages)
115
+ .set({
116
+ content: message,
117
+ text: message.parts.find((x) => x.type === "text")?.text,
118
+ })
119
+ .where(eq(messages.id, message.id))
120
+ .execute();
125
121
  }
126
122
  },
127
123
  },
package/src/ai/index.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import {
2
+ type UIMessage,
2
3
  type ToolSet,
4
+ consumeStream,
3
5
  convertToModelMessages,
4
6
  generateId,
5
7
  generateText,
@@ -15,7 +17,7 @@ import { createQueryGraphTool } from "./tools/query-graph.tool.ts";
15
17
  import { createCallEndpointTool } from "./tools/call-endpoint.tool.ts";
16
18
  import { createExecuteCodeTool } from "./tools/execute-code.tool.ts";
17
19
  import { captureFilesTool } from "./tools/capture-files.tool.ts";
18
- import { createCapturedFileInterceptor } from "./interceptors/resolve-captured-files.ts";
20
+ import { createRequestInterceptor } from "./interceptors/request-interceptor.ts";
19
21
  import { createNeo4jClient } from "../graph/neo4j.ts";
20
22
  import { resolveFromGraph } from "../graph/resolver.ts";
21
23
  import { notify } from "../ws/index.ts";
@@ -26,6 +28,7 @@ export async function stream(
26
28
  userId: string,
27
29
  token: string,
28
30
  config: ResolvedCortexAgentConfig,
31
+ abortSignal?: AbortSignal,
29
32
  ) {
30
33
  const validationResult = await safeValidateUIMessages({ messages });
31
34
  if (!validationResult.success) {
@@ -69,7 +72,11 @@ export async function stream(
69
72
  ...config.backendFetch,
70
73
  transformRequestBody:
71
74
  config.backendFetch.transformRequestBody ??
72
- createCapturedFileInterceptor(config.db, config.storage),
75
+ createRequestInterceptor(
76
+ config.db,
77
+ config.storage,
78
+ config.backendFetch.interceptor,
79
+ ),
73
80
  };
74
81
 
75
82
  builtInTools["callEndpoint"] = createCallEndpointTool(backendFetchWithInterceptor, token);
@@ -88,20 +95,30 @@ export async function stream(
88
95
  system: systemPrompt,
89
96
  tools,
90
97
  messages: recentMessages,
91
- onAbort: () => {
92
- console.log("Stream aborted");
93
- },
98
+ abortSignal,
94
99
  });
95
100
 
96
101
  return result.toUIMessageStreamResponse({
97
102
  originalMessages,
98
103
  generateMessageId: generateId,
104
+ consumeSseStream: consumeStream,
99
105
  onFinish: async ({ messages: finishedMessages, isAborted }) => {
100
106
  if (isAborted) {
101
- console.log("Stream was aborted");
107
+ finalizeAbortedMessages(finishedMessages);
102
108
  }
103
109
  await config.db.messages.upsert(thread.id, finishedMessages);
104
110
  config.onStreamFinish?.({ messages: finishedMessages, isAborted });
111
+
112
+ // XXX: we need to notify the user so that the client can
113
+ // fetch new messages. The client can't fetch messages
114
+ // immediately after abort because messages may not have been
115
+ // saved yet.
116
+ if (isAborted) {
117
+ notify(userId, {
118
+ type: "thread:messages-updated",
119
+ payload: { threadId: thread.id },
120
+ });
121
+ }
105
122
  },
106
123
  });
107
124
  }
@@ -131,3 +148,31 @@ going to do so or any other speech. Spit out only the title.`,
131
148
  payload: { threadId, title: output ?? "" },
132
149
  });
133
150
  }
151
+
152
+ const TERMINAL_TOOL_STATES = new Set(["output-available", "output-error", "output-denied"]);
153
+
154
+ function finalizeAbortedMessages(messages: UIMessage[]) {
155
+ const lastMessage = messages.at(-1);
156
+ if (!lastMessage || lastMessage.role !== "assistant") return;
157
+
158
+ lastMessage.parts = lastMessage.parts.map((part) => {
159
+ if ((part.type === "text" || part.type === "reasoning") && part.state === "streaming") {
160
+ return { ...part, state: "done" as const };
161
+ }
162
+
163
+ if ("toolCallId" in part && "state" in part) {
164
+ const toolState = part.state;
165
+ if (!TERMINAL_TOOL_STATES.has(toolState)) {
166
+ const { approval: _, ...rest } = part;
167
+ return {
168
+ ...rest,
169
+ state: "output-error" as const,
170
+ errorText: "Generation was aborted",
171
+ output: undefined,
172
+ };
173
+ }
174
+ }
175
+
176
+ return part;
177
+ });
178
+ }
@@ -7,14 +7,18 @@ export type ResolvedFile = {
7
7
  bytes: string;
8
8
  };
9
9
 
10
- export function createCapturedFileInterceptor(
10
+ export type RequestInterceptorOptions = {
11
+ transformFile?: (file: ResolvedFile) => unknown;
12
+ };
13
+
14
+ export function createRequestInterceptor(
11
15
  db: DatabaseAdapter,
12
16
  storage: StorageAdapter,
13
- options?: { transformFile?: (file: ResolvedFile) => unknown },
17
+ options?: RequestInterceptorOptions,
14
18
  ) {
15
19
  const transformFile = options?.transformFile ?? ((file: ResolvedFile) => file);
16
20
 
17
- return async function resolveCapturedFiles(
21
+ return async function resolveRequestBody(
18
22
  body: Record<string, unknown>,
19
23
  context: { token: string },
20
24
  ) {
@@ -2,13 +2,7 @@ import { tool } from "ai";
2
2
  import z from "zod";
3
3
  import type { ResolvedCortexAgentConfig } from "../../config.ts";
4
4
  import { fetchBackend } from "../fetch.ts";
5
-
6
- type ApiHelper = {
7
- get: (path: string, queryParams?: Record<string, unknown>) => Promise<unknown>;
8
- post: (path: string, body?: unknown) => Promise<unknown>;
9
- put: (path: string, body?: unknown) => Promise<unknown>;
10
- del: (path: string) => Promise<unknown>;
11
- };
5
+ import { getQuickJS, Scope, shouldInterruptAfterDeadline } from "quickjs-emscripten";
12
6
 
13
7
  function buildQueryString(params: Record<string, unknown>) {
14
8
  const searchParams = new URLSearchParams();
@@ -26,7 +20,8 @@ function buildQueryString(params: Record<string, unknown>) {
26
20
  return qs ? `?${qs}` : "";
27
21
  }
28
22
 
29
- function createApiHelper(
23
+ async function runInSandbox(
24
+ code: string,
30
25
  backendFetch: NonNullable<ResolvedCortexAgentConfig["backendFetch"]>,
31
26
  token: string,
32
27
  ) {
@@ -53,22 +48,82 @@ function createApiHelper(
53
48
  return response.json();
54
49
  }
55
50
 
56
- return {
57
- get: (path, queryParams) => {
58
- const fullPath = queryParams ? path + buildQueryString(queryParams) : path;
59
- return request("GET", fullPath);
60
- },
61
- post: (path, body) => request("POST", path, body),
62
- put: (path, body) => request("PUT", path, body),
63
- del: (path) => request("DELETE", path),
64
- } satisfies ApiHelper;
65
- }
51
+ return await Scope.withScopeAsync(async (scope) => {
52
+ const QuickJS = await getQuickJS();
53
+
54
+ const runtime = scope.manage(QuickJS.newRuntime());
55
+ runtime.setMemoryLimit(1024 * 1024 * 20); // 20MB
56
+ runtime.setInterruptHandler(shouldInterruptAfterDeadline(Date.now() + 10_000)); // 60 seconds;
57
+
58
+ const vm = scope.manage(runtime.newContext());
59
+
60
+ function drainJobs() {
61
+ vm.runtime.executePendingJobs();
62
+ }
63
+
64
+ function makeApiFunction(fn: (...args: unknown[]) => Promise<unknown>) {
65
+ return scope.manage(
66
+ vm.newFunction(fn.name, (...argHandles) => {
67
+ const args = argHandles.map(vm.dump.bind(vm));
68
+ const promise = scope.manage(
69
+ vm.newPromise(
70
+ fn(...args).then((result) =>
71
+ scope.manage(
72
+ vm.unwrapResult(vm.evalCode(`(${JSON.stringify(result)})`)),
73
+ ),
74
+ ),
75
+ ),
76
+ );
66
77
 
67
- const AsyncFunction: new (
68
- ...args: [...paramNames: string[], body: string]
69
- ) => (...args: unknown[]) => Promise<unknown> = Object.getPrototypeOf(
70
- async function () {},
71
- ).constructor;
78
+ promise.settled.finally(() => {
79
+ drainJobs();
80
+ });
81
+
82
+ return promise.handle;
83
+ }),
84
+ );
85
+ }
86
+
87
+ const apiHandle = scope.manage(vm.newObject());
88
+ vm.setProp(
89
+ apiHandle,
90
+ "get",
91
+ makeApiFunction(async (path, queryParams?) => {
92
+ const fullPath = queryParams
93
+ ? path + buildQueryString(queryParams as Record<string, unknown>)
94
+ : path;
95
+ return await request("GET", fullPath as string);
96
+ }),
97
+ );
98
+ vm.setProp(
99
+ apiHandle,
100
+ "post",
101
+ makeApiFunction(async (path, body) => await request("POST", path as string, body)),
102
+ );
103
+ vm.setProp(
104
+ apiHandle,
105
+ "put",
106
+ makeApiFunction(async (path, body) => await request("PUT", path as string, body)),
107
+ );
108
+ vm.setProp(
109
+ apiHandle,
110
+ "del",
111
+ makeApiFunction(async (path) => await request("DELETE", path as string)),
112
+ );
113
+
114
+ vm.setProp(vm.global, "api", apiHandle);
115
+
116
+ const runResult = vm.evalCode(`(async () => {
117
+ ${code}
118
+ })()`);
119
+ const resultHandle = scope.manage(vm.unwrapResult(runResult));
120
+ const pending = vm.resolvePromise(resultHandle);
121
+ drainJobs();
122
+ const resolvedResult = await pending;
123
+ const result = scope.manage(vm.unwrapResult(resolvedResult));
124
+ return vm.dump(result);
125
+ });
126
+ }
72
127
 
73
128
  export function createExecuteCodeTool(
74
129
  backendFetch: NonNullable<ResolvedCortexAgentConfig["backendFetch"]>,
@@ -86,11 +141,8 @@ export function createExecuteCodeTool(
86
141
  ),
87
142
  }),
88
143
  execute: async ({ code }) => {
89
- const apiHelper = createApiHelper(backendFetch, token);
90
-
91
144
  try {
92
- const fn = new AsyncFunction("api", code);
93
- const result = await fn(apiHelper);
145
+ const result = await runInSandbox(code, backendFetch, token);
94
146
  return JSON.stringify(result);
95
147
  } catch (e) {
96
148
  const message = e instanceof Error ? e.message : String(e);
package/src/config.ts CHANGED
@@ -1,7 +1,8 @@
1
- import type { UIMessage } from "ai";
1
+ import type { Tool, ToolSet, UIMessage } from "ai";
2
2
  import type { DatabaseAdapter } from "./adapters/database";
3
3
  import type { StorageAdapter } from "./adapters/storage";
4
4
  import type { DomainDef } from "./graph/types.ts";
5
+ import type { RequestInterceptorOptions } from "./ai/interceptors/request-interceptor.ts";
5
6
 
6
7
  export type KnowledgeConfig = {
7
8
  swagger?: { url: string };
@@ -24,7 +25,7 @@ export type StorageConfig = {
24
25
 
25
26
  export type CortexAgentDefinition = {
26
27
  systemPrompt: string | ((session: Record<string, unknown> | null) => string | Promise<string>);
27
- tools?: Record<string, unknown>;
28
+ tools?: ToolSet;
28
29
  backendFetch?: {
29
30
  baseUrl: string;
30
31
  apiKey: string;
@@ -33,6 +34,7 @@ export type CortexAgentDefinition = {
33
34
  body: Record<string, unknown>,
34
35
  context: { token: string },
35
36
  ) => Promise<Record<string, unknown>>;
37
+ interceptor?: RequestInterceptorOptions;
36
38
  };
37
39
  loadSessionData?: (token: string) => Promise<Record<string, unknown>>;
38
40
  onToolCall?: (toolCall: {
@@ -90,7 +92,7 @@ export type ResolvedCortexAgentConfig = {
90
92
  apiKey: string;
91
93
  };
92
94
  systemPrompt: string | ((session: Record<string, unknown> | null) => string | Promise<string>);
93
- tools?: Record<string, unknown>;
95
+ tools?: ToolSet;
94
96
  backendFetch?: {
95
97
  baseUrl: string;
96
98
  apiKey: string;
@@ -99,6 +101,7 @@ export type ResolvedCortexAgentConfig = {
99
101
  body: Record<string, unknown>,
100
102
  context: { token: string },
101
103
  ) => Promise<Record<string, unknown>>;
104
+ interceptor?: RequestInterceptorOptions;
102
105
  };
103
106
  loadSessionData?: (token: string) => Promise<Record<string, unknown>>;
104
107
  onToolCall?: (toolCall: {
package/src/index.ts CHANGED
@@ -14,6 +14,13 @@ export type { Thread, AppEnv } from "./types";
14
14
  export type { CortexInstance } from "./factory";
15
15
  export { createCortex } from "./factory";
16
16
 
17
+ // Request interceptor (consumers may customize or replace the default)
18
+ export type {
19
+ ResolvedFile,
20
+ RequestInterceptorOptions,
21
+ } from "./ai/interceptors/request-interceptor";
22
+ export { createRequestInterceptor } from "./ai/interceptors/request-interceptor";
23
+
17
24
  // Tools (consumers may register custom tools or use built-in ones)
18
25
  export { captureFilesTool } from "./ai/tools/capture-files.tool";
19
26
  export { createQueryGraphTool } from "./ai/tools/query-graph.tool";
@@ -30,7 +30,7 @@ export function createChatRoutes() {
30
30
  throw new HTTPException(404, { message: "Not found" });
31
31
  }
32
32
 
33
- return stream(messages, thread, userId, token, config);
33
+ return stream(messages, thread, userId, token, config, c.req.raw.signal);
34
34
  },
35
35
  );
36
36
 
package/src/ws/events.ts CHANGED
@@ -3,4 +3,9 @@ export type ThreadTitleUpdatedEvent = {
3
3
  payload: { threadId: string; title: string };
4
4
  };
5
5
 
6
- export type WsEvent = ThreadTitleUpdatedEvent;
6
+ export type ThreadMessagesUpdatedEvent = {
7
+ type: "thread:messages-updated";
8
+ payload: { threadId: string };
9
+ };
10
+
11
+ export type WsEvent = ThreadTitleUpdatedEvent | ThreadMessagesUpdatedEvent;
package/src/ws/index.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  export { addConnection, removeConnection, getConnections } from "./connections.ts";
2
2
  export { notify } from "./notify.ts";
3
- export type { WsEvent, ThreadTitleUpdatedEvent } from "./events.ts";
3
+ export type { WsEvent, ThreadTitleUpdatedEvent, ThreadMessagesUpdatedEvent } from "./events.ts";