@data-club/ai-hub-server 0.0.1
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/dist/auth/argon2.d.ts +72 -0
- package/dist/auth/argon2.d.ts.map +1 -0
- package/dist/auth/argon2.js +145 -0
- package/dist/auth/argon2.js.map +1 -0
- package/dist/auth/encryption.d.ts +52 -0
- package/dist/auth/encryption.d.ts.map +1 -0
- package/dist/auth/encryption.js +98 -0
- package/dist/auth/encryption.js.map +1 -0
- package/dist/auth/jwt.d.ts +133 -0
- package/dist/auth/jwt.d.ts.map +1 -0
- package/dist/auth/jwt.js +147 -0
- package/dist/auth/jwt.js.map +1 -0
- package/dist/auth/middleware.d.ts +101 -0
- package/dist/auth/middleware.d.ts.map +1 -0
- package/dist/auth/middleware.js +161 -0
- package/dist/auth/middleware.js.map +1 -0
- package/dist/auth/passwordPolicy.d.ts +72 -0
- package/dist/auth/passwordPolicy.d.ts.map +1 -0
- package/dist/auth/passwordPolicy.js +88 -0
- package/dist/auth/passwordPolicy.js.map +1 -0
- package/dist/auth/roleRules.d.ts +200 -0
- package/dist/auth/roleRules.d.ts.map +1 -0
- package/dist/auth/roleRules.js +365 -0
- package/dist/auth/roleRules.js.map +1 -0
- package/dist/auth/userView.d.ts +55 -0
- package/dist/auth/userView.d.ts.map +1 -0
- package/dist/auth/userView.js +77 -0
- package/dist/auth/userView.js.map +1 -0
- package/dist/bootstrap.d.ts +104 -0
- package/dist/bootstrap.d.ts.map +1 -0
- package/dist/bootstrap.js +160 -0
- package/dist/bootstrap.js.map +1 -0
- package/dist/db/connection.d.ts +39 -0
- package/dist/db/connection.d.ts.map +1 -0
- package/dist/db/connection.js +62 -0
- package/dist/db/connection.js.map +1 -0
- package/dist/db/migrate.d.ts +31 -0
- package/dist/db/migrate.d.ts.map +1 -0
- package/dist/db/migrate.js +197 -0
- package/dist/db/migrate.js.map +1 -0
- package/dist/db/schema.d.ts +1902 -0
- package/dist/db/schema.d.ts.map +1 -0
- package/dist/db/schema.js +662 -0
- package/dist/db/schema.js.map +1 -0
- package/dist/db/seed.d.ts +92 -0
- package/dist/db/seed.d.ts.map +1 -0
- package/dist/db/seed.js +160 -0
- package/dist/db/seed.js.map +1 -0
- package/dist/handlers/fetch.d.ts +89 -0
- package/dist/handlers/fetch.d.ts.map +1 -0
- package/dist/handlers/fetch.js +173 -0
- package/dist/handlers/fetch.js.map +1 -0
- package/dist/handlers/nextjs.d.ts +40 -0
- package/dist/handlers/nextjs.d.ts.map +1 -0
- package/dist/handlers/nextjs.js +42 -0
- package/dist/handlers/nextjs.js.map +1 -0
- package/dist/handlers/responses.d.ts +46 -0
- package/dist/handlers/responses.d.ts.map +1 -0
- package/dist/handlers/responses.js +74 -0
- package/dist/handlers/responses.js.map +1 -0
- package/dist/handlers/routeTable.d.ts +21 -0
- package/dist/handlers/routeTable.d.ts.map +1 -0
- package/dist/handlers/routeTable.js +133 -0
- package/dist/handlers/routeTable.js.map +1 -0
- package/dist/handlers/router.d.ts +37 -0
- package/dist/handlers/router.d.ts.map +1 -0
- package/dist/handlers/router.js +82 -0
- package/dist/handlers/router.js.map +1 -0
- package/dist/handlers/sse.d.ts +38 -0
- package/dist/handlers/sse.d.ts.map +1 -0
- package/dist/handlers/sse.js +52 -0
- package/dist/handlers/sse.js.map +1 -0
- package/dist/handlers/types.d.ts +96 -0
- package/dist/handlers/types.d.ts.map +1 -0
- package/dist/handlers/types.js +14 -0
- package/dist/handlers/types.js.map +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/letta/client.d.ts +355 -0
- package/dist/letta/client.d.ts.map +1 -0
- package/dist/letta/client.js +529 -0
- package/dist/letta/client.js.map +1 -0
- package/dist/letta/eventTranslator.d.ts +166 -0
- package/dist/letta/eventTranslator.d.ts.map +1 -0
- package/dist/letta/eventTranslator.js +532 -0
- package/dist/letta/eventTranslator.js.map +1 -0
- package/dist/letta/index.d.ts +13 -0
- package/dist/letta/index.d.ts.map +1 -0
- package/dist/letta/index.js +12 -0
- package/dist/letta/index.js.map +1 -0
- package/dist/letta/modelSettings.d.ts +176 -0
- package/dist/letta/modelSettings.d.ts.map +1 -0
- package/dist/letta/modelSettings.js +314 -0
- package/dist/letta/modelSettings.js.map +1 -0
- package/dist/letta/promptWrapping.d.ts +141 -0
- package/dist/letta/promptWrapping.d.ts.map +1 -0
- package/dist/letta/promptWrapping.js +213 -0
- package/dist/letta/promptWrapping.js.map +1 -0
- package/dist/letta/sandbox.d.ts +100 -0
- package/dist/letta/sandbox.d.ts.map +1 -0
- package/dist/letta/sandbox.js +151 -0
- package/dist/letta/sandbox.js.map +1 -0
- package/dist/letta/settingsFileSeed.d.ts +146 -0
- package/dist/letta/settingsFileSeed.d.ts.map +1 -0
- package/dist/letta/settingsFileSeed.js +177 -0
- package/dist/letta/settingsFileSeed.js.map +1 -0
- package/dist/letta/subprocessThrottle.d.ts +151 -0
- package/dist/letta/subprocessThrottle.d.ts.map +1 -0
- package/dist/letta/subprocessThrottle.js +195 -0
- package/dist/letta/subprocessThrottle.js.map +1 -0
- package/dist/letta/tpmRecovery.d.ts +202 -0
- package/dist/letta/tpmRecovery.d.ts.map +1 -0
- package/dist/letta/tpmRecovery.js +314 -0
- package/dist/letta/tpmRecovery.js.map +1 -0
- package/dist/letta/types.d.ts +54 -0
- package/dist/letta/types.d.ts.map +1 -0
- package/dist/letta/types.js +16 -0
- package/dist/letta/types.js.map +1 -0
- package/dist/observability/logger.d.ts +48 -0
- package/dist/observability/logger.d.ts.map +1 -0
- package/dist/observability/logger.js +81 -0
- package/dist/observability/logger.js.map +1 -0
- package/dist/observability/metrics.d.ts +53 -0
- package/dist/observability/metrics.d.ts.map +1 -0
- package/dist/observability/metrics.js +92 -0
- package/dist/observability/metrics.js.map +1 -0
- package/dist/routes/admin/agents.d.ts +63 -0
- package/dist/routes/admin/agents.d.ts.map +1 -0
- package/dist/routes/admin/agents.js +461 -0
- package/dist/routes/admin/agents.js.map +1 -0
- package/dist/routes/admin/rotatingMessages.d.ts +51 -0
- package/dist/routes/admin/rotatingMessages.d.ts.map +1 -0
- package/dist/routes/admin/rotatingMessages.js +292 -0
- package/dist/routes/admin/rotatingMessages.js.map +1 -0
- package/dist/routes/admin/sessions.d.ts +61 -0
- package/dist/routes/admin/sessions.d.ts.map +1 -0
- package/dist/routes/admin/sessions.js +232 -0
- package/dist/routes/admin/sessions.js.map +1 -0
- package/dist/routes/admin/settings.d.ts +110 -0
- package/dist/routes/admin/settings.d.ts.map +1 -0
- package/dist/routes/admin/settings.js +401 -0
- package/dist/routes/admin/settings.js.map +1 -0
- package/dist/routes/admin/users.d.ts +115 -0
- package/dist/routes/admin/users.d.ts.map +1 -0
- package/dist/routes/admin/users.js +374 -0
- package/dist/routes/admin/users.js.map +1 -0
- package/dist/routes/agents.d.ts +30 -0
- package/dist/routes/agents.d.ts.map +1 -0
- package/dist/routes/agents.js +55 -0
- package/dist/routes/agents.js.map +1 -0
- package/dist/routes/auth.d.ts +121 -0
- package/dist/routes/auth.d.ts.map +1 -0
- package/dist/routes/auth.js +422 -0
- package/dist/routes/auth.js.map +1 -0
- package/dist/routes/chat.d.ts +97 -0
- package/dist/routes/chat.d.ts.map +1 -0
- package/dist/routes/chat.js +484 -0
- package/dist/routes/chat.js.map +1 -0
- package/dist/routes/featureToggles.d.ts +34 -0
- package/dist/routes/featureToggles.d.ts.map +1 -0
- package/dist/routes/featureToggles.js +47 -0
- package/dist/routes/featureToggles.js.map +1 -0
- package/dist/routes/health.d.ts +24 -0
- package/dist/routes/health.d.ts.map +1 -0
- package/dist/routes/health.js +37 -0
- package/dist/routes/health.js.map +1 -0
- package/dist/routes/metrics.d.ts +35 -0
- package/dist/routes/metrics.d.ts.map +1 -0
- package/dist/routes/metrics.js +106 -0
- package/dist/routes/metrics.js.map +1 -0
- package/dist/routes/sessions.d.ts +161 -0
- package/dist/routes/sessions.d.ts.map +1 -0
- package/dist/routes/sessions.js +1501 -0
- package/dist/routes/sessions.js.map +1 -0
- package/dist/routes/stubs.d.ts +25 -0
- package/dist/routes/stubs.d.ts.map +1 -0
- package/dist/routes/stubs.js +25 -0
- package/dist/routes/stubs.js.map +1 -0
- package/dist/state/cron.d.ts +58 -0
- package/dist/state/cron.d.ts.map +1 -0
- package/dist/state/cron.js +160 -0
- package/dist/state/cron.js.map +1 -0
- package/dist/state/rotationRunner.d.ts +96 -0
- package/dist/state/rotationRunner.d.ts.map +1 -0
- package/dist/state/rotationRunner.js +246 -0
- package/dist/state/rotationRunner.js.map +1 -0
- package/dist/state/rotationScheduler.d.ts +76 -0
- package/dist/state/rotationScheduler.d.ts.map +1 -0
- package/dist/state/rotationScheduler.js +124 -0
- package/dist/state/rotationScheduler.js.map +1 -0
- package/dist/state/turnRegistry.d.ts +95 -0
- package/dist/state/turnRegistry.d.ts.map +1 -0
- package/dist/state/turnRegistry.js +149 -0
- package/dist/state/turnRegistry.js.map +1 -0
- package/dist/state/turnRunner.d.ts +134 -0
- package/dist/state/turnRunner.d.ts.map +1 -0
- package/dist/state/turnRunner.js +476 -0
- package/dist/state/turnRunner.js.map +1 -0
- package/package.json +47 -0
- package/src/auth/argon2.ts +152 -0
- package/src/auth/encryption.ts +110 -0
- package/src/auth/jwt.ts +217 -0
- package/src/auth/middleware.ts +250 -0
- package/src/auth/passwordPolicy.ts +99 -0
- package/src/auth/roleRules.ts +497 -0
- package/src/auth/userView.ts +88 -0
- package/src/bootstrap.ts +254 -0
- package/src/db/connection.ts +75 -0
- package/src/db/migrate.ts +212 -0
- package/src/db/schema.ts +750 -0
- package/src/db/seed.ts +236 -0
- package/src/handlers/fetch.ts +251 -0
- package/src/handlers/nextjs.ts +49 -0
- package/src/handlers/responses.ts +95 -0
- package/src/handlers/routeTable.ts +188 -0
- package/src/handlers/router.ts +96 -0
- package/src/handlers/sse.ts +55 -0
- package/src/handlers/types.ts +104 -0
- package/src/index.ts +60 -0
- package/src/letta/client.ts +860 -0
- package/src/letta/eventTranslator.ts +667 -0
- package/src/letta/index.ts +46 -0
- package/src/letta/lettaCliShim.mjs +40 -0
- package/src/letta/sandbox.ts +167 -0
- package/src/letta/settingsFileSeed.ts +254 -0
- package/src/letta/subprocessThrottle.ts +251 -0
- package/src/letta/types.ts +65 -0
- package/src/observability/logger.ts +103 -0
- package/src/observability/metrics.ts +114 -0
- package/src/routes/admin/agents.ts +574 -0
- package/src/routes/admin/rotatingMessages.ts +351 -0
- package/src/routes/admin/sessions.ts +283 -0
- package/src/routes/admin/settings.ts +463 -0
- package/src/routes/admin/users.ts +455 -0
- package/src/routes/agents.ts +66 -0
- package/src/routes/auth.ts +513 -0
- package/src/routes/chat.ts +534 -0
- package/src/routes/featureToggles.ts +52 -0
- package/src/routes/health.ts +44 -0
- package/src/routes/metrics.ts +111 -0
- package/src/routes/sessions.ts +1878 -0
- package/src/state/cron.ts +179 -0
- package/src/state/rotationRunner.ts +326 -0
- package/src/state/rotationScheduler.ts +164 -0
- package/src/state/turnRegistry.ts +210 -0
- package/src/state/turnRunner.ts +588 -0
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-authoritative turn lifecycle (§6.6, §10).
|
|
3
|
+
*
|
|
4
|
+
* `runTurn(...)` is the one entry point that:
|
|
5
|
+
*
|
|
6
|
+
* 1. Inserts the `turn` row in status=running, phase=model_text.
|
|
7
|
+
* 2. Opens a live channel (`turnRegistry.openTurnChannel`) so concurrent
|
|
8
|
+
* resumption attempts can attach.
|
|
9
|
+
* 3. Emits `stream_start` (seq 0).
|
|
10
|
+
* 4. Calls `LettaClient.streamUserMessage(...)` which spawns a Letta
|
|
11
|
+
* Code subprocess, acquires the throttle lease, sends the user
|
|
12
|
+
* message. Returns a `ChatTurnHandle`.
|
|
13
|
+
* 5. Iterates the handle's `events` stream:
|
|
14
|
+
* - Non-terminal SDK messages → `translateSdkMessage(...)` →
|
|
15
|
+
* stamps + persists + broadcasts each emitted ai-hub event.
|
|
16
|
+
* - Terminal `result` SDK message → `finalizeTerminal(...)` →
|
|
17
|
+
* emits the closing `assistant_message_complete` + `stream_end`
|
|
18
|
+
* with the correct reason (`done` / `cancelled` / `error`).
|
|
19
|
+
* 6. On cancellation (caller signal OR `requestTurnCancellation`):
|
|
20
|
+
* calls `handle.abort()` to send `interrupt` to the CLI, keeps
|
|
21
|
+
* draining until terminal, then closes.
|
|
22
|
+
* 7. On throttle rejection (`ServerOverloadedError`): emits
|
|
23
|
+
* `error{code:"server_overloaded"}` + `stream_end{error}`.
|
|
24
|
+
* Transitions turn → failed.
|
|
25
|
+
* 8. On unhandled error: emits `error` + `stream_end{error}`,
|
|
26
|
+
* `turn.status = "failed"`.
|
|
27
|
+
* 9. Always calls `handle.close()` in finally (releases subprocess +
|
|
28
|
+
* throttle lease).
|
|
29
|
+
*
|
|
30
|
+
* @see SPECIFICATIONS.md §6.3.2 — chat / SSE
|
|
31
|
+
* @see SPECIFICATIONS.md §6.6 — state-machine ownership
|
|
32
|
+
* @see SPECIFICATIONS.md §6.9 — cancellation
|
|
33
|
+
* @see SPECIFICATIONS.md §9 — wire protocol
|
|
34
|
+
* @see SPECIFICATIONS.md §10 — state machine
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { randomUUID } from "node:crypto";
|
|
38
|
+
|
|
39
|
+
import { eq, sql } from "drizzle-orm";
|
|
40
|
+
|
|
41
|
+
import type {
|
|
42
|
+
AIHubEvent,
|
|
43
|
+
AIHubEventState,
|
|
44
|
+
RunningPhase,
|
|
45
|
+
TurnStatus,
|
|
46
|
+
} from "@data-club/ai-hub";
|
|
47
|
+
|
|
48
|
+
import { messageUsage, turn, turnEvent } from "../db/schema.js";
|
|
49
|
+
import type { DrizzleDB } from "../db/connection.js";
|
|
50
|
+
import { ServerOverloadedError, type ChatTurnHandle, type LettaClient } from "../letta/client.js";
|
|
51
|
+
import {
|
|
52
|
+
finalizeAssistantMessage,
|
|
53
|
+
finalizeTerminal,
|
|
54
|
+
initialTranslatorState,
|
|
55
|
+
translateSdkMessage,
|
|
56
|
+
type TranslatorState,
|
|
57
|
+
type UnsequencedEvent,
|
|
58
|
+
} from "../letta/eventTranslator.js";
|
|
59
|
+
import type { Logger } from "../observability/logger.js";
|
|
60
|
+
import * as metrics from "../observability/metrics.js";
|
|
61
|
+
|
|
62
|
+
/** The ai-hub SSE wire-protocol version this server speaks (§9.1). */
|
|
63
|
+
export const WIRE_PROTOCOL_VERSION = "1.0";
|
|
64
|
+
|
|
65
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
66
|
+
/* Inputs */
|
|
67
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
68
|
+
|
|
69
|
+
/** Parameters for `runTurn`. */
|
|
70
|
+
export interface RunTurnOpts {
|
|
71
|
+
/** Drizzle handle. */
|
|
72
|
+
db: DrizzleDB;
|
|
73
|
+
/** Letta client wrapper (production wraps the SDKs; tests mock). */
|
|
74
|
+
lettaClient: LettaClient;
|
|
75
|
+
/** Pino logger for this request. */
|
|
76
|
+
logger: Logger;
|
|
77
|
+
/** Per-request id for log correlation (stamped on `error` events). */
|
|
78
|
+
requestId: string;
|
|
79
|
+
/** Owner of the session. */
|
|
80
|
+
userId: string;
|
|
81
|
+
/** ai-hub session id this turn belongs to. */
|
|
82
|
+
sessionId: string;
|
|
83
|
+
/** The Letta conversation id stored on the session row. */
|
|
84
|
+
lettaConversationId: string;
|
|
85
|
+
/** The Letta agent id stored on the session row. */
|
|
86
|
+
lettaAgentId: string;
|
|
87
|
+
/**
|
|
88
|
+
* Step-12.5 — absolute path of the per-session sandbox folder, or
|
|
89
|
+
* `null` when the session has no sandbox (today's stateless shape).
|
|
90
|
+
* Threaded as `cwd` into the Letta Code subprocess spawn so the
|
|
91
|
+
* agent's filesystem tools operate inside the seeded folder. When
|
|
92
|
+
* non-null, also flips the disallowed-tools list to re-enable
|
|
93
|
+
* `Read` / `Write` / `Bash` / etc.
|
|
94
|
+
*/
|
|
95
|
+
sandboxDir: string | null;
|
|
96
|
+
/** The user's typed message. */
|
|
97
|
+
userMessage: string;
|
|
98
|
+
/** Optional idempotency key from the request body. */
|
|
99
|
+
idempotencyKey?: string;
|
|
100
|
+
/**
|
|
101
|
+
* Listener that receives every event the runner emits. The chat
|
|
102
|
+
* handler subscribes its SSE writer here. `null` events signal
|
|
103
|
+
* "channel closed; no more events coming."
|
|
104
|
+
*/
|
|
105
|
+
onEvent: (event: AIHubEvent | null) => void;
|
|
106
|
+
/**
|
|
107
|
+
* Caller-supplied AbortSignal. When aborted, the runner calls
|
|
108
|
+
* `handle.abort()` on the spawned Letta Code subprocess and emits
|
|
109
|
+
* `stream_end{reason:"cancelled"}` after the upstream terminal
|
|
110
|
+
* `result` arrives.
|
|
111
|
+
*/
|
|
112
|
+
signal?: AbortSignal;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Returned by `runTurn`. The runner returns after `turn_event`s are committed. */
|
|
116
|
+
export interface RunTurnResult {
|
|
117
|
+
/** The new turn id (the `turn_id` carried in `stream_start`). */
|
|
118
|
+
turnId: string;
|
|
119
|
+
/** Terminal status (`done` / `cancelled` / `failed`). */
|
|
120
|
+
status: Exclude<TurnStatus, "idle" | "running">;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
124
|
+
/* Runner */
|
|
125
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Runs one turn from "user pressed Send" to terminal status. Returns a
|
|
129
|
+
* promise that resolves when the runner has persisted the terminal
|
|
130
|
+
* event(s), closed the live channel, and torn down the Letta Code
|
|
131
|
+
* subprocess.
|
|
132
|
+
*
|
|
133
|
+
* The caller is expected to keep `onEvent` alive for the entire run.
|
|
134
|
+
*
|
|
135
|
+
* @param opts - see `RunTurnOpts`.
|
|
136
|
+
* @returns the turn id and terminal status.
|
|
137
|
+
*/
|
|
138
|
+
export async function runTurn(opts: RunTurnOpts): Promise<RunTurnResult> {
|
|
139
|
+
const turnId = randomUUID();
|
|
140
|
+
const startedAt = new Date().toISOString();
|
|
141
|
+
const startedMs = Date.now();
|
|
142
|
+
|
|
143
|
+
metrics.chatRequestsTotal.inc();
|
|
144
|
+
|
|
145
|
+
// Step 1 — insert turn row in running/model_text.
|
|
146
|
+
opts.db
|
|
147
|
+
.insert(turn)
|
|
148
|
+
.values({
|
|
149
|
+
id: turnId,
|
|
150
|
+
sessionId: opts.sessionId,
|
|
151
|
+
status: "running",
|
|
152
|
+
phase: "model_text",
|
|
153
|
+
lastSeq: 0,
|
|
154
|
+
idempotencyKey: opts.idempotencyKey ?? null,
|
|
155
|
+
startedAt,
|
|
156
|
+
})
|
|
157
|
+
.run();
|
|
158
|
+
|
|
159
|
+
const { openTurnChannel } = await import("./turnRegistry.js");
|
|
160
|
+
const publisher = openTurnChannel(turnId);
|
|
161
|
+
|
|
162
|
+
// Compose the caller's signal with the registry-supplied cancel
|
|
163
|
+
// signal: either side can fire to cancel the turn. The registry
|
|
164
|
+
// cancel is what `POST /chat/:id/cancel` triggers from a separate
|
|
165
|
+
// HTTP request.
|
|
166
|
+
const registryCancelSignal = publisher.cancelSignal;
|
|
167
|
+
const cancelController = new AbortController();
|
|
168
|
+
const propagateCancel = (): void => cancelController.abort();
|
|
169
|
+
if (opts.signal) {
|
|
170
|
+
if (opts.signal.aborted) cancelController.abort();
|
|
171
|
+
else opts.signal.addEventListener("abort", propagateCancel, { once: true });
|
|
172
|
+
}
|
|
173
|
+
if (registryCancelSignal.aborted) cancelController.abort();
|
|
174
|
+
else registryCancelSignal.addEventListener("abort", propagateCancel, { once: true });
|
|
175
|
+
|
|
176
|
+
let translatorState: TranslatorState = initialTranslatorState();
|
|
177
|
+
let nextSeq = 0;
|
|
178
|
+
|
|
179
|
+
/** Stamps + persists + broadcasts one event. Also fills in
|
|
180
|
+
* `request_id` on error events — the translator emits them without
|
|
181
|
+
* a request_id (it has no access to the request), so we add it here. */
|
|
182
|
+
const emit = (unsequenced: UnsequencedEvent): AIHubEvent => {
|
|
183
|
+
let withRequestId: UnsequencedEvent = unsequenced;
|
|
184
|
+
if (unsequenced.event === "error") {
|
|
185
|
+
withRequestId = {
|
|
186
|
+
...unsequenced,
|
|
187
|
+
data: { ...unsequenced.data, request_id: opts.requestId },
|
|
188
|
+
} as UnsequencedEvent;
|
|
189
|
+
}
|
|
190
|
+
const event = { ...withRequestId, seq: nextSeq++ } as AIHubEvent;
|
|
191
|
+
const persistedData =
|
|
192
|
+
"data" in event ? (event.data as unknown as object) : ({} as object);
|
|
193
|
+
opts.db
|
|
194
|
+
.insert(turnEvent)
|
|
195
|
+
.values({
|
|
196
|
+
turnId,
|
|
197
|
+
seq: event.seq,
|
|
198
|
+
eventName: event.event,
|
|
199
|
+
data: persistedData,
|
|
200
|
+
state: event.state as unknown as object,
|
|
201
|
+
})
|
|
202
|
+
.run();
|
|
203
|
+
publisher.publish(event);
|
|
204
|
+
opts.onEvent(event);
|
|
205
|
+
return event;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
/** Persists turn-row mutations driven by translator-state changes. */
|
|
209
|
+
const syncTurnRow = (status: TurnStatus, phase: RunningPhase | null): void => {
|
|
210
|
+
const sets: Record<string, unknown> = { lastSeq: nextSeq - 1 };
|
|
211
|
+
if (status !== "running") {
|
|
212
|
+
sets.status = status;
|
|
213
|
+
sets.phase = null;
|
|
214
|
+
sets.finishedAt = new Date().toISOString();
|
|
215
|
+
} else {
|
|
216
|
+
sets.status = "running";
|
|
217
|
+
sets.phase = phase;
|
|
218
|
+
}
|
|
219
|
+
opts.db.update(turn).set(sets).where(eq(turn.id, turnId)).run();
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
let handle: ChatTurnHandle | null = null;
|
|
223
|
+
|
|
224
|
+
try {
|
|
225
|
+
// Step 3 — stream_start (emitted once, at the top of the run).
|
|
226
|
+
emit({
|
|
227
|
+
event: "stream_start",
|
|
228
|
+
data: {
|
|
229
|
+
protocol_version: WIRE_PROTOCOL_VERSION,
|
|
230
|
+
session_id: opts.sessionId,
|
|
231
|
+
turn_id: turnId,
|
|
232
|
+
},
|
|
233
|
+
state: { status: "running", phase: "model_text" } satisfies AIHubEventState,
|
|
234
|
+
});
|
|
235
|
+
syncTurnRow("running", "model_text");
|
|
236
|
+
|
|
237
|
+
if (cancelController.signal.aborted) {
|
|
238
|
+
// Caller cancelled before we even spawned — emit cancelled and
|
|
239
|
+
// bail without ever calling streamUserMessage.
|
|
240
|
+
throw new AbortLikeError();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Step 4 — spawn + send. Throttle gate runs inside
|
|
244
|
+
// streamUserMessage; ServerOverloadedError is caught below and
|
|
245
|
+
// turned into a `server_overloaded` SSE error event.
|
|
246
|
+
const lettaStartedMs = Date.now();
|
|
247
|
+
try {
|
|
248
|
+
handle = await opts.lettaClient.streamUserMessage({
|
|
249
|
+
conversationId: opts.lettaConversationId,
|
|
250
|
+
lettaAgentId: opts.lettaAgentId,
|
|
251
|
+
userMessage: opts.userMessage,
|
|
252
|
+
...(opts.sandboxDir !== null ? { cwd: opts.sandboxDir } : {}),
|
|
253
|
+
});
|
|
254
|
+
} catch (err) {
|
|
255
|
+
metrics.lettaRequestDurationMs.set(Date.now() - lettaStartedMs);
|
|
256
|
+
throw err;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Wire the cancel signal to the handle's abort once we have one.
|
|
260
|
+
// If the signal already fired during the await above, abort + bail
|
|
261
|
+
// immediately. Otherwise install a listener for future aborts.
|
|
262
|
+
if (cancelController.signal.aborted) {
|
|
263
|
+
void handle.abort();
|
|
264
|
+
throw new AbortLikeError();
|
|
265
|
+
}
|
|
266
|
+
const onCancel = (): void => {
|
|
267
|
+
void handle?.abort();
|
|
268
|
+
};
|
|
269
|
+
cancelController.signal.addEventListener("abort", onCancel, { once: true });
|
|
270
|
+
|
|
271
|
+
// Step 5 — drain the SDK message stream.
|
|
272
|
+
// Track the last Letta run id we see — used to fetch usage stats
|
|
273
|
+
// after the terminal `result`. The SDK strips Letta message ids
|
|
274
|
+
// from its translated payloads, so we re-fetch via REST
|
|
275
|
+
// (`getRunUsageStats(runId)`) to get both the usage_statistics
|
|
276
|
+
// and the last assistant message id we'll stamp the badge on.
|
|
277
|
+
let lastRunId: string | null = null;
|
|
278
|
+
try {
|
|
279
|
+
for await (const sdkMsg of handle.events) {
|
|
280
|
+
// SDKAssistantMessage / SDKReasoningMessage / SDKToolCallMessage
|
|
281
|
+
// each carry `runId`. Take the most recent — a multi-step turn
|
|
282
|
+
// can have several runs, and we want the latest.
|
|
283
|
+
if ((sdkMsg as { runId?: string }).runId) {
|
|
284
|
+
lastRunId = (sdkMsg as { runId: string }).runId;
|
|
285
|
+
}
|
|
286
|
+
if (sdkMsg.type === "result") {
|
|
287
|
+
// Terminal — finalizeTerminal emits the right closing events
|
|
288
|
+
// based on success / interrupted / error.
|
|
289
|
+
// Override the reason to `cancelled` when our cancel fired
|
|
290
|
+
// (even if the SDK reports success — see the cancel fixture
|
|
291
|
+
// open-question-5 finding: the SDK may complete the run if
|
|
292
|
+
// the interrupt arrived during a tool call).
|
|
293
|
+
const cancelOverride = cancelController.signal.aborted;
|
|
294
|
+
const terminalToUse = cancelOverride
|
|
295
|
+
? { ...sdkMsg, success: false, errorCode: "interrupted" as const }
|
|
296
|
+
: sdkMsg;
|
|
297
|
+
const step = finalizeTerminal(terminalToUse, translatorState);
|
|
298
|
+
translatorState = step.state;
|
|
299
|
+
for (const ev of step.events) {
|
|
300
|
+
emit(ev);
|
|
301
|
+
}
|
|
302
|
+
syncTurnRow(translatorState.status, null);
|
|
303
|
+
break;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const step = translateSdkMessage(sdkMsg, translatorState);
|
|
307
|
+
translatorState = step.state;
|
|
308
|
+
for (const ev of step.events) {
|
|
309
|
+
emit(ev);
|
|
310
|
+
}
|
|
311
|
+
syncTurnRow(translatorState.status, translatorState.phase);
|
|
312
|
+
}
|
|
313
|
+
} finally {
|
|
314
|
+
cancelController.signal.removeEventListener("abort", onCancel);
|
|
315
|
+
metrics.lettaRequestDurationMs.set(Date.now() - lettaStartedMs);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Step 5.5 — usage emission + persistence. Only fire on successful
|
|
319
|
+
// termination (cancelled / failed turns may or may not have a
|
|
320
|
+
// usable runId, and the badge isn't load-bearing). Best-effort:
|
|
321
|
+
// any failure here logs and continues — we already emitted
|
|
322
|
+
// stream_end above, so the turn is "done" from the client's POV.
|
|
323
|
+
if (
|
|
324
|
+
lastRunId !== null &&
|
|
325
|
+
translatorState.status === "done"
|
|
326
|
+
) {
|
|
327
|
+
try {
|
|
328
|
+
const usage = await opts.lettaClient.getRunUsageStats(lastRunId);
|
|
329
|
+
if (usage && usage.lastAssistantMessageId) {
|
|
330
|
+
// Persist for /sessions/{id}/messages history rendering.
|
|
331
|
+
opts.db
|
|
332
|
+
.insert(messageUsage)
|
|
333
|
+
.values({
|
|
334
|
+
lettaMessageId: usage.lastAssistantMessageId,
|
|
335
|
+
sessionId: opts.sessionId,
|
|
336
|
+
promptTokens: usage.promptTokens,
|
|
337
|
+
completionTokens: usage.completionTokens,
|
|
338
|
+
totalTokens: usage.totalTokens,
|
|
339
|
+
cachedInputTokens: usage.cachedInputTokens,
|
|
340
|
+
cacheWriteTokens: usage.cacheWriteTokens,
|
|
341
|
+
reasoningTokens: usage.reasoningTokens,
|
|
342
|
+
contextTokens: usage.contextTokens,
|
|
343
|
+
runId: lastRunId,
|
|
344
|
+
})
|
|
345
|
+
.onConflictDoUpdate({
|
|
346
|
+
target: messageUsage.lettaMessageId,
|
|
347
|
+
set: {
|
|
348
|
+
promptTokens: usage.promptTokens,
|
|
349
|
+
completionTokens: usage.completionTokens,
|
|
350
|
+
totalTokens: usage.totalTokens,
|
|
351
|
+
cachedInputTokens: usage.cachedInputTokens,
|
|
352
|
+
cacheWriteTokens: usage.cacheWriteTokens,
|
|
353
|
+
reasoningTokens: usage.reasoningTokens,
|
|
354
|
+
contextTokens: usage.contextTokens,
|
|
355
|
+
runId: lastRunId,
|
|
356
|
+
},
|
|
357
|
+
})
|
|
358
|
+
.run();
|
|
359
|
+
// Live wire event — UI can attach the badge immediately
|
|
360
|
+
// without waiting for a /messages refetch.
|
|
361
|
+
opts.onEvent({
|
|
362
|
+
seq: 0, // emit() bypassed here; the stream's already drained.
|
|
363
|
+
event: "usage",
|
|
364
|
+
data: {
|
|
365
|
+
letta_message_id: usage.lastAssistantMessageId,
|
|
366
|
+
...(usage.promptTokens !== null ? { input: usage.promptTokens } : {}),
|
|
367
|
+
...(usage.completionTokens !== null ? { output: usage.completionTokens } : {}),
|
|
368
|
+
...(usage.cachedInputTokens !== null ? { cache_read: usage.cachedInputTokens } : {}),
|
|
369
|
+
...(usage.cacheWriteTokens !== null ? { cache_write: usage.cacheWriteTokens } : {}),
|
|
370
|
+
...(usage.reasoningTokens !== null ? { reasoning: usage.reasoningTokens } : {}),
|
|
371
|
+
...(usage.contextTokens !== null ? { context: usage.contextTokens } : {}),
|
|
372
|
+
...(usage.totalTokens !== null ? { total: usage.totalTokens } : {}),
|
|
373
|
+
},
|
|
374
|
+
state: { status: "done", phase: null } satisfies AIHubEventState,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
} catch (err) {
|
|
378
|
+
opts.logger.warn({ err, runId: lastRunId }, "usage-stats fetch/persist failed");
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
publisher.close();
|
|
383
|
+
opts.onEvent(null);
|
|
384
|
+
metrics.chatDurationMs.set(Date.now() - startedMs);
|
|
385
|
+
|
|
386
|
+
const finalStatus = translatorState.status as RunTurnResult["status"];
|
|
387
|
+
opts.logger.info(
|
|
388
|
+
{
|
|
389
|
+
event: `turn_${finalStatus}`,
|
|
390
|
+
turn_id: turnId,
|
|
391
|
+
session_id: opts.sessionId,
|
|
392
|
+
},
|
|
393
|
+
`Turn ${finalStatus}.`,
|
|
394
|
+
);
|
|
395
|
+
return { turnId, status: finalStatus };
|
|
396
|
+
} catch (err) {
|
|
397
|
+
if (err instanceof ServerOverloadedError) {
|
|
398
|
+
emit({
|
|
399
|
+
event: "error",
|
|
400
|
+
data: {
|
|
401
|
+
code: "server_overloaded",
|
|
402
|
+
message: err.message,
|
|
403
|
+
},
|
|
404
|
+
state: { status: "failed", phase: null },
|
|
405
|
+
});
|
|
406
|
+
emit({
|
|
407
|
+
event: "stream_end",
|
|
408
|
+
data: { reason: "error" },
|
|
409
|
+
state: { status: "failed", phase: null },
|
|
410
|
+
});
|
|
411
|
+
syncTurnRow("failed", null);
|
|
412
|
+
publisher.close();
|
|
413
|
+
opts.onEvent(null);
|
|
414
|
+
metrics.chatDurationMs.set(Date.now() - startedMs);
|
|
415
|
+
opts.logger.warn(
|
|
416
|
+
{
|
|
417
|
+
event: "turn_rejected_overloaded",
|
|
418
|
+
turn_id: turnId,
|
|
419
|
+
session_id: opts.sessionId,
|
|
420
|
+
gate: err.gate,
|
|
421
|
+
detail: err.detail,
|
|
422
|
+
},
|
|
423
|
+
"Turn rejected: server overloaded.",
|
|
424
|
+
);
|
|
425
|
+
return { turnId, status: "failed" };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (cancelController.signal.aborted || err instanceof AbortLikeError) {
|
|
429
|
+
// Emit any partial assistant text the turn produced before the
|
|
430
|
+
// cancel landed, then the cancelled stream_end.
|
|
431
|
+
for (const ev of finalizeAssistantMessage(translatorState, "cancelled")) {
|
|
432
|
+
emit(ev);
|
|
433
|
+
}
|
|
434
|
+
emit({
|
|
435
|
+
event: "stream_end",
|
|
436
|
+
data: { reason: "cancelled" },
|
|
437
|
+
state: { status: "cancelled", phase: null },
|
|
438
|
+
});
|
|
439
|
+
syncTurnRow("cancelled", null);
|
|
440
|
+
publisher.close();
|
|
441
|
+
opts.onEvent(null);
|
|
442
|
+
metrics.chatDurationMs.set(Date.now() - startedMs);
|
|
443
|
+
opts.logger.info(
|
|
444
|
+
{ event: "turn_cancelled", turn_id: turnId, session_id: opts.sessionId },
|
|
445
|
+
"Turn cancelled.",
|
|
446
|
+
);
|
|
447
|
+
return { turnId, status: "cancelled" };
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
451
|
+
emit({
|
|
452
|
+
event: "error",
|
|
453
|
+
data: { code: classifyServerError(err), message },
|
|
454
|
+
state: { status: "failed", phase: null },
|
|
455
|
+
});
|
|
456
|
+
emit({
|
|
457
|
+
event: "stream_end",
|
|
458
|
+
data: { reason: "error" },
|
|
459
|
+
state: { status: "failed", phase: null },
|
|
460
|
+
});
|
|
461
|
+
syncTurnRow("failed", null);
|
|
462
|
+
publisher.close();
|
|
463
|
+
opts.onEvent(null);
|
|
464
|
+
metrics.chatDurationMs.set(Date.now() - startedMs);
|
|
465
|
+
opts.logger.error(
|
|
466
|
+
{
|
|
467
|
+
event: "turn_failed",
|
|
468
|
+
turn_id: turnId,
|
|
469
|
+
session_id: opts.sessionId,
|
|
470
|
+
err: message,
|
|
471
|
+
},
|
|
472
|
+
"Turn failed.",
|
|
473
|
+
);
|
|
474
|
+
return { turnId, status: "failed" };
|
|
475
|
+
} finally {
|
|
476
|
+
if (handle) {
|
|
477
|
+
try {
|
|
478
|
+
handle.close();
|
|
479
|
+
} catch {
|
|
480
|
+
// best-effort
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
if (opts.signal) opts.signal.removeEventListener("abort", propagateCancel);
|
|
484
|
+
registryCancelSignal.removeEventListener("abort", propagateCancel);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
489
|
+
/* Helpers */
|
|
490
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
491
|
+
|
|
492
|
+
/** Sentinel for "the cancel path fired" — the catch arm checks
|
|
493
|
+
* `err instanceof AbortLikeError` plus the cancel signal. */
|
|
494
|
+
class AbortLikeError extends Error {
|
|
495
|
+
constructor() {
|
|
496
|
+
super("Turn aborted.");
|
|
497
|
+
this.name = "AbortLikeError";
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Maps an unhandled runner error onto an ai-hub `error.code`.
|
|
503
|
+
*
|
|
504
|
+
* Post-migration the set of error sources is much smaller — most of
|
|
505
|
+
* the provider/upstream signals come through the translator (which
|
|
506
|
+
* maps them via `classifyErrorCode`). What lands here is
|
|
507
|
+
* spawn-failure, network-down-to-Letta, decryption errors, and other
|
|
508
|
+
* "couldn't even start the turn" failures.
|
|
509
|
+
*
|
|
510
|
+
* @param err - the unhandled error.
|
|
511
|
+
* @returns the SSE `error.code` to emit.
|
|
512
|
+
*/
|
|
513
|
+
function classifyServerError(err: unknown): "provider_error" | "internal" {
|
|
514
|
+
if (err instanceof Error) {
|
|
515
|
+
const msg = err.message.toLowerCase();
|
|
516
|
+
if (msg.includes("letta") || msg.includes("upstream")) return "provider_error";
|
|
517
|
+
}
|
|
518
|
+
return "internal";
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
522
|
+
/* Resumption helpers */
|
|
523
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Replays persisted `turn_event` rows newer than `fromSeq`, returned as
|
|
527
|
+
* ai-hub-shaped events. Used by `GET /chat/:turn_id/events?from_seq=N`
|
|
528
|
+
* for the replay half of resumption.
|
|
529
|
+
*
|
|
530
|
+
* @param db - Drizzle handle.
|
|
531
|
+
* @param turnId - the turn id.
|
|
532
|
+
* @param fromSeq - replays events with `seq > fromSeq`.
|
|
533
|
+
* @returns the persisted events.
|
|
534
|
+
*
|
|
535
|
+
* @see SPECIFICATIONS.md §6.3.2 — resumption
|
|
536
|
+
*/
|
|
537
|
+
export function replayPersistedEvents(
|
|
538
|
+
db: DrizzleDB,
|
|
539
|
+
turnId: string,
|
|
540
|
+
fromSeq: number,
|
|
541
|
+
): ReadonlyArray<AIHubEvent> {
|
|
542
|
+
const rows = db
|
|
543
|
+
.select()
|
|
544
|
+
.from(turnEvent)
|
|
545
|
+
.where(sql`${turnEvent.turnId} = ${turnId} AND ${turnEvent.seq} > ${fromSeq}`)
|
|
546
|
+
.orderBy(turnEvent.seq)
|
|
547
|
+
.all();
|
|
548
|
+
return rows.map((row) => ({
|
|
549
|
+
event: row.eventName,
|
|
550
|
+
data: row.data as Record<string, unknown>,
|
|
551
|
+
state: row.state as AIHubEventState,
|
|
552
|
+
seq: row.seq,
|
|
553
|
+
})) as unknown as AIHubEvent[];
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Idempotently marks a turn as cancelled in the DB. Returns the row's
|
|
558
|
+
* resulting status. Called by `POST /chat/:turn_id/cancel` after firing
|
|
559
|
+
* `requestTurnCancellation`. The runner's catch arm also persists
|
|
560
|
+
* `cancelled` via `syncTurnRow`; this helper just guarantees the row
|
|
561
|
+
* is updated when the runner has already exited (terminal-after-cancel
|
|
562
|
+
* race).
|
|
563
|
+
*
|
|
564
|
+
* Does not overwrite `done` / `failed` — those are terminal states.
|
|
565
|
+
*
|
|
566
|
+
* @param db - Drizzle handle.
|
|
567
|
+
* @param turnId - the turn to cancel.
|
|
568
|
+
* @returns the row's resulting status, or `null` if no such row.
|
|
569
|
+
*/
|
|
570
|
+
export function markCancelled(
|
|
571
|
+
db: DrizzleDB,
|
|
572
|
+
turnId: string,
|
|
573
|
+
): TurnStatus | null {
|
|
574
|
+
const row = db.select().from(turn).where(eq(turn.id, turnId)).get();
|
|
575
|
+
if (!row) return null;
|
|
576
|
+
if (row.status === "running") {
|
|
577
|
+
db.update(turn)
|
|
578
|
+
.set({
|
|
579
|
+
status: "cancelled",
|
|
580
|
+
phase: null,
|
|
581
|
+
finishedAt: new Date().toISOString(),
|
|
582
|
+
})
|
|
583
|
+
.where(eq(turn.id, turnId))
|
|
584
|
+
.run();
|
|
585
|
+
return "cancelled";
|
|
586
|
+
}
|
|
587
|
+
return row.status;
|
|
588
|
+
}
|