freddie 0.0.116 → 0.0.118

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": "freddie",
3
- "version": "0.0.116",
3
+ "version": "0.0.118",
4
4
  "type": "module",
5
5
  "description": "Open JS agent harness built on pi-mono, floosie, xstate, and anentrypoint-design",
6
6
  "bin": {
@@ -62,9 +62,15 @@ export default {
62
62
  let model = opts.model || undefined
63
63
  if (!provider && model && /^[a-z][a-z0-9-]*\//.test(model)) { provider = model.split('/')[0]; model = model.slice(provider.length + 1) }
64
64
  const out = await runTurn({ prompt: opts.prompt, provider, model, skill: opts.skill || undefined, cwd: opts.cwd || undefined, timeoutMs: Number(opts.timeout), witnessPath: opts.witness || undefined })
65
- if (out.error) { console.error('error:', out.error); process.exit(1) }
66
- console.log(out.result || out.messages?.at(-1)?.content || '')
67
- process.exit(0)
65
+ console.log(out.error ? '' : (out.result || out.messages?.at(-1)?.content || ''))
66
+ if (out.error) console.error('error:', out.error)
67
+ // Tear down cleanly instead of process.exit(): force-closing the
68
+ // process while undici keep-alive sockets and a pending setImmediate
69
+ // are still live makes libuv double-close its async handle and assert
70
+ // UV_HANDLE_CLOSING on Windows. Close the HTTP dispatcher's sockets,
71
+ // then set exitCode and let the event loop drain on its own.
72
+ try { const u = await import('undici'); await u.getGlobalDispatcher()?.close?.() } catch {}
73
+ process.exitCode = out.error ? 1 : 0
68
74
  } })
