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/src/tools.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { tool, jsonSchema, asSchema, type Tool, type ToolSet } from 'ai';
|
|
2
|
+
import { Ajv } from 'ajv';
|
|
3
|
+
import type { AnyAgentTool } from '@letta-ai/letta-agent-sdk';
|
|
4
|
+
import { appendFileSync, mkdirSync, chmodSync, lstatSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
7
|
+
import { validateQuestion, type Question, type ToolInteractions } from './interactions.js';
|
|
8
|
+
import type { ToolPermission } from './definition.js';
|
|
9
|
+
|
|
10
|
+
/** Name of the built-in human question tool. */
|
|
11
|
+
export const ASK_USER_TOOL = 'ask_user';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Built-in tool that lets the agent ask the human a structured question
|
|
15
|
+
* mid-turn. Add it to a definition's tools as `ask_user: askUserTool`.
|
|
16
|
+
* The answer is supplied by the connected interaction renderer.
|
|
17
|
+
*/
|
|
18
|
+
/** Result of `ask_user`, as returned to the agent. */
|
|
19
|
+
export type AskUserResult = { cancelled: boolean; selected?: string[]; text?: string };
|
|
20
|
+
|
|
21
|
+
export const askUserTool: Tool<Question, AskUserResult> = tool({
|
|
22
|
+
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.',
|
|
23
|
+
inputSchema: jsonSchema<Question>({ 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 }),
|
|
24
|
+
// The bridge answers this tool through the interaction broker; this handler never runs.
|
|
25
|
+
execute: async (_question: Question): Promise<AskUserResult> => { throw new Error('interaction_unavailable'); },
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/** Metadata-only audit event. Never contains arguments, outputs or raw call IDs. */
|
|
29
|
+
export type ToolActivity = { type: 'tool'; sessionId: string; callId: string; tool: string; status: 'start' | 'completion' | 'error' | 'denied' | 'approved' | 'cancelled'; durationMs: number; code?: string; at: string };
|
|
30
|
+
|
|
31
|
+
/** Append-only, private (0600) daily NDJSON trace writer. */
|
|
32
|
+
export function fileTraceWriter(directory: string): (event: ToolActivity) => void {
|
|
33
|
+
return event => {
|
|
34
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
35
|
+
if (lstatSync(directory).isSymbolicLink()) throw new Error('unsafe_trace_directory');
|
|
36
|
+
chmodSync(directory, 0o700);
|
|
37
|
+
const file = join(directory, `${event.at.slice(0, 10)}.ndjson`);
|
|
38
|
+
try { if (lstatSync(file).isSymbolicLink()) throw new Error('unsafe_trace_file'); } catch (e) { if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e; }
|
|
39
|
+
appendFileSync(file, `${JSON.stringify(event)}\n`, { mode: 0o600 });
|
|
40
|
+
chmodSync(file, 0o600);
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Options for {@link createToolBridge}. */
|
|
45
|
+
export interface ToolBridgeOptions {
|
|
46
|
+
/** AI SDK tools available to the agent. */
|
|
47
|
+
tools: ToolSet;
|
|
48
|
+
/** Permission per tool. Tools without an entry, or with `'deny'`, are never exposed. */
|
|
49
|
+
permissions: Readonly<Record<string, ToolPermission>>;
|
|
50
|
+
/** Further restrict exposure to these names (intersection). Defaults to every permitted tool. */
|
|
51
|
+
allowedTools?: readonly string[];
|
|
52
|
+
/** Aborts every in-flight call (for example, when the turn is cancelled). */
|
|
53
|
+
signal?: AbortSignal;
|
|
54
|
+
/** Human interaction broker. Without it, `ask` tools and `ask_user` fail closed. */
|
|
55
|
+
interactions?: ToolInteractions;
|
|
56
|
+
/** Per-call execution deadline in ms, excluding human waits. @default 5000 */
|
|
57
|
+
timeoutMs?: number;
|
|
58
|
+
/** Audit sink; see {@link fileTraceWriter}. @default no persistence */
|
|
59
|
+
persist?: (event: ToolActivity) => void;
|
|
60
|
+
/** Observer for audit events. */
|
|
61
|
+
onTool?: (event: ToolActivity) => void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
type ToolOutput = { content: { type: 'text'; text: string }[]; isError: boolean };
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Map AI SDK tools onto Letta client-side tools with a fail-closed policy:
|
|
68
|
+
* schema validation before anything else, exactly-once execution per call ID,
|
|
69
|
+
* per-call approval bound to a snapshot of the arguments, deadlines,
|
|
70
|
+
* cancellation, bounded output and sanitized errors.
|
|
71
|
+
*/
|
|
72
|
+
export function createToolBridge(options: ToolBridgeOptions) {
|
|
73
|
+
const definitions = options.tools as Record<string, { description?: string; inputSchema: Parameters<typeof asSchema>[0]; execute?: (args: unknown, context: { toolCallId: string; messages: []; abortSignal: AbortSignal }) => unknown }>;
|
|
74
|
+
const permissions = options.permissions;
|
|
75
|
+
const restrict = options.allowedTools ? new Set(options.allowedTools) : undefined;
|
|
76
|
+
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')));
|
|
77
|
+
const sessionId = randomUUID();
|
|
78
|
+
const seen = new Set<string>();
|
|
79
|
+
const validators = new Map<string, ReturnType<Ajv['compile']>>();
|
|
80
|
+
const ajv = new Ajv({ strict: false, allErrors: false });
|
|
81
|
+
const validateArguments = async (name: string, args: unknown) => {
|
|
82
|
+
try {
|
|
83
|
+
let validate = validators.get(name);
|
|
84
|
+
if (!validate) { validate = ajv.compile(await asSchema(definitions[name]!.inputSchema).jsonSchema); validators.set(name, validate); }
|
|
85
|
+
if (!validate(args)) return false;
|
|
86
|
+
if (name === ASK_USER_TOOL) validateQuestion(args as Question);
|
|
87
|
+
return true;
|
|
88
|
+
} catch { return false; }
|
|
89
|
+
};
|
|
90
|
+
const emit = (name: string, id: string, status: ToolActivity['status'], started: number, code?: string) => {
|
|
91
|
+
const event: ToolActivity = { 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 } : {}) };
|
|
92
|
+
options.persist?.(event);
|
|
93
|
+
options.onTool?.(event);
|
|
94
|
+
};
|
|
95
|
+
const denied = (name: string, id: string, code: string): ToolOutput => { emit(name, id, 'denied', Date.now(), code); return { content: [{ type: 'text', text: JSON.stringify({ error: code }) }], isError: true }; };
|
|
96
|
+
const execute = async (name: string, id: string, args: unknown, sdkSignal?: AbortSignal): Promise<ToolOutput> => {
|
|
97
|
+
if (!allowed.has(name)) return denied(name, id, 'tool_denied');
|
|
98
|
+
if (seen.has(id) || seen.size >= 100) return denied(name, id, 'duplicate_or_limit');
|
|
99
|
+
seen.add(id);
|
|
100
|
+
const definition = definitions[name]!;
|
|
101
|
+
// Snapshot before any await; caller mutation cannot change an approved call.
|
|
102
|
+
try { args = structuredClone(args); } catch { return denied(name, id, 'invalid_arguments'); }
|
|
103
|
+
const control = new AbortController();
|
|
104
|
+
const signal = AbortSignal.any([control.signal, ...[options.signal, sdkSignal].filter((s): s is AbortSignal => !!s)]);
|
|
105
|
+
if (!await validateArguments(name, args)) return denied(name, id, 'invalid_arguments');
|
|
106
|
+
const started = Date.now();
|
|
107
|
+
if (permissions[name] === 'ask' || name === ASK_USER_TOOL) {
|
|
108
|
+
try {
|
|
109
|
+
signal.throwIfAborted();
|
|
110
|
+
if (!options.interactions) return denied(name, id, 'interaction_unavailable');
|
|
111
|
+
if (permissions[name] === 'ask') {
|
|
112
|
+
const answer = await options.interactions.request({ kind: 'approval', toolCallId: id, tool: name, title: `Approve ${name}?`, details: JSON.stringify(args) }, signal);
|
|
113
|
+
signal.throwIfAborted();
|
|
114
|
+
if (answer.approved !== true) return denied(name, id, answer.cancelled ? 'approval_cancelled' : 'user_denied');
|
|
115
|
+
emit(name, id, 'approved', started, 'approved_once');
|
|
116
|
+
}
|
|
117
|
+
if (name === ASK_USER_TOOL) {
|
|
118
|
+
const question = args as Question;
|
|
119
|
+
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);
|
|
120
|
+
signal.throwIfAborted();
|
|
121
|
+
const output = answer.cancelled ? { cancelled: true } : { cancelled: false, selected: answer.selected ?? [], ...(answer.text ? { text: answer.text } : {}) };
|
|
122
|
+
emit(name, id, answer.cancelled ? 'cancelled' : 'completion', started, answer.cancelled ? 'user_cancelled' : 'answered');
|
|
123
|
+
return { content: [{ type: 'text', text: JSON.stringify(output) }], isError: false };
|
|
124
|
+
}
|
|
125
|
+
} catch { return denied(name, id, signal.aborted ? 'tool_cancelled' : 'interaction_unavailable'); }
|
|
126
|
+
}
|
|
127
|
+
emit(name, id, 'start', started, permissions[name] === 'ask' ? 'approved_once' : 'static_allow');
|
|
128
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
129
|
+
let abort: (() => void) | undefined;
|
|
130
|
+
try {
|
|
131
|
+
signal.throwIfAborted();
|
|
132
|
+
const interrupted = new Promise<never>((_, reject) => {
|
|
133
|
+
abort = () => reject(new Error(control.signal.aborted ? 'tool_timeout' : 'tool_cancelled'));
|
|
134
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
135
|
+
timer = setTimeout(() => control.abort(), options.timeoutMs ?? 5000);
|
|
136
|
+
});
|
|
137
|
+
const output = await Promise.race([Promise.resolve().then(() => { signal.throwIfAborted(); return definition.execute!(args, { toolCallId: id, messages: [], abortSignal: signal }); }), interrupted]);
|
|
138
|
+
const text = JSON.stringify(output);
|
|
139
|
+
if (typeof text !== 'string' || text.length > 16000) throw new Error('tool_output_limit');
|
|
140
|
+
emit(name, id, 'completion', started);
|
|
141
|
+
return { content: [{ type: 'text', text }], isError: false };
|
|
142
|
+
} catch {
|
|
143
|
+
const code = signal.aborted ? (control.signal.aborted ? 'tool_timeout' : 'tool_cancelled') : 'tool_failed';
|
|
144
|
+
emit(name, id, 'error', started, code);
|
|
145
|
+
return { content: [{ type: 'text', text: JSON.stringify({ error: code }) }], isError: true };
|
|
146
|
+
} finally { clearTimeout(timer); if (abort) signal.removeEventListener('abort', abort); }
|
|
147
|
+
};
|
|
148
|
+
const tools: AnyAgentTool[] = [...allowed].map(name => ({ name, label: name, description: definitions[name]!.description ?? name, parameters: asSchema(definitions[name]!.inputSchema).jsonSchema as AnyAgentTool['parameters'], execute: (id: string, args: unknown, signal?: AbortSignal) => execute(name, id, args, signal) }) as AnyAgentTool);
|
|
149
|
+
return {
|
|
150
|
+
/** Letta client-tool descriptors. */
|
|
151
|
+
tools,
|
|
152
|
+
/** Names exposed to the agent. */
|
|
153
|
+
allowedTools: [...allowed],
|
|
154
|
+
/** Execute one call through the full policy. */
|
|
155
|
+
execute,
|
|
156
|
+
/** Letta `canUseTool` hook: allow only exposed tools with valid arguments. */
|
|
157
|
+
canUseTool: async (name: string, input?: Record<string, unknown>, _context?: unknown) => {
|
|
158
|
+
if (allowed.has(name) && (input === undefined || await validateArguments(name, input))) return { behavior: 'allow' as const };
|
|
159
|
+
denied(name, randomUUID(), 'permission_denied');
|
|
160
|
+
return { behavior: 'deny' as const, message: 'Tool not allowed by this session.' };
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Return type of {@link createToolBridge}. */
|
|
166
|
+
export type ToolBridge = ReturnType<typeof createToolBridge>;
|