create-loopengine 0.1.41 → 0.1.43
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/adapters/http.ts +118 -4
package/package.json
CHANGED
|
@@ -64,6 +64,7 @@ import {
|
|
|
64
64
|
systemTools,
|
|
65
65
|
systemSkillsDir,
|
|
66
66
|
type AgentConfig,
|
|
67
|
+
type AgentModelConfig,
|
|
67
68
|
type ToolDefinition,
|
|
68
69
|
type ModelContentBlock,
|
|
69
70
|
type RunAgentResult,
|
|
@@ -839,6 +840,33 @@ async function handleCreateAgent(req: IncomingMessage, res: ServerResponse): Pro
|
|
|
839
840
|
// why that's regenerable on its own, unlike the rest of an already-
|
|
840
841
|
// imported module). No restart needed, same as every other admin edit
|
|
841
842
|
// in this app.
|
|
843
|
+
// model.maxTokens/model.reasoningEffort are edit-only — scaffoldAgent has
|
|
844
|
+
// no use for either at creation time, so they're parsed here rather than
|
|
845
|
+
// widening parseAgentTemplateOptions/AgentTemplateOptions (shared with
|
|
846
|
+
// agent creation) for something only this route needs. Only ever called
|
|
847
|
+
// after parseAgentTemplateOptions already validated provider/model, so
|
|
848
|
+
// there's nothing to re-validate here beyond these two fields themselves.
|
|
849
|
+
function parseEditModelExtras(
|
|
850
|
+
body: Record<string, unknown>,
|
|
851
|
+
model: AgentEditableFields['model'],
|
|
852
|
+
): { ok: true; value: AgentEditableFields['model'] } | { ok: false; error: string } {
|
|
853
|
+
if (!model || typeof body.model !== 'object' || body.model === null) return { ok: true, value: model }
|
|
854
|
+
const rawModel = body.model as Record<string, unknown>
|
|
855
|
+
if (rawModel.maxTokens !== undefined && rawModel.maxTokens !== null) {
|
|
856
|
+
if (typeof rawModel.maxTokens !== 'number' || !Number.isInteger(rawModel.maxTokens) || rawModel.maxTokens < 1) {
|
|
857
|
+
return { ok: false, error: 'model.maxTokens must be a positive integer' }
|
|
858
|
+
}
|
|
859
|
+
model.maxTokens = rawModel.maxTokens
|
|
860
|
+
}
|
|
861
|
+
if (rawModel.reasoningEffort !== undefined && rawModel.reasoningEffort !== null) {
|
|
862
|
+
if (typeof rawModel.reasoningEffort !== 'string') {
|
|
863
|
+
return { ok: false, error: 'model.reasoningEffort must be a string' }
|
|
864
|
+
}
|
|
865
|
+
model.reasoningEffort = rawModel.reasoningEffort
|
|
866
|
+
}
|
|
867
|
+
return { ok: true, value: model }
|
|
868
|
+
}
|
|
869
|
+
|
|
842
870
|
async function handleEditAgent(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
|
|
843
871
|
if (!getEntry(agentName)) {
|
|
844
872
|
res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
|
|
@@ -850,12 +878,17 @@ async function handleEditAgent(req: IncomingMessage, res: ServerResponse, agentN
|
|
|
850
878
|
res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: parsedOptions.error }))
|
|
851
879
|
return
|
|
852
880
|
}
|
|
881
|
+
const parsedModelExtras = parseEditModelExtras(body, parsedOptions.value.model)
|
|
882
|
+
if (!parsedModelExtras.ok) {
|
|
883
|
+
res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: parsedModelExtras.error }))
|
|
884
|
+
return
|
|
885
|
+
}
|
|
853
886
|
const parsedLimits = parseAgentLimitsFields(body)
|
|
854
887
|
if (!parsedLimits.ok) {
|
|
855
888
|
res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: parsedLimits.error }))
|
|
856
889
|
return
|
|
857
890
|
}
|
|
858
|
-
const editFields: AgentEditableFields = { ...parsedOptions.value, ...parsedLimits.value }
|
|
891
|
+
const editFields: AgentEditableFields = { ...parsedOptions.value, model: parsedModelExtras.value, ...parsedLimits.value }
|
|
859
892
|
if (Object.keys(editFields).length === 0) {
|
|
860
893
|
res
|
|
861
894
|
.writeHead(400, { 'content-type': 'application/json' })
|
|
@@ -880,8 +913,27 @@ async function handleEditAgent(req: IncomingMessage, res: ServerResponse, agentN
|
|
|
880
913
|
if (result.skillIndexBudgetTokens !== undefined) configPatch.skillIndexBudgetTokens = result.skillIndexBudgetTokens
|
|
881
914
|
let createModelCall: RegistryEntry['createModelCall'] | undefined
|
|
882
915
|
if (result.model) {
|
|
883
|
-
|
|
884
|
-
|
|
916
|
+
// editAgentFile's own AgentEditResult.model is one flat shape across
|
|
917
|
+
// every provider (see that file's own reasoningEffort-only-for-openai
|
|
918
|
+
// check) — AgentModelConfig is a discriminated union instead, so
|
|
919
|
+
// reasoningEffort only ever type-checks on the 'openai' branch.
|
|
920
|
+
// reasoningEffort itself is narrowed no further than `string` at this
|
|
921
|
+
// boundary (same as `model` — an arbitrary provider-defined name, not
|
|
922
|
+
// an enum this layer validates); an invalid value surfaces as a clear
|
|
923
|
+
// rejection from the provider's own API, same as an invalid model name
|
|
924
|
+
// already does.
|
|
925
|
+
const m = result.model
|
|
926
|
+
const modelConfig: AgentModelConfig =
|
|
927
|
+
m.provider === 'openai'
|
|
928
|
+
? {
|
|
929
|
+
provider: 'openai',
|
|
930
|
+
model: m.model,
|
|
931
|
+
maxTokens: m.maxTokens,
|
|
932
|
+
reasoningEffort: m.reasoningEffort as Extract<AgentModelConfig, { provider: 'openai' }>['reasoningEffort'],
|
|
933
|
+
}
|
|
934
|
+
: { provider: m.provider, model: m.model, maxTokens: m.maxTokens }
|
|
935
|
+
configPatch.model = modelConfig
|
|
936
|
+
createModelCall = await synthesizeCreateModelCall(modelConfig)
|
|
885
937
|
}
|
|
886
938
|
updateAgent(agentName, { config: configPatch, createModelCall })
|
|
887
939
|
|
|
@@ -2440,6 +2492,14 @@ const server = createServer(async (req, res) => {
|
|
|
2440
2492
|
|
|
2441
2493
|
await handleMessages(req, res, decodeURIComponent(match[1]))
|
|
2442
2494
|
} catch (err) {
|
|
2495
|
+
// headersSent means some route already called writeHead (200,
|
|
2496
|
+
// usually) before an awaited call after it threw — the exact
|
|
2497
|
+
// "Unexpected end of JSON input" symptom on the client, since
|
|
2498
|
+
// res.end() here with no argument sends an empty body under
|
|
2499
|
+
// already-committed headers. Logged, not just silently ended, so
|
|
2500
|
+
// that symptom is actually diagnosable from the server side instead
|
|
2501
|
+
// of a client-side error with no server-side trace at all.
|
|
2502
|
+
console.error('[loopengine] unhandled error in request handler:', err)
|
|
2443
2503
|
if (res.headersSent) {
|
|
2444
2504
|
res.end()
|
|
2445
2505
|
} else {
|
|
@@ -2451,8 +2511,62 @@ const server = createServer(async (req, res) => {
|
|
|
2451
2511
|
const port = Number(process.env.PORT ?? 8787)
|
|
2452
2512
|
server.listen(port, () => console.log(`agent API listening on :${port}`))
|
|
2453
2513
|
|
|
2514
|
+
// server.close() alone only stops accepting *new* connections — it
|
|
2515
|
+
// doesn't wait for existing ones, and the old code called process.exit
|
|
2516
|
+
// right after it regardless, killing every still-open connection
|
|
2517
|
+
// immediately. A long-lived SSE stream (the Playground's own
|
|
2518
|
+
// /messages/stream, open for a whole turn — longer still if the model
|
|
2519
|
+
// polls a background job within that same turn) dies mid-response, and
|
|
2520
|
+
// whatever's in front of this process (a load balancer, a reverse
|
|
2521
|
+
// proxy) sees the backend vanish mid-stream and reports a 502 to
|
|
2522
|
+
// whoever was watching it — confirmed live: a `pm2 restart` while an
|
|
2523
|
+
// image-generation batch was in progress produced exactly that. Now
|
|
2524
|
+
// actually waits for server.close()'s own callback (fires once every
|
|
2525
|
+
// in-flight connection has closed on its own) before tearing anything
|
|
2526
|
+
// else down — bounded by LOOPENGINE_SHUTDOWN_GRACE_MS so a genuinely
|
|
2527
|
+
// stuck connection can't hang a deploy forever. Whatever's orchestrating
|
|
2528
|
+
// the restart (pm2's own kill_timeout, a container platform's own grace
|
|
2529
|
+
// period) needs to be at least this long too, or it'll SIGKILL out from
|
|
2530
|
+
// under this before the wait finishes — SIGKILL can't be caught or
|
|
2531
|
+
// delayed by anything here.
|
|
2532
|
+
//
|
|
2533
|
+
// This only protects the *connection* a request/stream is running
|
|
2534
|
+
// over. A tool's own background work that isn't awaited by its
|
|
2535
|
+
// execute() call (e.g. a batch job that returns a job_id immediately
|
|
2536
|
+
// and keeps working after) isn't tied to any connection at all, and
|
|
2537
|
+
// still dies the instant process.exit runs regardless of how long this
|
|
2538
|
+
// grace period is — there's no persistent job queue here surviving a
|
|
2539
|
+
// process restart, just this one process's own event loop.
|
|
2454
2540
|
async function shutdown() {
|
|
2455
|
-
|
|
2541
|
+
console.log('[loopengine] shutting down — draining in-flight requests...')
|
|
2542
|
+
const graceMs = Number(process.env.LOOPENGINE_SHUTDOWN_GRACE_MS ?? 30000)
|
|
2543
|
+
const closed = new Promise<void>((resolve) => server.close(() => resolve()))
|
|
2544
|
+
// HTTP keep-alive means a connection whose last request already
|
|
2545
|
+
// finished doesn't close itself — it sits open, idle, waiting for a
|
|
2546
|
+
// *next* request that (mid-restart) is never coming, which would
|
|
2547
|
+
// otherwise hold server.close()'s own callback back for the entire
|
|
2548
|
+
// grace period even though nothing real is still in flight on it —
|
|
2549
|
+
// confirmed live: an idle keep-alive socket alone reproduced the full
|
|
2550
|
+
// wait. closeIdleConnections() (Node >= 18.2) drops exactly those
|
|
2551
|
+
// (never an actively-streaming request/response, which is the one
|
|
2552
|
+
// thing this whole function exists to protect) right away — but only
|
|
2553
|
+
// the ones idle *at the instant it's called*. A connection that's
|
|
2554
|
+
// still active right now (the actual case this whole function exists
|
|
2555
|
+
// to protect) goes idle *later*, once its own response finishes, so a
|
|
2556
|
+
// single call up front misses it — confirmed live: without the
|
|
2557
|
+
// repeated sweep below, that connection's own idle socket still held
|
|
2558
|
+
// the grace period open right up to the timeout, same as if this call
|
|
2559
|
+
// were never made at all. Repeating it on an interval catches that as
|
|
2560
|
+
// soon as it happens, instead of only ever catching sockets already
|
|
2561
|
+
// idle before shutdown even began.
|
|
2562
|
+
server.closeIdleConnections()
|
|
2563
|
+
const sweep = setInterval(() => server.closeIdleConnections(), 1000)
|
|
2564
|
+
const timedOut = new Promise<'timeout'>((resolve) => setTimeout(() => resolve('timeout'), graceMs))
|
|
2565
|
+
const outcome = await Promise.race([closed.then(() => 'closed' as const), timedOut])
|
|
2566
|
+
clearInterval(sweep)
|
|
2567
|
+
if (outcome === 'timeout') {
|
|
2568
|
+
console.log(`[loopengine] shutdown grace period (${graceMs}ms) elapsed with connections still open — exiting anyway`)
|
|
2569
|
+
}
|
|
2456
2570
|
await sessions.close()
|
|
2457
2571
|
process.exit(0)
|
|
2458
2572
|
}
|