@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.
- package/LICENSE +21 -0
- package/README.md +1 -1
- package/database/field.crypto.ts +38 -13
- package/database/mongodbadapter.ts +85 -25
- package/database/vars.ts +22 -2
- package/index.ts +22 -0
- package/lib/agent.ts +707 -79
- package/lib/agentMemory.ts +199 -0
- package/lib/agents.ts +133 -52
- package/lib/attachment.ts +24 -3
- package/lib/define.ts +5 -1
- package/lib/eval.ts +123 -0
- package/lib/files.ts +21 -15
- package/lib/jsonSchema.ts +8 -0
- package/lib/mcp.ts +52 -30
- package/lib/messages.ts +202 -19
- package/lib/partialJson.ts +79 -0
- package/lib/providers.ts +26 -2
- package/lib/redact.ts +11 -0
- package/lib/tracing.ts +20 -0
- package/lib/zip.ts +23 -3
- package/package.json +4 -5
- package/server/agents.ts +66 -1
- package/server/api.ts +302 -20
- package/server/boot.ts +13 -4
- package/server/hono.ts +146 -6
- package/tests/access-log-body.test.ts +253 -0
- package/tests/agent-checkpoint.test.ts +195 -0
- package/tests/agent-hardening.test.ts +238 -0
- package/tests/agent-memory.test.ts +175 -1
- package/tests/agent-stream-advanced.test.ts +246 -0
- package/tests/agent-tracing-eval.test.ts +219 -0
- package/tests/agent-working-memory.test.ts +7 -4
- package/tests/agent.test.ts +54 -3
- package/tests/agents-api.test.ts +21 -0
- package/tests/api-external-guards.test.ts +304 -0
- package/tests/attachment.test.ts +63 -0
- package/tests/audit-redact.test.ts +26 -1
- package/tests/field-encryption.test.ts +47 -3
- package/tests/filters-hardening.test.ts +81 -0
- package/tests/fixtures/ai-tenant/ai/agents/support/billing.agent.ts +9 -0
- package/tests/fixtures/ai-tenant/ai/agents/support/billing.tool.ts +9 -0
- package/tests/fixtures/ai-tenant/ai/agents/support.agent.ts +9 -0
- package/tests/fixtures/ai-tenant/ai/agents/support.tool.ts +9 -0
- package/tests/fixtures/ai-tenant/ai/mcp/resources/readme.resource.ts +9 -0
- package/tests/fixtures/ai-tenant/ai/mcp/tools/echo.tool.ts +9 -0
- package/tests/fixtures/fake-provider.ts +33 -0
- package/tests/fixtures/tool-tenant/ai/agents/report.agent.ts +12 -0
- package/tests/fixtures/tool-tenant/ai/agents/report.tool.ts +12 -0
- package/tests/fixtures/vars-tenant/vars/open.var.ts +18 -0
- package/tests/hono.test.ts +81 -1
- package/tests/jwt-tenant.test.ts +165 -0
- package/tests/logger.test.ts +62 -10
- package/tests/messages.test.ts +88 -32
- package/tests/tool-runtime.test.ts +291 -0
- package/tests/tool-types.ts +33 -0
- package/tests/upload.test.ts +175 -3
- package/tests/vars-api.test.ts +73 -1
- package/tests/vars.test.ts +1 -1
- package/types/agent.d.ts +386 -4
- package/types/config.d.ts +56 -2
- package/types/file.d.ts +1 -1
- package/types/mcp.d.ts +2 -2
- package/utils/func.ts +20 -0
- package/utils/logger.ts +125 -33
package/lib/agentMemory.ts
CHANGED
|
@@ -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
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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(
|
|
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
|
+
}
|