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 +3 -3
- package/plugins/gui-machines/plugin.js +4 -0
- package/src/acp/server.js +9 -2
- package/src/agent/machine.js +30 -17
- package/src/batch.js +3 -2
- package/src/cron/scheduler.js +5 -1
- package/src/gateway/run.js +3 -1
- package/src/machines/step-journal.js +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "freddie",
|
|
3
|
-
"version": "0.0.
|
|
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.
|
|
31
|
-
"anentrypoint-design": "^0.0.
|
|
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
|
|
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
|
-
|
|
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
|
},
|
package/src/agent/machine.js
CHANGED
|
@@ -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
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
184
|
-
|
|
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
|
})
|
package/src/cron/scheduler.js
CHANGED
|
@@ -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
|
-
|
|
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
|
package/src/gateway/run.js
CHANGED
|
@@ -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
|