@andreprado/agentkit 0.1.0-alpha.6 → 0.1.0-alpha.8

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.
@@ -1,5 +1,6 @@
1
- import { readFile } from "node:fs/promises";
2
- import { relative, resolve } from "node:path";
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import type { Dirent } from "node:fs";
3
+ import { basename, relative, resolve } from "node:path";
3
4
 
4
5
  import type { DatabaseArgs, DatabaseResult, DatabaseRunner, DatabaseStatement } from "../index";
5
6
  import { openCapsuleStore, type SqliteAgentKitStore } from "../storage/sqlite";
@@ -9,6 +10,12 @@ import { AgentKitError } from "./errors";
9
10
  export type LocalDatabasePrepareResult = {
10
11
  store: SqliteAgentKitStore;
11
12
  databasePath: string;
13
+ migrations: {
14
+ configured: boolean;
15
+ path: string | null;
16
+ applied: string[];
17
+ skipped: string[];
18
+ };
12
19
  schema: {
13
20
  configured: boolean;
14
21
  applied: boolean;
@@ -20,10 +27,12 @@ export async function openPreparedCapsuleStore(capsule: LoadedAgentCapsule): Pro
20
27
  const store = await openCapsuleStore(capsule);
21
28
 
22
29
  try {
30
+ const migrations = await applyCapsuleDatabaseMigrations(capsule, store);
23
31
  const schema = await applyCapsuleDatabaseSchema(capsule, store);
24
32
  return {
25
33
  store,
26
34
  databasePath: store.dbPath,
35
+ migrations,
27
36
  schema,
28
37
  };
29
38
  } catch (error) {
@@ -32,6 +41,84 @@ export async function openPreparedCapsuleStore(capsule: LoadedAgentCapsule): Pro
32
41
  }
33
42
  }
34
43
 
44
+ export async function applyCapsuleDatabaseMigrations(
45
+ capsule: LoadedAgentCapsule,
46
+ store: SqliteAgentKitStore,
47
+ ): Promise<LocalDatabasePrepareResult["migrations"]> {
48
+ const database = capsule.config.storage.database;
49
+
50
+ if (!database || database.driver !== "turso") {
51
+ return {
52
+ configured: false,
53
+ path: null,
54
+ applied: [],
55
+ skipped: [],
56
+ };
57
+ }
58
+
59
+ const migrationsPath = resolve(capsule.root, database.migrations ?? "migrations");
60
+ const migrations = await readMigrationFiles(migrationsPath);
61
+
62
+ if (migrations.length === 0) {
63
+ return {
64
+ configured: false,
65
+ path: migrationsPath,
66
+ applied: [],
67
+ skipped: [],
68
+ };
69
+ }
70
+
71
+ try {
72
+ const result = store.applyApplicationMigrations(migrations);
73
+ return {
74
+ configured: true,
75
+ path: migrationsPath,
76
+ applied: result.applied,
77
+ skipped: result.skipped,
78
+ };
79
+ } catch (error) {
80
+ if (error instanceof AgentKitError && error.code === "database_schema_error") {
81
+ throw new AgentKitError(
82
+ "database_schema_error",
83
+ [
84
+ `Could not apply database migrations from ${relative(capsule.root, migrationsPath)}.`,
85
+ "Migrations must be ordered .sql files and should use safe, forward-only changes.",
86
+ error.message,
87
+ ].join(" "),
88
+ { cause: error },
89
+ );
90
+ }
91
+
92
+ throw error;
93
+ }
94
+ }
95
+
96
+ async function readMigrationFiles(directory: string): Promise<Array<{ id: string; sql: string }>> {
97
+ let entries: Dirent[];
98
+
99
+ try {
100
+ entries = await readdir(directory, { withFileTypes: true });
101
+ } catch (error) {
102
+ if (isNodeError(error) && error.code === "ENOENT") {
103
+ return [];
104
+ }
105
+
106
+ throw error;
107
+ }
108
+
109
+ const files = entries
110
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".sql"))
111
+ .map((entry) => entry.name)
112
+ .sort();
113
+
114
+ return Promise.all(
115
+ files.map(async (file) => ({
116
+ id: basename(file, ".sql"),
117
+ sql: await readFile(resolve(directory, file), "utf8"),
118
+ })),
119
+ );
120
+ }
121
+
35
122
  export async function applyCapsuleDatabaseSchema(
36
123
  capsule: LoadedAgentCapsule,
37
124
  store: SqliteAgentKitStore,
@@ -163,3 +250,7 @@ function validateStatements(statements: DatabaseStatement[]): void {
163
250
  validateSql(statement.sql, statement.args ?? []);
164
251
  }
165
252
  }
