@ai-sdk-letta/server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/serve.ts ADDED
@@ -0,0 +1,128 @@
1
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { join } from 'node:path';
4
+ import type { Server } from 'node:http';
5
+ import type { ToolSet } from 'ai';
6
+ import { createLettaAgent, resolveStateDirectory, statePaths, type AgentDefinition, type LettaRuntime } from 'ai-sdk-letta';
7
+ import { ThreadRuntime, type RuntimeHost } from './runtime.js';
8
+ import { guiApp, tokenApiApp } from './http.js';
9
+
10
+ /** Default GUI port (the token API uses the next one). */
11
+ export const DEFAULT_PORT = 4400;
12
+
13
+ /** Options shared by {@link startGuiServer} and {@link startApiServer}. */
14
+ export interface ServeOptions {
15
+ /** Loopback port. `0` picks a free port. @default 4400 */
16
+ port?: number;
17
+ /** State root; see `resolveStateDirectory`. */
18
+ stateDirectory?: string;
19
+ /** Log sink for startup and shutdown lines. @default console.log */
20
+ log?: (line: string) => void;
21
+ }
22
+
23
+ /** A running server. */
24
+ export interface RunningServer {
25
+ url: string;
26
+ port: number;
27
+ /** Stop accepting requests, close the agent, then release locks. Idempotent. */
28
+ close(): Promise<void>;
29
+ }
30
+
31
+ function host<TOOLS extends ToolSet>(definition: AgentDefinition<TOOLS>, stateDirectory: string): RuntimeHost {
32
+ let runtime: LettaRuntime<TOOLS> | undefined;
33
+ return {
34
+ open: async options => {
35
+ runtime = await createLettaAgent(definition, { ...options, stateDirectory, foregroundExternalTools: true });
36
+ const { agent } = runtime;
37
+ if (!agent.lettaAgentId || !agent.presentation) throw new Error('Runtime identity unavailable');
38
+ return { agent, agentId: agent.lettaAgentId, conversationId: agent.presentation.conversationId, history: agent.presentation.initialMessages };
39
+ },
40
+ close: async () => { const current = runtime; runtime = undefined; await current?.close(); },
41
+ };
42
+ }
43
+
44
+ function serviceLock(directory: string) {
45
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
46
+ const lock = join(directory, 'service.lock');
47
+ let fd: number;
48
+ try { fd = openSync(lock, 'wx', 0o600); }
49
+ catch { throw new Error(`Server already running or stale lock: ${lock}. Verify the recorded PID is not running before removing it.`); }
50
+ writeFileSync(fd, String(process.pid)); closeSync(fd);
51
+ return () => unlinkSync(lock);
52
+ }
53
+
54
+ async function listen(server: Server, port: number) {
55
+ await new Promise<void>((resolve, reject) => { server.once('error', reject); server.once('listening', () => { server.off('error', reject); resolve(); }); });
56
+ const address = server.address();
57
+ if (!address || typeof address === 'string') throw new Error('Listener unavailable');
58
+ return address.port || port;
59
+ }
60
+
61
+ function lifecycle(server: Server, runtime: ThreadRuntime, unlock: () => void, log: (line: string) => void, label: string) {
62
+ let closing: Promise<void> | undefined;
63
+ return () => closing ??= (async () => {
64
+ // SDK shutdown can await unref'ed resources; keep Node alive until locks release.
65
+ const keepAlive = setInterval(() => {}, 1000);
66
+ server.close(); server.closeAllConnections?.();
67
+ try { await runtime.close(); unlock(); log(`${label} stopped cleanly.`); }
68
+ catch (error) { log(`${label} shutdown incomplete; inspect the recorded locks before restarting.`); throw error; }
69
+ finally { clearInterval(keepAlive); }
70
+ })();
71
+ }
72
+
73
+ /**
74
+ * Serve the assistant-ui web app for one agent on 127.0.0.1.
75
+ *
76
+ * No agent session is opened until the browser creates or opens a thread.
77
+ * @param assets Directory of the built web app (for example `@ai-sdk-letta/web`'s `dist`).
78
+ */
79
+ export async function startGuiServer<TOOLS extends ToolSet>(definition: AgentDefinition<TOOLS>, assets: string, options: ServeOptions = {}): Promise<RunningServer> {
80
+ const log = options.log ?? console.log;
81
+ const stateDirectory = resolveStateDirectory(options.stateDirectory);
82
+ const directory = join(statePaths(stateDirectory).server(definition.id), 'gui');
83
+ if (!existsSync(join(assets, 'index.html'))) throw new Error(`Web assets not found in ${assets}; build @ai-sdk-letta/web first`);
84
+ const unlock = serviceLock(directory);
85
+ try {
86
+ const port = options.port ?? DEFAULT_PORT;
87
+ const owner = 'local-gui';
88
+ const runtime = new ThreadRuntime(host(definition, stateDirectory), join(directory, 'state.json'), owner);
89
+ const server = guiApp(runtime, owner, port, assets, { id: definition.id, name: definition.name, approvalTools: Object.keys(definition.permissions).filter(name => definition.permissions[name] === 'ask') }).listen(port, '127.0.0.1');
90
+ const bound = await listen(server, port);
91
+ const url = `http://127.0.0.1:${bound}`;
92
+ log(`${definition.name} GUI: ${url}\nDefinition: ${definition.id} · state: ${stateDirectory}\nPID ${process.pid}. Stop with Ctrl-C or SIGTERM.`);
93
+ return { url, port: bound, close: lifecycle(server, runtime, unlock, log, 'GUI') };
94
+ } catch (error) { unlock(); throw error; }
95
+ }
96
+
97
+ /**
98
+ * Serve the token-authenticated server-to-server API for one agent on 127.0.0.1.
99
+ * The token is created once (0600) in the state directory and printed by path only.
100
+ */
101
+ export async function startApiServer<TOOLS extends ToolSet>(definition: AgentDefinition<TOOLS>, options: ServeOptions & { owner?: string } = {}): Promise<RunningServer & { tokenPath: string; owner: string }> {
102
+ const log = options.log ?? console.log;
103
+ const stateDirectory = resolveStateDirectory(options.stateDirectory);
104
+ const directory = join(statePaths(stateDirectory).server(definition.id), 'api');
105
+ const unlock = serviceLock(directory);
106
+ try {
107
+ const tokenPath = join(directory, 'token');
108
+ if (!existsSync(tokenPath)) writeFileSync(tokenPath, randomBytes(32).toString('hex'), { mode: 0o600, flag: 'wx' });
109
+ const token = readFileSync(tokenPath, 'utf8').trim();
110
+ const owner = options.owner ?? 'local-api';
111
+ const port = options.port ?? DEFAULT_PORT + 1;
112
+ const runtime = new ThreadRuntime(host(definition, stateDirectory), join(directory, 'state.json'), owner);
113
+ let stop: (() => Promise<void>) | undefined;
114
+ const server = tokenApiApp(runtime, token, owner, port, () => stop!()).listen(port, '127.0.0.1');
115
+ const bound = await listen(server, port);
116
+ stop = lifecycle(server, runtime, unlock, log, 'API');
117
+ const url = `http://127.0.0.1:${bound}`;
118
+ log(`${definition.name} API: ${url} (bearer token required)\nDefinition: ${definition.id} · owner: ${owner} · token file: ${tokenPath}`);
119
+ return { url, port: bound, tokenPath, owner, close: stop };
120
+ } catch (error) { unlock(); throw error; }
121
+ }
122
+
123
+ /** Close `server` on SIGINT/SIGTERM and set a non-zero exit code if shutdown fails. */
124
+ export function closeOnSignals(server: Pick<RunningServer, 'close'>): void {
125
+ const stop = () => { server.close().catch(() => { process.exitCode = 1; }); };
126
+ process.once('SIGINT', stop);
127
+ process.once('SIGTERM', stop);
128
+ }