@jussmor/sdk-ai 0.2.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/dist/conversation-kIkMQdYK.d.cts +105 -0
- package/dist/conversation-kIkMQdYK.d.ts +105 -0
- package/dist/conversation-store-CAyPuBjk.d.ts +10 -0
- package/dist/conversation-store-Cl42jpsA.d.cts +10 -0
- package/dist/index.cjs +1630 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +251 -0
- package/dist/index.d.ts +251 -0
- package/dist/index.js +1536 -0
- package/dist/index.js.map +1 -0
- package/dist/memory-uBLqrQRY.d.cts +28 -0
- package/dist/memory-uBLqrQRY.d.ts +28 -0
- package/dist/providers/llm/anthropic.cjs +275 -0
- package/dist/providers/llm/anthropic.cjs.map +1 -0
- package/dist/providers/llm/anthropic.d.cts +22 -0
- package/dist/providers/llm/anthropic.d.ts +22 -0
- package/dist/providers/llm/anthropic.js +240 -0
- package/dist/providers/llm/anthropic.js.map +1 -0
- package/dist/providers/llm/ollama.cjs +195 -0
- package/dist/providers/llm/ollama.cjs.map +1 -0
- package/dist/providers/llm/ollama.d.cts +23 -0
- package/dist/providers/llm/ollama.d.ts +23 -0
- package/dist/providers/llm/ollama.js +170 -0
- package/dist/providers/llm/ollama.js.map +1 -0
- package/dist/providers/llm/openai.cjs +213 -0
- package/dist/providers/llm/openai.cjs.map +1 -0
- package/dist/providers/llm/openai.d.cts +22 -0
- package/dist/providers/llm/openai.d.ts +22 -0
- package/dist/providers/llm/openai.js +178 -0
- package/dist/providers/llm/openai.js.map +1 -0
- package/dist/providers/memory/filesystem.cjs +112 -0
- package/dist/providers/memory/filesystem.cjs.map +1 -0
- package/dist/providers/memory/filesystem.d.cts +17 -0
- package/dist/providers/memory/filesystem.d.ts +17 -0
- package/dist/providers/memory/filesystem.js +87 -0
- package/dist/providers/memory/filesystem.js.map +1 -0
- package/dist/providers/store/filesystem.cjs +87 -0
- package/dist/providers/store/filesystem.cjs.map +1 -0
- package/dist/providers/store/filesystem.d.cts +14 -0
- package/dist/providers/store/filesystem.d.ts +14 -0
- package/dist/providers/store/filesystem.js +62 -0
- package/dist/providers/store/filesystem.js.map +1 -0
- package/dist/providers/thread/memory.cjs +81 -0
- package/dist/providers/thread/memory.cjs.map +1 -0
- package/dist/providers/thread/memory.d.cts +14 -0
- package/dist/providers/thread/memory.d.ts +14 -0
- package/dist/providers/thread/memory.js +56 -0
- package/dist/providers/thread/memory.js.map +1 -0
- package/dist/providers/thread/sqlite.cjs +917 -0
- package/dist/providers/thread/sqlite.cjs.map +1 -0
- package/dist/providers/thread/sqlite.d.cts +17 -0
- package/dist/providers/thread/sqlite.d.ts +17 -0
- package/dist/providers/thread/sqlite.js +911 -0
- package/dist/providers/thread/sqlite.js.map +1 -0
- package/dist/providers/tokenizers/auto.cjs +136 -0
- package/dist/providers/tokenizers/auto.cjs.map +1 -0
- package/dist/providers/tokenizers/auto.d.cts +24 -0
- package/dist/providers/tokenizers/auto.d.ts +24 -0
- package/dist/providers/tokenizers/auto.js +107 -0
- package/dist/providers/tokenizers/auto.js.map +1 -0
- package/dist/streaming-B-P6Fw_k.d.cts +372 -0
- package/dist/streaming-BtD23BE0.d.ts +372 -0
- package/dist/thread-C2b9xRMJ.d.cts +30 -0
- package/dist/thread-C2b9xRMJ.d.ts +30 -0
- package/dist/tokenizer-BhG_RGUk.d.cts +13 -0
- package/dist/tokenizer-BhG_RGUk.d.ts +13 -0
- package/package.json +84 -0
- package/src/agent-loop.ts +311 -0
- package/src/agent-source.ts +12 -0
- package/src/artifact.ts +31 -0
- package/src/compaction.ts +75 -0
- package/src/context-budget.ts +65 -0
- package/src/conversation-store.ts +8 -0
- package/src/conversation.ts +42 -0
- package/src/dispatch.ts +207 -0
- package/src/engine.ts +53 -0
- package/src/execution-context.ts +31 -0
- package/src/index.ts +37 -0
- package/src/interrupt-store.ts +25 -0
- package/src/interrupt.ts +55 -0
- package/src/llm-router.ts +34 -0
- package/src/llm.ts +100 -0
- package/src/memory-selector.ts +38 -0
- package/src/memory.ts +34 -0
- package/src/mode.ts +81 -0
- package/src/permissions.ts +104 -0
- package/src/protocol.ts +1 -0
- package/src/providers/llm/anthropic.ts +298 -0
- package/src/providers/llm/ollama.ts +219 -0
- package/src/providers/llm/openai.ts +215 -0
- package/src/providers/memory/filesystem.ts +99 -0
- package/src/providers/store/filesystem.ts +64 -0
- package/src/providers/thread/memory.ts +67 -0
- package/src/providers/thread/sqlite.ts +147 -0
- package/src/providers/tokenizers/auto.ts +26 -0
- package/src/providers/tokenizers/byte.ts +27 -0
- package/src/providers/tokenizers/tiktoken.ts +91 -0
- package/src/reasoning.ts +7 -0
- package/src/rule-matcher.ts +32 -0
- package/src/runtime.ts +416 -0
- package/src/safety.ts +56 -0
- package/src/sandbox.ts +23 -0
- package/src/session-context.ts +33 -0
- package/src/skill-source.ts +21 -0
- package/src/streaming.ts +124 -0
- package/src/system-prompt.ts +71 -0
- package/src/system-reminder.ts +9 -0
- package/src/thread.ts +33 -0
- package/src/tokenizer.ts +31 -0
- package/src/tool.ts +175 -0
- package/src/tracing.ts +63 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import type { LLMProvider, ChatRequest, ChatMessage, ChatResponse } from "./llm.js";
|
|
2
|
+
import type { ToolRegistry } from "./tool.js";
|
|
3
|
+
import type { SandboxDriver } from "./sandbox.js";
|
|
4
|
+
import type { PermissionEngine } from "./permissions.js";
|
|
5
|
+
import type { Engine } from "./engine.js";
|
|
6
|
+
import type { ReasoningStep } from "./reasoning.js";
|
|
7
|
+
import { ToolDispatcher, toolResultsToMessages } from "./dispatch.js";
|
|
8
|
+
import { hasLLM, hasTools, hasSandbox, hasModes, hasPrompt } from "./engine.js";
|
|
9
|
+
import { parseModelRef } from "./llm-router.js";
|
|
10
|
+
|
|
11
|
+
export interface AgentLoopConfig {
|
|
12
|
+
provider?: LLMProvider;
|
|
13
|
+
systemPrompt?: string;
|
|
14
|
+
model?: string;
|
|
15
|
+
tools?: ToolRegistry;
|
|
16
|
+
sandbox?: SandboxDriver;
|
|
17
|
+
sandboxId?: string;
|
|
18
|
+
permissions?: PermissionEngine;
|
|
19
|
+
maxTurns?: number;
|
|
20
|
+
maxRetries?: number;
|
|
21
|
+
onTurn?: (turn: number, resp: ChatResponse) => boolean;
|
|
22
|
+
onToolCall?: (call: { id: string; name: string; arguments: string }) => boolean;
|
|
23
|
+
onToolResult?: (
|
|
24
|
+
call: { id: string; name: string; arguments: string },
|
|
25
|
+
result: { toolCallId: string; name: string; content: string; error?: Error },
|
|
26
|
+
) => { toolCallId: string; name: string; content: string; error?: Error };
|
|
27
|
+
shouldStop?: (turn: number, resp: ChatResponse) => boolean;
|
|
28
|
+
onError?: (err: Error, attempt: number) => boolean;
|
|
29
|
+
buildRequest?: (
|
|
30
|
+
systemPrompt: string,
|
|
31
|
+
messages: ChatMessage[],
|
|
32
|
+
tools?: ToolRegistry,
|
|
33
|
+
) => ChatRequest;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface AgentLoopResult {
|
|
37
|
+
finalContent: string;
|
|
38
|
+
providerReasoning: string;
|
|
39
|
+
totalTurns: number;
|
|
40
|
+
totalUsage: { promptTokens: number; completionTokens: number; totalTokens: number };
|
|
41
|
+
messages: ChatMessage[];
|
|
42
|
+
reasoningTrace: ReasoningStep[];
|
|
43
|
+
stopReason: "complete" | "max_turns" | "aborted" | "stopped" | "error";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function previewText(value: string, limit: number): string {
|
|
47
|
+
const trimmed = value.trim().replace(/\s+/g, " ");
|
|
48
|
+
if (trimmed.length <= limit || limit <= 3) return trimmed;
|
|
49
|
+
return trimmed.slice(0, limit - 3) + "...";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function previewJson(value: string, limit: number): string {
|
|
53
|
+
const trimmed = value.trim();
|
|
54
|
+
if (!trimmed) return "";
|
|
55
|
+
try {
|
|
56
|
+
const parsed = JSON.parse(trimmed);
|
|
57
|
+
return previewText(JSON.stringify(parsed), limit);
|
|
58
|
+
} catch {
|
|
59
|
+
return previewText(trimmed, limit);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function buildDefaultRequest(cfg: AgentLoopConfig, messages: ChatMessage[]): ChatRequest {
|
|
64
|
+
const req: ChatRequest = {
|
|
65
|
+
model: cfg.model,
|
|
66
|
+
messages: [],
|
|
67
|
+
};
|
|
68
|
+
if (cfg.systemPrompt) {
|
|
69
|
+
req.messages.push({ role: "system", content: cfg.systemPrompt });
|
|
70
|
+
}
|
|
71
|
+
req.messages.push(...messages);
|
|
72
|
+
if (cfg.tools) {
|
|
73
|
+
req.tools = cfg.tools.toolDefs();
|
|
74
|
+
}
|
|
75
|
+
return req;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function buildRequest(cfg: AgentLoopConfig, messages: ChatMessage[]): ChatRequest {
|
|
79
|
+
if (cfg.buildRequest) {
|
|
80
|
+
return cfg.buildRequest(cfg.systemPrompt ?? "", messages, cfg.tools);
|
|
81
|
+
}
|
|
82
|
+
return buildDefaultRequest(cfg, messages);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
type ErrorClass = "transient" | "permanent" | "unknown";
|
|
86
|
+
|
|
87
|
+
function classifyError(err: Error): ErrorClass {
|
|
88
|
+
const msg = err.message.toLowerCase();
|
|
89
|
+
const permanent = ["unauthorized", "401", "403", "forbidden", "invalid api key",
|
|
90
|
+
"authentication", "invalid request", "400", "not found", "404",
|
|
91
|
+
"context length", "context window", "content_filter", "content filter"];
|
|
92
|
+
if (permanent.some((p) => msg.includes(p))) return "permanent";
|
|
93
|
+
|
|
94
|
+
const transient = ["rate limit", "429", "too many requests", "timeout", "deadline",
|
|
95
|
+
"connection reset", "connection refused", "eof", "broken pipe",
|
|
96
|
+
"500", "502", "503", "504", "server error", "service unavailable", "temporary"];
|
|
97
|
+
if (transient.some((t) => msg.includes(t))) return "transient";
|
|
98
|
+
|
|
99
|
+
return "unknown";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function callWithRetry(
|
|
103
|
+
provider: LLMProvider,
|
|
104
|
+
req: ChatRequest,
|
|
105
|
+
maxRetries: number,
|
|
106
|
+
onError?: (err: Error, attempt: number) => boolean,
|
|
107
|
+
signal?: AbortSignal,
|
|
108
|
+
): Promise<ChatResponse> {
|
|
109
|
+
let lastErr: Error = new Error("unknown");
|
|
110
|
+
|
|
111
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
112
|
+
try {
|
|
113
|
+
return await provider.chat(req, signal);
|
|
114
|
+
} catch (e) {
|
|
115
|
+
lastErr = e instanceof Error ? e : new Error(String(e));
|
|
116
|
+
|
|
117
|
+
switch (classifyError(lastErr)) {
|
|
118
|
+
case "permanent":
|
|
119
|
+
throw lastErr;
|
|
120
|
+
case "transient":
|
|
121
|
+
if (attempt < maxRetries) {
|
|
122
|
+
const delay = Math.min(1000 * 2 ** (attempt - 1), 30000);
|
|
123
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
break;
|
|
127
|
+
default:
|
|
128
|
+
if (onError == null || !onError(lastErr, attempt)) throw lastErr;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
throw new Error(`max retries (${maxRetries}) exceeded: ${lastErr.message}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function runAgentLoop(
|
|
136
|
+
cfg: AgentLoopConfig,
|
|
137
|
+
messages: ChatMessage[],
|
|
138
|
+
signal?: AbortSignal,
|
|
139
|
+
): Promise<AgentLoopResult> {
|
|
140
|
+
if (!cfg.provider) throw new Error("AgentLoopConfig.provider is required");
|
|
141
|
+
|
|
142
|
+
const maxTurns = cfg.maxTurns && cfg.maxTurns > 0 ? cfg.maxTurns : 50;
|
|
143
|
+
const maxRetries = cfg.maxRetries && cfg.maxRetries > 0 ? cfg.maxRetries : 3;
|
|
144
|
+
|
|
145
|
+
let req = buildRequest(cfg, messages);
|
|
146
|
+
|
|
147
|
+
const dispatcher = cfg.tools
|
|
148
|
+
? new ToolDispatcher(cfg.tools, cfg.sandbox)
|
|
149
|
+
: undefined;
|
|
150
|
+
if (dispatcher && cfg.permissions) {
|
|
151
|
+
dispatcher.withPermissions(cfg.permissions);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const result: AgentLoopResult = {
|
|
155
|
+
finalContent: "",
|
|
156
|
+
providerReasoning: "",
|
|
157
|
+
totalTurns: 0,
|
|
158
|
+
totalUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
|
|
159
|
+
messages: req.messages.slice(),
|
|
160
|
+
reasoningTrace: [],
|
|
161
|
+
stopReason: "complete",
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
let traceSeq = 0;
|
|
165
|
+
const appendTrace = (
|
|
166
|
+
type: ReasoningStep["type"],
|
|
167
|
+
title: string,
|
|
168
|
+
content?: string,
|
|
169
|
+
details?: string[],
|
|
170
|
+
) => {
|
|
171
|
+
result.reasoningTrace.push({
|
|
172
|
+
id: `trace-${++traceSeq}`,
|
|
173
|
+
type,
|
|
174
|
+
title,
|
|
175
|
+
content,
|
|
176
|
+
details,
|
|
177
|
+
});
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
for (let turn = 1; turn <= maxTurns; turn++) {
|
|
181
|
+
if (signal?.aborted) {
|
|
182
|
+
result.stopReason = "aborted";
|
|
183
|
+
return result;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const resp = await callWithRetry(cfg.provider, req, maxRetries, cfg.onError, signal);
|
|
187
|
+
|
|
188
|
+
result.totalTurns = turn;
|
|
189
|
+
result.totalUsage.promptTokens += resp.usage.promptTokens;
|
|
190
|
+
result.totalUsage.completionTokens += resp.usage.completionTokens;
|
|
191
|
+
result.totalUsage.totalTokens += resp.usage.totalTokens;
|
|
192
|
+
|
|
193
|
+
if (resp.reasoning?.trim()) {
|
|
194
|
+
result.providerReasoning = result.providerReasoning
|
|
195
|
+
? result.providerReasoning + "\n\n" + resp.reasoning
|
|
196
|
+
: resp.reasoning;
|
|
197
|
+
appendTrace("thinking", `Provider reasoning ${turn}`, previewText(resp.reasoning, 220));
|
|
198
|
+
}
|
|
199
|
+
appendTrace("thinking", `Turn ${turn}`, undefined, [
|
|
200
|
+
`Finish reason: ${resp.finishReason}`,
|
|
201
|
+
`Tool calls requested: ${resp.toolCalls?.length ?? 0}`,
|
|
202
|
+
`Tokens this turn: ${resp.usage.totalTokens}`,
|
|
203
|
+
]);
|
|
204
|
+
|
|
205
|
+
if (cfg.onTurn && !cfg.onTurn(turn, resp)) {
|
|
206
|
+
result.finalContent = resp.content;
|
|
207
|
+
result.stopReason = "aborted";
|
|
208
|
+
return result;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (cfg.shouldStop?.(turn, resp)) {
|
|
212
|
+
result.finalContent = resp.content;
|
|
213
|
+
result.stopReason = "stopped";
|
|
214
|
+
result.messages.push({ role: "assistant", content: resp.content });
|
|
215
|
+
return result;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (!resp.toolCalls?.length) {
|
|
219
|
+
result.finalContent = resp.content;
|
|
220
|
+
result.stopReason = "complete";
|
|
221
|
+
result.messages.push({ role: "assistant", content: resp.content });
|
|
222
|
+
return result;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (!dispatcher) {
|
|
226
|
+
result.stopReason = "error";
|
|
227
|
+
throw new Error("LLM requested tool calls but no ToolRegistry is configured");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const callsToRun = resp.toolCalls.filter(
|
|
231
|
+
(tc) => cfg.onToolCall == null || cfg.onToolCall(tc),
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
for (const tc of callsToRun) {
|
|
235
|
+
appendTrace("action", `Tool call: ${tc.name}`, previewJson(tc.arguments, 220));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
let toolResults = await dispatcher.dispatchAll(callsToRun, cfg.sandboxId ?? "", signal);
|
|
239
|
+
|
|
240
|
+
if (cfg.onToolResult) {
|
|
241
|
+
toolResults = toolResults.map((tr, i) =>
|
|
242
|
+
cfg.onToolResult!(callsToRun[i]!, tr),
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
for (const tr of toolResults) {
|
|
247
|
+
appendTrace(tr.error ? "thinking" : "result", `Tool result: ${tr.name}`, previewText(tr.content, 220));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const toolMsgs = toolResultsToMessages(resp.toolCalls, toolResults);
|
|
251
|
+
result.messages.push(...toolMsgs);
|
|
252
|
+
req = buildRequest(cfg, result.messages);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
result.stopReason = "max_turns";
|
|
256
|
+
return result;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export async function runAgentLoopWithEngine(
|
|
260
|
+
engine: Engine,
|
|
261
|
+
modeId: string,
|
|
262
|
+
cfg: AgentLoopConfig,
|
|
263
|
+
messages: ChatMessage[],
|
|
264
|
+
signal?: AbortSignal,
|
|
265
|
+
): Promise<AgentLoopResult> {
|
|
266
|
+
const callerSuppliedPrompt = Boolean(cfg.systemPrompt);
|
|
267
|
+
let resolvedModel = cfg.model ?? "";
|
|
268
|
+
|
|
269
|
+
if (hasModes(engine) && modeId) {
|
|
270
|
+
const mode = await engine.modes!.get(modeId, signal);
|
|
271
|
+
if (!cfg.model && mode.modelSettings?.model) {
|
|
272
|
+
cfg.model = mode.modelSettings.model;
|
|
273
|
+
}
|
|
274
|
+
if (!callerSuppliedPrompt) {
|
|
275
|
+
if (hasPrompt(engine)) {
|
|
276
|
+
engine.prompt!.set("mode", mode.promptContent ?? "");
|
|
277
|
+
cfg.systemPrompt = engine.prompt!.build();
|
|
278
|
+
} else {
|
|
279
|
+
cfg.systemPrompt = mode.promptContent ?? "";
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
resolvedModel = cfg.model ?? "";
|
|
283
|
+
} else if (!callerSuppliedPrompt && hasPrompt(engine)) {
|
|
284
|
+
cfg.systemPrompt = engine.prompt!.build();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (!resolvedModel) resolvedModel = cfg.model ?? "";
|
|
288
|
+
|
|
289
|
+
if (!cfg.provider) {
|
|
290
|
+
if (!hasLLM(engine)) throw new Error("no LLM provider configured");
|
|
291
|
+
cfg.provider = resolveProvider(engine, resolvedModel);
|
|
292
|
+
const [, modelOnly] = parseModelRef(cfg.model ?? "");
|
|
293
|
+
if (modelOnly) cfg.model = modelOnly;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (!cfg.tools && hasTools(engine)) cfg.tools = engine.tools;
|
|
297
|
+
if (!cfg.sandbox && hasSandbox(engine)) cfg.sandbox = engine.sandbox;
|
|
298
|
+
|
|
299
|
+
return runAgentLoop(cfg, messages, signal);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function resolveProvider(engine: Engine, model: string): LLMProvider {
|
|
303
|
+
if (!hasLLM(engine)) throw new Error("no LLM provider configured");
|
|
304
|
+
if (model) {
|
|
305
|
+
const llm = engine.llm!;
|
|
306
|
+
if ("route" in llm && typeof (llm as { route?: unknown }).route === "function") {
|
|
307
|
+
return (llm as { route: (m: string) => LLMProvider }).route(model);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return engine.llm!;
|
|
311
|
+
}
|
package/src/artifact.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export type ArtifactType = "file" | "component" | "data";
|
|
2
|
+
|
|
3
|
+
export interface Artifact {
|
|
4
|
+
id: string;
|
|
5
|
+
type: ArtifactType;
|
|
6
|
+
title?: string;
|
|
7
|
+
content: string;
|
|
8
|
+
mediaType?: string;
|
|
9
|
+
props?: Record<string, unknown>;
|
|
10
|
+
createdAt?: Date;
|
|
11
|
+
updatedAt?: Date;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type ArtifactEmitter = (artifact: Artifact) => void;
|
|
15
|
+
|
|
16
|
+
const ARTIFACT_EMITTER_KEY = Symbol("artifactEmitter");
|
|
17
|
+
|
|
18
|
+
export interface ArtifactContext {
|
|
19
|
+
[ARTIFACT_EMITTER_KEY]?: ArtifactEmitter;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function withArtifactEmitter<T extends ArtifactContext>(
|
|
23
|
+
ctx: T,
|
|
24
|
+
emitter: ArtifactEmitter,
|
|
25
|
+
): T & ArtifactContext {
|
|
26
|
+
return { ...ctx, [ARTIFACT_EMITTER_KEY]: emitter };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function emitArtifact(ctx: ArtifactContext, artifact: Artifact): void {
|
|
30
|
+
ctx[ARTIFACT_EMITTER_KEY]?.(artifact);
|
|
31
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { ChatMessage, LLMProvider } from "./llm.js";
|
|
2
|
+
import type { EnforcementResult } from "./context-budget.js";
|
|
3
|
+
import type { Conversation } from "./conversation.js";
|
|
4
|
+
import type { ContextBudget } from "./context-budget.js";
|
|
5
|
+
import { enforceContextBudget } from "./context-budget.js";
|
|
6
|
+
import type { Tokenizer } from "./tokenizer.js";
|
|
7
|
+
import { HeuristicTokenizer } from "./tokenizer.js";
|
|
8
|
+
|
|
9
|
+
export interface CompactionResult {
|
|
10
|
+
enforcementResult: EnforcementResult;
|
|
11
|
+
summary: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface Compactor {
|
|
15
|
+
summarize(messages: ChatMessage[], signal?: AbortSignal): Promise<string>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class LLMCompactor implements Compactor {
|
|
19
|
+
constructor(private llm: LLMProvider) {}
|
|
20
|
+
|
|
21
|
+
async summarize(messages: ChatMessage[], signal?: AbortSignal): Promise<string> {
|
|
22
|
+
const history = messages
|
|
23
|
+
.map((m) => `${m.role.toUpperCase()}: ${m.content}`)
|
|
24
|
+
.join("\n\n");
|
|
25
|
+
|
|
26
|
+
const resp = await this.llm.chat(
|
|
27
|
+
{
|
|
28
|
+
messages: [
|
|
29
|
+
{
|
|
30
|
+
role: "user",
|
|
31
|
+
content: `Summarize the following conversation history concisely, preserving key decisions, facts, and context:\n\n${history}`,
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
maxTokens: 1024,
|
|
35
|
+
},
|
|
36
|
+
signal,
|
|
37
|
+
);
|
|
38
|
+
return resp.content;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class BulletCompactor implements Compactor {
|
|
43
|
+
async summarize(messages: ChatMessage[]): Promise<string> {
|
|
44
|
+
const points: string[] = [];
|
|
45
|
+
for (const msg of messages) {
|
|
46
|
+
if (msg.role === "assistant" && msg.content.trim()) {
|
|
47
|
+
const first = msg.content.split("\n")[0]?.trim() ?? "";
|
|
48
|
+
if (first) points.push(`- ${first.slice(0, 120)}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return points.join("\n");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function enforceWithCompaction(
|
|
56
|
+
budget: ContextBudget,
|
|
57
|
+
compactor: Compactor | undefined,
|
|
58
|
+
conv: Conversation,
|
|
59
|
+
memoryTokens: number,
|
|
60
|
+
tok: Tokenizer = new HeuristicTokenizer(),
|
|
61
|
+
signal?: AbortSignal,
|
|
62
|
+
): Promise<CompactionResult> {
|
|
63
|
+
const enforcementResult = enforceContextBudget(budget, conv, memoryTokens, tok);
|
|
64
|
+
|
|
65
|
+
let summary = "";
|
|
66
|
+
if (compactor && enforcementResult.truncatedHistory) {
|
|
67
|
+
try {
|
|
68
|
+
summary = await compactor.summarize(conv.messages.slice(0, enforcementResult.historyDropped), signal);
|
|
69
|
+
} catch {
|
|
70
|
+
// compaction failure is non-fatal
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { enforcementResult, summary };
|
|
75
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { ChatMessage } from "./llm.js";
|
|
2
|
+
import type { Tokenizer } from "./tokenizer.js";
|
|
3
|
+
import type { Conversation } from "./conversation.js";
|
|
4
|
+
|
|
5
|
+
export interface ContextBudget {
|
|
6
|
+
windowSize: number;
|
|
7
|
+
skillsReserve: number;
|
|
8
|
+
memoryReserve: number;
|
|
9
|
+
historyReserve: number;
|
|
10
|
+
reserveBuffer: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface EnforcementResult {
|
|
14
|
+
overflowTokens: number;
|
|
15
|
+
truncatedHistory: boolean;
|
|
16
|
+
historyDropped: number;
|
|
17
|
+
stillOverflow: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function defaultContextBudget(windowSize: number): ContextBudget {
|
|
21
|
+
return {
|
|
22
|
+
windowSize,
|
|
23
|
+
skillsReserve: Math.floor(windowSize * 0.1),
|
|
24
|
+
memoryReserve: Math.floor(windowSize * 0.15),
|
|
25
|
+
historyReserve: Math.floor(windowSize * 0.6),
|
|
26
|
+
reserveBuffer: Math.floor(windowSize * 0.05),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function enforceContextBudget(
|
|
31
|
+
budget: ContextBudget,
|
|
32
|
+
conv: Conversation,
|
|
33
|
+
memoryTokens: number,
|
|
34
|
+
tok: Tokenizer,
|
|
35
|
+
): EnforcementResult {
|
|
36
|
+
const result: EnforcementResult = {
|
|
37
|
+
overflowTokens: 0,
|
|
38
|
+
truncatedHistory: false,
|
|
39
|
+
historyDropped: 0,
|
|
40
|
+
stillOverflow: false,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const available = budget.historyReserve;
|
|
44
|
+
let used = 0;
|
|
45
|
+
const kept: ChatMessage[] = [];
|
|
46
|
+
|
|
47
|
+
for (let i = conv.messages.length - 1; i >= 0; i--) {
|
|
48
|
+
const msg = conv.messages[i]!;
|
|
49
|
+
const tokens = tok.count(msg.content);
|
|
50
|
+
if (used + tokens > available && kept.length > 0) {
|
|
51
|
+
result.truncatedHistory = true;
|
|
52
|
+
result.historyDropped = i + 1;
|
|
53
|
+
result.overflowTokens = used + tokens - available;
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
used += tokens;
|
|
57
|
+
kept.unshift(msg);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (result.truncatedHistory) {
|
|
61
|
+
conv.messages = kept;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Conversation } from "./conversation.js";
|
|
2
|
+
|
|
3
|
+
export interface ConversationStore {
|
|
4
|
+
save(conv: Conversation, signal?: AbortSignal): Promise<void>;
|
|
5
|
+
load(id: string, signal?: AbortSignal): Promise<Conversation | undefined>;
|
|
6
|
+
list(threadId?: string, signal?: AbortSignal): Promise<Conversation[]>;
|
|
7
|
+
delete(id: string, signal?: AbortSignal): Promise<void>;
|
|
8
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { ChatMessage } from "./llm.js";
|
|
2
|
+
|
|
3
|
+
export interface Conversation {
|
|
4
|
+
id: string;
|
|
5
|
+
threadId?: string;
|
|
6
|
+
messages: ChatMessage[];
|
|
7
|
+
memoryRead: boolean;
|
|
8
|
+
turnCount: number;
|
|
9
|
+
createdAt: Date;
|
|
10
|
+
lastTurnAt?: Date;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function newConversation(id: string): Conversation {
|
|
14
|
+
return {
|
|
15
|
+
id,
|
|
16
|
+
messages: [],
|
|
17
|
+
memoryRead: false,
|
|
18
|
+
turnCount: 0,
|
|
19
|
+
createdAt: new Date(),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isCold(conv: Conversation): boolean {
|
|
24
|
+
return conv.turnCount === 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function appendUser(conv: Conversation, content: string): void {
|
|
28
|
+
conv.messages.push({ role: "user", content });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function appendAssistant(conv: Conversation, content: string): void {
|
|
32
|
+
conv.messages.push({ role: "assistant", content });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function incrementTurn(conv: Conversation): void {
|
|
36
|
+
conv.turnCount++;
|
|
37
|
+
conv.lastTurnAt = new Date();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function messageCount(conv: Conversation): number {
|
|
41
|
+
return conv.messages.length;
|
|
42
|
+
}
|