253
+
254
+ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
255
+ return typeof error === "object" && error !== null && "code" in error;
256
+ }
@@ -10,6 +10,9 @@ import { AgentKitError } from "./errors";
10
10
  export type DbMigrateResult = {
11
11
  root: string;
12
12
  databasePath: string;
13
+ migrationsPath: string | null;
14
+ migrationsApplied: string[];
15
+ migrationsSkipped: string[];
13
16
  schemaPath: string | null;
14
17
  schemaApplied: boolean;
15
18
  };
@@ -43,6 +46,9 @@ export async function migrateLocalDatabase(cwd = process.cwd()): Promise<DbMigra
43
46
  return {
44
47
  root: capsule.root,
45
48
  databasePath: prepared.databasePath,
49
+ migrationsPath: prepared.migrations.path,
50
+ migrationsApplied: prepared.migrations.applied,
51
+ migrationsSkipped: prepared.migrations.skipped,
46
52
  schemaPath: prepared.schema.path,
47
53
  schemaApplied: prepared.schema.applied,
48
54
  };
@@ -87,6 +93,9 @@ export async function seedLocalDatabase(cwd = process.cwd(), seedFile = "seed.sq
87
93
  return {
88
94
  root: capsule.root,
89
95
  databasePath: prepared.databasePath,
96
+ migrationsPath: prepared.migrations.path,
97
+ migrationsApplied: prepared.migrations.applied,
98
+ migrationsSkipped: prepared.migrations.skipped,
90
99
  schemaPath: prepared.schema.path,
91
100
  schemaApplied: prepared.schema.applied,
92
101
  seedPath,
@@ -147,18 +147,20 @@ function createDevHttpServer(capsule: LoadedAgentCapsule): Server {
147
147
  dedupeKeys: new Set<string>(),
148
148
  buffers: new Map<string, DevChannelBuffer>(),
149
149
  };
150
+ const capsuleRoot = capsule.root;
150
151
  return createHttpServer((incoming, outgoing) => {
151
- void handleNodeRequest(capsule, incoming, outgoing, channelRuntime);
152
+ void handleNodeRequest(capsuleRoot, incoming, outgoing, channelRuntime);
152
153
  });
153
154
  }
154
155
 
155
156
  async function handleNodeRequest(
156
- capsule: LoadedAgentCapsule,
157
+ capsuleRoot: string,
157
158
  incoming: IncomingMessage,
158
159
  outgoing: ServerResponse,
159
160
  channelRuntime: DevChannelRuntimeState,
160
161
  ): Promise<void> {
161
162
  try {
163
+ const capsule = await loadAgentCapsule(capsuleRoot);
162
164
  const request = nodeRequestToFetchRequest(incoming);
163
165
  const response = await handleDevServerRequest(capsule, request, channelRuntime);
164
166
  await writeFetchResponse(outgoing, response);
@@ -296,6 +298,12 @@ async function handleDevServerRequest(
296
298
  return jsonResponse(await getConversation(capsule, decodeURIComponent(conversationMatch[1])));
297
299
  }
298
300
 
301
+ const conversationTraceMatch = route.match(/^\/v1\/conversations\/([^/]+)\/trace$/);
302
+
303
+ if (request.method === "GET" && conversationTraceMatch) {
304
+ return jsonResponse(await getConversationTrace(capsule, decodeURIComponent(conversationTraceMatch[1])));
305
+ }
306
+
299
307
  return jsonError(new AgentKitError("not_found", `Route ${request.method} ${route} was not found.`), 404);
300
308
  } catch (error) {
301
309
  return jsonError(error, statusForError(error));
@@ -570,6 +578,32 @@ async function getConversation(capsule: LoadedAgentCapsule, conversationId: stri
570
578
  }
571
579
  }
572
580
 
581
+ async function getConversationTrace(capsule: LoadedAgentCapsule, conversationId: string) {
582
+ if (!conversationId) {
583
+ throw new AgentKitError("validation_error", "Missing conversation id.");
584
+ }
585
+
586
+ const store = await openCapsuleStore(capsule);
587
+
588
+ try {
589
+ const conversation = store.getConversation(conversationId);
590
+
591
+ if (!conversation) {
592
+ throw new AgentKitError("conversation_not_found", `Conversation "${conversationId}" was not found.`);
593
+ }
594
+
595
+ return {
596
+ ...conversation,
597
+ runs: store.listRunsForConversation(conversation.id).map((run) => ({
598
+ ...run,
599
+ toolCalls: store.listToolCallsForRun(run.id),
600
+ })),
601
+ };
602
+ } finally {
603
+ store.close();
604
+ }
605
+ }
606
+
573
607
  async function readJsonBody(request: Request): Promise<unknown> {
574
608
  const contentType = request.headers.get("content-type") ?? "";
575
609
 
@@ -1066,6 +1100,13 @@ function renderChatApp(capsule: LoadedAgentCapsule, baseUrl: string, env: Record
1066
1100
  messages.scrollTop = messages.scrollHeight;
1067
1101
  }
1068
1102
 
1103
+ textarea.addEventListener("keydown", (event) => {
1104
+ if (event.key !== "Enter" || event.shiftKey || event.isComposing) return;
1105
+ event.preventDefault();
1106
+ if (button.disabled) return;
1107
+ form.requestSubmit();
1108
+ });
1109
+
1069
1110
  form.addEventListener("submit", async (event) => {
1070
1111
  event.preventDefault();
1071
1112
  const content = textarea.value.trim();
@@ -1,6 +1,6 @@
1
- import { readdir } from "node:fs/promises";
1
+ import { mkdir, readdir, stat, writeFile } from "node:fs/promises";
2
2
  import type { Dirent } from "node:fs";
3
- import { join, relative } from "node:path";
3
+ import { dirname, join, relative, resolve } from "node:path";
4
4
  import { pathToFileURL } from "node:url";
5
5
 
6
6
  import type { AgentRunResult } from "./chat";
@@ -8,6 +8,7 @@ import { runAgentMessageFromCwd } from "./chat";
8
8
  import { findAgentCapsuleRoot, loadAgentCapsule } from "./config";
9
9
  import { AgentKitError } from "./errors";
10
10
  import { openCapsuleStore, type StoredToolCall } from "../storage/sqlite";
11
+ import { getConversationTraceFromCwd } from "./traces";
11
12
 
12
13
  export type EvalRunSummary = {
13
14
  root: string;
@@ -27,9 +28,17 @@ export type EvalResult = {
27
28
  type EvalCase = {
28
29
  name?: string;
29
30
  input?: string;
31
+ turns?: EvalTurn[];
30
32
  expect?: EvalExpect;
31
33
  };
32
34
 
35
+ type EvalTurn =
36
+ | string
37
+ | {
38
+ input: string;
39
+ expect?: EvalExpect;
40
+ };
41
+
33
42
  type EvalExpect = {
34
43
  contains?: string | string[];
35
44
  not_contains?: string | string[];
@@ -55,16 +64,25 @@ type ToolExpectation =
55
64
  output?: unknown;
56
65
  status?: "running" | "completed" | "failed";
57
66
  visibility?: "user" | "internal";
67
+ rendered?: unknown;
58
68
  };
59
69
 
60
70
  type ToolCallSnapshot = {
61
71
  name?: unknown;
62
72
  input?: unknown;
63
73
  output?: unknown;
74
+ rendered?: unknown;
64
75
  status?: unknown;
65
76
  visibility?: unknown;
66
77
  };
67
78
 
79
+ export type EvalFromConversationResult = {
80
+ root: string;
81
+ file: string;
82
+ conversationId: string;
83
+ turns: number;
84
+ };
85
+
68
86
  export async function runEvalsFromCwd(cwd: string): Promise<EvalRunSummary> {
69
87
  const root = await findAgentCapsuleRoot(cwd);
70
88
  const evalFiles = await findEvalFiles(join(root, "evals"));
@@ -77,28 +95,14 @@ export async function runEvalsFromCwd(cwd: string): Promise<EvalRunSummary> {
77
95
 
78
96
  for (const file of evalFiles) {
79
97
  const evalCase = await loadEvalCase(file);
80
- const input = evalCase.input;
81
-
82
- if (typeof input !== "string" || input.trim().length === 0) {
83
- throw new AgentKitError("validation_error", `${relative(root, file)} must export an eval with a non-empty input string.`);
84
- }
85
-
86
- const run = await runAgentMessageFromCwd(root, {
87
- message: input,
88
- runtime: {
89
- environment: "eval",
90
- invocation: "eval",
91
- },
92
- });
93
- const persistedToolCalls = await loadPersistedToolCalls(root, run.runId);
94
- const failures = evaluateExpectations(evalCase.expect ?? {}, run, persistedToolCalls);
98
+ const run = await runEvalCase(root, relative(root, file), evalCase);
95
99
 
96
100
  results.push({
97
101
  name: evalCase.name ?? relative(root, file),
98
102
  file: relative(root, file),
99
- passed: failures.length === 0,
100
- failures,
101
- output: run.message.content,
103
+ passed: run.failures.length === 0,
104
+ failures: run.failures,
105
+ output: run.output,
102
106
  });
103
107
  }
104
108
 
@@ -112,6 +116,133 @@ export async function runEvalsFromCwd(cwd: string): Promise<EvalRunSummary> {
112
116
  };
113
117
  }
114
118
 
119
+ export async function writeEvalFromConversation(
120
+ cwd: string,
121
+ input: {
122
+ conversationId: string;
123
+ out?: string;
124
+ name?: string;
125
+ force?: boolean;
126
+ },
127
+ ): Promise<EvalFromConversationResult> {
128
+ const root = await findAgentCapsuleRoot(cwd);
129
+ const trace = await getConversationTraceFromCwd(root, input.conversationId);
130
+ const turns = replayTurnsFromMessages(trace.messages);
131
+
132
+ if (turns.length === 0) {
133
+ throw new AgentKitError(
134
+ "validation_error",
135
+ `Conversation "${input.conversationId}" does not contain user/assistant turns that can become an eval.`,
136
+ );
137
+ }
138
+
139
+ const file = resolve(root, input.out ?? join("evals", `replay-${slugify(trace.title ?? trace.id)}.eval.ts`));
140
+
141
+ if (!input.force && await pathExists(file)) {
142
+ throw new AgentKitError(
143
+ "validation_error",
144
+ `${relative(root, file)} already exists. Re-run with --force or choose --out <path>.`,
145
+ );
146
+ }
147
+
148
+ await mkdir(dirname(file), { recursive: true });
149
+ await writeFile(
150
+ file,
151
+ `export default {
152
+ name: ${JSON.stringify(input.name ?? `replay ${trace.title ?? trace.id}`)},
153
+ turns: ${formatEvalTurns(turns)},
154
+ };
155
+ `,
156
+ );
157
+
158
+ return {
159
+ root,
160
+ file,
161
+ conversationId: trace.id,
162
+ turns: turns.length,
163
+ };
164
+ }
165
+
166
+ async function runEvalCase(
167
+ root: string,
168
+ file: string,
169
+ evalCase: EvalCase,
170
+ ): Promise<{ failures: string[]; output: string }> {
171
+ const turns = normalizeEvalTurns(evalCase);
172
+
173
+ if (turns.length === 0) {
174
+ throw new AgentKitError(
175
+ "validation_error",
176
+ `${file} must export either a non-empty input string or a non-empty turns array.`,
177
+ );
178
+ }
179
+
180
+ const conversationId = `eval_${crypto.randomUUID()}`;
181
+ const failures: string[] = [];
182
+ let output = "";
183
+
184
+ for (const [index, turn] of turns.entries()) {
185
+ const run = await runAgentMessageFromCwd(root, {
186
+ message: turn.input,
187
+ conversationId,
188
+ runtime: {
189
+ environment: "eval",
190
+ invocation: "eval",
191
+ },
192
+ });
193
+ const persistedToolCalls = await loadPersistedToolCalls(root, run.runId);
194
+ const turnFailures = evaluateExpectations(turn.expect ?? {}, run, persistedToolCalls);
195
+
196
+ for (const failure of turnFailures) {
197
+ failures.push(turns.length === 1 ? failure : `turn ${index + 1}: ${failure}`);
198
+ }
199
+
200
+ output = run.message.content;
201
+ }
202
+
203
+ return { failures, output };
204
+ }
205
+
206
+ function normalizeEvalTurns(evalCase: EvalCase): Array<{ input: string; expect?: EvalExpect }> {
207
+ if (evalCase.turns !== undefined) {
208
+ if (!Array.isArray(evalCase.turns)) {
209
+ throw new AgentKitError("validation_error", "eval turns must be an array when provided.");
210
+ }
211
+
212
+ return evalCase.turns.map((turn, index) => normalizeEvalTurn(turn, index));
213
+ }
214
+
215
+ if (typeof evalCase.input === "string" && evalCase.input.trim().length > 0) {
216
+ return [
217
+ {
218
+ input: evalCase.input,
219
+ expect: evalCase.expect,
220
+ },
221
+ ];
222
+ }
223
+
224
+ return [];
225
+ }
226
+
227
+ function normalizeEvalTurn(turn: EvalTurn, index: number): { input: string; expect?: EvalExpect } {
228
+ if (typeof turn === "string") {
229
+ if (turn.trim().length === 0) {
230
+ throw new AgentKitError("validation_error", `eval turns[${index}] must be non-empty.`);
231
+ }
232
+
233
+ return { input: turn };
234
+ }
235
+
236
+ if (!isRecord(turn) || typeof turn.input !== "string" || turn.input.trim().length === 0) {
237
+ throw new AgentKitError("validation_error", `eval turns[${index}].input must be a non-empty string.`);
238
+ }
239
+
240
+ return {
241
+ input: turn.input,
242
+ ...(turn.expect ? { expect: turn.expect } : {}),
243
+ };
244
+ }
245
+
115
246
  async function findEvalFiles(directory: string): Promise<string[]> {
116
247
  let entries: Dirent[];
117
248
 
@@ -253,6 +384,10 @@ function matchesToolExpectation(toolCall: ToolCallSnapshot, expectation: ToolExp
253
384
  return false;
254
385
  }
255
386
 
387
+ if ("rendered" in expectation && !jsonContains(toolCall.rendered, expectation.rendered)) {
388
+ return false;
389
+ }
390
+
256
391
  return true;
257
392
  }
258
393
 
@@ -269,11 +404,66 @@ function toolCallSnapshotFromStored(toolCall: StoredToolCall): ToolCallSnapshot
269
404
  name: toolCall.toolName,
270
405
  input: toolCall.input,
271
406
  output: toolCall.output,
407
+ rendered: toolCall.rendered,
272
408
  status: toolCall.status,
273
409
  visibility: toolCall.visibility,
274
410
  };
275
411
  }
276
412
 
413
+ function replayTurnsFromMessages(
414
+ messages: Array<{ role: string; content: string }>,
415
+ ): Array<{ input: string; expect?: EvalExpect }> {
416
+ const turns: Array<{ input: string; expect?: EvalExpect }> = [];
417
+
418
+ for (let index = 0; index < messages.length; index += 1) {
419
+ const message = messages[index];
420
+
421
+ if (message.role !== "user") {
422
+ continue;
423
+ }
424
+
425
+ const nextAssistant = messages.slice(index + 1).find((candidate) => candidate.role === "assistant");
426
+ turns.push({
427
+ input: message.content,
428
+ ...(nextAssistant
429
+ ? {
430
+ expect: {
431
+ contains: nextAssistant.content,
432
+ },
433
+ }
434
+ : {}),
435
+ });
436
+ }
437
+
438
+ return turns;
439
+ }
440
+
441
+ function formatEvalTurns(turns: Array<{ input: string; expect?: EvalExpect }>): string {
442
+ return JSON.stringify(turns, null, 4)
443
+ .split("\n")
444
+ .map((line, index) => (index === 0 ? line : ` ${line}`))
445
+ .join("\n");
446
+ }
447
+
448
+ function slugify(value: string): string {
449
+ const slug = value
450
+ .toLowerCase()
451
+ .replace(/[^a-z0-9]+/g, "-")
452
+ .replace(/^-+|-+$/g, "")
453
+ .slice(0, 48);
454
+
455
+ return slug || "conversation";
456
+ }
457
+
458
+ async function pathExists(path: string): Promise<boolean> {
459
+ try {
460
+ await stat(path);
461
+ return true;
462
+ } catch {
463
+ return false;
464
+ }
465
+ }
466
+
277
467
  function jsonContains(actual: unknown, expected: unknown): boolean {
278
468
  if (Array.isArray(expected)) {
279
469
  return jsonEqual(actual, expected);
@@ -59,11 +59,13 @@ export type AgentInspectDatabase = {
59
59
  driver: "sqlite";
60
60
  path: string | null;
61
61
  schema: string | null;
62
+ migrations: string | null;
62
63
  };
63
64
  hosted: {
64
65
  driver: "none" | "turso";
65
66
  provisioning: "none" | "agentkit-managed";
66
67
  schema: string | null;
68
+ migrations: string | null;
67
69
  };
68
70
  };
69
71
 
@@ -171,17 +173,20 @@ function toCapsulePath(root: string, path: string): string {
171
173
  function inspectDatabase(capsule: LoadedAgentCapsule): AgentInspectDatabase {
172
174
  const database = capsule.config.storage.database;
173
175
  const schema = database?.driver === "turso" && database.schema ? database.schema : null;
176
+ const migrations = database?.driver === "turso" ? database.migrations ?? "migrations" : null;
174
177
 
175
178
  return {
176
179
  local: {
177
180
  driver: "sqlite",
178
181
  path: capsule.storagePath ? toCapsulePath(capsule.root, capsule.storagePath) : null,
179
182
  schema,
183
+ migrations,
180
184
  },
181
185
  hosted: {
182
186
  driver: database?.driver === "turso" ? "turso" : "none",
183
187
  provisioning: database?.driver === "turso" ? "agentkit-managed" : "none",
184
188
  schema,
189
+ migrations,
185
190
  },
186
191
  };
187
192
  }