@data-club/ai-hub-server 0.0.9 → 0.0.11
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/encryption.d.ts +5 -5
- package/dist/auth/encryption.js +5 -5
- package/dist/auth/userView.d.ts +8 -15
- package/dist/auth/userView.d.ts.map +1 -1
- package/dist/auth/userView.js +10 -23
- package/dist/auth/userView.js.map +1 -1
- package/dist/bootstrap.d.ts +2 -8
- package/dist/bootstrap.d.ts.map +1 -1
- package/dist/bootstrap.js +2 -35
- package/dist/bootstrap.js.map +1 -1
- package/dist/db/migrate.js +6 -17
- package/dist/db/migrate.js.map +1 -1
- package/dist/db/schema.d.ts +17 -263
- package/dist/db/schema.d.ts.map +1 -1
- package/dist/db/schema.js +8 -57
- package/dist/db/schema.js.map +1 -1
- package/dist/db/seed.d.ts +4 -19
- package/dist/db/seed.d.ts.map +1 -1
- package/dist/db/seed.js +6 -33
- package/dist/db/seed.js.map +1 -1
- package/dist/handlers/fetch.d.ts +6 -7
- package/dist/handlers/fetch.d.ts.map +1 -1
- package/dist/handlers/fetch.js +1 -3
- package/dist/handlers/fetch.js.map +1 -1
- package/dist/handlers/types.d.ts +5 -6
- package/dist/handlers/types.d.ts.map +1 -1
- package/dist/letta/client.d.ts +86 -263
- package/dist/letta/client.d.ts.map +1 -1
- package/dist/letta/client.js +99 -404
- package/dist/letta/client.js.map +1 -1
- package/dist/letta/index.d.ts +6 -8
- package/dist/letta/index.d.ts.map +1 -1
- package/dist/letta/index.js +5 -7
- package/dist/letta/index.js.map +1 -1
- package/dist/letta/sandbox.d.ts +45 -9
- package/dist/letta/sandbox.d.ts.map +1 -1
- package/dist/letta/sandbox.js +123 -11
- package/dist/letta/sandbox.js.map +1 -1
- package/dist/letta/types.d.ts +6 -49
- package/dist/letta/types.d.ts.map +1 -1
- package/dist/letta/types.js +5 -10
- package/dist/letta/types.js.map +1 -1
- package/dist/observability/logger.d.ts.map +1 -1
- package/dist/observability/logger.js +0 -2
- package/dist/observability/logger.js.map +1 -1
- package/dist/routes/admin/agents.d.ts.map +1 -1
- package/dist/routes/admin/agents.js +0 -40
- package/dist/routes/admin/agents.js.map +1 -1
- package/dist/routes/admin/rotatingMessages.d.ts.map +1 -1
- package/dist/routes/admin/rotatingMessages.js +37 -1
- package/dist/routes/admin/rotatingMessages.js.map +1 -1
- package/dist/routes/admin/settings.d.ts +6 -15
- package/dist/routes/admin/settings.d.ts.map +1 -1
- package/dist/routes/admin/settings.js +9 -107
- package/dist/routes/admin/settings.js.map +1 -1
- package/dist/routes/auth.d.ts +0 -4
- package/dist/routes/auth.d.ts.map +1 -1
- package/dist/routes/auth.js +1 -5
- package/dist/routes/auth.js.map +1 -1
- package/dist/routes/sessions.d.ts.map +1 -1
- package/dist/routes/sessions.js +9 -30
- package/dist/routes/sessions.js.map +1 -1
- package/dist/state/rotationRunner.d.ts +6 -0
- package/dist/state/rotationRunner.d.ts.map +1 -1
- package/dist/state/rotationRunner.js +19 -2
- package/dist/state/rotationRunner.js.map +1 -1
- package/dist/state/rotationScheduler.d.ts +2 -0
- package/dist/state/rotationScheduler.d.ts.map +1 -1
- package/dist/state/rotationScheduler.js.map +1 -1
- package/dist/state/turnRunner.d.ts.map +1 -1
- package/dist/state/turnRunner.js +1 -75
- package/dist/state/turnRunner.js.map +1 -1
- package/package.json +4 -4
- package/src/auth/encryption.ts +5 -5
- package/src/auth/userView.ts +9 -22
- package/src/bootstrap.ts +4 -43
- package/src/db/migrate.ts +6 -17
- package/src/db/schema.ts +8 -68
- package/src/db/seed.ts +6 -47
- package/src/handlers/fetch.ts +7 -10
- package/src/handlers/types.ts +5 -6
- package/src/letta/client.ts +170 -634
- package/src/letta/index.ts +8 -21
- package/src/letta/sandbox.ts +151 -11
- package/src/letta/types.ts +9 -60
- package/src/observability/logger.ts +0 -2
- package/src/routes/admin/agents.ts +0 -49
- package/src/routes/admin/rotatingMessages.ts +47 -1
- package/src/routes/admin/settings.ts +9 -113
- package/src/routes/auth.ts +1 -5
- package/src/routes/sessions.ts +11 -32
- package/src/state/rotationRunner.ts +37 -1
- package/src/state/rotationScheduler.ts +2 -0
- package/src/state/turnRunner.ts +1 -77
- package/src/letta/lettaCliShim.mjs +0 -40
- package/src/letta/settingsFileSeed.ts +0 -254
package/src/letta/client.ts
CHANGED
|
@@ -1,71 +1,62 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Letta access layer — server-less local backend.
|
|
3
3
|
*
|
|
4
|
-
* **
|
|
4
|
+
* AI Hub drives **Letta Code's local backend** through
|
|
5
|
+
* `@data-club/letta-runtime`: agent/conversation state lives on the local
|
|
6
|
+
* filesystem (`AI_HUB_LETTA_STORE_DIR`), chat turns run in an SDK-spawned
|
|
7
|
+
* Letta Code CLI subprocess, and inference is client-side (Letta Code's Pi
|
|
8
|
+
* provider layer calling Anthropic / AWS Bedrock directly). There is no
|
|
9
|
+
* Letta server and no `@letta-ai/letta-client` anywhere in this path — the
|
|
10
|
+
* server-backed implementation was removed in the local-backend migration
|
|
11
|
+
* (see docs/investigations/2026-07-01-letta-code-local-backend-migration.md).
|
|
5
12
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* `streamUserMessage`. Spawns a Letta Code CLI subprocess per turn;
|
|
13
|
-
* the CLI's internal Pi (Provider Interface) + bundled `models.json`
|
|
14
|
-
* handle model selection, reasoning-tier presets, and provider
|
|
15
|
-
* fallback routing (e.g. Anthropic → Bedrock Opus 4.7). This is what
|
|
16
|
-
* keeps ai-hub current with model releases without code changes.
|
|
13
|
+
* **MCP tools.** Letta Code is not an MCP client; with no Letta server, AI
|
|
14
|
+
* Hub is the MCP host. On the first chat turn this module connects to the
|
|
15
|
+
* servers configured in `AI_HUB_MCP_SERVERS` (`name=url,name2=url2`,
|
|
16
|
+
* Streamable HTTP) and injects their tools into every session via the
|
|
17
|
+
* runtime's `tools` option; tool executions round-trip through the SDK's
|
|
18
|
+
* `execute_external_tool` control channel in this process.
|
|
17
19
|
*
|
|
18
|
-
* **
|
|
19
|
-
*
|
|
20
|
+
* **Tool suppression.** Letta Code's bundled tools are hidden from the
|
|
21
|
+
* model via `disallowedTools` (the headless-mode equivalent of the TUI's
|
|
22
|
+
* `/toolset none`). The old `--tools ""` CLI shim is gone — emptying the
|
|
23
|
+
* CLI's tool registry also strips SDK-injected MCP tools (verified
|
|
24
|
+
* empirically), so the disallow list is the only suppression mechanism.
|
|
20
25
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* instance per `(apiKey, baseURL)` pair so super-admin connection
|
|
25
|
-
* edits propagate without restart. The Letta-Code subprocess inherits
|
|
26
|
-
* `process.env` (which must carry `LETTA_BASE_URL` + `LETTA_API_KEY` —
|
|
27
|
-
* the boot script sets these from the same decrypted DB row).
|
|
28
|
-
*
|
|
29
|
-
* @see SPECIFICATIONS.md §6.4 — Letta integration
|
|
30
|
-
* @see SPECIFICATIONS.md §6.4.1 — first-class SDK surfaces
|
|
26
|
+
* This module composes AI-Hub policy (subprocess throttle, tool
|
|
27
|
+
* disallow-lists, sandbox cwd) around the runtime; everything Letta-shaped
|
|
28
|
+
* lives in `@data-club/letta-runtime`.
|
|
31
29
|
*/
|
|
32
30
|
|
|
33
|
-
import {
|
|
31
|
+
import { homedir } from "node:os";
|
|
32
|
+
import { join } from "node:path";
|
|
33
|
+
import { createRequire } from "node:module";
|
|
34
34
|
|
|
35
|
-
import LettaSdkDefault, {
|
|
36
|
-
NotFoundError,
|
|
37
|
-
} from "@letta-ai/letta-client";
|
|
38
35
|
import {
|
|
39
|
-
|
|
36
|
+
ConversationNotFoundError,
|
|
37
|
+
connectMcpServers,
|
|
38
|
+
LocalLettaRuntime,
|
|
39
|
+
parseMcpServersConfig,
|
|
40
|
+
type AnyAgentTool,
|
|
41
|
+
type LettaMessageRecord,
|
|
42
|
+
type LettaRuntime,
|
|
40
43
|
type SDKMessage,
|
|
41
|
-
type
|
|
42
|
-
} from "@
|
|
43
|
-
|
|
44
|
-
import { decryptString } from "../auth/encryption.js";
|
|
45
|
-
import { adminSetting } from "../db/schema.js";
|
|
46
|
-
import type { DrizzleDB } from "../db/connection.js";
|
|
44
|
+
type TurnHandle,
|
|
45
|
+
} from "@data-club/letta-runtime";
|
|
47
46
|
|
|
48
|
-
import { seedAgentToolsetNone } from "./settingsFileSeed.js";
|
|
49
47
|
import {
|
|
50
48
|
type SubprocessThrottle,
|
|
51
49
|
type ThrottleLease,
|
|
52
50
|
} from "./subprocessThrottle.js";
|
|
53
51
|
|
|
54
|
-
import type {
|
|
55
|
-
LettaMessage,
|
|
56
|
-
LettaSdk,
|
|
57
|
-
} from "./types.js";
|
|
58
|
-
|
|
59
52
|
// Re-export so consumers don't need to know about the throttle module.
|
|
60
53
|
export { ServerOverloadedError, SubprocessThrottle } from "./subprocessThrottle.js";
|
|
61
54
|
|
|
62
55
|
/**
|
|
63
|
-
* Thrown when `forkConversation(sourceId)`
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
* validation should have caught it before any user-facing session
|
|
68
|
-
* create reached the fork path).
|
|
56
|
+
* Thrown when `forkConversation(sourceId)` resolves to "no such
|
|
57
|
+
* conversation". The admin agents validator catches this and returns
|
|
58
|
+
* `400 fork_source_not_found` inline on the field; the chat route surfaces
|
|
59
|
+
* it as a 500 (admin-side validation should have caught it earlier).
|
|
69
60
|
*
|
|
70
61
|
* @see docs/server/session-sandboxes.md
|
|
71
62
|
*/
|
|
@@ -78,35 +69,32 @@ export class ForkSourceNotFoundError extends Error {
|
|
|
78
69
|
|
|
79
70
|
/**
|
|
80
71
|
* Letta Code's bundled tools that are ALWAYS disallowed regardless of
|
|
81
|
-
* whether a per-session sandbox is configured
|
|
82
|
-
*
|
|
83
|
-
* Includes:
|
|
84
|
-
* - Memory tools (the v1 no-memory stance keeps these forbidden; see
|
|
85
|
-
* docs/admin/agent-management.md).
|
|
86
|
-
* - Sub-agent management (`Task` / `TaskOutput` / `TaskStop` / `TodoWrite`
|
|
87
|
-
* / `Skill`) — not exposed to ai-hub end users.
|
|
88
|
-
* - Interactive tools (`AskUserQuestion` / `EnterPlanMode` /
|
|
89
|
-
* `ExitPlanMode`) — hang the headless code path.
|
|
90
|
-
* - Web tools (`WebFetch` / `WebSearch`) — operator-curated agents
|
|
91
|
-
* should reach the web through ADE-attached MCP tools, not Letta
|
|
92
|
-
* Code's defaults.
|
|
72
|
+
* whether a per-session sandbox is configured:
|
|
93
73
|
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
74
|
+
* - Memory tools — the v1 no-memory stance (memfs is off store-wide via the
|
|
75
|
+
* runtime, this is defense-in-depth).
|
|
76
|
+
* - Sub-agent / task management — not exposed to ai-hub end users.
|
|
77
|
+
* - Interactive tools — hang the headless code path.
|
|
78
|
+
* - Web tools — operator-curated agents reach the web through MCP tools.
|
|
97
79
|
*
|
|
98
|
-
*
|
|
80
|
+
* Grep-anchor: ALWAYS_DISALLOWED_TOOLS. Extend when Letta Code adds new
|
|
81
|
+
* bundled tools upstream (check the headless init message's `tools` list
|
|
82
|
+
* after a fork sync).
|
|
99
83
|
*/
|
|
100
84
|
const ALWAYS_DISALLOWED_TOOLS: ReadonlyArray<string> = [
|
|
101
85
|
// Sub-agent + task management
|
|
102
86
|
"Task",
|
|
103
87
|
"TaskOutput",
|
|
104
88
|
"TaskStop",
|
|
89
|
+
"TaskCreate",
|
|
90
|
+
"TaskGet",
|
|
91
|
+
"TaskList",
|
|
92
|
+
"TaskUpdate",
|
|
105
93
|
"TodoWrite",
|
|
94
|
+
"Agent",
|
|
106
95
|
// Skills + slash invocation
|
|
107
96
|
"Skill",
|
|
108
|
-
// Memory (
|
|
109
|
-
// no-memory stance keeps these forbidden even when a sandbox is enabled.
|
|
97
|
+
// Memory (kept forbidden even with a sandbox)
|
|
110
98
|
"memory",
|
|
111
99
|
"memory_apply_patch",
|
|
112
100
|
"memory_insert",
|
|
@@ -116,8 +104,8 @@ const ALWAYS_DISALLOWED_TOOLS: ReadonlyArray<string> = [
|
|
|
116
104
|
"AskUserQuestion",
|
|
117
105
|
"EnterPlanMode",
|
|
118
106
|
"ExitPlanMode",
|
|
119
|
-
|
|
120
|
-
//
|
|
107
|
+
"EnterWorktree",
|
|
108
|
+
// Web tools
|
|
121
109
|
"WebFetch",
|
|
122
110
|
"WebSearch",
|
|
123
111
|
];
|
|
@@ -129,7 +117,6 @@ const ALWAYS_DISALLOWED_TOOLS: ReadonlyArray<string> = [
|
|
|
129
117
|
* agent can actually use the seeded files (Step 12.5).
|
|
130
118
|
*/
|
|
131
119
|
const FILESYSTEM_TOOLS: ReadonlyArray<string> = [
|
|
132
|
-
// File / shell — every preset toolset's core set
|
|
133
120
|
"Read",
|
|
134
121
|
"Write",
|
|
135
122
|
"Edit",
|
|
@@ -148,64 +135,26 @@ const FILESYSTEM_TOOLS: ReadonlyArray<string> = [
|
|
|
148
135
|
/* Project surface */
|
|
149
136
|
/* ────────────────────────────────────────────────────────────────────────── */
|
|
150
137
|
|
|
151
|
-
/**
|
|
152
|
-
* Provider-reported token usage for a single Letta run + the run's
|
|
153
|
-
* last assistant-message id (so callers can stamp the badge on the
|
|
154
|
-
* visible bubble). All token fields are nullable because the
|
|
155
|
-
* producing provider may not report every dimension — Anthropic
|
|
156
|
-
* reports `cache_write_tokens`, others don't; reasoning tokens come
|
|
157
|
-
* back only when the model produced any; etc.
|
|
158
|
-
*
|
|
159
|
-
* Sourced via a single `GET /v1/runs/{run_id}/messages` call.
|
|
160
|
-
*/
|
|
161
|
-
export interface RunUsageStats {
|
|
162
|
-
/**
|
|
163
|
-
* Id of the LAST `assistant_message`-typed entry in the run. `null`
|
|
164
|
-
* when the run had no assistant messages (rare — error-only runs).
|
|
165
|
-
* Callers stamp `message_usage` against this id.
|
|
166
|
-
*/
|
|
167
|
-
lastAssistantMessageId: string | null;
|
|
168
|
-
promptTokens: number | null;
|
|
169
|
-
completionTokens: number | null;
|
|
170
|
-
totalTokens: number | null;
|
|
171
|
-
cachedInputTokens: number | null;
|
|
172
|
-
cacheWriteTokens: number | null;
|
|
173
|
-
reasoningTokens: number | null;
|
|
174
|
-
contextTokens: number | null;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
138
|
/** Parameters for `streamUserMessage`. */
|
|
178
139
|
export interface StreamUserMessageOpts {
|
|
179
140
|
/** The Letta conversation id (`session.letta_conversation_id`). */
|
|
180
141
|
conversationId: string;
|
|
181
142
|
/**
|
|
182
|
-
* The Letta agent id the session is against.
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
* agent-registration-time seed isn't reliable on its own. The
|
|
186
|
-
* seed-on-spawn call below is idempotent (no-op when the entry
|
|
187
|
-
* is already present with the same value).
|
|
143
|
+
* The Letta agent id the session is against. Not needed to spawn a turn
|
|
144
|
+
* on the local backend (the conversation record carries its agent), but
|
|
145
|
+
* kept on the opts shape for logging and interface stability.
|
|
188
146
|
*/
|
|
189
147
|
lettaAgentId: string;
|
|
190
148
|
/**
|
|
191
|
-
* Plain-text user message body. Sent verbatim
|
|
192
|
-
*
|
|
193
|
-
* agent's persisted `system` field), so we don't re-inject it here.
|
|
149
|
+
* Plain-text user message body. Sent verbatim. The admin's system prompt
|
|
150
|
+
* is delivered through the agent record's persisted `system` field.
|
|
194
151
|
*/
|
|
195
152
|
userMessage: string;
|
|
196
153
|
/**
|
|
197
154
|
* Step-12.5 — optional working directory for the spawned Letta Code
|
|
198
|
-
* subprocess. When set,
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
* provided, the spawn ALSO drops the `FILESYSTEM_TOOLS` group from
|
|
202
|
-
* the `disallowedTools` list so the agent can actually read / write
|
|
203
|
-
* inside that folder.
|
|
204
|
-
*
|
|
205
|
-
* When `undefined`, no `cwd` is passed (the CLI inherits the
|
|
206
|
-
* ai-hub-server process's working directory, as it always has) AND
|
|
207
|
-
* the filesystem tools remain disallowed — today's stateless agent
|
|
208
|
-
* shape.
|
|
155
|
+
* subprocess (per-session sandbox folder). When set, the filesystem
|
|
156
|
+
* tools are re-enabled so the agent can use the seeded files; when
|
|
157
|
+
* `undefined`, they stay disallowed.
|
|
209
158
|
*
|
|
210
159
|
* @see docs/server/session-sandboxes.md
|
|
211
160
|
*/
|
|
@@ -213,360 +162,176 @@ export interface StreamUserMessageOpts {
|
|
|
213
162
|
}
|
|
214
163
|
|
|
215
164
|
/**
|
|
216
|
-
* Handle returned by `streamUserMessage`. Wraps the
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
* Lifecycle expectations:
|
|
165
|
+
* Handle returned by `streamUserMessage`. Wraps the runtime's turn handle +
|
|
166
|
+
* the subprocess-throttle lease so the caller owns one resource bundle to
|
|
167
|
+
* drain, cancel, and clean up.
|
|
221
168
|
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
* during model generation interrupts work cleanly; during a
|
|
226
|
-
* long-running tool call the interrupt is currently queued (see
|
|
227
|
-
* migration plan §7 open question 5).
|
|
228
|
-
* 3. Caller MUST call `close()` in a `finally` regardless of how the
|
|
229
|
-
* iteration ended (done, cancelled, error). `close()` terminates
|
|
230
|
-
* the CLI subprocess + releases the throttle lease. Idempotent.
|
|
169
|
+
* Lifecycle: iterate `events`; `abort()` to interrupt; ALWAYS `close()` in
|
|
170
|
+
* a `finally` (terminates the CLI subprocess + releases the throttle lease;
|
|
171
|
+
* idempotent).
|
|
231
172
|
*/
|
|
232
173
|
export interface ChatTurnHandle {
|
|
233
174
|
/** Async iterable of Letta Code SDK messages — caller drains this. */
|
|
234
175
|
events: AsyncIterable<SDKMessage>;
|
|
235
|
-
/**
|
|
236
|
-
* Sends an `interrupt` control to the spawned CLI. Best-effort; if
|
|
237
|
-
* the CLI is mid-tool the interrupt may be queued. The caller still
|
|
238
|
-
* needs to call `close()` to release subprocess + lease.
|
|
239
|
-
*/
|
|
176
|
+
/** Sends an `interrupt` control to the spawned CLI. Best-effort. */
|
|
240
177
|
abort(): Promise<void>;
|
|
241
|
-
/**
|
|
242
|
-
* Tears down the Letta Code subprocess + releases the throttle slot.
|
|
243
|
-
* Idempotent — safe to call twice. Caller MUST invoke this in a
|
|
244
|
-
* `finally`. Sync because the SDK's `session.close()` is sync.
|
|
245
|
-
*/
|
|
178
|
+
/** Tears down the subprocess + releases the throttle slot. Idempotent. */
|
|
246
179
|
close(): void;
|
|
247
180
|
}
|
|
248
181
|
|
|
249
182
|
/**
|
|
250
|
-
* Project-shaped Letta client. The handlers depend on this interface,
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
* @see SPECIFICATIONS.md §6.4
|
|
183
|
+
* Project-shaped Letta client. The handlers depend on this interface, not
|
|
184
|
+
* on the underlying runtime, so tests can plug a mock implementation in via
|
|
185
|
+
* `getLettaClient` on the server context.
|
|
255
186
|
*/
|
|
256
187
|
export interface LettaClient {
|
|
257
188
|
/**
|
|
258
|
-
* Retrieves a Letta agent by id
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
* error rethrows — the admin handler returns 500.
|
|
263
|
-
*
|
|
264
|
-
* @param lettaAgentId - candidate Letta-side agent id.
|
|
265
|
-
* @returns the agent state or `null` if not found.
|
|
189
|
+
* Retrieves a Letta agent by id from the local store. Returns `null`
|
|
190
|
+
* (not throws) on miss so the agent-validation flow on
|
|
191
|
+
* `POST/PATCH /admin/agents` can return a 400 `letta_agent_not_found`
|
|
192
|
+
* without try/catch noise.
|
|
266
193
|
*/
|
|
267
194
|
retrieveAgent(lettaAgentId: string): Promise<{
|
|
268
195
|
id: string;
|
|
269
196
|
name: string;
|
|
270
|
-
/** Provider-prefixed model handle (e.g. `"anthropic
|
|
197
|
+
/** Provider-prefixed model handle (e.g. `"bedrock/eu.anthropic.claude-opus-4-6-v1"`). */
|
|
271
198
|
model: string;
|
|
272
199
|
} | null>;
|
|
273
|
-
/**
|
|
274
|
-
* Creates a fresh conversation against the given Letta agent (via
|
|
275
|
-
* `@letta-ai/letta-client`). Returns the conversation id we store
|
|
276
|
-
* on the new session row.
|
|
277
|
-
*
|
|
278
|
-
* @param lettaAgentId - the Letta-side agent id.
|
|
279
|
-
* @returns the new conversation's id.
|
|
280
|
-
*/
|
|
200
|
+
/** Creates a fresh conversation against the given agent. */
|
|
281
201
|
createConversation(lettaAgentId: string): Promise<{ id: string }>;
|
|
282
202
|
/**
|
|
283
|
-
*
|
|
284
|
-
* `
|
|
285
|
-
* validator to verify `fork_source_conversation_id` resolves to a
|
|
286
|
-
* real conversation AND that its `agent_id` matches the agent's
|
|
287
|
-
* `letta_agent_id` (Letta's fork API requires same-agent forking).
|
|
288
|
-
*
|
|
289
|
-
* Returns `null` on Letta 404 so the handler can surface
|
|
290
|
-
* `400 fork_source_not_found` without try/catch noise; non-404
|
|
291
|
-
* SDK errors propagate.
|
|
292
|
-
*
|
|
293
|
-
* @param conversationId - the candidate Letta conversation id.
|
|
294
|
-
* @returns `{ id, agentId }` or `null` if not found.
|
|
203
|
+
* Retrieves a conversation by id (admin fork-source validation). Returns
|
|
204
|
+
* `null` on miss.
|
|
295
205
|
*/
|
|
296
206
|
retrieveConversation(
|
|
297
207
|
conversationId: string,
|
|
298
208
|
): Promise<{ id: string; agentId: string } | null>;
|
|
299
209
|
/**
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
* source's in-context messages and gets a freshly compiled system
|
|
304
|
-
* message reflecting the agent's current memory-block values.
|
|
305
|
-
*
|
|
306
|
-
* Used by `POST /sessions` when the parent agent's
|
|
307
|
-
* `fork_source_conversation_id` is set. The admin endpoint validates
|
|
308
|
-
* source existence + agent match at write time, so this call is
|
|
309
|
-
* expected to succeed in the happy path; a 404 here is treated as
|
|
310
|
-
* "operator deleted the source conversation between admin-validate
|
|
311
|
-
* and user-session-create" and surfaced as `ForkSourceNotFoundError`.
|
|
210
|
+
* Clones an existing conversation (transcript included) into a new one
|
|
211
|
+
* on the same agent. Powers session creation from a rotating message's
|
|
212
|
+
* prepared conversation.
|
|
312
213
|
*
|
|
313
|
-
* @
|
|
314
|
-
* @returns the new conversation's `{ id, agentId }`.
|
|
315
|
-
* @throws ForkSourceNotFoundError on Letta 404.
|
|
214
|
+
* @throws ForkSourceNotFoundError when the source no longer exists.
|
|
316
215
|
*/
|
|
317
216
|
forkConversation(
|
|
318
217
|
sourceConversationId: string,
|
|
319
218
|
): Promise<{ id: string; agentId: string }>;
|
|
320
|
-
/**
|
|
321
|
-
* Lists every message in a conversation, oldest first (via
|
|
322
|
-
* `@letta-ai/letta-client`). Used for `GET /sessions/{id}/messages`
|
|
323
|
-
* history rendering.
|
|
324
|
-
*
|
|
325
|
-
* @param conversationId - the Letta conversation id.
|
|
326
|
-
* @returns the (oldest-first) array of messages.
|
|
327
|
-
*/
|
|
219
|
+
/** Lists every message in a conversation, oldest first. */
|
|
328
220
|
listMessages(conversationId: string): Promise<ReadonlyArray<LettaMessage>>;
|
|
329
221
|
/**
|
|
330
|
-
*
|
|
331
|
-
*
|
|
332
|
-
*
|
|
333
|
-
*
|
|
334
|
-
* endpoint via the SDK's raw HTTP escape hatch (`sdk.delete(...)`) — the
|
|
335
|
-
* same pattern `getRunUsageStats` uses for `sdk.get(...)`. A 404 is treated
|
|
336
|
-
* as "already gone" and swallowed; other errors propagate.
|
|
337
|
-
*
|
|
338
|
-
* Used by the rotation runner to remove the prompt's `user_message` so a
|
|
339
|
-
* rotating starting message's prepared conversation opens with the agent's
|
|
340
|
-
* reply.
|
|
341
|
-
*
|
|
342
|
-
* @param lettaAgentId - the agent the message belongs to.
|
|
343
|
-
* @param messageId - the message id (from `listMessages`).
|
|
222
|
+
* NO-OP on the local backend. Message deletion was a Letta-server REST
|
|
223
|
+
* capability; per the migration decision, rotating messages now keep
|
|
224
|
+
* their initial prompt message instead of deleting it. The method stays
|
|
225
|
+
* on the interface so the rotation runner's flow is unchanged.
|
|
344
226
|
*/
|
|
345
227
|
deleteMessage(lettaAgentId: string, messageId: string): Promise<void>;
|
|
346
228
|
/**
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
* conversation after a successful rotation produces a fresh one — a 404 is
|
|
350
|
-
* swallowed; other errors propagate to the caller (which logs + continues).
|
|
351
|
-
*
|
|
352
|
-
* @param conversationId - the Letta conversation id to delete.
|
|
229
|
+
* NO-OP on the local backend. Old prepared conversations are left in the
|
|
230
|
+
* local store (orphans are harmless files); a cleanup pass can come later.
|
|
353
231
|
*/
|
|
354
232
|
deleteConversation(conversationId: string): Promise<void>;
|
|
355
233
|
/**
|
|
356
|
-
*
|
|
357
|
-
* prompt-cache hit / write counts. The SDK's `session.stream()`
|
|
358
|
-
* drops `usage_statistics` messages, so we re-fetch via REST after
|
|
359
|
-
* the turn lands; the runner uses this to stamp a usage badge on
|
|
360
|
-
* the last assistant message.
|
|
361
|
-
*
|
|
362
|
-
* Returns `null` when:
|
|
363
|
-
* - the run id is unknown to Letta (404),
|
|
364
|
-
* - the run hasn't emitted a usage_statistics message yet,
|
|
365
|
-
* - the network call fails (best-effort — the badge is a nicety,
|
|
366
|
-
* not load-bearing).
|
|
367
|
-
*
|
|
368
|
-
* @param runId - Letta run id captured from `SDKAssistantMessage.runId`.
|
|
369
|
-
*/
|
|
370
|
-
getRunUsageStats(runId: string): Promise<RunUsageStats | null>;
|
|
371
|
-
/**
|
|
372
|
-
* Spawns a Letta Code CLI subprocess and streams a chat turn against
|
|
373
|
-
* the given conversation. Returns a `ChatTurnHandle` the caller
|
|
374
|
-
* drains + cancels + closes.
|
|
375
|
-
*
|
|
376
|
-
* Pre-flight checks (in order, all may throw):
|
|
377
|
-
* 1. **Subprocess throttle.** `SubprocessThrottle.acquire()` runs
|
|
378
|
-
* first — throws `ServerOverloadedError` if RAM is under the
|
|
379
|
-
* threshold or the hard-cap is reached. The chat route catches
|
|
380
|
-
* this and returns HTTP 503 `server_overloaded`.
|
|
381
|
-
* 2. **Session initialization.** `session.initialize()` may throw if
|
|
382
|
-
* the CLI subprocess fails to spawn or can't reach Letta. The
|
|
383
|
-
* lease is released before the throw so the throttle counter
|
|
384
|
-
* doesn't leak.
|
|
385
|
-
*
|
|
386
|
-
* The Letta Code session is configured for the ai-hub use case:
|
|
387
|
-
* - `permissionMode: "bypassPermissions"` — no interactive approvals
|
|
388
|
-
* (we don't expose them to end-users in v1).
|
|
389
|
-
* - `skillSources: []` — no auto-loaded skills (challenge 2 in the
|
|
390
|
-
* migration brief).
|
|
391
|
-
* - `systemInfoReminder: false` — no first-turn environment dump.
|
|
392
|
-
* - `memfsStartup: "skip"` — no git-backed memory filesystem.
|
|
393
|
-
* - `disallowedTools: ["AskUserQuestion", "EnterPlanMode",
|
|
394
|
-
* "ExitPlanMode"]` — tools that require runtime user input.
|
|
395
|
-
*
|
|
396
|
-
* Toolset suppression is handled out-of-band by the
|
|
397
|
-
* `seedAgentToolsetNone` upsert into `~/.letta/settings.json` at
|
|
398
|
-
* agent-registration time.
|
|
234
|
+
* Spawns a Letta Code CLI subprocess and streams a chat turn.
|
|
399
235
|
*
|
|
400
|
-
*
|
|
401
|
-
*
|
|
402
|
-
*
|
|
236
|
+
* Pre-flight (in order, both may throw):
|
|
237
|
+
* 1. `SubprocessThrottle.acquire()` — `ServerOverloadedError` → HTTP 503.
|
|
238
|
+
* 2. MCP bridge connect (first turn only, cached after) — a configured
|
|
239
|
+
* MCP server that can't be reached fails the turn rather than
|
|
240
|
+
* presenting a differently-capable agent.
|
|
403
241
|
*/
|
|
404
242
|
streamUserMessage(opts: StreamUserMessageOpts): Promise<ChatTurnHandle>;
|
|
405
243
|
}
|
|
406
244
|
|
|
245
|
+
/** One Letta-format message as consumed by history renderers. */
|
|
246
|
+
export type LettaMessage = LettaMessageRecord;
|
|
247
|
+
|
|
407
248
|
/* ────────────────────────────────────────────────────────────────────────── */
|
|
408
249
|
/* Default implementation */
|
|
409
250
|
/* ────────────────────────────────────────────────────────────────────────── */
|
|
410
251
|
|
|
411
252
|
/** Dependency bundle for `createDefaultLettaClient`. */
|
|
412
253
|
export interface CreateLettaClientDeps {
|
|
413
|
-
/** Drizzle handle. */
|
|
414
|
-
db: DrizzleDB;
|
|
415
|
-
/** Parsed encryption key for decrypting the Letta API key at rest. */
|
|
416
|
-
encryptionKey: Buffer;
|
|
417
254
|
/** Subprocess-spawn throttle (shared across the server instance). */
|
|
418
255
|
subprocessThrottle: SubprocessThrottle;
|
|
419
256
|
/**
|
|
420
|
-
* Env source
|
|
421
|
-
*
|
|
422
|
-
*
|
|
423
|
-
*
|
|
424
|
-
*
|
|
257
|
+
* Env source. Reads:
|
|
258
|
+
* - `AI_HUB_LETTA_STORE_DIR` — local-backend store (default
|
|
259
|
+
* `~/.letta/lc-local-backend`; on deployments point this at the
|
|
260
|
+
* persistent encrypted volume, shared with operator SSH sessions).
|
|
261
|
+
* - `AI_HUB_LETTA_CLI_PATH` — Letta Code CLI entry override (default:
|
|
262
|
+
* the installed `@data-club/letta-code` package).
|
|
263
|
+
* - `AI_HUB_MCP_SERVERS` — MCP servers to host (`name=url,...`); empty
|
|
264
|
+
* or unset → no MCP tools.
|
|
425
265
|
*/
|
|
426
266
|
envSource?: Record<string, string | undefined>;
|
|
427
|
-
/**
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
* used by tests, which inject a fake session factory.
|
|
436
|
-
*/
|
|
437
|
-
resumeSessionFn?: typeof resumeSession;
|
|
267
|
+
/** Override the runtime — only used by tests. */
|
|
268
|
+
runtime?: LettaRuntime;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Resolves the Letta Code CLI entry (`letta.js`) from the installed fork. */
|
|
272
|
+
function resolveDefaultCliPath(): string {
|
|
273
|
+
const require = createRequire(import.meta.url);
|
|
274
|
+
return require.resolve("@data-club/letta-code");
|
|
438
275
|
}
|
|
439
276
|
|
|
440
277
|
/**
|
|
441
|
-
* Constructs the default `LettaClient` implementation
|
|
442
|
-
*
|
|
443
|
-
* Reads `admin_setting.letta_base_url` + `letta_api_key_encrypted` on
|
|
444
|
-
* each Letta-Client call (no in-flight polling — fresh-on-demand) and
|
|
445
|
-
* memoizes the underlying SDK instance against `(apiKey, baseURL)` so
|
|
446
|
-
* super-admin connection-settings edits propagate without restart.
|
|
447
|
-
*
|
|
448
|
-
* Throws on the first Letta-Client call if `admin_setting` has no
|
|
449
|
-
* `letta_base_url` or no decryptable API key.
|
|
450
|
-
*
|
|
451
|
-
* @param deps - see `CreateLettaClientDeps`.
|
|
452
|
-
* @returns the project-shaped Letta client.
|
|
453
|
-
*
|
|
454
|
-
* @see SPECIFICATIONS.md §6.4
|
|
455
|
-
* @see SPECIFICATIONS.md §11 — `admin_setting` schema
|
|
278
|
+
* Constructs the default `LettaClient` implementation over the local
|
|
279
|
+
* backend. See the module doc for the architecture.
|
|
456
280
|
*/
|
|
457
281
|
export function createDefaultLettaClient(
|
|
458
282
|
deps: CreateLettaClientDeps,
|
|
459
283
|
): LettaClient {
|
|
460
|
-
const
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
284
|
+
const envSource =
|
|
285
|
+
deps.envSource ?? (process.env as Record<string, string | undefined>);
|
|
286
|
+
|
|
287
|
+
const runtime: LettaRuntime =
|
|
288
|
+
deps.runtime ??
|
|
289
|
+
new LocalLettaRuntime({
|
|
290
|
+
cliPath: envSource.AI_HUB_LETTA_CLI_PATH ?? resolveDefaultCliPath(),
|
|
291
|
+
storeDir:
|
|
292
|
+
envSource.AI_HUB_LETTA_STORE_DIR ??
|
|
293
|
+
join(homedir(), ".letta", "lc-local-backend"),
|
|
294
|
+
memfs: false,
|
|
295
|
+
});
|
|
467
296
|
|
|
468
297
|
/**
|
|
469
|
-
*
|
|
470
|
-
*
|
|
471
|
-
*
|
|
472
|
-
* **API key precedence** (per the operator-spec'd model):
|
|
473
|
-
* 1. If `admin_setting.letta_api_key_encrypted` decrypts to a non-empty,
|
|
474
|
-
* non-whitespace string → use that (super-admin set it via the
|
|
475
|
-
* admin panel; DB wins over env).
|
|
476
|
-
* 2. Otherwise → fall back to `env.LETTA_API_KEY` (whitespace-trimmed).
|
|
477
|
-
* 3. Otherwise → throw — neither source has a usable value.
|
|
478
|
-
*
|
|
479
|
-
* Evaluated on every call, so:
|
|
480
|
-
* - A super-admin PATCH to /admin/settings takes effect immediately.
|
|
481
|
-
* - Clearing the value via the admin panel (empty/whitespace) falls
|
|
482
|
-
* back to env on the very next request.
|
|
483
|
-
* - A new `.env` after a restart is reflected immediately when the DB
|
|
484
|
-
* column is empty.
|
|
298
|
+
* MCP tools, connected lazily on the first chat turn and cached for the
|
|
299
|
+
* process lifetime. A failed connect is NOT cached — the next turn
|
|
300
|
+
* retries, so a briefly-down MCP server doesn't wedge the deployment.
|
|
485
301
|
*/
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
.
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
const envKey = envSource.LETTA_API_KEY;
|
|
499
|
-
if (envKey && envKey.trim().length > 0) {
|
|
500
|
-
return { apiKey: envKey, source: "env" };
|
|
302
|
+
let mcpToolsPromise: Promise<AnyAgentTool[]> | null = null;
|
|
303
|
+
function getMcpTools(): Promise<AnyAgentTool[]> {
|
|
304
|
+
if (mcpToolsPromise === null) {
|
|
305
|
+
const servers = parseMcpServersConfig(envSource.AI_HUB_MCP_SERVERS);
|
|
306
|
+
mcpToolsPromise =
|
|
307
|
+
servers.length === 0
|
|
308
|
+
? Promise.resolve([])
|
|
309
|
+
: connectMcpServers(servers).then((bridge) => bridge.tools);
|
|
310
|
+
mcpToolsPromise.catch(() => {
|
|
311
|
+
mcpToolsPromise = null;
|
|
312
|
+
});
|
|
501
313
|
}
|
|
502
|
-
|
|
503
|
-
"No Letta API key configured. Set LETTA_API_KEY in .env, OR PATCH it via /admin/settings.",
|
|
504
|
-
);
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
function getSdk(): LettaSdk {
|
|
508
|
-
const settings = deps.db
|
|
509
|
-
.select()
|
|
510
|
-
.from(adminSetting)
|
|
511
|
-
.where(eq(adminSetting.id, 1))
|
|
512
|
-
.get();
|
|
513
|
-
if (!settings?.lettaBaseUrl) {
|
|
514
|
-
throw new Error(
|
|
515
|
-
"admin_setting.letta_base_url is empty — configure it via /admin/settings or LETTA_BASE_URL.",
|
|
516
|
-
);
|
|
517
|
-
}
|
|
518
|
-
const { apiKey } = resolveApiKey();
|
|
519
|
-
// The Letta Client SDK appends its own `/v1/...` paths, so a
|
|
520
|
-
// `letta_base_url` like `http://localhost:8283/v1` produces
|
|
521
|
-
// double-v1 calls. Strip a trailing `/v1` defensively.
|
|
522
|
-
const baseURL = settings.lettaBaseUrl.replace(/\/v1\/?$/i, "");
|
|
523
|
-
if (cached && cached.apiKey === apiKey && cached.baseURL === baseURL) {
|
|
524
|
-
return cached.sdk;
|
|
525
|
-
}
|
|
526
|
-
const sdk = new SdkCtor({ apiKey, baseURL });
|
|
527
|
-
cached = { apiKey, baseURL, sdk };
|
|
528
|
-
return sdk;
|
|
314
|
+
return mcpToolsPromise;
|
|
529
315
|
}
|
|
530
316
|
|
|
531
317
|
return {
|
|
532
318
|
async retrieveAgent(lettaAgentId) {
|
|
533
|
-
|
|
534
|
-
try {
|
|
535
|
-
const state = await sdk.agents.retrieve(lettaAgentId);
|
|
536
|
-
return { id: state.id, name: state.name, model: state.model ?? "" };
|
|
537
|
-
} catch (err) {
|
|
538
|
-
if (err instanceof NotFoundError) return null;
|
|
539
|
-
throw err;
|
|
540
|
-
}
|
|
319
|
+
return runtime.retrieveAgent(lettaAgentId);
|
|
541
320
|
},
|
|
542
321
|
|
|
543
322
|
async createConversation(lettaAgentId) {
|
|
544
|
-
|
|
545
|
-
const conv = await sdk.conversations.create({ agent_id: lettaAgentId });
|
|
546
|
-
return { id: conv.id };
|
|
323
|
+
return runtime.createConversation(lettaAgentId);
|
|
547
324
|
},
|
|
548
325
|
|
|
549
326
|
async retrieveConversation(conversationId) {
|
|
550
|
-
|
|
551
|
-
try {
|
|
552
|
-
const conv = await sdk.conversations.retrieve(conversationId);
|
|
553
|
-
return { id: conv.id, agentId: conv.agent_id };
|
|
554
|
-
} catch (err) {
|
|
555
|
-
if (err instanceof NotFoundError) return null;
|
|
556
|
-
throw err;
|
|
557
|
-
}
|
|
327
|
+
return runtime.retrieveConversation(conversationId);
|
|
558
328
|
},
|
|
559
329
|
|
|
560
330
|
async forkConversation(sourceConversationId) {
|
|
561
|
-
const sdk = getSdk();
|
|
562
331
|
try {
|
|
563
|
-
|
|
564
|
-
// We don't pass a body — defaults are fine for our use case
|
|
565
|
-
// (forked conversation is not hidden, no extra params needed).
|
|
566
|
-
const conv = await sdk.conversations.fork(sourceConversationId);
|
|
567
|
-
return { id: conv.id, agentId: conv.agent_id };
|
|
332
|
+
return await runtime.forkConversation(sourceConversationId);
|
|
568
333
|
} catch (err) {
|
|
569
|
-
if (err instanceof
|
|
334
|
+
if (err instanceof ConversationNotFoundError) {
|
|
570
335
|
throw new ForkSourceNotFoundError(sourceConversationId);
|
|
571
336
|
}
|
|
572
337
|
throw err;
|
|
@@ -574,114 +339,15 @@ export function createDefaultLettaClient(
|
|
|
574
339
|
},
|
|
575
340
|
|
|
576
341
|
async listMessages(conversationId) {
|
|
577
|
-
|
|
578
|
-
const out: LettaMessage[] = [];
|
|
579
|
-
const page = await sdk.conversations.messages.list(conversationId);
|
|
580
|
-
for await (const msg of page) {
|
|
581
|
-
out.push(msg);
|
|
582
|
-
}
|
|
583
|
-
return out;
|
|
342
|
+
return runtime.listMessages(conversationId);
|
|
584
343
|
},
|
|
585
344
|
|
|
586
|
-
async deleteMessage(
|
|
587
|
-
|
|
588
|
-
try {
|
|
589
|
-
// The typed `conversations.messages` resource is create/list-only;
|
|
590
|
-
// the message-delete capability lives on the agent-scoped REST route.
|
|
591
|
-
// Same raw-HTTP escape hatch `getRunUsageStats` uses for `sdk.get`.
|
|
592
|
-
await sdk.delete(
|
|
593
|
-
`/v1/agents/${encodeURIComponent(lettaAgentId)}/messages/${encodeURIComponent(messageId)}`,
|
|
594
|
-
);
|
|
595
|
-
} catch (err) {
|
|
596
|
-
// Already gone is fine — the goal state (message absent) is reached.
|
|
597
|
-
if (err instanceof NotFoundError) return;
|
|
598
|
-
throw err;
|
|
599
|
-
}
|
|
345
|
+
async deleteMessage(_lettaAgentId, _messageId) {
|
|
346
|
+
// Intentional no-op — see the interface doc.
|
|
600
347
|
},
|
|
601
348
|
|
|
602
|
-
async deleteConversation(
|
|
603
|
-
|
|
604
|
-
try {
|
|
605
|
-
await sdk.conversations.delete(conversationId);
|
|
606
|
-
} catch (err) {
|
|
607
|
-
if (err instanceof NotFoundError) return;
|
|
608
|
-
throw err;
|
|
609
|
-
}
|
|
610
|
-
},
|
|
611
|
-
|
|
612
|
-
async getRunUsageStats(runId) {
|
|
613
|
-
const sdk = getSdk();
|
|
614
|
-
// Two endpoints in parallel:
|
|
615
|
-
// - `/v1/runs/{id}/messages` carries persisted assistant
|
|
616
|
-
// messages — we need the LAST one's id to stamp the badge.
|
|
617
|
-
// The `usage_statistics` SDK stream event is NOT persisted
|
|
618
|
-
// here; we used to look for it but it's stream-only.
|
|
619
|
-
// - `/v1/runs/{id}/steps` carries per-LLM-call usage with the
|
|
620
|
-
// full cache breakdown (`cached_input_tokens`,
|
|
621
|
-
// `cache_write_tokens`, `reasoning_tokens`). For multi-step
|
|
622
|
-
// turns we sum across steps; for single-step turns we just
|
|
623
|
-
// read the one row.
|
|
624
|
-
//
|
|
625
|
-
// Best-effort: any failure on either side returns null and the
|
|
626
|
-
// badge just doesn't render. Never blocks the chat turn.
|
|
627
|
-
let messagesRaw: unknown;
|
|
628
|
-
let stepsRaw: unknown;
|
|
629
|
-
try {
|
|
630
|
-
[messagesRaw, stepsRaw] = await Promise.all([
|
|
631
|
-
sdk.get(`/v1/runs/${runId}/messages`),
|
|
632
|
-
sdk.get(`/v1/runs/${runId}/steps`),
|
|
633
|
-
]);
|
|
634
|
-
} catch {
|
|
635
|
-
return null;
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
// Pull the last assistant message id (anchor for the badge).
|
|
639
|
-
let lastAssistantMessageId: string | null = null;
|
|
640
|
-
if (Array.isArray(messagesRaw)) {
|
|
641
|
-
for (const item of messagesRaw) {
|
|
642
|
-
if (!item || typeof item !== "object") continue;
|
|
643
|
-
const m = item as Record<string, unknown>;
|
|
644
|
-
if (m["message_type"] === "assistant_message" && typeof m["id"] === "string") {
|
|
645
|
-
lastAssistantMessageId = m["id"];
|
|
646
|
-
}
|
|
647
|
-
}
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
// Sum tokens across steps. Missing fields stay null in the sum
|
|
651
|
-
// (the sum only goes non-null if at least one step reported it),
|
|
652
|
-
// so providers that don't expose cache_write keep it null at the
|
|
653
|
-
// top level rather than silently appearing as 0.
|
|
654
|
-
if (!Array.isArray(stepsRaw)) return null;
|
|
655
|
-
const sum = {
|
|
656
|
-
promptTokens: null as number | null,
|
|
657
|
-
completionTokens: null as number | null,
|
|
658
|
-
totalTokens: null as number | null,
|
|
659
|
-
cachedInputTokens: null as number | null,
|
|
660
|
-
cacheWriteTokens: null as number | null,
|
|
661
|
-
reasoningTokens: null as number | null,
|
|
662
|
-
contextTokens: null as number | null,
|
|
663
|
-
};
|
|
664
|
-
const addInto = (key: keyof typeof sum, v: unknown): void => {
|
|
665
|
-
if (typeof v !== "number") return;
|
|
666
|
-
sum[key] = (sum[key] ?? 0) + v;
|
|
667
|
-
};
|
|
668
|
-
let anyStep = false;
|
|
669
|
-
for (const item of stepsRaw) {
|
|
670
|
-
if (!item || typeof item !== "object") continue;
|
|
671
|
-
anyStep = true;
|
|
672
|
-
const s = item as Record<string, unknown>;
|
|
673
|
-
addInto("promptTokens", s["prompt_tokens"]);
|
|
674
|
-
addInto("completionTokens", s["completion_tokens"]);
|
|
675
|
-
addInto("totalTokens", s["total_tokens"]);
|
|
676
|
-
addInto("cachedInputTokens", s["cached_input_tokens"]);
|
|
677
|
-
addInto("cacheWriteTokens", s["cache_write_tokens"]);
|
|
678
|
-
addInto("reasoningTokens", s["reasoning_tokens"]);
|
|
679
|
-
}
|
|
680
|
-
if (!anyStep) return null;
|
|
681
|
-
return {
|
|
682
|
-
lastAssistantMessageId,
|
|
683
|
-
...sum,
|
|
684
|
-
};
|
|
349
|
+
async deleteConversation(_conversationId) {
|
|
350
|
+
// Intentional no-op — see the interface doc.
|
|
685
351
|
},
|
|
686
352
|
|
|
687
353
|
async streamUserMessage(opts) {
|
|
@@ -689,172 +355,42 @@ export function createDefaultLettaClient(
|
|
|
689
355
|
// Caller's chat-route catch turns that into HTTP 503.
|
|
690
356
|
const lease: ThrottleLease = deps.subprocessThrottle.acquire();
|
|
691
357
|
|
|
692
|
-
//
|
|
693
|
-
// (
|
|
694
|
-
//
|
|
695
|
-
// process.env at spawn time is what the CLI sends to Letta. Mutating
|
|
696
|
-
// it here on every spawn means a super-admin's PATCH to
|
|
697
|
-
// /admin/settings takes effect on the very next chat turn without
|
|
698
|
-
// a process restart — matches the same hot-reload behavior the
|
|
699
|
-
// direct-REST `getSdk()` path already had via SDK memoization on
|
|
700
|
-
// the `(apiKey, baseURL)` pair.
|
|
701
|
-
//
|
|
702
|
-
// If resolveApiKey throws (no usable value anywhere), we release
|
|
703
|
-
// the throttle lease before propagating — otherwise the slot would
|
|
704
|
-
// be leaked.
|
|
705
|
-
try {
|
|
706
|
-
const resolved = resolveApiKey();
|
|
707
|
-
process.env.LETTA_API_KEY = resolved.apiKey;
|
|
708
|
-
} catch (err) {
|
|
709
|
-
lease.release();
|
|
710
|
-
throw err;
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
// Letta Code's CLI reads `LETTA_BASE_URL` from env and appends
|
|
714
|
-
// `/v1/...` to it. If the operator set the env to
|
|
715
|
-
// `http://localhost:8283/v1` (matching the Letta Client SDK's
|
|
716
|
-
// expectation), the CLI produces double-v1 URLs that 404 — the
|
|
717
|
-
// CLI surfaces this as "Failed to connect to Letta server /
|
|
718
|
-
// credentials may be invalid", which is misleading. Strip the
|
|
719
|
-
// trailing `/v1` in process.env on every spawn (idempotent —
|
|
720
|
-
// already-stripped values pass through unchanged). Operators
|
|
721
|
-
// who use the bare-host form aren't affected. (Also done in
|
|
722
|
-
// `bootstrap.ts` at boot for the primary case; this is
|
|
723
|
-
// defense-in-depth.)
|
|
724
|
-
const rawBaseUrl = process.env.LETTA_BASE_URL;
|
|
725
|
-
if (rawBaseUrl) {
|
|
726
|
-
const stripped = rawBaseUrl.replace(/\/v1\/?$/i, "");
|
|
727
|
-
if (stripped !== rawBaseUrl) {
|
|
728
|
-
process.env.LETTA_BASE_URL = stripped;
|
|
729
|
-
}
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
// Re-seed `~/.letta/settings.json` with `{toolset: "none"}` for
|
|
733
|
-
// this agent on every spawn. Necessary because concurrent CLI
|
|
734
|
-
// subprocesses race on settings-file writes (each maintains its
|
|
735
|
-
// own in-memory snapshot, writes back on exit, last-writer-wins)
|
|
736
|
-
// and the agent-registration-time seed can be wiped between
|
|
737
|
-
// spawns. Idempotent — when the entry already matches, this is
|
|
738
|
-
// a no-op (doesn't touch the file's mtime). Best-effort: a
|
|
739
|
-
// write failure logs but doesn't block the chat turn (the shim
|
|
740
|
-
// is the primary mechanism; the settings file is defense-in-
|
|
741
|
-
// depth + TUI-compatibility).
|
|
742
|
-
try {
|
|
743
|
-
seedAgentToolsetNone({
|
|
744
|
-
agentId: opts.lettaAgentId,
|
|
745
|
-
lettaBaseUrl: process.env.LETTA_BASE_URL,
|
|
746
|
-
});
|
|
747
|
-
} catch {
|
|
748
|
-
// intentionally ignored — see above
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
// Point the SDK at our CLI shim. The shim injects `--tools ""`
|
|
752
|
-
// into argv before invoking the real Letta Code CLI, which
|
|
753
|
-
// mimics the TUI `/toolset none` action — both end up with an
|
|
754
|
-
// empty `toolRegistry` so the LLM sees only the agent's
|
|
755
|
-
// ADE-attached tools (no Read/Write/Bash/etc. from Letta
|
|
756
|
-
// Code's bundled presets). See `lettaCliShim.mjs` for the
|
|
757
|
-
// full rationale + traceability to the Letta Code source.
|
|
758
|
-
//
|
|
759
|
-
// This is set once per spawn (cheap — just a process.env write).
|
|
760
|
-
// We use the migration's source path so the shim is in the
|
|
761
|
-
// same repo and ships with ai-hub-server.
|
|
762
|
-
if (process.env.LETTA_CLI_PATH === undefined) {
|
|
763
|
-
const { dirname, join } = await import("node:path");
|
|
764
|
-
const { fileURLToPath } = await import("node:url");
|
|
765
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
766
|
-
process.env.LETTA_CLI_PATH = join(here, "lettaCliShim.mjs");
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
// We send the raw user message verbatim; the admin's system prompt
|
|
770
|
-
// flows through Letta's normal channel (the agent's persisted
|
|
771
|
-
// `system` field).
|
|
772
|
-
const userContent: string = opts.userMessage;
|
|
773
|
-
|
|
774
|
-
// Step-12.5 — disallow-list composition.
|
|
775
|
-
//
|
|
776
|
-
// When the parent session has a sandbox folder (opts.cwd set), the
|
|
777
|
-
// filesystem tools are RE-ENABLED so the agent can use the seeded
|
|
778
|
-
// files. Without a sandbox, those tools stay disallowed (today's
|
|
779
|
-
// stateless agent shape; regression-locked by a snapshot test).
|
|
780
|
-
//
|
|
781
|
-
// The always-disallowed group (memory / sub-agent / interactive /
|
|
782
|
-
// web) stays disallowed regardless — the v1 no-memory stance is
|
|
783
|
-
// unchanged. See docs/server/session-sandboxes.md.
|
|
358
|
+
// Step-12.5 — disallow-list composition. With a sandbox folder
|
|
359
|
+
// (opts.cwd set) the filesystem tools are re-enabled so the agent can
|
|
360
|
+
// use the seeded files; without one they stay disallowed.
|
|
784
361
|
const disallowedTools: string[] =
|
|
785
362
|
opts.cwd === undefined
|
|
786
363
|
? [...ALWAYS_DISALLOWED_TOOLS, ...FILESYSTEM_TOOLS]
|
|
787
364
|
: [...ALWAYS_DISALLOWED_TOOLS];
|
|
788
365
|
|
|
789
|
-
let
|
|
366
|
+
let handle: TurnHandle;
|
|
790
367
|
try {
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
memfsStartup: "skip",
|
|
796
|
-
// `disallowedTools` mirrors the effect of TUI `/toolset none` —
|
|
797
|
-
// the persisted-setting approach (`~/.letta/settings.json`) is
|
|
798
|
-
// only consulted by Letta Code's interactive code path, NOT
|
|
799
|
-
// the headless `--input-format stream-json` mode the SDK uses.
|
|
800
|
-
// And `--toolset none` is rejected as a CLI arg in headless
|
|
801
|
-
// mode (only auto/codex/default/gemini accepted there).
|
|
802
|
-
//
|
|
803
|
-
// Adding tools to this list as Letta Code adds them upstream
|
|
804
|
-
// is maintenance debt — grep `ALWAYS_DISALLOWED_TOOLS` /
|
|
805
|
-
// `FILESYSTEM_TOOLS` to find the constants. The longer-term
|
|
806
|
-
// fix is upstream: either accept `--toolset none` in headless
|
|
807
|
-
// mode or expose a `--no-tools` flag. Filed against
|
|
808
|
-
// letta-ai/letta-code (TBD).
|
|
368
|
+
const tools = await getMcpTools();
|
|
369
|
+
handle = await runtime.streamTurn({
|
|
370
|
+
conversationId: opts.conversationId,
|
|
371
|
+
userMessage: opts.userMessage,
|
|
809
372
|
disallowedTools,
|
|
810
|
-
|
|
373
|
+
...(tools.length > 0 ? { tools } : {}),
|
|
811
374
|
...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
|
|
812
375
|
});
|
|
813
|
-
await session.initialize();
|
|
814
|
-
await session.send(userContent);
|
|
815
376
|
} catch (err) {
|
|
816
|
-
// Spawn / init / send failure — release lease + tear down the
|
|
817
|
-
// subprocess if we got that far, then re-throw for the caller.
|
|
818
|
-
if (session) {
|
|
819
|
-
try {
|
|
820
|
-
session.close();
|
|
821
|
-
} catch {
|
|
822
|
-
// best-effort
|
|
823
|
-
}
|
|
824
|
-
}
|
|
825
377
|
lease.release();
|
|
826
378
|
throw err;
|
|
827
379
|
}
|
|
828
380
|
|
|
829
|
-
const activeSession = session;
|
|
830
381
|
let closed = false;
|
|
831
382
|
return {
|
|
832
|
-
events:
|
|
383
|
+
events: handle.events,
|
|
833
384
|
async abort() {
|
|
834
|
-
|
|
835
|
-
await activeSession.abort();
|
|
836
|
-
} catch {
|
|
837
|
-
// The CLI may already be tearing down — swallow to keep
|
|
838
|
-
// the cancel route idempotent. `close()` still runs.
|
|
839
|
-
}
|
|
385
|
+
await handle.abort();
|
|
840
386
|
},
|
|
841
387
|
close() {
|
|
842
388
|
if (closed) return;
|
|
843
389
|
closed = true;
|
|
844
|
-
|
|
845
|
-
activeSession.close();
|
|
846
|
-
} catch {
|
|
847
|
-
// best-effort
|
|
848
|
-
}
|
|
390
|
+
handle.close();
|
|
849
391
|
lease.release();
|
|
850
392
|
},
|
|
851
393
|
};
|
|
852
394
|
},
|
|
853
395
|
};
|
|
854
396
|
}
|
|
855
|
-
|
|
856
|
-
/* ────────────────────────────────────────────────────────────────────────── */
|
|
857
|
-
/* Re-exports */
|
|
858
|
-
/* ────────────────────────────────────────────────────────────────────────── */
|
|
859
|
-
|
|
860
|
-
export type { LettaMessage, LettaSdk } from "./types.js";
|