@anteros/core 0.0.41 → 0.0.44
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 +724 -58
- package/database/ttl.ts +68 -0
- package/database/workflow.ts +466 -240
- package/index.ts +6 -0
- package/lib/agent.ts +1567 -0
- package/lib/agentMemory.ts +689 -0
- package/lib/agents.ts +180 -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/mcpClient.ts +128 -0
- package/lib/messages.ts +299 -0
- package/lib/providers.ts +970 -0
- package/lib/routes.ts +14 -1
- package/lib/scriptWorker.ts +4 -1
- package/lib/scripts.ts +6 -1
- package/lib/skills.ts +481 -0
- 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 +472 -0
- package/server/api.ts +80 -65
- 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-mcp.test.ts +116 -0
- package/tests/agent-memory.test.ts +617 -0
- package/tests/agent-provider.test.ts +288 -0
- package/tests/agent-runtime.test.ts +369 -0
- package/tests/agent-skills.test.ts +177 -0
- package/tests/agent-summarize.test.ts +124 -0
- package/tests/agent-working-memory.test.ts +404 -0
- package/tests/agent.test.ts +1241 -0
- package/tests/agents-api.test.ts +593 -0
- package/tests/api-private-fields.test.ts +196 -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/file-collection-replication.test.ts +2 -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/repl-only/app.ts +3 -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/skills-tenant/agents/docs.agent.ts +23 -0
- package/tests/fixtures/skills-tenant/agents/incomplete.agent.ts +14 -0
- package/tests/fixtures/skills-tenant/broken/bad-name.md +6 -0
- package/tests/fixtures/skills-tenant/broken/dup-a.md +6 -0
- package/tests/fixtures/skills-tenant/broken/dup-b.md +6 -0
- package/tests/fixtures/skills-tenant/broken/empty-body.md +4 -0
- package/tests/fixtures/skills-tenant/broken/long-compat.md +7 -0
- package/tests/fixtures/skills-tenant/broken/long-description.md +6 -0
- package/tests/fixtures/skills-tenant/broken/mismatch/SKILL.md +6 -0
- package/tests/fixtures/skills-tenant/broken/no-description.md +5 -0
- package/tests/fixtures/skills-tenant/broken/no-name.md +5 -0
- package/tests/fixtures/skills-tenant/broken/plain.md +4 -0
- package/tests/fixtures/skills-tenant/broken/unknown-key.md +9 -0
- package/tests/fixtures/skills-tenant/docs/pdf-forms.md +18 -0
- package/tests/fixtures/skills-tenant/guide/ml.md +9 -0
- package/tests/fixtures/skills-tenant/guides/pdf/SKILL.md +9 -0
- package/tests/fixtures/skills-tenant/runbooks/incident.md +9 -0
- package/tests/fixtures/skills-tenant/runbooks/notes.txt +2 -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-change-stream.test.ts +240 -0
- package/tests/replication-control.test.ts +4 -2
- package/tests/replication-initial-sync.test.ts +2 -0
- package/tests/replication-meta.test.ts +246 -0
- package/tests/replication.test.ts +2 -0
- package/tests/upload.test.ts +44 -12
- package/tests/vars-api.test.ts +1 -1
- package/tests/vars-replication.test.ts +5 -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 +1067 -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 +70 -4
- 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/server/agents.ts
ADDED
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
import type { Hono } from "hono";
|
|
2
|
+
import { cfg } from "./config";
|
|
3
|
+
import { AppError } from "../lib/error";
|
|
4
|
+
import { useRest } from "../database/rest";
|
|
5
|
+
import { createAgents } from "../lib/agents";
|
|
6
|
+
import { getTenant } from "../database/tenant";
|
|
7
|
+
import { requestCtxStorage } from "../lib/asyncContextStorage";
|
|
8
|
+
import { evaluateAccess, errorResponse } from "./access";
|
|
9
|
+
import { patterns } from "../lib/endpoints";
|
|
10
|
+
import type { HonoVariables } from "./env";
|
|
11
|
+
import type { Agent } from "../lib/agent";
|
|
12
|
+
import type {
|
|
13
|
+
AgentCallOptions,
|
|
14
|
+
AgentGenerateResult,
|
|
15
|
+
AgentInput,
|
|
16
|
+
AgentMessageInput,
|
|
17
|
+
AgentResume,
|
|
18
|
+
} from "../types/agent";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Agents HTTP API — `POST /api/:tenant_id/agents/:agent/:action`.
|
|
22
|
+
*
|
|
23
|
+
* Same shape as the services API (`/api/:tenant_id/services/:service/:action`) and
|
|
24
|
+
* the same access model (`api.access`, **no rules = denied**).
|
|
25
|
+
*
|
|
26
|
+
* ```ts
|
|
27
|
+
* // define.Agent({ api: { access: { generate: (ctx) => !!ctx.token.value } } })
|
|
28
|
+
* POST /api/v1/agents/support/generate { "input": "Where is my order?" }
|
|
29
|
+
* POST /api/v1/agents/support/stream { "input": "…", "thread": "user-42" } → SSE
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* A client can only send **what it is allowed to choose**: the input, a memory
|
|
33
|
+
* thread, injected turns and a *lower* `maxSteps` / `lastMessages`. The provider
|
|
34
|
+
* (model, endpoint, key), the options and the structured-output schema stay
|
|
35
|
+
* server-side — changing the model or the endpoint of a declared agent is a
|
|
36
|
+
* deployment decision, not a request one.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
const AGENT_PREFIX = patterns.agent;
|
|
40
|
+
|
|
41
|
+
/** What an HTTP call may carry. Nothing else is read. */
|
|
42
|
+
type AgentRequestBody = {
|
|
43
|
+
/** A string (one user turn), one message, or a whole conversation. */
|
|
44
|
+
input?: string | AgentMessageInput | AgentMessageInput[];
|
|
45
|
+
/**
|
|
46
|
+
* Turns **injected** into the run — after the thread, before the `input`. They
|
|
47
|
+
* are sent to the model *and* saved with the rest of the conversation.
|
|
48
|
+
*/
|
|
49
|
+
messages?: AgentMessageInput | AgentMessageInput[];
|
|
50
|
+
/** Memory thread. */
|
|
51
|
+
thread?: string;
|
|
52
|
+
/** @deprecated Alias of `thread`. */
|
|
53
|
+
threadId?: string;
|
|
54
|
+
/** Opaque caller identity, forwarded to the instructions function. */
|
|
55
|
+
resource?: string;
|
|
56
|
+
/** @deprecated Alias of `resource`. */
|
|
57
|
+
resourceId?: string;
|
|
58
|
+
/** Lower the run's step ceiling (never raise it). */
|
|
59
|
+
maxSteps?: number;
|
|
60
|
+
/** Lower the replayed-history window (never raise it past the declaration). */
|
|
61
|
+
lastMessages?: number;
|
|
62
|
+
/** `threads` — how many threads to return (default 20). */
|
|
63
|
+
limit?: number;
|
|
64
|
+
/**
|
|
65
|
+
* Files and images sent with the run — base64, a `File`/`Blob` never crosses the
|
|
66
|
+
* wire as such. `{ name: 'invoice.pdf', data: '<base64>' }`.
|
|
67
|
+
*/
|
|
68
|
+
files?: Array<{
|
|
69
|
+
/** File name — its extension decides how it is read (`report.pdf`, `data.csv`, `photo.png`). */
|
|
70
|
+
name: string;
|
|
71
|
+
mimeType?: string;
|
|
72
|
+
/** Base64 (default) or UTF-8 text with `encoding: 'utf8'`. */
|
|
73
|
+
data: string;
|
|
74
|
+
encoding?: "base64" | "utf8";
|
|
75
|
+
}>;
|
|
76
|
+
/** Read the thread without writing the run back. */
|
|
77
|
+
readOnly?: boolean;
|
|
78
|
+
/**
|
|
79
|
+
* Continue a run that paused for an approval — the `resume` payload `generate`
|
|
80
|
+
* handed back (`{ messages, decisions }`). `input` is then not needed.
|
|
81
|
+
*/
|
|
82
|
+
resume?: AgentResume;
|
|
83
|
+
/** Mastra-style shorthand — `memory: { thread, resource, readOnly }`. */
|
|
84
|
+
memory?: {
|
|
85
|
+
thread?: string;
|
|
86
|
+
/** @deprecated Alias of `thread`. */
|
|
87
|
+
threadId?: string;
|
|
88
|
+
resource?: string;
|
|
89
|
+
/** @deprecated Alias of `resource`. */
|
|
90
|
+
resourceId?: string;
|
|
91
|
+
readOnly?: boolean;
|
|
92
|
+
};
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** `memory.thread` first, then the flat `thread` (and its `threadId` alias). */
|
|
96
|
+
function resolveThread(body: AgentRequestBody | undefined): string | undefined {
|
|
97
|
+
const thread = body?.memory?.thread ?? body?.memory?.threadId ?? body?.thread ?? body?.threadId;
|
|
98
|
+
return typeof thread === 'string' && thread.trim() ? thread : undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** `memory.resource` first, then the flat `resource` (and its `resourceId` alias). */
|
|
102
|
+
function resolveResource(body: AgentRequestBody | undefined): string | undefined {
|
|
103
|
+
const resource = body?.memory?.resource ?? body?.memory?.resourceId ?? body?.resource ?? body?.resourceId;
|
|
104
|
+
return typeof resource === 'string' && resource.trim() ? resource : undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Cap on the prompt kept in the audit trail. */
|
|
108
|
+
const AUDIT_INPUT_MAX = 2000;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The input of a run — a string, **one** message, or a whole conversation. The
|
|
112
|
+
* shape itself (roles, content parts, tool parts) is validated by
|
|
113
|
+
* `normalizeMessages`, shared with the in-process runtime: the two cannot drift.
|
|
114
|
+
*/
|
|
115
|
+
function normalizeInput(body: AgentRequestBody | undefined): AgentInput {
|
|
116
|
+
const input = body?.input;
|
|
117
|
+
if (typeof input === "string") {
|
|
118
|
+
if (!input.trim()) throw new AppError('`input` is required', { status: 400, code: 'INPUT_REQUIRED' });
|
|
119
|
+
return input;
|
|
120
|
+
}
|
|
121
|
+
if (Array.isArray(input)) {
|
|
122
|
+
if (!input.length) throw new AppError('`input` is required', { status: 400, code: 'INPUT_REQUIRED' });
|
|
123
|
+
return input as AgentInput;
|
|
124
|
+
}
|
|
125
|
+
if (input && typeof input === "object" && typeof (input as any).role === "string") {
|
|
126
|
+
return input as AgentInput;
|
|
127
|
+
}
|
|
128
|
+
throw new AppError('`input` is required (a string, a message or an array of messages)', {
|
|
129
|
+
status: 400, code: 'INPUT_REQUIRED',
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function requireThread(body: AgentRequestBody | undefined): string {
|
|
134
|
+
const thread = resolveThread(body);
|
|
135
|
+
if (!thread) {
|
|
136
|
+
throw new AppError('`thread` is required (or `memory.thread`)', { status: 400, code: 'THREAD_ID_REQUIRED' });
|
|
137
|
+
}
|
|
138
|
+
return thread;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** A client may lower the step ceiling, never raise it — the cost is the tenant's. */
|
|
142
|
+
function resolveMaxSteps(requested: unknown, agent: Agent): number {
|
|
143
|
+
const declared = agent.getConfig().maxSteps ?? 5;
|
|
144
|
+
const asked = Number(requested);
|
|
145
|
+
if (!Number.isFinite(asked) || asked <= 0) return declared;
|
|
146
|
+
return Math.max(1, Math.min(Math.floor(asked), declared));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The history window of a request: a client may shrink the prompt, never grow it
|
|
151
|
+
* past what the agent declares (with no declaration, the store cap is the only
|
|
152
|
+
* bound that matters — and it is already applied on read).
|
|
153
|
+
*/
|
|
154
|
+
function resolveLastMessages(requested: unknown, agent: Agent): number | undefined {
|
|
155
|
+
const declared = agent.getConfig().lastMessages;
|
|
156
|
+
const asked = Number(requested);
|
|
157
|
+
if (!Number.isFinite(asked) || asked <= 0) return declared;
|
|
158
|
+
if (!declared) return Math.floor(asked);
|
|
159
|
+
return Math.max(1, Math.min(Math.floor(asked), declared));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The answer's envelope — the conversation itself is available through `history`. */
|
|
163
|
+
function presentResult(result: AgentGenerateResult) {
|
|
164
|
+
return {
|
|
165
|
+
text: result.text,
|
|
166
|
+
// Same envelope as `generate()` in-process: the reasoning travels with the run
|
|
167
|
+
...(result.reasoning ? { reasoning: result.reasoning } : {}),
|
|
168
|
+
toolCalls: result.toolCalls,
|
|
169
|
+
toolResults: result.toolResults,
|
|
170
|
+
steps: result.steps,
|
|
171
|
+
usage: result.usage,
|
|
172
|
+
finishReason: result.finishReason,
|
|
173
|
+
// A paused run hands back the conversation to resume with: nothing is stored
|
|
174
|
+
// until the run completes, and an assistant turn with unanswered calls would
|
|
175
|
+
// poison the thread
|
|
176
|
+
...(result.pendingApprovals?.length
|
|
177
|
+
? {
|
|
178
|
+
pendingApprovals: result.pendingApprovals,
|
|
179
|
+
resume: { messages: result.messages },
|
|
180
|
+
}
|
|
181
|
+
: {}),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The prompt is a parameter — capped, and redacted like any other payload. */
|
|
186
|
+
function auditInput(input: AgentInput | undefined) {
|
|
187
|
+
if (typeof input === 'string') {
|
|
188
|
+
return input.length > AUDIT_INPUT_MAX
|
|
189
|
+
? { text: input.slice(0, AUDIT_INPUT_MAX), length: input.length, truncated: true }
|
|
190
|
+
: { text: input };
|
|
191
|
+
}
|
|
192
|
+
return { messages: Array.isArray(input) ? input.length : 1 };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
type AuditOptions = {
|
|
196
|
+
rest: InstanceType<typeof useRest>;
|
|
197
|
+
agentId: string;
|
|
198
|
+
/** The prompt's version, when the agent declares one — what produced the answer. */
|
|
199
|
+
version?: string;
|
|
200
|
+
action: string;
|
|
201
|
+
input?: AgentInput;
|
|
202
|
+
thread?: string;
|
|
203
|
+
result?: AgentGenerateResult;
|
|
204
|
+
error?: { message: string; code?: string };
|
|
205
|
+
started: number;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
/** One audit entry per call, at the same place as `_vars_:<ns>` — `_agents_:<id>`. */
|
|
209
|
+
async function auditAgent({
|
|
210
|
+
rest, agentId, version, action, input, thread, result, error, started,
|
|
211
|
+
}: AuditOptions): Promise<void> {
|
|
212
|
+
await rest.audit.log({
|
|
213
|
+
action: `agent.${action}`,
|
|
214
|
+
collection: `_agents_:${agentId}`,
|
|
215
|
+
input: { agent: agentId, ...(version ? { version } : {}), thread, ...auditInput(input) },
|
|
216
|
+
// Never the answer, never the tool arguments: the envelope only.
|
|
217
|
+
result: result
|
|
218
|
+
? {
|
|
219
|
+
finishReason: result.finishReason,
|
|
220
|
+
usage: result.usage,
|
|
221
|
+
toolCalls: result.toolCalls.map((call) => ({ name: call.name })),
|
|
222
|
+
steps: result.steps,
|
|
223
|
+
}
|
|
224
|
+
: undefined,
|
|
225
|
+
error,
|
|
226
|
+
duration: Date.now() - started,
|
|
227
|
+
}).catch(() => {});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** The streamed answer is one SSE event per runtime chunk, then a `done` event. */
|
|
231
|
+
function streamResponse(
|
|
232
|
+
c: any,
|
|
233
|
+
agent: Agent,
|
|
234
|
+
input: AgentInput,
|
|
235
|
+
callOptions: AgentCallOptions,
|
|
236
|
+
audit: Omit<AuditOptions, 'input' | 'thread' | 'result' | 'error'>,
|
|
237
|
+
): Response {
|
|
238
|
+
const encoder = new TextEncoder();
|
|
239
|
+
const stream = agent.stream(input, callOptions);
|
|
240
|
+
|
|
241
|
+
const body = new ReadableStream<Uint8Array>({
|
|
242
|
+
async start(controller) {
|
|
243
|
+
const send = (payload: any) => {
|
|
244
|
+
try {
|
|
245
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify(payload)}\n\n`));
|
|
246
|
+
} catch {
|
|
247
|
+
// The client is gone — the run is already aborted by its signal.
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
try {
|
|
252
|
+
for await (const chunk of stream.fullStream) send(chunk);
|
|
253
|
+
|
|
254
|
+
const result = {
|
|
255
|
+
text: await stream.text,
|
|
256
|
+
object: await stream.object,
|
|
257
|
+
toolCalls: await stream.toolCalls,
|
|
258
|
+
toolResults: await stream.toolResults,
|
|
259
|
+
steps: await stream.steps,
|
|
260
|
+
usage: await stream.usage,
|
|
261
|
+
finishReason: await stream.finishReason,
|
|
262
|
+
messages: [],
|
|
263
|
+
} as AgentGenerateResult;
|
|
264
|
+
|
|
265
|
+
send({
|
|
266
|
+
type: 'done',
|
|
267
|
+
result: presentResult(result),
|
|
268
|
+
...(result.object !== undefined ? { object: result.object } : {}),
|
|
269
|
+
});
|
|
270
|
+
await auditAgent({ ...audit, result });
|
|
271
|
+
} catch (err: any) {
|
|
272
|
+
send({
|
|
273
|
+
type: 'error',
|
|
274
|
+
message: err?.message ?? 'Agent stream failed',
|
|
275
|
+
code: err?.code ?? 'INTERNAL_AGENT_ERROR',
|
|
276
|
+
});
|
|
277
|
+
await auditAgent({
|
|
278
|
+
...audit,
|
|
279
|
+
error: { message: err?.message, code: err?.code || 'INTERNAL_AGENT_ERROR' },
|
|
280
|
+
});
|
|
281
|
+
} finally {
|
|
282
|
+
try { controller.close(); } catch { /* already closed */ }
|
|
283
|
+
}
|
|
284
|
+
},
|
|
285
|
+
cancel() {
|
|
286
|
+
// Client disconnected mid-run: stop paying for tokens.
|
|
287
|
+
stream.abort('client disconnected');
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
return new Response(body, {
|
|
292
|
+
headers: {
|
|
293
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
294
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
295
|
+
'Connection': 'keep-alive',
|
|
296
|
+
// nginx: never buffer an event stream
|
|
297
|
+
'X-Accel-Buffering': 'no',
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function initializeAgents(app: Hono<{ Variables: HonoVariables }>) {
|
|
303
|
+
app.post(AGENT_PREFIX, async (c) => {
|
|
304
|
+
const started = Date.now();
|
|
305
|
+
let rest: InstanceType<typeof useRest> | undefined;
|
|
306
|
+
let tenant_id = '';
|
|
307
|
+
let agentId = '';
|
|
308
|
+
let agentVersion: string | undefined;
|
|
309
|
+
let action = '';
|
|
310
|
+
|
|
311
|
+
try {
|
|
312
|
+
const contentType = c.req.header('Content-Type');
|
|
313
|
+
let body: AgentRequestBody | undefined;
|
|
314
|
+
if (contentType?.includes('application/json')) {
|
|
315
|
+
// `info` legitimately carries no body: an empty one is `{}`, a
|
|
316
|
+
// malformed one is an error (never silently ignored).
|
|
317
|
+
const raw = await c.req.text();
|
|
318
|
+
if (raw.trim()) {
|
|
319
|
+
try {
|
|
320
|
+
body = JSON.parse(raw);
|
|
321
|
+
} catch {
|
|
322
|
+
throw new AppError('Invalid JSON body', { status: 400, code: 'INVALID_JSON_BODY' });
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const params = c.req.param() as { tenant_id: string; agent: string; action: string };
|
|
328
|
+
tenant_id = params.tenant_id;
|
|
329
|
+
agentId = params.agent;
|
|
330
|
+
action = params.action;
|
|
331
|
+
|
|
332
|
+
if (!tenant_id) throw new AppError('Tenant ID is required', { status: 400, code: 'TENANT_ID_REQUIRED' });
|
|
333
|
+
if (!agentId) throw new AppError('Agent is required', { status: 400, code: 'AGENT_REQUIRED' });
|
|
334
|
+
if (!action) throw new AppError('Action is required', { status: 400, code: 'ACTION_REQUIRED' });
|
|
335
|
+
if (!getTenant(tenant_id)) throw new AppError('Tenant not found', { status: 400, code: 'TENANT_NOT_FOUND' });
|
|
336
|
+
|
|
337
|
+
requestCtxStorage.set('tenant_id', tenant_id);
|
|
338
|
+
|
|
339
|
+
// Only agents declared with `define.Agent` are reachable, and their
|
|
340
|
+
// `api.access` gates every action (no rules = denied).
|
|
341
|
+
rest = new useRest({ internal: false, tenant_id });
|
|
342
|
+
const agent = createAgents(tenant_id, rest).get(agentId);
|
|
343
|
+
if (!agent) throw new AppError(`Agent '${agentId}' not found`, { status: 400, code: 'AGENT_NOT_FOUND' });
|
|
344
|
+
agentVersion = agent.getConfig().version;
|
|
345
|
+
|
|
346
|
+
const api = agent.getConfig().api;
|
|
347
|
+
// A resume carries its own conversation: no input to ask for
|
|
348
|
+
const needsInput = (action === 'generate' || action === 'stream' || action === 'object') && !body?.resume;
|
|
349
|
+
const input = needsInput ? normalizeInput(body) : undefined;
|
|
350
|
+
// `memory: { thread, resource }` or the flat `thread` / `resource`
|
|
351
|
+
const thread = resolveThread(body);
|
|
352
|
+
const resource = resolveResource(body);
|
|
353
|
+
const readOnly = body?.readOnly ?? body?.memory?.readOnly ?? false;
|
|
354
|
+
|
|
355
|
+
await evaluateAccess(api?.access as any, action, rest, `agents:${agentId}`, c, {
|
|
356
|
+
agent,
|
|
357
|
+
action,
|
|
358
|
+
body,
|
|
359
|
+
input,
|
|
360
|
+
thread,
|
|
361
|
+
// The deprecated alias, so a rule written against it keeps working
|
|
362
|
+
threadId: thread,
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
const callOptions: AgentCallOptions = {
|
|
366
|
+
thread,
|
|
367
|
+
resource,
|
|
368
|
+
maxSteps: resolveMaxSteps(body?.maxSteps, agent),
|
|
369
|
+
lastMessages: resolveLastMessages(body?.lastMessages, agent),
|
|
370
|
+
// Injected turns: the same normalization as the input, so a client can
|
|
371
|
+
// seed context without building one big `input` array
|
|
372
|
+
...(body?.messages ? { messages: body.messages as AgentInput } : {}),
|
|
373
|
+
// Stop the run when the client disconnects (no tokens past the wire)
|
|
374
|
+
signal: c.req.raw.signal,
|
|
375
|
+
...(body?.files?.length ? { files: body.files } : {}),
|
|
376
|
+
...(body?.resume ? { resume: body.resume } : {}),
|
|
377
|
+
...(readOnly ? { memory: { readOnly: true } } : {}),
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
if (action === 'stream') {
|
|
381
|
+
return streamResponse(c, agent, input as AgentInput, callOptions, {
|
|
382
|
+
rest, agentId, action, started, version: agent.getConfig().version,
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
let result: AgentGenerateResult | undefined;
|
|
387
|
+
let response: any;
|
|
388
|
+
|
|
389
|
+
switch (action) {
|
|
390
|
+
case 'info':
|
|
391
|
+
// No model call, no token spent
|
|
392
|
+
response = { agent: agent.toJSON() };
|
|
393
|
+
break;
|
|
394
|
+
|
|
395
|
+
case 'history': {
|
|
396
|
+
if (!agent.getMemory()) {
|
|
397
|
+
throw new AppError(`Agent '${agentId}' declares no memory`, { status: 400, code: 'AGENT_NO_MEMORY' });
|
|
398
|
+
}
|
|
399
|
+
// The same `resource` the run used: it namespaces the thread
|
|
400
|
+
response = {
|
|
401
|
+
messages: await agent.getMessages(requireThread(body), { resource }),
|
|
402
|
+
};
|
|
403
|
+
break;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
case 'threads': {
|
|
407
|
+
if (!agent.getMemory()) {
|
|
408
|
+
throw new AppError(`Agent '${agentId}' declares no memory`, { status: 400, code: 'AGENT_NO_MEMORY' });
|
|
409
|
+
}
|
|
410
|
+
if (!agent.getMemory()?.list) {
|
|
411
|
+
throw new AppError(
|
|
412
|
+
`The memory of agent '${agentId}' does not list threads`,
|
|
413
|
+
{ status: 400, code: 'AGENT_MEMORY_NO_LIST' },
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
response = { threads: await agent.listThreads({ resource, limit: body?.limit }) };
|
|
417
|
+
break;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
case 'clear': {
|
|
421
|
+
if (!agent.getMemory()) {
|
|
422
|
+
throw new AppError(`Agent '${agentId}' declares no memory`, { status: 400, code: 'AGENT_NO_MEMORY' });
|
|
423
|
+
}
|
|
424
|
+
await agent.clearMessages(requireThread(body), { resource });
|
|
425
|
+
response = { ok: true };
|
|
426
|
+
break;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
case 'generate': {
|
|
430
|
+
const generated = await agent.generate(input as AgentInput, callOptions);
|
|
431
|
+
result = generated;
|
|
432
|
+
response = presentResult(generated);
|
|
433
|
+
break;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
case 'object': {
|
|
437
|
+
const structuredOutput = api?.object;
|
|
438
|
+
if (!structuredOutput?.schema) {
|
|
439
|
+
throw new AppError(
|
|
440
|
+
`Agent '${agentId}' declares no \`api.object\` schema`,
|
|
441
|
+
{ status: 400, code: 'AGENT_NO_OBJECT_SCHEMA' },
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
const generated = await agent.generate(input as AgentInput, { ...callOptions, structuredOutput });
|
|
445
|
+
result = generated;
|
|
446
|
+
response = { ...presentResult(generated), object: generated.object };
|
|
447
|
+
break;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
default:
|
|
451
|
+
throw new AppError(`Action '${action}' not found`, { status: 400, code: 'ACTION_NOT_FOUND' });
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
await auditAgent({ rest, agentId, action, input, thread, result, started, version: agent.getConfig().version });
|
|
455
|
+
return c.json(response);
|
|
456
|
+
} catch (err: any) {
|
|
457
|
+
if (cfg?.debug) console.error(err);
|
|
458
|
+
if (rest && tenant_id && agentId) {
|
|
459
|
+
await rest.audit.log({
|
|
460
|
+
action: `agent.${action || 'unknown'}`,
|
|
461
|
+
collection: `_agents_:${agentId}`,
|
|
462
|
+
input: { agent: agentId, ...(agentVersion ? { version: agentVersion } : {}) },
|
|
463
|
+
error: { message: err?.message, code: err?.code || 'INTERNAL_AGENT_ERROR' },
|
|
464
|
+
duration: Date.now() - started,
|
|
465
|
+
}).catch(() => {});
|
|
466
|
+
}
|
|
467
|
+
return errorResponse(c, err);
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export { initializeAgents, AGENT_PREFIX };
|
package/server/api.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { cfg, safePublicConfig } from "./config";
|
|
|
3
3
|
import type { ApiOptions } from "../types/api";
|
|
4
4
|
import { getCollection } from "../database/collection";
|
|
5
5
|
import { getFileCollection } from "../database/file";
|
|
6
|
+
import { getTenant } from "../database/tenant";
|
|
6
7
|
import { AppError, fn } from "../lib/error";
|
|
7
8
|
import * as cookie from "hono/cookie"
|
|
8
9
|
import { useRest } from "../database/rest";
|
|
@@ -17,12 +18,16 @@ import * as os from 'node:os';
|
|
|
17
18
|
import { basename } from 'node:path';
|
|
18
19
|
import { handleUpload, handleServe, handleDelete, type FileResult } from "../lib/files";
|
|
19
20
|
import { getVarsDefinition } from "../database/vars";
|
|
20
|
-
import {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const
|
|
21
|
+
import { endpoints, patterns } from "../lib/endpoints";
|
|
22
|
+
import { getAccessToken, errorResponse, evaluateAccess } from "./access";
|
|
23
|
+
|
|
24
|
+
// Every surface hangs off `/api/:tenant_id` — the patterns live in `lib/endpoints.ts`,
|
|
25
|
+
// next to the builders a client (and the SDK) uses, so a path is written once.
|
|
26
|
+
const API_PREFIX = patterns.collection;
|
|
27
|
+
const SERVICE_PREFIX = patterns.service;
|
|
28
|
+
const VARS_PREFIX = patterns.vars;
|
|
29
|
+
const UPLOAD_PREFIX = patterns.upload;
|
|
30
|
+
const FILES_PREFIX = patterns.file;
|
|
26
31
|
|
|
27
32
|
|
|
28
33
|
const ActionsValues = [
|
|
@@ -44,26 +49,6 @@ const ActionsValues = [
|
|
|
44
49
|
|
|
45
50
|
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
46
51
|
|
|
47
|
-
function getAccessToken(c: any) {
|
|
48
|
-
const t = c.get('token');
|
|
49
|
-
return {
|
|
50
|
-
value: (t?.value ?? null) as string | null,
|
|
51
|
-
decoded: (t?.decoded ?? null) as Record<string, unknown> | null,
|
|
52
|
-
provided: (t?.provided ?? false) as boolean,
|
|
53
|
-
expired: (t?.expired ?? false) as boolean,
|
|
54
|
-
};
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function errorResponse(c: any, err: any) {
|
|
58
|
-
const isAppError = err instanceof AppError;
|
|
59
|
-
const status = isAppError ? Number(err.status) : 500;
|
|
60
|
-
return c.json({
|
|
61
|
-
message: isAppError ? err.message : 'Internal server error',
|
|
62
|
-
code: isAppError ? err.code : 'INTERNAL_SERVER_ERROR',
|
|
63
|
-
meta: isAppError ? err.meta : undefined,
|
|
64
|
-
}, status);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
52
|
function stripReadOnlyData(col: any, body: any) {
|
|
68
53
|
if (col?.api?.readOnlyFields?.length && body?.data) {
|
|
69
54
|
body.data = func.omit(body.data, col.api.readOnlyFields);
|
|
@@ -76,53 +61,79 @@ function stripReadOnlyUpdate(col: any, body: any) {
|
|
|
76
61
|
}
|
|
77
62
|
}
|
|
78
63
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
|
|
64
|
+
/**
|
|
65
|
+
* The collections a request pulls documents **into** its response — an `$include`
|
|
66
|
+
* on `find`/`findOne`, a `$lookup` / `$graphLookup` anywhere in an `aggregate`
|
|
67
|
+
* pipeline (`$facet` branches and nested lookups included).
|
|
68
|
+
*
|
|
69
|
+
* Returns the path each joined document lands at, paired with the target
|
|
70
|
+
* collection's `api.privateFields`. `func.omit` only knows the *queried*
|
|
71
|
+
* collection's matchers, so a joined document — which carries the *included*
|
|
72
|
+
* collection's fields — has to be masked separately, at its own path.
|
|
73
|
+
*/
|
|
74
|
+
function embeddedPrivateFields(col: any, body: any): Array<{ path: string[]; keys: (string | RegExp)[] }> {
|
|
75
|
+
const found: Array<{ path: string[]; keys: (string | RegExp)[] }> = [];
|
|
76
|
+
|
|
77
|
+
const keysOf = (slug: string): (string | RegExp)[] =>
|
|
78
|
+
(cfg.collections?.find((c) => c.slug === slug && c._tenant_ === col._tenant_)?.api?.privateFields) ?? [];
|
|
79
|
+
|
|
80
|
+
const push = (slug: string, path?: string) => {
|
|
81
|
+
if (!path) return;
|
|
82
|
+
const keys = keysOf(slug);
|
|
83
|
+
if (keys.length) found.push({ path: path.split('.'), keys });
|
|
84
|
+
};
|
|
94
85
|
|
|
95
|
-
|
|
96
|
-
|
|
86
|
+
// find / findOne — `$include`
|
|
87
|
+
for (const inc of body?.params?.$include ?? []) {
|
|
88
|
+
if (typeof inc === 'string') {
|
|
89
|
+
const field = col.fields?.find((f: any) => f.name === inc);
|
|
90
|
+
if (field?.relation?.to) push(field.relation.to, inc);
|
|
91
|
+
} else if (inc?.from) {
|
|
92
|
+
push(inc.from, inc.as || inc.localField);
|
|
93
|
+
}
|
|
97
94
|
}
|
|
98
95
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
96
|
+
// aggregate — every `$lookup` / `$graphLookup`, nested ones included
|
|
97
|
+
const walk = (stages: any, prefix: string) => {
|
|
98
|
+
if (!Array.isArray(stages)) return;
|
|
99
|
+
for (const stage of stages) {
|
|
100
|
+
if (!stage || typeof stage !== 'object') continue;
|
|
101
|
+
const lookup = stage.$lookup ?? stage.$graphLookup;
|
|
102
|
+
if (lookup?.from && typeof lookup.as === 'string') {
|
|
103
|
+
const path = prefix ? `${prefix}.${lookup.as}` : lookup.as;
|
|
104
|
+
push(lookup.from, path);
|
|
105
|
+
walk(lookup.pipeline, path);
|
|
106
|
+
}
|
|
107
|
+
if (stage.$facet && typeof stage.$facet === 'object') {
|
|
108
|
+
for (const branch of Object.values(stage.$facet)) walk(branch, prefix);
|
|
109
|
+
}
|
|
106
110
|
}
|
|
107
|
-
|
|
108
|
-
|
|
111
|
+
};
|
|
112
|
+
walk(body?.pipeline, '');
|
|
109
113
|
|
|
110
|
-
|
|
111
|
-
|
|
114
|
+
return found;
|
|
115
|
+
}
|
|
112
116
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
/**
|
|
118
|
+
* Apply `func.omit` **at `path` only**, walking through arrays (a `$include` of a
|
|
119
|
+
* `hasMany`). Each node on the way is shallow-copied, so a cached result is never
|
|
120
|
+
* mutated in place.
|
|
121
|
+
*/
|
|
122
|
+
function omitAtPath(node: any, path: string[], keys: (string | RegExp)[]): any {
|
|
123
|
+
if (node === null || node === undefined) return node;
|
|
124
|
+
if (Array.isArray(node)) return node.map((item) => omitAtPath(item, path, keys));
|
|
125
|
+
if (typeof node !== 'object') return node;
|
|
119
126
|
|
|
120
|
-
const
|
|
121
|
-
if (!
|
|
122
|
-
|
|
123
|
-
}
|
|
127
|
+
const [head, ...rest] = path;
|
|
128
|
+
if (!head || !(head in node)) return node;
|
|
129
|
+
|
|
130
|
+
return { ...node, [head]: rest.length ? omitAtPath(node[head], rest, keys) : func.omit(node[head], keys) };
|
|
124
131
|
}
|
|
125
132
|
|
|
133
|
+
// ─── Access control ─────────────────────────────────────────────────────────
|
|
134
|
+
// `evaluateAccess` / `errorResponse` / `getAccessToken` live in `./access`
|
|
135
|
+
// (shared with the agents API).
|
|
136
|
+
|
|
126
137
|
async function checkFileAccess(
|
|
127
138
|
access: { [key: string]: boolean | ((ctx: any) => boolean | Promise<boolean>) | undefined } | undefined,
|
|
128
139
|
operation: string,
|
|
@@ -371,10 +382,14 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
|
|
|
371
382
|
response = await handler(rest, collection, body);
|
|
372
383
|
}
|
|
373
384
|
|
|
374
|
-
// Strip private fields from response
|
|
385
|
+
// Strip private fields from response — the queried collection's own,
|
|
386
|
+
// then each collection it embeds via `$include` / `$lookup`
|
|
375
387
|
if (privateFields.length > 0) {
|
|
376
388
|
response = func.omit(response!, privateFields, fieldOrder);
|
|
377
389
|
}
|
|
390
|
+
for (const { path, keys } of embeddedPrivateFields(col, body)) {
|
|
391
|
+
response = omitAtPath(response, path, keys);
|
|
392
|
+
}
|
|
378
393
|
|
|
379
394
|
return c.json(response);
|
|
380
395
|
|