freddie 0.0.119 → 0.0.121

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.119",
3
+ "version": "0.0.121",
4
4
  "type": "module",
5
5
  "description": "Open JS agent harness built on pi-mono, floosie, xstate, and anentrypoint-design",
6
6
  "bin": {
@@ -27,8 +27,8 @@
27
27
  "@mariozechner/pi-ai": "^0.70.6",
28
28
  "@mariozechner/pi-coding-agent": "^0.70.6",
29
29
  "@mariozechner/pi-tui": "^0.70.6",
30
- "acptoapi": "^1.0.114",
31
- "anentrypoint-design": "^0.0.140",
30
+ "acptoapi": "^1.0.115",
31
+ "anentrypoint-design": "^0.0.144",
32
32
  "commander": "^14.0.0",
33
33
  "express": "^5.0.0",
34
34
  "flatspace": "^1.0.18",
@@ -41,6 +41,10 @@ export default {
41
41
  try { res.json({ machines: await snapshotRows(req.params.kind) }) }
42
42
  catch (e) { res.status(500).json({ error: String(e.message || e) }) }
43
43
  })
44
+ gui.route('GET', '/api/machines/steps/:key', async (req, res) => {
45
+ try { const { listSteps } = await import('../../src/machines/step-journal.js'); res.json({ key: req.params.key, steps: await listSteps(req.params.key) }) }
46
+ catch (e) { res.status(500).json({ error: String(e.message || e) }) }
47
+ })
44
48
  gui.route('POST', '/api/machines/resume', async (_req, res) => {
45
49
  try { const { resumeAll } = await import('../../src/machines/resume.js'); res.json({ ok: true, summary: await resumeAll() }) }
46
50
  catch (e) { res.status(500).json({ error: String(e.message || e) }) }
package/src/acp/server.js CHANGED
@@ -8,6 +8,7 @@ import { checkPermission, rememberAllow, rememberDeny } from './permissions.js'
8
8
  import { AcpSessionManager } from './session.js'
9
9
  import { createMachine, createActor } from 'xstate'
10
10
  import { persist, load, clear } from '../machines/snapshot-store.js'
11
+ import { runStep, clearSteps } from '../machines/step-journal.js'
11
12
 
12
13
  const log = logger('acp')
13
14
 
@@ -102,9 +103,15 @@ const METHODS = {
102
103
  // refresh mid-turn is observable + resumable (the agent snapshot for the
103
104
  // turn itself lives under kind=agent via runTurn sessionKey).
104
105
  await persist('acp-prompt', sessionId, { status: 'active', value: 'running', context: { sessionId, prompt } })
105
- const out = await runTurn({ prompt, callLLM: srv.callLLM, sessionKey: 'acp:' + sessionId })
106
+ const sk = 'acp:' + sessionId
107
+ // The agent turn itself is step-journaled under sessionKey=sk (at-most-once
108
+ // LLM + tool effects). The post-turn persistence (session append) is its
109
+ // own journaled step so a crash between runTurn return and appendAssistant
110
+ // does not double-append on resume.
111
+ const out = await runTurn({ prompt, callLLM: srv.callLLM, sessionKey: sk })
112
+ await runStep(sk, 'acp-persist', async () => { await srv.sessions.appendAssistant(sessionId, out.result || ''); return { ok: true } })
106
113
  await clear('acp-prompt', sessionId)
107
- srv.sessions.appendAssistant(sessionId, out.result || '')
114
+ await clearSteps(sk)
108
115
  Events.messageComplete((o) => srv.send(o), { sessionId, role: 'assistant', content: out.result || '' })
109
116
  return { result: out.result, error: out.error, iterations: out.iterations }
110
117
  },
@@ -4,11 +4,12 @@ import { getEnabledToolSchemas } from '../toolsets.js'
4
4
  import { logger } from '../observability/log.js'
5
5
  import { resolveCallLLM } from './llm_resolver.js'
6
6
  import { createPersistentActor } from '../machines/persistent-actor.js'
7
+ import { runStep, clearSteps } from '../machines/step-journal.js'
7
8
  import { randomUUID } from 'node:crypto'
8
9
 
9
10
  const log = logger('agent')
10
11
 
11
- export function createAgentMachine({ provider, model, maxIterations = 90, callLLM, enabledToolsets = ['core'], disabledToolsets = [], events } = {}) {
12
+ export function createAgentMachine({ provider, model, maxIterations = 90, callLLM, enabledToolsets = ['core'], disabledToolsets = [], events, sessionKey } = {}) {
12
13
  const baseLLM = callLLM || resolveCallLLM({ provider, model })
13
14
  const llm = events ? async (input) => {
14
15
  const t0 = Date.now()
@@ -34,6 +35,7 @@ export function createAgentMachine({ provider, model, maxIterations = 90, callLL
34
35
  error: null,
35
36
  provider, model,
36
37
  enabledToolsets, disabledToolsets,
38
+ sessionKey,
37
39
  }),
38
40
  states: {
39
41
  idle: {
@@ -52,9 +54,9 @@ export function createAgentMachine({ provider, model, maxIterations = 90, callLL
52
54
  invoke: {
53
55
  src: fromPromise(async ({ input }) => {
54
56
  const schemas = await getEnabledToolSchemas(input.enabledToolsets, input.disabledToolsets)
55
- return llm({ messages: input.messages, tools: schemas, model: input.model, provider: input.provider })
57
+ return await runStep(input.sessionKey, 'llm:' + input.iterations, () => llm({ messages: input.messages, tools: schemas, model: input.model, provider: input.provider }))
56
58
  }),
57
- input: ({ context }) => ({ messages: context.messages, model: context.model, provider: context.provider, enabledToolsets: context.enabledToolsets, disabledToolsets: context.disabledToolsets }),
59
+ input: ({ context }) => ({ messages: context.messages, model: context.model, provider: context.provider, enabledToolsets: context.enabledToolsets, disabledToolsets: context.disabledToolsets, sessionKey: context.sessionKey, iterations: context.iterations }),
58
60
  onDone: [
59
61
  { 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 }] }) },
60
62
  { target: 'done', actions: assign({ messages: ({ context, event }) => [...context.messages, { role: 'assistant', content: event.output.content || '' }], lastResult: ({ context, event }) => {
@@ -92,16 +94,21 @@ export function createAgentMachine({ provider, model, maxIterations = 90, callLL
92
94
  const tname = call.name || call.function?.name
93
95
  const targs = call.arguments || call.function?.arguments || {}
94
96
  const tcid = call.id || call.tool_call_id
95
- const pushExtras = r => { if (r?.systemMessage) extras.push({ role: 'system', content: '[hook] ' + r.systemMessage }); if (r?.additionalContext) extras.push({ role: 'system', content: r.additionalContext }) }
96
- const pre = await h.hooks.invoke('preToolCall', { name: tname, args: targs }); pushExtras(pre)
97
- if (pre?.behavior === 'block') { results.push({ tool_call_id: tcid, content: JSON.stringify({ error: 'tool call denied by plugsdk hook', tool: tname, reason: pre.reason || 'denied' }) }); continue }
98
- const res = await h.pi.dispatchTool(tname, (pre && pre.args) || targs)
99
- pushExtras(await h.hooks.invoke('postToolCall', { name: tname, args: targs, result: res }))
100
- results.push({ tool_call_id: tcid, content: res })
97
+ const ret = await runStep(input.sessionKey, 'tool:' + input.iterations + ':' + tcid, async () => {
98
+ const callExtras = []
99
+ const pushExtras = r => { if (r?.systemMessage) callExtras.push({ role: 'system', content: '[hook] ' + r.systemMessage }); if (r?.additionalContext) callExtras.push({ role: 'system', content: r.additionalContext }) }
100
+ const pre = await h.hooks.invoke('preToolCall', { name: tname, args: targs }); pushExtras(pre)
101
+ if (pre?.behavior === 'block') { return { content: JSON.stringify({ error: 'tool call denied by plugsdk hook', tool: tname, reason: pre.reason || 'denied' }), extras: callExtras } }
102
+ const res = await h.pi.dispatchTool(tname, (pre && pre.args) || targs)
103
+ pushExtras(await h.hooks.invoke('postToolCall', { name: tname, args: targs, result: res }))
104
+ return { content: res, extras: callExtras }
105
+ })
106
+ results.push({ tool_call_id: tcid, content: ret.content })
107
+ extras.push(...ret.extras)
101
108
  }
102
109
  return { results, extras }
103
110
  }),
104
- input: ({ context }) => ({ messages: context.messages }),
111
+ input: ({ context }) => ({ messages: context.messages, sessionKey: context.sessionKey, iterations: context.iterations }),
105
112
  onDone: { target: 'prompting', actions: assign({
106
113
  messages: ({ context, event }) => [...context.messages, ...event.output.results.map(r => ({ role: 'tool', tool_call_id: r.tool_call_id, content: r.content })), ...event.output.extras],
107
114
  iterations: ({ context }) => context.iterations + 1,
@@ -175,13 +182,17 @@ function mergeHookExtras(messages, r, tag) {
175
182
  // Drive a started persistent agent actor to its final state, wiring timeout +
176
183
  // session-end hooks + trajectory. Shared by runTurn (fresh) and resumeTurn
177
184
  // (rehydrated from a persisted snapshot after a refresh/restart).
178
- async function driveAgentActor({ pa, h, events, prompt, provider, model, skill, cwd, witnessPath, timeoutMs }) {
185
+ async function driveAgentActor({ pa, h, events, prompt, provider, model, skill, cwd, witnessPath, timeoutMs, sessionKey }) {
179
186
  const { actor } = pa
180
187
  return await new Promise((resolve, reject) => {
181
188
  let sub
182
189
  const cleanup = () => { try { sub?.unsubscribe() } catch {} ; pa.flush().catch(() => {}).finally(() => { try { actor.stop() } catch {} }) }
183
- const t = setTimeout(() => { cleanup(); reject(new Error('agent turn timeout')) }, timeoutMs)
184
- sub = actor.subscribe(snap => { if (snap.status !== 'done') return; clearTimeout(t)
190
+ let settled = false
191
+ const t = setTimeout(() => { if (settled) return; settled = true; cleanup(); reject(new Error('agent turn timeout')) }, timeoutMs)
192
+ // Do not let a pending turn-timeout timer keep the event loop alive or fire
193
+ // during process teardown after the awaiting caller has already moved on.
194
+ if (typeof t?.unref === 'function') t.unref()
195
+ sub = actor.subscribe(snap => { if (snap.status !== 'done') return; if (settled) return; settled = true; clearTimeout(t)
185
196
  ;(async () => {
186
197
  const out = snap.output
187
198
  const outbound = await h.hooks.invoke('onMessageOutbound', { content: out?.result || '' })
@@ -189,6 +200,8 @@ async function driveAgentActor({ pa, h, events, prompt, provider, model, skill,
189
200
  await h.hooks.invoke('onSessionEnd', { reason: out?.error ? 'error' : 'ok', iterations: out?.iterations })
190
201
  const errorStack = out?.error ? (events.find(e => e.type === 'llm_call' && !e.ok)?.stack || null) : null
191
202
  await writeTrajectory(out, { prompt, provider, model, skill, cwd, events, errorStack, witnessPath })
203
+ // Completed turn leaves no step-journal residue.
204
+ await clearSteps(sessionKey)
192
205
  // Unsubscribe, flush the final snapshot (persistent-actor clears it on
193
206
  // the done state) + stop the actor — a finished actor should not be
194
207
  // left running with live subscriptions/handles.
@@ -209,13 +222,13 @@ export async function runTurn({ prompt, messages = [], model, provider, callLLM,
209
222
  const inbound = await h.hooks.invoke('onMessageInbound', { content: prompt })
210
223
  if (inbound?.behavior === 'block') { await h.hooks.invoke('onSessionEnd', { reason: 'prompt_blocked' }); return { messages: initMessages, result: null, error: 'prompt blocked by plugsdk hook: ' + (inbound.reason || 'denied'), iterations: 0 } }
211
224
  initMessages = mergeHookExtras(initMessages, inbound, 'onMessageInbound')
212
- const machine = createAgentMachine({ model, provider, callLLM, enabledToolsets, disabledToolsets, maxIterations, events })
213
225
  // Persist the turn snapshot under kind=agent so an interrupted turn (process
214
226
  // refresh mid-tool-call) resumes exactly where it stopped via resumeTurn.
215
227
  const key = sessionKey || randomUUID()
228
+ const machine = createAgentMachine({ model, provider, callLLM, enabledToolsets, disabledToolsets, maxIterations, events, sessionKey: key })
216
229
  const pa = await createPersistentActor(machine, { kind: 'agent', key, input: { messages: initMessages } })
217
230
  pa.actor.send({ type: 'SUBMIT', prompt })
218
- return await driveAgentActor({ pa, h, events, prompt, provider, model, skill, cwd, witnessPath, timeoutMs })
231
+ return await driveAgentActor({ pa, h, events, prompt, provider, model, skill, cwd, witnessPath, timeoutMs, sessionKey: key })
219
232
  }
220
233
 
221
234
  // Rehydrate an interrupted turn from its persisted snapshot and drive it to
@@ -226,10 +239,10 @@ export async function resumeTurn({ sessionKey, model, provider, callLLM, enabled
226
239
  const { load } = await import('../machines/snapshot-store.js')
227
240
  if (!(await load('agent', sessionKey))) return null
228
241
  const events = []; const h = await bootHost()
229
- const machine = createAgentMachine({ model, provider, callLLM, enabledToolsets, disabledToolsets, maxIterations, events })
242
+ const machine = createAgentMachine({ model, provider, callLLM, enabledToolsets, disabledToolsets, maxIterations, events, sessionKey })
230
243
  const pa = await createPersistentActor(machine, { kind: 'agent', key: sessionKey, input: { messages: [] } })
231
244
  if (!pa.resumed) { await pa.forget(); return null }
232
- return await driveAgentActor({ pa, h, events, prompt: '', provider, model, skill, cwd, witnessPath, timeoutMs })
245
+ return await driveAgentActor({ pa, h, events, prompt: '', provider, model, skill, cwd, witnessPath, timeoutMs, sessionKey })
233
246
  }
234
247
 
235
248
  export async function invokeCompactHooks({ trigger = 'auto', messages = [] } = {}) {
package/src/batch.js CHANGED
@@ -6,6 +6,7 @@ import { randomUUID } from 'node:crypto'
6
6
  import { createMachine, assign, fromPromise } from 'xstate'
7
7
  import { createPersistentActor } from './machines/persistent-actor.js'
8
8
  import { load } from './machines/snapshot-store.js'
9
+ import { runStep, clearSteps } from './machines/step-journal.js'
9
10
 
10
11
  // Run one prompt and append its result to the batch jsonl file.
11
12
  async function runOne({ job, model, callLLM, file }) {
@@ -43,7 +44,7 @@ export function createBatchMachine({ prompts, concurrency, model, callLLM, file
43
44
  .map((p, i) => ({ i, p }))
44
45
  .filter(({ i }) => !context.done.includes(i))
45
46
  .slice(0, context.concurrency)
46
- return await Promise.all(pending.map(job => runOne({ job, model: context.model, callLLM, file: context.file })))
47
+ return await Promise.all(pending.map(job => runStep(context.id, 'prompt:' + job.i, () => runOne({ job, model: context.model, callLLM, file: context.file }))))
47
48
  }),
48
49
  input: ({ context }) => ({ context }),
49
50
  onDone: {
@@ -89,7 +90,7 @@ function driveBatch(pa) {
89
90
  const sub = actor.subscribe(snap => {
90
91
  if (snap.status !== 'done') return
91
92
  const out = snap.output
92
- pa.flush().catch(() => {}).finally(() => { try { sub.unsubscribe() } catch {}; try { actor.stop() } catch {}; resolve(out) })
93
+ pa.flush().catch(() => {}).then(() => clearSteps(out.id)).catch(() => {}).finally(() => { try { sub.unsubscribe() } catch {}; try { actor.stop() } catch {}; resolve(out) })
93
94
  })
94
95
  actor.subscribe({ error: (e) => { try { sub.unsubscribe() } catch {}; reject(e) } })
95
96
  })
@@ -4,6 +4,7 @@ import { runTurn } from '../agent/machine.js'
4
4
  import { logger } from '../observability/log.js'
5
5
  import { createMachine, assign, fromPromise } from 'xstate'
6
6
  import { createPersistentActor } from '../machines/persistent-actor.js'
7
+ import { runStep } from '../machines/step-journal.js'
7
8
 
8
9
  const log = logger('cron')
9
10
 
@@ -46,7 +47,10 @@ export async function tick(now = new Date(), { callLLM = null } = {}) {
46
47
  if (j.last_run && Math.floor(j.last_run / 60000) === minuteKey) continue
47
48
  await d.prepare(`UPDATE cron_jobs SET last_run = ? WHERE id = ?`).run(now.getTime(), j.id)
48
49
  fired.push(j)
49
- runTurn({ prompt: j.prompt, callLLM }).catch(e => log.error('cron run failed', { id: j.id, err: String(e) }))
50
+ // runStep makes the fire idempotent within the minute as a second layer
51
+ // atop the last_run guard. Cron step rows accumulate (one per fired
52
+ // minute-key) but are tiny; minute-keys rotate so they don't collide.
53
+ runStep('cron:' + j.id, 'fire:' + minuteKey, () => runTurn({ prompt: j.prompt, callLLM })).catch(e => log.error('cron run failed', { id: j.id, err: String(e) }))
50
54
  } catch (e) { log.error('cron tick failed', { id: j.id, err: String(e) }) }
51
55
  }
52
56
  return fired
@@ -2,6 +2,7 @@ import { logger } from '../observability/log.js'
2
2
  import { runTurn } from '../agent/machine.js'
3
3
  import { createMachine, assign, fromPromise, createActor } from 'xstate'
4
4
  import { persist, load, clear } from '../machines/snapshot-store.js'
5
+ import { runStep, clearSteps } from '../machines/step-journal.js'
5
6
  import { randomUUID } from 'node:crypto'
6
7
 
7
8
  const log = logger('gateway')
@@ -65,12 +66,13 @@ export class Gateway {
65
66
  await persist('gateway-msg', msgKey, { status: 'active', value: 'processing', context: { platform, from: msg.from, text: msg.text } })
66
67
  let cur = { ...msg, platform }
67
68
  for (const h of this.hooks.inbound) cur = (await h(cur)) || cur
68
- const result = await runTurn({ prompt: cur.text || '', callLLM: this.callLLM })
69
+ const result = await runStep(msgKey, 'run', () => runTurn({ prompt: cur.text || '', callLLM: this.callLLM }))
69
70
  let reply = { to: msg.from, text: result.result || result.error || '', platform, result }
70
71
  for (const h of this.hooks.outbound) reply = (await h(reply)) || reply
71
72
  const adapter = this.platforms.get(platform)
72
73
  await adapter.send?.(reply)
73
74
  await clear('gateway-msg', msgKey)
75
+ await clearSteps(msgKey)
74
76
  return reply
75
77
  }
76
78
  }
Binary file