create-loopengine 0.1.9 → 0.1.12
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/cli.js +5 -1
- package/package.json +1 -1
- package/template/.env.example +8 -2
- package/template/README.md +17 -0
- package/template/_gitignore +6 -0
- package/template/adapters/cli.ts +70 -11
- package/template/adapters/http.ts +1837 -36
- package/template/agent-registry.ts +58 -6
- package/template/package.json +3 -1
|
@@ -1,7 +1,18 @@
|
|
|
1
|
-
// Channel adapter #2: HTTP API.
|
|
1
|
+
// Channel adapter #2: HTTP API. Deliberately built on node:http, not a
|
|
2
|
+
// framework — the point is that runAgent doesn't care what's calling it,
|
|
3
|
+
// so the adapter has no special integration surface to show off.
|
|
2
4
|
//
|
|
3
5
|
// npm run dev
|
|
6
|
+
// # open http://localhost:8787/agents/config for the Admin UI, or
|
|
7
|
+
// # http://localhost:8787/playground to talk to an agent in a browser
|
|
4
8
|
//
|
|
9
|
+
// # single JSON response, once the whole loop finishes — an agent can
|
|
10
|
+
// # define its own sessionIdFor (see AgentConfig.sessionIdFor) to key
|
|
11
|
+
// # sessions off something richer than a plain client-supplied id; the
|
|
12
|
+
// # default fallback takes a plain sessionId field instead, or omits it
|
|
13
|
+
// # entirely for a fresh one-off session (see defaultSessionIdFor
|
|
14
|
+
// # below) — either way the response body echoes back whichever
|
|
15
|
+
// # sessionId was actually used
|
|
5
16
|
// curl -X POST localhost:8787/agents/example-agent/messages \
|
|
6
17
|
// -H 'content-type: application/json' \
|
|
7
18
|
// -d '{"message":"what is the weather in Boston?"}'
|
|
@@ -11,35 +22,173 @@
|
|
|
11
22
|
// -H 'content-type: application/json' \
|
|
12
23
|
// -d '{"message":"what is the weather in Boston?"}'
|
|
13
24
|
//
|
|
14
|
-
// sessionId is optional — omit it for a fresh, one-off session (a
|
|
15
|
-
// generated id, echoed back in the response as sessionId so you can pass
|
|
16
|
-
// it in explicitly on a later request to continue that same conversation).
|
|
17
|
-
//
|
|
18
25
|
// Owns: routing by agent name, request/response shape, and (via
|
|
19
26
|
// SessionStore.withSession) making sure two concurrent requests for the
|
|
20
|
-
// same session don't race on read-modify-write of that session's
|
|
21
|
-
|
|
27
|
+
// *same* session don't race on read-modify-write of that session's
|
|
28
|
+
// history. What counts as "the same session" is deliberately not this
|
|
29
|
+
// file's call — see AgentConfig.sessionIdFor and defaultSessionIdFor below.
|
|
30
|
+
import { randomUUID, timingSafeEqual } from 'node:crypto'
|
|
22
31
|
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
|
|
23
|
-
import {
|
|
24
|
-
import { getEntry, type RegistryEntry } from '../agent-registry.js'
|
|
32
|
+
import { pathToFileURL } from 'node:url'
|
|
33
|
+
import { getEntry, listAgents, projectDir, registerAgent, updateAgent, type RegistryEntry } from '../agent-registry.js'
|
|
34
|
+
import {
|
|
35
|
+
loadAgentModule,
|
|
36
|
+
synthesizeCreateModelCall,
|
|
37
|
+
editAgentFile,
|
|
38
|
+
createHttpTool,
|
|
39
|
+
HttpToolNameError,
|
|
40
|
+
HttpToolExistsError,
|
|
41
|
+
HttpToolIndexShapeError,
|
|
42
|
+
type HttpToolSpec,
|
|
43
|
+
AgentEditNotSupportedError,
|
|
44
|
+
AgentFileNotFoundError,
|
|
45
|
+
type AgentEditResult,
|
|
46
|
+
createSessionStore,
|
|
47
|
+
createCheckpointStore,
|
|
48
|
+
type TurnCheckpoint,
|
|
49
|
+
runAgent,
|
|
50
|
+
resumeAgent,
|
|
51
|
+
loadRules,
|
|
52
|
+
loadDefaultTools,
|
|
53
|
+
loadSubagentAsTools,
|
|
54
|
+
systemTools,
|
|
55
|
+
systemSkillsDir,
|
|
56
|
+
type AgentConfig,
|
|
57
|
+
type ToolDefinition,
|
|
58
|
+
type ModelContentBlock,
|
|
59
|
+
type RunAgentResult,
|
|
60
|
+
playgroundHtml,
|
|
61
|
+
agentsConfigPageHtml,
|
|
62
|
+
agentsListPageHtml,
|
|
63
|
+
globalConfigPageHtml,
|
|
64
|
+
addGatewayTool,
|
|
65
|
+
agentDir,
|
|
66
|
+
disconnectComposioAccount,
|
|
67
|
+
describeGatewayTools,
|
|
68
|
+
listComposioConnections,
|
|
69
|
+
listComposioTools,
|
|
70
|
+
loadGatewayToolsFromDir,
|
|
71
|
+
removeGatewayTool,
|
|
72
|
+
removeGatewayToolSlug,
|
|
73
|
+
GatewayToolExistsError,
|
|
74
|
+
GatewayToolNotFoundError,
|
|
75
|
+
type GatewayToolEntry,
|
|
76
|
+
type GatewayToolDecision,
|
|
77
|
+
readSkill,
|
|
78
|
+
writeSkill,
|
|
79
|
+
deleteSkill,
|
|
80
|
+
SkillInvalidIdError,
|
|
81
|
+
SkillNotFoundError,
|
|
82
|
+
readActauthConfig,
|
|
83
|
+
addActauthRule,
|
|
84
|
+
updateActauthRule,
|
|
85
|
+
removeActauthRule,
|
|
86
|
+
setDefaultDecision,
|
|
87
|
+
ActauthRuleExistsError,
|
|
88
|
+
ActauthRuleNotFoundError,
|
|
89
|
+
describeModelProviders,
|
|
90
|
+
describeGateways,
|
|
91
|
+
scaffoldAgent,
|
|
92
|
+
AgentNameError,
|
|
93
|
+
AgentExistsError,
|
|
94
|
+
AgentModelError,
|
|
95
|
+
type AgentTemplateOptions,
|
|
96
|
+
createTrackedApprover,
|
|
97
|
+
listApprovals,
|
|
98
|
+
decideApproval,
|
|
99
|
+
findApproval,
|
|
100
|
+
listQuestions,
|
|
101
|
+
// answerQuestion here is the server-side resolver (aliased from
|
|
102
|
+
// loopengine's own resolvePendingQuestion) — not the HTTP client-side
|
|
103
|
+
// helper of the same name loopengine also exports for a browser/Node
|
|
104
|
+
// caller talking *to* this file's own routes; this file is the server,
|
|
105
|
+
// so it needs the former.
|
|
106
|
+
resolvePendingQuestion as answerQuestion,
|
|
107
|
+
findQuestion,
|
|
108
|
+
createAskUserTool,
|
|
109
|
+
type PendingQuestion,
|
|
110
|
+
WebhookNotifier,
|
|
111
|
+
} from 'loopengine'
|
|
112
|
+
import { SkillGarden } from 'skillgarden'
|
|
113
|
+
import type { Decision, PendingApproval } from 'actauth'
|
|
114
|
+
import type { LoopEvent } from 'loopengine'
|
|
25
115
|
|
|
26
116
|
const sessions = createSessionStore()
|
|
117
|
+
const checkpoints = createCheckpointStore()
|
|
118
|
+
|
|
119
|
+
// The plain (non-streaming) POST /messages route's own built-in default
|
|
120
|
+
// for the 'http' channel — durable when configured, since that route
|
|
121
|
+
// already has no live connection of its own to hold open (see
|
|
122
|
+
// handleMessages' own header comment). One shared instance, not
|
|
123
|
+
// per-request: unlike WebchatApprover, nothing about WebhookNotifier
|
|
124
|
+
// needs to be scoped to a specific turn/connection — and, same "it's
|
|
125
|
+
// channel-specific, not concern-specific" reasoning
|
|
126
|
+
// core/http-notify-triggers/webhook.ts's own doc comment gives, one
|
|
127
|
+
// instance covers both the approval and question default, not two
|
|
128
|
+
// separately-configured ones (an earlier version of this file supported
|
|
129
|
+
// a separate LOOPENGINE_DEFAULT_QUESTION_WEBHOOK_URL/_SECRET pair for the
|
|
130
|
+
// question side specifically — dropped once WebhookNotifier's own
|
|
131
|
+
// one-target-for-both shape made that a real, not just cosmetic,
|
|
132
|
+
// simplification; a deployment that genuinely needs different targets
|
|
133
|
+
// per concern can still set AgentConfig.httpNotifier per agent instead,
|
|
134
|
+
// which wins outright over this default anyway). Deployment-wide env
|
|
135
|
+
// vars, same pattern REDIS_URL/LOOPENGINE_ADMIN_AUTH already use —
|
|
136
|
+
// there's no sensible webhook target to invent without one configured,
|
|
137
|
+
// so this is undefined (handleMessages falls back to today's live
|
|
138
|
+
// WebchatApprover instead, not all the way to ConsoleApprover) rather
|
|
139
|
+
// than guessed.
|
|
140
|
+
const defaultHttpWebhookUrl = process.env.LOOPENGINE_DEFAULT_WEBHOOK_URL
|
|
141
|
+
const defaultHttpWebhookSecret = process.env.LOOPENGINE_DEFAULT_WEBHOOK_SECRET
|
|
142
|
+
const defaultHttpWebhookNotifier =
|
|
143
|
+
defaultHttpWebhookUrl && defaultHttpWebhookSecret
|
|
144
|
+
? new WebhookNotifier({ webhookUrl: defaultHttpWebhookUrl, signingSecret: defaultHttpWebhookSecret })
|
|
145
|
+
: undefined
|
|
27
146
|
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
147
|
+
// Used when an AgentConfig doesn't define its own sessionIdFor — a plain
|
|
148
|
+
// client-supplied key, same shape adapters/cli.ts's --session flag
|
|
149
|
+
// already uses. Deriving a session key from something richer (a
|
|
150
|
+
// customer's email, a Slack channel, a support ticket ID, ...) is
|
|
151
|
+
// business logic specific to what that agent is for, so it belongs on
|
|
152
|
+
// the AgentConfig, not hardcoded here for every agent this adapter might
|
|
153
|
+
// ever route to.
|
|
33
154
|
function defaultSessionIdFor(body: Record<string, unknown>): string | undefined {
|
|
34
155
|
const sessionId = body.sessionId
|
|
35
156
|
return typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : undefined
|
|
36
157
|
}
|
|
37
158
|
|
|
159
|
+
// An agent-defined sessionIdFor always wins outright when present — for
|
|
160
|
+
// customer-service that's a real, stable identity (a customerEmail hash),
|
|
161
|
+
// and it returning undefined (no customerEmail in the body) is a genuine
|
|
162
|
+
// validation failure, not "start me an anonymous session." Only the
|
|
163
|
+
// *default*, agent-agnostic fallback auto-generates one: a missing
|
|
164
|
+
// sessionId there just means "no ongoing conversation to resume," the
|
|
165
|
+
// same thing omitting --session now means for adapters/cli.ts, not an
|
|
166
|
+
// error — see the response body for how the caller learns what id got
|
|
167
|
+
// used.
|
|
38
168
|
function sessionIdFor(config: AgentConfig, body: Record<string, unknown>): string | undefined {
|
|
39
169
|
if (config.sessionIdFor) return config.sessionIdFor(body)
|
|
40
170
|
return defaultSessionIdFor(body) ?? randomUUID()
|
|
41
171
|
}
|
|
42
172
|
|
|
173
|
+
// Resolves AgentConfig.tenantFor against this request's headers/body —
|
|
174
|
+
// run-agent.ts itself never sees a request, so this per-request
|
|
175
|
+
// resolution has to happen here, before that point (see
|
|
176
|
+
// AgentConfig.tenantFor's own doc comment). Returning undefined is a real
|
|
177
|
+
// auth failure, not "use 'default'" — if "no header at all" should mean
|
|
178
|
+
// 'default' rather than a rejection, the resolver itself must return
|
|
179
|
+
// 'default' explicitly. No tenantFor at all is not a failure, though —
|
|
180
|
+
// it just means this agent doesn't need per-request tenants, so every
|
|
181
|
+
// request is the 'default' tenant.
|
|
182
|
+
function resolveTenant(
|
|
183
|
+
config: AgentConfig,
|
|
184
|
+
headers: IncomingMessage['headers'],
|
|
185
|
+
body: Record<string, unknown>,
|
|
186
|
+
): { ok: true; value: string } | { ok: false } {
|
|
187
|
+
if (!config.tenantFor) return { ok: true, value: 'default' }
|
|
188
|
+
const result = config.tenantFor(headers, body)
|
|
189
|
+
return result === undefined ? { ok: false } : { ok: true, value: result }
|
|
190
|
+
}
|
|
191
|
+
|
|
43
192
|
function readJsonBody(req: IncomingMessage): Promise<Record<string, unknown>> {
|
|
44
193
|
return new Promise((resolve, reject) => {
|
|
45
194
|
let data = ''
|
|
@@ -58,12 +207,23 @@ function readJsonBody(req: IncomingMessage): Promise<Record<string, unknown>> {
|
|
|
58
207
|
interface ParsedRequest {
|
|
59
208
|
entry: RegistryEntry
|
|
60
209
|
message: string
|
|
210
|
+
/** Whatever the caller sent (or, for the default fallback, what got
|
|
211
|
+
* generated) — echo this back in the response so a client that omitted
|
|
212
|
+
* sessionId can capture it and resume the same conversation next time. */
|
|
61
213
|
rawSessionId: string
|
|
214
|
+
/** The actual SessionStore key — tenant-, environment-, and agent-namespaced, never what goes in a response. */
|
|
62
215
|
storageSessionId: string
|
|
216
|
+
/** This request's resolved tenant — pass straight through as
|
|
217
|
+
* RunAgentOptions.tenant on every runAgent call in this file. */
|
|
218
|
+
tenant: string
|
|
63
219
|
}
|
|
64
220
|
|
|
65
221
|
type ParseResult = { ok: true; value: ParsedRequest } | { ok: false; status: number; error: string }
|
|
66
222
|
|
|
223
|
+
// Shared by both routes: resolve the agent, validate the body, compute
|
|
224
|
+
// the session key. Neither route commits to a response shape until this
|
|
225
|
+
// has succeeded — the streaming route in particular must not send SSE
|
|
226
|
+
// headers until it knows the request is actually going to run.
|
|
67
227
|
async function parseRequest(req: IncomingMessage, agentName: string): Promise<ParseResult> {
|
|
68
228
|
const entry = getEntry(agentName)
|
|
69
229
|
if (!entry) return { ok: false, status: 404, error: `unknown agent '${agentName}'` }
|
|
@@ -72,15 +232,827 @@ async function parseRequest(req: IncomingMessage, agentName: string): Promise<Pa
|
|
|
72
232
|
const message = String(body.message ?? '')
|
|
73
233
|
const rawSessionId = sessionIdFor(entry.config, body)
|
|
74
234
|
if (!message) return { ok: false, status: 400, error: 'message is required' }
|
|
235
|
+
// Only reachable when the agent defines its own sessionIdFor and it
|
|
236
|
+
// returned undefined — the default fallback (sessionIdFor above) always
|
|
237
|
+
// produces something, generating one rather than leaving this undefined.
|
|
75
238
|
if (!rawSessionId) return { ok: false, status: 400, error: 'could not derive a session id from the request body' }
|
|
76
239
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
240
|
+
const tenantResolution = resolveTenant(entry.config, req.headers, body)
|
|
241
|
+
if (!tenantResolution.ok) return { ok: false, status: 401, error: 'could not verify tenant for this request' }
|
|
242
|
+
const tenant = tenantResolution.value
|
|
243
|
+
|
|
244
|
+
// SessionStore itself is agent-agnostic — it has no idea which agent is
|
|
245
|
+
// calling withSession, so two different agents given the same sessionId
|
|
246
|
+
// (e.g. a client that reuses one ID across agents, or two agents whose
|
|
247
|
+
// sessionIdFor happens to produce the same value) would otherwise read
|
|
248
|
+
// and write the exact same underlying log, splicing one agent's history
|
|
249
|
+
// into another's. Namespacing by agent name here is what actually
|
|
250
|
+
// prevents that — confirmed live: before this, calling file-agent then
|
|
251
|
+
// rag-agent with the same sessionId fed rag-agent file-agent's entire
|
|
252
|
+
// prior conversation as context.
|
|
253
|
+
//
|
|
254
|
+
// Also namespaced by tenant and environment, for the same reason: two
|
|
255
|
+
// different tenants (or environments) whose sessionIdFor happens to
|
|
256
|
+
// produce the same raw id would otherwise collide on the exact same
|
|
257
|
+
// underlying log, splicing one tenant's/environment's conversation into
|
|
258
|
+
// another's. environment is a deployment-wide setting (LOOPENGINE_ENV),
|
|
259
|
+
// not resolved per-request the way tenant is — same reasoning
|
|
260
|
+
// run-agent.ts's own Scope construction uses.
|
|
261
|
+
const environment = process.env.LOOPENGINE_ENV ?? 'production'
|
|
262
|
+
return {
|
|
263
|
+
ok: true,
|
|
264
|
+
value: { entry, message, rawSessionId, tenant, storageSessionId: `${tenant}:${environment}:${agentName}:${rawSessionId}` },
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Backs GET /agents/:name/config (the JSON API behind the /agents/config
|
|
269
|
+
// page below). Reuses loadRules/loadDefaultTools rather than re-deriving
|
|
270
|
+
// AgentConfig's defaults independently — so this always shows exactly what
|
|
271
|
+
// runAgent() would actually resolve and enforce, not a second guess at it
|
|
272
|
+
// that could drift out of sync. Never includes AgentModelConfig.apiKey or
|
|
273
|
+
// any function value (approver, isSafeTool, sessionIdFor, tenantFor) —
|
|
274
|
+
// those either aren't JSON-serializable or would leak a secret; each is
|
|
275
|
+
// reported as 'custom' vs its default instead.
|
|
276
|
+
//
|
|
277
|
+
// Includes subagents (loadSubagentAsTools) and gateway-registered tools
|
|
278
|
+
// (loadGatewayToolsFromDir) alongside config.tools/loadDefaultTools —
|
|
279
|
+
// omitting either would make this page lie about what runAgent()
|
|
280
|
+
// actually resolves, exactly the drift its own reasoning above is meant
|
|
281
|
+
// to prevent.
|
|
282
|
+
function describeTool(t: { name: string; description: string; safe?: boolean; input_schema: Record<string, unknown> }) {
|
|
283
|
+
return { name: t.name, description: t.description, safe: t.safe === true, input_schema: t.input_schema }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function describeAgent(entry: RegistryEntry): Promise<Record<string, unknown>> {
|
|
287
|
+
const { config } = entry
|
|
288
|
+
// Kept separate (not just concatenated into one `tools` array — though
|
|
289
|
+
// that's still returned too, below, for Overview's own at-a-glance
|
|
290
|
+
// count) so the "Tools" tab can show where each one actually comes
|
|
291
|
+
// from: hand-written (agents/<name>/tools/), delegated to a subagent
|
|
292
|
+
// (agentAsTool), or registered through a gateway (gateway-tools.ts) —
|
|
293
|
+
// three different things an operator would reason about differently,
|
|
294
|
+
// flattened together they'd just look like an undifferentiated list.
|
|
295
|
+
const localTools = config.tools ?? (await loadDefaultTools(config))
|
|
296
|
+
const agentAsTools = await loadSubagentAsTools(config)
|
|
297
|
+
const gatewayTools = await loadGatewayToolsFromDir(agentDir(config.name))
|
|
298
|
+
const tools = [...localTools, ...agentAsTools, ...gatewayTools]
|
|
299
|
+
const rules = loadRules(config)
|
|
300
|
+
const rulesSource = Array.isArray(config.rules)
|
|
301
|
+
? 'inline'
|
|
302
|
+
: config.rules !== undefined
|
|
303
|
+
? `file: ${config.rules}`
|
|
304
|
+
: `default: agents/${config.name}/actauth.yml`
|
|
305
|
+
|
|
306
|
+
// Same resolution run-agent.ts's own runAgent() does for its Skill
|
|
307
|
+
// tool's index — reused rather than re-derived, so a "Skills" tab shows
|
|
308
|
+
// exactly what the next real request would actually see available, not
|
|
309
|
+
// a second guess (same reasoning this function's own header comment
|
|
310
|
+
// already gives for tools/permissions).
|
|
311
|
+
// Kept separate from systemSkillsDir (mirrors localTools/systemTools
|
|
312
|
+
// above) — system-skills/composio-large-outputs is real infrastructure
|
|
313
|
+
// every agent gets (see run-agent.ts), not something an operator
|
|
314
|
+
// configured for *this* agent, so the config page's own Skills tab
|
|
315
|
+
// deliberately doesn't mix it into skills/skillsDirs below.
|
|
316
|
+
const skillsDirs = config.skillsDirs ?? [`agents/${config.name}/skills`]
|
|
317
|
+
const skillGarden = new SkillGarden({ dirs: skillsDirs, indexBudgetTokens: config.skillIndexBudgetTokens ?? 200 })
|
|
318
|
+
const skillIndex = skillGarden.buildIndex().included
|
|
319
|
+
const systemSkillGarden = new SkillGarden({ dirs: [systemSkillsDir], indexBudgetTokens: config.skillIndexBudgetTokens ?? 200 })
|
|
320
|
+
const systemSkillIndex = systemSkillGarden.buildIndex().included
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
name: config.name,
|
|
324
|
+
systemPrompt: config.systemPrompt,
|
|
325
|
+
model: config.model
|
|
326
|
+
? { provider: config.model.provider, model: config.model.model, maxTokens: config.model.maxTokens }
|
|
327
|
+
: 'custom (module exports its own createModelCall)',
|
|
328
|
+
maxTurns: config.maxTurns ?? 25,
|
|
329
|
+
contextBudgetTokens: config.contextBudgetTokens ?? 8000,
|
|
330
|
+
skillIndexBudgetTokens: config.skillIndexBudgetTokens ?? 200,
|
|
331
|
+
skillsDirs: skillsDirs,
|
|
332
|
+
skills: skillIndex.map((s) => ({ name: s.name, description: s.description })),
|
|
333
|
+
systemSkills: systemSkillIndex.map((s) => ({ name: s.name, description: s.description })),
|
|
334
|
+
tools: tools.map(describeTool),
|
|
335
|
+
// createAskUserTool() with no onPending, purely to describe its
|
|
336
|
+
// schema here — never executed, so the console-prompt fallback its
|
|
337
|
+
// own doc comment mentions never applies to this call.
|
|
338
|
+
systemTools: [...systemTools, createAskUserTool({ agent: config.name })].map(describeTool),
|
|
339
|
+
localTools: localTools.map(describeTool),
|
|
340
|
+
agentAsTools: agentAsTools.map(describeTool),
|
|
341
|
+
gatewayTools: gatewayTools.map(describeTool),
|
|
342
|
+
permissions: {
|
|
343
|
+
source: rulesSource,
|
|
344
|
+
defaultDecision: rules.defaultDecision,
|
|
345
|
+
rules: rules.rules,
|
|
346
|
+
},
|
|
347
|
+
isSafeTool: config.isSafeTool ? 'custom' : "default (each tool's own `safe` flag)",
|
|
348
|
+
sessionIdFor: config.sessionIdFor ? 'custom' : 'default (client-supplied `sessionId` field)',
|
|
349
|
+
tenantFor: config.tenantFor ? 'custom' : "none (every request is the 'default' tenant)",
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Backs POST /agents/:name/gateway-tools — validates the body into a
|
|
354
|
+
// GatewayToolEntry and hands it to gateway-tools.ts's addGatewayTool. Only
|
|
355
|
+
// 'composio' is accepted today (gateway-tools.ts's own GatewayToolEntry
|
|
356
|
+
// union is exactly that narrow); a second provider means one more arm
|
|
357
|
+
// here, not a rewrite.
|
|
358
|
+
function parseGatewayToolEntry(body: Record<string, unknown>): { ok: true; value: GatewayToolEntry } | { ok: false; error: string } {
|
|
359
|
+
if (body.provider !== 'composio') return { ok: false, error: `unsupported provider '${String(body.provider)}' — only 'composio' today` }
|
|
360
|
+
if (typeof body.name !== 'string' || !body.name) return { ok: false, error: 'name is required' }
|
|
361
|
+
if (!Array.isArray(body.slugs) || body.slugs.length === 0 || !body.slugs.every((s) => typeof s === 'string' && s)) {
|
|
362
|
+
return { ok: false, error: 'slugs must be a non-empty array of strings' }
|
|
363
|
+
}
|
|
364
|
+
const entry: GatewayToolEntry = { provider: 'composio', name: body.name, slugs: body.slugs as string[] }
|
|
365
|
+
if (typeof body.cliCommand === 'string' && body.cliCommand) entry.cliCommand = body.cliCommand
|
|
366
|
+
return { ok: true, value: entry }
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function isDecision(value: unknown): value is Decision {
|
|
370
|
+
return value === 'allow' || value === 'ask' || value === 'deny'
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Only the gateway-tools add route accepts 'auto' — actauth rules
|
|
374
|
+
// themselves (handleActauthRulePost/Put, handleActauthDefaultDecisionPut)
|
|
375
|
+
// still require a real Decision, since 'auto' only makes sense as "figure
|
|
376
|
+
// out a decision per tool for this batch," not as a value a single rule
|
|
377
|
+
// or default_decision could itself hold.
|
|
378
|
+
function isGatewayToolDecision(value: unknown): value is GatewayToolDecision {
|
|
379
|
+
return isDecision(value) || value === 'auto'
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async function handleToolSourcesGet(res: ServerResponse, agentName: string, force: boolean): Promise<void> {
|
|
383
|
+
if (!getEntry(agentName)) {
|
|
384
|
+
res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
|
|
385
|
+
return
|
|
386
|
+
}
|
|
387
|
+
const sources = await describeGatewayTools(agentName, { force })
|
|
388
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ sources }))
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async function handleToolSourcesPost(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
|
|
392
|
+
if (!getEntry(agentName)) {
|
|
393
|
+
res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
|
|
394
|
+
return
|
|
395
|
+
}
|
|
396
|
+
const body = await readJsonBody(req)
|
|
397
|
+
const parsed = parseGatewayToolEntry(body)
|
|
398
|
+
if (!parsed.ok) {
|
|
399
|
+
res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: parsed.error }))
|
|
400
|
+
return
|
|
401
|
+
}
|
|
402
|
+
const decision = isGatewayToolDecision(body.decision) ? body.decision : undefined
|
|
403
|
+
try {
|
|
404
|
+
addGatewayTool(agentName, parsed.value, decision)
|
|
405
|
+
} catch (err) {
|
|
406
|
+
const status = err instanceof GatewayToolExistsError ? 409 : 500
|
|
407
|
+
res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
|
|
408
|
+
return
|
|
409
|
+
}
|
|
410
|
+
const sources = await describeGatewayTools(agentName)
|
|
411
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ sources }))
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function handleToolSourcesDelete(res: ServerResponse, agentName: string, sourceName: string): void {
|
|
415
|
+
if (!getEntry(agentName)) {
|
|
416
|
+
res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
|
|
417
|
+
return
|
|
418
|
+
}
|
|
419
|
+
try {
|
|
420
|
+
removeGatewayTool(agentName, sourceName)
|
|
421
|
+
} catch (err) {
|
|
422
|
+
const status = err instanceof GatewayToolNotFoundError ? 404 : 500
|
|
423
|
+
res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
|
|
424
|
+
return
|
|
425
|
+
}
|
|
426
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true }))
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Backs the per-tool remove icon in the Tools tab's Gateway Tools list
|
|
430
|
+
// (as opposed to handleToolSourcesDelete above, which drops a whole
|
|
431
|
+
// source). Returns the freshly-resolved sources, same as
|
|
432
|
+
// handleToolSourcesPost — so the frontend can apply the result directly
|
|
433
|
+
// instead of making a second, redundant GET for the same data.
|
|
434
|
+
async function handleGatewayToolSlugDelete(res: ServerResponse, agentName: string, sourceName: string, slug: string): Promise<void> {
|
|
435
|
+
if (!getEntry(agentName)) {
|
|
436
|
+
res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
|
|
437
|
+
return
|
|
438
|
+
}
|
|
439
|
+
try {
|
|
440
|
+
removeGatewayToolSlug(agentName, sourceName, slug)
|
|
441
|
+
} catch (err) {
|
|
442
|
+
const status = err instanceof GatewayToolNotFoundError ? 404 : 500
|
|
443
|
+
res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
|
|
444
|
+
return
|
|
445
|
+
}
|
|
446
|
+
const sources = await describeGatewayTools(agentName)
|
|
447
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ sources }))
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function parseHttpToolBody(body: Record<string, unknown>): { ok: true; value: HttpToolSpec; decision?: Decision } | { ok: false; error: string } {
|
|
451
|
+
if (typeof body.name !== 'string' || !body.name) return { ok: false, error: 'name is required' }
|
|
452
|
+
if (typeof body.description !== 'string' || !body.description) return { ok: false, error: 'description is required' }
|
|
453
|
+
if (!Array.isArray(body.fields) || !body.fields.every((f) => f && typeof f === 'object')) {
|
|
454
|
+
return { ok: false, error: 'fields must be an array' }
|
|
455
|
+
}
|
|
456
|
+
const fields = body.fields as Record<string, unknown>[]
|
|
457
|
+
for (const f of fields) {
|
|
458
|
+
if (typeof f.name !== 'string' || !f.name) return { ok: false, error: 'every field needs a name' }
|
|
459
|
+
if (f.type !== 'string' && f.type !== 'number' && f.type !== 'boolean') return { ok: false, error: `field "${f.name}" has an invalid type` }
|
|
460
|
+
}
|
|
461
|
+
const method = body.method
|
|
462
|
+
if (method !== 'GET' && method !== 'POST' && method !== 'PUT' && method !== 'PATCH' && method !== 'DELETE') {
|
|
463
|
+
return { ok: false, error: 'method must be one of GET, POST, PUT, PATCH, DELETE' }
|
|
464
|
+
}
|
|
465
|
+
if (typeof body.url !== 'string' || !body.url) return { ok: false, error: 'url is required' }
|
|
466
|
+
if (!Array.isArray(body.headers) || !body.headers.every((h) => h && typeof h === 'object' && typeof (h as Record<string, unknown>).key === 'string' && typeof (h as Record<string, unknown>).value === 'string')) {
|
|
467
|
+
return { ok: false, error: 'headers must be an array of {key, value} strings' }
|
|
468
|
+
}
|
|
469
|
+
if (body.responseJsonPath !== undefined && typeof body.responseJsonPath !== 'string') {
|
|
470
|
+
return { ok: false, error: 'responseJsonPath must be a string' }
|
|
471
|
+
}
|
|
472
|
+
if (body.decision !== undefined && !isDecision(body.decision)) {
|
|
473
|
+
return { ok: false, error: 'decision must be allow, ask, or deny' }
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return {
|
|
477
|
+
ok: true,
|
|
478
|
+
value: {
|
|
479
|
+
name: body.name,
|
|
480
|
+
description: body.description,
|
|
481
|
+
fields: fields as unknown as HttpToolSpec['fields'],
|
|
482
|
+
method,
|
|
483
|
+
url: body.url,
|
|
484
|
+
headers: body.headers as HttpToolSpec['headers'],
|
|
485
|
+
sendFieldsAsJsonBody: body.sendFieldsAsJsonBody === true,
|
|
486
|
+
responseJsonPath: body.responseJsonPath as string | undefined,
|
|
487
|
+
},
|
|
488
|
+
decision: body.decision as Decision | undefined,
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Backs the Tools tab's "Create an HTTP tool" form — generates a real
|
|
493
|
+
// agents/:name/tools/<tool>.ts file (loopengine's own createHttpTool),
|
|
494
|
+
// then splices the freshly-imported ToolDefinition straight into this
|
|
495
|
+
// process's own live registry via updateAgent (../agent-registry.js) —
|
|
496
|
+
// the same "no restart needed" mechanism a newly-created agent already
|
|
497
|
+
// gets (see registerAgent's own doc comment), and deliberately not a
|
|
498
|
+
// reliance on tsx watch picking up the new file: this way the tool is
|
|
499
|
+
// callable immediately under `serve` (no watch) too, not just `dev`. An
|
|
500
|
+
// optional `decision` seeds one actauth.yml rule via the already-existing
|
|
501
|
+
// addActauthRule, mirroring gateway-tools.ts's own "a rule is opt-in,
|
|
502
|
+
// omitting it falls through to default_decision" precedent — never a
|
|
503
|
+
// hardcoded default here.
|
|
504
|
+
async function handleHttpToolPost(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
|
|
505
|
+
const entry = getEntry(agentName)
|
|
506
|
+
if (!entry) {
|
|
507
|
+
res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
|
|
508
|
+
return
|
|
509
|
+
}
|
|
510
|
+
const body = await readJsonBody(req)
|
|
511
|
+
const parsed = parseHttpToolBody(body)
|
|
512
|
+
if (!parsed.ok) {
|
|
513
|
+
res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: parsed.error }))
|
|
514
|
+
return
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
let tool: ToolDefinition
|
|
518
|
+
try {
|
|
519
|
+
const result = await createHttpTool(agentName, parsed.value)
|
|
520
|
+
tool = result.tool
|
|
521
|
+
} catch (err) {
|
|
522
|
+
const status = err instanceof HttpToolExistsError ? 409 : err instanceof HttpToolNameError || err instanceof HttpToolIndexShapeError ? 400 : 500
|
|
523
|
+
res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
|
|
524
|
+
return
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const currentTools = entry.config.tools ?? (await loadDefaultTools(entry.config))
|
|
528
|
+
updateAgent(agentName, { config: { tools: [...currentTools, tool] } })
|
|
529
|
+
|
|
530
|
+
if (parsed.decision) {
|
|
531
|
+
try {
|
|
532
|
+
addActauthRule(agentName, { name: `${parsed.value.name}-admin-created`, scope: '*/*', tool: parsed.value.name, decision: parsed.decision })
|
|
533
|
+
} catch (err) {
|
|
534
|
+
// The tool file and live registration above already succeeded —
|
|
535
|
+
// a rule-seeding failure (near-impossible: only real cause is a
|
|
536
|
+
// same-named rule already existing) shouldn't roll either back,
|
|
537
|
+
// just surface as a warning-shaped success so the operator can
|
|
538
|
+
// add the rule by hand via the Actauth tab instead.
|
|
539
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(
|
|
540
|
+
JSON.stringify({ tool: describeTool(tool), ruleWarning: err instanceof Error ? err.message : String(err) }),
|
|
541
|
+
)
|
|
542
|
+
return
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ tool: describeTool(tool) }))
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Backs GET /composio/connections and GET /composio/tools — not scoped
|
|
550
|
+
// under /agents/:name/ like the routes above, deliberately: which apps
|
|
551
|
+
// are connected and what they offer isn't a property of any one agent,
|
|
552
|
+
// it's whatever Composio account is authenticated on this machine (via
|
|
553
|
+
// `composio link <toolkit>`), the same one every agent's gateway tools
|
|
554
|
+
// already draw from. These two back the add-a-source picker in
|
|
555
|
+
// web/agents-config-page.ts's Gateway Tools section — see
|
|
556
|
+
// listComposioConnections/listComposioTools's own doc comments for why
|
|
557
|
+
// this doesn't need any extra setup beyond that.
|
|
558
|
+
async function handleComposioConnections(res: ServerResponse): Promise<void> {
|
|
559
|
+
try {
|
|
560
|
+
const connections = await listComposioConnections()
|
|
561
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ connections }))
|
|
562
|
+
} catch (err) {
|
|
563
|
+
res.writeHead(502, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
|
|
564
|
+
}
|
|
80
565
|
}
|
|
81
566
|
|
|
82
|
-
function
|
|
83
|
-
|
|
567
|
+
async function handleComposioTools(res: ServerResponse, toolkit: string | undefined): Promise<void> {
|
|
568
|
+
if (!toolkit) {
|
|
569
|
+
res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'toolkit query parameter is required' }))
|
|
570
|
+
return
|
|
571
|
+
}
|
|
572
|
+
try {
|
|
573
|
+
const tools = await listComposioTools(toolkit)
|
|
574
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ tools }))
|
|
575
|
+
} catch (err) {
|
|
576
|
+
res.writeHead(502, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Backs the Gateways panel's own Disconnect control — see
|
|
581
|
+
// disconnectComposioAccount's own doc comment for why there's no
|
|
582
|
+
// Connect counterpart (composio login has no non-interactive path that
|
|
583
|
+
// actually matches a credential an operator has on hand). Returns the
|
|
584
|
+
// freshly-resolved gateways list on success, same "apply the response
|
|
585
|
+
// directly instead of a second GET" pattern the gateway-tools routes
|
|
586
|
+
// already use.
|
|
587
|
+
async function handleComposioDisconnect(res: ServerResponse): Promise<void> {
|
|
588
|
+
try {
|
|
589
|
+
await disconnectComposioAccount()
|
|
590
|
+
} catch (err) {
|
|
591
|
+
res.writeHead(502, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
|
|
592
|
+
return
|
|
593
|
+
}
|
|
594
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(await describeGateways()))
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// Backs the agents list page's "Create new agent" button — reuses
|
|
598
|
+
// bin/cli.ts's own scaffoldAgent rather than re-implementing the
|
|
599
|
+
// agents/<name>/index.ts template here, so a web-created agent is
|
|
600
|
+
// byte-for-byte the same stub `loopengine add-agent` would generate.
|
|
601
|
+
// scaffoldAgent runs against core/agent-registry.ts's own projectDir(), not
|
|
602
|
+
// process.cwd() — they're usually the same directory, but only
|
|
603
|
+
// projectDir() is guaranteed to match where this registry's own
|
|
604
|
+
// discoverAgents call actually resolved agents/ against (see its own
|
|
605
|
+
// doc comment), which is what matters here.
|
|
606
|
+
//
|
|
607
|
+
// Unlike core/agent-registry.ts's own discoverAgents (a one-shot directory
|
|
608
|
+
// scan at process startup — see that module's header comment), this
|
|
609
|
+
// loads and registers the new agent into *this* running process
|
|
610
|
+
// immediately: loadAgentModule imports just the one new file (the same
|
|
611
|
+
// primitive discoverAgents itself uses per-entry), and registerAgent
|
|
612
|
+
// adds it to the live registry, in place. No restart needed — this is
|
|
613
|
+
// the one legitimate case for it: a module that was never imported
|
|
614
|
+
// before has nothing to invalidate or go stale, unlike hot-reloading an
|
|
615
|
+
// *existing* agent's already-imported code would (Node's ESM loader
|
|
616
|
+
// caches a given module forever; there's no supported way to safely
|
|
617
|
+
// re-import a changed one without a real restart). If loading/
|
|
618
|
+
// registering the fresh file fails for some reason (a bug in the
|
|
619
|
+
// generated template, an extremely unlikely name race), the file is
|
|
620
|
+
// still there on disk — reported as `registered: false` rather than
|
|
621
|
+
// treated as the whole request failing, since scaffolding did succeed;
|
|
622
|
+
// a restart (or `loopengine dev`'s own auto-restart on new
|
|
623
|
+
// agents/*/index.ts files — see bin/cli.ts's own comment there) would still
|
|
624
|
+
// pick it up the normal way.
|
|
625
|
+
// systemPrompt/model are both optional in the request body — see
|
|
626
|
+
// AgentTemplateOptions' own doc comment (via agentIndexTemplate) for the
|
|
627
|
+
// defaults scaffoldAgent falls back to when either is omitted.
|
|
628
|
+
function parseAgentTemplateOptions(body: Record<string, unknown>): { ok: true; value: AgentTemplateOptions } | { ok: false; error: string } {
|
|
629
|
+
const options: AgentTemplateOptions = {}
|
|
630
|
+
if (typeof body.systemPrompt === 'string' && body.systemPrompt.trim()) {
|
|
631
|
+
options.systemPrompt = body.systemPrompt
|
|
632
|
+
}
|
|
633
|
+
if (body.model !== undefined && body.model !== null) {
|
|
634
|
+
if (typeof body.model !== 'object') {
|
|
635
|
+
return { ok: false, error: 'model must be an object' }
|
|
636
|
+
}
|
|
637
|
+
const provider = (body.model as Record<string, unknown>).provider
|
|
638
|
+
if (provider !== 'anthropic' && provider !== 'openai' && provider !== 'deepseek') {
|
|
639
|
+
return { ok: false, error: `unsupported model provider '${String(provider)}'` }
|
|
640
|
+
}
|
|
641
|
+
const modelName = (body.model as Record<string, unknown>).model
|
|
642
|
+
options.model = { provider, model: typeof modelName === 'string' ? modelName : undefined }
|
|
643
|
+
}
|
|
644
|
+
return { ok: true, value: options }
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
async function handleCreateAgent(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
648
|
+
const body = await readJsonBody(req)
|
|
649
|
+
if (typeof body.name !== 'string' || !body.name) {
|
|
650
|
+
res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'name is required' }))
|
|
651
|
+
return
|
|
652
|
+
}
|
|
653
|
+
const parsedOptions = parseAgentTemplateOptions(body)
|
|
654
|
+
if (!parsedOptions.ok) {
|
|
655
|
+
res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: parsedOptions.error }))
|
|
656
|
+
return
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
let indexPath: string
|
|
660
|
+
try {
|
|
661
|
+
indexPath = await scaffoldAgent(projectDir(), body.name, parsedOptions.value)
|
|
662
|
+
} catch (err) {
|
|
663
|
+
const status = err instanceof AgentNameError || err instanceof AgentModelError ? 400 : err instanceof AgentExistsError ? 409 : 500
|
|
664
|
+
res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
|
|
665
|
+
return
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
try {
|
|
669
|
+
registerAgent(await loadAgentModule(indexPath, indexPath))
|
|
670
|
+
} catch (err) {
|
|
671
|
+
res
|
|
672
|
+
.writeHead(200, { 'content-type': 'application/json' })
|
|
673
|
+
.end(JSON.stringify({ path: indexPath, registered: false, error: err instanceof Error ? err.message : String(err) }))
|
|
674
|
+
return
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ path: indexPath, registered: true }))
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// Backs the Overview tab's System prompt / Model edit forms — either
|
|
681
|
+
// field can be sent independently (see parseAgentTemplateOptions), the
|
|
682
|
+
// other stays untouched both on disk and live. Persists via
|
|
683
|
+
// agent-file-admin.ts's editAgentFile (see its own doc comment for why
|
|
684
|
+
// this is a surgical AST edit, not a full file regeneration), then
|
|
685
|
+
// applies the exact same resolved values to *this* running process via
|
|
686
|
+
// core/agent-registry.ts's updateAgent — a model change also needs a fresh
|
|
687
|
+
// createModelCall (see synthesizeCreateModelCall's own doc comment for
|
|
688
|
+
// why that's regenerable on its own, unlike the rest of an already-
|
|
689
|
+
// imported module). No restart needed, same as every other admin edit
|
|
690
|
+
// in this app.
|
|
691
|
+
async function handleEditAgent(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
|
|
692
|
+
if (!getEntry(agentName)) {
|
|
693
|
+
res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
|
|
694
|
+
return
|
|
695
|
+
}
|
|
696
|
+
const body = await readJsonBody(req)
|
|
697
|
+
const parsedOptions = parseAgentTemplateOptions(body)
|
|
698
|
+
if (!parsedOptions.ok) {
|
|
699
|
+
res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: parsedOptions.error }))
|
|
700
|
+
return
|
|
701
|
+
}
|
|
702
|
+
if (parsedOptions.value.systemPrompt === undefined && parsedOptions.value.model === undefined) {
|
|
703
|
+
res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'systemPrompt or model is required' }))
|
|
704
|
+
return
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
let result: AgentEditResult
|
|
708
|
+
try {
|
|
709
|
+
result = editAgentFile(agentName, parsedOptions.value)
|
|
710
|
+
} catch (err) {
|
|
711
|
+
const status =
|
|
712
|
+
err instanceof AgentEditNotSupportedError || err instanceof AgentModelError ? 400 : err instanceof AgentFileNotFoundError ? 404 : 500
|
|
713
|
+
res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
|
|
714
|
+
return
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
const configPatch: Partial<AgentConfig> = {}
|
|
718
|
+
if (result.systemPrompt !== undefined) configPatch.systemPrompt = result.systemPrompt
|
|
719
|
+
let createModelCall: RegistryEntry['createModelCall'] | undefined
|
|
720
|
+
if (result.model) {
|
|
721
|
+
configPatch.model = result.model
|
|
722
|
+
createModelCall = await synthesizeCreateModelCall(result.model)
|
|
723
|
+
}
|
|
724
|
+
updateAgent(agentName, { config: configPatch, createModelCall })
|
|
725
|
+
|
|
726
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(await describeAgent(getEntry(agentName)!)))
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// Backs the Skills tab's edit form (GET .../skills/:skillId to populate
|
|
730
|
+
// it, PUT to save, DELETE to remove) — see skills-admin.ts's own doc
|
|
731
|
+
// comment for why this only reaches flat (non-nested) skills.
|
|
732
|
+
function handleSkillGet(res: ServerResponse, agentName: string, skillId: string): void {
|
|
733
|
+
if (!getEntry(agentName)) {
|
|
734
|
+
res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
|
|
735
|
+
return
|
|
736
|
+
}
|
|
737
|
+
try {
|
|
738
|
+
const skill = readSkill(agentName, skillId)
|
|
739
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(skill))
|
|
740
|
+
} catch (err) {
|
|
741
|
+
const status = err instanceof SkillNotFoundError ? 404 : err instanceof SkillInvalidIdError ? 400 : 500
|
|
742
|
+
res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
async function handleSkillPut(req: IncomingMessage, res: ServerResponse, agentName: string, skillId: string): Promise<void> {
|
|
747
|
+
if (!getEntry(agentName)) {
|
|
748
|
+
res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
|
|
749
|
+
return
|
|
750
|
+
}
|
|
751
|
+
const body = await readJsonBody(req)
|
|
752
|
+
if (typeof body.description !== 'string' || !body.description) {
|
|
753
|
+
res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'description is required' }))
|
|
754
|
+
return
|
|
755
|
+
}
|
|
756
|
+
if (typeof body.body !== 'string' || !body.body) {
|
|
757
|
+
res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'body is required' }))
|
|
758
|
+
return
|
|
759
|
+
}
|
|
760
|
+
try {
|
|
761
|
+
writeSkill(agentName, skillId, { description: body.description, body: body.body })
|
|
762
|
+
} catch (err) {
|
|
763
|
+
const status = err instanceof SkillInvalidIdError ? 400 : 500
|
|
764
|
+
res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
|
|
765
|
+
return
|
|
766
|
+
}
|
|
767
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true }))
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function handleSkillDelete(res: ServerResponse, agentName: string, skillId: string): void {
|
|
771
|
+
if (!getEntry(agentName)) {
|
|
772
|
+
res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
|
|
773
|
+
return
|
|
774
|
+
}
|
|
775
|
+
try {
|
|
776
|
+
deleteSkill(agentName, skillId)
|
|
777
|
+
} catch (err) {
|
|
778
|
+
const status = err instanceof SkillNotFoundError ? 404 : err instanceof SkillInvalidIdError ? 400 : 500
|
|
779
|
+
res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
|
|
780
|
+
return
|
|
781
|
+
}
|
|
782
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true }))
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// Backs the Actauth tab's rule editor — parses a rule body shared by both
|
|
786
|
+
// the add (POST) and update (PUT, minus `name`) routes below.
|
|
787
|
+
function parseActauthRuleBody(body: Record<string, unknown>, requireName: boolean): { ok: true; value: { name: string; scope: string; tool: string; decision: Decision } } | { ok: false; error: string } {
|
|
788
|
+
if (requireName && (typeof body.name !== 'string' || !body.name)) return { ok: false, error: 'name is required' }
|
|
789
|
+
if (typeof body.scope !== 'string' || !body.scope) return { ok: false, error: 'scope is required' }
|
|
790
|
+
if (typeof body.tool !== 'string' || !body.tool) return { ok: false, error: 'tool is required' }
|
|
791
|
+
if (!isDecision(body.decision)) return { ok: false, error: "decision must be 'allow', 'ask', or 'deny'" }
|
|
792
|
+
return { ok: true, value: { name: typeof body.name === 'string' ? body.name : '', scope: body.scope, tool: body.tool, decision: body.decision } }
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function handleActauthGet(res: ServerResponse, agentName: string): void {
|
|
796
|
+
if (!getEntry(agentName)) {
|
|
797
|
+
res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
|
|
798
|
+
return
|
|
799
|
+
}
|
|
800
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(readActauthConfig(agentName)))
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
async function handleActauthRulePost(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
|
|
804
|
+
if (!getEntry(agentName)) {
|
|
805
|
+
res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
|
|
806
|
+
return
|
|
807
|
+
}
|
|
808
|
+
const body = await readJsonBody(req)
|
|
809
|
+
const parsed = parseActauthRuleBody(body, true)
|
|
810
|
+
if (!parsed.ok) {
|
|
811
|
+
res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: parsed.error }))
|
|
812
|
+
return
|
|
813
|
+
}
|
|
814
|
+
try {
|
|
815
|
+
addActauthRule(agentName, parsed.value)
|
|
816
|
+
} catch (err) {
|
|
817
|
+
const status = err instanceof ActauthRuleExistsError ? 409 : 500
|
|
818
|
+
res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
|
|
819
|
+
return
|
|
820
|
+
}
|
|
821
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(readActauthConfig(agentName)))
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
async function handleActauthRulePut(req: IncomingMessage, res: ServerResponse, agentName: string, ruleName: string): Promise<void> {
|
|
825
|
+
if (!getEntry(agentName)) {
|
|
826
|
+
res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
|
|
827
|
+
return
|
|
828
|
+
}
|
|
829
|
+
const body = await readJsonBody(req)
|
|
830
|
+
const parsed = parseActauthRuleBody(body, false)
|
|
831
|
+
if (!parsed.ok) {
|
|
832
|
+
res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: parsed.error }))
|
|
833
|
+
return
|
|
834
|
+
}
|
|
835
|
+
try {
|
|
836
|
+
updateActauthRule(agentName, ruleName, parsed.value)
|
|
837
|
+
} catch (err) {
|
|
838
|
+
const status = err instanceof ActauthRuleNotFoundError ? 404 : 500
|
|
839
|
+
res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
|
|
840
|
+
return
|
|
841
|
+
}
|
|
842
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(readActauthConfig(agentName)))
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function handleActauthRuleDelete(res: ServerResponse, agentName: string, ruleName: string): void {
|
|
846
|
+
if (!getEntry(agentName)) {
|
|
847
|
+
res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
|
|
848
|
+
return
|
|
849
|
+
}
|
|
850
|
+
try {
|
|
851
|
+
removeActauthRule(agentName, ruleName)
|
|
852
|
+
} catch (err) {
|
|
853
|
+
const status = err instanceof ActauthRuleNotFoundError ? 404 : 500
|
|
854
|
+
res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
|
|
855
|
+
return
|
|
856
|
+
}
|
|
857
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(readActauthConfig(agentName)))
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
async function handleActauthDefaultDecisionPut(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
|
|
861
|
+
if (!getEntry(agentName)) {
|
|
862
|
+
res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
|
|
863
|
+
return
|
|
864
|
+
}
|
|
865
|
+
const body = await readJsonBody(req)
|
|
866
|
+
if (!isDecision(body.decision)) {
|
|
867
|
+
res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: "decision must be 'allow', 'ask', or 'deny'" }))
|
|
868
|
+
return
|
|
869
|
+
}
|
|
870
|
+
setDefaultDecision(agentName, body.decision)
|
|
871
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(readActauthConfig(agentName)))
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// GET /agents content-negotiates on this: a browser navigating there sends
|
|
875
|
+
// an Accept header that prefers text/html, so it gets agentsListPageHtml
|
|
876
|
+
// below; fetch()'s own default Accept (`*/*`, the same default every
|
|
877
|
+
// fetch('/agents') call in playground.ts/agents-config-page.ts/
|
|
878
|
+
// agents-list-page.ts itself already relies on) does not include
|
|
879
|
+
// text/html, so this can't silently break an existing JSON consumer.
|
|
880
|
+
function prefersHtml(req: IncomingMessage): boolean {
|
|
881
|
+
return (req.headers.accept ?? '').includes('text/html')
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// The event's own `type` field is both the SSE event name and the
|
|
885
|
+
// serialized payload — see loop-events.ts's own header comment for why a
|
|
886
|
+
// wire frame is never "name + unrelated detail" the way it used to be.
|
|
887
|
+
function writeSseEvent(res: ServerResponse, event: LoopEvent): void {
|
|
888
|
+
res.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`)
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// Backs GET /agents/:name/sessions/:id — the playground's "resume a past
|
|
892
|
+
// conversation" sidebar rehydrating the chat pane after a page refresh
|
|
893
|
+
// lost its in-memory sessionId. rawSessionId is exactly what a `session`
|
|
894
|
+
// SSE event (see handleMessagesStream) echoed back earlier, so
|
|
895
|
+
// reconstructing the same storageSessionId parseRequest computes from it
|
|
896
|
+
// (tenant/environment/agentName-namespaced — see its own doc comment for
|
|
897
|
+
// why) finds the same underlying log. Tenant resolution has no request
|
|
898
|
+
// body to work with here (a GET has none) — fine for the common case
|
|
899
|
+
// (no custom tenantFor, or one that only reads headers), but an agent
|
|
900
|
+
// whose tenantFor depends on the message body can't be resolved this way.
|
|
901
|
+
async function handleSessionGet(req: IncomingMessage, res: ServerResponse, agentName: string, rawSessionId: string): Promise<void> {
|
|
902
|
+
const entry = getEntry(agentName)
|
|
903
|
+
if (!entry) {
|
|
904
|
+
res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
|
|
905
|
+
return
|
|
906
|
+
}
|
|
907
|
+
const tenantResolution = resolveTenant(entry.config, req.headers, {})
|
|
908
|
+
if (!tenantResolution.ok) {
|
|
909
|
+
res.writeHead(401, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'could not verify tenant for this request' }))
|
|
910
|
+
return
|
|
911
|
+
}
|
|
912
|
+
const environment = process.env.LOOPENGINE_ENV ?? 'production'
|
|
913
|
+
const storageSessionId = `${tenantResolution.value}:${environment}:${agentName}:${rawSessionId}`
|
|
914
|
+
const history = await sessions.getHistory(storageSessionId)
|
|
915
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ sessionId: rawSessionId, history }))
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
type PendingSignal = { type: 'question'; entry: PendingQuestion } | { type: 'approval'; entry: PendingApproval }
|
|
919
|
+
type FinishedTurn = { text: string; stopReason?: 'max_turns' | 'denied' | 'pending_approval' | 'pending_question' }
|
|
920
|
+
|
|
921
|
+
// One entry per turn currently in flight for the plain (non-streaming)
|
|
922
|
+
// /messages route, keyed by "<agentName>:<rawSessionId>" — the same
|
|
923
|
+
// (agent, sessionId) granularity every other pending-item lookup in this
|
|
924
|
+
// file already uses (see listApprovals/listQuestions's own filters),
|
|
925
|
+
// not a stricter per-tenant key: a custom rawSessionId reused across two
|
|
926
|
+
// tenants for the same agent was already ambiguous before this existed.
|
|
927
|
+
// This is what makes POST /approvals/:id/approve|deny and
|
|
928
|
+
// POST /questions/:id/answer able to report "what's next" (another
|
|
929
|
+
// pending item, or the final result) instead of just "ok" — see
|
|
930
|
+
// raceAndRespond below for the shared logic, and its own doc comment for
|
|
931
|
+
// why a fresh Promise per race isn't enough on its own once a *second*
|
|
932
|
+
// pending item can arrive after the first is already decided.
|
|
933
|
+
interface SessionTurnState {
|
|
934
|
+
turnPromise: Promise<FinishedTurn> | null
|
|
935
|
+
// At most one of these is ever non-null at a time: a signal that
|
|
936
|
+
// arrived before anyone asked for it (bufferedSignal), or a resolver
|
|
937
|
+
// waiting for one that hasn't arrived yet (waiter) — never both, since
|
|
938
|
+
// pushSignal always drains whichever waiter is currently registered
|
|
939
|
+
// the instant a signal shows up, and nextSignal always drains the
|
|
940
|
+
// buffer the instant something asks. Only one item can genuinely be
|
|
941
|
+
// pending at a time per session regardless (run-agent.ts's own gate
|
|
942
|
+
// loop evaluates — and so can only ever be blocked on — one tool call
|
|
943
|
+
// at a time), so a single slot is enough; no queue needed.
|
|
944
|
+
bufferedSignal: PendingSignal | null
|
|
945
|
+
waiter: ((signal: PendingSignal) => void) | null
|
|
946
|
+
// Every LoopEvent this turn has produced so far, in order — populated
|
|
947
|
+
// identically whether this state backs a streamed or a plain turn (see
|
|
948
|
+
// both handleMessages/handleMessagesStream below). The streaming route
|
|
949
|
+
// already delivers each one live over SSE and has no direct use for the
|
|
950
|
+
// array itself, but keeps it anyway: a decide/answer call that arrives
|
|
951
|
+
// after that turn's own SSE connection is long gone (see this
|
|
952
|
+
// interface's own header comment) still resumes via raceAndRespond,
|
|
953
|
+
// whose final response should report the same full transcript a plain
|
|
954
|
+
// turn's response would, not an empty one. The plain (non-streaming)
|
|
955
|
+
// /messages route is what actually reads this on the way out — see
|
|
956
|
+
// pendingResponseBody/raceAndRespond below — since it has no live
|
|
957
|
+
// channel to have delivered any of this already.
|
|
958
|
+
events: LoopEvent[]
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
const sessionTurns = new Map<string, SessionTurnState>()
|
|
962
|
+
|
|
963
|
+
function turnKey(agentName: string, rawSessionId: string): string {
|
|
964
|
+
return `${agentName}:${rawSessionId}`
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
function pushSignal(state: SessionTurnState, signal: PendingSignal): void {
|
|
968
|
+
if (state.waiter) {
|
|
969
|
+
const waiter = state.waiter
|
|
970
|
+
state.waiter = null
|
|
971
|
+
waiter(signal)
|
|
972
|
+
} else {
|
|
973
|
+
state.bufferedSignal = signal
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
function nextSignal(state: SessionTurnState): Promise<PendingSignal> {
|
|
978
|
+
if (state.bufferedSignal) {
|
|
979
|
+
const signal = state.bufferedSignal
|
|
980
|
+
state.bufferedSignal = null
|
|
981
|
+
return Promise.resolve(signal)
|
|
982
|
+
}
|
|
983
|
+
return new Promise((resolve) => {
|
|
984
|
+
state.waiter = resolve
|
|
985
|
+
})
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
function pendingResponseBody(rawSessionId: string, signal: PendingSignal, events: LoopEvent[]): Record<string, unknown> {
|
|
989
|
+
const { type, entry } = signal
|
|
990
|
+
return type === 'question'
|
|
991
|
+
? {
|
|
992
|
+
pending: true,
|
|
993
|
+
type,
|
|
994
|
+
id: entry.id,
|
|
995
|
+
sessionId: rawSessionId,
|
|
996
|
+
question: entry.question,
|
|
997
|
+
options: entry.options,
|
|
998
|
+
answerUrl: `/questions/${entry.id}/answer`,
|
|
999
|
+
events,
|
|
1000
|
+
}
|
|
1001
|
+
: {
|
|
1002
|
+
pending: true,
|
|
1003
|
+
type,
|
|
1004
|
+
id: entry.id,
|
|
1005
|
+
sessionId: rawSessionId,
|
|
1006
|
+
tool: entry.tool,
|
|
1007
|
+
args: entry.args,
|
|
1008
|
+
// Already on PendingApproval itself (actauth's own Scope) — the
|
|
1009
|
+
// playground's appendApprovalCard reads data.scope.tenant/
|
|
1010
|
+
// environment/agent directly, live or resumed alike, so this was
|
|
1011
|
+
// missing here even before raceAndRespond was reused for the
|
|
1012
|
+
// decide/answer routes too; just never exercised until a resumed
|
|
1013
|
+
// card's own decision started rendering the next one straight
|
|
1014
|
+
// from this body instead of a fresh /approvals fetch (which does
|
|
1015
|
+
// include it).
|
|
1016
|
+
scope: entry.scope,
|
|
1017
|
+
reason: entry.reason,
|
|
1018
|
+
approveUrl: `/approvals/${entry.id}/approve`,
|
|
1019
|
+
denyUrl: `/approvals/${entry.id}/deny`,
|
|
1020
|
+
events,
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// The one place that actually answers "what should the HTTP response be
|
|
1025
|
+
// right now" for this whole pending-item family — POST /messages calls it
|
|
1026
|
+
// once the turn starts; POST .../approve|deny|answer call it again after
|
|
1027
|
+
// resolving their own one decision, to report whatever comes next instead
|
|
1028
|
+
// of a bare {ok: true} that leaves the caller polling. Every one of these
|
|
1029
|
+
// routes ends up returning exactly one of the same two shapes: 202
|
|
1030
|
+
// {pending: true, ...} if the turn immediately needs another decision, or
|
|
1031
|
+
// 200 {text, sessionId, stopReason?} if it's actually done — a caller can
|
|
1032
|
+
// treat the whole family as one uniform "decide-or-finish" loop regardless
|
|
1033
|
+
// of which endpoint the response came from.
|
|
1034
|
+
async function raceAndRespond(res: ServerResponse, agentName: string, rawSessionId: string, state: SessionTurnState): Promise<void> {
|
|
1035
|
+
const winner = await Promise.race([
|
|
1036
|
+
state.turnPromise!.then((turnResult) => ({ kind: 'done' as const, turnResult })),
|
|
1037
|
+
nextSignal(state).then((signal) => ({ kind: 'pending' as const, signal })),
|
|
1038
|
+
])
|
|
1039
|
+
|
|
1040
|
+
if (winner.kind === 'pending') {
|
|
1041
|
+
res.writeHead(202, { 'content-type': 'application/json' }).end(
|
|
1042
|
+
JSON.stringify({ ...pendingResponseBody(rawSessionId, winner.signal, state.events), statusUrl: `/agents/${agentName}/sessions/${rawSessionId}` }),
|
|
1043
|
+
)
|
|
1044
|
+
return
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
// events: the full typed lifecycle of this turn, start to finish — see
|
|
1048
|
+
// SessionTurnState.events's own doc comment. Both the plain and
|
|
1049
|
+
// streaming routes populate it identically; this is what makes a plain
|
|
1050
|
+
// POST /messages response a real, complete projection of the same
|
|
1051
|
+
// lifecycle the streaming route delivers frame-by-frame, not just a
|
|
1052
|
+
// final-text summary of it.
|
|
1053
|
+
const responseBody: Record<string, unknown> = { text: winner.turnResult.text, sessionId: rawSessionId, events: state.events }
|
|
1054
|
+
if (winner.turnResult.stopReason) responseBody.stopReason = winner.turnResult.stopReason
|
|
1055
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(responseBody))
|
|
84
1056
|
}
|
|
85
1057
|
|
|
86
1058
|
async function handleMessages(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
|
|
@@ -89,55 +1061,884 @@ async function handleMessages(req: IncomingMessage, res: ServerResponse, agentNa
|
|
|
89
1061
|
res.writeHead(parsed.status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: parsed.error }))
|
|
90
1062
|
return
|
|
91
1063
|
}
|
|
92
|
-
const { entry, message, rawSessionId, storageSessionId } = parsed.value
|
|
1064
|
+
const { entry, message, rawSessionId, storageSessionId, tenant } = parsed.value
|
|
1065
|
+
|
|
1066
|
+
// This route has no live channel of its own — no SSE connection to push
|
|
1067
|
+
// a pending question/approval onto the way the streaming route does.
|
|
1068
|
+
// For a tool-call approval, that's exactly why its channel default is
|
|
1069
|
+
// durable (see defaultHttpWebhookNotifier above): the turn just returns
|
|
1070
|
+
// stopReason 'pending_approval' and this responds with that directly,
|
|
1071
|
+
// no live wait at all. question:pending gets the same durable default
|
|
1072
|
+
// now too (see defaultHttpWebhookNotifier's own use below and
|
|
1073
|
+
// HUMAN_IN_THE_LOOP.md's "Durable questions" section) — but either kind
|
|
1074
|
+
// can still fall through to the live path (unconfigured webhook, or an
|
|
1075
|
+
// agent that explicitly opts into a live approver for this channel), so
|
|
1076
|
+
// the race-the-whole-turn-against-the-first-pending-signal machinery
|
|
1077
|
+
// below stays fully in place for both regardless: race the whole turn
|
|
1078
|
+
// against the *first* moment anything in it needs a human — and every
|
|
1079
|
+
// *subsequent* moment too (see raceAndRespond, called again from
|
|
1080
|
+
// POST .../approve|deny|answer below), not just the first — respond
|
|
1081
|
+
// early with everything the caller needs to decide it themselves, no
|
|
1082
|
+
// separate discovery/polling call required.
|
|
1083
|
+
const key = turnKey(agentName, rawSessionId)
|
|
1084
|
+
const state: SessionTurnState = { turnPromise: null, bufferedSignal: null, waiter: null, events: [{ type: 'session', sessionId: rawSessionId }] }
|
|
1085
|
+
sessionTurns.set(key, state)
|
|
93
1086
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
1087
|
+
// Durable when LOOPENGINE_DEFAULT_WEBHOOK_URL/_SECRET are configured
|
|
1088
|
+
// (see this module's own defaultHttpWebhookNotifier) — otherwise falls
|
|
1089
|
+
// back to today's live WebchatApprover, tracked so a decide() call routes
|
|
1090
|
+
// back to this exact instance (see createTrackedApprover's own doc
|
|
1091
|
+
// comment). Either way, this agent's own config.httpNotifier (if it
|
|
1092
|
+
// covers 'approval') still wins outright over this — see
|
|
1093
|
+
// RunAgentOptions.approver's own doc comment.
|
|
1094
|
+
const approver =
|
|
1095
|
+
defaultHttpWebhookNotifier ??
|
|
1096
|
+
createTrackedApprover(rawSessionId, (approval) => {
|
|
1097
|
+
state.events.push({ type: 'approval:pending', ...approval })
|
|
1098
|
+
pushSignal(state, { type: 'approval', entry: approval })
|
|
1099
|
+
})
|
|
1100
|
+
|
|
1101
|
+
// Fresh modelCall per request — see core/agent-registry.ts. Not awaited
|
|
1102
|
+
// directly below — see raceAndRespond's own Promise.race for why.
|
|
1103
|
+
const turnPromise = sessions.withSession(storageSessionId, async (history) => {
|
|
1104
|
+
const result = await runAgent(entry.config, entry.createModelCall(), message, history, {
|
|
1105
|
+
tenant,
|
|
1106
|
+
sessionId: rawSessionId,
|
|
1107
|
+
channel: 'http',
|
|
1108
|
+
approver,
|
|
1109
|
+
// Durable when LOOPENGINE_DEFAULT_WEBHOOK_URL/_SECRET are
|
|
1110
|
+
// configured — see this module's own defaultHttpWebhookNotifier,
|
|
1111
|
+
// the same shared instance `approver` above already uses (one
|
|
1112
|
+
// instance, both concerns — see WebhookNotifier's own doc
|
|
1113
|
+
// comment). When unset, onQuestionPending below still applies
|
|
1114
|
+
// unchanged, same fallback-of-the-fallback relationship `approver`
|
|
1115
|
+
// above already has.
|
|
1116
|
+
questionHandler: defaultHttpWebhookNotifier,
|
|
1117
|
+
onEvent: (event) => state.events.push(event),
|
|
1118
|
+
onQuestionPending: (question) => {
|
|
1119
|
+
state.events.push({ type: 'question:pending', ...question })
|
|
1120
|
+
pushSignal(state, { type: 'question', entry: question })
|
|
1121
|
+
},
|
|
1122
|
+
})
|
|
1123
|
+
if (result.pending) {
|
|
1124
|
+
await createCheckpointFromPending(agentName, tenant, rawSessionId, result.pending)
|
|
1125
|
+
}
|
|
1126
|
+
// No SSE connection to write this to (see this route's own header
|
|
1127
|
+
// comment) — pushed straight onto state.events instead, same event
|
|
1128
|
+
// shape the streaming route's own 'done' SSE frame uses, so the final
|
|
1129
|
+
// `events` array this route's response body carries (see
|
|
1130
|
+
// raceAndRespond) always ends the same way regardless of which route
|
|
1131
|
+
// produced it.
|
|
1132
|
+
state.events.push({ type: 'done', text: result.text, ...(result.stopReason ? { stopReason: result.stopReason } : {}) })
|
|
1133
|
+
return { newMessages: result.newMessages, result: { text: result.text, stopReason: result.stopReason } }
|
|
97
1134
|
})
|
|
1135
|
+
state.turnPromise = turnPromise
|
|
1136
|
+
|
|
1137
|
+
// The turn itself keeps running to completion regardless of how (or
|
|
1138
|
+
// whether) this specific request ever responds — sessions.withSession
|
|
1139
|
+
// still owns appending its result to the session log once it actually
|
|
1140
|
+
// resolves, same durability guarantee as any other call. Nothing else
|
|
1141
|
+
// is guaranteed to be awaiting turnPromise by the time it settles (a
|
|
1142
|
+
// caller might stop polling, or this response might win the race
|
|
1143
|
+
// below), so a failure needs its own catch here or it's a silent
|
|
1144
|
+
// unhandled rejection. Cleanup only removes *this* state if it's still
|
|
1145
|
+
// the current one for `key` — an unrelated new POST /messages reusing
|
|
1146
|
+
// the same (agent, sessionId) pair before this one's cleanup runs would
|
|
1147
|
+
// otherwise have its own fresh state clobbered here.
|
|
1148
|
+
turnPromise
|
|
1149
|
+
.catch((err) => {
|
|
1150
|
+
console.error(`[messages] background turn for '${agentName}' (session ${rawSessionId}) failed:`, err)
|
|
1151
|
+
})
|
|
1152
|
+
.finally(() => {
|
|
1153
|
+
if (sessionTurns.get(key) === state) sessionTurns.delete(key)
|
|
1154
|
+
})
|
|
98
1155
|
|
|
99
|
-
res
|
|
1156
|
+
await raceAndRespond(res, agentName, rawSessionId, state)
|
|
100
1157
|
}
|
|
101
1158
|
|
|
102
1159
|
async function handleMessagesStream(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
|
|
103
1160
|
const parsed = await parseRequest(req, agentName)
|
|
104
1161
|
if (!parsed.ok) {
|
|
1162
|
+
// Headers not sent yet — still a plain JSON error response, not SSE.
|
|
105
1163
|
res.writeHead(parsed.status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: parsed.error }))
|
|
106
1164
|
return
|
|
107
1165
|
}
|
|
108
|
-
const { entry, message, rawSessionId, storageSessionId } = parsed.value
|
|
1166
|
+
const { entry, message, rawSessionId, storageSessionId, tenant } = parsed.value
|
|
109
1167
|
|
|
110
1168
|
res.writeHead(200, {
|
|
111
1169
|
'content-type': 'text/event-stream',
|
|
112
1170
|
'cache-control': 'no-cache',
|
|
113
1171
|
connection: 'keep-alive',
|
|
114
1172
|
})
|
|
115
|
-
|
|
1173
|
+
// First event, always — same reason handleMessages echoes sessionId in
|
|
1174
|
+
// its JSON body: a caller that omitted sessionId needs some way to
|
|
1175
|
+
// learn what got generated in order to resume this conversation later.
|
|
1176
|
+
writeSseEvent(res, { type: 'session', sessionId: rawSessionId })
|
|
116
1177
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
1178
|
+
// Same shared per-(agent, sessionId) tracking the plain /messages route
|
|
1179
|
+
// uses (see sessionTurns' own doc comment) — registered here too so a
|
|
1180
|
+
// decide/answer call made *after* this SSE connection is gone (a
|
|
1181
|
+
// resumed session, with no live connection left to push onto) can still
|
|
1182
|
+
// learn what's next from its own response instead of falling back to
|
|
1183
|
+
// plain {ok: true} and leaving the playground to poll (see
|
|
1184
|
+
// playground.ts's own pollForCompletion, now dead code once this
|
|
1185
|
+
// shipped — nothing about a *live* connection needs this for itself,
|
|
1186
|
+
// writeSseEvent already pushes everything as it happens; this is purely
|
|
1187
|
+
// for whoever might decide something after the tab that started it is
|
|
1188
|
+
// long gone).
|
|
1189
|
+
const key = turnKey(agentName, rawSessionId)
|
|
1190
|
+
const state: SessionTurnState = { turnPromise: null, bufferedSignal: null, waiter: null, events: [{ type: 'session', sessionId: rawSessionId }] }
|
|
1191
|
+
sessionTurns.set(key, state)
|
|
1192
|
+
|
|
1193
|
+
// A fresh WebchatApprover per streamed turn, not the shared one the plain
|
|
1194
|
+
// /messages route passes — its onPending writes straight onto *this*
|
|
1195
|
+
// SSE connection, so the approve/deny popup shows up inline in the
|
|
1196
|
+
// conversation that's actually blocked on it, not on some separate page
|
|
1197
|
+
// an operator has to remember to check. Passed unconditionally, and
|
|
1198
|
+
// nothing can override it for this channel — AgentConfig.httpNotifier
|
|
1199
|
+
// only ever matches 'http', never 'http_stream' (see its own doc
|
|
1200
|
+
// comment), so this is always what actually decides an 'ask' here.
|
|
1201
|
+
const streamApprover = createTrackedApprover(rawSessionId, (approval) => {
|
|
1202
|
+
const event: LoopEvent = { type: 'approval:pending', ...approval }
|
|
1203
|
+
writeSseEvent(res, event)
|
|
1204
|
+
state.events.push(event)
|
|
1205
|
+
pushSignal(state, { type: 'approval', entry: approval })
|
|
1206
|
+
})
|
|
1207
|
+
|
|
1208
|
+
const turnPromise = sessions.withSession(storageSessionId, async (history) => {
|
|
1209
|
+
// onEvent already fires at every loop step (budget:check,
|
|
1210
|
+
// actauth:decision, toollane:result, ...) — streaming is just
|
|
1211
|
+
// forwarding those, not a separate code path through runAgent.
|
|
1212
|
+
// onQuestionPending is separate (see its own doc comment for why):
|
|
1213
|
+
// pushes straight onto this same SSE connection, same as
|
|
1214
|
+
// streamApprover's own onPending does for approvals.
|
|
1215
|
+
const result = await runAgent(entry.config, entry.createModelCall(), message, history, {
|
|
1216
|
+
tenant,
|
|
1217
|
+
sessionId: rawSessionId,
|
|
1218
|
+
channel: 'http_stream',
|
|
1219
|
+
approver: streamApprover,
|
|
1220
|
+
onEvent: (event) => {
|
|
1221
|
+
writeSseEvent(res, event)
|
|
1222
|
+
state.events.push(event)
|
|
1223
|
+
},
|
|
1224
|
+
onQuestionPending: (question) => {
|
|
1225
|
+
const event: LoopEvent = { type: 'question:pending', ...question }
|
|
1226
|
+
writeSseEvent(res, event)
|
|
1227
|
+
state.events.push(event)
|
|
1228
|
+
pushSignal(state, { type: 'question', entry: question })
|
|
1229
|
+
},
|
|
124
1230
|
})
|
|
1231
|
+
if (result.pending) {
|
|
1232
|
+
await createCheckpointFromPending(agentName, tenant, rawSessionId, result.pending)
|
|
1233
|
+
}
|
|
1234
|
+
const doneEvent: LoopEvent = { type: 'done', text: result.text, ...(result.stopReason ? { stopReason: result.stopReason } : {}) }
|
|
1235
|
+
writeSseEvent(res, doneEvent)
|
|
1236
|
+
state.events.push(doneEvent)
|
|
1237
|
+
return { newMessages: result.newMessages, result: { text: result.text, stopReason: result.stopReason } }
|
|
1238
|
+
})
|
|
1239
|
+
state.turnPromise = turnPromise
|
|
1240
|
+
|
|
1241
|
+
try {
|
|
1242
|
+
await turnPromise
|
|
125
1243
|
} catch (err) {
|
|
126
|
-
|
|
1244
|
+
// Headers are already sent by this point, so an error becomes an SSE
|
|
1245
|
+
// event, not an HTTP status code.
|
|
1246
|
+
const errorEvent: LoopEvent = { type: 'error', error: String(err) }
|
|
1247
|
+
writeSseEvent(res, errorEvent)
|
|
1248
|
+
state.events.push(errorEvent)
|
|
127
1249
|
} finally {
|
|
1250
|
+
if (sessionTurns.get(key) === state) sessionTurns.delete(key)
|
|
128
1251
|
res.end()
|
|
129
1252
|
}
|
|
130
1253
|
}
|
|
131
1254
|
|
|
1255
|
+
// Gates every route this server has — the whole admin surface (approve/
|
|
1256
|
+
// deny tool calls, read conversation history, edit permission rules,
|
|
1257
|
+
// register gateway tools) is otherwise open to anyone who can reach the
|
|
1258
|
+
// port (see server.listen below: it binds every interface, not just
|
|
1259
|
+
// localhost). HTTP Basic Auth specifically, not a bearer token, because
|
|
1260
|
+
// it's the one scheme a plain browser navigation (GET /playground, no JS
|
|
1261
|
+
// involved yet) can satisfy on its own — the browser's native login
|
|
1262
|
+
// prompt handles it, then resends the same credentials automatically on
|
|
1263
|
+
// every later request to this origin, admin UI's own fetch() calls
|
|
1264
|
+
// included. curl covers the same ground with `-u user:pass`.
|
|
1265
|
+
//
|
|
1266
|
+
// Off entirely (today's behavior, unchanged) when LOOPENGINE_ADMIN_AUTH
|
|
1267
|
+
// isn't set — this file is meant to run locally with zero setup by
|
|
1268
|
+
// default. A real deployment opts in by setting it; either way, a
|
|
1269
|
+
// startup warning makes "I forgot to set this" loud instead of silent.
|
|
1270
|
+
const adminAuth = process.env.LOOPENGINE_ADMIN_AUTH
|
|
1271
|
+
if (!adminAuth) {
|
|
1272
|
+
console.warn(
|
|
1273
|
+
'[loopengine] LOOPENGINE_ADMIN_AUTH is not set — every route on this server (including tool-call approvals, conversation history, and permission rules) is open to anyone who can reach it. Set LOOPENGINE_ADMIN_AUTH="user:pass" to require HTTP Basic Auth.',
|
|
1274
|
+
)
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
// Compares the whole "user:pass" string as one shared secret, not
|
|
1278
|
+
// username and password separately — simpler, and just as sound for a
|
|
1279
|
+
// single shared credential with no per-user distinction to make.
|
|
1280
|
+
// timingSafeEqual requires equal-length buffers, so length is checked
|
|
1281
|
+
// first; a length mismatch isn't sensitive information worth spending a
|
|
1282
|
+
// constant-time comparison to protect.
|
|
1283
|
+
function isAuthorized(req: IncomingMessage): boolean {
|
|
1284
|
+
if (!adminAuth) return true
|
|
1285
|
+
const header = req.headers.authorization
|
|
1286
|
+
if (!header || !header.startsWith('Basic ')) return false
|
|
1287
|
+
const provided = Buffer.from(header.slice('Basic '.length), 'base64')
|
|
1288
|
+
const expected = Buffer.from(adminAuth)
|
|
1289
|
+
return provided.length === expected.length && timingSafeEqual(provided, expected)
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
// Same tool-resolution shape describeAgent above already uses
|
|
1293
|
+
// (localTools/agentAsTools/gatewayTools) — reused here rather than
|
|
1294
|
+
// re-derived, so a resolved approval always executes the exact tool
|
|
1295
|
+
// implementation the paused turn itself would have called, not a second
|
|
1296
|
+
// guess at it. Deliberately doesn't include systemTools/askUserTool: a
|
|
1297
|
+
// DurableApprover's own pendingId only ever names a real, gated business
|
|
1298
|
+
// tool call (see run-agent.ts's own pendingCalls — system tools bypass
|
|
1299
|
+
// the gate entirely and can never end up 'pending').
|
|
1300
|
+
async function resolveAgentTool(config: AgentConfig, toolName: string): Promise<ToolDefinition | undefined> {
|
|
1301
|
+
const localTools = config.tools ?? (await loadDefaultTools(config))
|
|
1302
|
+
const agentAsTools = await loadSubagentAsTools(config)
|
|
1303
|
+
const gatewayTools = await loadGatewayToolsFromDir(agentDir(config.name))
|
|
1304
|
+
return [...localTools, ...agentAsTools, ...gatewayTools].find((t) => t.name === toolName)
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
/** Deliberately not full JSON Schema validation — this project has no
|
|
1308
|
+
* ajv dependency, and one field-presence check is enough to stop an
|
|
1309
|
+
* approver's edited args from being obviously wrong (missing a field the
|
|
1310
|
+
* tool genuinely needs) without adding one just for this. See
|
|
1311
|
+
* HUMAN_IN_THE_LOOP.md's own "Approve-with-edit" section. */
|
|
1312
|
+
function validateEditedArgs(args: unknown, schema: Record<string, unknown>): string | undefined {
|
|
1313
|
+
if (typeof args !== 'object' || args === null || Array.isArray(args)) return 'must be an object'
|
|
1314
|
+
const required = Array.isArray(schema.required) ? schema.required : []
|
|
1315
|
+
for (const field of required) {
|
|
1316
|
+
if (typeof field === 'string' && !(field in args)) return `missing required field '${field}'`
|
|
1317
|
+
}
|
|
1318
|
+
return undefined
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
// Called from both handleMessages and handleMessagesStream right after a
|
|
1322
|
+
// runAgent() call returns a non-empty `pending` — turns it into the
|
|
1323
|
+
// durable TurnCheckpoint a later resolve call looks up by pendingId.
|
|
1324
|
+
// Handles both kinds uniformly (a mixed batch lands both an 'approval'
|
|
1325
|
+
// and a 'question' item in the same checkpoint's outstanding — see
|
|
1326
|
+
// PendingItem's own doc comment for why that has to be true). One real
|
|
1327
|
+
// ordering caveat, not solved here: a DurableApprover's/
|
|
1328
|
+
// DurableQuestionHandler's own notification (e.g. a webhook) fires from
|
|
1329
|
+
// inside runAgent() itself, before it returns — an improbably fast
|
|
1330
|
+
// responder could in principle hit /pending-approvals/ or
|
|
1331
|
+
// /pending-questions/ before this has run and get an 'alreadyResolved'
|
|
1332
|
+
// false negative. Not worth restructuring around for a race that needs a
|
|
1333
|
+
// human to click a link in under the time it takes this call to return.
|
|
1334
|
+
async function createCheckpointFromPending(
|
|
1335
|
+
agent: string,
|
|
1336
|
+
tenant: string,
|
|
1337
|
+
sessionId: string,
|
|
1338
|
+
pending: NonNullable<RunAgentResult['pending']>,
|
|
1339
|
+
): Promise<void> {
|
|
1340
|
+
const outstanding: TurnCheckpoint['outstanding'] = {}
|
|
1341
|
+
for (const item of pending.outstanding) {
|
|
1342
|
+
outstanding[item.pendingId] = { kind: item.kind, toolUseId: item.toolUseId, tool: item.tool, args: item.args, reason: item.reason }
|
|
1343
|
+
}
|
|
1344
|
+
await checkpoints.create({ sessionId, agent, tenant, resultsSoFar: pending.resultsSoFar, outstanding })
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
/** Resolves one outstanding item on a durable checkpoint (see
|
|
1348
|
+
* HUMAN_IN_THE_LOOP.md) — approve (optionally with editedArgs) or deny —
|
|
1349
|
+
* and, once nothing's left outstanding, resumes the turn via
|
|
1350
|
+
* resumeAgent() (see respondAfterResolution below, shared with
|
|
1351
|
+
* handlePendingQuestionAnswer's own tail). Has no live sessionTurns entry
|
|
1352
|
+
* to fall back on the way /approvals/:id/approve|deny does: the whole
|
|
1353
|
+
* point of durable is that the process (or even the request) that
|
|
1354
|
+
* started this turn may be long gone by the time this fires, so the
|
|
1355
|
+
* response here is authoritative on its own, not a fallback for some
|
|
1356
|
+
* other channel. */
|
|
1357
|
+
async function handlePendingApprovalResolve(req: IncomingMessage, res: ServerResponse, pendingId: string): Promise<void> {
|
|
1358
|
+
const body = await readJsonBody(req)
|
|
1359
|
+
const decision = body.decision === 'approve' || body.decision === 'deny' ? body.decision : undefined
|
|
1360
|
+
if (!decision) {
|
|
1361
|
+
res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: "decision must be 'approve' or 'deny'" }))
|
|
1362
|
+
return
|
|
1363
|
+
}
|
|
1364
|
+
const editedArgs = body.editedArgs as Record<string, unknown> | undefined
|
|
1365
|
+
|
|
1366
|
+
type ResolveOutcome =
|
|
1367
|
+
| { kind: 'unknown' }
|
|
1368
|
+
| { kind: 'wrong-kind' }
|
|
1369
|
+
| { kind: 'validation-error'; error: string }
|
|
1370
|
+
| { kind: 'resolved'; checkpoint: TurnCheckpoint }
|
|
1371
|
+
|
|
1372
|
+
const outcome = await checkpoints.withCheckpoint(pendingId, async (checkpoint): Promise<{ checkpoint: TurnCheckpoint | undefined; result: ResolveOutcome }> => {
|
|
1373
|
+
if (!checkpoint) return { checkpoint: undefined, result: { kind: 'unknown' } }
|
|
1374
|
+
const item = checkpoint.outstanding[pendingId]
|
|
1375
|
+
// undefined kind means 'approval' — see OutstandingItem.kind's own
|
|
1376
|
+
// doc comment for why this stays optional/back-compat rather than
|
|
1377
|
+
// required.
|
|
1378
|
+
if (item.kind === 'question') return { checkpoint, result: { kind: 'wrong-kind' } }
|
|
1379
|
+
|
|
1380
|
+
let resultBlock: ModelContentBlock
|
|
1381
|
+
if (decision === 'deny') {
|
|
1382
|
+
resultBlock = { type: 'tool_result', tool_use_id: item.toolUseId, content: `denied: ${item.reason}`, is_error: true }
|
|
1383
|
+
} else {
|
|
1384
|
+
const entry = getEntry(checkpoint.agent)
|
|
1385
|
+
const tool = entry && (await resolveAgentTool(entry.config, item.tool))
|
|
1386
|
+
if (!entry) {
|
|
1387
|
+
resultBlock = { type: 'tool_result', tool_use_id: item.toolUseId, content: `error: agent '${checkpoint.agent}' no longer registered`, is_error: true }
|
|
1388
|
+
} else if (!tool) {
|
|
1389
|
+
resultBlock = { type: 'tool_result', tool_use_id: item.toolUseId, content: `error: tool '${item.tool}' no longer exists`, is_error: true }
|
|
1390
|
+
} else {
|
|
1391
|
+
const validationError = editedArgs ? validateEditedArgs(editedArgs, tool.input_schema) : undefined
|
|
1392
|
+
if (validationError) {
|
|
1393
|
+
// Leave the checkpoint completely untouched — persisting the
|
|
1394
|
+
// *same* object back is a harmless no-op write, not a real
|
|
1395
|
+
// update. This pendingId stays open so the approver can retry
|
|
1396
|
+
// with corrected args, instead of a typo burning the one
|
|
1397
|
+
// chance to resolve this call.
|
|
1398
|
+
return { checkpoint, result: { kind: 'validation-error', error: validationError } }
|
|
1399
|
+
}
|
|
1400
|
+
try {
|
|
1401
|
+
const output = await tool.execute(editedArgs ?? item.args)
|
|
1402
|
+
resultBlock = { type: 'tool_result', tool_use_id: item.toolUseId, content: JSON.stringify(output), is_error: false }
|
|
1403
|
+
} catch (err) {
|
|
1404
|
+
resultBlock = { type: 'tool_result', tool_use_id: item.toolUseId, content: `ERROR: ${err}`, is_error: true }
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
const { [pendingId]: _resolvedItem, ...remainingOutstanding } = checkpoint.outstanding
|
|
1410
|
+
const resultsSoFar = [...checkpoint.resultsSoFar, resultBlock]
|
|
1411
|
+
let outstanding = remainingOutstanding
|
|
1412
|
+
// A denial closes the *whole* checkpoint immediately, same as
|
|
1413
|
+
// run-agent.ts's own synchronous denial path — any other still-
|
|
1414
|
+
// outstanding item in this batch gets a synthesized skip, it never
|
|
1415
|
+
// gets left dangling for someone to resolve later.
|
|
1416
|
+
if (decision === 'deny') {
|
|
1417
|
+
for (const otherItem of Object.values(remainingOutstanding)) {
|
|
1418
|
+
resultsSoFar.push({
|
|
1419
|
+
type: 'tool_result',
|
|
1420
|
+
tool_use_id: otherItem.toolUseId,
|
|
1421
|
+
content: `skipped: a sibling tool call in this turn (${item.tool}) was denied`,
|
|
1422
|
+
is_error: true,
|
|
1423
|
+
})
|
|
1424
|
+
}
|
|
1425
|
+
outstanding = {}
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
const updated: TurnCheckpoint = { ...checkpoint, resultsSoFar, outstanding, closed: Object.keys(outstanding).length === 0 }
|
|
1429
|
+
return { checkpoint: updated, result: { kind: 'resolved', checkpoint: updated } }
|
|
1430
|
+
})
|
|
1431
|
+
|
|
1432
|
+
if (outcome.kind === 'unknown') {
|
|
1433
|
+
// Unknown pendingId, or its checkpoint was already closed — a
|
|
1434
|
+
// sibling denial got there first, or this exact pendingId was
|
|
1435
|
+
// already resolved. Graceful no-op, not an error (see
|
|
1436
|
+
// HUMAN_IN_THE_LOOP.md's own worked example for why this has to be).
|
|
1437
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ alreadyResolved: true }))
|
|
1438
|
+
return
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
if (outcome.kind === 'wrong-kind') {
|
|
1442
|
+
res.writeHead(400, { 'content-type': 'application/json' }).end(
|
|
1443
|
+
JSON.stringify({ error: `pendingId '${pendingId}' is a pending question, not a pending approval — use POST /pending-questions/${pendingId}/answer` }),
|
|
1444
|
+
)
|
|
1445
|
+
return
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
if (outcome.kind === 'validation-error') {
|
|
1449
|
+
res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `editedArgs invalid — ${outcome.error}` }))
|
|
1450
|
+
return
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
await respondAfterResolution(res, outcome.checkpoint)
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
/** Resolves one outstanding durable question (see HUMAN_IN_THE_LOOP.md's
|
|
1457
|
+
* "Durable questions" section) — the question-side sibling of
|
|
1458
|
+
* handlePendingApprovalResolve above. Simpler than an approval's own
|
|
1459
|
+
* resolution: the human's free-text answer becomes the completing
|
|
1460
|
+
* tool_result content directly, no tool.execute() involved (a question
|
|
1461
|
+
* has nothing to run), and no deny/editedArgs concept — a question is
|
|
1462
|
+
* either answered or it isn't. Shares the same "outstanding empty →
|
|
1463
|
+
* resume, else report remaining count" tail via respondAfterResolution. */
|
|
1464
|
+
async function handlePendingQuestionAnswer(req: IncomingMessage, res: ServerResponse, pendingId: string): Promise<void> {
|
|
1465
|
+
const body = await readJsonBody(req)
|
|
1466
|
+
if (typeof body.answer !== 'string') {
|
|
1467
|
+
res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: "'answer' must be a string" }))
|
|
1468
|
+
return
|
|
1469
|
+
}
|
|
1470
|
+
const answer = body.answer
|
|
1471
|
+
|
|
1472
|
+
type ResolveOutcome = { kind: 'unknown' } | { kind: 'wrong-kind' } | { kind: 'resolved'; checkpoint: TurnCheckpoint }
|
|
1473
|
+
|
|
1474
|
+
const outcome = await checkpoints.withCheckpoint(pendingId, async (checkpoint): Promise<{ checkpoint: TurnCheckpoint | undefined; result: ResolveOutcome }> => {
|
|
1475
|
+
if (!checkpoint) return { checkpoint: undefined, result: { kind: 'unknown' } }
|
|
1476
|
+
const item = checkpoint.outstanding[pendingId]
|
|
1477
|
+
// undefined/'approval' kind hitting the question route is the mirror
|
|
1478
|
+
// mismatch of handlePendingApprovalResolve's own guard above.
|
|
1479
|
+
if (item.kind !== 'question') return { checkpoint, result: { kind: 'wrong-kind' } }
|
|
1480
|
+
|
|
1481
|
+
const resultBlock: ModelContentBlock = { type: 'tool_result', tool_use_id: item.toolUseId, content: answer, is_error: false }
|
|
1482
|
+
const { [pendingId]: _resolvedItem, ...remainingOutstanding } = checkpoint.outstanding
|
|
1483
|
+
const resultsSoFar = [...checkpoint.resultsSoFar, resultBlock]
|
|
1484
|
+
const updated: TurnCheckpoint = { ...checkpoint, resultsSoFar, outstanding: remainingOutstanding, closed: Object.keys(remainingOutstanding).length === 0 }
|
|
1485
|
+
return { checkpoint: updated, result: { kind: 'resolved', checkpoint: updated } }
|
|
1486
|
+
})
|
|
1487
|
+
|
|
1488
|
+
if (outcome.kind === 'unknown') {
|
|
1489
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ alreadyResolved: true }))
|
|
1490
|
+
return
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
if (outcome.kind === 'wrong-kind') {
|
|
1494
|
+
res.writeHead(400, { 'content-type': 'application/json' }).end(
|
|
1495
|
+
JSON.stringify({ error: `pendingId '${pendingId}' is a pending approval, not a pending question — use POST /pending-approvals/${pendingId}/resolve` }),
|
|
1496
|
+
)
|
|
1497
|
+
return
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
await respondAfterResolution(res, outcome.checkpoint)
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
/** Shared tail for both durable resolve routes above, once a single
|
|
1504
|
+
* outstanding item has just been resolved: report how many items are
|
|
1505
|
+
* still outstanding, or — once none are — resume the turn for real via
|
|
1506
|
+
* resumeAgent(), through the same durable sessions.withSession append
|
|
1507
|
+
* path any other turn goes through. */
|
|
1508
|
+
async function respondAfterResolution(res: ServerResponse, finalCheckpoint: TurnCheckpoint): Promise<void> {
|
|
1509
|
+
if (Object.keys(finalCheckpoint.outstanding).length > 0) {
|
|
1510
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(
|
|
1511
|
+
JSON.stringify({ resolved: true, outstanding: Object.keys(finalCheckpoint.outstanding).length }),
|
|
1512
|
+
)
|
|
1513
|
+
return
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
const entry = getEntry(finalCheckpoint.agent)
|
|
1517
|
+
if (!entry) {
|
|
1518
|
+
res.writeHead(500, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `agent '${finalCheckpoint.agent}' no longer registered` }))
|
|
1519
|
+
return
|
|
1520
|
+
}
|
|
1521
|
+
const environment = process.env.LOOPENGINE_ENV ?? 'production'
|
|
1522
|
+
const storageSessionId = `${finalCheckpoint.tenant}:${environment}:${finalCheckpoint.agent}:${finalCheckpoint.sessionId}`
|
|
1523
|
+
|
|
1524
|
+
const responseBody = await sessions.withSession(storageSessionId, async (history) => {
|
|
1525
|
+
// Same channel/approver/questionHandler fallback the original
|
|
1526
|
+
// (now-resolved) call used — a chained second pending item should
|
|
1527
|
+
// behave identically to the first, not fall through further just
|
|
1528
|
+
// because it's mid-resume. No live connection to wire an onPending
|
|
1529
|
+
// push onto here (unlike handleMessages' own WebchatApprover) — the
|
|
1530
|
+
// approver is still tracked, so GET /agents/:name/approvals and
|
|
1531
|
+
// POST /approvals/:id/approve|deny can find and resolve it, and its
|
|
1532
|
+
// own 5-minute timeout still applies rather than this hanging
|
|
1533
|
+
// forever. A resumed turn's own new system_ask_user call, likewise,
|
|
1534
|
+
// is durable when defaultHttpWebhookNotifier is configured — and
|
|
1535
|
+
// when it isn't, onQuestionPending still has to be *something*
|
|
1536
|
+
// (not omitted): createAskUserTool's own live/console-fallback branch
|
|
1537
|
+
// keys off onQuestionPending's mere presence, not usefulness, and
|
|
1538
|
+
// omitting it here (as an earlier version of this code did) meant a
|
|
1539
|
+
// resumed turn's fresh question blocked the *server process's own
|
|
1540
|
+
// stdin* — unrecoverable via any HTTP endpoint, not just live-but-
|
|
1541
|
+
// unwatched. A no-op is enough to route it into the shared
|
|
1542
|
+
// WebchatQuestionHandler registry instead — same "still tracked, still
|
|
1543
|
+
// answerable via the REST endpoints, still times out on its own"
|
|
1544
|
+
// safety net createTrackedApprover(sessionId) (no onPending) already
|
|
1545
|
+
// gives a resumed approval just above.
|
|
1546
|
+
const result = await resumeAgent(entry.config, entry.createModelCall(), history, finalCheckpoint.resultsSoFar, {
|
|
1547
|
+
tenant: finalCheckpoint.tenant,
|
|
1548
|
+
sessionId: finalCheckpoint.sessionId,
|
|
1549
|
+
channel: 'http',
|
|
1550
|
+
approver: defaultHttpWebhookNotifier ?? createTrackedApprover(finalCheckpoint.sessionId),
|
|
1551
|
+
questionHandler: defaultHttpWebhookNotifier,
|
|
1552
|
+
onQuestionPending: () => {},
|
|
1553
|
+
})
|
|
1554
|
+
// The resumed turn can itself hit another durably-pending call/
|
|
1555
|
+
// question — same wiring as the fresh-turn call sites above, or its
|
|
1556
|
+
// own new pendingId(s) would have nowhere indexed to be resolved
|
|
1557
|
+
// against.
|
|
1558
|
+
if (result.pending) {
|
|
1559
|
+
await createCheckpointFromPending(finalCheckpoint.agent, finalCheckpoint.tenant, finalCheckpoint.sessionId, result.pending)
|
|
1560
|
+
}
|
|
1561
|
+
return { newMessages: result.newMessages, result: { text: result.text, stopReason: result.stopReason } }
|
|
1562
|
+
})
|
|
1563
|
+
|
|
1564
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(
|
|
1565
|
+
JSON.stringify({ text: responseBody.text, sessionId: finalCheckpoint.sessionId, ...(responseBody.stopReason ? { stopReason: responseBody.stopReason } : {}) }),
|
|
1566
|
+
)
|
|
1567
|
+
}
|
|
1568
|
+
|
|
132
1569
|
const server = createServer(async (req, res) => {
|
|
1570
|
+
if (!isAuthorized(req)) {
|
|
1571
|
+
res
|
|
1572
|
+
.writeHead(401, { 'content-type': 'application/json', 'www-authenticate': 'Basic realm="loopengine"' })
|
|
1573
|
+
.end(JSON.stringify({ error: 'authorization required' }))
|
|
1574
|
+
return
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
// One catch-all around both routes. node:http does not catch rejections
|
|
1578
|
+
// from an async request listener itself — an uncaught one here doesn't
|
|
1579
|
+
// just fail the request, it crashes the whole process (confirmed: an
|
|
1580
|
+
// unhandled rejection anywhere in a route took the entire server down
|
|
1581
|
+
// before this existed). handleMessagesStream's own try/catch below
|
|
1582
|
+
// still handles errors *after* SSE headers are sent (those need an
|
|
1583
|
+
// in-band `error` event, not a fresh status code) — this is the
|
|
1584
|
+
// backstop for everything before that point, for both routes.
|
|
133
1585
|
try {
|
|
134
|
-
|
|
1586
|
+
// Query strings (the browser pages below link to each other with
|
|
1587
|
+
// ?agent=<name> to deep-link a preselected agent) would otherwise
|
|
1588
|
+
// break every exact-match/regex `$`-anchored route below, which only
|
|
1589
|
+
// ever expected a bare path — stripped once here rather than in each
|
|
1590
|
+
// route individually.
|
|
1591
|
+
const pathname = (req.url ?? '/').split('?')[0]
|
|
1592
|
+
|
|
1593
|
+
const streamMatch = req.method === 'POST' && pathname.match(/^\/agents\/([^/]+)\/messages\/stream$/)
|
|
135
1594
|
if (streamMatch) {
|
|
136
1595
|
await handleMessagesStream(req, res, decodeURIComponent(streamMatch[1]))
|
|
137
1596
|
return
|
|
138
1597
|
}
|
|
139
1598
|
|
|
140
|
-
const
|
|
1599
|
+
const sessionGetMatch = req.method === 'GET' && pathname.match(/^\/agents\/([^/]+)\/sessions\/([^/]+)$/)
|
|
1600
|
+
if (sessionGetMatch) {
|
|
1601
|
+
await handleSessionGet(req, res, decodeURIComponent(sessionGetMatch[1]), decodeURIComponent(sessionGetMatch[2]))
|
|
1602
|
+
return
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
// Dev playground: a browser client on top of the two routes above,
|
|
1606
|
+
// rendering the same SSE events /messages/stream already emits — see
|
|
1607
|
+
// web/playground.ts. GET, not POST, and an exact path match
|
|
1608
|
+
// (neither has a :name segment), so no risk of colliding with the
|
|
1609
|
+
// regexes above (already partitioned by method).
|
|
1610
|
+
if (req.method === 'GET' && pathname === '/playground') {
|
|
1611
|
+
res.writeHead(200, { 'content-type': 'text/html' }).end(playgroundHtml)
|
|
1612
|
+
return
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
// Pending 'ask' decisions — no browser page of its own: a streamed
|
|
1616
|
+
// chat turn (handleMessagesStream above) pushes its own pending
|
|
1617
|
+
// approvals straight onto that conversation's SSE connection instead,
|
|
1618
|
+
// so this is really just the plain-JSON escape hatch for the
|
|
1619
|
+
// non-streaming /messages route, or any other client that wants to
|
|
1620
|
+
// decide by hand. Scoped to one agent (exact) and, when given, one
|
|
1621
|
+
// session (best-effort — see web/web-approver.ts's sessionByApprover for
|
|
1622
|
+
// why that's not always knowable) — never a blanket list across every
|
|
1623
|
+
// agent/tenant this process serves, which would leak one
|
|
1624
|
+
// conversation's pending approvals to any caller asking about a
|
|
1625
|
+
// completely different one.
|
|
1626
|
+
const approvalsSessionMatch = req.method === 'GET' && pathname.match(/^\/agents\/([^/]+)\/sessions\/([^/]+)\/approvals$/)
|
|
1627
|
+
if (approvalsSessionMatch) {
|
|
1628
|
+
const agent = decodeURIComponent(approvalsSessionMatch[1])
|
|
1629
|
+
const sessionId = decodeURIComponent(approvalsSessionMatch[2])
|
|
1630
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ approvals: listApprovals({ agent, sessionId }) }))
|
|
1631
|
+
return
|
|
1632
|
+
}
|
|
1633
|
+
const approvalsAgentMatch = req.method === 'GET' && pathname.match(/^\/agents\/([^/]+)\/approvals$/)
|
|
1634
|
+
if (approvalsAgentMatch) {
|
|
1635
|
+
const agent = decodeURIComponent(approvalsAgentMatch[1])
|
|
1636
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ approvals: listApprovals({ agent }) }))
|
|
1637
|
+
return
|
|
1638
|
+
}
|
|
1639
|
+
const approvalDecisionMatch = req.method === 'POST' && pathname.match(/^\/approvals\/([^/]+)\/(approve|deny)$/)
|
|
1640
|
+
if (approvalDecisionMatch) {
|
|
1641
|
+
const id = decodeURIComponent(approvalDecisionMatch[1])
|
|
1642
|
+
const approved = approvalDecisionMatch[2] === 'approve'
|
|
1643
|
+
// Looked up *before* deciding — decideApproval below removes it (see
|
|
1644
|
+
// web/web-approver.ts's own onSettled), so this is the last chance to
|
|
1645
|
+
// learn which agent/session it belonged to. No await in between, so
|
|
1646
|
+
// nothing else can race in and decide it out from under this lookup.
|
|
1647
|
+
const found = findApproval(id)
|
|
1648
|
+
if (!found) {
|
|
1649
|
+
res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `No pending approval '${id}' (already decided, timed out, or never existed).` }))
|
|
1650
|
+
return
|
|
1651
|
+
}
|
|
1652
|
+
decideApproval(id, approved)
|
|
1653
|
+
// A session-turn state only exists for a turn started via the plain
|
|
1654
|
+
// POST /messages route (see its own doc comment) — one raised via
|
|
1655
|
+
// the streaming route, or whose owning turn already finished and
|
|
1656
|
+
// cleaned itself up, falls back to the old plain {ok: true}: that
|
|
1657
|
+
// caller already has its own way of finding out what happens next
|
|
1658
|
+
// (its still-open SSE connection, or the fact the turn's already
|
|
1659
|
+
// over). Known, accepted gap: two overlapping POST /messages calls
|
|
1660
|
+
// for the very same (agent, sessionId) before the first resolves
|
|
1661
|
+
// would leave this decide racing the *second* call's state instead
|
|
1662
|
+
// of the first's — sessions.withSession's own per-session lock
|
|
1663
|
+
// still serializes their actual execution either way, so nothing
|
|
1664
|
+
// is lost or corrupted, just possibly reported against the wrong
|
|
1665
|
+
// one of the two. Sending a second message before the first
|
|
1666
|
+
// finishes isn't a flow this API is meant to support regardless.
|
|
1667
|
+
const state = sessionTurns.get(turnKey(found.approval.scope.agent, found.sessionId))
|
|
1668
|
+
if (state) {
|
|
1669
|
+
// A streamed turn registers this same state (see
|
|
1670
|
+
// handleMessagesStream) but never drains it itself — its own live
|
|
1671
|
+
// SSE connection already delivers everything as it happens, so
|
|
1672
|
+
// pushSignal's buffer (see its own doc comment) just sits there
|
|
1673
|
+
// holding *this exact* now-decided approval, unconsumed, the
|
|
1674
|
+
// whole time. Left alone, the race below would immediately "win"
|
|
1675
|
+
// on that stale entry instead of actually waiting for whatever
|
|
1676
|
+
// comes next — confirmed live: approving step_one echoed step_one
|
|
1677
|
+
// straight back as the "next" pending item. Only one thing can
|
|
1678
|
+
// ever be genuinely pending at a time per session (run-agent.ts's
|
|
1679
|
+
// own gate loop blocks on one decision before evaluating the
|
|
1680
|
+
// next), so any buffered signal still sitting here at this exact
|
|
1681
|
+
// moment can only be the one just decided — safe to discard
|
|
1682
|
+
// unconditionally, not just when it happens to match this id.
|
|
1683
|
+
state.bufferedSignal = null
|
|
1684
|
+
await raceAndRespond(res, found.approval.scope.agent, found.sessionId, state)
|
|
1685
|
+
return
|
|
1686
|
+
}
|
|
1687
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true }))
|
|
1688
|
+
return
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
// A DurableApprover's own pending decision — see HUMAN_IN_THE_LOOP.md.
|
|
1692
|
+
// Unlike /approvals/:id/approve|deny above, this doesn't assume a live
|
|
1693
|
+
// sessionTurns entry exists at all (the whole point of durable is that
|
|
1694
|
+
// the process that started the turn may be long gone) — resolving one
|
|
1695
|
+
// just updates the checkpoint and, once nothing's left outstanding,
|
|
1696
|
+
// resumes the turn fresh via resumeAgent + sessions.withSession, the
|
|
1697
|
+
// same durable append path any other turn goes through.
|
|
1698
|
+
const pendingApprovalResolveMatch = req.method === 'POST' && pathname.match(/^\/pending-approvals\/([^/]+)\/resolve$/)
|
|
1699
|
+
if (pendingApprovalResolveMatch) {
|
|
1700
|
+
await handlePendingApprovalResolve(req, res, decodeURIComponent(pendingApprovalResolveMatch[1]))
|
|
1701
|
+
return
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
// A DurableQuestionHandler's own pending question — question-side
|
|
1705
|
+
// sibling of /pending-approvals/:id/resolve just above, same
|
|
1706
|
+
// reasoning (see HUMAN_IN_THE_LOOP.md's "Durable questions" section):
|
|
1707
|
+
// no live sessionTurns entry assumed, resolving one just updates the
|
|
1708
|
+
// checkpoint and, once nothing's left outstanding, resumes the turn
|
|
1709
|
+
// fresh via resumeAgent + sessions.withSession.
|
|
1710
|
+
const pendingQuestionAnswerMatch = req.method === 'POST' && pathname.match(/^\/pending-questions\/([^/]+)\/answer$/)
|
|
1711
|
+
if (pendingQuestionAnswerMatch) {
|
|
1712
|
+
await handlePendingQuestionAnswer(req, res, decodeURIComponent(pendingQuestionAnswerMatch[1]))
|
|
1713
|
+
return
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
// Pending ask_user questions (see system-tools/ask_user.ts) — same
|
|
1717
|
+
// shape/reasoning as /approvals just above: a streamed chat turn
|
|
1718
|
+
// pushes its own 'question:pending' SSE event straight onto that
|
|
1719
|
+
// conversation (see playground.ts's inline card for it), so this is
|
|
1720
|
+
// the plain-JSON escape hatch for anything else — the non-streaming
|
|
1721
|
+
// /messages route, a plain CLI run's fallback console prompt aside,
|
|
1722
|
+
// or a client that wants to answer by hand. Scoped to one agent and,
|
|
1723
|
+
// when given, one exact session — every question always knows both
|
|
1724
|
+
// (see PendingQuestion's own doc comment), so unlike approvals this
|
|
1725
|
+
// scoping is never just best-effort.
|
|
1726
|
+
const questionsSessionMatch = req.method === 'GET' && pathname.match(/^\/agents\/([^/]+)\/sessions\/([^/]+)\/questions$/)
|
|
1727
|
+
if (questionsSessionMatch) {
|
|
1728
|
+
const agent = decodeURIComponent(questionsSessionMatch[1])
|
|
1729
|
+
const sessionId = decodeURIComponent(questionsSessionMatch[2])
|
|
1730
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ questions: listQuestions({ agent, sessionId }) }))
|
|
1731
|
+
return
|
|
1732
|
+
}
|
|
1733
|
+
const questionsAgentMatch = req.method === 'GET' && pathname.match(/^\/agents\/([^/]+)\/questions$/)
|
|
1734
|
+
if (questionsAgentMatch) {
|
|
1735
|
+
const agent = decodeURIComponent(questionsAgentMatch[1])
|
|
1736
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ questions: listQuestions({ agent }) }))
|
|
1737
|
+
return
|
|
1738
|
+
}
|
|
1739
|
+
const questionAnswerMatch = req.method === 'POST' && pathname.match(/^\/questions\/([^/]+)\/answer$/)
|
|
1740
|
+
if (questionAnswerMatch) {
|
|
1741
|
+
const id = decodeURIComponent(questionAnswerMatch[1])
|
|
1742
|
+
const body = await readJsonBody(req)
|
|
1743
|
+
const answer = String(body.answer ?? '')
|
|
1744
|
+
// Same "look up before deciding" reasoning as the approval route
|
|
1745
|
+
// just above.
|
|
1746
|
+
const found = findQuestion(id)
|
|
1747
|
+
if (!found) {
|
|
1748
|
+
res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `No pending question '${id}' (already answered, timed out, or never existed).` }))
|
|
1749
|
+
return
|
|
1750
|
+
}
|
|
1751
|
+
answerQuestion(id, answer)
|
|
1752
|
+
// See the approval route's own doc comment for why a missing state
|
|
1753
|
+
// (or a question with no sessionId at all — see PendingQuestion's
|
|
1754
|
+
// own doc comment for when that happens) falls back to plain
|
|
1755
|
+
// {ok: true} instead.
|
|
1756
|
+
const state = found.sessionId ? sessionTurns.get(turnKey(found.agent, found.sessionId)) : undefined
|
|
1757
|
+
if (state && found.sessionId) {
|
|
1758
|
+
// See the approval route's own doc comment on this exact line —
|
|
1759
|
+
// same staleness hazard, same fix.
|
|
1760
|
+
state.bufferedSignal = null
|
|
1761
|
+
await raceAndRespond(res, found.agent, found.sessionId, state)
|
|
1762
|
+
return
|
|
1763
|
+
}
|
|
1764
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true }))
|
|
1765
|
+
return
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
if (req.method === 'GET' && pathname === '/agents') {
|
|
1769
|
+
if (prefersHtml(req)) {
|
|
1770
|
+
res.writeHead(200, { 'content-type': 'text/html' }).end(agentsListPageHtml)
|
|
1771
|
+
return
|
|
1772
|
+
}
|
|
1773
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(
|
|
1774
|
+
JSON.stringify({ agents: listAgents().map((name) => ({ name, systemPrompt: getEntry(name)!.config.systemPrompt })) }),
|
|
1775
|
+
)
|
|
1776
|
+
return
|
|
1777
|
+
}
|
|
1778
|
+
if (req.method === 'POST' && pathname === '/agents') {
|
|
1779
|
+
await handleCreateAgent(req, res)
|
|
1780
|
+
return
|
|
1781
|
+
}
|
|
1782
|
+
const editAgentMatch = req.method === 'PUT' && pathname.match(/^\/agents\/([^/]+)$/)
|
|
1783
|
+
if (editAgentMatch) {
|
|
1784
|
+
await handleEditAgent(req, res, decodeURIComponent(editAgentMatch[1]))
|
|
1785
|
+
return
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
// Browser page: the account-wide counterpart to the per-agent
|
|
1789
|
+
// Overview/Skills/Tools/ActAuth tabs below — a left sidebar of
|
|
1790
|
+
// Models/Gateways (plus a plain link out to the per-agent Agents
|
|
1791
|
+
// page), neither of which is a property of any one agent (see
|
|
1792
|
+
// global-config.ts's own header comment for why those live in a
|
|
1793
|
+
// separate module and page from web/agents-config-page.ts).
|
|
1794
|
+
if (req.method === 'GET' && pathname === '/config') {
|
|
1795
|
+
res.writeHead(200, { 'content-type': 'text/html' }).end(globalConfigPageHtml)
|
|
1796
|
+
return
|
|
1797
|
+
}
|
|
1798
|
+
if (req.method === 'GET' && pathname === '/config/models') {
|
|
1799
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(describeModelProviders(listAgents(), (name) => getEntry(name)?.config)))
|
|
1800
|
+
return
|
|
1801
|
+
}
|
|
1802
|
+
if (req.method === 'GET' && pathname === '/config/gateways') {
|
|
1803
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(await describeGateways()))
|
|
1804
|
+
return
|
|
1805
|
+
}
|
|
1806
|
+
if (req.method === 'POST' && pathname === '/config/gateways/composio/disconnect') {
|
|
1807
|
+
await handleComposioDisconnect(res)
|
|
1808
|
+
return
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
// Browser page: lists every registered agent and renders its full
|
|
1812
|
+
// config (tools, permissions, sessionIdFor, ...) via the JSON route
|
|
1813
|
+
// below — see web/agents-config-page.ts. A fixed two-segment
|
|
1814
|
+
// path, so it can't collide with the three-segment
|
|
1815
|
+
// /agents/:name/config regex right below it.
|
|
1816
|
+
if (req.method === 'GET' && pathname === '/agents/config') {
|
|
1817
|
+
res.writeHead(200, { 'content-type': 'text/html' }).end(agentsConfigPageHtml)
|
|
1818
|
+
return
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
const configMatch = req.method === 'GET' && pathname.match(/^\/agents\/([^/]+)\/config$/)
|
|
1822
|
+
if (configMatch) {
|
|
1823
|
+
const agentName = decodeURIComponent(configMatch[1])
|
|
1824
|
+
const entry = getEntry(agentName)
|
|
1825
|
+
if (!entry) {
|
|
1826
|
+
res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
|
|
1827
|
+
return
|
|
1828
|
+
}
|
|
1829
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(await describeAgent(entry)))
|
|
1830
|
+
return
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
// Backs the add-a-source picker (see handleComposioConnections's own
|
|
1834
|
+
// doc comment for why these aren't under /agents/:name/). Checked
|
|
1835
|
+
// before the /agents/... routes below only because they're declared
|
|
1836
|
+
// first here — there's no actual path overlap to worry about, these
|
|
1837
|
+
// don't start with /agents at all.
|
|
1838
|
+
if (req.method === 'GET' && pathname === '/composio/connections') {
|
|
1839
|
+
await handleComposioConnections(res)
|
|
1840
|
+
return
|
|
1841
|
+
}
|
|
1842
|
+
if (req.method === 'GET' && pathname === '/composio/tools') {
|
|
1843
|
+
const toolkit = new URL(req.url ?? '/', 'http://localhost').searchParams.get('toolkit') ?? undefined
|
|
1844
|
+
await handleComposioTools(res, toolkit)
|
|
1845
|
+
return
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
// No standalone admin page for these — gateway-tools.ts's registry is
|
|
1849
|
+
// managed from a "Gateway tools" tab inside /agents/config now (see
|
|
1850
|
+
// web/agents-config-page.ts), not its own page. These JSON
|
|
1851
|
+
// routes are what that tab's script calls.
|
|
1852
|
+
// Three segments after gateway-tools/ (sourceName/slug) — checked
|
|
1853
|
+
// before the two-segment (sourceName only) route right below; the
|
|
1854
|
+
// trailing `$` anchor on each means they never actually overlap
|
|
1855
|
+
// regardless of order, but the more specific one reads better first.
|
|
1856
|
+
const gatewayToolSlugDeleteMatch = req.method === 'DELETE' && pathname.match(/^\/agents\/([^/]+)\/gateway-tools\/([^/]+)\/([^/]+)$/)
|
|
1857
|
+
if (gatewayToolSlugDeleteMatch) {
|
|
1858
|
+
await handleGatewayToolSlugDelete(
|
|
1859
|
+
res,
|
|
1860
|
+
decodeURIComponent(gatewayToolSlugDeleteMatch[1]),
|
|
1861
|
+
decodeURIComponent(gatewayToolSlugDeleteMatch[2]),
|
|
1862
|
+
decodeURIComponent(gatewayToolSlugDeleteMatch[3]),
|
|
1863
|
+
)
|
|
1864
|
+
return
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
const gatewayToolsDeleteMatch = req.method === 'DELETE' && pathname.match(/^\/agents\/([^/]+)\/gateway-tools\/([^/]+)$/)
|
|
1868
|
+
if (gatewayToolsDeleteMatch) {
|
|
1869
|
+
handleToolSourcesDelete(res, decodeURIComponent(gatewayToolsDeleteMatch[1]), decodeURIComponent(gatewayToolsDeleteMatch[2]))
|
|
1870
|
+
return
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
const gatewayToolsMatch = pathname.match(/^\/agents\/([^/]+)\/gateway-tools$/)
|
|
1874
|
+
if (gatewayToolsMatch && req.method === 'GET') {
|
|
1875
|
+
// ?refresh=1 is the admin page's own "Refresh" button — bypasses
|
|
1876
|
+
// describeGatewayTools' own cache to actually re-check Composio
|
|
1877
|
+
// live (see its own doc comment for why a plain page load doesn't).
|
|
1878
|
+
const force = new URLSearchParams((req.url ?? '').split('?')[1] ?? '').get('refresh') === '1'
|
|
1879
|
+
await handleToolSourcesGet(res, decodeURIComponent(gatewayToolsMatch[1]), force)
|
|
1880
|
+
return
|
|
1881
|
+
}
|
|
1882
|
+
if (gatewayToolsMatch && req.method === 'POST') {
|
|
1883
|
+
await handleToolSourcesPost(req, res, decodeURIComponent(gatewayToolsMatch[1]))
|
|
1884
|
+
return
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
const httpToolMatch = req.method === 'POST' && pathname.match(/^\/agents\/([^/]+)\/tools\/http$/)
|
|
1888
|
+
if (httpToolMatch) {
|
|
1889
|
+
await handleHttpToolPost(req, res, decodeURIComponent(httpToolMatch[1]))
|
|
1890
|
+
return
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
// Backs the Skills tab's edit form — see skills-admin.ts for why
|
|
1894
|
+
// :skillId is restricted to a flat (non-nested) id.
|
|
1895
|
+
const skillMatch = pathname.match(/^\/agents\/([^/]+)\/skills\/([^/]+)$/)
|
|
1896
|
+
if (skillMatch && req.method === 'GET') {
|
|
1897
|
+
handleSkillGet(res, decodeURIComponent(skillMatch[1]), decodeURIComponent(skillMatch[2]))
|
|
1898
|
+
return
|
|
1899
|
+
}
|
|
1900
|
+
if (skillMatch && req.method === 'PUT') {
|
|
1901
|
+
await handleSkillPut(req, res, decodeURIComponent(skillMatch[1]), decodeURIComponent(skillMatch[2]))
|
|
1902
|
+
return
|
|
1903
|
+
}
|
|
1904
|
+
if (skillMatch && req.method === 'DELETE') {
|
|
1905
|
+
handleSkillDelete(res, decodeURIComponent(skillMatch[1]), decodeURIComponent(skillMatch[2]))
|
|
1906
|
+
return
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
// Backs the Actauth tab's rule editor and default_decision control.
|
|
1910
|
+
// The default-decision route is checked before the :ruleName routes
|
|
1911
|
+
// right below since 'default-decision' would otherwise itself match
|
|
1912
|
+
// as a rule name.
|
|
1913
|
+
const actauthDefaultDecisionMatch = req.method === 'PUT' && pathname.match(/^\/agents\/([^/]+)\/actauth\/default-decision$/)
|
|
1914
|
+
if (actauthDefaultDecisionMatch) {
|
|
1915
|
+
await handleActauthDefaultDecisionPut(req, res, decodeURIComponent(actauthDefaultDecisionMatch[1]))
|
|
1916
|
+
return
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
const actauthRuleMatch = pathname.match(/^\/agents\/([^/]+)\/actauth\/rules\/([^/]+)$/)
|
|
1920
|
+
if (actauthRuleMatch && req.method === 'PUT') {
|
|
1921
|
+
await handleActauthRulePut(req, res, decodeURIComponent(actauthRuleMatch[1]), decodeURIComponent(actauthRuleMatch[2]))
|
|
1922
|
+
return
|
|
1923
|
+
}
|
|
1924
|
+
if (actauthRuleMatch && req.method === 'DELETE') {
|
|
1925
|
+
handleActauthRuleDelete(res, decodeURIComponent(actauthRuleMatch[1]), decodeURIComponent(actauthRuleMatch[2]))
|
|
1926
|
+
return
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
const actauthRulesMatch = pathname.match(/^\/agents\/([^/]+)\/actauth\/rules$/)
|
|
1930
|
+
if (actauthRulesMatch && req.method === 'POST') {
|
|
1931
|
+
await handleActauthRulePost(req, res, decodeURIComponent(actauthRulesMatch[1]))
|
|
1932
|
+
return
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
const actauthMatch = req.method === 'GET' && pathname.match(/^\/agents\/([^/]+)\/actauth$/)
|
|
1936
|
+
if (actauthMatch) {
|
|
1937
|
+
handleActauthGet(res, decodeURIComponent(actauthMatch[1]))
|
|
1938
|
+
return
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
const match = req.method === 'POST' && pathname.match(/^\/agents\/([^/]+)\/messages$/)
|
|
141
1942
|
if (!match) {
|
|
142
1943
|
res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'not found' }))
|
|
143
1944
|
return
|