create-loopengine 0.1.51 → 0.1.53
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/package.json +1 -1
- package/template/_gitignore +1 -0
- package/template/adapters/http.ts +196 -7
- package/template/package.json +1 -1
package/package.json
CHANGED
package/template/_gitignore
CHANGED
|
@@ -100,6 +100,16 @@ import {
|
|
|
100
100
|
agentDirFor,
|
|
101
101
|
verifyProxyIdentity,
|
|
102
102
|
TRUSTED_PROXY_IDENTITY_HEADER,
|
|
103
|
+
NotifierFileError,
|
|
104
|
+
verifyDecisionToken,
|
|
105
|
+
renderDecidePage,
|
|
106
|
+
renderDecideGonePage,
|
|
107
|
+
renderDecideInvalidPage,
|
|
108
|
+
describeAgentNotifier,
|
|
109
|
+
listAgentPending,
|
|
110
|
+
removeAgentNotifier,
|
|
111
|
+
saveAgentNotifier,
|
|
112
|
+
testAgentNotifier,
|
|
103
113
|
EnvVarNameError,
|
|
104
114
|
readActauthConfig,
|
|
105
115
|
addActauthRule,
|
|
@@ -178,6 +188,11 @@ const defaultHttpWebhookNotifier =
|
|
|
178
188
|
defaultHttpWebhookUrl && defaultHttpWebhookSecret
|
|
179
189
|
? new WebhookNotifier({ webhookUrl: defaultHttpWebhookUrl, signingSecret: defaultHttpWebhookSecret })
|
|
180
190
|
: undefined
|
|
191
|
+
// The same default, as plain settings, for the console's Approvals
|
|
192
|
+
// section (web/notifier-admin.ts) to show and test.
|
|
193
|
+
const defaultWebhook =
|
|
194
|
+
defaultHttpWebhookUrl && defaultHttpWebhookSecret ? { webhookUrl: defaultHttpWebhookUrl, webhookSecret: defaultHttpWebhookSecret } : undefined
|
|
195
|
+
|
|
181
196
|
|
|
182
197
|
// Used when an AgentConfig doesn't define its own sessionIdFor — a plain
|
|
183
198
|
// client-supplied key, same shape adapters/cli.ts's --session flag
|
|
@@ -1034,6 +1049,76 @@ function handleEnvGet(res: ServerResponse, agentName: string): void {
|
|
|
1034
1049
|
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(listDeclaredEnvVars(agentName)))
|
|
1035
1050
|
}
|
|
1036
1051
|
|
|
1052
|
+
// Backs the console's Permissions tab (where approval requests and questions are sent) — see
|
|
1053
|
+
// web/notifier-admin.ts. Reading is open like the rest of the admin
|
|
1054
|
+
// surface; saving or removing writes a secret and a file, so it gets the
|
|
1055
|
+
// same refuse-without-auth gate as the env routes below.
|
|
1056
|
+
function handleNotifierGet(res: ServerResponse, agentName: string): void {
|
|
1057
|
+
const entry = getEntry(agentName)
|
|
1058
|
+
if (!entry) {
|
|
1059
|
+
res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
|
|
1060
|
+
return
|
|
1061
|
+
}
|
|
1062
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ...describeAgentNotifier(entry.config, defaultWebhook), canSave: secretsProtected }))
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
async function handleNotifierPut(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
|
|
1066
|
+
if (!secretsProtected) {
|
|
1067
|
+
res.writeHead(403, { 'content-type': 'application/json' }).end(
|
|
1068
|
+
JSON.stringify({ error: 'Refusing to save a notifier without LOOPENGINE_ADMIN_AUTH configured — set it first, since this stores a secret.' }),
|
|
1069
|
+
)
|
|
1070
|
+
return
|
|
1071
|
+
}
|
|
1072
|
+
const entry = getEntry(agentName)
|
|
1073
|
+
if (!entry) {
|
|
1074
|
+
res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
|
|
1075
|
+
return
|
|
1076
|
+
}
|
|
1077
|
+
try {
|
|
1078
|
+
await saveAgentNotifier(entry.config, await readJsonBody(req))
|
|
1079
|
+
} catch (err) {
|
|
1080
|
+
const status = err instanceof NotifierFileError ? 400 : 500
|
|
1081
|
+
res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
|
|
1082
|
+
return
|
|
1083
|
+
}
|
|
1084
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ...describeAgentNotifier(entry.config, defaultWebhook), canSave: secretsProtected }))
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
function handleNotifierDelete(res: ServerResponse, agentName: string): void {
|
|
1088
|
+
if (!secretsProtected) {
|
|
1089
|
+
res.writeHead(403, { 'content-type': 'application/json' }).end(
|
|
1090
|
+
JSON.stringify({ error: 'Refusing to remove a notifier without LOOPENGINE_ADMIN_AUTH configured.' }),
|
|
1091
|
+
)
|
|
1092
|
+
return
|
|
1093
|
+
}
|
|
1094
|
+
const entry = getEntry(agentName)
|
|
1095
|
+
if (!entry) {
|
|
1096
|
+
res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
|
|
1097
|
+
return
|
|
1098
|
+
}
|
|
1099
|
+
removeAgentNotifier(entry.config)
|
|
1100
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ...describeAgentNotifier(entry.config, defaultWebhook), canSave: secretsProtected }))
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
async function handleNotifierTest(res: ServerResponse, agentName: string): Promise<void> {
|
|
1104
|
+
const entry = getEntry(agentName)
|
|
1105
|
+
if (!entry) {
|
|
1106
|
+
res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
|
|
1107
|
+
return
|
|
1108
|
+
}
|
|
1109
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(await testAgentNotifier(entry.config, defaultWebhook)))
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
// Every approval/question still waiting for this agent — for an app that
|
|
1113
|
+
// can't receive webhooks (it polls this instead) and for the console.
|
|
1114
|
+
async function handlePendingGet(res: ServerResponse, agentName: string): Promise<void> {
|
|
1115
|
+
if (!getEntry(agentName)) {
|
|
1116
|
+
res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
|
|
1117
|
+
return
|
|
1118
|
+
}
|
|
1119
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ pending: await listAgentPending(checkpoints, agentName) }))
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1037
1122
|
// `?slot=agent` targets this agent's own agents/<name>/.env (a per-agent
|
|
1038
1123
|
// value, read by its tools through ToolContext.env) instead of the
|
|
1039
1124
|
// project-wide .env — see loopengine's DeclaredEnvVar.slot.
|
|
@@ -1485,7 +1570,25 @@ async function handleSessionGet(req: IncomingMessage, res: ServerResponse, agent
|
|
|
1485
1570
|
}
|
|
1486
1571
|
|
|
1487
1572
|
type PendingSignal = { type: 'question'; entry: PendingQuestion } | { type: 'approval'; entry: PendingApproval }
|
|
1488
|
-
type FinishedTurn = { text: string; stopReason?: 'max_turns' | 'denied' | 'pending_approval' | 'pending_question' }
|
|
1573
|
+
type FinishedTurn = { text: string; stopReason?: 'max_turns' | 'denied' | 'pending_approval' | 'pending_question'; pending?: PendingSummary[] }
|
|
1574
|
+
|
|
1575
|
+
// What a caller needs to act on a turn that paused for a durable
|
|
1576
|
+
// decision: which decision, and where to send it — the same pendingId and
|
|
1577
|
+
// path the notifier's webhook carries, so an app can resolve straight
|
|
1578
|
+
// from this response instead of waiting for the webhook or polling.
|
|
1579
|
+
type PendingSummary = { pendingId: string; kind: 'approval' | 'question'; tool: string; args: Record<string, unknown>; reason: string; resolvePath?: string; answerPath?: string }
|
|
1580
|
+
|
|
1581
|
+
function pendingSummary(pending: RunAgentResult['pending']): PendingSummary[] | undefined {
|
|
1582
|
+
if (!pending?.outstanding.length) return undefined
|
|
1583
|
+
return pending.outstanding.map((item) => ({
|
|
1584
|
+
pendingId: item.pendingId,
|
|
1585
|
+
kind: item.kind,
|
|
1586
|
+
tool: item.tool,
|
|
1587
|
+
args: item.args,
|
|
1588
|
+
reason: item.reason,
|
|
1589
|
+
...(item.kind === 'question' ? { answerPath: `/pending-questions/${item.pendingId}/answer` } : { resolvePath: `/pending-approvals/${item.pendingId}/resolve` }),
|
|
1590
|
+
}))
|
|
1591
|
+
}
|
|
1489
1592
|
|
|
1490
1593
|
// One entry per turn currently in flight for the plain (non-streaming)
|
|
1491
1594
|
// /messages route, keyed by "<agentName>:<rawSessionId>" — the same
|
|
@@ -1621,6 +1724,7 @@ async function raceAndRespond(res: ServerResponse, agentName: string, rawSession
|
|
|
1621
1724
|
// final-text summary of it.
|
|
1622
1725
|
const responseBody: Record<string, unknown> = { text: winner.turnResult.text, sessionId: rawSessionId, events: state.events }
|
|
1623
1726
|
if (winner.turnResult.stopReason) responseBody.stopReason = winner.turnResult.stopReason
|
|
1727
|
+
if (winner.turnResult.pending) responseBody.pending = winner.turnResult.pending
|
|
1624
1728
|
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(responseBody))
|
|
1625
1729
|
}
|
|
1626
1730
|
|
|
@@ -1699,7 +1803,7 @@ async function handleMessages(req: IncomingMessage, res: ServerResponse, agentNa
|
|
|
1699
1803
|
// raceAndRespond) always ends the same way regardless of which route
|
|
1700
1804
|
// produced it.
|
|
1701
1805
|
state.events.push({ type: 'done', text: result.text, ...(result.stopReason ? { stopReason: result.stopReason } : {}) })
|
|
1702
|
-
return { newMessages: result.newMessages, result: { text: result.text, stopReason: result.stopReason } }
|
|
1806
|
+
return { newMessages: result.newMessages, result: { text: result.text, stopReason: result.stopReason, pending: pendingSummary(result.pending) } }
|
|
1703
1807
|
})
|
|
1704
1808
|
state.turnPromise = turnPromise
|
|
1705
1809
|
|
|
@@ -1805,7 +1909,7 @@ async function handleMessagesStream(req: IncomingMessage, res: ServerResponse, a
|
|
|
1805
1909
|
const doneEvent: LoopEvent = { type: 'done', text: result.text, ...(result.stopReason ? { stopReason: result.stopReason } : {}) }
|
|
1806
1910
|
writeSseEvent(res, doneEvent)
|
|
1807
1911
|
state.events.push(doneEvent)
|
|
1808
|
-
return { newMessages: result.newMessages, result: { text: result.text, stopReason: result.stopReason } }
|
|
1912
|
+
return { newMessages: result.newMessages, result: { text: result.text, stopReason: result.stopReason, pending: pendingSummary(result.pending) } }
|
|
1809
1913
|
})
|
|
1810
1914
|
state.turnPromise = turnPromise
|
|
1811
1915
|
|
|
@@ -1939,7 +2043,11 @@ async function createCheckpointFromPending(
|
|
|
1939
2043
|
* response here is authoritative on its own, not a fallback for some
|
|
1940
2044
|
* other channel. */
|
|
1941
2045
|
async function handlePendingApprovalResolve(req: IncomingMessage, res: ServerResponse, pendingId: string): Promise<void> {
|
|
1942
|
-
|
|
2046
|
+
await resolvePendingApproval(res, pendingId, await readJsonBody(req))
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
// The resolve itself, shared with the decision page's POST /decide/<token>.
|
|
2050
|
+
async function resolvePendingApproval(res: ServerResponse, pendingId: string, body: Record<string, unknown>): Promise<void> {
|
|
1943
2051
|
const decision = body.decision === 'approve' || body.decision === 'deny' ? body.decision : undefined
|
|
1944
2052
|
if (!decision) {
|
|
1945
2053
|
res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: "decision must be 'approve' or 'deny'" }))
|
|
@@ -2051,7 +2159,10 @@ async function handlePendingApprovalResolve(req: IncomingMessage, res: ServerRes
|
|
|
2051
2159
|
* either answered or it isn't. Shares the same "outstanding empty →
|
|
2052
2160
|
* resume, else report remaining count" tail via respondAfterResolution. */
|
|
2053
2161
|
async function handlePendingQuestionAnswer(req: IncomingMessage, res: ServerResponse, pendingId: string): Promise<void> {
|
|
2054
|
-
|
|
2162
|
+
await answerPendingQuestion(res, pendingId, await readJsonBody(req))
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
async function answerPendingQuestion(res: ServerResponse, pendingId: string, body: Record<string, unknown>): Promise<void> {
|
|
2055
2166
|
if (typeof body.answer !== 'string') {
|
|
2056
2167
|
res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: "'answer' must be a string" }))
|
|
2057
2168
|
return
|
|
@@ -2147,11 +2258,11 @@ async function respondAfterResolution(res: ServerResponse, finalCheckpoint: Turn
|
|
|
2147
2258
|
if (result.pending) {
|
|
2148
2259
|
await createCheckpointFromPending(finalCheckpoint.agent, finalCheckpoint.tenant, finalCheckpoint.sessionId, result.pending)
|
|
2149
2260
|
}
|
|
2150
|
-
return { newMessages: result.newMessages, result: { text: result.text, stopReason: result.stopReason } }
|
|
2261
|
+
return { newMessages: result.newMessages, result: { text: result.text, stopReason: result.stopReason, pending: pendingSummary(result.pending) } }
|
|
2151
2262
|
})
|
|
2152
2263
|
|
|
2153
2264
|
res.writeHead(200, { 'content-type': 'application/json' }).end(
|
|
2154
|
-
JSON.stringify({ text: responseBody.text, sessionId: finalCheckpoint.sessionId, ...(responseBody.stopReason ? { stopReason: responseBody.stopReason } : {}) }),
|
|
2265
|
+
JSON.stringify({ text: responseBody.text, sessionId: finalCheckpoint.sessionId, ...(responseBody.stopReason ? { stopReason: responseBody.stopReason } : {}), ...(responseBody.pending ? { pending: responseBody.pending } : {}) }),
|
|
2155
2266
|
)
|
|
2156
2267
|
}
|
|
2157
2268
|
|
|
@@ -2337,7 +2448,59 @@ function handleActivityGet(res: ServerResponse): void {
|
|
|
2337
2448
|
)
|
|
2338
2449
|
}
|
|
2339
2450
|
|
|
2451
|
+
// A decision link (loopengine's core/approval-links.ts): GET shows the decision page,
|
|
2452
|
+
// POST makes the decision. The signed token is the credential, so these
|
|
2453
|
+
// sit outside the admin login on purpose — the link is meant for
|
|
2454
|
+
// whoever gets the notification, not only admins.
|
|
2455
|
+
async function handleDecide(req: IncomingMessage, res: ServerResponse, rawToken: string): Promise<void> {
|
|
2456
|
+
const html = { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' }
|
|
2457
|
+
const json = { 'content-type': 'application/json', 'cache-control': 'no-store' }
|
|
2458
|
+
const token = verifyDecisionToken(rawToken)
|
|
2459
|
+
if (!token) {
|
|
2460
|
+
if (req.method === 'GET') res.writeHead(404, html).end(renderDecideInvalidPage())
|
|
2461
|
+
else res.writeHead(400, json).end(JSON.stringify({ error: 'This link is not valid or has expired.' }))
|
|
2462
|
+
return
|
|
2463
|
+
}
|
|
2464
|
+
const checkpoint = await checkpoints.get(token.pendingId)
|
|
2465
|
+
const item = checkpoint?.outstanding[token.pendingId]
|
|
2466
|
+
if (!checkpoint || !item || (item.kind ?? 'approval') !== token.kind) {
|
|
2467
|
+
if (req.method === 'GET') res.writeHead(410, html).end(renderDecideGonePage())
|
|
2468
|
+
else res.writeHead(410, json).end(JSON.stringify({ error: 'This decision has already been made.' }))
|
|
2469
|
+
return
|
|
2470
|
+
}
|
|
2471
|
+
if (req.method === 'GET') {
|
|
2472
|
+
res.writeHead(200, html).end(
|
|
2473
|
+
renderDecidePage({
|
|
2474
|
+
kind: token.kind,
|
|
2475
|
+
agent: checkpoint.agent,
|
|
2476
|
+
tool: item.tool,
|
|
2477
|
+
args: item.args,
|
|
2478
|
+
reason: item.reason,
|
|
2479
|
+
sessionId: checkpoint.sessionId,
|
|
2480
|
+
tenant: checkpoint.tenant,
|
|
2481
|
+
createdAt: checkpoint.createdAt,
|
|
2482
|
+
expiresAt: token.expiresAt,
|
|
2483
|
+
}),
|
|
2484
|
+
)
|
|
2485
|
+
return
|
|
2486
|
+
}
|
|
2487
|
+
const body = await readJsonBody(req)
|
|
2488
|
+
if (token.kind === 'question') await answerPendingQuestion(res, token.pendingId, { answer: body.answer })
|
|
2489
|
+
else await resolvePendingApproval(res, token.pendingId, { decision: body.decision, editedArgs: body.editedArgs })
|
|
2490
|
+
}
|
|
2491
|
+
|
|
2340
2492
|
const server = createServer(async (req, res) => {
|
|
2493
|
+
const decideMatch = (req.method === 'GET' || req.method === 'POST') && (req.url ?? '/').split('?')[0].match(/^\/decide\/([A-Za-z0-9_.-]+)$/)
|
|
2494
|
+
if (decideMatch) {
|
|
2495
|
+
try {
|
|
2496
|
+
await handleDecide(req, res, decideMatch[1])
|
|
2497
|
+
} catch (err) {
|
|
2498
|
+
console.error('[loopengine] decision link failed:', err)
|
|
2499
|
+
if (!res.headersSent) res.writeHead(500, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'Something went wrong; try again.' }))
|
|
2500
|
+
}
|
|
2501
|
+
return
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2341
2504
|
if (!isAuthorized(req)) {
|
|
2342
2505
|
const headers: Record<string, string> = { 'content-type': 'application/json' }
|
|
2343
2506
|
if (adminAuth) headers['www-authenticate'] = 'Basic realm="loopengine"'
|
|
@@ -2713,6 +2876,32 @@ const server = createServer(async (req, res) => {
|
|
|
2713
2876
|
return
|
|
2714
2877
|
}
|
|
2715
2878
|
|
|
2879
|
+
// Backs the console's Approvals section and polling clients — see
|
|
2880
|
+
// handleNotifierGet/handlePendingGet above.
|
|
2881
|
+
const notifierMatch = pathname.match(/^\/agents\/([^/]+)\/notifier$/)
|
|
2882
|
+
if (notifierMatch && req.method === 'GET') {
|
|
2883
|
+
handleNotifierGet(res, decodeURIComponent(notifierMatch[1]))
|
|
2884
|
+
return
|
|
2885
|
+
}
|
|
2886
|
+
if (notifierMatch && req.method === 'PUT') {
|
|
2887
|
+
await handleNotifierPut(req, res, decodeURIComponent(notifierMatch[1]))
|
|
2888
|
+
return
|
|
2889
|
+
}
|
|
2890
|
+
if (notifierMatch && req.method === 'DELETE') {
|
|
2891
|
+
handleNotifierDelete(res, decodeURIComponent(notifierMatch[1]))
|
|
2892
|
+
return
|
|
2893
|
+
}
|
|
2894
|
+
const notifierTestMatch = req.method === 'POST' && pathname.match(/^\/agents\/([^/]+)\/notifier\/test$/)
|
|
2895
|
+
if (notifierTestMatch) {
|
|
2896
|
+
await handleNotifierTest(res, decodeURIComponent(notifierTestMatch[1]))
|
|
2897
|
+
return
|
|
2898
|
+
}
|
|
2899
|
+
const pendingListMatch = req.method === 'GET' && pathname.match(/^\/agents\/([^/]+)\/pending$/)
|
|
2900
|
+
if (pendingListMatch) {
|
|
2901
|
+
await handlePendingGet(res, decodeURIComponent(pendingListMatch[1]))
|
|
2902
|
+
return
|
|
2903
|
+
}
|
|
2904
|
+
|
|
2716
2905
|
// Backs the Admin UI's "Environment" section — see
|
|
2717
2906
|
// handleEnvGet/handleEnvPut's own doc comments above.
|
|
2718
2907
|
const envListMatch = pathname.match(/^\/agents\/([^/]+)\/env$/)
|