@anteros/core 0.0.44 → 0.0.47

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.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1 -1
  3. package/database/field.crypto.ts +38 -13
  4. package/database/mongodbadapter.ts +85 -25
  5. package/database/vars.ts +22 -2
  6. package/index.ts +22 -0
  7. package/lib/agent.ts +707 -79
  8. package/lib/agentMemory.ts +199 -0
  9. package/lib/agents.ts +133 -52
  10. package/lib/attachment.ts +24 -3
  11. package/lib/define.ts +5 -1
  12. package/lib/eval.ts +123 -0
  13. package/lib/files.ts +21 -15
  14. package/lib/jsonSchema.ts +8 -0
  15. package/lib/mcp.ts +52 -30
  16. package/lib/messages.ts +202 -19
  17. package/lib/partialJson.ts +79 -0
  18. package/lib/providers.ts +26 -2
  19. package/lib/redact.ts +11 -0
  20. package/lib/tracing.ts +20 -0
  21. package/lib/zip.ts +23 -3
  22. package/package.json +4 -5
  23. package/server/agents.ts +66 -1
  24. package/server/api.ts +302 -20
  25. package/server/boot.ts +13 -4
  26. package/server/hono.ts +146 -6
  27. package/tests/access-log-body.test.ts +253 -0
  28. package/tests/agent-checkpoint.test.ts +195 -0
  29. package/tests/agent-hardening.test.ts +238 -0
  30. package/tests/agent-memory.test.ts +175 -1
  31. package/tests/agent-stream-advanced.test.ts +246 -0
  32. package/tests/agent-tracing-eval.test.ts +219 -0
  33. package/tests/agent-working-memory.test.ts +7 -4
  34. package/tests/agent.test.ts +54 -3
  35. package/tests/agents-api.test.ts +21 -0
  36. package/tests/api-external-guards.test.ts +304 -0
  37. package/tests/attachment.test.ts +63 -0
  38. package/tests/audit-redact.test.ts +26 -1
  39. package/tests/field-encryption.test.ts +47 -3
  40. package/tests/filters-hardening.test.ts +81 -0
  41. package/tests/fixtures/ai-tenant/ai/agents/support/billing.agent.ts +9 -0
  42. package/tests/fixtures/ai-tenant/ai/agents/support/billing.tool.ts +9 -0
  43. package/tests/fixtures/ai-tenant/ai/agents/support.agent.ts +9 -0
  44. package/tests/fixtures/ai-tenant/ai/agents/support.tool.ts +9 -0
  45. package/tests/fixtures/ai-tenant/ai/mcp/resources/readme.resource.ts +9 -0
  46. package/tests/fixtures/ai-tenant/ai/mcp/tools/echo.tool.ts +9 -0
  47. package/tests/fixtures/fake-provider.ts +33 -0
  48. package/tests/fixtures/tool-tenant/ai/agents/report.agent.ts +12 -0
  49. package/tests/fixtures/tool-tenant/ai/agents/report.tool.ts +12 -0
  50. package/tests/fixtures/vars-tenant/vars/open.var.ts +18 -0
  51. package/tests/hono.test.ts +81 -1
  52. package/tests/jwt-tenant.test.ts +165 -0
  53. package/tests/logger.test.ts +62 -10
  54. package/tests/messages.test.ts +88 -32
  55. package/tests/tool-runtime.test.ts +291 -0
  56. package/tests/tool-types.ts +33 -0
  57. package/tests/upload.test.ts +175 -3
  58. package/tests/vars-api.test.ts +73 -1
  59. package/tests/vars.test.ts +1 -1
  60. package/types/agent.d.ts +386 -4
  61. package/types/config.d.ts +56 -2
  62. package/types/file.d.ts +1 -1
  63. package/types/mcp.d.ts +2 -2
  64. package/utils/func.ts +20 -0
  65. package/utils/logger.ts +125 -33
@@ -3,6 +3,7 @@ import { parseDuration } from "../utils/func";
3
3
  import { syncTtlRetention } from "../database/ttl";
4
4
  import { writeDeleteMarkers, replicatesCollection } from "../database/deleteLog";
5
5
  import { Redis } from "ioredis";
6
+ import { createHash } from "node:crypto";
6
7
  import type { Db } from "mongodb";
