@data-club/ai-hub-server 0.0.1
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/dist/auth/argon2.d.ts +72 -0
- package/dist/auth/argon2.d.ts.map +1 -0
- package/dist/auth/argon2.js +145 -0
- package/dist/auth/argon2.js.map +1 -0
- package/dist/auth/encryption.d.ts +52 -0
- package/dist/auth/encryption.d.ts.map +1 -0
- package/dist/auth/encryption.js +98 -0
- package/dist/auth/encryption.js.map +1 -0
- package/dist/auth/jwt.d.ts +133 -0
- package/dist/auth/jwt.d.ts.map +1 -0
- package/dist/auth/jwt.js +147 -0
- package/dist/auth/jwt.js.map +1 -0
- package/dist/auth/middleware.d.ts +101 -0
- package/dist/auth/middleware.d.ts.map +1 -0
- package/dist/auth/middleware.js +161 -0
- package/dist/auth/middleware.js.map +1 -0
- package/dist/auth/passwordPolicy.d.ts +72 -0
- package/dist/auth/passwordPolicy.d.ts.map +1 -0
- package/dist/auth/passwordPolicy.js +88 -0
- package/dist/auth/passwordPolicy.js.map +1 -0
- package/dist/auth/roleRules.d.ts +200 -0
- package/dist/auth/roleRules.d.ts.map +1 -0
- package/dist/auth/roleRules.js +365 -0
- package/dist/auth/roleRules.js.map +1 -0
- package/dist/auth/userView.d.ts +55 -0
- package/dist/auth/userView.d.ts.map +1 -0
- package/dist/auth/userView.js +77 -0
- package/dist/auth/userView.js.map +1 -0
- package/dist/bootstrap.d.ts +104 -0
- package/dist/bootstrap.d.ts.map +1 -0
- package/dist/bootstrap.js +160 -0
- package/dist/bootstrap.js.map +1 -0
- package/dist/db/connection.d.ts +39 -0
- package/dist/db/connection.d.ts.map +1 -0
- package/dist/db/connection.js +62 -0
- package/dist/db/connection.js.map +1 -0
- package/dist/db/migrate.d.ts +31 -0
- package/dist/db/migrate.d.ts.map +1 -0
- package/dist/db/migrate.js +197 -0
- package/dist/db/migrate.js.map +1 -0
- package/dist/db/schema.d.ts +1902 -0
- package/dist/db/schema.d.ts.map +1 -0
- package/dist/db/schema.js +662 -0
- package/dist/db/schema.js.map +1 -0
- package/dist/db/seed.d.ts +92 -0
- package/dist/db/seed.d.ts.map +1 -0
- package/dist/db/seed.js +160 -0
- package/dist/db/seed.js.map +1 -0
- package/dist/handlers/fetch.d.ts +89 -0
- package/dist/handlers/fetch.d.ts.map +1 -0
- package/dist/handlers/fetch.js +173 -0
- package/dist/handlers/fetch.js.map +1 -0
- package/dist/handlers/nextjs.d.ts +40 -0
- package/dist/handlers/nextjs.d.ts.map +1 -0
- package/dist/handlers/nextjs.js +42 -0
- package/dist/handlers/nextjs.js.map +1 -0
- package/dist/handlers/responses.d.ts +46 -0
- package/dist/handlers/responses.d.ts.map +1 -0
- package/dist/handlers/responses.js +74 -0
- package/dist/handlers/responses.js.map +1 -0
- package/dist/handlers/routeTable.d.ts +21 -0
- package/dist/handlers/routeTable.d.ts.map +1 -0
- package/dist/handlers/routeTable.js +133 -0
- package/dist/handlers/routeTable.js.map +1 -0
- package/dist/handlers/router.d.ts +37 -0
- package/dist/handlers/router.d.ts.map +1 -0
- package/dist/handlers/router.js +82 -0
- package/dist/handlers/router.js.map +1 -0
- package/dist/handlers/sse.d.ts +38 -0
- package/dist/handlers/sse.d.ts.map +1 -0
- package/dist/handlers/sse.js +52 -0
- package/dist/handlers/sse.js.map +1 -0
- package/dist/handlers/types.d.ts +96 -0
- package/dist/handlers/types.d.ts.map +1 -0
- package/dist/handlers/types.js +14 -0
- package/dist/handlers/types.js.map +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/letta/client.d.ts +355 -0
- package/dist/letta/client.d.ts.map +1 -0
- package/dist/letta/client.js +529 -0
- package/dist/letta/client.js.map +1 -0
- package/dist/letta/eventTranslator.d.ts +166 -0
- package/dist/letta/eventTranslator.d.ts.map +1 -0
- package/dist/letta/eventTranslator.js +532 -0
- package/dist/letta/eventTranslator.js.map +1 -0
- package/dist/letta/index.d.ts +13 -0
- package/dist/letta/index.d.ts.map +1 -0
- package/dist/letta/index.js +12 -0
- package/dist/letta/index.js.map +1 -0
- package/dist/letta/modelSettings.d.ts +176 -0
- package/dist/letta/modelSettings.d.ts.map +1 -0
- package/dist/letta/modelSettings.js +314 -0
- package/dist/letta/modelSettings.js.map +1 -0
- package/dist/letta/promptWrapping.d.ts +141 -0
- package/dist/letta/promptWrapping.d.ts.map +1 -0
- package/dist/letta/promptWrapping.js +213 -0
- package/dist/letta/promptWrapping.js.map +1 -0
- package/dist/letta/sandbox.d.ts +100 -0
- package/dist/letta/sandbox.d.ts.map +1 -0
- package/dist/letta/sandbox.js +151 -0
- package/dist/letta/sandbox.js.map +1 -0
- package/dist/letta/settingsFileSeed.d.ts +146 -0
- package/dist/letta/settingsFileSeed.d.ts.map +1 -0
- package/dist/letta/settingsFileSeed.js +177 -0
- package/dist/letta/settingsFileSeed.js.map +1 -0
- package/dist/letta/subprocessThrottle.d.ts +151 -0
- package/dist/letta/subprocessThrottle.d.ts.map +1 -0
- package/dist/letta/subprocessThrottle.js +195 -0
- package/dist/letta/subprocessThrottle.js.map +1 -0
- package/dist/letta/tpmRecovery.d.ts +202 -0
- package/dist/letta/tpmRecovery.d.ts.map +1 -0
- package/dist/letta/tpmRecovery.js +314 -0
- package/dist/letta/tpmRecovery.js.map +1 -0
- package/dist/letta/types.d.ts +54 -0
- package/dist/letta/types.d.ts.map +1 -0
- package/dist/letta/types.js +16 -0
- package/dist/letta/types.js.map +1 -0
- package/dist/observability/logger.d.ts +48 -0
- package/dist/observability/logger.d.ts.map +1 -0
- package/dist/observability/logger.js +81 -0
- package/dist/observability/logger.js.map +1 -0
- package/dist/observability/metrics.d.ts +53 -0
- package/dist/observability/metrics.d.ts.map +1 -0
- package/dist/observability/metrics.js +92 -0
- package/dist/observability/metrics.js.map +1 -0
- package/dist/routes/admin/agents.d.ts +63 -0
- package/dist/routes/admin/agents.d.ts.map +1 -0
- package/dist/routes/admin/agents.js +461 -0
- package/dist/routes/admin/agents.js.map +1 -0
- package/dist/routes/admin/rotatingMessages.d.ts +51 -0
- package/dist/routes/admin/rotatingMessages.d.ts.map +1 -0
- package/dist/routes/admin/rotatingMessages.js +292 -0
- package/dist/routes/admin/rotatingMessages.js.map +1 -0
- package/dist/routes/admin/sessions.d.ts +61 -0
- package/dist/routes/admin/sessions.d.ts.map +1 -0
- package/dist/routes/admin/sessions.js +232 -0
- package/dist/routes/admin/sessions.js.map +1 -0
- package/dist/routes/admin/settings.d.ts +110 -0
- package/dist/routes/admin/settings.d.ts.map +1 -0
- package/dist/routes/admin/settings.js +401 -0
- package/dist/routes/admin/settings.js.map +1 -0
- package/dist/routes/admin/users.d.ts +115 -0
- package/dist/routes/admin/users.d.ts.map +1 -0
- package/dist/routes/admin/users.js +374 -0
- package/dist/routes/admin/users.js.map +1 -0
- package/dist/routes/agents.d.ts +30 -0
- package/dist/routes/agents.d.ts.map +1 -0
- package/dist/routes/agents.js +55 -0
- package/dist/routes/agents.js.map +1 -0
- package/dist/routes/auth.d.ts +121 -0
- package/dist/routes/auth.d.ts.map +1 -0
- package/dist/routes/auth.js +422 -0
- package/dist/routes/auth.js.map +1 -0
- package/dist/routes/chat.d.ts +97 -0
- package/dist/routes/chat.d.ts.map +1 -0
- package/dist/routes/chat.js +484 -0
- package/dist/routes/chat.js.map +1 -0
- package/dist/routes/featureToggles.d.ts +34 -0
- package/dist/routes/featureToggles.d.ts.map +1 -0
- package/dist/routes/featureToggles.js +47 -0
- package/dist/routes/featureToggles.js.map +1 -0
- package/dist/routes/health.d.ts +24 -0
- package/dist/routes/health.d.ts.map +1 -0
- package/dist/routes/health.js +37 -0
- package/dist/routes/health.js.map +1 -0
- package/dist/routes/metrics.d.ts +35 -0
- package/dist/routes/metrics.d.ts.map +1 -0
- package/dist/routes/metrics.js +106 -0
- package/dist/routes/metrics.js.map +1 -0
- package/dist/routes/sessions.d.ts +161 -0
- package/dist/routes/sessions.d.ts.map +1 -0
- package/dist/routes/sessions.js +1501 -0
- package/dist/routes/sessions.js.map +1 -0
- package/dist/routes/stubs.d.ts +25 -0
- package/dist/routes/stubs.d.ts.map +1 -0
- package/dist/routes/stubs.js +25 -0
- package/dist/routes/stubs.js.map +1 -0
- package/dist/state/cron.d.ts +58 -0
- package/dist/state/cron.d.ts.map +1 -0
- package/dist/state/cron.js +160 -0
- package/dist/state/cron.js.map +1 -0
- package/dist/state/rotationRunner.d.ts +96 -0
- package/dist/state/rotationRunner.d.ts.map +1 -0
- package/dist/state/rotationRunner.js +246 -0
- package/dist/state/rotationRunner.js.map +1 -0
- package/dist/state/rotationScheduler.d.ts +76 -0
- package/dist/state/rotationScheduler.d.ts.map +1 -0
- package/dist/state/rotationScheduler.js +124 -0
- package/dist/state/rotationScheduler.js.map +1 -0
- package/dist/state/turnRegistry.d.ts +95 -0
- package/dist/state/turnRegistry.d.ts.map +1 -0
- package/dist/state/turnRegistry.js +149 -0
- package/dist/state/turnRegistry.js.map +1 -0
- package/dist/state/turnRunner.d.ts +134 -0
- package/dist/state/turnRunner.d.ts.map +1 -0
- package/dist/state/turnRunner.js +476 -0
- package/dist/state/turnRunner.js.map +1 -0
- package/package.json +47 -0
- package/src/auth/argon2.ts +152 -0
- package/src/auth/encryption.ts +110 -0
- package/src/auth/jwt.ts +217 -0
- package/src/auth/middleware.ts +250 -0
- package/src/auth/passwordPolicy.ts +99 -0
- package/src/auth/roleRules.ts +497 -0
- package/src/auth/userView.ts +88 -0
- package/src/bootstrap.ts +254 -0
- package/src/db/connection.ts +75 -0
- package/src/db/migrate.ts +212 -0
- package/src/db/schema.ts +750 -0
- package/src/db/seed.ts +236 -0
- package/src/handlers/fetch.ts +251 -0
- package/src/handlers/nextjs.ts +49 -0
- package/src/handlers/responses.ts +95 -0
- package/src/handlers/routeTable.ts +188 -0
- package/src/handlers/router.ts +96 -0
- package/src/handlers/sse.ts +55 -0
- package/src/handlers/types.ts +104 -0
- package/src/index.ts +60 -0
- package/src/letta/client.ts +860 -0
- package/src/letta/eventTranslator.ts +667 -0
- package/src/letta/index.ts +46 -0
- package/src/letta/lettaCliShim.mjs +40 -0
- package/src/letta/sandbox.ts +167 -0
- package/src/letta/settingsFileSeed.ts +254 -0
- package/src/letta/subprocessThrottle.ts +251 -0
- package/src/letta/types.ts +65 -0
- package/src/observability/logger.ts +103 -0
- package/src/observability/metrics.ts +114 -0
- package/src/routes/admin/agents.ts +574 -0
- package/src/routes/admin/rotatingMessages.ts +351 -0
- package/src/routes/admin/sessions.ts +283 -0
- package/src/routes/admin/settings.ts +463 -0
- package/src/routes/admin/users.ts +455 -0
- package/src/routes/agents.ts +66 -0
- package/src/routes/auth.ts +513 -0
- package/src/routes/chat.ts +534 -0
- package/src/routes/featureToggles.ts +52 -0
- package/src/routes/health.ts +44 -0
- package/src/routes/metrics.ts +111 -0
- package/src/routes/sessions.ts +1878 -0
- package/src/state/cron.ts +179 -0
- package/src/state/rotationRunner.ts +326 -0
- package/src/state/rotationScheduler.ts +164 -0
- package/src/state/turnRegistry.ts +210 -0
- package/src/state/turnRunner.ts +588 -0
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/admin/agents/*` — power_user + admin + super_admin catalog management.
|
|
3
|
+
*
|
|
4
|
+
* Three endpoints (no DELETE by design — agents are archive-only in v1):
|
|
5
|
+
*
|
|
6
|
+
* - `GET /admin/agents` — every agent in the deployment, including archived
|
|
7
|
+
* ones. Lets the admin UI render the catalog with the archived rows
|
|
8
|
+
* filtered in/out via the `[☐ Show archived]` toggle ([06-admin-agents.md]).
|
|
9
|
+
* - `POST /admin/agents` — body `{ display_name, letta_agent_id, tpm_recovery_enabled?, max_recovery_attempts?, default_retry_after_seconds? }`.
|
|
10
|
+
* Validates `letta_agent_id` against Letta (Step 5 wires the real check;
|
|
11
|
+
* Step 4 stubs it through with a `TODO`).
|
|
12
|
+
* - `PATCH /admin/agents/:id` — body `{ display_name?, letta_agent_id?, tpm_recovery_enabled?, max_recovery_attempts?, default_retry_after_seconds?, is_archived? }`.
|
|
13
|
+
* Archive / unarchive is just `{ is_archived: true | false }`. All fields
|
|
14
|
+
* optional; empty patch is a 200 no-op.
|
|
15
|
+
*
|
|
16
|
+
* **No DELETE.** Per §6.3.4 / §11, agents cannot be hard-deleted in v1 —
|
|
17
|
+
* archiving keeps existing sessions readable + writable while preventing new
|
|
18
|
+
* sessions from starting against the agent. Step 12's UI exposes only
|
|
19
|
+
* archive/unarchive; this server enforces the same.
|
|
20
|
+
*
|
|
21
|
+
* @see SPECIFICATIONS.md §6.3.4 — admin agents endpoints
|
|
22
|
+
* @see SPECIFICATIONS.md §6.5 — TPM recovery defaults
|
|
23
|
+
* @see ui-sketches/06-admin-agents.md
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { randomUUID } from "node:crypto";
|
|
27
|
+
|
|
28
|
+
import { asc, eq } from "drizzle-orm";
|
|
29
|
+
import { z } from "zod";
|
|
30
|
+
|
|
31
|
+
import { protectedRoute } from "../../auth/middleware.js";
|
|
32
|
+
import { agent, rotatingMessage } from "../../db/schema.js";
|
|
33
|
+
import type { AgentRow } from "../../db/schema.js";
|
|
34
|
+
import {
|
|
35
|
+
errorResponse,
|
|
36
|
+
jsonResponse,
|
|
37
|
+
notFound,
|
|
38
|
+
validationError,
|
|
39
|
+
} from "../../handlers/responses.js";
|
|
40
|
+
import type { AIHubServerContext, RouteHandler } from "../../handlers/types.js";
|
|
41
|
+
import { seedDirExists, validateSeedName } from "../../letta/sandbox.js";
|
|
42
|
+
import { seedAgentToolsetNone } from "../../letta/settingsFileSeed.js";
|
|
43
|
+
|
|
44
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
45
|
+
/* Helpers */
|
|
46
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Wire-format view of an `agent` row for admin consumption. Differs from the
|
|
50
|
+
* user-facing `/agents` response: this one includes `letta_agent_id` (admins
|
|
51
|
+
* need to see + edit it), the TPM-recovery fields, and the archive flag.
|
|
52
|
+
*
|
|
53
|
+
* @param row - the agent row.
|
|
54
|
+
* @returns the JSON-safe response shape.
|
|
55
|
+
*/
|
|
56
|
+
function adminAgentView(row: AgentRow): Record<string, unknown> {
|
|
57
|
+
return {
|
|
58
|
+
id: row.id,
|
|
59
|
+
display_name: row.displayName,
|
|
60
|
+
letta_agent_id: row.lettaAgentId,
|
|
61
|
+
tpm_recovery_enabled: row.tpmRecoveryEnabled,
|
|
62
|
+
max_recovery_attempts: row.maxRecoveryAttempts,
|
|
63
|
+
default_retry_after_seconds: row.defaultRetryAfterSeconds,
|
|
64
|
+
stream_idle_timeout_seconds: row.streamIdleTimeoutSeconds,
|
|
65
|
+
sandbox_seed_name: row.sandboxSeedName,
|
|
66
|
+
fork_source_conversation_id: row.forkSourceConversationId,
|
|
67
|
+
is_archived: row.isArchived,
|
|
68
|
+
created_at: row.createdAt,
|
|
69
|
+
updated_at: row.updatedAt,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** ISO-8601 timestamp with millisecond precision. */
|
|
74
|
+
function nowIso(): string {
|
|
75
|
+
return new Date().toISOString();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Validates that `letta_agent_id` references a real Letta-side agent.
|
|
80
|
+
*
|
|
81
|
+
* Calls `ctx.lettaClient.retrieveAgent(...)` (which wraps
|
|
82
|
+
* `client.agents.retrieve` per §6.4 / §6.4.1) and returns `false` when the
|
|
83
|
+
* SDK reports the agent doesn't exist. The handler turns the `false` into
|
|
84
|
+
* a 400 `letta_agent_not_found` so the React modal can surface the error
|
|
85
|
+
* inline on the field ([06-admin-agents.md]). Any non-404 SDK error
|
|
86
|
+
* propagates so the route catch-all returns a 500.
|
|
87
|
+
*
|
|
88
|
+
* @param ctx - server context (carries the Letta client).
|
|
89
|
+
* @param lettaAgentId - candidate id.
|
|
90
|
+
* @returns `true` if the id resolves to a live Letta agent.
|
|
91
|
+
*
|
|
92
|
+
* @see SPECIFICATIONS.md §6.4.1 — first-class SDK surfaces
|
|
93
|
+
*/
|
|
94
|
+
async function validateLettaAgentId(
|
|
95
|
+
ctx: AIHubServerContext,
|
|
96
|
+
lettaAgentId: string,
|
|
97
|
+
): Promise<boolean> {
|
|
98
|
+
const found = await ctx.lettaClient.retrieveAgent(lettaAgentId);
|
|
99
|
+
return found !== null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Step-12.5 — outcome of validating the two new specialized-agent
|
|
104
|
+
* fields (`sandbox_seed_name`, `fork_source_conversation_id`) before a
|
|
105
|
+
* `POST` / `PATCH /admin/agents` row write. Each non-ok variant maps to
|
|
106
|
+
* a single inline-on-field error code the React modal surfaces.
|
|
107
|
+
*/
|
|
108
|
+
type SpecializedFieldsValidation =
|
|
109
|
+
| { kind: "ok" }
|
|
110
|
+
| { kind: "seed_name_invalid" }
|
|
111
|
+
| { kind: "seed_dir_not_found" }
|
|
112
|
+
| { kind: "fork_source_not_found" }
|
|
113
|
+
| { kind: "fork_source_agent_mismatch"; expectedAgentId: string; actualAgentId: string }
|
|
114
|
+
| { kind: "rotating_message_not_found" }
|
|
115
|
+
| { kind: "rotating_message_agent_mismatch"; expectedAgentId: string; actualAgentId: string };
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Validates the (optional) `sandbox_seed_name` and (optional)
|
|
119
|
+
* `fork_source_conversation_id` against the filesystem + Letta. Pure for
|
|
120
|
+
* the success / regex-fail cases; talks to Letta only when a fork source
|
|
121
|
+
* was supplied. The caller maps the outcome to one of:
|
|
122
|
+
* - `400 seed_name_invalid`
|
|
123
|
+
* - `400 seed_dir_not_found`
|
|
124
|
+
* - `400 fork_source_not_found`
|
|
125
|
+
* - `400 fork_source_agent_mismatch`
|
|
126
|
+
*
|
|
127
|
+
* `null` / `undefined` values short-circuit the corresponding check —
|
|
128
|
+
* those fields are explicitly opt-in.
|
|
129
|
+
*
|
|
130
|
+
* @param ctx - server context (filesDir + lettaClient).
|
|
131
|
+
* @param fields - candidate values.
|
|
132
|
+
* @returns the resolved outcome.
|
|
133
|
+
*/
|
|
134
|
+
async function validateSpecializedFields(
|
|
135
|
+
ctx: AIHubServerContext,
|
|
136
|
+
fields: {
|
|
137
|
+
sandboxSeedName: string | null | undefined;
|
|
138
|
+
forkSourceConversationId: string | null | undefined;
|
|
139
|
+
expectedLettaAgentId: string;
|
|
140
|
+
},
|
|
141
|
+
): Promise<SpecializedFieldsValidation> {
|
|
142
|
+
if (
|
|
143
|
+
fields.sandboxSeedName !== undefined &&
|
|
144
|
+
fields.sandboxSeedName !== null &&
|
|
145
|
+
fields.sandboxSeedName.length > 0
|
|
146
|
+
) {
|
|
147
|
+
const nameValidation = validateSeedName(fields.sandboxSeedName);
|
|
148
|
+
if (nameValidation !== "ok") {
|
|
149
|
+
return { kind: "seed_name_invalid" };
|
|
150
|
+
}
|
|
151
|
+
if (!seedDirExists(ctx.filesDir, fields.sandboxSeedName)) {
|
|
152
|
+
return { kind: "seed_dir_not_found" };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (
|
|
156
|
+
fields.forkSourceConversationId !== undefined &&
|
|
157
|
+
fields.forkSourceConversationId !== null &&
|
|
158
|
+
fields.forkSourceConversationId.length > 0
|
|
159
|
+
) {
|
|
160
|
+
const ref = fields.forkSourceConversationId;
|
|
161
|
+
if (ref.startsWith("rmsg-")) {
|
|
162
|
+
// The base is a rotating starting message. Validate against the
|
|
163
|
+
// `rotating_message` catalog instead of Letta — its prepared
|
|
164
|
+
// conversation may not exist yet (no rotation has run), which is fine
|
|
165
|
+
// at write time; `POST /sessions` handles the "not ready" case.
|
|
166
|
+
const rmsg = ctx.db
|
|
167
|
+
.select()
|
|
168
|
+
.from(rotatingMessage)
|
|
169
|
+
.where(eq(rotatingMessage.id, ref))
|
|
170
|
+
.get();
|
|
171
|
+
if (!rmsg || rmsg.isArchived) {
|
|
172
|
+
return { kind: "rotating_message_not_found" };
|
|
173
|
+
}
|
|
174
|
+
if (rmsg.lettaAgentId !== fields.expectedLettaAgentId) {
|
|
175
|
+
return {
|
|
176
|
+
kind: "rotating_message_agent_mismatch",
|
|
177
|
+
expectedAgentId: fields.expectedLettaAgentId,
|
|
178
|
+
actualAgentId: rmsg.lettaAgentId,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
} else {
|
|
182
|
+
const conv = await ctx.lettaClient.retrieveConversation(ref);
|
|
183
|
+
if (!conv) {
|
|
184
|
+
return { kind: "fork_source_not_found" };
|
|
185
|
+
}
|
|
186
|
+
if (conv.agentId !== fields.expectedLettaAgentId) {
|
|
187
|
+
return {
|
|
188
|
+
kind: "fork_source_agent_mismatch",
|
|
189
|
+
expectedAgentId: fields.expectedLettaAgentId,
|
|
190
|
+
actualAgentId: conv.agentId,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return { kind: "ok" };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Maps a `SpecializedFieldsValidation` failure to the canonical
|
|
200
|
+
* `errorResponse(400, code, message)`. Keeps the create + patch handlers
|
|
201
|
+
* tidy.
|
|
202
|
+
*/
|
|
203
|
+
function respondSpecializedFailure(
|
|
204
|
+
failure: Exclude<SpecializedFieldsValidation, { kind: "ok" }>,
|
|
205
|
+
): Response {
|
|
206
|
+
switch (failure.kind) {
|
|
207
|
+
case "seed_name_invalid":
|
|
208
|
+
return errorResponse(
|
|
209
|
+
400,
|
|
210
|
+
"seed_name_invalid",
|
|
211
|
+
"sandbox_seed_name must match ^[a-zA-Z0-9_-]+$ (no slashes, no dots).",
|
|
212
|
+
);
|
|
213
|
+
case "seed_dir_not_found":
|
|
214
|
+
return errorResponse(
|
|
215
|
+
400,
|
|
216
|
+
"seed_dir_not_found",
|
|
217
|
+
"No seed folder with that name was found under AI_HUB_FILES_DIR/seeds/.",
|
|
218
|
+
);
|
|
219
|
+
case "fork_source_not_found":
|
|
220
|
+
return errorResponse(
|
|
221
|
+
400,
|
|
222
|
+
"fork_source_not_found",
|
|
223
|
+
"No Letta conversation with that id was found.",
|
|
224
|
+
);
|
|
225
|
+
case "fork_source_agent_mismatch":
|
|
226
|
+
return errorResponse(
|
|
227
|
+
400,
|
|
228
|
+
"fork_source_agent_mismatch",
|
|
229
|
+
`The fork source belongs to Letta agent ${failure.actualAgentId}, ` +
|
|
230
|
+
`but this ai-hub agent points at ${failure.expectedAgentId}. Letta's fork ` +
|
|
231
|
+
`API requires the new conversation to share the source's agent.`,
|
|
232
|
+
);
|
|
233
|
+
case "rotating_message_not_found":
|
|
234
|
+
return errorResponse(
|
|
235
|
+
400,
|
|
236
|
+
"rotating_message_not_found",
|
|
237
|
+
"No active rotating starting message with that id was found.",
|
|
238
|
+
);
|
|
239
|
+
case "rotating_message_agent_mismatch":
|
|
240
|
+
return errorResponse(
|
|
241
|
+
400,
|
|
242
|
+
"rotating_message_agent_mismatch",
|
|
243
|
+
`The rotating starting message runs against Letta agent ${failure.actualAgentId}, ` +
|
|
244
|
+
`but this ai-hub agent points at ${failure.expectedAgentId}. A rotating message ` +
|
|
245
|
+
`can only back agents that share its Letta agent (Letta forks must be same-agent).`,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Best-effort seed of `~/.letta/settings.json` with `{toolset: "none"}` for
|
|
252
|
+
* the given Letta agent id. Idempotent. Logs the outcome but **does not
|
|
253
|
+
* throw**: if seeding fails (read-only home dir, permission denied, …) the
|
|
254
|
+
* agent creation should still succeed — the toolset suppression is then
|
|
255
|
+
* a runtime concern the operator notices when chats start producing weird
|
|
256
|
+
* file-edit tool calls. The intent here is hands-off automation, not a
|
|
257
|
+
* required precondition.
|
|
258
|
+
*
|
|
259
|
+
* @param ctx - server context (for logging).
|
|
260
|
+
* @param lettaAgentId - id to seed the toolset preference for.
|
|
261
|
+
*
|
|
262
|
+
* @see ../../letta/settingsFileSeed.ts
|
|
263
|
+
*/
|
|
264
|
+
function seedToolsetNoneBestEffort(
|
|
265
|
+
ctx: AIHubServerContext,
|
|
266
|
+
lettaAgentId: string,
|
|
267
|
+
): void {
|
|
268
|
+
try {
|
|
269
|
+
seedAgentToolsetNone({
|
|
270
|
+
agentId: lettaAgentId,
|
|
271
|
+
lettaBaseUrl: process.env.LETTA_BASE_URL,
|
|
272
|
+
logger: ctx.logger,
|
|
273
|
+
});
|
|
274
|
+
} catch (err) {
|
|
275
|
+
ctx.logger.warn(
|
|
276
|
+
{
|
|
277
|
+
agent_id: lettaAgentId,
|
|
278
|
+
err: err instanceof Error ? err.message : String(err),
|
|
279
|
+
event: "letta_code_settings_seed_failed",
|
|
280
|
+
},
|
|
281
|
+
"Failed to seed Letta Code toolset preference; continuing anyway.",
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
287
|
+
/* GET /admin/agents */
|
|
288
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* `GET /admin/agents` — every agent in the deployment, archived rows
|
|
292
|
+
* included. The admin UI filters them client-side via the
|
|
293
|
+
* `[☐ Show archived]` toggle ([06-admin-agents.md]).
|
|
294
|
+
*
|
|
295
|
+
* Sorted by `display_name` for stable rendering.
|
|
296
|
+
*
|
|
297
|
+
* Available to `power_user`+ per the §8.2 matrix.
|
|
298
|
+
*
|
|
299
|
+
* @see SPECIFICATIONS.md §6.3.4
|
|
300
|
+
*/
|
|
301
|
+
export const handleListAgents: RouteHandler = protectedRoute(
|
|
302
|
+
{ requiredRole: "power_user" },
|
|
303
|
+
async (_req, ctx) => {
|
|
304
|
+
const rows = ctx.db.select().from(agent).orderBy(asc(agent.displayName)).all();
|
|
305
|
+
return jsonResponse(200, { agents: rows.map(adminAgentView) });
|
|
306
|
+
},
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
310
|
+
/* POST /admin/agents */
|
|
311
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
312
|
+
|
|
313
|
+
/** Body for `POST /admin/agents`. */
|
|
314
|
+
const createAgentSchema = z
|
|
315
|
+
.object({
|
|
316
|
+
display_name: z.string().min(1).max(255),
|
|
317
|
+
letta_agent_id: z.string().min(1).max(255),
|
|
318
|
+
tpm_recovery_enabled: z.boolean().optional(),
|
|
319
|
+
max_recovery_attempts: z.number().int().min(0).max(10).optional(),
|
|
320
|
+
default_retry_after_seconds: z.number().int().min(1).max(3600).optional(),
|
|
321
|
+
/** Per-agent inter-event idle watchdog (seconds). Default 60. */
|
|
322
|
+
stream_idle_timeout_seconds: z.number().int().min(5).max(3600).optional(),
|
|
323
|
+
/**
|
|
324
|
+
* Step-12.5 — name of the seed folder under `{AI_HUB_FILES_DIR}/seeds/`.
|
|
325
|
+
* `null` or `""` clears the field; non-null strings are validated
|
|
326
|
+
* against the safe-name regex + existence on disk.
|
|
327
|
+
*/
|
|
328
|
+
sandbox_seed_name: z.string().max(128).nullable().optional(),
|
|
329
|
+
/**
|
|
330
|
+
* Step-12.5 — Letta conversation id (`conv-…`) to fork from at session
|
|
331
|
+
* create. `null` or `""` clears the field; non-null strings are
|
|
332
|
+
* validated via `client.conversations.retrieve` + agent-id match.
|
|
333
|
+
*/
|
|
334
|
+
fork_source_conversation_id: z.string().max(255).nullable().optional(),
|
|
335
|
+
})
|
|
336
|
+
.strict();
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* `POST /admin/agents` — create a new ai-hub agent (catalog entry).
|
|
340
|
+
*
|
|
341
|
+
* Validates the Letta-side id via `validateLettaAgentId` (Step 4 stub —
|
|
342
|
+
* always passes; Step 5 wires the real check against the Letta SDK). On a
|
|
343
|
+
* Letta-id miss returns 400 `letta_agent_not_found` with the offending field
|
|
344
|
+
* named, so the React modal can surface the error inline on the field
|
|
345
|
+
* ([06-admin-agents.md]).
|
|
346
|
+
*
|
|
347
|
+
* @see SPECIFICATIONS.md §6.3.4
|
|
348
|
+
* @see ui-sketches/06-admin-agents.md
|
|
349
|
+
*/
|
|
350
|
+
export const handleCreateAgent: RouteHandler = protectedRoute(
|
|
351
|
+
{ requiredRole: "power_user" },
|
|
352
|
+
async (req, ctx, auth) => {
|
|
353
|
+
let raw: unknown;
|
|
354
|
+
try {
|
|
355
|
+
raw = await req.json();
|
|
356
|
+
} catch {
|
|
357
|
+
return validationError("Request body must be valid JSON.");
|
|
358
|
+
}
|
|
359
|
+
const parsed = createAgentSchema.safeParse(raw);
|
|
360
|
+
if (!parsed.success) {
|
|
361
|
+
return validationError(parsed.error.issues[0]?.message ?? "Invalid request body.");
|
|
362
|
+
}
|
|
363
|
+
const body = parsed.data;
|
|
364
|
+
|
|
365
|
+
const ok = await validateLettaAgentId(ctx, body.letta_agent_id);
|
|
366
|
+
if (!ok) {
|
|
367
|
+
return errorResponse(
|
|
368
|
+
400,
|
|
369
|
+
"letta_agent_not_found",
|
|
370
|
+
"No Letta agent with that id was found.",
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Step-12.5 — validate the optional sandbox + fork fields. Runs AFTER
|
|
375
|
+
// the letta_agent_id check so the agent-mismatch error has a clear
|
|
376
|
+
// expected value to compare against (and so an admin entering a bad
|
|
377
|
+
// letta_agent_id only sees that error, not the cascading 400 from
|
|
378
|
+
// the fork validator).
|
|
379
|
+
const specialized = await validateSpecializedFields(ctx, {
|
|
380
|
+
sandboxSeedName: body.sandbox_seed_name,
|
|
381
|
+
forkSourceConversationId: body.fork_source_conversation_id,
|
|
382
|
+
expectedLettaAgentId: body.letta_agent_id,
|
|
383
|
+
});
|
|
384
|
+
if (specialized.kind !== "ok") {
|
|
385
|
+
return respondSpecializedFailure(specialized);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const id = randomUUID();
|
|
389
|
+
// Empty strings normalize to null so the storage matches the
|
|
390
|
+
// "field cleared" semantics the React modal sends.
|
|
391
|
+
const normalizedSeedName =
|
|
392
|
+
body.sandbox_seed_name && body.sandbox_seed_name.length > 0
|
|
393
|
+
? body.sandbox_seed_name
|
|
394
|
+
: null;
|
|
395
|
+
const normalizedForkSrc =
|
|
396
|
+
body.fork_source_conversation_id &&
|
|
397
|
+
body.fork_source_conversation_id.length > 0
|
|
398
|
+
? body.fork_source_conversation_id
|
|
399
|
+
: null;
|
|
400
|
+
ctx.db
|
|
401
|
+
.insert(agent)
|
|
402
|
+
.values({
|
|
403
|
+
id,
|
|
404
|
+
displayName: body.display_name,
|
|
405
|
+
lettaAgentId: body.letta_agent_id,
|
|
406
|
+
tpmRecoveryEnabled: body.tpm_recovery_enabled ?? false,
|
|
407
|
+
maxRecoveryAttempts: body.max_recovery_attempts ?? 2,
|
|
408
|
+
defaultRetryAfterSeconds: body.default_retry_after_seconds ?? 65,
|
|
409
|
+
streamIdleTimeoutSeconds: body.stream_idle_timeout_seconds ?? 60,
|
|
410
|
+
sandboxSeedName: normalizedSeedName,
|
|
411
|
+
forkSourceConversationId: normalizedForkSrc,
|
|
412
|
+
isArchived: false,
|
|
413
|
+
})
|
|
414
|
+
.run();
|
|
415
|
+
const row = ctx.db.select().from(agent).where(eq(agent.id, id)).get();
|
|
416
|
+
if (!row) throw new Error("Just-inserted agent vanished.");
|
|
417
|
+
|
|
418
|
+
// Seed `~/.letta/settings.json` so Letta Code's bundled CLI suppresses
|
|
419
|
+
// its auto-toolset (Read/Write/Bash/etc.) when chat sessions spawn
|
|
420
|
+
// against this agent. ai-hub wants only the tools the ADE admin
|
|
421
|
+
// explicitly attached. Best-effort: a write failure logs + continues.
|
|
422
|
+
seedToolsetNoneBestEffort(ctx, row.lettaAgentId);
|
|
423
|
+
|
|
424
|
+
ctx.logger.info(
|
|
425
|
+
{
|
|
426
|
+
actor_id: auth.user.id,
|
|
427
|
+
agent_id: row.id,
|
|
428
|
+
letta_agent_id: row.lettaAgentId,
|
|
429
|
+
event: "admin_agent_created",
|
|
430
|
+
},
|
|
431
|
+
"Admin created an ai-hub agent.",
|
|
432
|
+
);
|
|
433
|
+
return jsonResponse(201, adminAgentView(row));
|
|
434
|
+
},
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
438
|
+
/* PATCH /admin/agents/:id */
|
|
439
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
440
|
+
|
|
441
|
+
/** Body for `PATCH /admin/agents/:id`. All fields optional; empty patch is a no-op. */
|
|
442
|
+
const patchAgentSchema = z
|
|
443
|
+
.object({
|
|
444
|
+
display_name: z.string().min(1).max(255).optional(),
|
|
445
|
+
letta_agent_id: z.string().min(1).max(255).optional(),
|
|
446
|
+
tpm_recovery_enabled: z.boolean().optional(),
|
|
447
|
+
max_recovery_attempts: z.number().int().min(0).max(10).optional(),
|
|
448
|
+
default_retry_after_seconds: z.number().int().min(1).max(3600).optional(),
|
|
449
|
+
stream_idle_timeout_seconds: z.number().int().min(5).max(3600).optional(),
|
|
450
|
+
is_archived: z.boolean().optional(),
|
|
451
|
+
/** Step-12.5 — nullable to support explicit clear. */
|
|
452
|
+
sandbox_seed_name: z.string().max(128).nullable().optional(),
|
|
453
|
+
/** Step-12.5 — nullable to support explicit clear. */
|
|
454
|
+
fork_source_conversation_id: z.string().max(255).nullable().optional(),
|
|
455
|
+
})
|
|
456
|
+
.strict();
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* `PATCH /admin/agents/:id` — partial update.
|
|
460
|
+
*
|
|
461
|
+
* If `letta_agent_id` is in the patch, the same Letta-side validation runs
|
|
462
|
+
* as on create. `is_archived` is the archive / unarchive toggle (no separate
|
|
463
|
+
* endpoint). All fields optional; an empty patch (`{}`) is a 200 no-op that
|
|
464
|
+
* echoes the row unchanged.
|
|
465
|
+
*
|
|
466
|
+
* @see SPECIFICATIONS.md §6.3.4
|
|
467
|
+
* @see ui-sketches/06-admin-agents.md
|
|
468
|
+
*/
|
|
469
|
+
export const handlePatchAgent: RouteHandler = protectedRoute(
|
|
470
|
+
{ requiredRole: "power_user" },
|
|
471
|
+
async (req, ctx, auth, params) => {
|
|
472
|
+
const id = params.id;
|
|
473
|
+
if (!id) return notFound();
|
|
474
|
+
let raw: unknown;
|
|
475
|
+
try {
|
|
476
|
+
raw = await req.json();
|
|
477
|
+
} catch {
|
|
478
|
+
return validationError("Request body must be valid JSON.");
|
|
479
|
+
}
|
|
480
|
+
const parsed = patchAgentSchema.safeParse(raw);
|
|
481
|
+
if (!parsed.success) {
|
|
482
|
+
return validationError(parsed.error.issues[0]?.message ?? "Invalid request body.");
|
|
483
|
+
}
|
|
484
|
+
const body = parsed.data;
|
|
485
|
+
|
|
486
|
+
const existing = ctx.db.select().from(agent).where(eq(agent.id, id)).get();
|
|
487
|
+
if (!existing) return notFound("Agent not found.");
|
|
488
|
+
|
|
489
|
+
if (body.letta_agent_id !== undefined && body.letta_agent_id !== existing.lettaAgentId) {
|
|
490
|
+
const ok = await validateLettaAgentId(ctx, body.letta_agent_id);
|
|
491
|
+
if (!ok) {
|
|
492
|
+
return errorResponse(
|
|
493
|
+
400,
|
|
494
|
+
"letta_agent_not_found",
|
|
495
|
+
"No Letta agent with that id was found.",
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Step-12.5 — validate the specialized fields against the
|
|
501
|
+
// *effective* letta_agent_id (the patched value when supplied,
|
|
502
|
+
// otherwise the existing one). Only validates fields touched by
|
|
503
|
+
// this PATCH; unchanged fields are skipped.
|
|
504
|
+
const effectiveLettaAgentId = body.letta_agent_id ?? existing.lettaAgentId;
|
|
505
|
+
const seedTouched = body.sandbox_seed_name !== undefined;
|
|
506
|
+
const forkTouched = body.fork_source_conversation_id !== undefined;
|
|
507
|
+
if (seedTouched || forkTouched) {
|
|
508
|
+
const specialized = await validateSpecializedFields(ctx, {
|
|
509
|
+
sandboxSeedName: seedTouched ? body.sandbox_seed_name : null,
|
|
510
|
+
forkSourceConversationId: forkTouched
|
|
511
|
+
? body.fork_source_conversation_id
|
|
512
|
+
: null,
|
|
513
|
+
expectedLettaAgentId: effectiveLettaAgentId,
|
|
514
|
+
});
|
|
515
|
+
if (specialized.kind !== "ok") {
|
|
516
|
+
return respondSpecializedFailure(specialized);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const update: Partial<typeof agent.$inferInsert> = { updatedAt: nowIso() };
|
|
521
|
+
if (body.display_name !== undefined) update.displayName = body.display_name;
|
|
522
|
+
if (body.letta_agent_id !== undefined) update.lettaAgentId = body.letta_agent_id;
|
|
523
|
+
if (body.tpm_recovery_enabled !== undefined)
|
|
524
|
+
update.tpmRecoveryEnabled = body.tpm_recovery_enabled;
|
|
525
|
+
if (body.max_recovery_attempts !== undefined)
|
|
526
|
+
update.maxRecoveryAttempts = body.max_recovery_attempts;
|
|
527
|
+
if (body.default_retry_after_seconds !== undefined)
|
|
528
|
+
update.defaultRetryAfterSeconds = body.default_retry_after_seconds;
|
|
529
|
+
if (body.stream_idle_timeout_seconds !== undefined)
|
|
530
|
+
update.streamIdleTimeoutSeconds = body.stream_idle_timeout_seconds;
|
|
531
|
+
if (body.is_archived !== undefined) update.isArchived = body.is_archived;
|
|
532
|
+
// Empty strings + null both normalize to null (consistent with create).
|
|
533
|
+
if (body.sandbox_seed_name !== undefined) {
|
|
534
|
+
update.sandboxSeedName =
|
|
535
|
+
body.sandbox_seed_name && body.sandbox_seed_name.length > 0
|
|
536
|
+
? body.sandbox_seed_name
|
|
537
|
+
: null;
|
|
538
|
+
}
|
|
539
|
+
if (body.fork_source_conversation_id !== undefined) {
|
|
540
|
+
update.forkSourceConversationId =
|
|
541
|
+
body.fork_source_conversation_id &&
|
|
542
|
+
body.fork_source_conversation_id.length > 0
|
|
543
|
+
? body.fork_source_conversation_id
|
|
544
|
+
: null;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
ctx.db.update(agent).set(update).where(eq(agent.id, id)).run();
|
|
548
|
+
const fresh = ctx.db.select().from(agent).where(eq(agent.id, id)).get();
|
|
549
|
+
if (!fresh) return notFound();
|
|
550
|
+
|
|
551
|
+
// If the patch re-pointed the ai-hub agent at a different Letta agent,
|
|
552
|
+
// seed the new agent's toolset preference too. (We deliberately do NOT
|
|
553
|
+
// remove the old agent's seeded record — it may still be backing live
|
|
554
|
+
// sessions per the §11 schema note: existing sessions keep their
|
|
555
|
+
// original `letta_conversation_id` against the original Letta agent.)
|
|
556
|
+
if (
|
|
557
|
+
body.letta_agent_id !== undefined &&
|
|
558
|
+
body.letta_agent_id !== existing.lettaAgentId
|
|
559
|
+
) {
|
|
560
|
+
seedToolsetNoneBestEffort(ctx, body.letta_agent_id);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
ctx.logger.info(
|
|
564
|
+
{
|
|
565
|
+
actor_id: auth.user.id,
|
|
566
|
+
agent_id: id,
|
|
567
|
+
patch_keys: Object.keys(body),
|
|
568
|
+
event: "admin_agent_patched",
|
|
569
|
+
},
|
|
570
|
+
"Admin updated an ai-hub agent.",
|
|
571
|
+
);
|
|
572
|
+
return jsonResponse(200, adminAgentView(fresh));
|
|
573
|
+
},
|
|
574
|
+
);
|