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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-loopengine",
3
- "version": "0.1.41",
3
+ "version": "0.1.43",
4
4
  "description": "Scaffold a new loopengine agent project — a runnable HTTP server with one example agent, in your own repo.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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
- configPatch.model = result.model
884
- createModelCall = await synthesizeCreateModelCall(result.model)
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
- server.close()
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
  }