69
75
  C({ name: 'cron', description: 'Manage cron jobs', args: [{ name: 'action', default: 'list' }, { name: 'a1' }, { name: 'a2' }], action: async (action, a1, a2) => {
70
76
  const { listJobs, createJob, cancelJob, deleteJob, tick } = await import('../../src/cron/scheduler.js')
@@ -2,6 +2,32 @@ import { logger } from '../observability/log.js'
2
2
 
3
3
  const log = logger('acptoapi')
4
4
 
5
+ // acptoapi may take minutes to answer when it serially walks dead providers
6
+ // (each ~90s timeout) before a live one responds. Node's default undici
7
+ // headersTimeout/bodyTimeout (~300s) would throw UND_ERR_HEADERS_TIMEOUT
8
+ // mid-walk, so we raise them to 0 (disabled) on the global dispatcher and let
9
+ // our AbortController be the single source of truth for the overall deadline
10
+ // (default 240s, override via FREDDIE_LLM_TIMEOUT_MS). Use setGlobalDispatcher
11
+ // once rather than a per-request Agent so we don't leak a dispatcher per call.
12
+ const ACPTOAPI_TIMEOUT_MS = Number(process.env.FREDDIE_LLM_TIMEOUT_MS) || 240000
13
+ let _dispatcherSet = false
14
+ async function ensureLongTimeoutDispatcher() {
15
+ if (_dispatcherSet) return
16
+ _dispatcherSet = true
17
+ try {
18
+ const undici = await import('undici')
19
+ // headersTimeout/bodyTimeout 0: tolerate acptoapi's minutes-long walk.
20
+ // keepAlive*Timeout 1ms: close the socket right after the response so no
21
+ // keep-alive socket lingers between calls.
22
+ undici.setGlobalDispatcher(new undici.Agent({
23
+ headersTimeout: 0,
24
+ bodyTimeout: 0,
25
+ keepAliveTimeout: 1,
26
+ keepAliveMaxTimeout: 1,
27
+ }))
28
+ } catch { /* undici not available — rely on AbortController + defaults */ }
29
+ }
30
+
5
31
  export function getAcptoapiUrl() {
6
32
  return process.env.FREDDIE_LLM_URL || 'http://127.0.0.1:4800/v1'
7
33
  }
@@ -36,8 +62,9 @@ export async function callLLM({ messages, tools = [], model } = {}) {
36
62
  // Network Access headers so cross-origin loopback (gh-pages → localhost)
37
63
  // succeeds when acptoapi is running. The earlier preemptive loopback
38
64
  // refusal caused false negatives on reachable endpoints.
65
+ await ensureLongTimeoutDispatcher()
39
66
  const _ac = new AbortController()
40
- const _tid = setTimeout(() => _ac.abort(new Error('acptoapi fetch timeout')), 60000)
67
+ const _tid = setTimeout(() => _ac.abort(new Error('acptoapi fetch timeout')), ACPTOAPI_TIMEOUT_MS)
41
68
  let res
42
69
  try {
43
70
  res = await fetch(base.replace(/\/$/, '') + '/chat/completions', {
@@ -94,7 +121,7 @@ function adaptResponse(r) {
94
121
 
95
122
  function tryParseJson(s) { try { return typeof s === 'string' ? JSON.parse(s) : (s || {}) } catch { return {} } }
96
123
 
97
- export async function isReachable(timeoutMs = 2000) {
124
+ export async function isReachable(timeoutMs = 10000) {
98
125
  try {
99
126
  const controller = new AbortController()
100
127
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
@@ -55,7 +55,18 @@ export function createAgentMachine({ provider, model, maxIterations = 90, callLL
55
55
  input: ({ context }) => ({ messages: context.messages, model: context.model, provider: context.provider, enabledToolsets: context.enabledToolsets, disabledToolsets: context.disabledToolsets }),
56
56
  onDone: [
57
57
  { guard: ({ event }) => Array.isArray(event.output?.tool_calls) && event.output.tool_calls.length > 0, target: 'tool_calls', actions: assign({ messages: ({ context, event }) => [...context.messages, { role: 'assistant', content: event.output.content || '', tool_calls: event.output.tool_calls }] }) },
58
- { target: 'done', actions: assign({ messages: ({ context, event }) => [...context.messages, { role: 'assistant', content: event.output.content || '' }], lastResult: ({ event }) => event.output.content || '' }) },
58
+ { target: 'done', actions: assign({ messages: ({ context, event }) => [...context.messages, { role: 'assistant', content: event.output.content || '' }], lastResult: ({ context, event }) => {
59
+ // Prefer this turn's content, but if the model ended with empty
60
+ // text (it may have put its answer in an earlier turn alongside a
61
+ // tool_call), fall back to the last non-empty assistant message so
62
+ // the caller never gets an empty result after a successful run.
63
+ if (event.output.content && event.output.content.trim()) return event.output.content;
64
+ for (let i = context.messages.length - 1; i >= 0; i--) {
65
+ const m = context.messages[i];
66
+ if (m.role === 'assistant' && typeof m.content === 'string' && m.content.trim()) return m.content;
67
+ }
68
+ return event.output.content || '';
69
+ } }) },
59
70
  ],
60
71
  onError: { target: 'done', actions: assign({ error: ({ event }) => String(event.error?.message || event.error) }) },
61
72
  },
@@ -172,16 +183,22 @@ export async function runTurn({ prompt, messages = [], model, provider, callLLM,
172
183
  const machine = createAgentMachine({ model, provider, callLLM, enabledToolsets, disabledToolsets, maxIterations, events })
173
184
  const actor = createActor(machine, { input: { messages: initMessages } }); actor.start(); actor.send({ type: 'SUBMIT', prompt })
174
185
  return await new Promise((resolve, reject) => {
175
- const t = setTimeout(() => { try { actor.stop() } catch {} reject(new Error('agent turn timeout')) }, timeoutMs)
176
- actor.subscribe(snap => { if (snap.status !== 'done') return; clearTimeout(t)
186
+ let sub
187
+ const cleanup = () => { try { sub?.unsubscribe() } catch {} try { actor.stop() } catch {} }
188
+ const t = setTimeout(() => { cleanup(); reject(new Error('agent turn timeout')) }, timeoutMs)
189
+ sub = actor.subscribe(snap => { if (snap.status !== 'done') return; clearTimeout(t)
177
190
  ;(async () => {
178
191
  const out = snap.output
179
192
  const outbound = await h.hooks.invoke('onMessageOutbound', { content: out?.result || '' })
180
193
  if (outbound?.systemMessage || outbound?.additionalContext) out.messages = mergeHookExtras(out.messages || [], outbound, 'onMessageOutbound')
181
194
  await h.hooks.invoke('onSessionEnd', { reason: out?.error ? 'error' : 'ok', iterations: out?.iterations })
182
195
  const errorStack = out?.error ? (events.find(e => e.type === 'llm_call' && !e.ok)?.stack || null) : null
183
- await writeTrajectory(out, { prompt, provider, model, skill, cwd, events, errorStack, witnessPath }); resolve(out)
184
- })().catch(reject)
196
+ await writeTrajectory(out, { prompt, provider, model, skill, cwd, events, errorStack, witnessPath })
197
+ // Unsubscribe + stop the actor once the turn is done — a finished
198
+ // actor should not be left running with live subscriptions/handles.
199
+ cleanup()
200
+ resolve(out)
201
+ })().catch(e => { cleanup(); reject(e) })
185
202
  })
186
203
  })
187
204
  }