ai-sdk-letta 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/CHANGELOG.md +7 -0
- package/LICENSE +202 -0
- package/NOTICE +9 -0
- package/README.md +35 -0
- package/dist/agent.d.ts +108 -0
- package/dist/agent.d.ts.map +1 -0
- package/dist/agent.js +371 -0
- package/dist/agent.js.map +1 -0
- package/dist/definition.d.ts +81 -0
- package/dist/definition.d.ts.map +1 -0
- package/dist/definition.js +87 -0
- package/dist/definition.js.map +1 -0
- package/dist/history.d.ts +38 -0
- package/dist/history.d.ts.map +1 -0
- package/dist/history.js +226 -0
- package/dist/history.js.map +1 -0
- package/dist/identity.d.ts +43 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/identity.js +158 -0
- package/dist/identity.js.map +1 -0
- package/dist/images.d.ts +83 -0
- package/dist/images.d.ts.map +1 -0
- package/dist/images.js +181 -0
- package/dist/images.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/dist/interactions.d.ts +61 -0
- package/dist/interactions.d.ts.map +1 -0
- package/dist/interactions.js +110 -0
- package/dist/interactions.js.map +1 -0
- package/dist/memory.d.ts +11 -0
- package/dist/memory.d.ts.map +1 -0
- package/dist/memory.js +55 -0
- package/dist/memory.js.map +1 -0
- package/dist/navigation.d.ts +50 -0
- package/dist/navigation.d.ts.map +1 -0
- package/dist/navigation.js +87 -0
- package/dist/navigation.js.map +1 -0
- package/dist/runtime.d.ts +83 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +246 -0
- package/dist/runtime.js.map +1 -0
- package/dist/state.d.ts +24 -0
- package/dist/state.d.ts.map +1 -0
- package/dist/state.js +39 -0
- package/dist/state.js.map +1 -0
- package/dist/tools.d.ts +83 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +162 -0
- package/dist/tools.js.map +1 -0
- package/package.json +67 -0
- package/src/agent.ts +334 -0
- package/src/definition.ts +136 -0
- package/src/history.ts +198 -0
- package/src/identity.ts +125 -0
- package/src/images.ts +184 -0
- package/src/index.ts +23 -0
- package/src/interactions.ts +121 -0
- package/src/memory.ts +42 -0
- package/src/navigation.ts +84 -0
- package/src/runtime.ts +243 -0
- package/src/state.ts +39 -0
- package/src/tools.ts +166 -0
package/dist/tools.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { tool, jsonSchema, asSchema } from 'ai';
|
|
2
|
+
import { Ajv } from 'ajv';
|
|
3
|
+
import { appendFileSync, mkdirSync, chmodSync, lstatSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
6
|
+
import { validateQuestion } from './interactions.js';
|
|
7
|
+
/** Name of the built-in human question tool. */
|
|
8
|
+
export const ASK_USER_TOOL = 'ask_user';
|
|
9
|
+
export const askUserTool = tool({
|
|
10
|
+
description: 'Ask a structured question during this turn. Supply choices and/or allowFreeText. User can cancel. Ordinary conversational clarification can instead use normal assistant text.',
|
|
11
|
+
inputSchema: jsonSchema({ type: 'object', properties: { question: { type: 'string', minLength: 1, maxLength: 1000 }, options: { type: 'array', maxItems: 12, items: { type: 'object', properties: { id: { type: 'string', pattern: '^[a-zA-Z0-9_-]{1,40}$' }, label: { type: 'string', minLength: 1, maxLength: 200 } }, required: ['id', 'label'], additionalProperties: false } }, allowFreeText: { type: 'boolean' }, multiSelect: { type: 'boolean' } }, required: ['question'], additionalProperties: false }),
|
|
12
|
+
// The bridge answers this tool through the interaction broker; this handler never runs.
|
|
13
|
+
execute: async (_question) => { throw new Error('interaction_unavailable'); },
|
|
14
|
+
});
|
|
15
|
+
/** Append-only, private (0600) daily NDJSON trace writer. */
|
|
16
|
+
export function fileTraceWriter(directory) {
|
|
17
|
+
return event => {
|
|
18
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
19
|
+
if (lstatSync(directory).isSymbolicLink())
|
|
20
|
+
throw new Error('unsafe_trace_directory');
|
|
21
|
+
chmodSync(directory, 0o700);
|
|
22
|
+
const file = join(directory, `${event.at.slice(0, 10)}.ndjson`);
|
|
23
|
+
try {
|
|
24
|
+
if (lstatSync(file).isSymbolicLink())
|
|
25
|
+
throw new Error('unsafe_trace_file');
|
|
26
|
+
}
|
|
27
|
+
catch (e) {
|
|
28
|
+
if (e.code !== 'ENOENT')
|
|
29
|
+
throw e;
|
|
30
|
+
}
|
|
31
|
+
appendFileSync(file, `${JSON.stringify(event)}\n`, { mode: 0o600 });
|
|
32
|
+
chmodSync(file, 0o600);
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Map AI SDK tools onto Letta client-side tools with a fail-closed policy:
|
|
37
|
+
* schema validation before anything else, exactly-once execution per call ID,
|
|
38
|
+
* per-call approval bound to a snapshot of the arguments, deadlines,
|
|
39
|
+
* cancellation, bounded output and sanitized errors.
|
|
40
|
+
*/
|
|
41
|
+
export function createToolBridge(options) {
|
|
42
|
+
const definitions = options.tools;
|
|
43
|
+
const permissions = options.permissions;
|
|
44
|
+
const restrict = options.allowedTools ? new Set(options.allowedTools) : undefined;
|
|
45
|
+
const allowed = new Set(Object.keys(definitions).filter(n => (!restrict || restrict.has(n)) && Object.hasOwn(permissions, n) && (permissions[n] === 'allow' || permissions[n] === 'ask') && (n === ASK_USER_TOOL || typeof definitions[n]?.execute === 'function')));
|
|
46
|
+
const sessionId = randomUUID();
|
|
47
|
+
const seen = new Set();
|
|
48
|
+
const validators = new Map();
|
|
49
|
+
const ajv = new Ajv({ strict: false, allErrors: false });
|
|
50
|
+
const validateArguments = async (name, args) => {
|
|
51
|
+
try {
|
|
52
|
+
let validate = validators.get(name);
|
|
53
|
+
if (!validate) {
|
|
54
|
+
validate = ajv.compile(await asSchema(definitions[name].inputSchema).jsonSchema);
|
|
55
|
+
validators.set(name, validate);
|
|
56
|
+
}
|
|
57
|
+
if (!validate(args))
|
|
58
|
+
return false;
|
|
59
|
+
if (name === ASK_USER_TOOL)
|
|
60
|
+
validateQuestion(args);
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
const emit = (name, id, status, started, code) => {
|
|
68
|
+
const event = { type: 'tool', sessionId, callId: createHash('sha256').update(id).digest('hex').slice(0, 24), tool: Object.hasOwn(definitions, name) ? name : 'unknown', status, durationMs: Math.max(0, Date.now() - started), at: new Date().toISOString(), ...(code ? { code } : {}) };
|
|
69
|
+
options.persist?.(event);
|
|
70
|
+
options.onTool?.(event);
|
|
71
|
+
};
|
|
72
|
+
const denied = (name, id, code) => { emit(name, id, 'denied', Date.now(), code); return { content: [{ type: 'text', text: JSON.stringify({ error: code }) }], isError: true }; };
|
|
73
|
+
const execute = async (name, id, args, sdkSignal) => {
|
|
74
|
+
if (!allowed.has(name))
|
|
75
|
+
return denied(name, id, 'tool_denied');
|
|
76
|
+
if (seen.has(id) || seen.size >= 100)
|
|
77
|
+
return denied(name, id, 'duplicate_or_limit');
|
|
78
|
+
seen.add(id);
|
|
79
|
+
const definition = definitions[name];
|
|
80
|
+
// Snapshot before any await; caller mutation cannot change an approved call.
|
|
81
|
+
try {
|
|
82
|
+
args = structuredClone(args);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return denied(name, id, 'invalid_arguments');
|
|
86
|
+
}
|
|
87
|
+
const control = new AbortController();
|
|
88
|
+
const signal = AbortSignal.any([control.signal, ...[options.signal, sdkSignal].filter((s) => !!s)]);
|
|
89
|
+
if (!await validateArguments(name, args))
|
|
90
|
+
return denied(name, id, 'invalid_arguments');
|
|
91
|
+
const started = Date.now();
|
|
92
|
+
if (permissions[name] === 'ask' || name === ASK_USER_TOOL) {
|
|
93
|
+
try {
|
|
94
|
+
signal.throwIfAborted();
|
|
95
|
+
if (!options.interactions)
|
|
96
|
+
return denied(name, id, 'interaction_unavailable');
|
|
97
|
+
if (permissions[name] === 'ask') {
|
|
98
|
+
const answer = await options.interactions.request({ kind: 'approval', toolCallId: id, tool: name, title: `Approve ${name}?`, details: JSON.stringify(args) }, signal);
|
|
99
|
+
signal.throwIfAborted();
|
|
100
|
+
if (answer.approved !== true)
|
|
101
|
+
return denied(name, id, answer.cancelled ? 'approval_cancelled' : 'user_denied');
|
|
102
|
+
emit(name, id, 'approved', started, 'approved_once');
|
|
103
|
+
}
|
|
104
|
+
if (name === ASK_USER_TOOL) {
|
|
105
|
+
const question = args;
|
|
106
|
+
const answer = await options.interactions.request({ kind: 'question', toolCallId: id, tool: name, title: question.question, options: question.options, allowFreeText: question.allowFreeText, multiSelect: question.multiSelect }, signal);
|
|
107
|
+
signal.throwIfAborted();
|
|
108
|
+
const output = answer.cancelled ? { cancelled: true } : { cancelled: false, selected: answer.selected ?? [], ...(answer.text ? { text: answer.text } : {}) };
|
|
109
|
+
emit(name, id, answer.cancelled ? 'cancelled' : 'completion', started, answer.cancelled ? 'user_cancelled' : 'answered');
|
|
110
|
+
return { content: [{ type: 'text', text: JSON.stringify(output) }], isError: false };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return denied(name, id, signal.aborted ? 'tool_cancelled' : 'interaction_unavailable');
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
emit(name, id, 'start', started, permissions[name] === 'ask' ? 'approved_once' : 'static_allow');
|
|
118
|
+
let timer;
|
|
119
|
+
let abort;
|
|
120
|
+
try {
|
|
121
|
+
signal.throwIfAborted();
|
|
122
|
+
const interrupted = new Promise((_, reject) => {
|
|
123
|
+
abort = () => reject(new Error(control.signal.aborted ? 'tool_timeout' : 'tool_cancelled'));
|
|
124
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
125
|
+
timer = setTimeout(() => control.abort(), options.timeoutMs ?? 5000);
|
|
126
|
+
});
|
|
127
|
+
const output = await Promise.race([Promise.resolve().then(() => { signal.throwIfAborted(); return definition.execute(args, { toolCallId: id, messages: [], abortSignal: signal }); }), interrupted]);
|
|
128
|
+
const text = JSON.stringify(output);
|
|
129
|
+
if (typeof text !== 'string' || text.length > 16000)
|
|
130
|
+
throw new Error('tool_output_limit');
|
|
131
|
+
emit(name, id, 'completion', started);
|
|
132
|
+
return { content: [{ type: 'text', text }], isError: false };
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
const code = signal.aborted ? (control.signal.aborted ? 'tool_timeout' : 'tool_cancelled') : 'tool_failed';
|
|
136
|
+
emit(name, id, 'error', started, code);
|
|
137
|
+
return { content: [{ type: 'text', text: JSON.stringify({ error: code }) }], isError: true };
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
clearTimeout(timer);
|
|
141
|
+
if (abort)
|
|
142
|
+
signal.removeEventListener('abort', abort);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
const tools = [...allowed].map(name => ({ name, label: name, description: definitions[name].description ?? name, parameters: asSchema(definitions[name].inputSchema).jsonSchema, execute: (id, args, signal) => execute(name, id, args, signal) }));
|
|
146
|
+
return {
|
|
147
|
+
/** Letta client-tool descriptors. */
|
|
148
|
+
tools,
|
|
149
|
+
/** Names exposed to the agent. */
|
|
150
|
+
allowedTools: [...allowed],
|
|
151
|
+
/** Execute one call through the full policy. */
|
|
152
|
+
execute,
|
|
153
|
+
/** Letta `canUseTool` hook: allow only exposed tools with valid arguments. */
|
|
154
|
+
canUseTool: async (name, input, _context) => {
|
|
155
|
+
if (allowed.has(name) && (input === undefined || await validateArguments(name, input)))
|
|
156
|
+
return { behavior: 'allow' };
|
|
157
|
+
denied(name, randomUUID(), 'permission_denied');
|
|
158
|
+
return { behavior: 'deny', message: 'Tool not allowed by this session.' };
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
//# sourceMappingURL=tools.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tools.js","sourceRoot":"","sources":["../src/tools.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ai-sdk-letta",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A persistent, Letta-backed Vercel AI SDK Agent with memory, dreaming, application-owned tools and human-in-the-loop.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"author": "Leonard Schneider",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/leonardschneider/ai-sdk-letta.git",
|
|
11
|
+
"directory": "packages/ai-sdk-letta"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/leonardschneider/ai-sdk-letta#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/leonardschneider/ai-sdk-letta/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"ai",
|
|
19
|
+
"ai-sdk",
|
|
20
|
+
"vercel",
|
|
21
|
+
"letta",
|
|
22
|
+
"agent",
|
|
23
|
+
"memory",
|
|
24
|
+
"stateful-agents",
|
|
25
|
+
"human-in-the-loop",
|
|
26
|
+
"tools"
|
|
27
|
+
],
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=22.19.0"
|
|
30
|
+
},
|
|
31
|
+
"sideEffects": false,
|
|
32
|
+
"main": "./dist/index.js",
|
|
33
|
+
"types": "./dist/index.d.ts",
|
|
34
|
+
"exports": {
|
|
35
|
+
".": {
|
|
36
|
+
"ai-sdk-letta-source": "./src/index.ts",
|
|
37
|
+
"types": "./dist/index.d.ts",
|
|
38
|
+
"import": "./dist/index.js",
|
|
39
|
+
"default": "./dist/index.js"
|
|
40
|
+
},
|
|
41
|
+
"./package.json": "./package.json"
|
|
42
|
+
},
|
|
43
|
+
"files": [
|
|
44
|
+
"dist",
|
|
45
|
+
"src",
|
|
46
|
+
"CHANGELOG.md",
|
|
47
|
+
"NOTICE"
|
|
48
|
+
],
|
|
49
|
+
"scripts": {
|
|
50
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
51
|
+
"typecheck": "tsc --noEmit -p .",
|
|
52
|
+
"test": "node --conditions=ai-sdk-letta-source --import tsx --test test/*.test.ts",
|
|
53
|
+
"test:live": "node --conditions=ai-sdk-letta-source --import tsx --test --test-concurrency=1 test/live/*.test.ts",
|
|
54
|
+
"prepack": "node ../../scripts/check-dist.mjs"
|
|
55
|
+
},
|
|
56
|
+
"dependencies": {
|
|
57
|
+
"@letta-ai/letta-agent-sdk": "0.8.22",
|
|
58
|
+
"ajv": "^8.20.0"
|
|
59
|
+
},
|
|
60
|
+
"peerDependencies": {
|
|
61
|
+
"ai": "^7.0.118"
|
|
62
|
+
},
|
|
63
|
+
"devDependencies": {
|
|
64
|
+
"@ai-sdk/provider": "4.0.18",
|
|
65
|
+
"ai": "7.0.118"
|
|
66
|
+
}
|
|
67
|
+
}
|
package/src/agent.ts
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import { generateText, streamText, stepCountIs, type Agent, type AgentCallParameters, type AgentStreamParameters, type ModelMessage, type ToolSet, type UIMessage } from 'ai';
|
|
2
|
+
import type { JSONValue, LanguageModelV4, LanguageModelV4StreamPart, LanguageModelV4Usage } from '@ai-sdk/provider';
|
|
3
|
+
import type { LettaCodeSession, MessageContentItem, SendMessage } from '@letta-ai/letta-agent-sdk';
|
|
4
|
+
import { ToolInteractions } from './interactions.js';
|
|
5
|
+
import { assertImageBudget, compactImagePart, decodeImagePart, imagePartDigest, ImageInputError, isImagePart, toLettaImage, type DecodedImage } from './images.js';
|
|
6
|
+
|
|
7
|
+
/** Most characters of text in one user turn. */
|
|
8
|
+
export const MAX_INPUT_CHARACTERS = 8000;
|
|
9
|
+
|
|
10
|
+
/** The subset of a Letta session a turn needs. */
|
|
11
|
+
export type TurnSession = Pick<LettaCodeSession, 'send' | 'stream' | 'abort' | 'close'>;
|
|
12
|
+
|
|
13
|
+
/** Display-only state restored when the agent was opened. Never sent to the model. */
|
|
14
|
+
export interface AgentPresentation {
|
|
15
|
+
conversationId: string;
|
|
16
|
+
title: string;
|
|
17
|
+
/** Projected backend history for display (text and completed application tool cards). */
|
|
18
|
+
initialMessages: UIMessage[];
|
|
19
|
+
/** Human-readable startup summary. */
|
|
20
|
+
status: string;
|
|
21
|
+
memoryDirectory: string;
|
|
22
|
+
historyTruncated: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Durable delivery hooks: `begin` before sending, `complete` after a confirmed finish. */
|
|
26
|
+
export interface DeliveryHooks { begin(): void; complete(): void }
|
|
27
|
+
|
|
28
|
+
/** Options for constructing a {@link LettaAgent} directly (normally done by `openLettaAgent`). */
|
|
29
|
+
export interface LettaAgentOptions<TOOLS extends ToolSet> {
|
|
30
|
+
/** Logical definition ID. */
|
|
31
|
+
id: string;
|
|
32
|
+
tools: TOOLS;
|
|
33
|
+
/** Opens a per-turn view of the (long-lived) Letta session. */
|
|
34
|
+
open: (signal: AbortSignal) => TurnSession;
|
|
35
|
+
/** Harness tool names hidden from AI SDK results (MemFS operations). */
|
|
36
|
+
memoryTools?: readonly string[];
|
|
37
|
+
/** Letta-generated agent ID. */
|
|
38
|
+
lettaAgentId?: string;
|
|
39
|
+
/** Model handle reported as `modelId` on results. */
|
|
40
|
+
modelId?: string;
|
|
41
|
+
presentation?: AgentPresentation;
|
|
42
|
+
delivery?: DeliveryHooks;
|
|
43
|
+
interactions?: ToolInteractions;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type Call<TOOLS extends ToolSet> = AgentCallParameters<never, TOOLS>;
|
|
47
|
+
const usage = (): LanguageModelV4Usage => ({ inputTokens: { total: undefined, noCache: undefined, cacheRead: undefined, cacheWrite: undefined }, outputTokens: { total: undefined, text: undefined, reasoning: undefined } });
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Canonical key of a semantic transcript (text, user images and completed tool
|
|
51
|
+
* calls), ignoring SDK metadata and step boundaries. Used to reject replayed or
|
|
52
|
+
* edited history: a new call must extend exactly what this process already
|
|
53
|
+
* sent. Images are compared by a SHA-256 of their bytes, so a transcript may
|
|
54
|
+
* carry either the image data or a compact reference to it.
|
|
55
|
+
*/
|
|
56
|
+
export function historyKey(messages: ModelMessage[]): string {
|
|
57
|
+
const parts: unknown[] = [];
|
|
58
|
+
for (const message of messages) {
|
|
59
|
+
if (message.role === 'system') throw new Error('System messages are not supported');
|
|
60
|
+
const content = typeof message.content === 'string' ? [{ type: 'text' as const, text: message.content }] : message.content;
|
|
61
|
+
for (const part of content) {
|
|
62
|
+
if (part.type === 'text') {
|
|
63
|
+
const last = parts.at(-1) as { role?: string; text?: string } | undefined;
|
|
64
|
+
if (last?.role === message.role && typeof last.text === 'string') last.text += part.text;
|
|
65
|
+
else parts.push({ role: message.role, text: part.text });
|
|
66
|
+
} else if (message.role === 'user' && isImagePart(part)) {
|
|
67
|
+
// A boundary like text: an image between two texts is not the same turn as the joined text.
|
|
68
|
+
parts.push({ role: message.role, text: null, image: imagePartDigest(part) });
|
|
69
|
+
} else if (part.type === 'tool-call' && message.role === 'assistant') {
|
|
70
|
+
parts.push({ call: part.toolCallId, name: part.toolName, input: part.input });
|
|
71
|
+
} else if (part.type === 'tool-result' && (message.role === 'assistant' || message.role === 'tool')) {
|
|
72
|
+
// UI error cards stringify provider error objects; conversion returns an
|
|
73
|
+
// error-json string whereas response.messages retains the original JSON.
|
|
74
|
+
// Normalize only error payloads, never successful text/JSON outputs.
|
|
75
|
+
let output = part.output;
|
|
76
|
+
if (output.type === 'error-json' && typeof output.value === 'string') {
|
|
77
|
+
try { output = { ...output, value: JSON.parse(output.value) }; } catch { /* plain error text */ }
|
|
78
|
+
}
|
|
79
|
+
parts.push({ result: part.toolCallId, name: part.toolName, output });
|
|
80
|
+
} else throw new Error('Only text, user image and completed application tool history are supported');
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// Parallel calls arrive call/call/result/result, while UI cards convert as
|
|
84
|
+
// call/result/call/result. Canonicalize contiguous tool groups only; retain
|
|
85
|
+
// every ID, name, input and output and all text/turn boundaries.
|
|
86
|
+
const canonical: unknown[] = [];
|
|
87
|
+
let tools: unknown[] = [];
|
|
88
|
+
const flush = () => {
|
|
89
|
+
tools.sort((a, b) => {
|
|
90
|
+
const key = (value: unknown) => { const p = value as { call?: string; result?: string }; return `${p.call ?? p.result}\u0000${p.call ? '0' : '1'}`; };
|
|
91
|
+
return key(a).localeCompare(key(b));
|
|
92
|
+
});
|
|
93
|
+
canonical.push(...tools); tools = [];
|
|
94
|
+
};
|
|
95
|
+
for (const part of parts) {
|
|
96
|
+
if ('text' in (part as object)) { flush(); canonical.push(part); }
|
|
97
|
+
else tools.push(part);
|
|
98
|
+
}
|
|
99
|
+
flush();
|
|
100
|
+
return JSON.stringify(canonical);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Replace user image data with content-hash references so the retained transcript never duplicates image bytes. */
|
|
104
|
+
function compactTranscript(messages: ModelMessage[]): ModelMessage[] {
|
|
105
|
+
return messages.map(message => message.role === 'user' && typeof message.content !== 'string'
|
|
106
|
+
? { ...message, content: message.content.map(part => isImagePart(part) ? compactImagePart(part) : part) }
|
|
107
|
+
: message);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Validate the new user turn and build the single Letta message to send:
|
|
112
|
+
* a plain string for text-only turns, or text and `ImageContent` items in
|
|
113
|
+
* their original order.
|
|
114
|
+
* @throws {ImageInputError} for unsupported, invalid or oversized images
|
|
115
|
+
*/
|
|
116
|
+
export function userTurnContent(content: ModelMessage['content']): { message: SendMessage; text: string; images: DecodedImage[] } {
|
|
117
|
+
const parts = typeof content === 'string' ? [{ type: 'text' as const, text: content }] : content;
|
|
118
|
+
if (!Array.isArray(parts)) throw new Error('Expected a new user turn');
|
|
119
|
+
const items: MessageContentItem[] = [];
|
|
120
|
+
const images: DecodedImage[] = [];
|
|
121
|
+
for (const part of parts) {
|
|
122
|
+
if (part.type === 'text') {
|
|
123
|
+
const last = items.at(-1);
|
|
124
|
+
if (last?.type === 'text') last.text += part.text;
|
|
125
|
+
else items.push({ type: 'text', text: part.text });
|
|
126
|
+
} else if (isImagePart(part)) {
|
|
127
|
+
const image = decodeImagePart(part);
|
|
128
|
+
images.push(image);
|
|
129
|
+
assertImageBudget(images);
|
|
130
|
+
items.push(toLettaImage(image));
|
|
131
|
+
} else if (part.type === 'file') {
|
|
132
|
+
throw new ImageInputError('image_unsupported_type', `Unsupported attachment type ${String(part.mediaType) || 'unknown'}; only PNG, JPEG, GIF and WebP images are supported`);
|
|
133
|
+
} else throw new Error('Only text and image input is supported');
|
|
134
|
+
}
|
|
135
|
+
const text = items.map(item => item.type === 'text' ? item.text : '').join('');
|
|
136
|
+
if (text.length > MAX_INPUT_CHARACTERS) throw new Error(`Input text can be up to ${MAX_INPUT_CHARACTERS} characters`);
|
|
137
|
+
if (!text.trim() && !images.length) throw new Error('Input must contain text or an image');
|
|
138
|
+
// Whitespace-only text next to images carries nothing; keep text-only turns byte-identical to before.
|
|
139
|
+
const message: SendMessage = images.length ? items.filter(item => item.type !== 'text' || item.text.trim()) : text;
|
|
140
|
+
return { message, text, images };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* A persistent, Letta-backed Vercel AI SDK `Agent`.
|
|
145
|
+
*
|
|
146
|
+
* Letta owns the only reasoning and tool loop: each call sends exactly one new
|
|
147
|
+
* user message, and application tools run in this process through the Letta
|
|
148
|
+
* client-tool protocol (reported as `providerExecuted`). The AI SDK never
|
|
149
|
+
* dispatches tools itself. History you pass in must extend what this instance
|
|
150
|
+
* already sent; edits, regeneration and replay are rejected. After any failure
|
|
151
|
+
* or cancellation the instance refuses further turns, because delivery is
|
|
152
|
+
* uncertain; reopen it after inspecting the backend.
|
|
153
|
+
*/
|
|
154
|
+
export class LettaAgent<TOOLS extends ToolSet = ToolSet> implements Agent<never, TOOLS> {
|
|
155
|
+
readonly version = 'agent-v1' as const;
|
|
156
|
+
readonly id: string;
|
|
157
|
+
readonly tools: TOOLS;
|
|
158
|
+
/** Letta-generated agent ID, when known. */
|
|
159
|
+
readonly lettaAgentId?: string;
|
|
160
|
+
/** Display-only restored state. */
|
|
161
|
+
readonly presentation?: AgentPresentation;
|
|
162
|
+
/** Broker for approvals and `ask_user` questions; connect a renderer to it. */
|
|
163
|
+
readonly interactions: ToolInteractions;
|
|
164
|
+
private readonly open: (signal: AbortSignal) => TurnSession;
|
|
165
|
+
private readonly memoryTools: readonly string[];
|
|
166
|
+
private readonly delivery?: DeliveryHooks;
|
|
167
|
+
private readonly modelId: string;
|
|
168
|
+
private history: ModelMessage[] = [];
|
|
169
|
+
private busy = false;
|
|
170
|
+
private unusable = false;
|
|
171
|
+
private active?: AbortController;
|
|
172
|
+
|
|
173
|
+
constructor(options: LettaAgentOptions<TOOLS>) {
|
|
174
|
+
this.id = options.id;
|
|
175
|
+
this.tools = options.tools;
|
|
176
|
+
this.open = options.open;
|
|
177
|
+
this.memoryTools = options.memoryTools ?? [];
|
|
178
|
+
this.lettaAgentId = options.lettaAgentId;
|
|
179
|
+
this.presentation = options.presentation;
|
|
180
|
+
this.delivery = options.delivery;
|
|
181
|
+
this.interactions = options.interactions ?? new ToolInteractions();
|
|
182
|
+
this.modelId = options.modelId ?? 'letta';
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* A copy of the transcript this instance has sent and received (images as
|
|
187
|
+
* content-hash references, never bytes). To send a multimodal turn without
|
|
188
|
+
* keeping your own history, pass `messages: [...agent.transcript, newUserMessage]`.
|
|
189
|
+
*/
|
|
190
|
+
get transcript(): ModelMessage[] { return structuredClone(this.history); }
|
|
191
|
+
|
|
192
|
+
/** Abort any running turn, cancel pending prompts, and refuse further turns. */
|
|
193
|
+
close(): void { this.unusable = true; this.active?.abort(); this.interactions.close(); }
|
|
194
|
+
|
|
195
|
+
private prepare(options: Call<TOOLS>) {
|
|
196
|
+
if (this.unusable) throw new Error('Session closed or delivery uncertain; inspect backend history before reopening (no retries).');
|
|
197
|
+
if (this.busy) throw new Error('A turn is already running');
|
|
198
|
+
for (const [key, value] of Object.entries(options)) {
|
|
199
|
+
if (!['prompt', 'messages', 'abortSignal'].includes(key) && value !== undefined) throw new Error(`Unsupported agent option: ${key}`);
|
|
200
|
+
}
|
|
201
|
+
if (options.prompt !== undefined && options.messages !== undefined) throw new Error('Use prompt or messages, not both');
|
|
202
|
+
const messages = typeof options.prompt === 'string' ? [...this.history, { role: 'user' as const, content: options.prompt }] : options.messages ?? options.prompt;
|
|
203
|
+
if (!Array.isArray(messages) || !messages.length) throw new Error('Expected a new user turn');
|
|
204
|
+
const last = messages.at(-1)!;
|
|
205
|
+
if (last.role !== 'user') throw new Error('History edits, replay, and regeneration are not supported');
|
|
206
|
+
// Validate the new turn first, so an oversized or unsupported image is reported as such.
|
|
207
|
+
const turn = userTurnContent(last.content);
|
|
208
|
+
if (historyKey(messages.slice(0, -1)) !== historyKey(this.history)) throw new Error('History edits, replay, and regeneration are not supported');
|
|
209
|
+
options.abortSignal?.throwIfAborted();
|
|
210
|
+
this.busy = true;
|
|
211
|
+
const control = new AbortController();
|
|
212
|
+
this.active = control;
|
|
213
|
+
const signal = options.abortSignal ? AbortSignal.any([options.abortSignal, control.signal]) : control.signal;
|
|
214
|
+
// The retained transcript keeps images as hashes only; Letta already holds the bytes.
|
|
215
|
+
// (Compact before cloning: URL objects in image parts are not cloneable.)
|
|
216
|
+
return { messages: structuredClone(compactTranscript(messages)), message: turn.message, prompt: turn.text.trim() ? turn.text : '[Image]', signal };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private model(message: SendMessage, signal: AbortSignal): LanguageModelV4 {
|
|
220
|
+
const run = async (emit: (part: LanguageModelV4StreamPart) => void) => {
|
|
221
|
+
let session: TurnSession | undefined;
|
|
222
|
+
let completed = false;
|
|
223
|
+
const abort = () => { void session?.abort().catch(() => {}); session?.close(); };
|
|
224
|
+
const calls = new Map<string, string>();
|
|
225
|
+
const memoryCalls = new Set<string>();
|
|
226
|
+
const tokens = usage();
|
|
227
|
+
let textId = 0;
|
|
228
|
+
let textOpen = false;
|
|
229
|
+
const endText = () => { if (textOpen) { emit({ type: 'text-end', id: String(textId) }); textOpen = false; } };
|
|
230
|
+
try {
|
|
231
|
+
signal.throwIfAborted();
|
|
232
|
+
session = this.open(signal);
|
|
233
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
234
|
+
this.delivery?.begin();
|
|
235
|
+
await session.send(message);
|
|
236
|
+
signal.throwIfAborted();
|
|
237
|
+
for await (const event of session.stream()) {
|
|
238
|
+
signal.throwIfAborted();
|
|
239
|
+
if (event.type === 'assistant') {
|
|
240
|
+
if (!textOpen) { textId++; emit({ type: 'text-start', id: String(textId) }); textOpen = true; }
|
|
241
|
+
emit({ type: 'text-delta', id: String(textId), delta: event.content });
|
|
242
|
+
} else if (event.type === 'tool_call') {
|
|
243
|
+
endText();
|
|
244
|
+
if (calls.has(event.toolCallId) || memoryCalls.has(event.toolCallId)) throw new Error('Duplicate tool call');
|
|
245
|
+
// Harness memory operations are not application tool cards/history.
|
|
246
|
+
if (this.memoryTools.includes(event.toolName)) { memoryCalls.add(event.toolCallId); continue; }
|
|
247
|
+
if (!Object.hasOwn(this.tools, event.toolName)) throw new Error('Unexpected tool call');
|
|
248
|
+
calls.set(event.toolCallId, event.toolName);
|
|
249
|
+
emit({ type: 'tool-call', toolCallId: event.toolCallId, toolName: event.toolName, input: JSON.stringify(event.toolInput), providerExecuted: true });
|
|
250
|
+
} else if (event.type === 'tool_result') {
|
|
251
|
+
// The SDK can emit provisional Bash output as a tool_result before
|
|
252
|
+
// the authoritative result. Only known internal calls may use it.
|
|
253
|
+
if (memoryCalls.has(event.toolCallId) && event.uuid.startsWith('synthetic-tool-return-stream-')) continue;
|
|
254
|
+
if (memoryCalls.delete(event.toolCallId)) continue;
|
|
255
|
+
const toolName = calls.get(event.toolCallId);
|
|
256
|
+
if (!toolName) throw new Error('Unmatched tool result');
|
|
257
|
+
let result: JSONValue = event.content;
|
|
258
|
+
try { result = JSON.parse(event.content); } catch { /* SDK text output */ }
|
|
259
|
+
emit({ type: 'tool-result', toolCallId: event.toolCallId, toolName, result: result ?? 'null', isError: event.isError });
|
|
260
|
+
calls.delete(event.toolCallId);
|
|
261
|
+
} else if (event.type === 'stream_event' && event.event.message_type === 'usage_statistics') {
|
|
262
|
+
if (typeof event.event.prompt_tokens === 'number') tokens.inputTokens.total = event.event.prompt_tokens;
|
|
263
|
+
if (typeof event.event.completion_tokens === 'number') tokens.outputTokens.total = event.event.completion_tokens;
|
|
264
|
+
} else if (event.type === 'result') {
|
|
265
|
+
if (!event.success || calls.size || memoryCalls.size) throw new Error('Letta turn failed or left incomplete tools');
|
|
266
|
+
endText(); completed = true;
|
|
267
|
+
emit({ type: 'finish', finishReason: { unified: 'stop', raw: 'stop' }, usage: tokens });
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
throw new Error('Letta stream closed before completion');
|
|
272
|
+
} catch (error) {
|
|
273
|
+
this.unusable = true;
|
|
274
|
+
throw error;
|
|
275
|
+
} finally {
|
|
276
|
+
signal.removeEventListener('abort', abort);
|
|
277
|
+
if (!completed) void session?.abort().catch(() => {});
|
|
278
|
+
session?.close();
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
return {
|
|
282
|
+
specificationVersion: 'v4', provider: 'ai-sdk-letta', modelId: this.modelId, supportedUrls: {},
|
|
283
|
+
doGenerate: async () => {
|
|
284
|
+
const content: Awaited<ReturnType<LanguageModelV4['doGenerate']>>['content'] = [];
|
|
285
|
+
let tokens = usage();
|
|
286
|
+
await run(part => {
|
|
287
|
+
if (part.type === 'text-delta') {
|
|
288
|
+
const last = content.at(-1);
|
|
289
|
+
if (last?.type === 'text') last.text += part.delta;
|
|
290
|
+
else content.push({ type: 'text', text: part.delta });
|
|
291
|
+
} else if (part.type === 'tool-call' || part.type === 'tool-result') content.push(part);
|
|
292
|
+
else if (part.type === 'finish') tokens = part.usage;
|
|
293
|
+
});
|
|
294
|
+
return { content, usage: tokens, warnings: [], finishReason: { unified: 'stop', raw: 'stop' } };
|
|
295
|
+
},
|
|
296
|
+
doStream: async () => ({ stream: new ReadableStream<LanguageModelV4StreamPart>({
|
|
297
|
+
start: controller => {
|
|
298
|
+
controller.enqueue({ type: 'stream-start', warnings: [] });
|
|
299
|
+
void run(part => controller.enqueue(part)).catch(error => {
|
|
300
|
+
try { controller.enqueue({ type: 'error', error }); } catch { /* reader closed */ }
|
|
301
|
+
}).finally(() => { try { controller.close(); } catch { /* reader closed */ } });
|
|
302
|
+
},
|
|
303
|
+
cancel: () => this.close(),
|
|
304
|
+
}) }),
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Run one turn and wait for the full result. */
|
|
309
|
+
async generate(options: Call<TOOLS>) {
|
|
310
|
+
const turn = this.prepare(options);
|
|
311
|
+
try {
|
|
312
|
+
const result = await generateText({ model: this.model(turn.message, turn.signal), prompt: turn.prompt, tools: this.tools, maxRetries: 0, stopWhen: stepCountIs(1), abortSignal: turn.signal });
|
|
313
|
+
this.delivery?.complete();
|
|
314
|
+
this.history = [...turn.messages, ...result.response.messages];
|
|
315
|
+
return result;
|
|
316
|
+
} catch (error) { this.unusable = true; throw error; }
|
|
317
|
+
finally { this.busy = false; this.active = undefined; }
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Run one turn as a stream (text deltas, provider-executed tool calls and results). */
|
|
321
|
+
async stream(options: AgentStreamParameters<never, TOOLS>) {
|
|
322
|
+
const turn = this.prepare(options);
|
|
323
|
+
return streamText({ model: this.model(turn.message, turn.signal), prompt: turn.prompt, tools: this.tools, maxRetries: 0, stopWhen: stepCountIs(1), abortSignal: turn.signal,
|
|
324
|
+
onError: () => { this.unusable = true; this.busy = false; },
|
|
325
|
+
onAbort: () => { this.close(); this.busy = false; },
|
|
326
|
+
onFinish: result => {
|
|
327
|
+
try { if (!this.unusable && result.finishReason === 'stop') this.delivery?.complete(); }
|
|
328
|
+
catch (error) { this.unusable = true; throw error; }
|
|
329
|
+
finally { this.busy = false; this.active = undefined; }
|
|
330
|
+
this.history = [...turn.messages, ...result.response.messages];
|
|
331
|
+
},
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
}
|