@anteros/core 0.0.41 → 0.0.42
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/database/audit.ts +9 -59
- package/database/collection.ts +3 -1
- package/database/deleteLog.ts +67 -3
- package/database/file.ts +4 -3
- package/database/mongodbadapter.ts +103 -42
- package/database/replication.ts +110 -32
- package/database/ttl.ts +68 -0
- package/database/workflow.ts +466 -240
- package/index.ts +6 -0
- package/lib/agent.ts +1327 -0
- package/lib/agentMemory.ts +586 -0
- package/lib/agents.ts +157 -0
- package/lib/ai.ts +0 -0
- package/lib/api.ts +246 -0
- package/lib/attachment.ts +351 -0
- package/lib/auditResult.ts +13 -0
- package/lib/define.ts +43 -1
- package/lib/endpoints.ts +80 -0
- package/lib/files.ts +2 -1
- package/lib/jsonSchema.ts +183 -0
- package/lib/lifecycle.ts +7 -1
- package/lib/load.ts +17 -0
- package/lib/mcp.ts +3 -2
- package/lib/messages.ts +299 -0
- package/lib/providers.ts +867 -0
- package/lib/routes.ts +14 -1
- package/lib/scriptWorker.ts +4 -1
- package/lib/scripts.ts +6 -1
- package/lib/sockets.ts +3 -1
- package/lib/workflow.ts +209 -37
- package/lib/zip.ts +109 -0
- package/package.json +1 -1
- package/server/access.ts +96 -0
- package/server/agents.ts +451 -0
- package/server/api.ts +14 -72
- package/server/boot.ts +11 -0
- package/server/hono.ts +8 -3
- package/server/mcp.ts +17 -63
- package/server/routes.ts +19 -24
- package/tests/agent-memory.test.ts +617 -0
- package/tests/agent-working-memory.test.ts +402 -0
- package/tests/agent.test.ts +1239 -0
- package/tests/agents-api.test.ts +593 -0
- package/tests/api.test.ts +51 -0
- package/tests/attachment.test.ts +241 -0
- package/tests/audit-operations.test.ts +1 -1
- package/tests/audit-redact.test.ts +29 -3
- package/tests/audit-run-action.test.ts +1 -1
- package/tests/endpoints.test.ts +80 -0
- package/tests/fixtures/agent-tenant/agents/support.agent.ts +9 -0
- package/tests/fixtures/agent-tenant/agents/weather.agent.ts +21 -0
- package/tests/fixtures/agents-api/agents/assistant.agent.ts +46 -0
- package/tests/fixtures/agents-api/agents/plain.agent.ts +23 -0
- package/tests/fixtures/agents-api/agents/private.agent.ts +15 -0
- package/tests/fixtures/agents-api/collections/notes.model.ts +26 -0
- package/tests/fixtures/agents-api/routes/probe.route.ts +21 -0
- package/tests/fixtures/agents-api/services/probe.service.ts +19 -0
- package/tests/fixtures/attachment/data.csv +2 -0
- package/tests/fixtures/fake-provider.ts +128 -0
- package/tests/fixtures/replication-meta/agents/chat.agent.ts +16 -0
- package/tests/fixtures/replication-meta/collections/counters.model.ts +13 -0
- package/tests/fixtures/replication-meta/collections/orders.model.ts +9 -0
- package/tests/fixtures/replication-meta/workflows/run.workflow.ts +8 -0
- package/tests/fixtures/vars-tenant/vars/private.var.ts +4 -1
- package/tests/fixtures/workflow-engine/workflows/failing.workflow.ts +34 -0
- package/tests/fixtures/workflow-engine/workflows/flaky.workflow.ts +23 -0
- package/tests/fixtures/workflow-engine/workflows/hang.workflow.ts +17 -0
- package/tests/fixtures/workflow-engine/workflows/simple.workflow.ts +19 -0
- package/tests/fixtures/workflow-engine/workflows/slow.workflow.ts +23 -0
- package/tests/fixtures/workflow-engine/workflows/versioned.workflow.ts +19 -0
- package/tests/fixtures/workflow-tenant/workflows/broken.workflow.ts +3 -0
- package/tests/fixtures/workflow-tenant/workflows/disabled.workflow.ts +8 -0
- package/tests/fixtures/workflow-tenant/workflows/invoice.workflow.ts +21 -0
- package/tests/fixtures/workflow-tenant/workflows/no-id.workflow.ts +7 -0
- package/tests/fixtures/workflow-tenant/workflows/order-duplicate.workflow.ts +13 -0
- package/tests/fixtures/workflow-tenant/workflows/order.workflow.ts +17 -0
- package/tests/hono.test.ts +6 -3
- package/tests/locks.test.ts +120 -0
- package/tests/mcp.test.ts +11 -11
- package/tests/messages.test.ts +258 -0
- package/tests/replication-control.test.ts +2 -2
- package/tests/replication-meta.test.ts +243 -0
- package/tests/upload.test.ts +44 -12
- package/tests/vars-api.test.ts +1 -1
- package/tests/vars-replication.test.ts +3 -2
- package/tests/workflow-engine.test.ts +340 -0
- package/tests/workflow-retention.test.ts +103 -0
- package/tests/workflow.test.ts +186 -0
- package/types/agent.d.ts +837 -0
- package/types/config.d.ts +16 -0
- package/types/file.d.ts +7 -7
- package/types/hook.d.ts +6 -0
- package/types/lifecycle.d.ts +6 -0
- package/types/mcp.d.ts +11 -1
- package/types/replication.d.ts +29 -0
- package/types/route.d.ts +7 -1
- package/types/scripts.d.ts +6 -0
- package/types/service.d.ts +7 -1
- package/types/tenant.d.ts +13 -0
- package/types/vars.d.ts +2 -2
- package/types/websocket.d.ts +6 -0
- package/types/workflow.d.ts +50 -3
package/lib/agent.ts
ADDED
|
@@ -0,0 +1,1327 @@
|
|
|
1
|
+
import { AppError, fn } from "./error";
|
|
2
|
+
import { toJsonSchema, validateWithSchema } from "./jsonSchema";
|
|
3
|
+
import { InMemoryAgentMemory, trimMessages } from "./agentMemory";
|
|
4
|
+
import { forMemory, resolveAttachments } from "./attachment";
|
|
5
|
+
import { normalizeMessages, serializeToolResult } from "./messages";
|
|
6
|
+
import {
|
|
7
|
+
EMPTY_USAGE,
|
|
8
|
+
resolveProvider,
|
|
9
|
+
} from "./providers";
|
|
10
|
+
import type {
|
|
11
|
+
AgentToolSpec,
|
|
12
|
+
ModelAdapter,
|
|
13
|
+
ModelChatRequest,
|
|
14
|
+
} from "./providers";
|
|
15
|
+
import type { useRest } from "../database/rest";
|
|
16
|
+
import type {
|
|
17
|
+
AgentCallOptions,
|
|
18
|
+
AgentConfig,
|
|
19
|
+
AgentGenerateResult,
|
|
20
|
+
AgentInput,
|
|
21
|
+
AgentInstructions,
|
|
22
|
+
AgentMediaPart,
|
|
23
|
+
AgentMemory,
|
|
24
|
+
AgentMemoryContext,
|
|
25
|
+
AgentMemoryProcessor,
|
|
26
|
+
AgentMemoryState,
|
|
27
|
+
AgentMessage,
|
|
28
|
+
AgentOptions,
|
|
29
|
+
AgentTextPart,
|
|
30
|
+
AgentThinkingBlock,
|
|
31
|
+
AgentThread,
|
|
32
|
+
AgentProvider,
|
|
33
|
+
AgentStreamChunk,
|
|
34
|
+
AgentStreamResult,
|
|
35
|
+
AgentStep,
|
|
36
|
+
AgentTool,
|
|
37
|
+
AgentToolCall,
|
|
38
|
+
AgentToolContext,
|
|
39
|
+
AgentToolResultEntry,
|
|
40
|
+
AgentTools,
|
|
41
|
+
AgentUsage,
|
|
42
|
+
AgentWorkingMemory,
|
|
43
|
+
} from "../types/agent";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Agents — an LLM with instructions, tools and memory.
|
|
47
|
+
*
|
|
48
|
+
* Use an agent when the task is open-ended: the model decides which tools to
|
|
49
|
+
* call, how many times, and when to answer. Use a workflow when the steps are
|
|
50
|
+
* known in advance.
|
|
51
|
+
*
|
|
52
|
+
* ```ts
|
|
53
|
+
* // v1/agents/weather.agent.ts
|
|
54
|
+
* import { define, v } from '@anteros/core'
|
|
55
|
+
*
|
|
56
|
+
* export default define.Agent({
|
|
57
|
+
* id: 'weather',
|
|
58
|
+
* instructions: 'You are a concise weather assistant.',
|
|
59
|
+
* provider: { model: 'gpt-4o-mini', apiKey: Bun.env.OPENAI_API_KEY },
|
|
60
|
+
* tools: {
|
|
61
|
+
* forecast: {
|
|
62
|
+
* description: 'Current weather for a city',
|
|
63
|
+
* inputSchema: v.object({ city: v.string().required() }),
|
|
64
|
+
* execute: async ({ city }, { rest }) => ({ city, celsius: 21 }),
|
|
65
|
+
* },
|
|
66
|
+
* },
|
|
67
|
+
* })
|
|
68
|
+
*
|
|
69
|
+
* // anywhere you have a context — a route, a service, a hook, a script…
|
|
70
|
+
* const { text } = await agents.get('weather')!.generate('Weather in Paris?')
|
|
71
|
+
* ```
|
|
72
|
+
*
|
|
73
|
+
* The provider is declared inline — `{ model, apiKey, compatible }` — with no
|
|
74
|
+
* SDK: `'openai'` (the default, and every OpenAI-compatible gateway) or
|
|
75
|
+
* `'anthropic'`.
|
|
76
|
+
*/
|
|
77
|
+
|
|
78
|
+
const DEFAULT_MAX_STEPS = 5;
|
|
79
|
+
|
|
80
|
+
// ─── Small async primitives ──────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
/** Unbounded async queue — bridges a push producer with a pull consumer. */
|
|
83
|
+
class AsyncQueue<T> {
|
|
84
|
+
#items: T[] = [];
|
|
85
|
+
#waiters: Array<() => void> = [];
|
|
86
|
+
#closed = false;
|
|
87
|
+
#error: any = null;
|
|
88
|
+
|
|
89
|
+
push(item: T): void {
|
|
90
|
+
if (this.#closed) return;
|
|
91
|
+
this.#items.push(item);
|
|
92
|
+
for (const wake of this.#waiters.splice(0)) wake();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
close(): void {
|
|
96
|
+
if (this.#closed) return;
|
|
97
|
+
this.#closed = true;
|
|
98
|
+
for (const wake of this.#waiters.splice(0)) wake();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
fail(error: any): void {
|
|
102
|
+
if (this.#closed) return;
|
|
103
|
+
this.#error = error;
|
|
104
|
+
this.#closed = true;
|
|
105
|
+
for (const wake of this.#waiters.splice(0)) wake();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async next(): Promise<IteratorResult<T>> {
|
|
109
|
+
for (;;) {
|
|
110
|
+
if (this.#error) throw this.#error;
|
|
111
|
+
if (this.#items.length) return { value: this.#items.shift() as T, done: false };
|
|
112
|
+
if (this.#closed) return { value: undefined as any, done: true };
|
|
113
|
+
await new Promise<void>((resolve) => this.#waiters.push(resolve));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
[Symbol.asyncIterator](): AsyncIterator<T> {
|
|
118
|
+
return { next: () => this.next() };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
type Deferred<T> = {
|
|
123
|
+
promise: Promise<T>;
|
|
124
|
+
resolve: (value: T) => void;
|
|
125
|
+
reject: (reason?: any) => void;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
function deferred<T>(): Deferred<T> {
|
|
129
|
+
let resolve!: (value: T) => void;
|
|
130
|
+
let reject!: (reason?: any) => void;
|
|
131
|
+
const promise = new Promise<T>((res, rej) => {
|
|
132
|
+
resolve = res;
|
|
133
|
+
reject = rej;
|
|
134
|
+
});
|
|
135
|
+
return { promise, resolve, reject };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ─── Tool helpers ────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
/** Normalize `AgentTools` (record or array) into entries, keyed by tool id. */
|
|
141
|
+
function toolEntries(tools: AgentTools): Array<[string, AgentTool]> {
|
|
142
|
+
const entries: Array<[string, AgentTool]> = [];
|
|
143
|
+
|
|
144
|
+
const resolve = (tool: AgentTool, label?: string): [string, AgentTool] => {
|
|
145
|
+
// An MCP tool carries the protocol's `name`; our own tools carry an `id`
|
|
146
|
+
const id = tool?.id ?? tool?.name;
|
|
147
|
+
if (!id) {
|
|
148
|
+
throw new AppError("A tool requires an `id` (`define.Tool({ id, description, execute })`)", {
|
|
149
|
+
status: 500, code: "AGENT_TOOL_INVALID",
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
// The record key is a label for the reader — it must agree with the id, since
|
|
153
|
+
// the id is what the model calls
|
|
154
|
+
if (label !== undefined && label !== id) {
|
|
155
|
+
throw new AppError(
|
|
156
|
+
`Tool '${label}' has id '${id}' — the key must match the id`,
|
|
157
|
+
{ status: 500, code: "AGENT_TOOL_ID_MISMATCH" },
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
return [id, tool];
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
if (Array.isArray(tools)) {
|
|
164
|
+
for (const tool of tools) entries.push(resolve(tool));
|
|
165
|
+
return entries;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
for (const [label, tool] of Object.entries(tools)) entries.push(resolve(tool, label));
|
|
169
|
+
return entries;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function requireHandler(name: string, tool: AgentTool): void {
|
|
173
|
+
if (typeof tool.execute !== "function" && typeof tool.exec !== "function") {
|
|
174
|
+
throw new AppError(`Tool '${name}' has neither \`execute\` nor \`exec\``, {
|
|
175
|
+
status: 500,
|
|
176
|
+
code: "AGENT_TOOL_INVALID",
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Parse the arguments the model produced (`{}` when it emitted invalid JSON). */
|
|
182
|
+
function toToolCall(raw: { id: string; name: string; argsText: string }): AgentToolCall {
|
|
183
|
+
const argsText = raw.argsText ?? "";
|
|
184
|
+
let args: Record<string, any> = {};
|
|
185
|
+
if (argsText.trim()) {
|
|
186
|
+
try {
|
|
187
|
+
const parsed = JSON.parse(argsText);
|
|
188
|
+
args = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : { value: parsed };
|
|
189
|
+
} catch {
|
|
190
|
+
args = {};
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return { id: raw.id, name: raw.name, args, argsText };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The tool the framework adds when `workingMemory` is declared. */
|
|
197
|
+
const WORKING_MEMORY_TOOL = "updateWorkingMemory";
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The system-prompt block of the working memory — what the model reads, and the
|
|
201
|
+
* rule that keeps it up to date.
|
|
202
|
+
*/
|
|
203
|
+
function workingMemoryBlock(config: AgentWorkingMemory, value: any): string {
|
|
204
|
+
const head = "## Working memory\n\n";
|
|
205
|
+
const rule = config.tool === false
|
|
206
|
+
? ""
|
|
207
|
+
: `\n\nKeep it up to date with the \`${WORKING_MEMORY_TOOL}\` tool: record what is durable,\nremove what is no longer true, and never store secrets.`;
|
|
208
|
+
|
|
209
|
+
if (config.schema) {
|
|
210
|
+
return `${head}\`\`\`json\n${JSON.stringify(value ?? {}, null, 2)}\n\`\`\`${rule}`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const body = typeof value === "string" && value.trim() ? value : (config.template ?? "");
|
|
214
|
+
return `${head}${body}${rule}`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* A **partial** update of a structured working memory: objects are deep merged,
|
|
219
|
+
* a `null` removes a key, an array replaces the array.
|
|
220
|
+
*/
|
|
221
|
+
function mergeWorkingMemory(current: any, patch: any): any {
|
|
222
|
+
if (!patch || typeof patch !== "object" || Array.isArray(patch)) return patch;
|
|
223
|
+
|
|
224
|
+
const base: Record<string, any> = current && typeof current === "object" && !Array.isArray(current)
|
|
225
|
+
? { ...current }
|
|
226
|
+
: {};
|
|
227
|
+
|
|
228
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
229
|
+
if (value === null) delete base[key];
|
|
230
|
+
else if (value && typeof value === "object" && !Array.isArray(value)) base[key] = mergeWorkingMemory(base[key], value);
|
|
231
|
+
else base[key] = value;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return base;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Run the `load` or `save` processors in order. A processor that returns nothing
|
|
239
|
+
* leaves the conversation as it is; a thrown error stops the pipeline (for
|
|
240
|
+
* `save`, the caller catches it and stores nothing).
|
|
241
|
+
*/
|
|
242
|
+
async function applyProcessors(
|
|
243
|
+
processors: AgentMemoryProcessor[] | undefined,
|
|
244
|
+
hook: "load" | "save",
|
|
245
|
+
messages: AgentMessage[],
|
|
246
|
+
ctx: AgentMemoryContext,
|
|
247
|
+
): Promise<AgentMessage[]> {
|
|
248
|
+
let current = messages;
|
|
249
|
+
for (const processor of processors ?? []) {
|
|
250
|
+
const result = await processor[hook]?.(current, ctx);
|
|
251
|
+
if (Array.isArray(result)) current = result;
|
|
252
|
+
}
|
|
253
|
+
return current;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ─── Usage / structured output ───────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
function addUsage(base: AgentUsage, next: Partial<AgentUsage>): AgentUsage {
|
|
259
|
+
const inputTokens = base.inputTokens + (next.inputTokens ?? 0);
|
|
260
|
+
const outputTokens = base.outputTokens + (next.outputTokens ?? 0);
|
|
261
|
+
return {
|
|
262
|
+
inputTokens,
|
|
263
|
+
outputTokens,
|
|
264
|
+
totalTokens: inputTokens + outputTokens,
|
|
265
|
+
requests: base.requests + (next.requests ?? 0),
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function structuredSuffix(schema: any): string {
|
|
270
|
+
return "\n\nRespond with a single JSON object matching this JSON Schema, and nothing else"
|
|
271
|
+
+ " — no markdown fence, no commentary:\n"
|
|
272
|
+
+ JSON.stringify(schema, null, 2);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Attach resolved parts to the turn being asked — the **last user message**, like
|
|
277
|
+
* an email with attachments. A missing user turn gets a new one.
|
|
278
|
+
*/
|
|
279
|
+
function attachToTurn(messages: AgentMessage[], parts: AgentMediaPart[]): void {
|
|
280
|
+
const last = messages[messages.length - 1];
|
|
281
|
+
|
|
282
|
+
if (last?.role === "user") {
|
|
283
|
+
const existing = Array.isArray(last.content) ? last.content : [];
|
|
284
|
+
const text = typeof last.content === "string" && last.content ? [{ type: "text" as const, text: last.content }] : [];
|
|
285
|
+
last.content = [...text, ...existing, ...parts];
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
messages.push({ role: "user", content: parts });
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* What a run keeps in its memory: the text verbatim, the heavy parts replaced by
|
|
294
|
+
* a note (`[file report.pdf]`) — a conversation is text, not a file store.
|
|
295
|
+
*/
|
|
296
|
+
function lightenForMemory(messages: AgentMessage[]): AgentMessage[] {
|
|
297
|
+
return messages.map((message) => {
|
|
298
|
+
if (!Array.isArray(message.content)) return message;
|
|
299
|
+
|
|
300
|
+
const text = message.content
|
|
301
|
+
.filter((part): part is AgentTextPart => part.type === "text")
|
|
302
|
+
.map((part) => part.text);
|
|
303
|
+
const notes = message.content
|
|
304
|
+
.filter((part) => part.type !== "text")
|
|
305
|
+
.map((part) => forMemory(part).text)
|
|
306
|
+
.filter(Boolean);
|
|
307
|
+
|
|
308
|
+
return { ...message, content: [...text, ...notes].join("\n\n") };
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Read the JSON out of a model answer, tolerating a markdown fence. */
|
|
313
|
+
function extractJson(text: string): { value?: any; error?: string } {
|
|
314
|
+
const trimmed = (text ?? "").trim();
|
|
315
|
+
if (!trimmed) return { error: "the answer was empty" };
|
|
316
|
+
const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
|
|
317
|
+
const candidate = (fenced ? fenced[1] : trimmed) ?? trimmed;
|
|
318
|
+
try {
|
|
319
|
+
return { value: JSON.parse(candidate) };
|
|
320
|
+
} catch (err: any) {
|
|
321
|
+
return { error: err?.message ?? "invalid JSON" };
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ─── Default memory ──────────────────────────────────────────────────────
|
|
326
|
+
// The stores live in `lib/agentMemory.ts` (`agents.memory.InMemory` / `.Mongo`).
|
|
327
|
+
|
|
328
|
+
export { InMemoryAgentMemory };
|
|
329
|
+
|
|
330
|
+
// ─── The agent ───────────────────────────────────────────────────────────
|
|
331
|
+
|
|
332
|
+
type RunOptions = {
|
|
333
|
+
input: AgentInput;
|
|
334
|
+
call: AgentCallOptions;
|
|
335
|
+
streaming: boolean;
|
|
336
|
+
controller: AbortController;
|
|
337
|
+
emit?: (chunk: AgentStreamChunk) => void;
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
class Agent {
|
|
341
|
+
_isAgent_ = true;
|
|
342
|
+
_tenant_?: string;
|
|
343
|
+
|
|
344
|
+
#config: AgentConfig;
|
|
345
|
+
#tools = new Map<string, AgentTool>();
|
|
346
|
+
#rest?: InstanceType<typeof useRest>;
|
|
347
|
+
|
|
348
|
+
constructor(config: AgentConfig, options: { rest?: InstanceType<typeof useRest>; tenant?: string } = {}) {
|
|
349
|
+
if (!config || typeof config !== "object") {
|
|
350
|
+
throw new AppError("An agent requires a configuration object", { status: 500, code: "AGENT_INVALID" });
|
|
351
|
+
}
|
|
352
|
+
if (!config.instructions) {
|
|
353
|
+
throw new AppError(`Agent '${config.id ?? "?"}' requires \`instructions\``, { status: 500, code: "AGENT_INVALID" });
|
|
354
|
+
}
|
|
355
|
+
if (!config.id) {
|
|
356
|
+
throw new AppError("An agent requires an `id`", { status: 500, code: "AGENT_INVALID" });
|
|
357
|
+
}
|
|
358
|
+
if (!config.description) {
|
|
359
|
+
throw new AppError(`Agent '${config.id}' requires a \`description\``, { status: 500, code: "AGENT_INVALID" });
|
|
360
|
+
}
|
|
361
|
+
if (!config.provider?.model) {
|
|
362
|
+
throw new AppError(`Agent '${config.id}' requires \`provider.model\``, { status: 500, code: "AGENT_INVALID" });
|
|
363
|
+
}
|
|
364
|
+
if (config.workingMemory && !!config.workingMemory.template === !!config.workingMemory.schema) {
|
|
365
|
+
throw new AppError(
|
|
366
|
+
`Agent '${config.id}': \`workingMemory\` needs a \`template\` **or** a \`schema\` — not both, not neither`,
|
|
367
|
+
{ status: 500, code: "AGENT_WORKING_MEMORY_INVALID" },
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
this.#config = config;
|
|
372
|
+
this._tenant_ = config._tenant_ ?? options.tenant;
|
|
373
|
+
this.#rest = options.rest;
|
|
374
|
+
if (config.tools) this.setTools(config.tools);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
static isAgent(value: any): boolean {
|
|
378
|
+
return !!value && value._isAgent_ === true;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// ── Identity ─────────────────────────────────────────────────────────
|
|
382
|
+
|
|
383
|
+
getId(): string | undefined {
|
|
384
|
+
return this.#config.id;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
getName(): string {
|
|
388
|
+
return this.#config.name ?? this.#config.id ?? "agent";
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
getDescription(): string | undefined {
|
|
392
|
+
return this.#config.description;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
getTenant(): string | undefined {
|
|
396
|
+
return this._tenant_;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
getConfig(): AgentConfig {
|
|
400
|
+
return this.#config;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ── Provider ─────────────────────────────────────────────────────────
|
|
404
|
+
|
|
405
|
+
/** The provider as declared — the API key is **not** included. */
|
|
406
|
+
getProvider(): AgentProvider {
|
|
407
|
+
const { apiKey, ...rest } = this.#config.provider;
|
|
408
|
+
return { ...rest, options: { ...this.#config.provider.options } };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** Sampling and transport options as declared (`provider.options`). */
|
|
412
|
+
getOptions(): AgentOptions {
|
|
413
|
+
return { ...this.#config.provider.options };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Merge `provider.options` (`{ temperature, maxTokens, topP, timeout, retries }`). */
|
|
417
|
+
setOptions(options: AgentOptions): this {
|
|
418
|
+
this.#config.provider.options = { ...this.#config.provider.options, ...options };
|
|
419
|
+
return this;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
getModel(): string {
|
|
423
|
+
return this.#config.provider.model;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** Merge a provider override (a single field is enough — e.g. the model). */
|
|
427
|
+
setProvider(provider: Partial<AgentProvider>): this {
|
|
428
|
+
this.#config.provider = { ...this.#config.provider, ...provider };
|
|
429
|
+
return this;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
setModel(model: string): this {
|
|
433
|
+
return this.setProvider({ model });
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ── Instructions ─────────────────────────────────────────────────────
|
|
437
|
+
|
|
438
|
+
/** The declaration as written (a string, or a function). */
|
|
439
|
+
getInstructions(): AgentInstructions {
|
|
440
|
+
return this.#config.instructions;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** The resolved system prompt for this call. */
|
|
444
|
+
async resolveInstructions(ctx: { thread?: string; resource?: string; threadId?: string; resourceId?: string } = {}): Promise<string> {
|
|
445
|
+
const raw = this.#config.instructions;
|
|
446
|
+
if (typeof raw === "function") {
|
|
447
|
+
// Both spellings are handed over: a prompt written against `threadId`
|
|
448
|
+
// keeps working after the renaming.
|
|
449
|
+
const thread = ctx.thread ?? ctx.threadId;
|
|
450
|
+
const resource = ctx.resource ?? ctx.resourceId;
|
|
451
|
+
return (await raw({ agent: this, rest: this.#rest, thread, resource, threadId: thread, resourceId: resource })) ?? "";
|
|
452
|
+
}
|
|
453
|
+
return raw ?? "";
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
setInstructions(instructions: AgentInstructions): this {
|
|
457
|
+
this.#config.instructions = instructions;
|
|
458
|
+
return this;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// ── Tools ────────────────────────────────────────────────────────────
|
|
462
|
+
|
|
463
|
+
/** The declared tools, keyed by name. */
|
|
464
|
+
getTools(): Record<string, AgentTool> {
|
|
465
|
+
return Object.fromEntries(this.#tools);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
hasTool(name: string): boolean {
|
|
469
|
+
return this.#tools.has(name);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Replace the tool set. */
|
|
473
|
+
setTools(tools: AgentTools): this {
|
|
474
|
+
const entries = toolEntries(tools);
|
|
475
|
+
for (const [name, tool] of entries) requireHandler(name, tool);
|
|
476
|
+
this.#tools = new Map(entries);
|
|
477
|
+
return this;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** Add or replace one tool — its `id` is the name the model sees. */
|
|
481
|
+
addTool(tool: AgentTool): this {
|
|
482
|
+
const entry = toolEntries([tool])[0]!;
|
|
483
|
+
requireHandler(entry[0], entry[1]);
|
|
484
|
+
this.#tools.set(entry[0], entry[1]);
|
|
485
|
+
return this;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
removeTool(name: string): boolean {
|
|
489
|
+
return this.#tools.delete(name);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// ── Memory ───────────────────────────────────────────────────────────
|
|
493
|
+
|
|
494
|
+
getMemory(): AgentMemory | undefined {
|
|
495
|
+
return this.#config.memory;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ── Working memory ───────────────────────────────────────────────────
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* The key the working memory is stored under: the caller (`'resource'`, the
|
|
502
|
+
* default) or the conversation (`'thread'`). `undefined` when the run has
|
|
503
|
+
* nothing to scope it with — the scratchpad is then simply not used.
|
|
504
|
+
*/
|
|
505
|
+
#workingMemoryKey(ctx: { thread?: string; resource?: string } = {}): string | undefined {
|
|
506
|
+
const config = this.#config.workingMemory;
|
|
507
|
+
if (!config) return undefined;
|
|
508
|
+
|
|
509
|
+
if ((config.scope ?? "resource") === "thread") {
|
|
510
|
+
return ctx.thread ? (ctx.resource ? `thread:${ctx.resource}:${ctx.thread}` : `thread:${ctx.thread}`) : undefined;
|
|
511
|
+
}
|
|
512
|
+
return ctx.resource ? `resource:${ctx.resource}` : undefined;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** The store of the scratchpad, when it has one. */
|
|
516
|
+
#stateStore(): AgentMemory & Required<Pick<AgentMemory, "getState" | "setState">> {
|
|
517
|
+
const memory = this.#config.memory;
|
|
518
|
+
if (!memory) {
|
|
519
|
+
throw new AppError(`Agent '${this.getId() ?? "?"}' declares no memory`, {
|
|
520
|
+
status: 400, code: "AGENT_NO_MEMORY",
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
if (typeof memory.getState !== "function" || typeof memory.setState !== "function") {
|
|
524
|
+
throw new AppError(
|
|
525
|
+
`The memory of agent '${this.getId() ?? "?"}' does not store a working memory`,
|
|
526
|
+
{ status: 400, code: "AGENT_WORKING_MEMORY_UNSUPPORTED" },
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
return memory as AgentMemory & Required<Pick<AgentMemory, "getState" | "setState">>;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** The scratchpad value must fit the declared schema, whoever wrote it. */
|
|
533
|
+
async #validateWorkingMemory(value: any): Promise<void> {
|
|
534
|
+
const schema = this.#config.workingMemory?.schema;
|
|
535
|
+
if (!schema) return;
|
|
536
|
+
|
|
537
|
+
const { error } = await validateWithSchema(schema, value);
|
|
538
|
+
if (error) {
|
|
539
|
+
throw new AppError(`Invalid working memory: ${error}`, {
|
|
540
|
+
status: 400, code: "AGENT_WORKING_MEMORY_VALUE_INVALID",
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Read the agent's scratchpad — the profile it keeps about a caller, or the
|
|
547
|
+
* state of a conversation (`workingMemory.scope`).
|
|
548
|
+
*/
|
|
549
|
+
async getWorkingMemory(ctx: { thread?: string; resource?: string } = {}): Promise<any> {
|
|
550
|
+
const key = this.#workingMemoryKey(ctx);
|
|
551
|
+
if (!key) return undefined;
|
|
552
|
+
const state = await this.#stateStore().getState(key, this.#memoryCtx(ctx.resource));
|
|
553
|
+
return state?.value;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Write the scratchpad **without calling the model** — seed a profile, or
|
|
558
|
+
* correct it from your own code.
|
|
559
|
+
*
|
|
560
|
+
* ```ts
|
|
561
|
+
* await support.setWorkingMemory('# Profile\n- Name: Sam\n- Timezone: CET', {
|
|
562
|
+
* resource: 'user-42',
|
|
563
|
+
* });
|
|
564
|
+
* ```
|
|
565
|
+
*
|
|
566
|
+
* With a `schema`, the value is validated and **merged** into what is stored
|
|
567
|
+
* (`null` removes a key); with a `template`, it **replaces** the block.
|
|
568
|
+
*/
|
|
569
|
+
async setWorkingMemory(value: any, ctx: { thread?: string; resource?: string } = {}): Promise<any> {
|
|
570
|
+
const memory = this.#stateStore(); // `AGENT_NO_MEMORY` / unsupported first: nothing to write into
|
|
571
|
+
const config = this.#config.workingMemory;
|
|
572
|
+
if (!config) {
|
|
573
|
+
throw new AppError(`Agent '${this.getId() ?? "?"}' declares no \`workingMemory\``, {
|
|
574
|
+
status: 400, code: "AGENT_WORKING_MEMORY_DISABLED",
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const key = this.#workingMemoryKey(ctx);
|
|
579
|
+
if (!key) {
|
|
580
|
+
throw new AppError(
|
|
581
|
+
`A \`${config.scope ?? "resource"}\`-scoped working memory needs \`${(config.scope ?? "resource") === "thread" ? "thread" : "resource"}\``,
|
|
582
|
+
{ status: 400, code: "AGENT_WORKING_MEMORY_SCOPE_REQUIRED" },
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const context = this.#memoryCtx(ctx.resource);
|
|
587
|
+
let next = value;
|
|
588
|
+
|
|
589
|
+
if (config.schema) {
|
|
590
|
+
const current = (await memory.getState(key, context))?.value;
|
|
591
|
+
next = mergeWorkingMemory(current, value);
|
|
592
|
+
await this.#validateWorkingMemory(next);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
await memory.setState(key, { value: next, updatedAt: new Date() }, context);
|
|
596
|
+
return next;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** Forget a scratchpad. */
|
|
600
|
+
async clearWorkingMemory(ctx: { thread?: string; resource?: string } = {}): Promise<void> {
|
|
601
|
+
const key = this.#workingMemoryKey(ctx);
|
|
602
|
+
if (!key) return;
|
|
603
|
+
const memory = this.#stateStore();
|
|
604
|
+
await memory.clearState?.(key, this.#memoryCtx(ctx.resource));
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
setMemory(memory?: AgentMemory): this {
|
|
608
|
+
this.#config.memory = memory;
|
|
609
|
+
return this;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** What a store needs to know — the client, the tenant, the caller. */
|
|
613
|
+
#memoryCtx(resourceId?: string): AgentMemoryContext {
|
|
614
|
+
return {
|
|
615
|
+
agentId: this.#config.id,
|
|
616
|
+
tenant: this._tenant_,
|
|
617
|
+
rest: this.#rest,
|
|
618
|
+
resourceId,
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/** The stored conversation of a thread (`[]` when there is no memory). */
|
|
623
|
+
async getMessages(thread: string, ctx?: { resource?: string; resourceId?: string }): Promise<AgentMessage[]> {
|
|
624
|
+
if (!this.#config.memory) return [];
|
|
625
|
+
const resource = ctx?.resource ?? ctx?.resourceId;
|
|
626
|
+
return [...(await this.#config.memory.get(thread, this.#memoryCtx(resource)))];
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* The tool the model uses to keep its scratchpad up to date. Template mode
|
|
631
|
+
* replaces the block; schema mode takes a **partial** object (every field
|
|
632
|
+
* optional) and merges it.
|
|
633
|
+
*/
|
|
634
|
+
async #workingMemoryTool(stateKey: string, ctx: AgentMemoryContext): Promise<AgentTool> {
|
|
635
|
+
const config = this.#config.workingMemory!;
|
|
636
|
+
const memory = this.#config.memory!;
|
|
637
|
+
|
|
638
|
+
// Schema mode → patch semantics: what the model omits is left as it is
|
|
639
|
+
let inputSchema: any = {
|
|
640
|
+
type: "object",
|
|
641
|
+
properties: {
|
|
642
|
+
content: {
|
|
643
|
+
type: "string",
|
|
644
|
+
description: "The complete working memory block, as markdown — what should be kept from now on.",
|
|
645
|
+
},
|
|
646
|
+
},
|
|
647
|
+
required: ["content"],
|
|
648
|
+
};
|
|
649
|
+
|
|
650
|
+
if (config.schema) {
|
|
651
|
+
inputSchema = { ...(await toJsonSchema(config.schema)) };
|
|
652
|
+
delete inputSchema.required;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
return {
|
|
656
|
+
id: WORKING_MEMORY_TOOL,
|
|
657
|
+
description: config.schema
|
|
658
|
+
? "Update the working memory with the fields you learned. Send only the fields to change; send null to remove one."
|
|
659
|
+
: "Save the working memory: the profile and the durable facts worth keeping. Send the whole block, updated.",
|
|
660
|
+
inputSchema,
|
|
661
|
+
execute: async (input: any) => {
|
|
662
|
+
const current = (await memory.getState!(stateKey, ctx))?.value;
|
|
663
|
+
const next = config.schema
|
|
664
|
+
? mergeWorkingMemory(current, input)
|
|
665
|
+
: (input?.content ?? "");
|
|
666
|
+
|
|
667
|
+
// A patch that breaks the schema is reported to the model — the run goes on
|
|
668
|
+
if (config.schema) await this.#validateWorkingMemory(next);
|
|
669
|
+
|
|
670
|
+
await memory.setState!(stateKey, { value: next, updatedAt: new Date() }, ctx);
|
|
671
|
+
return { ok: true };
|
|
672
|
+
},
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Add turns to a stored thread **without calling the model**.
|
|
678
|
+
*
|
|
679
|
+
* A store replaces a thread on `save()`, so this reads it, appends and writes
|
|
680
|
+
* it back — the `maxMessages` cap and the caller namespace still apply, and the
|
|
681
|
+
* heavy payloads of an attachment are stored as a note, exactly like a run
|
|
682
|
+
* does. Use it to seed a conversation, to import a history from elsewhere, or
|
|
683
|
+
* to persist what happened outside the agent.
|
|
684
|
+
*
|
|
685
|
+
* ```ts
|
|
686
|
+
* await agent.remember('chat-1', [
|
|
687
|
+
* { role: 'user', content: 'Bonjour' },
|
|
688
|
+
* { role: 'assistant', content: 'Bonjour ! Comment puis-je aider ?' },
|
|
689
|
+
* ]);
|
|
690
|
+
* ```
|
|
691
|
+
*
|
|
692
|
+
* @returns the whole stored thread, as it now is.
|
|
693
|
+
*/
|
|
694
|
+
async remember(
|
|
695
|
+
thread: string,
|
|
696
|
+
messages: AgentInput,
|
|
697
|
+
ctx?: { resource?: string; resourceId?: string },
|
|
698
|
+
): Promise<AgentMessage[]> {
|
|
699
|
+
const memory = this.#config.memory;
|
|
700
|
+
if (!memory) {
|
|
701
|
+
throw new AppError(`Agent '${this.getId() ?? "?"}' declares no memory`, {
|
|
702
|
+
status: 400, code: "AGENT_NO_MEMORY",
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
const context = this.#memoryCtx(ctx?.resource ?? ctx?.resourceId);
|
|
707
|
+
const existing = [...(await memory.get(thread, context))];
|
|
708
|
+
const next = [...existing, ...lightenForMemory(await normalizeMessages(messages))];
|
|
709
|
+
await memory.save(thread, next, context);
|
|
710
|
+
return next;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
async clearMessages(thread: string, ctx?: { resource?: string; resourceId?: string }): Promise<void> {
|
|
714
|
+
const resource = ctx?.resource ?? ctx?.resourceId;
|
|
715
|
+
await this.#config.memory?.clear(thread, this.#memoryCtx(resource));
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Every thread of a caller — only for a store that supports it
|
|
720
|
+
* (`agents.memory.Mongo().list()`); `[]` otherwise.
|
|
721
|
+
*/
|
|
722
|
+
async listThreads(ctx?: { resource?: string; resourceId?: string; limit?: number }): Promise<AgentThread[]> {
|
|
723
|
+
const memory = this.#config.memory;
|
|
724
|
+
if (!memory?.list) return [];
|
|
725
|
+
const resource = ctx?.resource ?? ctx?.resourceId;
|
|
726
|
+
return [...(await memory.list({ ...this.#memoryCtx(resource), limit: ctx?.limit }))];
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/** Forget a thread — or the whole in-process memory when no thread is given. */
|
|
730
|
+
async reset(thread?: string): Promise<void> {
|
|
731
|
+
if (thread) return this.clearMessages(thread);
|
|
732
|
+
this.#config.memory = new InMemoryAgentMemory();
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// ── Runtime wiring (used by the loader) ──────────────────────────────
|
|
736
|
+
|
|
737
|
+
setRest(rest: InstanceType<typeof useRest>): this {
|
|
738
|
+
this.#rest = rest;
|
|
739
|
+
return this;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
getRest(): InstanceType<typeof useRest> | undefined {
|
|
743
|
+
return this.#rest;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/** Serializable view — never exposes the API key. */
|
|
747
|
+
toJSON(): Record<string, any> {
|
|
748
|
+
return {
|
|
749
|
+
id: this.#config.id,
|
|
750
|
+
name: this.getName(),
|
|
751
|
+
description: this.#config.description,
|
|
752
|
+
tenant: this._tenant_,
|
|
753
|
+
provider: { ...this.getProvider(), compatible: this.#config.provider.compatible ?? "openai" },
|
|
754
|
+
instructions: typeof this.#config.instructions === "string" ? this.#config.instructions : "[dynamic]",
|
|
755
|
+
tools: [...this.#tools.keys()],
|
|
756
|
+
maxSteps: this.#config.maxSteps ?? DEFAULT_MAX_STEPS,
|
|
757
|
+
toolChoice: this.#config.toolChoice ?? "auto",
|
|
758
|
+
thinking: this.#config.thinking ?? false,
|
|
759
|
+
memory: !!this.#config.memory,
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// ── Run ──────────────────────────────────────────────────────────────
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* Run the agent to completion and return the final answer.
|
|
767
|
+
* Tool calls are executed in between, until the model answers without
|
|
768
|
+
* asking for a tool (or `maxSteps` is reached).
|
|
769
|
+
*/
|
|
770
|
+
async generate<T = any>(input: AgentInput, options: AgentCallOptions = {}): Promise<AgentGenerateResult<T>> {
|
|
771
|
+
return this.#run<T>({ input, call: options, streaming: false, controller: new AbortController() });
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* Same as `generate()`, but the answer is forced into a typed object and
|
|
776
|
+
* validated with the schema (`AGENT_OUTPUT_INVALID` when it stays invalid).
|
|
777
|
+
*/
|
|
778
|
+
async generateObject<T = any>(input: AgentInput, options: AgentCallOptions = {}): Promise<T> {
|
|
779
|
+
if (!options.schema && !options.structuredOutput) {
|
|
780
|
+
throw new AppError("`generateObject` requires a `schema` (or `structuredOutput`)", {
|
|
781
|
+
status: 500,
|
|
782
|
+
code: "AGENT_SCHEMA_REQUIRED",
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
const result = await this.generate<T>(input, options);
|
|
786
|
+
if (result.object === undefined) {
|
|
787
|
+
throw new AppError(`Agent '${this.getId() ?? "?"}' did not return a valid object`, {
|
|
788
|
+
status: 502,
|
|
789
|
+
code: "AGENT_OUTPUT_INVALID",
|
|
790
|
+
reason: result.text,
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
return result.object;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* Run the agent and stream the answer token by token.
|
|
798
|
+
* The promises of the returned object resolve when the **run** is over
|
|
799
|
+
* (tool rounds included) — `await stream.text` is not the last token.
|
|
800
|
+
*/
|
|
801
|
+
stream<T = any>(input: AgentInput, options: AgentCallOptions = {}): AgentStreamResult<T> {
|
|
802
|
+
const textQueue = new AsyncQueue<string>();
|
|
803
|
+
const fullQueue = new AsyncQueue<AgentStreamChunk>();
|
|
804
|
+
const controller = new AbortController();
|
|
805
|
+
|
|
806
|
+
const emit = (chunk: AgentStreamChunk) => {
|
|
807
|
+
fullQueue.push(chunk);
|
|
808
|
+
if (chunk.type === "text") textQueue.push(chunk.text);
|
|
809
|
+
};
|
|
810
|
+
|
|
811
|
+
const text = deferred<string>();
|
|
812
|
+
const reasoning = deferred<string | undefined>();
|
|
813
|
+
const object = deferred<T | undefined>();
|
|
814
|
+
const toolCalls = deferred<AgentToolCall[]>();
|
|
815
|
+
const toolResults = deferred<AgentToolResultEntry[]>();
|
|
816
|
+
const steps = deferred<AgentStep[]>();
|
|
817
|
+
const usage = deferred<AgentUsage>();
|
|
818
|
+
const finishReason = deferred<string>();
|
|
819
|
+
const messages = deferred<AgentMessage[]>();
|
|
820
|
+
|
|
821
|
+
const deferreds = [text, reasoning, object, toolCalls, toolResults, steps, usage, finishReason, messages];
|
|
822
|
+
|
|
823
|
+
// The caller may only consume `textStream` — a rejected promise nobody
|
|
824
|
+
// awaits must not become an unhandled rejection.
|
|
825
|
+
for (const pending of deferreds) {
|
|
826
|
+
pending.promise.catch(() => {});
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
void (async () => {
|
|
830
|
+
try {
|
|
831
|
+
const result = await this.#run<T>({ input, call: options, streaming: true, controller, emit });
|
|
832
|
+
text.resolve(result.text);
|
|
833
|
+
reasoning.resolve(result.reasoning);
|
|
834
|
+
object.resolve(result.object);
|
|
835
|
+
toolCalls.resolve(result.toolCalls);
|
|
836
|
+
toolResults.resolve(result.toolResults);
|
|
837
|
+
steps.resolve(result.steps);
|
|
838
|
+
usage.resolve(result.usage);
|
|
839
|
+
finishReason.resolve(result.finishReason);
|
|
840
|
+
messages.resolve(result.messages);
|
|
841
|
+
emit({ type: "finish", finishReason: result.finishReason, usage: result.usage });
|
|
842
|
+
textQueue.close();
|
|
843
|
+
fullQueue.close();
|
|
844
|
+
} catch (err) {
|
|
845
|
+
for (const pending of deferreds) pending.reject(err);
|
|
846
|
+
textQueue.fail(err);
|
|
847
|
+
fullQueue.fail(err);
|
|
848
|
+
}
|
|
849
|
+
})();
|
|
850
|
+
|
|
851
|
+
return {
|
|
852
|
+
textStream: textQueue,
|
|
853
|
+
fullStream: fullQueue,
|
|
854
|
+
text: text.promise,
|
|
855
|
+
/** Everything the model thought, when `thinking` was on — like `generate()`. */
|
|
856
|
+
reasoning: reasoning.promise,
|
|
857
|
+
object: object.promise,
|
|
858
|
+
toolCalls: toolCalls.promise,
|
|
859
|
+
toolResults: toolResults.promise,
|
|
860
|
+
steps: steps.promise,
|
|
861
|
+
usage: usage.promise,
|
|
862
|
+
finishReason: finishReason.promise,
|
|
863
|
+
messages: messages.promise,
|
|
864
|
+
abort: (reason?: string) => controller.abort(reason),
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// ── Internals ────────────────────────────────────────────────────────
|
|
869
|
+
|
|
870
|
+
/** Provider merged with the call-level override — `options` merged too, not replaced. */
|
|
871
|
+
#provider(override?: Partial<AgentProvider>): AgentProvider {
|
|
872
|
+
const base = this.#config.provider;
|
|
873
|
+
if (!override) return base;
|
|
874
|
+
return {
|
|
875
|
+
...base,
|
|
876
|
+
...override,
|
|
877
|
+
options: { ...base.options, ...override.options },
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* The tools of one run — `true` (all), `false` (none), an array of **names**
|
|
883
|
+
* (an allow-list) or extra tools to merge in.
|
|
884
|
+
*/
|
|
885
|
+
#collectTools(requested?: boolean | string[] | AgentTools): Map<string, AgentTool> {
|
|
886
|
+
// `tools: false` — the run answers directly
|
|
887
|
+
if (requested === false) return new Map();
|
|
888
|
+
|
|
889
|
+
const tools = new Map(this.#tools);
|
|
890
|
+
|
|
891
|
+
// `tools: ['forecast', 'echo']` — only these (an unknown name is ignored:
|
|
892
|
+
// tool availability may legitimately vary between callers)
|
|
893
|
+
if (Array.isArray(requested) && requested.every((entry) => typeof entry === "string")) {
|
|
894
|
+
const wanted = new Set(requested as string[]);
|
|
895
|
+
for (const name of [...tools.keys()]) {
|
|
896
|
+
if (!wanted.has(name)) tools.delete(name);
|
|
897
|
+
}
|
|
898
|
+
return tools;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// `tools: true` — everything the agent declares; a record/array of tools merges in
|
|
902
|
+
if (requested && requested !== true) {
|
|
903
|
+
for (const [name, tool] of toolEntries(requested as AgentTools)) {
|
|
904
|
+
requireHandler(name, tool);
|
|
905
|
+
tools.set(name, tool);
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
return tools;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
async #toolSpecs(tools: Map<string, AgentTool>): Promise<AgentToolSpec[]> {
|
|
913
|
+
const specs: AgentToolSpec[] = [];
|
|
914
|
+
for (const [name, tool] of tools) {
|
|
915
|
+
if (tool.enabled === false) continue;
|
|
916
|
+
specs.push({
|
|
917
|
+
name,
|
|
918
|
+
description: tool.description,
|
|
919
|
+
parameters: await toJsonSchema(tool.inputSchema),
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
return specs;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/** One model call, without streaming. */
|
|
926
|
+
async #chat(adapter: ModelAdapter, request: ModelChatRequest): Promise<{
|
|
927
|
+
text: string;
|
|
928
|
+
reasoning?: string;
|
|
929
|
+
thinking?: AgentThinkingBlock[];
|
|
930
|
+
toolCalls: AgentToolCall[];
|
|
931
|
+
finishReason: string;
|
|
932
|
+
usage: AgentUsage;
|
|
933
|
+
}> {
|
|
934
|
+
const response = await adapter.chat(request);
|
|
935
|
+
return {
|
|
936
|
+
text: response.text,
|
|
937
|
+
...(response.reasoning ? { reasoning: response.reasoning } : {}),
|
|
938
|
+
...(response.thinking?.length ? { thinking: response.thinking } : {}),
|
|
939
|
+
toolCalls: response.toolCalls.map(toToolCall),
|
|
940
|
+
finishReason: response.finishReason,
|
|
941
|
+
usage: response.usage,
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/** One model call, streaming — the deltas are reassembled into a full turn. */
|
|
946
|
+
async #chatStream(adapter: ModelAdapter, request: ModelChatRequest, emit: (chunk: AgentStreamChunk) => void): Promise<{
|
|
947
|
+
text: string;
|
|
948
|
+
reasoning?: string;
|
|
949
|
+
thinking?: AgentThinkingBlock[];
|
|
950
|
+
toolCalls: AgentToolCall[];
|
|
951
|
+
finishReason: string;
|
|
952
|
+
usage: AgentUsage;
|
|
953
|
+
}> {
|
|
954
|
+
const partial = new Map<number, { id?: string; name?: string; argsText?: string }>();
|
|
955
|
+
let text = "";
|
|
956
|
+
let reasoning = "";
|
|
957
|
+
let thinking: AgentThinkingBlock[] | undefined;
|
|
958
|
+
let finishReason = "stop";
|
|
959
|
+
let usage: Partial<AgentUsage> = {};
|
|
960
|
+
|
|
961
|
+
for await (const event of adapter.chatStream(request)) {
|
|
962
|
+
switch (event.type) {
|
|
963
|
+
case "text":
|
|
964
|
+
text += event.text;
|
|
965
|
+
emit({ type: "text", text: event.text });
|
|
966
|
+
break;
|
|
967
|
+
case "reasoning":
|
|
968
|
+
reasoning += event.text;
|
|
969
|
+
emit({ type: "reasoning", text: event.text });
|
|
970
|
+
break;
|
|
971
|
+
case "thinking_blocks":
|
|
972
|
+
thinking = [...(thinking ?? []), ...event.blocks];
|
|
973
|
+
break;
|
|
974
|
+
case "tool_call": {
|
|
975
|
+
const current = partial.get(event.index) ?? {};
|
|
976
|
+
if (event.id) current.id = event.id;
|
|
977
|
+
if (event.name) current.name = event.name;
|
|
978
|
+
if (event.argsText) current.argsText = (current.argsText ?? "") + event.argsText;
|
|
979
|
+
partial.set(event.index, current);
|
|
980
|
+
break;
|
|
981
|
+
}
|
|
982
|
+
case "usage":
|
|
983
|
+
usage = {
|
|
984
|
+
inputTokens: event.usage.inputTokens ?? usage.inputTokens,
|
|
985
|
+
outputTokens: event.usage.outputTokens ?? usage.outputTokens,
|
|
986
|
+
};
|
|
987
|
+
break;
|
|
988
|
+
case "finish":
|
|
989
|
+
finishReason = event.finishReason;
|
|
990
|
+
break;
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
const toolCalls = [...partial.entries()]
|
|
995
|
+
.sort((a, b) => a[0] - b[0])
|
|
996
|
+
.map(([index, call]) => toToolCall({
|
|
997
|
+
id: call.id ?? `call_${index}`,
|
|
998
|
+
name: call.name ?? "unknown",
|
|
999
|
+
argsText: call.argsText ?? "{}",
|
|
1000
|
+
}));
|
|
1001
|
+
|
|
1002
|
+
return {
|
|
1003
|
+
text,
|
|
1004
|
+
...(reasoning ? { reasoning } : {}),
|
|
1005
|
+
...(thinking?.length ? { thinking } : {}),
|
|
1006
|
+
toolCalls,
|
|
1007
|
+
finishReason,
|
|
1008
|
+
usage: {
|
|
1009
|
+
inputTokens: usage.inputTokens ?? 0,
|
|
1010
|
+
outputTokens: usage.outputTokens ?? 0,
|
|
1011
|
+
totalTokens: (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0),
|
|
1012
|
+
requests: 1,
|
|
1013
|
+
},
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
async #executeTool(
|
|
1018
|
+
call: AgentToolCall,
|
|
1019
|
+
tools: Map<string, AgentTool>,
|
|
1020
|
+
step: number,
|
|
1021
|
+
signal: AbortSignal,
|
|
1022
|
+
): Promise<AgentToolResultEntry> {
|
|
1023
|
+
const started = performance.now();
|
|
1024
|
+
const entry: AgentToolResultEntry = { id: call.id, name: call.name, args: call.args, durationMs: 0 };
|
|
1025
|
+
const done = (): AgentToolResultEntry => {
|
|
1026
|
+
entry.durationMs = Math.round((performance.now() - started) * 100) / 100;
|
|
1027
|
+
return entry;
|
|
1028
|
+
};
|
|
1029
|
+
|
|
1030
|
+
const tool = tools.get(call.name);
|
|
1031
|
+
if (!tool) {
|
|
1032
|
+
entry.error = `Unknown tool '${call.name}'`;
|
|
1033
|
+
return done();
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
const { value, error } = await validateWithSchema(tool.inputSchema, call.args);
|
|
1037
|
+
if (error) {
|
|
1038
|
+
entry.error = `Invalid arguments for tool '${call.name}': ${error}`;
|
|
1039
|
+
return done();
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
const ctx: AgentToolContext = {
|
|
1043
|
+
rest: this.#rest,
|
|
1044
|
+
agent: this,
|
|
1045
|
+
tenant: this._tenant_,
|
|
1046
|
+
toolCallId: call.id,
|
|
1047
|
+
step,
|
|
1048
|
+
signal,
|
|
1049
|
+
error: fn.error,
|
|
1050
|
+
};
|
|
1051
|
+
|
|
1052
|
+
try {
|
|
1053
|
+
entry.result = tool.execute
|
|
1054
|
+
? await tool.execute(value, ctx)
|
|
1055
|
+
// MCP tools take the framework's own context shape
|
|
1056
|
+
: await tool.exec!({ ...ctx, args: value, c: (this.#rest as any)?.c });
|
|
1057
|
+
return done();
|
|
1058
|
+
} catch (err: any) {
|
|
1059
|
+
entry.error = err?.message ?? String(err);
|
|
1060
|
+
return done();
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
async #run<T>(run: RunOptions): Promise<AgentGenerateResult<T>> {
|
|
1065
|
+
const { input, call, streaming, controller } = run;
|
|
1066
|
+
const emit = run.emit;
|
|
1067
|
+
|
|
1068
|
+
const tools = this.#collectTools(call.tools);
|
|
1069
|
+
const provider = this.#provider(call.provider);
|
|
1070
|
+
const adapter = resolveProvider(provider);
|
|
1071
|
+
|
|
1072
|
+
const structured = call.structuredOutput ?? (call.schema ? { schema: call.schema } : undefined);
|
|
1073
|
+
const schema = structured?.schema;
|
|
1074
|
+
const jsonSchema = schema ? await toJsonSchema(schema) : undefined;
|
|
1075
|
+
|
|
1076
|
+
// `memory: { thread, resource }` (the Mastra-style shorthand) wins over the
|
|
1077
|
+
// flat `thread` / `resource` options — both name the same thing, and the
|
|
1078
|
+
// `…Id` spellings are still read as aliases.
|
|
1079
|
+
const thread = call.memory?.thread ?? call.memory?.threadId ?? call.thread ?? call.threadId;
|
|
1080
|
+
const resource = call.memory?.resource ?? call.memory?.resourceId ?? call.resource ?? call.resourceId;
|
|
1081
|
+
const signal = call.signal ? AbortSignal.any([call.signal, controller.signal]) : controller.signal;
|
|
1082
|
+
|
|
1083
|
+
const history = thread && this.#config.memory
|
|
1084
|
+
? [...(await this.#config.memory.get(thread, this.#memoryCtx(resource)))]
|
|
1085
|
+
: [];
|
|
1086
|
+
|
|
1087
|
+
// What the caller wants the run to **know**: injected after the thread
|
|
1088
|
+
// history and before the question, and saved with the rest of the
|
|
1089
|
+
// conversation. A trailing assistant turn is legitimate ("continue from
|
|
1090
|
+
// here"), so the block is not forced to end on a user turn.
|
|
1091
|
+
const injected = call.messages ? await normalizeMessages(call.messages) : [];
|
|
1092
|
+
|
|
1093
|
+
// What this run **adds to the thread** — the injected turns and the input,
|
|
1094
|
+
// with the attachments on the turn being asked. The history window and the
|
|
1095
|
+
// `load` processors only shape the prompt: neither reaches the store.
|
|
1096
|
+
const additions: AgentMessage[] = [...injected, ...(await normalizeMessages(input))];
|
|
1097
|
+
|
|
1098
|
+
// Attachments of this run — read now, attached to the turn being asked
|
|
1099
|
+
if (call.files) {
|
|
1100
|
+
const parts = await resolveAttachments(call.files, { baseDir: call.filesBaseDir });
|
|
1101
|
+
if (parts.length) attachToTurn(additions, parts);
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
// The history window: the thread is what the store keeps, the window is what
|
|
1105
|
+
// the prompt replays (a cheap bound on the prompt)
|
|
1106
|
+
const window = call.lastMessages ?? this.#config.lastMessages;
|
|
1107
|
+
const replay = window && window > 0 ? trimMessages(history, window) : history;
|
|
1108
|
+
|
|
1109
|
+
const memoryCtx = this.#memoryCtx(resource);
|
|
1110
|
+
let messages: AgentMessage[] = [...replay, ...additions];
|
|
1111
|
+
|
|
1112
|
+
// Memory processors, `load` side: they see exactly what the model will see
|
|
1113
|
+
// (history, injected turns, input and attachments included)
|
|
1114
|
+
messages = await applyProcessors(this.#config.processors, "load", messages, memoryCtx);
|
|
1115
|
+
|
|
1116
|
+
let system = call.instructions ?? await this.resolveInstructions({ thread, resource });
|
|
1117
|
+
|
|
1118
|
+
// The scratchpad is part of the prompt, right after the instructions
|
|
1119
|
+
const stateKey = this.#workingMemoryKey({ thread, resource });
|
|
1120
|
+
const working = this.#config.workingMemory;
|
|
1121
|
+
if (working && stateKey && typeof this.#config.memory?.getState === "function") {
|
|
1122
|
+
const state = await this.#config.memory.getState(stateKey, memoryCtx);
|
|
1123
|
+
system += `\n\n${workingMemoryBlock(working, state?.value)}`;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
if (jsonSchema) system += structuredSuffix(jsonSchema);
|
|
1127
|
+
|
|
1128
|
+
const maxSteps = call.maxSteps ?? this.#config.maxSteps ?? DEFAULT_MAX_STEPS;
|
|
1129
|
+
// Reasoning defaults to the declaration, like `toolChoice` — a per-call
|
|
1130
|
+
// `thinking` wins, and `false` turns it off for one run
|
|
1131
|
+
const thinking = call.thinking ?? this.#config.thinking;
|
|
1132
|
+
const steps: AgentStep[] = [];
|
|
1133
|
+
const allToolCalls: AgentToolCall[] = [];
|
|
1134
|
+
const allToolResults: AgentToolResultEntry[] = [];
|
|
1135
|
+
let usage: AgentUsage = { ...EMPTY_USAGE };
|
|
1136
|
+
let text = "";
|
|
1137
|
+
let reasoning = "";
|
|
1138
|
+
let finishReason = "stop";
|
|
1139
|
+
|
|
1140
|
+
// What the model may use, and how — the framework picks no tool itself
|
|
1141
|
+
const toolChoice = call.toolChoice ?? this.#config.toolChoice ?? "auto";
|
|
1142
|
+
|
|
1143
|
+
// The working-memory tool is part of the run's toolbox: it disappears with
|
|
1144
|
+
// `tools: false` and with an allow-list, and a tool the tenant declared under
|
|
1145
|
+
// the same id wins (nothing is overridden behind their back)
|
|
1146
|
+
const narrowed = Array.isArray(call.tools) || call.tools === false;
|
|
1147
|
+
if (working && stateKey && working.tool !== false && !call.memory?.readOnly && !narrowed && !tools.has(WORKING_MEMORY_TOOL)) {
|
|
1148
|
+
tools.set(WORKING_MEMORY_TOOL, await this.#workingMemoryTool(stateKey, memoryCtx));
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
const specs = await this.#toolSpecs(tools);
|
|
1152
|
+
const offeredIds = [...tools.keys()];
|
|
1153
|
+
|
|
1154
|
+
try {
|
|
1155
|
+
for (let step = 0; step < maxSteps; step++) {
|
|
1156
|
+
if (signal.aborted) {
|
|
1157
|
+
finishReason = "aborted";
|
|
1158
|
+
break;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
const request: ModelChatRequest = {
|
|
1162
|
+
system,
|
|
1163
|
+
messages,
|
|
1164
|
+
tools: specs.length ? specs : undefined,
|
|
1165
|
+
toolChoice,
|
|
1166
|
+
thinking,
|
|
1167
|
+
signal,
|
|
1168
|
+
json: !!schema,
|
|
1169
|
+
};
|
|
1170
|
+
|
|
1171
|
+
const turn = streaming && emit
|
|
1172
|
+
? await this.#chatStream(adapter, request, emit)
|
|
1173
|
+
: await this.#chat(adapter, request);
|
|
1174
|
+
|
|
1175
|
+
usage = addUsage(usage, turn.usage);
|
|
1176
|
+
text = turn.text;
|
|
1177
|
+
if (turn.reasoning) reasoning = [reasoning, turn.reasoning].filter(Boolean).join("\n\n");
|
|
1178
|
+
|
|
1179
|
+
const current: AgentStep = {
|
|
1180
|
+
step,
|
|
1181
|
+
text: turn.text,
|
|
1182
|
+
...(turn.reasoning ? { reasoning: turn.reasoning } : {}),
|
|
1183
|
+
...(offeredIds.length ? { tools: offeredIds } : {}),
|
|
1184
|
+
toolCalls: turn.toolCalls,
|
|
1185
|
+
toolResults: [],
|
|
1186
|
+
finishReason: turn.finishReason,
|
|
1187
|
+
};
|
|
1188
|
+
|
|
1189
|
+
// The assistant turn is always echoed into the conversation — it
|
|
1190
|
+
// carries the tool call ids the results are attached to, and it is
|
|
1191
|
+
// what a memory replays on the next call.
|
|
1192
|
+
const assistantTurn: AgentMessage = {
|
|
1193
|
+
role: "assistant",
|
|
1194
|
+
content: turn.text || null,
|
|
1195
|
+
...(turn.toolCalls.length ? { toolCalls: turn.toolCalls } : {}),
|
|
1196
|
+
...(turn.thinking?.length ? { thinking: turn.thinking } : {}),
|
|
1197
|
+
};
|
|
1198
|
+
messages.push(assistantTurn);
|
|
1199
|
+
additions.push({ ...assistantTurn });
|
|
1200
|
+
allToolCalls.push(...turn.toolCalls);
|
|
1201
|
+
|
|
1202
|
+
if (!turn.toolCalls.length) {
|
|
1203
|
+
finishReason = turn.finishReason;
|
|
1204
|
+
steps.push(current);
|
|
1205
|
+
await call.onStepFinish?.(current);
|
|
1206
|
+
break;
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
for (const toolCall of turn.toolCalls) {
|
|
1210
|
+
const result = await this.#executeTool(toolCall, tools, step, signal);
|
|
1211
|
+
current.toolResults.push(result);
|
|
1212
|
+
allToolResults.push(result);
|
|
1213
|
+
const toolTurn: AgentMessage = {
|
|
1214
|
+
role: "tool",
|
|
1215
|
+
toolCallId: toolCall.id,
|
|
1216
|
+
name: toolCall.name,
|
|
1217
|
+
content: serializeToolResult(result),
|
|
1218
|
+
isError: !!result.error,
|
|
1219
|
+
};
|
|
1220
|
+
messages.push(toolTurn);
|
|
1221
|
+
additions.push({ ...toolTurn });
|
|
1222
|
+
emit?.({ type: "tool_result", toolResult: result });
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
steps.push(current);
|
|
1226
|
+
await call.onStepFinish?.(current);
|
|
1227
|
+
emit?.({ type: "step", step: current });
|
|
1228
|
+
finishReason = turn.finishReason;
|
|
1229
|
+
if (step === maxSteps - 1) finishReason = "max_steps";
|
|
1230
|
+
}
|
|
1231
|
+
} catch (err: any) {
|
|
1232
|
+
if (signal.aborted) {
|
|
1233
|
+
finishReason = "aborted";
|
|
1234
|
+
} else {
|
|
1235
|
+
throw err;
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
// Structured output — one repair turn, then give up with a stable code.
|
|
1240
|
+
let object: T | undefined;
|
|
1241
|
+
if (schema) {
|
|
1242
|
+
const attempt = extractJson(text);
|
|
1243
|
+
let candidate: any = attempt.value;
|
|
1244
|
+
let failure = attempt.error;
|
|
1245
|
+
if (!failure) {
|
|
1246
|
+
const validated = await validateWithSchema(schema, candidate);
|
|
1247
|
+
if (validated.error) failure = validated.error;
|
|
1248
|
+
else candidate = validated.value;
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
if (failure && text.trim()) {
|
|
1252
|
+
// One repair turn — `messages` already ends with the rejected answer.
|
|
1253
|
+
const repair = [...messages, {
|
|
1254
|
+
role: "user" as const,
|
|
1255
|
+
content: `That answer was rejected: ${failure}. Reply with a single valid JSON object matching the schema — no markdown, no commentary.`,
|
|
1256
|
+
}];
|
|
1257
|
+
const turn = await this.#chat(adapter, {
|
|
1258
|
+
system,
|
|
1259
|
+
messages: repair,
|
|
1260
|
+
temperature: 0,
|
|
1261
|
+
signal,
|
|
1262
|
+
json: true,
|
|
1263
|
+
});
|
|
1264
|
+
|
|
1265
|
+
usage = addUsage(usage, turn.usage);
|
|
1266
|
+
text = turn.text;
|
|
1267
|
+
messages.push({ role: "assistant", content: text });
|
|
1268
|
+
additions.push({ role: "assistant", content: text });
|
|
1269
|
+
|
|
1270
|
+
const retry = extractJson(text);
|
|
1271
|
+
failure = retry.error;
|
|
1272
|
+
if (!failure) {
|
|
1273
|
+
const validated = await validateWithSchema(schema, retry.value);
|
|
1274
|
+
if (validated.error) failure = validated.error;
|
|
1275
|
+
else candidate = validated.value;
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
if (failure) {
|
|
1280
|
+
throw new AppError(`Agent '${this.#config.id ?? "?"}' did not return valid structured output: ${failure}`, {
|
|
1281
|
+
status: 502,
|
|
1282
|
+
code: "AGENT_OUTPUT_INVALID",
|
|
1283
|
+
reason: text,
|
|
1284
|
+
});
|
|
1285
|
+
}
|
|
1286
|
+
object = candidate as T;
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
if (thread && this.#config.memory && !call.memory?.readOnly) {
|
|
1290
|
+
try {
|
|
1291
|
+
// `save` processors run last: they see what the run produced, and a
|
|
1292
|
+
// throw keeps the whole conversation out of the store (a guardrail)
|
|
1293
|
+
const stored = await applyProcessors(
|
|
1294
|
+
this.#config.processors,
|
|
1295
|
+
"save",
|
|
1296
|
+
[...history, ...additions], // the whole thread — never the window
|
|
1297
|
+
memoryCtx,
|
|
1298
|
+
);
|
|
1299
|
+
await this.#config.memory.save(thread, lightenForMemory(stored), memoryCtx);
|
|
1300
|
+
} catch (err: any) {
|
|
1301
|
+
console.error(`The conversation of thread '${thread}' was not saved: ${err?.message}`);
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
return {
|
|
1306
|
+
text,
|
|
1307
|
+
...(object !== undefined ? { object } : {}),
|
|
1308
|
+
...(reasoning ? { reasoning } : {}),
|
|
1309
|
+
toolCalls: allToolCalls,
|
|
1310
|
+
toolResults: allToolResults,
|
|
1311
|
+
steps,
|
|
1312
|
+
usage,
|
|
1313
|
+
finishReason,
|
|
1314
|
+
messages: [...history, ...additions],
|
|
1315
|
+
};
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
/**
|
|
1320
|
+
* Declares an agent — the object is the config; the loader instantiates it.
|
|
1321
|
+
* (Also available as `define.Agent`.)
|
|
1322
|
+
*/
|
|
1323
|
+
function agent(config: AgentConfig): AgentConfig & { _isAgent_: true } {
|
|
1324
|
+
return { ...config, enabled: config.enabled ?? true, _isAgent_: true };
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
export { Agent, agent };
|