freddie 0.0.120 → 0.0.122
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/AGENTS.md +35 -10
- package/package.json +3 -2
- package/plugins/core-cli/plugin.js +132 -5
- package/plugins/gm-skill/plugin.js +16 -4
- package/plugins/gui-auth/plugin.js +45 -0
- package/plugins/gui-chat/plugin.js +21 -4
- package/plugins/gui-env/plugin.js +14 -1
- package/plugins/gui-machines/plugin.js +4 -0
- package/plugins/gui-sessions/plugin.js +7 -1
- package/plugins/memory/handler.js +23 -46
- package/plugins/platform-discord/handler.js +83 -1
- package/plugins/platform-whatsapp/handler.js +31 -5
- package/src/acp/server.js +10 -3
- package/src/acp/tools.js +5 -1
- package/src/agent/acptoapi-bridge.js +33 -9
- package/src/agent/llm_resolver.js +10 -1
- package/src/agent/machine.js +84 -22
- package/src/agent/tool_call_text.js +68 -0
- package/src/batch.js +7 -5
- package/src/browser/index.js +4 -0
- package/src/cli/cli_output.js +4 -4
- package/src/cli/interactive.js +58 -9
- package/src/cli/memory_setup.js +18 -4
- package/src/cli/relaunch.js +2 -2
- package/src/cli/stdin_secret.js +31 -0
- package/src/context/engine.js +7 -4
- package/src/cron/scheduler.js +5 -1
- package/src/gateway/run.js +20 -6
- package/src/index.js +1 -0
- package/src/learn/gm-learn.js +179 -0
- package/src/machines/step-journal.js +0 -0
- package/src/plugins/case/index.js +28 -0
- package/src/plugins/case/toolset.js +312 -0
- package/src/sessions.js +38 -1
- package/src/toolset_distributions.js +3 -0
- package/src/toolsets.js +1 -1
- package/src/web/app.js +55 -12
- package/src/web/index.html +30 -3
- package/src/web/server.js +51 -2
- package/src/web/state.js +74 -32
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import { EventEmitter } from 'node:events'
|
|
2
|
+
import WebSocket from 'ws'
|
|
3
|
+
|
|
4
|
+
// Discord gateway opcodes
|
|
5
|
+
const OP = { DISPATCH: 0, HEARTBEAT: 1, IDENTIFY: 2, RESUME: 6, RECONNECT: 7, INVALID_SESSION: 9, HELLO: 10, HEARTBEAT_ACK: 11 }
|
|
6
|
+
// GUILD_MESSAGES(1<<9) + DIRECT_MESSAGES(1<<12) + MESSAGE_CONTENT(1<<15)
|
|
7
|
+
const DEFAULT_INTENTS = (1 << 9) | (1 << 12) | (1 << 15)
|
|
2
8
|
|
|
3
9
|
export class DiscordAdapter extends EventEmitter {
|
|
4
10
|
constructor(opts = {}) {
|
|
@@ -6,16 +12,92 @@ export class DiscordAdapter extends EventEmitter {
|
|
|
6
12
|
this.platform = 'discord'
|
|
7
13
|
this.token = opts.token || process.env.DISCORD_BOT_TOKEN
|
|
8
14
|
this.api = opts.api || 'https://discord.com/api/v10'
|
|
15
|
+
this.intents = opts.intents ?? DEFAULT_INTENTS
|
|
16
|
+
this.receive = opts.receive !== false // open the gateway WS to receive messages
|
|
9
17
|
this._ws = null
|
|
18
|
+
this._heartbeat = null
|
|
19
|
+
this._seq = null
|
|
20
|
+
this._sessionId = null
|
|
21
|
+
this._resumeUrl = null
|
|
22
|
+
this._acked = true
|
|
23
|
+
this._closed = false
|
|
10
24
|
}
|
|
11
25
|
getRequiredEnv() { return ['DISCORD_BOT_TOKEN'] }
|
|
26
|
+
|
|
12
27
|
async start() {
|
|
13
28
|
if (!this.token) throw new Error('DiscordAdapter: DISCORD_BOT_TOKEN required')
|
|
14
29
|
const gw = await fetch(`${this.api}/gateway/bot`, { headers: { authorization: `Bot ${this.token}` } }).then(r => r.json())
|
|
15
30
|
if (!gw.url) throw new Error('DiscordAdapter: gateway lookup failed: ' + JSON.stringify(gw))
|
|
16
31
|
this.gatewayUrl = gw.url + '/?v=10&encoding=json'
|
|
32
|
+
// Open the gateway WebSocket so inbound messages are emitted as 'message'
|
|
33
|
+
// events { from, text, raw, platform }. Without this the adapter can send
|
|
34
|
+
// but never receives.
|
|
35
|
+
if (this.receive) { this._closed = false; this._connect() }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
_connect(resume = false) {
|
|
39
|
+
const url = resume && this._resumeUrl ? this._resumeUrl + '/?v=10&encoding=json' : this.gatewayUrl
|
|
40
|
+
const ws = this._ws = new WebSocket(url)
|
|
41
|
+
ws.on('message', (raw) => {
|
|
42
|
+
let p; try { p = JSON.parse(raw.toString()) } catch { return }
|
|
43
|
+
if (p.s != null) this._seq = p.s
|
|
44
|
+
switch (p.op) {
|
|
45
|
+
case OP.HELLO:
|
|
46
|
+
this._startHeartbeat(p.d.heartbeat_interval)
|
|
47
|
+
if (resume && this._sessionId) this._send({ op: OP.RESUME, d: { token: this.token, session_id: this._sessionId, seq: this._seq } })
|
|
48
|
+
else this._identify()
|
|
49
|
+
break
|
|
50
|
+
case OP.HEARTBEAT: this._send({ op: OP.HEARTBEAT, d: this._seq }); break
|
|
51
|
+
case OP.HEARTBEAT_ACK: this._acked = true; break
|
|
52
|
+
case OP.RECONNECT: this._reconnect(true); break
|
|
53
|
+
case OP.INVALID_SESSION: this._sessionId = null; setTimeout(() => this._reconnect(false), 1500); break
|
|
54
|
+
case OP.DISPATCH: this._dispatch(p); break
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
ws.on('close', () => {
|
|
58
|
+
clearInterval(this._heartbeat)
|
|
59
|
+
if (!this._closed) setTimeout(() => this._reconnect(true), 2500)
|
|
60
|
+
})
|
|
61
|
+
ws.on('error', () => { /* close handler drives reconnect */ })
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
_dispatch(p) {
|
|
65
|
+
if (p.t === 'READY') { this._sessionId = p.d?.session_id; this._resumeUrl = p.d?.resume_gateway_url; return }
|
|
66
|
+
if (p.t === 'MESSAGE_CREATE') {
|
|
67
|
+
const m = p.d
|
|
68
|
+
if (m.author?.bot) return // ignore bots and our own messages
|
|
69
|
+
this.emit('message', { from: m.author?.id, text: m.content || '', raw: m, platform: 'discord' })
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
_identify() {
|
|
74
|
+
this._send({ op: OP.IDENTIFY, d: { token: this.token, intents: this.intents, properties: { os: 'linux', browser: 'freddie', device: 'freddie' } } })
|
|
17
75
|
}
|
|
18
|
-
|
|
76
|
+
|
|
77
|
+
_startHeartbeat(interval) {
|
|
78
|
+
clearInterval(this._heartbeat)
|
|
79
|
+
this._acked = true
|
|
80
|
+
this._heartbeat = setInterval(() => {
|
|
81
|
+
if (!this._acked) { try { this._ws.terminate() } catch {} return } // zombie connection
|
|
82
|
+
this._acked = false
|
|
83
|
+
this._send({ op: OP.HEARTBEAT, d: this._seq })
|
|
84
|
+
}, interval)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
_reconnect(resume) {
|
|
88
|
+
if (this._closed) return
|
|
89
|
+
try { this._ws?.removeAllListeners?.(); this._ws?.close?.() } catch {}
|
|
90
|
+
this._connect(resume)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
_send(obj) { try { this._ws?.send(JSON.stringify(obj)) } catch {} }
|
|
94
|
+
|
|
95
|
+
async stop() {
|
|
96
|
+
this._closed = true
|
|
97
|
+
clearInterval(this._heartbeat)
|
|
98
|
+
try { this._ws?.close?.() } catch {}
|
|
99
|
+
}
|
|
100
|
+
|
|
19
101
|
async send(reply) {
|
|
20
102
|
if (!this.token) throw new Error('DiscordAdapter: token required')
|
|
21
103
|
const url = `${this.api}/channels/${reply.to}/messages`
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import express from 'express'
|
|
2
|
+
import crypto from 'node:crypto'
|
|
2
3
|
import { EventEmitter } from 'node:events'
|
|
3
4
|
|
|
4
5
|
export class WhatsappAdapter extends EventEmitter {
|
|
@@ -8,24 +9,49 @@ export class WhatsappAdapter extends EventEmitter {
|
|
|
8
9
|
this.token = opts.token || process.env.WHATSAPP_API_TOKEN
|
|
9
10
|
this.phoneId = opts.phoneId || process.env.WHATSAPP_PHONE_NUMBER_ID
|
|
10
11
|
this.verifyToken = opts.verifyToken || process.env.WHATSAPP_VERIFY_TOKEN || 'freddie'
|
|
11
|
-
|
|
12
|
+
// App secret enables X-Hub-Signature-256 verification on inbound webhooks.
|
|
13
|
+
// When set, unsigned or wrongly-signed requests are rejected.
|
|
14
|
+
this.appSecret = opts.appSecret || process.env.WHATSAPP_APP_SECRET || ''
|
|
15
|
+
this.port = opts.port ?? Number(process.env.WHATSAPP_WEBHOOK_PORT || 0)
|
|
16
|
+
this.path = opts.path || process.env.WHATSAPP_WEBHOOK_PATH || '/webhook'
|
|
12
17
|
this.api = opts.api || 'https://graph.facebook.com/v20.0'
|
|
13
18
|
this._server = null
|
|
14
19
|
}
|
|
15
20
|
getRequiredEnv() { return ['WHATSAPP_API_TOKEN', 'WHATSAPP_PHONE_NUMBER_ID'] }
|
|
21
|
+
|
|
22
|
+
// Verify Meta's HMAC-SHA256 signature over the raw request body.
|
|
23
|
+
_verifySignature(req) {
|
|
24
|
+
if (!this.appSecret) return true // verification disabled when no secret
|
|
25
|
+
const sig = req.get('x-hub-signature-256') || ''
|
|
26
|
+
if (!sig.startsWith('sha256=')) return false
|
|
27
|
+
const expected = 'sha256=' + crypto.createHmac('sha256', this.appSecret).update(req.rawBody || Buffer.alloc(0)).digest('hex')
|
|
28
|
+
try { return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)) } catch { return false }
|
|
29
|
+
}
|
|
30
|
+
|
|
16
31
|
async start() {
|
|
17
32
|
if (!this.token || !this.phoneId) throw new Error('WhatsappAdapter: WHATSAPP_API_TOKEN + WHATSAPP_PHONE_NUMBER_ID required')
|
|
18
33
|
const app = express()
|
|
19
|
-
|
|
20
|
-
app.
|
|
34
|
+
// Capture the raw body so the signature can be verified over exact bytes.
|
|
35
|
+
app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf } }))
|
|
36
|
+
|
|
37
|
+
app.get(this.path, (req, res) => {
|
|
21
38
|
if (req.query['hub.verify_token'] === this.verifyToken) return res.send(req.query['hub.challenge'])
|
|
22
39
|
res.sendStatus(403)
|
|
23
40
|
})
|
|
24
|
-
app.post(
|
|
41
|
+
app.post(this.path, (req, res) => {
|
|
42
|
+
if (!this._verifySignature(req)) return res.sendStatus(401)
|
|
25
43
|
const entries = req.body?.entry || []
|
|
26
44
|
for (const e of entries) for (const c of (e.changes || [])) {
|
|
27
45
|
const msgs = c.value?.messages || []
|
|
28
|
-
for (const m of msgs)
|
|
46
|
+
for (const m of msgs) {
|
|
47
|
+
this.emit('message', {
|
|
48
|
+
from: m.from,
|
|
49
|
+
text: m.text?.body || '',
|
|
50
|
+
// surface the platform message id for dedup, and the message type
|
|
51
|
+
// so media-only messages are recognisable upstream.
|
|
52
|
+
raw: { ...m, id: m.id, type: m.type },
|
|
53
|
+
})
|
|
54
|
+
}
|
|
29
55
|
}
|
|
30
56
|
res.json({ ok: true })
|
|
31
57
|
})
|
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
|
|
|
@@ -40,7 +41,7 @@ export class AcpServer extends EventEmitter {
|
|
|
40
41
|
this._pendingPerm = new Map()
|
|
41
42
|
this.machine = createAcpMachine()
|
|
42
43
|
this.actor = createActor(this.machine)
|
|
43
|
-
this.actor.subscribe(() => { persist('acp', 'lifecycle', this.actor.getPersistedSnapshot()).catch(
|
|
44
|
+
this.actor.subscribe(() => { persist('acp', 'lifecycle', this.actor.getPersistedSnapshot()).catch(e => log.error('acp lifecycle persist failed', { err: String(e) })) })
|
|
44
45
|
this.actor.start()
|
|
45
46
|
}
|
|
46
47
|
get state() { return this.actor.getSnapshot().value }
|
|
@@ -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/acp/tools.js
CHANGED
|
@@ -2,7 +2,11 @@ import { bootHost } from '../host/index.js'
|
|
|
2
2
|
import { Events } from './events.js'
|
|
3
3
|
export async function listToolsForAcp() {
|
|
4
4
|
const h = await bootHost()
|
|
5
|
-
|
|
5
|
+
// Advertise only tools that are actually available (checkFn passes), so an ACP
|
|
6
|
+
// client is not told about a tool that will fail at invocation for a missing
|
|
7
|
+
// env var -- upfront honesty over a spurious tool-not-available later.
|
|
8
|
+
const available = h.pi.tools.list().filter(t => !t.checkFn || t.checkFn(t) !== false)
|
|
9
|
+
return available.map(t => ({ name: t.name, toolset: t.toolset, schema: t.schema, requiresEnv: t.requiresEnv || [] }))
|
|
6
10
|
}
|
|
7
11
|
export async function dispatchWithEvents({ name, args, send, sessionId = null }) {
|
|
8
12
|
const h = await bootHost()
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { logger } from '../observability/log.js'
|
|
2
|
+
import { parseTextToolCalls } from './tool_call_text.js'
|
|
2
3
|
|
|
3
4
|
const log = logger('acptoapi')
|
|
4
5
|
|
|
@@ -9,7 +10,12 @@ const log = logger('acptoapi')
|
|
|
9
10
|
// our AbortController be the single source of truth for the overall deadline
|
|
10
11
|
// (default 240s, override via FREDDIE_LLM_TIMEOUT_MS). Use setGlobalDispatcher
|
|
11
12
|
// once rather than a per-request Agent so we don't leak a dispatcher per call.
|
|
12
|
-
|
|
13
|
+
// Browser-safe env read: this module evaluates in a plain browser context where
|
|
14
|
+
// `process` is undefined (no node shim yet), so a bare process.env throws
|
|
15
|
+
// "process is not defined" and aborts the whole bundle import. envVal() reads
|
|
16
|
+
// live (picks up a late-installed shim) and never throws.
|
|
17
|
+
const envVal = (k) => { try { return (typeof process !== 'undefined' && process.env) ? process.env[k] : undefined } catch { return undefined } }
|
|
18
|
+
const ACPTOAPI_TIMEOUT_MS = Number(envVal('FREDDIE_LLM_TIMEOUT_MS')) || 240000
|
|
13
19
|
let _dispatcherSet = false
|
|
14
20
|
async function ensureLongTimeoutDispatcher() {
|
|
15
21
|
if (_dispatcherSet) return
|
|
@@ -29,20 +35,25 @@ async function ensureLongTimeoutDispatcher() {
|
|
|
29
35
|
}
|
|
30
36
|
|
|
31
37
|
export function getAcptoapiUrl() {
|
|
32
|
-
return
|
|
38
|
+
return envVal('FREDDIE_LLM_URL') || 'http://127.0.0.1:4800/v1'
|
|
33
39
|
}
|
|
34
40
|
|
|
35
41
|
export function getAcptoapiModel() {
|
|
36
|
-
return
|
|
42
|
+
return envVal('FREDDIE_LLM_MODEL') || 'claude/haiku'
|
|
37
43
|
}
|
|
38
44
|
|
|
39
|
-
export async function callLLM({ messages, tools = [], model } = {}) {
|
|
45
|
+
export async function callLLM({ messages, tools = [], model, tool_choice, cwd = null } = {}) {
|
|
40
46
|
const base = getAcptoapiUrl()
|
|
41
47
|
const useModel = model || getAcptoapiModel()
|
|
42
48
|
const hasTools = Array.isArray(tools) && tools.length > 0
|
|
43
49
|
const adaptedMessages = messages.map(adaptMessage)
|
|
44
|
-
|
|
45
|
-
|
|
50
|
+
// The coder-agent working-directory note is OPT-IN via an explicit `cwd` param.
|
|
51
|
+
// It used to be injected on every tool-bearing call, which polluted NON-coder
|
|
52
|
+
// agents' prompts with "use your built-in tools (Bash, Read, Write)" -- tool
|
|
53
|
+
// hallucination bait plus a filesystem-path leak for hosts (like a contact-facing
|
|
54
|
+
// chat agent) whose toolset has no such tools. runTurn already composes its own
|
|
55
|
+
// cwd note when a caller passes cwd; direct callLLM users opt in the same way.
|
|
56
|
+
if (hasTools && cwd) {
|
|
46
57
|
const sysIdx = adaptedMessages.findIndex(m => m.role === 'system')
|
|
47
58
|
const cwdNote = `\nWorking directory: ${cwd}\nUse your built-in tools (Bash, Read, Write) to explore files in this directory when needed.`
|
|
48
59
|
if (sysIdx >= 0) adaptedMessages[sysIdx] = { ...adaptedMessages[sysIdx], content: (adaptedMessages[sysIdx].content || '') + cwdNote }
|
|
@@ -55,9 +66,11 @@ export async function callLLM({ messages, tools = [], model } = {}) {
|
|
|
55
66
|
max_tokens: 4096,
|
|
56
67
|
}
|
|
57
68
|
if (hasTools) body.tools = tools.map(adaptTool)
|
|
69
|
+
// Forward a forced tool_choice (e.g. 'required') so a caller can compel a tool
|
|
70
|
+
// call on a turn. Only when tools are present; absent -> the model chooses.
|
|
71
|
+
if (hasTools && tool_choice) body.tool_choice = tool_choice
|
|
58
72
|
const headers = { 'content-type': 'application/json', authorization: 'Bearer none' }
|
|
59
|
-
|
|
60
|
-
if (Array.isArray(tools) && tools.length) headers['x-cwd'] = cwd
|
|
73
|
+
if (hasTools && cwd) headers['x-cwd'] = cwd
|
|
61
74
|
// Rely on AbortController timeout. acptoapi v1+ ships CORS + Private
|
|
62
75
|
// Network Access headers so cross-origin loopback (gh-pages → localhost)
|
|
63
76
|
// succeeds when acptoapi is running. The earlier preemptive loopback
|
|
@@ -78,7 +91,12 @@ export async function callLLM({ messages, tools = [], model } = {}) {
|
|
|
78
91
|
const text = await res.text()
|
|
79
92
|
throw new Error(`acptoapi ${res.status}: ${text.slice(0, 400)}`)
|
|
80
93
|
}
|
|
81
|
-
|
|
94
|
+
let json
|
|
95
|
+
try {
|
|
96
|
+
json = await res.json()
|
|
97
|
+
} catch (e) {
|
|
98
|
+
throw new Error(`acptoapi ${res.status}: invalid JSON response: ${String(e)}`)
|
|
99
|
+
}
|
|
82
100
|
log.info('completed', { model: useModel, usage: json.usage })
|
|
83
101
|
return adaptResponse(json)
|
|
84
102
|
}
|
|
@@ -116,6 +134,12 @@ function adaptResponse(r) {
|
|
|
116
134
|
const tool_calls = Array.isArray(choice.tool_calls)
|
|
117
135
|
? choice.tool_calls.map(tc => ({ id: tc.id, name: tc.function?.name, arguments: tryParseJson(tc.function?.arguments) }))
|
|
118
136
|
: []
|
|
137
|
+
// Recover text-format tool calls (kimi <|tool_call_begin|> / llama
|
|
138
|
+
// <|python_tag|>) from weak models that don't emit structured tool_calls.
|
|
139
|
+
if (!tool_calls.length) {
|
|
140
|
+
const textTC = parseTextToolCalls(content)
|
|
141
|
+
if (textTC.length) return { content: '', tool_calls: textTC, raw: r }
|
|
142
|
+
}
|
|
119
143
|
return { content, tool_calls, raw: r }
|
|
120
144
|
}
|
|
121
145
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getConfigValue } from '../config.js'
|
|
2
2
|
import { MATRIX_FILE } from './model-matrix.js'
|
|
3
3
|
import { callLLM as bridgeCall, isReachable as bridgeReachable } from './acptoapi-bridge.js'
|
|
4
|
+
import { parseTextToolCalls } from './tool_call_text.js'
|
|
4
5
|
import * as sdkNs from 'acptoapi'
|
|
5
6
|
export { matrixUsable } from './model-matrix.js'
|
|
6
7
|
|
|
@@ -42,7 +43,15 @@ function adapt(result) {
|
|
|
42
43
|
const flat = flattenContent(c.content)
|
|
43
44
|
const openaiTC = Array.isArray(c.tool_calls) ? c.tool_calls.map(tc => ({ id: tc.id, name: tc.function?.name, arguments: tryJson(tc.function?.arguments) })) : []
|
|
44
45
|
const anthropicTC = flat.toolUses.map(t => ({ id: t.id, name: t.name, arguments: t.input || {} }))
|
|
45
|
-
|
|
46
|
+
const tool_calls = openaiTC.concat(anthropicTC)
|
|
47
|
+
// Weak models may emit tool calls as text (kimi <|tool_call_begin|> / llama
|
|
48
|
+
// <|python_tag|>) instead of structured tool_calls. Recover them so the loop
|
|
49
|
+
// iterates; clear the text content since it was the call, not a reply.
|
|
50
|
+
if (!tool_calls.length) {
|
|
51
|
+
const textTC = parseTextToolCalls(flat.text)
|
|
52
|
+
if (textTC.length) return { content: '', tool_calls: textTC, raw: result }
|
|
53
|
+
}
|
|
54
|
+
return { content: flat.text, tool_calls, raw: result }
|
|
46
55
|
}
|
|
47
56
|
|
|
48
57
|
// Names callers can use as model= to select a curated acptoapi chain.
|
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, toolCtx = null, tool_choice } = {}) {
|
|
12
13
|
const baseLLM = callLLM || resolveCallLLM({ provider, model })
|
|
13
14
|
const llm = events ? async (input) => {
|
|
14
15
|
const t0 = Date.now()
|
|
@@ -34,6 +35,20 @@ export function createAgentMachine({ provider, model, maxIterations = 90, callLL
|
|
|
34
35
|
error: null,
|
|
35
36
|
provider, model,
|
|
36
37
|
enabledToolsets, disabledToolsets,
|
|
38
|
+
sessionKey,
|
|
39
|
+
// Optional tool_choice policy. A FUNCTION receives the iteration index and
|
|
40
|
+
// returns the tool_choice for that llm call (full caller control). A plain
|
|
41
|
+
// VALUE (e.g. 'required') applies on ITERATION 0 ONLY, then reverts to the
|
|
42
|
+
// model's own choice -- a constant 'required' would make the done
|
|
43
|
+
// transition (which fires only on zero tool_calls) unreachable and exhaust
|
|
44
|
+
// the iteration budget, so first-call-only is the safe value semantics: it
|
|
45
|
+
// nudges a weak model into its first tool call without breaking loop
|
|
46
|
+
// termination. Undefined = model's own choice every call.
|
|
47
|
+
tool_choice,
|
|
48
|
+
// Opaque per-turn context handed to every tool handler (author/role/
|
|
49
|
+
// active-case/store etc.). The agent loop is identity-blind; tools that
|
|
50
|
+
// need who-is-asking read it from here. Null for a plain turn.
|
|
51
|
+
toolCtx,
|
|
37
52
|
}),
|
|
38
53
|
states: {
|
|
39
54
|
idle: {
|
|
@@ -52,9 +67,13 @@ export function createAgentMachine({ provider, model, maxIterations = 90, callLL
|
|
|
52
67
|
invoke: {
|
|
53
68
|
src: fromPromise(async ({ input }) => {
|
|
54
69
|
const schemas = await getEnabledToolSchemas(input.enabledToolsets, input.disabledToolsets)
|
|
55
|
-
|
|
70
|
+
// Resolve the per-iteration tool_choice policy (see context note).
|
|
71
|
+
const tc = typeof input.tool_choice === 'function'
|
|
72
|
+
? input.tool_choice(input.iterations)
|
|
73
|
+
: (input.iterations === 0 ? input.tool_choice : undefined)
|
|
74
|
+
return await runStep(input.sessionKey, 'llm:' + input.iterations, () => llm({ messages: input.messages, tools: schemas, model: input.model, provider: input.provider, tool_choice: tc }))
|
|
56
75
|
}),
|
|
57
|
-
input: ({ context }) => ({ messages: context.messages, model: context.model, provider: context.provider, enabledToolsets: context.enabledToolsets, disabledToolsets: context.disabledToolsets }),
|
|
76
|
+
input: ({ context }) => ({ messages: context.messages, model: context.model, provider: context.provider, enabledToolsets: context.enabledToolsets, disabledToolsets: context.disabledToolsets, sessionKey: context.sessionKey, iterations: context.iterations, tool_choice: context.tool_choice }),
|
|
58
77
|
onDone: [
|
|
59
78
|
{ 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
79
|
{ target: 'done', actions: assign({ messages: ({ context, event }) => [...context.messages, { role: 'assistant', content: event.output.content || '' }], lastResult: ({ context, event }) => {
|
|
@@ -92,16 +111,21 @@ export function createAgentMachine({ provider, model, maxIterations = 90, callLL
|
|
|
92
111
|
const tname = call.name || call.function?.name
|
|
93
112
|
const targs = call.arguments || call.function?.arguments || {}
|
|
94
113
|
const tcid = call.id || call.tool_call_id
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
114
|
+
const ret = await runStep(input.sessionKey, 'tool:' + input.iterations + ':' + tcid, async () => {
|
|
115
|
+
const callExtras = []
|
|
116
|
+
const pushExtras = r => { if (r?.systemMessage) callExtras.push({ role: 'system', content: '[hook] ' + r.systemMessage }); if (r?.additionalContext) callExtras.push({ role: 'system', content: r.additionalContext }) }
|
|
117
|
+
const pre = await h.hooks.invoke('preToolCall', { name: tname, args: targs }); pushExtras(pre)
|
|
118
|
+
if (pre?.behavior === 'block') { return { content: JSON.stringify({ error: 'tool call denied by plugsdk hook', tool: tname, reason: pre.reason || 'denied' }), extras: callExtras } }
|
|
119
|
+
const res = await h.pi.dispatchTool(tname, (pre && pre.args) || targs, input.toolCtx || {})
|
|
120
|
+
pushExtras(await h.hooks.invoke('postToolCall', { name: tname, args: targs, result: res }))
|
|
121
|
+
return { content: res, extras: callExtras }
|
|
122
|
+
})
|
|
123
|
+
results.push({ tool_call_id: tcid, content: ret.content })
|
|
124
|
+
extras.push(...ret.extras)
|
|
101
125
|
}
|
|
102
126
|
return { results, extras }
|
|
103
127
|
}),
|
|
104
|
-
input: ({ context }) => ({ messages: context.messages }),
|
|
128
|
+
input: ({ context }) => ({ messages: context.messages, sessionKey: context.sessionKey, iterations: context.iterations, toolCtx: context.toolCtx }),
|
|
105
129
|
onDone: { target: 'prompting', actions: assign({
|
|
106
130
|
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
131
|
iterations: ({ context }) => context.iterations + 1,
|
|
@@ -173,15 +197,39 @@ function mergeHookExtras(messages, r, tag) {
|
|
|
173
197
|
}
|
|
174
198
|
|
|
175
199
|
// Drive a started persistent agent actor to its final state, wiring timeout +
|
|
200
|
+
// Auto-learn: distill a salient fact from a completed turn and memorize it into gm rs-learn.
|
|
201
|
+
// Only fires on substantive, non-error outcomes; dedupes against existing near-identical
|
|
202
|
+
// memories so the store does not fill with restatements. Best-effort — never throws.
|
|
203
|
+
const AUTOLEARN_MIN_LEN = 40 // skip trivial one-liners
|
|
204
|
+
const AUTOLEARN_DEDUPE_COS = 0.92 // a hit this similar means we already know it
|
|
205
|
+
async function autoLearnTurn({ prompt, out }) {
|
|
206
|
+
try {
|
|
207
|
+
if (!out || out.error) return
|
|
208
|
+
const result = (out.result || '').toString().trim()
|
|
209
|
+
if (result.length < AUTOLEARN_MIN_LEN) return
|
|
210
|
+
const { memorize, recall, projectNamespace } = await import('../learn/gm-learn.js')
|
|
211
|
+
const namespace = await projectNamespace()
|
|
212
|
+
// Concise salient fact: the user's ask + the outcome, capped to keep recall sharp.
|
|
213
|
+
const fact = `Q: ${(prompt || '').toString().trim().slice(0, 200)}\nA: ${result.slice(0, 600)}`
|
|
214
|
+
const existing = await recall(fact, { limit: 1, namespace })
|
|
215
|
+
if (existing.length && existing[0].score >= AUTOLEARN_DEDUPE_COS) return
|
|
216
|
+
await memorize(fact, { namespace })
|
|
217
|
+
} catch (_) {}
|
|
218
|
+
}
|
|
219
|
+
|
|
176
220
|
// session-end hooks + trajectory. Shared by runTurn (fresh) and resumeTurn
|
|
177
221
|
// (rehydrated from a persisted snapshot after a refresh/restart).
|
|
178
|
-
async function driveAgentActor({ pa, h, events, prompt, provider, model, skill, cwd, witnessPath, timeoutMs }) {
|
|
222
|
+
async function driveAgentActor({ pa, h, events, prompt, provider, model, skill, cwd, witnessPath, timeoutMs, sessionKey }) {
|
|
179
223
|
const { actor } = pa
|
|
180
224
|
return await new Promise((resolve, reject) => {
|
|
181
225
|
let sub
|
|
182
226
|
const cleanup = () => { try { sub?.unsubscribe() } catch {} ; pa.flush().catch(() => {}).finally(() => { try { actor.stop() } catch {} }) }
|
|
183
|
-
|
|
184
|
-
|
|
227
|
+
let settled = false
|
|
228
|
+
const t = setTimeout(() => { if (settled) return; settled = true; cleanup(); reject(new Error('agent turn timeout')) }, timeoutMs)
|
|
229
|
+
// Do not let a pending turn-timeout timer keep the event loop alive or fire
|
|
230
|
+
// during process teardown after the awaiting caller has already moved on.
|
|
231
|
+
if (typeof t?.unref === 'function') t.unref()
|
|
232
|
+
sub = actor.subscribe(snap => { if (snap.status !== 'done') return; if (settled) return; settled = true; clearTimeout(t)
|
|
185
233
|
;(async () => {
|
|
186
234
|
const out = snap.output
|
|
187
235
|
const outbound = await h.hooks.invoke('onMessageOutbound', { content: out?.result || '' })
|
|
@@ -189,6 +237,11 @@ async function driveAgentActor({ pa, h, events, prompt, provider, model, skill,
|
|
|
189
237
|
await h.hooks.invoke('onSessionEnd', { reason: out?.error ? 'error' : 'ok', iterations: out?.iterations })
|
|
190
238
|
const errorStack = out?.error ? (events.find(e => e.type === 'llm_call' && !e.ok)?.stack || null) : null
|
|
191
239
|
await writeTrajectory(out, { prompt, provider, model, skill, cwd, events, errorStack, witnessPath })
|
|
240
|
+
// Auto-learn: memorize a salient summary of this turn into gm rs-learn so
|
|
241
|
+
// freddie learns from each substantive turn. Best-effort, deduped, capped.
|
|
242
|
+
await autoLearnTurn({ prompt, out })
|
|
243
|
+
// Completed turn leaves no step-journal residue.
|
|
244
|
+
await clearSteps(sessionKey)
|
|
192
245
|
// Unsubscribe, flush the final snapshot (persistent-actor clears it on
|
|
193
246
|
// the done state) + stop the actor — a finished actor should not be
|
|
194
247
|
// left running with live subscriptions/handles.
|
|
@@ -199,37 +252,46 @@ async function driveAgentActor({ pa, h, events, prompt, provider, model, skill,
|
|
|
199
252
|
})
|
|
200
253
|
}
|
|
201
254
|
|
|
202
|
-
export async function runTurn({ prompt, messages = [], model, provider, callLLM, enabledToolsets, disabledToolsets, maxIterations = 90, timeoutMs = 30000, cwd, skill, witnessPath, sessionKey } = {}) {
|
|
255
|
+
export async function runTurn({ prompt, messages = [], model, provider, callLLM, enabledToolsets, disabledToolsets, maxIterations = 90, timeoutMs = 30000, cwd, skill, witnessPath, sessionKey, toolCtx = null, tool_choice } = {}) {
|
|
203
256
|
const events = []; const h = await bootHost()
|
|
204
257
|
await h.hooks.invoke('onSessionStart', { prompt, model, provider, skill, cwd })
|
|
205
258
|
let initMessages = [...messages]; const sysParts = []
|
|
206
259
|
if (cwd) sysParts.push(`Working directory: ${cwd}. Always pass cwd="${cwd}" to bash tool calls. When reading or writing files use paths relative to this directory or absolute paths under it.`)
|
|
207
260
|
if (skill) { const sd = h.pi.skills.get(skill); if (sd?.content) sysParts.push('Skill context:\n' + sd.content) }
|
|
261
|
+
// Auto-recall on turn entry: surface salient learned memories for this prompt from gm
|
|
262
|
+
// rs-learn (freddie's primary learning store). Best-effort; never blocks the turn.
|
|
263
|
+
try {
|
|
264
|
+
const { autoRecall, projectNamespace } = await import('../learn/gm-learn.js')
|
|
265
|
+
const hits = await autoRecall(prompt, { limit: 5, namespace: await projectNamespace() })
|
|
266
|
+
if (hits.length) sysParts.push('Relevant memories (gm rs-learn):\n' + hits.map(h => '- ' + h.text).join('\n'))
|
|
267
|
+
} catch (_) {}
|
|
208
268
|
if (sysParts.length) initMessages.unshift({ role: 'user', content: sysParts.join('\n\n') })
|
|
209
269
|
const inbound = await h.hooks.invoke('onMessageInbound', { content: prompt })
|
|
210
270
|
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
271
|
initMessages = mergeHookExtras(initMessages, inbound, 'onMessageInbound')
|
|
212
|
-
const machine = createAgentMachine({ model, provider, callLLM, enabledToolsets, disabledToolsets, maxIterations, events })
|
|
213
272
|
// Persist the turn snapshot under kind=agent so an interrupted turn (process
|
|
214
273
|
// refresh mid-tool-call) resumes exactly where it stopped via resumeTurn.
|
|
215
274
|
const key = sessionKey || randomUUID()
|
|
275
|
+
const machine = createAgentMachine({ model, provider, callLLM, enabledToolsets, disabledToolsets, maxIterations, events, sessionKey: key, toolCtx, tool_choice })
|
|
216
276
|
const pa = await createPersistentActor(machine, { kind: 'agent', key, input: { messages: initMessages } })
|
|
217
277
|
pa.actor.send({ type: 'SUBMIT', prompt })
|
|
218
|
-
return await driveAgentActor({ pa, h, events, prompt, provider, model, skill, cwd, witnessPath, timeoutMs })
|
|
278
|
+
return await driveAgentActor({ pa, h, events, prompt, provider, model, skill, cwd, witnessPath, timeoutMs, sessionKey: key })
|
|
219
279
|
}
|
|
220
280
|
|
|
221
281
|
// Rehydrate an interrupted turn from its persisted snapshot and drive it to
|
|
222
282
|
// completion. Returns null if no live snapshot exists for the key (already
|
|
223
283
|
// completed or never persisted) — caller falls back to a fresh runTurn.
|
|
224
|
-
export async function resumeTurn({ sessionKey, model, provider, callLLM, enabledToolsets, disabledToolsets, maxIterations = 90, timeoutMs = 30000, cwd, skill, witnessPath } = {}) {
|
|
284
|
+
export async function resumeTurn({ sessionKey, model, provider, callLLM, enabledToolsets, disabledToolsets, maxIterations = 90, timeoutMs = 30000, cwd, skill, witnessPath, toolCtx = null } = {}) {
|
|
225
285
|
if (!sessionKey) throw new Error('resumeTurn requires sessionKey')
|
|
226
|
-
const { load } = await import('../machines/snapshot-store.js')
|
|
227
|
-
if (!(await load('agent', sessionKey))) return null
|
|
228
286
|
const events = []; const h = await bootHost()
|
|
229
|
-
const machine = createAgentMachine({ model, provider, callLLM, enabledToolsets, disabledToolsets, maxIterations, events })
|
|
287
|
+
const machine = createAgentMachine({ model, provider, callLLM, enabledToolsets, disabledToolsets, maxIterations, events, sessionKey, toolCtx })
|
|
288
|
+
// createPersistentActor.load() already handles a missing/stale snapshot and
|
|
289
|
+
// leaves pa.resumed=false, so the prior pre-check load() was a redundant
|
|
290
|
+
// second read that opened a TOCTOU window (a concurrent delete between the two
|
|
291
|
+
// reads made forget() delete a snapshot we had just confirmed). One read only.
|
|
230
292
|
const pa = await createPersistentActor(machine, { kind: 'agent', key: sessionKey, input: { messages: [] } })
|
|
231
|
-
if (!pa.resumed)
|
|
232
|
-
return await driveAgentActor({ pa, h, events, prompt: '', provider, model, skill, cwd, witnessPath, timeoutMs })
|
|
293
|
+
if (!pa.resumed) return null
|
|
294
|
+
return await driveAgentActor({ pa, h, events, prompt: '', provider, model, skill, cwd, witnessPath, timeoutMs, sessionKey })
|
|
233
295
|
}
|
|
234
296
|
|
|
235
297
|
export async function invokeCompactHooks({ trigger = 'auto', messages = [] } = {}) {
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Some fast/weak models emit tool calls as plain TEXT in their own native token
|
|
2
|
+
// format instead of structured OpenAI `tool_calls` (especially when the system
|
|
3
|
+
// prompt is rich). The agent loop only acts on structured tool_calls, so without
|
|
4
|
+
// recovery it stalls after one turn. parseTextToolCalls() extracts both observed
|
|
5
|
+
// formats from a content string and returns OpenAI-shaped {id,name,arguments}[].
|
|
6
|
+
//
|
|
7
|
+
// Formats handled:
|
|
8
|
+
// - kimi / moonshot section format:
|
|
9
|
+
// <|tool_calls_section_begin|>
|
|
10
|
+
// <|tool_call_begin|> functions.<name>:<idx>
|
|
11
|
+
// <|tool_call_argument_begin|> {json-args}
|
|
12
|
+
// <|tool_call_end|>
|
|
13
|
+
// <|tool_calls_section_end|>
|
|
14
|
+
// - llama python_tag format:
|
|
15
|
+
// <|python_tag|>name({...}) or name("query") or name(key="value")
|
|
16
|
+
//
|
|
17
|
+
// Returns [] when the content holds no recognizable text tool call.
|
|
18
|
+
|
|
19
|
+
function randId() {
|
|
20
|
+
return 'call_' + Math.random().toString(36).slice(2, 10)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function parseKimiSection(content) {
|
|
24
|
+
if (!content.includes('<|tool_call_begin|>')) return []
|
|
25
|
+
const re = /<\|tool_call_begin\|>\s*([\s\S]*?)\s*<\|tool_call_argument_begin\|>\s*([\s\S]*?)\s*<\|tool_call_end\|>/g
|
|
26
|
+
const out = []
|
|
27
|
+
let m
|
|
28
|
+
while ((m = re.exec(content)) !== null) {
|
|
29
|
+
const name = (m[1] || '').replace(/^functions\./, '').replace(/:\d+\s*$/, '').trim()
|
|
30
|
+
let args
|
|
31
|
+
try { args = JSON.parse((m[2] || '').trim()) } catch { args = {} }
|
|
32
|
+
if (name) out.push({ id: randId(), name, arguments: args })
|
|
33
|
+
}
|
|
34
|
+
return out
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function parsePythonTag(content) {
|
|
38
|
+
if (!content.includes('<|python_tag|>')) return []
|
|
39
|
+
const after = content.slice(content.indexOf('<|python_tag|>') + '<|python_tag|>'.length).trim().split('\n')[0]
|
|
40
|
+
const mc = /^([A-Za-z_][A-Za-z0-9_.]*)\s*\(([\s\S]*?)\)\s*$/.exec(after)
|
|
41
|
+
if (!mc) return []
|
|
42
|
+
const name = mc[1].split('.')[0]
|
|
43
|
+
const inner = mc[2].trim()
|
|
44
|
+
let args = {}
|
|
45
|
+
if (/^\{[\s\S]*\}$/.test(inner)) { try { args = JSON.parse(inner) } catch { args = {} } }
|
|
46
|
+
else if (/^"[\s\S]*"$/.test(inner)) { const s = inner.slice(1, -1); args = { query: s, input: s } }
|
|
47
|
+
else if (/=/.test(inner)) {
|
|
48
|
+
const kwRe = /([A-Za-z_][A-Za-z0-9_]*)\s*=\s*("([^"\\]|\\.)*"|'([^'\\]|\\.)*'|[\d.]+|true|false|null)/g
|
|
49
|
+
let mm
|
|
50
|
+
while ((mm = kwRe.exec(inner)) !== null) {
|
|
51
|
+
let v = mm[2]
|
|
52
|
+
if (/^["']/.test(v)) v = v.slice(1, -1)
|
|
53
|
+
else if (/^[\d.]+$/.test(v)) v = Number(v)
|
|
54
|
+
else if (v === 'true') v = true
|
|
55
|
+
else if (v === 'false') v = false
|
|
56
|
+
else if (v === 'null') v = null
|
|
57
|
+
args[mm[1]] = v
|
|
58
|
+
}
|
|
59
|
+
} else if (inner) args = { query: inner, input: inner }
|
|
60
|
+
return name ? [{ id: randId(), name, arguments: args }] : []
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function parseTextToolCalls(content) {
|
|
64
|
+
if (typeof content !== 'string' || !content) return []
|
|
65
|
+
const kimi = parseKimiSection(content)
|
|
66
|
+
if (kimi.length) return kimi
|
|
67
|
+
return parsePythonTag(content)
|
|
68
|
+
}
|
package/src/batch.js
CHANGED
|
@@ -5,7 +5,7 @@ import { getFreddieHome } from './home.js'
|
|
|
5
5
|
import { randomUUID } from 'node:crypto'
|
|
6
6
|
import { createMachine, assign, fromPromise } from 'xstate'
|
|
7
7
|
import { createPersistentActor } from './machines/persistent-actor.js'
|
|
8
|
-
import {
|
|
8
|
+
import { runStep, clearSteps } from './machines/step-journal.js'
|
|
9
9
|
|
|
10
10
|
// Run one prompt and append its result to the batch jsonl file.
|
|
11
11
|
async function runOne({ job, model, callLLM, file }) {
|
|
@@ -43,7 +43,7 @@ export function createBatchMachine({ prompts, concurrency, model, callLLM, file
|
|
|
43
43
|
.map((p, i) => ({ i, p }))
|
|
44
44
|
.filter(({ i }) => !context.done.includes(i))
|
|
45
45
|
.slice(0, context.concurrency)
|
|
46
|
-
return await Promise.all(pending.map(job => runOne({ job, model: context.model, callLLM, file: context.file })))
|
|
46
|
+
return await Promise.all(pending.map(job => runStep(context.id, 'prompt:' + job.i, () => runOne({ job, model: context.model, callLLM, file: context.file }))))
|
|
47
47
|
}),
|
|
48
48
|
input: ({ context }) => ({ context }),
|
|
49
49
|
onDone: {
|
|
@@ -76,10 +76,12 @@ export async function runBatch({ prompts = [], concurrency = 4, model, callLLM,
|
|
|
76
76
|
// yet in context.done get re-run. Returns null if no live snapshot for the id.
|
|
77
77
|
export async function resumeBatch({ batchId, model, callLLM } = {}) {
|
|
78
78
|
if (!batchId) throw new Error('resumeBatch requires batchId')
|
|
79
|
-
if (!(await load('batch', batchId))) return null
|
|
80
79
|
const machine = createBatchMachine({ model, callLLM })
|
|
80
|
+
// One read only: createPersistentActor.load() handles the missing-snapshot
|
|
81
|
+
// case (pa.resumed=false), so the prior pre-check load() was a redundant read
|
|
82
|
+
// that opened a TOCTOU window against a concurrent delete.
|
|
81
83
|
const pa = await createPersistentActor(machine, { kind: 'batch', key: batchId, input: { id: batchId, file: '', model, concurrency: 4, prompts: [] } })
|
|
82
|
-
if (!pa.resumed)
|
|
84
|
+
if (!pa.resumed) return null
|
|
83
85
|
return await driveBatch(pa)
|
|
84
86
|
}
|
|
85
87
|
|
|
@@ -89,7 +91,7 @@ function driveBatch(pa) {
|
|
|
89
91
|
const sub = actor.subscribe(snap => {
|
|
90
92
|
if (snap.status !== 'done') return
|
|
91
93
|
const out = snap.output
|
|
92
|
-
pa.flush().catch(() => {}).finally(() => { try { sub.unsubscribe() } catch {}; try { actor.stop() } catch {}; resolve(out) })
|
|
94
|
+
pa.flush().catch(() => {}).then(() => clearSteps(out.id)).catch(() => {}).finally(() => { try { sub.unsubscribe() } catch {}; try { actor.stop() } catch {}; resolve(out) })
|
|
93
95
|
})
|
|
94
96
|
actor.subscribe({ error: (e) => { try { sub.unsubscribe() } catch {}; reject(e) } })
|
|
95
97
|
})
|