@anteros/core 0.0.40 → 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 +143 -0
- package/database/collection.ts +6 -4
- package/database/deleteLog.ts +98 -0
- package/database/file.ts +7 -6
- package/database/mongodbadapter.ts +420 -185
- package/database/replication.ts +858 -0
- package/database/schema.ts +140 -125
- package/database/tenant.ts +4 -0
- package/database/ttl.ts +68 -0
- package/database/vars.ts +405 -0
- package/database/workflow.ts +466 -240
- package/index.ts +16 -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/audit.ts +271 -0
- package/lib/auditResult.ts +133 -0
- package/lib/define.ts +62 -1
- package/lib/endpoints.ts +80 -0
- package/lib/files.ts +66 -12
- package/lib/jsonSchema.ts +183 -0
- package/lib/lifecycle.ts +147 -0
- 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/redact.ts +117 -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 +121 -95
- package/server/boot.ts +196 -53
- package/server/flags.ts +136 -0
- package/server/hono.ts +9 -4
- 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-file.test.ts +296 -0
- package/tests/audit-operations.test.ts +184 -0
- package/tests/audit-redact.test.ts +259 -0
- package/tests/audit-results.test.ts +188 -0
- package/tests/audit-retention-config.test.ts +76 -0
- package/tests/audit-run-action.test.ts +120 -0
- package/tests/audit.test.ts +320 -0
- package/tests/db-cache.test.ts +7 -7
- package/tests/endpoints.test.ts +80 -0
- package/tests/file-collection-replication.test.ts +98 -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/lc-disabled/lifecycle.ts +11 -0
- package/tests/fixtures/lc-hang/lifecycle.ts +13 -0
- package/tests/fixtures/lc-normal/lifecycle.ts +23 -0
- package/tests/fixtures/lc-throw/lifecycle.ts +6 -0
- package/tests/fixtures/repl-only/app.ts +26 -0
- package/tests/fixtures/repl-only/tenant/collections/orders.model.ts +14 -0
- package/tests/fixtures/repl-only/tenant/lifecycle.ts +14 -0
- package/tests/fixtures/repl-only/tenant/scripts/probe.run.ts +8 -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/config.var.ts +23 -0
- package/tests/fixtures/vars-tenant/vars/private.var.ts +9 -0
- 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/lifecycle.test.ts +96 -0
- 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 +128 -0
- package/tests/replication-initial-sync.test.ts +86 -0
- package/tests/replication-meta.test.ts +243 -0
- package/tests/replication-only.test.ts +358 -0
- package/tests/replication.test.ts +188 -0
- package/tests/upload.test.ts +141 -8
- package/tests/vars-api.test.ts +123 -0
- package/tests/vars-replication.test.ts +96 -0
- package/tests/vars.test.ts +216 -0
- 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/collection.d.ts +3 -0
- package/types/config.d.ts +80 -0
- package/types/file.d.ts +11 -8
- package/types/hook.d.ts +6 -0
- package/types/lifecycle.d.ts +77 -0
- package/types/mcp.d.ts +11 -1
- package/types/replication.d.ts +228 -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 +36 -0
- package/types/vars.d.ts +147 -0
- package/types/websocket.d.ts +6 -0
- package/types/workflow.d.ts +50 -3
- package/utils/func.ts +67 -52
package/server/access.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { AppError, fn } from "../lib/error";
|
|
2
|
+
import * as func from "../utils/func";
|
|
3
|
+
import type { useRest } from "../database/rest";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Shared HTTP plumbing between the collection/vars/service API (`server/api.ts`)
|
|
7
|
+
* and the agents API (`server/agents.ts`).
|
|
8
|
+
*
|
|
9
|
+
* Extracted rather than duplicated: the access rule semantics (wildcard, boolean
|
|
10
|
+
* vs function, token requirement) must stay identical everywhere — a divergence
|
|
11
|
+
* here is a hole.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
function getAccessToken(c: any) {
|
|
15
|
+
const t = c.get('token');
|
|
16
|
+
return {
|
|
17
|
+
value: (t?.value ?? null) as string | null,
|
|
18
|
+
decoded: (t?.decoded ?? null) as Record<string, unknown> | null,
|
|
19
|
+
provided: (t?.provided ?? false) as boolean,
|
|
20
|
+
expired: (t?.expired ?? false) as boolean,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function errorResponse(c: any, err: any) {
|
|
25
|
+
const isAppError = err instanceof AppError;
|
|
26
|
+
const status = isAppError ? Number(err.status) : 500;
|
|
27
|
+
return c.json({
|
|
28
|
+
message: isAppError ? err.message : 'Internal server error',
|
|
29
|
+
code: isAppError ? err.code : 'INTERNAL_SERVER_ERROR',
|
|
30
|
+
meta: isAppError ? err.meta : undefined,
|
|
31
|
+
}, status);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Evaluate an `api.access` rule — **no rule = denied** (secure by default).
|
|
36
|
+
*
|
|
37
|
+
* - `true` → allowed without a token (public)
|
|
38
|
+
* - `false` → denied
|
|
39
|
+
* - function → **requires a valid, non-expired token**, then the rule decides
|
|
40
|
+
*
|
|
41
|
+
* `extra` is merged into the rule context (agents pass the agent, the action and
|
|
42
|
+
* the input so a rule can decide on the payload it is about to run).
|
|
43
|
+
*/
|
|
44
|
+
async function evaluateAccess(
|
|
45
|
+
access: { [key: string]: boolean | ((ctx: any) => boolean | Promise<boolean>) | undefined } | undefined,
|
|
46
|
+
operation: string,
|
|
47
|
+
rest: InstanceType<typeof useRest>,
|
|
48
|
+
label: string,
|
|
49
|
+
c: any,
|
|
50
|
+
extra: Record<string, any> = {},
|
|
51
|
+
): Promise<void> {
|
|
52
|
+
if (!access) {
|
|
53
|
+
throw new AppError('Access denied', { status: 401, code: 'ACCESS_DENIED' });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const hasWildcard = access['*'] !== undefined;
|
|
57
|
+
const hasSpecific = access[operation] !== undefined;
|
|
58
|
+
|
|
59
|
+
if (!hasWildcard && !hasSpecific) {
|
|
60
|
+
throw new AppError('Access denied', { status: 401, code: 'ACCESS_DENIED' });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const rule = hasSpecific ? access[operation] : access['*'];
|
|
64
|
+
if (rule === undefined) return;
|
|
65
|
+
|
|
66
|
+
// Boolean `true` → allow without requiring a token
|
|
67
|
+
if (typeof rule === 'boolean') {
|
|
68
|
+
if (!rule) {
|
|
69
|
+
throw new AppError(`${label} not allowed`, { status: 401, code: 'ACCESS_DENIED' });
|
|
70
|
+
}
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Function → requires a valid token
|
|
75
|
+
const accessToken = getAccessToken(c);
|
|
76
|
+
|
|
77
|
+
if (accessToken.expired) {
|
|
78
|
+
throw new AppError('Token expired', { status: 401, code: 'TOKEN_EXPIRED' });
|
|
79
|
+
}
|
|
80
|
+
if (!accessToken.value) {
|
|
81
|
+
throw new AppError('Authentication required', { status: 401, code: 'AUTH_REQUIRED' });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const allowed = await (rule as Function)({
|
|
85
|
+
rest,
|
|
86
|
+
error: fn.error,
|
|
87
|
+
jwt: func.jwt,
|
|
88
|
+
token: accessToken,
|
|
89
|
+
...extra,
|
|
90
|
+
});
|
|
91
|
+
if (!allowed) {
|
|
92
|
+
throw new AppError(`${label} not allowed`, { status: 401, code: 'ACCESS_DENIED' });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export { getAccessToken, errorResponse, evaluateAccess };
|
package/server/agents.ts
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
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
|
+
} from "../types/agent";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Agents HTTP API — `POST /api/:tenant_id/agents/:agent/:action`.
|
|
21
|
+
*
|
|
22
|
+
* Same shape as the services API (`/api/:tenant_id/services/:service/:action`) and
|
|
23
|
+
* the same access model (`api.access`, **no rules = denied**).
|
|
24
|
+
*
|
|
25
|
+
* ```ts
|
|
26
|
+
* // define.Agent({ api: { access: { generate: (ctx) => !!ctx.token.value } } })
|
|
27
|
+
* POST /api/v1/agents/support/generate { "input": "Where is my order?" }
|
|
28
|
+
* POST /api/v1/agents/support/stream { "input": "…", "thread": "user-42" } → SSE
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* A client can only send **what it is allowed to choose**: the input, a memory
|
|
32
|
+
* thread, injected turns and a *lower* `maxSteps` / `lastMessages`. The provider
|
|
33
|
+
* (model, endpoint, key), the options and the structured-output schema stay
|
|
34
|
+
* server-side — changing the model or the endpoint of a declared agent is a
|
|
35
|
+
* deployment decision, not a request one.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
const AGENT_PREFIX = patterns.agent;
|
|
39
|
+
|
|
40
|
+
/** What an HTTP call may carry. Nothing else is read. */
|
|
41
|
+
type AgentRequestBody = {
|
|
42
|
+
/** A string (one user turn), one message, or a whole conversation. */
|
|
43
|
+
input?: string | AgentMessageInput | AgentMessageInput[];
|
|
44
|
+
/**
|
|
45
|
+
* Turns **injected** into the run — after the thread, before the `input`. They
|
|
46
|
+
* are sent to the model *and* saved with the rest of the conversation.
|
|
47
|
+
*/
|
|
48
|
+
messages?: AgentMessageInput | AgentMessageInput[];
|
|
49
|
+
/** Memory thread. */
|
|
50
|
+
thread?: string;
|
|
51
|
+
/** @deprecated Alias of `thread`. */
|
|
52
|
+
threadId?: string;
|
|
53
|
+
/** Opaque caller identity, forwarded to the instructions function. */
|
|
54
|
+
resource?: string;
|
|
55
|
+
/** @deprecated Alias of `resource`. */
|
|
56
|
+
resourceId?: string;
|
|
57
|
+
/** Lower the run's step ceiling (never raise it). */
|
|
58
|
+
maxSteps?: number;
|
|
59
|
+
/** Lower the replayed-history window (never raise it past the declaration). */
|
|
60
|
+
lastMessages?: number;
|
|
61
|
+
/** `threads` — how many threads to return (default 20). */
|
|
62
|
+
limit?: number;
|
|
63
|
+
/**
|
|
64
|
+
* Files and images sent with the run — base64, a `File`/`Blob` never crosses the
|
|
65
|
+
* wire as such. `{ name: 'invoice.pdf', data: '<base64>' }`.
|
|
66
|
+
*/
|
|
67
|
+
files?: Array<{
|
|
68
|
+
/** File name — its extension decides how it is read (`report.pdf`, `data.csv`, `photo.png`). */
|
|
69
|
+
name: string;
|
|
70
|
+
mimeType?: string;
|
|
71
|
+
/** Base64 (default) or UTF-8 text with `encoding: 'utf8'`. */
|
|
72
|
+
data: string;
|
|
73
|
+
encoding?: "base64" | "utf8";
|
|
74
|
+
}>;
|
|
75
|
+
/** Read the thread without writing the run back. */
|
|
76
|
+
readOnly?: boolean;
|
|
77
|
+
/** Mastra-style shorthand — `memory: { thread, resource, readOnly }`. */
|
|
78
|
+
memory?: {
|
|
79
|
+
thread?: string;
|
|
80
|
+
/** @deprecated Alias of `thread`. */
|
|
81
|
+
threadId?: string;
|
|
82
|
+
resource?: string;
|
|
83
|
+
/** @deprecated Alias of `resource`. */
|
|
84
|
+
resourceId?: string;
|
|
85
|
+
readOnly?: boolean;
|
|
86
|
+
};
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** `memory.thread` first, then the flat `thread` (and its `threadId` alias). */
|
|
90
|
+
function resolveThread(body: AgentRequestBody | undefined): string | undefined {
|
|
91
|
+
const thread = body?.memory?.thread ?? body?.memory?.threadId ?? body?.thread ?? body?.threadId;
|
|
92
|
+
return typeof thread === 'string' && thread.trim() ? thread : undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** `memory.resource` first, then the flat `resource` (and its `resourceId` alias). */
|
|
96
|
+
function resolveResource(body: AgentRequestBody | undefined): string | undefined {
|
|
97
|
+
const resource = body?.memory?.resource ?? body?.memory?.resourceId ?? body?.resource ?? body?.resourceId;
|
|
98
|
+
return typeof resource === 'string' && resource.trim() ? resource : undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Cap on the prompt kept in the audit trail. */
|
|
102
|
+
const AUDIT_INPUT_MAX = 2000;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The input of a run — a string, **one** message, or a whole conversation. The
|
|
106
|
+
* shape itself (roles, content parts, tool parts) is validated by
|
|
107
|
+
* `normalizeMessages`, shared with the in-process runtime: the two cannot drift.
|
|
108
|
+
*/
|
|
109
|
+
function normalizeInput(body: AgentRequestBody | undefined): AgentInput {
|
|
110
|
+
const input = body?.input;
|
|
111
|
+
if (typeof input === "string") {
|
|
112
|
+
if (!input.trim()) throw new AppError('`input` is required', { status: 400, code: 'INPUT_REQUIRED' });
|
|
113
|
+
return input;
|
|
114
|
+
}
|
|
115
|
+
if (Array.isArray(input)) {
|
|
116
|
+
if (!input.length) throw new AppError('`input` is required', { status: 400, code: 'INPUT_REQUIRED' });
|
|
117
|
+
return input as AgentInput;
|
|
118
|
+
}
|
|
119
|
+
if (input && typeof input === "object" && typeof (input as any).role === "string") {
|
|
120
|
+
return input as AgentInput;
|
|
121
|
+
}
|
|
122
|
+
throw new AppError('`input` is required (a string, a message or an array of messages)', {
|
|
123
|
+
status: 400, code: 'INPUT_REQUIRED',
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function requireThread(body: AgentRequestBody | undefined): string {
|
|
128
|
+
const thread = resolveThread(body);
|
|
129
|
+
if (!thread) {
|
|
130
|
+
throw new AppError('`thread` is required (or `memory.thread`)', { status: 400, code: 'THREAD_ID_REQUIRED' });
|
|
131
|
+
}
|
|
132
|
+
return thread;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** A client may lower the step ceiling, never raise it — the cost is the tenant's. */
|
|
136
|
+
function resolveMaxSteps(requested: unknown, agent: Agent): number {
|
|
137
|
+
const declared = agent.getConfig().maxSteps ?? 5;
|
|
138
|
+
const asked = Number(requested);
|
|
139
|
+
if (!Number.isFinite(asked) || asked <= 0) return declared;
|
|
140
|
+
return Math.max(1, Math.min(Math.floor(asked), declared));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The history window of a request: a client may shrink the prompt, never grow it
|
|
145
|
+
* past what the agent declares (with no declaration, the store cap is the only
|
|
146
|
+
* bound that matters — and it is already applied on read).
|
|
147
|
+
*/
|
|
148
|
+
function resolveLastMessages(requested: unknown, agent: Agent): number | undefined {
|
|
149
|
+
const declared = agent.getConfig().lastMessages;
|
|
150
|
+
const asked = Number(requested);
|
|
151
|
+
if (!Number.isFinite(asked) || asked <= 0) return declared;
|
|
152
|
+
if (!declared) return Math.floor(asked);
|
|
153
|
+
return Math.max(1, Math.min(Math.floor(asked), declared));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The answer's envelope — the conversation itself is available through `history`. */
|
|
157
|
+
function presentResult(result: AgentGenerateResult) {
|
|
158
|
+
return {
|
|
159
|
+
text: result.text,
|
|
160
|
+
// Same envelope as `generate()` in-process: the reasoning travels with the run
|
|
161
|
+
...(result.reasoning ? { reasoning: result.reasoning } : {}),
|
|
162
|
+
toolCalls: result.toolCalls,
|
|
163
|
+
toolResults: result.toolResults,
|
|
164
|
+
steps: result.steps,
|
|
165
|
+
usage: result.usage,
|
|
166
|
+
finishReason: result.finishReason,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The prompt is a parameter — capped, and redacted like any other payload. */
|
|
171
|
+
function auditInput(input: AgentInput | undefined) {
|
|
172
|
+
if (typeof input === 'string') {
|
|
173
|
+
return input.length > AUDIT_INPUT_MAX
|
|
174
|
+
? { text: input.slice(0, AUDIT_INPUT_MAX), length: input.length, truncated: true }
|
|
175
|
+
: { text: input };
|
|
176
|
+
}
|
|
177
|
+
return { messages: Array.isArray(input) ? input.length : 1 };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
type AuditOptions = {
|
|
181
|
+
rest: InstanceType<typeof useRest>;
|
|
182
|
+
agentId: string;
|
|
183
|
+
action: string;
|
|
184
|
+
input?: AgentInput;
|
|
185
|
+
thread?: string;
|
|
186
|
+
result?: AgentGenerateResult;
|
|
187
|
+
error?: { message: string; code?: string };
|
|
188
|
+
started: number;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/** One audit entry per call, at the same place as `_vars_:<ns>` — `_agents_:<id>`. */
|
|
192
|
+
async function auditAgent({
|
|
193
|
+
rest, agentId, action, input, thread, result, error, started,
|
|
194
|
+
}: AuditOptions): Promise<void> {
|
|
195
|
+
await rest.audit.log({
|
|
196
|
+
action: `agent.${action}`,
|
|
197
|
+
collection: `_agents_:${agentId}`,
|
|
198
|
+
input: { agent: agentId, thread, ...auditInput(input) },
|
|
199
|
+
// Never the answer, never the tool arguments: the envelope only.
|
|
200
|
+
result: result
|
|
201
|
+
? {
|
|
202
|
+
finishReason: result.finishReason,
|
|
203
|
+
usage: result.usage,
|
|
204
|
+
toolCalls: result.toolCalls.map((call) => ({ name: call.name })),
|
|
205
|
+
steps: result.steps,
|
|
206
|
+
}
|
|
207
|
+
: undefined,
|
|
208
|
+
error,
|
|
209
|
+
duration: Date.now() - started,
|
|
210
|
+
}).catch(() => {});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The streamed answer is one SSE event per runtime chunk, then a `done` event. */
|
|
214
|
+
function streamResponse(
|
|
215
|
+
c: any,
|
|
216
|
+
agent: Agent,
|
|
217
|
+
input: AgentInput,
|
|
218
|
+
callOptions: AgentCallOptions,
|
|
219
|
+
audit: Omit<AuditOptions, 'input' | 'thread' | 'result' | 'error'>,
|
|
220
|
+
): Response {
|
|
221
|
+
const encoder = new TextEncoder();
|
|
222
|
+
const stream = agent.stream(input, callOptions);
|
|
223
|
+
|
|
224
|
+
const body = new ReadableStream<Uint8Array>({
|
|
225
|
+
async start(controller) {
|
|
226
|
+
const send = (payload: any) => {
|
|
227
|
+
try {
|
|
228
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify(payload)}\n\n`));
|
|
229
|
+
} catch {
|
|
230
|
+
// The client is gone — the run is already aborted by its signal.
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
for await (const chunk of stream.fullStream) send(chunk);
|
|
236
|
+
|
|
237
|
+
const result = {
|
|
238
|
+
text: await stream.text,
|
|
239
|
+
object: await stream.object,
|
|
240
|
+
toolCalls: await stream.toolCalls,
|
|
241
|
+
toolResults: await stream.toolResults,
|
|
242
|
+
steps: await stream.steps,
|
|
243
|
+
usage: await stream.usage,
|
|
244
|
+
finishReason: await stream.finishReason,
|
|
245
|
+
messages: [],
|
|
246
|
+
} as AgentGenerateResult;
|
|
247
|
+
|
|
248
|
+
send({
|
|
249
|
+
type: 'done',
|
|
250
|
+
result: presentResult(result),
|
|
251
|
+
...(result.object !== undefined ? { object: result.object } : {}),
|
|
252
|
+
});
|
|
253
|
+
await auditAgent({ ...audit, result });
|
|
254
|
+
} catch (err: any) {
|
|
255
|
+
send({
|
|
256
|
+
type: 'error',
|
|
257
|
+
message: err?.message ?? 'Agent stream failed',
|
|
258
|
+
code: err?.code ?? 'INTERNAL_AGENT_ERROR',
|
|
259
|
+
});
|
|
260
|
+
await auditAgent({
|
|
261
|
+
...audit,
|
|
262
|
+
error: { message: err?.message, code: err?.code || 'INTERNAL_AGENT_ERROR' },
|
|
263
|
+
});
|
|
264
|
+
} finally {
|
|
265
|
+
try { controller.close(); } catch { /* already closed */ }
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
cancel() {
|
|
269
|
+
// Client disconnected mid-run: stop paying for tokens.
|
|
270
|
+
stream.abort('client disconnected');
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
return new Response(body, {
|
|
275
|
+
headers: {
|
|
276
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
277
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
278
|
+
'Connection': 'keep-alive',
|
|
279
|
+
// nginx: never buffer an event stream
|
|
280
|
+
'X-Accel-Buffering': 'no',
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function initializeAgents(app: Hono<{ Variables: HonoVariables }>) {
|
|
286
|
+
app.post(AGENT_PREFIX, async (c) => {
|
|
287
|
+
const started = Date.now();
|
|
288
|
+
let rest: InstanceType<typeof useRest> | undefined;
|
|
289
|
+
let tenant_id = '';
|
|
290
|
+
let agentId = '';
|
|
291
|
+
let action = '';
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
const contentType = c.req.header('Content-Type');
|
|
295
|
+
let body: AgentRequestBody | undefined;
|
|
296
|
+
if (contentType?.includes('application/json')) {
|
|
297
|
+
// `info` legitimately carries no body: an empty one is `{}`, a
|
|
298
|
+
// malformed one is an error (never silently ignored).
|
|
299
|
+
const raw = await c.req.text();
|
|
300
|
+
if (raw.trim()) {
|
|
301
|
+
try {
|
|
302
|
+
body = JSON.parse(raw);
|
|
303
|
+
} catch {
|
|
304
|
+
throw new AppError('Invalid JSON body', { status: 400, code: 'INVALID_JSON_BODY' });
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const params = c.req.param() as { tenant_id: string; agent: string; action: string };
|
|
310
|
+
tenant_id = params.tenant_id;
|
|
311
|
+
agentId = params.agent;
|
|
312
|
+
action = params.action;
|
|
313
|
+
|
|
314
|
+
if (!tenant_id) throw new AppError('Tenant ID is required', { status: 400, code: 'TENANT_ID_REQUIRED' });
|
|
315
|
+
if (!agentId) throw new AppError('Agent is required', { status: 400, code: 'AGENT_REQUIRED' });
|
|
316
|
+
if (!action) throw new AppError('Action is required', { status: 400, code: 'ACTION_REQUIRED' });
|
|
317
|
+
if (!getTenant(tenant_id)) throw new AppError('Tenant not found', { status: 400, code: 'TENANT_NOT_FOUND' });
|
|
318
|
+
|
|
319
|
+
requestCtxStorage.set('tenant_id', tenant_id);
|
|
320
|
+
|
|
321
|
+
// Only agents declared with `define.Agent` are reachable, and their
|
|
322
|
+
// `api.access` gates every action (no rules = denied).
|
|
323
|
+
rest = new useRest({ internal: false, tenant_id });
|
|
324
|
+
const agent = createAgents(tenant_id, rest).get(agentId);
|
|
325
|
+
if (!agent) throw new AppError(`Agent '${agentId}' not found`, { status: 400, code: 'AGENT_NOT_FOUND' });
|
|
326
|
+
|
|
327
|
+
const api = agent.getConfig().api;
|
|
328
|
+
const needsInput = action === 'generate' || action === 'stream' || action === 'object';
|
|
329
|
+
const input = needsInput ? normalizeInput(body) : undefined;
|
|
330
|
+
// `memory: { thread, resource }` or the flat `thread` / `resource`
|
|
331
|
+
const thread = resolveThread(body);
|
|
332
|
+
const resource = resolveResource(body);
|
|
333
|
+
const readOnly = body?.readOnly ?? body?.memory?.readOnly ?? false;
|
|
334
|
+
|
|
335
|
+
await evaluateAccess(api?.access as any, action, rest, `agents:${agentId}`, c, {
|
|
336
|
+
agent,
|
|
337
|
+
action,
|
|
338
|
+
body,
|
|
339
|
+
input,
|
|
340
|
+
thread,
|
|
341
|
+
// The deprecated alias, so a rule written against it keeps working
|
|
342
|
+
threadId: thread,
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
const callOptions: AgentCallOptions = {
|
|
346
|
+
thread,
|
|
347
|
+
resource,
|
|
348
|
+
maxSteps: resolveMaxSteps(body?.maxSteps, agent),
|
|
349
|
+
lastMessages: resolveLastMessages(body?.lastMessages, agent),
|
|
350
|
+
// Injected turns: the same normalization as the input, so a client can
|
|
351
|
+
// seed context without building one big `input` array
|
|
352
|
+
...(body?.messages ? { messages: body.messages as AgentInput } : {}),
|
|
353
|
+
// Stop the run when the client disconnects (no tokens past the wire)
|
|
354
|
+
signal: c.req.raw.signal,
|
|
355
|
+
...(body?.files?.length ? { files: body.files } : {}),
|
|
356
|
+
...(readOnly ? { memory: { readOnly: true } } : {}),
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
if (action === 'stream') {
|
|
360
|
+
return streamResponse(c, agent, input as AgentInput, callOptions, {
|
|
361
|
+
rest, agentId, action, started,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
let result: AgentGenerateResult | undefined;
|
|
366
|
+
let response: any;
|
|
367
|
+
|
|
368
|
+
switch (action) {
|
|
369
|
+
case 'info':
|
|
370
|
+
// No model call, no token spent
|
|
371
|
+
response = { agent: agent.toJSON() };
|
|
372
|
+
break;
|
|
373
|
+
|
|
374
|
+
case 'history': {
|
|
375
|
+
if (!agent.getMemory()) {
|
|
376
|
+
throw new AppError(`Agent '${agentId}' declares no memory`, { status: 400, code: 'AGENT_NO_MEMORY' });
|
|
377
|
+
}
|
|
378
|
+
// The same `resource` the run used: it namespaces the thread
|
|
379
|
+
response = {
|
|
380
|
+
messages: await agent.getMessages(requireThread(body), { resource }),
|
|
381
|
+
};
|
|
382
|
+
break;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
case 'threads': {
|
|
386
|
+
if (!agent.getMemory()) {
|
|
387
|
+
throw new AppError(`Agent '${agentId}' declares no memory`, { status: 400, code: 'AGENT_NO_MEMORY' });
|
|
388
|
+
}
|
|
389
|
+
if (!agent.getMemory()?.list) {
|
|
390
|
+
throw new AppError(
|
|
391
|
+
`The memory of agent '${agentId}' does not list threads`,
|
|
392
|
+
{ status: 400, code: 'AGENT_MEMORY_NO_LIST' },
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
response = { threads: await agent.listThreads({ resource, limit: body?.limit }) };
|
|
396
|
+
break;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
case 'clear': {
|
|
400
|
+
if (!agent.getMemory()) {
|
|
401
|
+
throw new AppError(`Agent '${agentId}' declares no memory`, { status: 400, code: 'AGENT_NO_MEMORY' });
|
|
402
|
+
}
|
|
403
|
+
await agent.clearMessages(requireThread(body), { resource });
|
|
404
|
+
response = { ok: true };
|
|
405
|
+
break;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
case 'generate': {
|
|
409
|
+
const generated = await agent.generate(input as AgentInput, callOptions);
|
|
410
|
+
result = generated;
|
|
411
|
+
response = presentResult(generated);
|
|
412
|
+
break;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
case 'object': {
|
|
416
|
+
const structuredOutput = api?.object;
|
|
417
|
+
if (!structuredOutput?.schema) {
|
|
418
|
+
throw new AppError(
|
|
419
|
+
`Agent '${agentId}' declares no \`api.object\` schema`,
|
|
420
|
+
{ status: 400, code: 'AGENT_NO_OBJECT_SCHEMA' },
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
const generated = await agent.generate(input as AgentInput, { ...callOptions, structuredOutput });
|
|
424
|
+
result = generated;
|
|
425
|
+
response = { ...presentResult(generated), object: generated.object };
|
|
426
|
+
break;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
default:
|
|
430
|
+
throw new AppError(`Action '${action}' not found`, { status: 400, code: 'ACTION_NOT_FOUND' });
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
await auditAgent({ rest, agentId, action, input, thread, result, started });
|
|
434
|
+
return c.json(response);
|
|
435
|
+
} catch (err: any) {
|
|
436
|
+
if (cfg?.debug) console.error(err);
|
|
437
|
+
if (rest && tenant_id && agentId) {
|
|
438
|
+
await rest.audit.log({
|
|
439
|
+
action: `agent.${action || 'unknown'}`,
|
|
440
|
+
collection: `_agents_:${agentId}`,
|
|
441
|
+
input: { agent: agentId },
|
|
442
|
+
error: { message: err?.message, code: err?.code || 'INTERNAL_AGENT_ERROR' },
|
|
443
|
+
duration: Date.now() - started,
|
|
444
|
+
}).catch(() => {});
|
|
445
|
+
}
|
|
446
|
+
return errorResponse(c, err);
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export { initializeAgents, AGENT_PREFIX };
|