7
8
  import type {
8
9
  AgentMemory,
@@ -11,7 +12,11 @@ import type {
11
12
  AgentMemoryProcessor,
12
13
  AgentMemoryState,
13
14
  AgentMessage,
15
+ AgentRecallHit,
16
+ AgentRecallRecord,
17
+ AgentSemanticRecallOptions,
14
18
  AgentThread,
19
+ CustomAgentMemoryOptions,
15
20
  MongoAgentMemoryOptions,
16
21
  RedisAgentMemoryOptions,
17
22
  RedisClientLike,
@@ -464,8 +469,202 @@ export class MongoAgentMemory implements AgentMemory {
464
469
  }
465
470
  }
466
471
 
472
+ // ─── Custom store ───────────────────────────────────────────────────────
473
+
474
+ /**
475
+ * Build a conversation store from your own handlers — the framework applies the
476
+ * same policy as the built-in stores, so a handler only reads and writes.
477
+ *
478
+ * - **scoping** — the key it receives is `resourceId:threadId` when `scoped`
479
+ * (default) and a caller identity is present, the bare thread id otherwise;
480
+ * - **cap** — `save` receives at most `maxMessages` messages (default 100), the
481
+ * opening `system`/`developer` message kept first;
482
+ * - **optional parts** — `list`, `getState`, `setState`, `clearState` and
483
+ * `collections` exist on the returned store **only if you provide them**.
484
+ *
485
+ * ```ts
486
+ * import { agents } from "@anteros/core";
487
+ *
488
+ * define.Agent({
489
+ * // …
490
+ * memory: agents.memory.custom({
491
+ * name: "sqlite",
492
+ * maxMessages: 50,
493
+ * get: async (key) => db.thread(key),
494
+ * save: async (key, messages) => db.write(key, messages),
495
+ * clear: async (key) => db.drop(key),
496
+ * }),
497
+ * });
498
+ * ```
499
+ *
500
+ * A store used through `workingMemory` also needs `getState` / `setState`; without
501
+ * them the scratchpad is simply inert, as with any other store.
502
+ */
503
+ function customAgentMemory(options: CustomAgentMemoryOptions): AgentMemory {
504
+ for (const handler of ["get", "save", "clear"] as const) {
505
+ if (typeof (options as any)?.[handler] !== "function") {
506
+ throw new AppError(
507
+ `A custom memory${options?.name ? ` '${options.name}'` : ""} needs \`${handler}\` — agents.memory.custom({ get, save, clear, … })`,
508
+ { status: 500, code: "AGENT_MEMORY_HANDLER_REQUIRED" },
509
+ );
510
+ }
511
+ }
512
+
513
+ const scoped = options.scoped ?? true;
514
+ const maxMessages = Math.max(1, options.maxMessages ?? DEFAULT_MAX_MESSAGES);
515
+ const resolved = (threadId: string, ctx?: AgentMemoryContext) => threadKey(threadId, ctx, scoped);
516
+
517
+ const memory: AgentMemory = {
518
+ get: (threadId, ctx) => options.get(resolved(threadId, ctx), ctx ?? {}),
519
+ save: (threadId, messages, ctx) =>
520
+ options.save(resolved(threadId, ctx), trimMessages(messages, maxMessages), ctx ?? {}),
521
+ clear: (threadId, ctx) => options.clear(resolved(threadId, ctx), ctx ?? {}),
522
+ };
523
+
524
+ if (options.list) memory.list = (ctx) => options.list!(ctx ?? {});
525
+ if (options.getState) memory.getState = (key, ctx) => options.getState!(key, ctx ?? {});
526
+ if (options.setState) memory.setState = (key, value, ctx) => options.setState!(key, value, ctx ?? {});
527
+ if (options.clearState) memory.clearState = (key, ctx) => options.clearState!(key, ctx ?? {});
528
+ if (options.collections) memory.collections = () => options.collections!();
529
+
530
+ return memory;
531
+ }
532
+
533
+ // ─── Semantic recall ────────────────────────────────────────────────────
534
+
535
+ /** The text of a message, or `''` when it carries none (tool call, media-only). */
536
+ function recallText(message: AgentMessage): string {
537
+ const content: any = message.content;
538
+ if (typeof content === "string") return content.trim();
539
+ if (Array.isArray(content)) {
540
+ return content
541
+ .filter((part: any) => part?.type === "text" && typeof part.text === "string")
542
+ .map((part: any) => part.text)
543
+ .join(" ")
544
+ .trim();
545
+ }
546
+ return "";
547
+ }
548
+
549
+ /** Stable id of an indexed message — two identical turns collapse onto one hit. */
550
+ function recallId(message: AgentMessage, text: string): string {
551
+ return createHash("sha1").update(`${message.role}\0${text}`).digest("hex");
552
+ }
553
+
554
+ const RECALL_HEADER = "Relevant past exchanges (semantic recall):";
555
+
556
+ /**
557
+ * A `load` / `save` processor that recalls past messages **by meaning** — the
558
+ * framework's projection of Mastra's `semanticRecall`.
559
+ *
560
+ * - `load` embeds the last user message as a **query**, searches your index, and
561
+ * prepends the hits as a system message (only when there are hits).
562
+ * - `save` embeds the new user / assistant / tool messages as **passages** and
563
+ * hands them to `upsert`. Without `upsert` it only recalls (index filled
564
+ * elsewhere); without `search` it does not run at all.
565
+ *
566
+ * Same option names as Mastra: `topK`, `messageRange`, `scope` (`'thread'` or
567
+ * `'resource'`), `filter`. `messageRange` is forwarded to the store: expanding a
568
+ * hit into its surrounding messages belongs to the index, not to the prompt.
569
+ *
570
+ * ```ts
571
+ * processors: [
572
+ * agents.memory.semanticRecall({
573
+ * topK: 3,
574
+ * messageRange: 2,
575
+ * scope: "resource",
576
+ * embed: (texts, kind) => embedder(texts, kind), // → number[][]
577
+ * search: (vector, query) => vectorStore.search(vector, query),
578
+ * upsert: (records) => vectorStore.upsert(records),
579
+ * }),
580
+ * ],
581
+ * ```
582
+ */
583
+ function semanticRecall(options: AgentSemanticRecallOptions): AgentMemoryProcessor {
584
+ if (typeof options?.embed !== "function" || typeof options?.search !== "function") {
585
+ throw new AppError(
586
+ "A semantic recall processor needs `embed` and `search` — agents.memory.semanticRecall({ embed, search, … })",
587
+ { status: 500, code: "AGENT_RECALL_HANDLER_REQUIRED" },
588
+ );
589
+ }
590
+
591
+ const topK = Math.max(1, Math.trunc(options.topK ?? 3));
592
+ const messageRange = Math.max(0, Math.trunc(options.messageRange ?? 2));
593
+ const scope = options.scope === "resource" ? "resource" : "thread";
594
+ const format = options.format
595
+ ?? ((hits: AgentRecallHit[]) => hits.map((hit) => `- ${hit.text}`).join("\n"));
596
+
597
+ // A message is embedded once per process. The id is content-based and stable
598
+ // across restarts, so the store can deduplicate what it already holds.
599
+ const indexed = new Set<string>();
600
+
601
+ return {
602
+ id: "semantic-recall",
603
+
604
+ async load(messages, ctx) {
605
+ const question = [...messages].reverse().find((message) => message.role === "user");
606
+ const text = question ? recallText(question) : "";
607
+ if (!text) return messages;
608
+
609
+ const [vector] = await options.embed([text], "query");
610
+ if (!vector?.length) return messages;
611
+
612
+ const hits = await options.search(vector, {
613
+ topK,
614
+ messageRange,
615
+ scope,
616
+ ...(options.filter ? { filter: options.filter } : {}),
617
+ ...(ctx?.thread ? { thread: ctx.thread } : {}),
618
+ ...(ctx?.resourceId ? { resourceId: ctx.resourceId } : {}),
619
+ }, ctx);
620
+ if (!hits?.length) return messages;
621
+
622
+ const rendered = format(hits).trim();
623
+ if (!rendered) return messages;
624
+
625
+ return [{ role: "system", content: `${RECALL_HEADER}\n${rendered}` }, ...messages];
626
+ },
627
+
628
+ async save(messages, ctx) {
629
+ if (typeof options.upsert !== "function") return messages;
630
+
631
+ const seen = new Set<string>();
632
+ const fresh = messages
633
+ .filter((message) => message.role !== "system" && message.role !== "developer")
634
+ .map((message) => ({ message, text: recallText(message) }))
635
+ .filter(({ text }) => !!text)
636
+ .map((entry) => ({ ...entry, id: recallId(entry.message, entry.text) }))
637
+ .filter((entry) => {
638
+ if (indexed.has(entry.id) || seen.has(entry.id)) return false;
639
+ seen.add(entry.id);
640
+ return true;
641
+ });
642
+ if (!fresh.length) return messages;
643
+
644
+ const vectors = await options.embed(fresh.map((entry) => entry.text), "passage");
645
+ const records: AgentRecallRecord[] = fresh
646
+ .map((entry, index) => ({
647
+ id: entry.id,
648
+ text: entry.text,
649
+ vector: vectors[index] ?? [],
650
+ role: entry.message.role,
651
+ ...(ctx?.thread ? { thread: ctx.thread } : {}),
652
+ ...(ctx?.resourceId ? { resourceId: ctx.resourceId } : {}),
653
+ }))
654
+ .filter((record) => record.vector.length);
655
+ if (!records.length) return messages;
656
+
657
+ await options.upsert(records, ctx);
658
+ for (const record of records) indexed.add(record.id);
659
+ return messages;
660
+ },
661
+ };
662
+ }
663
+
467
664
  export {
468
665
  DEFAULT_MAX_MESSAGES,
666
+ customAgentMemory,
667
+ semanticRecall,
469
668
  summarize,
470
669
  threadKey,
471
670
  threadTitle,
package/lib/agents.ts CHANGED
@@ -3,25 +3,95 @@ import path from "path";
3
3
  import fs from "fs/promises";
4
4
  import { cfg } from "../server/config";
5
5
  import { Agent } from "./agent";
6
- import { InMemoryAgentMemory, MongoAgentMemory, RedisAgentMemory, summarize } from "./agentMemory";
6
+ import { InMemoryAgentMemory, MongoAgentMemory, RedisAgentMemory, customAgentMemory, semanticRecall, summarize } from "./agentMemory";
7
7
  import { importDefinition } from "./load";
8
8
  import { resolveSkills } from "./skills";
9
9
  import { closeMcpClients } from "./mcpClient";
10
10
  import { logger } from "../utils/logger";
11
11
  import type { useRest } from "../database/rest";
12
- import type { AgentDefinition, TenantAgents } from "../types/agent";
12
+ import type { AgentDefinition, AgentTool, TenantAgents } from "../types/agent";
13
13
 
14
14
  /**
15
- * Agent loader — `{tenant.dir}/agents/**\/*.agent.ts`.
15
+ * Agent loader — `{tenant.dir}/ai/agents/**\/*.agent.ts`, the legacy
16
+ * `{tenant.dir}/agents/**\/*.agent.ts` still read (with a deprecation warning).
16
17
  *
17
- * Each definition is instantiated once per tenant and kept in a process-wide
18
- * registry keyed by `tenant:agent`. `agents.get('weather')` resolves it at
19
- * runtime, binding the *calling* `rest` so tools run with the right scope.
18
+ * Tools live **next to the agent**: `support.agent.ts` + `support.tool.ts` in the
19
+ * same directory (a sub-directory works the same way). Each definition is
20
+ * instantiated once per tenant and kept in a process-wide registry keyed by
21
+ * `tenant:agent`. `agents.get('weather')` resolves it at runtime, binding the
22
+ * *calling* `rest` so tools run with the right scope.
20
23
  */
21
24
 
22
25
  const registry = new Map<string, Agent>();
23
26
  const definitions: AgentDefinition[] = [];
24
27
 
28
+ /** Paths scanned, legacy first so the `ai/` definition wins a duplicate id. */
29
+ function agentRoots(tenantDir: string): Array<{ dir: string; legacy: boolean }> {
30
+ return [
31
+ { dir: path.join(tenantDir, "agents"), legacy: true },
32
+ { dir: path.join(tenantDir, "ai", "agents"), legacy: false },
33
+ ];
34
+ }
35
+
36
+ /** A deprecation is logged once per tenant and path — `reload()` stays quiet. */
37
+ const warned = new Set<string>();
38
+ function warnLegacy(tenantId: string, from: string, to: string): void {
39
+ const key = `${tenantId}:${from}`;
40
+ if (warned.has(key)) return;
41
+ warned.add(key);
42
+ logger.warn(`Tenant '${tenantId}': '${from}' is deprecated — move it to '${to}'`);
43
+ }
44
+
45
+ /**
46
+ * Tools declared beside an agent — `<basename>.tool.ts` in the same directory.
47
+ * The file exports one `define.Tool` or an array; `enabled: false` and entries
48
+ * without an `id` are skipped. Tools declared inline on the agent win on a
49
+ * name clash.
50
+ */
51
+ async function loadSiblingTools(agentFile: string): Promise<Record<string, AgentTool> | undefined> {
52
+ const base = path.basename(agentFile).replace(/\.agent\.ts$/, "");
53
+ const file = path.join(path.dirname(agentFile), `${base}.tool.ts`);
54
+ if (!(await fs.exists(file))) return undefined;
55
+
56
+ const module = await importDefinition(file, "Agent tool");
57
+ const exported = Array.isArray(module?.default) ? module.default : [module?.default];
58
+ const tools: Record<string, AgentTool> = {};
59
+
60
+ for (const tool of exported) {
61
+ if (!tool?._isTool_) {
62
+ logger.warn(`'${file}' does not export a define.Tool — ignored`);
63
+ continue;
64
+ }
65
+ if (tool.enabled === false) continue;
66
+ if (!tool.id) {
67
+ logger.warn(`A tool in '${file}' has no id — ignored (define.Tool requires one)`);
68
+ continue;
69
+ }
70
+ // The description is the whole prompt of the tool: without it the model cannot
71
+ // choose it, and an unnamed capability in the toolbox is worse than none.
72
+ if (!String(tool.description ?? "").trim()) {
73
+ logger.warn(`Tool '${tool.id}' in '${file}' has no description — ignored (the model cannot choose it)`);
74
+ continue;
75
+ }
76
+ tools[tool.id] = tool;
77
+ }
78
+
79
+ return Object.keys(tools).length ? tools : undefined;
80
+ }
81
+
82
+ /** Merge file tools under the inline ones — an inline tool is an explicit choice. */
83
+ function mergeTools(
84
+ fromFiles: Record<string, AgentTool> | undefined,
85
+ inline: AgentDefinition["tools"],
86
+ ): AgentDefinition["tools"] {
87
+ if (!fromFiles) return inline;
88
+ if (!inline) return fromFiles;
89
+ const own = Array.isArray(inline)
90
+ ? Object.fromEntries(inline.map((tool) => [tool.id!, tool]))
91
+ : inline;
92
+ return { ...fromFiles, ...own };
93
+ }
94
+
25
95
  async function syncAgents(): Promise<void> {
26
96
  registry.clear();
27
97
  definitions.length = 0;
@@ -30,58 +100,64 @@ async function syncAgents(): Promise<void> {
30
100
  const memories: Array<{ _tenant_: string; collection: string }> = [];
31
101
 
32
102
  for (const tenant of cfg.tenants ?? []) {
33
- const agentsPath = path.join(process.cwd(), tenant.dir, "agents");
34
- if (!(await fs.exists(agentsPath))) continue;
35
- if (!(await fs.stat(agentsPath)).isDirectory()) continue;
36
-
37
- const glob = new Glob(path.join(agentsPath, "**/*.agent.ts"));
38
- for await (const file of glob.scan(".")) {
39
- const module = await importDefinition(file, "Agent");
40
- const definition: AgentDefinition | undefined = module?.default;
41
- if (!definition?._isAgent_) continue;
42
- if (definition.enabled === false) continue;
43
-
44
- // `id` and `description` are required — the `Agent` constructor refuses a
45
- // definition without them, and a broken file never hides the others
46
- const id = definition.id!;
47
-
48
- const key = `${tenant.id}:${id}`;
49
- const existing = registry.get(key);
50
- if (existing) {
51
- logger.warn(`Duplicate agent id '${id}' for tenant '${tenant.id}' — '${file}' replaces the previous definition`);
103
+ for (const root of agentRoots(tenant.dir)) {
104
+ const agentsPath = path.join(process.cwd(), root.dir);
105
+ if (!(await fs.exists(agentsPath))) continue;
106
+ if (!(await fs.stat(agentsPath)).isDirectory()) continue;
107
+ if (root.legacy) {
108
+ warnLegacy(tenant.id, path.join(tenant.dir, "agents"), path.join(tenant.dir, "ai", "agents"));
52
109
  }
53
110
 
54
- try {
55
- // `skills` are read **once**, here: a run injects the documents into
56
- // its prompt, it never touches the filesystem
57
- const baseDir = path.join(process.cwd(), tenant.dir);
58
- const skills = await resolveSkills(definition.skills, { baseDir, agentId: id });
59
-
60
- // A referenced sub-agent brings its own declaration: its `skills` are
61
- // resolved here too, so delegating is the same as running it directly
62
- // (and still never reads a file at run time)
63
- for (const referenced of definition.agents
64
- ? (Array.isArray(definition.agents) ? definition.agents : [definition.agents])
65
- : []) {
66
- if (referenced && !referenced._skills_?.length) {
67
- referenced._skills_ = await resolveSkills(referenced.skills, { baseDir, agentId: referenced.id });
68
- }
111
+ const glob = new Glob(path.join(agentsPath, "**/*.agent.ts"));
112
+ for await (const file of glob.scan(".")) {
113
+ const module = await importDefinition(file, "Agent");
114
+ const definition: AgentDefinition | undefined = module?.default;
115
+ if (!definition?._isAgent_) continue;
116
+ if (definition.enabled === false) continue;
117
+
118
+ // `id` and `description` are required — the `Agent` constructor refuses a
119
+ // definition without them, and a broken file never hides the others
120
+ const id = definition.id!;
121
+
122
+ const key = `${tenant.id}:${id}`;
123
+ const existing = registry.get(key);
124
+ if (existing) {
125
+ logger.warn(`Duplicate agent id '${id}' for tenant '${tenant.id}' — '${file}' replaces the previous definition`);
69
126
  }
70
127
 
71
- const instance = new Agent({ ...definition, id, _skills_: skills }, { tenant: tenant.id });
72
- registry.set(key, instance);
73
- definitions.push(instance.getConfig());
128
+ try {
129
+ // `skills` are read **once**, here: a run injects the documents into
130
+ // its prompt, it never touches the filesystem
131
+ const baseDir = path.join(process.cwd(), tenant.dir);
132
+ const skills = await resolveSkills(definition.skills, { baseDir, agentId: id });
133
+ const tools = mergeTools(await loadSiblingTools(file), definition.tools);
134
+
135
+ // A referenced sub-agent brings its own declaration: its `skills` are
136
+ // resolved here too, so delegating is the same as running it directly
137
+ // (and still never reads a file at run time)
138
+ for (const referenced of definition.agents
139
+ ? (Array.isArray(definition.agents) ? definition.agents : [definition.agents])
140
+ : []) {
141
+ if (referenced && !referenced._skills_?.length) {
142
+ referenced._skills_ = await resolveSkills(referenced.skills, { baseDir, agentId: referenced.id });
143
+ }
144
+ }
145
+
146
+ const instance = new Agent({ ...definition, id, tools, _skills_: skills }, { tenant: tenant.id });
147
+ registry.set(key, instance);
148
+ definitions.push(instance.getConfig());
74
149
 
75
- // A store says which collections it writes to (a Mongo memory does):
76
- // published on `cfg` so the replication engine copies them, exactly
77
- // like a declared collection — the tenant chooses the name.
78
- for (const collection of instance.getMemory()?.collections?.() ?? []) {
79
- if (!memories.some((m) => m._tenant_ === tenant.id && m.collection === collection)) {
80
- memories.push({ _tenant_: tenant.id, collection });
150
+ // A store says which collections it writes to (a Mongo memory does):
151
+ // published on `cfg` so the replication engine copies them, exactly
152
+ // like a declared collection — the tenant chooses the name.
153
+ for (const collection of instance.getMemory()?.collections?.() ?? []) {
154
+ if (!memories.some((m) => m._tenant_ === tenant.id && m.collection === collection)) {
155
+ memories.push({ _tenant_: tenant.id, collection });
156
+ }
81
157
  }
158
+ } catch (err: any) {
159
+ console.error(`Failed to load agent '${id}' of tenant '${tenant.id}': ${err?.message}`);
82
160
  }
83
- } catch (err: any) {
84
- console.error(`Failed to load agent '${id}' of tenant '${tenant.id}': ${err?.message}`);
85
161
  }
86
162
  }
87
163
  }
@@ -156,7 +232,7 @@ const agents = {
156
232
  list: (tenantId: string, rest?: InstanceType<typeof useRest>): Agent[] => createAgents(tenantId, rest).list(),
157
233
  /** Tenant-scoped registry — the same object a context receives as `agents`. */
158
234
  use: createAgents,
159
- /** Re-scan `{tenant.dir}/agents` and rebuild the registry. */
235
+ /** Re-scan `{tenant.dir}/ai/agents` (and the legacy `agents/`) and rebuild the registry. */
160
236
  reload: syncAgents,
161
237
  /** `{ total, tenants }` — what the boot banner prints. */
162
238
  stats: agentsStats,
@@ -164,6 +240,11 @@ const agents = {
164
240
  InMemory: InMemoryAgentMemory,
165
241
  Mongo: MongoAgentMemory,
166
242
  Redis: RedisAgentMemory,
243
+ // Your own storage — scoping and the message cap are applied for you.
244
+ custom: customAgentMemory,
245
+ // A `load`/`save` processor, not a store: recalls past messages by meaning
246
+ // from **your** vector index (`embed`, `search`, `upsert`).
247
+ semanticRecall,
167
248
  // A `load` processor, not a store: it compacts a thread with **your** model
168
249
  // and prompt. Every store above is a backend; this one shapes the prompt.
169
250
  summarize,
package/lib/attachment.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import path from "path";
2
+ import { realpath, stat } from "node:fs/promises";
2
3
  import { AppError } from "./error";
3
4
  import { openZip, type ZipArchive } from "./zip";
4
5
  import type { AgentAttachment, AgentMediaPart, AgentTextPart } from "../types/agent";
@@ -217,10 +218,30 @@ export type NormalizedAttachment = {
217
218
  */
218
219
  async function materialize(attachment: AgentAttachment, baseDir: string): Promise<NormalizedAttachment> {
219
220
  if (typeof attachment === "string") {
220
- const file = path.resolve(baseDir, attachment);
221
- if (!(await Bun.file(file).exists())) {
222
- throw new AppError(`Attachment '${attachment}' not found`, { status: 400, code: "AGENT_FILE_NOT_FOUND" });
221
+ // A path attachment is a **server-side** feature (scripts, in-process SDK):
222
+ // it may never escape `filesBaseDir` — not through `..`, not through an
223
+ // absolute path, and not through a symlink pointing outside (issue #29).
224
+ const root = path.resolve(baseDir);
225
+ const candidate = path.resolve(root, attachment);
226
+ if (candidate !== root && !candidate.startsWith(root + path.sep)) {
227
+ throw new AppError("Attachment path escapes the base directory", { status: 400, code: "AGENT_FILE_INVALID" });
223
228
  }
229
+
230
+ let file: string;
231
+ try {
232
+ file = await realpath(candidate);
233
+ } catch {
234
+ throw new AppError("Attachment not found", { status: 400, code: "AGENT_FILE_NOT_FOUND" });
235
+ }
236
+
237
+ const realRoot = await realpath(root).catch(() => root);
238
+ if (file !== realRoot && !file.startsWith(realRoot + path.sep)) {
239
+ throw new AppError("Attachment path escapes the base directory", { status: 400, code: "AGENT_FILE_INVALID" });
240
+ }
241
+ if (!(await stat(file)).isFile()) {
242
+ throw new AppError("Attachment is not a regular file", { status: 400, code: "AGENT_FILE_NOT_FOUND" });
243
+ }
244
+
224
245
  const bytes = new Uint8Array(await Bun.file(file).arrayBuffer());
225
246
  return { name: path.basename(file), mimeType: "", bytes };
226
247
  }
package/lib/define.ts CHANGED
@@ -199,7 +199,11 @@ function Agent(config: AgentConfig & { id: string; description: string }): Agent
199
199
  * The `id` is **required** (it is the name the model calls).
200
200
  * A `define.McpTool` is accepted by an agent as-is.
201
201
  */
202
- function Tool(tool: AgentToolDefinition): AgentToolDefinition {
202
+ function Tool<S extends import("zod").ZodType>(
203
+ tool: AgentToolDefinition<import("zod").output<S>> & { inputSchema: S },
204
+ ): AgentToolDefinition<import("zod").output<S>>;
205
+ function Tool<TInput = any>(tool: AgentToolDefinition<TInput>): AgentToolDefinition<TInput>;
206
+ function Tool(tool: AgentToolDefinition<any>): AgentToolDefinition<any> {
203
207
  return {
204
208
  ...tool,
205
209
  enabled: tool.enabled ?? true,
package/lib/eval.ts ADDED
@@ -0,0 +1,123 @@
1
+ import type { Agent } from "./agent";
2
+ import type {
3
+ AgentCallOptions,
4
+ AgentGenerateResult,
5
+ AgentInput,
6
+ AgentToolStats,
7
+ AgentUsage,
8
+ } from "../types/agent";
9
+
10
+ /** One case of an evaluation — the input, and what this case overrides. */
11
+ export type AgentEvalCase = {
12
+ /** Named in the report (default: `case-1`, `case-2`…). */
13
+ name?: string;
14
+ input: AgentInput;
15
+ options?: AgentCallOptions;
16
+ };
17
+
18
+ /** What a grader may return: a bare score, or a score with its reason. */
19
+ export type AgentEvalGrader = (
20
+ result: AgentGenerateResult,
21
+ testCase: AgentEvalCase,
22
+ ) => number | { score: number; reason?: string } | Promise<number | { score: number; reason?: string }>;
23
+
24
+ export type AgentEvalCaseReport = {
25
+ name: string;
26
+ /** Mean of this case's grader scores, in `0..1`. */
27
+ score: number;
28
+ /** The reasons the graders gave, in order (a thrown grader included). */
29
+ reasons: string[];
30
+ finishReason: string;
31
+ usage: AgentUsage;
32
+ toolStats: AgentToolStats;
33
+ };
34
+
35
+ export type AgentEvalReport = {
36
+ /** Mean of the case scores, in `0..1` (`0` for no case). */
37
+ score: number;
38
+ /** Cases reaching `passScore`, or more. */
39
+ passed: number;
40
+ cases: AgentEvalCaseReport[];
41
+ };
42
+
43
+ /** A case passes at or above this score. */
44
+ const DEFAULT_PASS_SCORE = 0.5;
45
+
46
+ /** Keep whatever a grader returned inside `0..1`. */
47
+ function clampScore(score: number): number {
48
+ if (!Number.isFinite(score)) return 0;
49
+ return Math.min(1, Math.max(0, score));
50
+ }
51
+
52
+ function caseName(testCase: AgentEvalCase, index: number): string {
53
+ return testCase.name?.trim() || `case-${index + 1}`;
54
+ }
55
+
56
+ /**
57
+ * Run an agent over a list of cases and score the answers with graders.
58
+ *
59
+ * The cases run **sequentially** (a run may write to a thread), each one with
60
+ * `options` overlaid by its own: `{ ...options, ...testCase.options }`.
61
+ *
62
+ * ```ts
63
+ * const report = await evaluateAgent({
64
+ * agent,
65
+ * cases: [{ name: 'greeting', input: 'Say hello' }],
66
+ * graders: [(result) => (result.text.includes('hello') ? 1 : 0)],
67
+ * });
68
+ * ```
69
+ */
70
+ export async function evaluateAgent(options: {
71
+ agent: Agent;
72
+ cases: AgentEvalCase[];
73
+ graders: AgentEvalGrader[];
74
+ /** A case passes at or above this score (default 0.5). */
75
+ passScore?: number;
76
+ /** Options merged into every case's call. */
77
+ options?: AgentCallOptions;
78
+ }): Promise<AgentEvalReport> {
79
+ const { agent, cases, graders } = options;
80
+ const passScore = options.passScore ?? DEFAULT_PASS_SCORE;
81
+ const reports: AgentEvalCaseReport[] = [];
82
+
83
+ for (const [index, testCase] of cases.entries()) {
84
+ const result = await agent.generate(testCase.input, { ...options.options, ...testCase.options });
85
+
86
+ const scores: number[] = [];
87
+ const reasons: string[] = [];
88
+ for (const grader of graders) {
89
+ try {
90
+ const outcome = await grader(result, testCase);
91
+ if (typeof outcome === "number") {
92
+ scores.push(clampScore(outcome));
93
+ } else {
94
+ scores.push(clampScore(outcome.score));
95
+ if (outcome.reason) reasons.push(outcome.reason);
96
+ }
97
+ } catch (err: any) {
98
+ // One broken grader is a zero, not the end of the evaluation
99
+ scores.push(0);
100
+ reasons.push(err?.message ?? String(err));
101
+ }
102
+ }
103
+
104
+ reports.push({
105
+ name: caseName(testCase, index),
106
+ score: scores.length ? scores.reduce((sum, score) => sum + score, 0) / scores.length : 0,
107
+ reasons,
108
+ finishReason: result.finishReason,
109
+ usage: result.usage,
110
+ toolStats: result.toolStats,
111
+ });
112
+ }
113
+
114
+ const score = reports.length
115
+ ? reports.reduce((sum, report) => sum + report.score, 0) / reports.length
116
+ : 0;
117
+
118
+ return {
119
+ score,
120
+ passed: reports.filter((report) => report.score >= passScore).length,
121
+ cases: reports,
122
+ };
123
+ }