freddie 0.0.121 → 0.0.123
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 +4 -3
- 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-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 +1 -1
- package/src/acp/tools.js +5 -1
- package/src/agent/acptoapi-bridge.js +95 -76
- package/src/agent/llm_resolver.js +10 -1
- package/src/agent/machine.js +61 -12
- package/src/agent/tool_call_text.js +68 -0
- package/src/batch.js +4 -3
- 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/gateway/run.js +18 -6
- package/src/index.js +1 -0
- package/src/learn/gm-learn.js +179 -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
|
@@ -41,7 +41,7 @@ export class AcpServer extends EventEmitter {
|
|
|
41
41
|
this._pendingPerm = new Map()
|
|
42
42
|
this.machine = createAcpMachine()
|
|
43
43
|
this.actor = createActor(this.machine)
|
|
44
|
-
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) })) })
|
|
45
45
|
this.actor.start()
|
|
46
46
|
}
|
|
47
47
|
get state() { return this.actor.getSnapshot().value }
|
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,86 +1,97 @@
|
|
|
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
|
|
|
5
|
-
//
|
|
6
|
-
// (
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
}
|
|
6
|
+
// Browser-safe env read: this module evaluates in a plain browser context where
|
|
7
|
+
// `process` is undefined (no node shim yet), so a bare process.env throws
|
|
8
|
+
// "process is not defined" and aborts the whole bundle import. envVal() reads
|
|
9
|
+
// live (picks up a late-installed shim) and never throws.
|
|
10
|
+
const envVal = (k) => { try { return (typeof process !== 'undefined' && process.env) ? process.env[k] : undefined } catch { return undefined } }
|
|
11
|
+
const ACPTOAPI_TIMEOUT_MS = Number(envVal('FREDDIE_LLM_TIMEOUT_MS')) || 240000
|
|
30
12
|
|
|
31
13
|
export function getAcptoapiUrl() {
|
|
32
|
-
|
|
14
|
+
// Retained for callers that still read a URL for display/logging purposes
|
|
15
|
+
// (the dashboard health row, CLI banner) -- there is no listening port to
|
|
16
|
+
// reach any more; this is informational only.
|
|
17
|
+
return envVal('FREDDIE_LLM_URL') || 'in-process (acptoapi)'
|
|
33
18
|
}
|
|
34
19
|
|
|
35
20
|
export function getAcptoapiModel() {
|
|
36
|
-
return
|
|
21
|
+
return envVal('FREDDIE_LLM_MODEL') || 'claude/haiku'
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let _acptoapi = null
|
|
25
|
+
async function getAcptoapi() {
|
|
26
|
+
if (!_acptoapi) {
|
|
27
|
+
const mod = await import('acptoapi')
|
|
28
|
+
// acptoapi is a CJS package; Node's CJS-to-ESM interop only statically
|
|
29
|
+
// detects a SUBSET of module.exports keys as named exports (witnessed:
|
|
30
|
+
// `chat` is a real named export, `listAllModelsAndQueues` is not, even
|
|
31
|
+
// though both are plain keys on the same module.exports object) -- read
|
|
32
|
+
// through `.default` (the full CJS exports object) so every export is
|
|
33
|
+
// reachable regardless of which subset the interop happened to pick up.
|
|
34
|
+
_acptoapi = mod.default && typeof mod.default === 'object' ? mod.default : mod
|
|
35
|
+
}
|
|
36
|
+
return _acptoapi
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
// In-process call: acptoapi's own chat() walks its provider/model resolution
|
|
40
|
+
// (including a comma-list or named chain for multi-model fallback) with no
|
|
41
|
+
// HTTP hop and no separate listening process -- eliminates the standalone
|
|
42
|
+
// acptoapi.js daemon on :4800 entirely, along with its witnessed failure mode
|
|
43
|
+
// (an uncaught ACP-timeout exception crashing the whole bridge process).
|
|
44
|
+
export async function callLLM({ messages, tools = [], model, tool_choice, cwd = null } = {}) {
|
|
45
|
+
const acptoapi = await getAcptoapi()
|
|
41
46
|
const useModel = model || getAcptoapiModel()
|
|
42
47
|
const hasTools = Array.isArray(tools) && tools.length > 0
|
|
43
48
|
const adaptedMessages = messages.map(adaptMessage)
|
|
44
|
-
|
|
45
|
-
|
|
49
|
+
// The coder-agent working-directory note is OPT-IN via an explicit `cwd` param.
|
|
50
|
+
// It used to be injected on every tool-bearing call, which polluted NON-coder
|
|
51
|
+
// agents' prompts with "use your built-in tools (Bash, Read, Write)" -- tool
|
|
52
|
+
// hallucination bait plus a filesystem-path leak for hosts (like a contact-facing
|
|
53
|
+
// chat agent) whose toolset has no such tools. runTurn already composes its own
|
|
54
|
+
// cwd note when a caller passes cwd; direct callLLM users opt in the same way.
|
|
55
|
+
if (hasTools && cwd) {
|
|
46
56
|
const sysIdx = adaptedMessages.findIndex(m => m.role === 'system')
|
|
47
57
|
const cwdNote = `\nWorking directory: ${cwd}\nUse your built-in tools (Bash, Read, Write) to explore files in this directory when needed.`
|
|
48
58
|
if (sysIdx >= 0) adaptedMessages[sysIdx] = { ...adaptedMessages[sysIdx], content: (adaptedMessages[sysIdx].content || '') + cwdNote }
|
|
49
59
|
else adaptedMessages.unshift({ role: 'system', content: cwdNote.trim() })
|
|
50
60
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
// Rely on AbortController timeout. acptoapi v1+ ships CORS + Private
|
|
62
|
-
// Network Access headers so cross-origin loopback (gh-pages → localhost)
|
|
63
|
-
// succeeds when acptoapi is running. The earlier preemptive loopback
|
|
64
|
-
// refusal caused false negatives on reachable endpoints.
|
|
65
|
-
await ensureLongTimeoutDispatcher()
|
|
66
|
-
const _ac = new AbortController()
|
|
67
|
-
const _tid = setTimeout(() => _ac.abort(new Error('acptoapi fetch timeout')), ACPTOAPI_TIMEOUT_MS)
|
|
68
|
-
let res
|
|
61
|
+
// acptoapi's chat() is a plain Promise with no AbortSignal support (each
|
|
62
|
+
// underlying provider has its own internal per-call timeout, e.g. the
|
|
63
|
+
// openai-compat provider's OPENAI_COMPAT_TIMEOUT_MS, default 180s) -- so the
|
|
64
|
+
// overall deadline here is enforced by racing a timeout, not by aborting the
|
|
65
|
+
// in-flight call itself.
|
|
66
|
+
let _timeoutHandle
|
|
67
|
+
const _timeout = new Promise((_, reject) => {
|
|
68
|
+
_timeoutHandle = setTimeout(() => reject(new Error('acptoapi call timeout')), ACPTOAPI_TIMEOUT_MS)
|
|
69
|
+
})
|
|
70
|
+
let json
|
|
69
71
|
try {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
const json = await res.json()
|
|
72
|
+
json = await Promise.race([
|
|
73
|
+
acptoapi.chat({
|
|
74
|
+
model: useModel,
|
|
75
|
+
messages: adaptedMessages,
|
|
76
|
+
...(hasTools ? { tools: tools.map(adaptTool) } : {}),
|
|
77
|
+
max_tokens: 4096,
|
|
78
|
+
}),
|
|
79
|
+
_timeout,
|
|
80
|
+
])
|
|
81
|
+
} finally { clearTimeout(_timeoutHandle) }
|
|
82
82
|
log.info('completed', { model: useModel, usage: json.usage })
|
|
83
|
-
|
|
83
|
+
const adapted = adaptResponse(json)
|
|
84
|
+
// acptoapi's chat()/toParams() does not forward tool_choice to any provider
|
|
85
|
+
// (confirmed: a pre-existing gap, not introduced by going in-process -- the
|
|
86
|
+
// old HTTP path silently dropped it too). Enforce the documented contract
|
|
87
|
+
// client-side: when the caller forced a tool call and none came back, this
|
|
88
|
+
// was previously a SILENT no-op -- log loud so the gap is visible instead of
|
|
89
|
+
// masquerading as "the model chose not to call a tool".
|
|
90
|
+
const forcedToolChoice = tool_choice === 'required' || tool_choice?.type === 'required'
|
|
91
|
+
if (forcedToolChoice && hasTools && !adapted.tool_calls.length) {
|
|
92
|
+
log.warn('tool_choice required but no tool call returned (acptoapi does not enforce tool_choice)', { model: useModel })
|
|
93
|
+
}
|
|
94
|
+
return adapted
|
|
84
95
|
}
|
|
85
96
|
|
|
86
97
|
function adaptMessage(m) {
|
|
@@ -116,26 +127,34 @@ function adaptResponse(r) {
|
|
|
116
127
|
const tool_calls = Array.isArray(choice.tool_calls)
|
|
117
128
|
? choice.tool_calls.map(tc => ({ id: tc.id, name: tc.function?.name, arguments: tryParseJson(tc.function?.arguments) }))
|
|
118
129
|
: []
|
|
130
|
+
// Recover text-format tool calls (kimi <|tool_call_begin|> / llama
|
|
131
|
+
// <|python_tag|>) from weak models that don't emit structured tool_calls.
|
|
132
|
+
if (!tool_calls.length) {
|
|
133
|
+
const textTC = parseTextToolCalls(content)
|
|
134
|
+
if (textTC.length) return { content: '', tool_calls: textTC, raw: r }
|
|
135
|
+
}
|
|
119
136
|
return { content, tool_calls, raw: r }
|
|
120
137
|
}
|
|
121
138
|
|
|
122
139
|
function tryParseJson(s) { try { return typeof s === 'string' ? JSON.parse(s) : (s || {}) } catch { return {} } }
|
|
123
140
|
|
|
141
|
+
// In-process reachability: acptoapi is a library import now, not a port to
|
|
142
|
+
// dial, so there is no cheap side-channel that answers "is the CONFIGURED
|
|
143
|
+
// model reachable" for an arbitrary provider (witnessed: listAllModelsAndQueues
|
|
144
|
+
// only enumerates configured matrix/queue sources, empty by default; the
|
|
145
|
+
// sampler cache starts empty until something actually probes/fails a
|
|
146
|
+
// provider; chatjimmy -- casey's configured model -- is itself a remote
|
|
147
|
+
// hosted proxy at https://chatjimmy.ai with its own internal model list, not
|
|
148
|
+
// exported from acptoapi's top-level API at all). The only generically
|
|
149
|
+
// correct answer is a real minimal call: send a trivial message with a short
|
|
150
|
+
// timeout and treat success as reachable.
|
|
124
151
|
export async function isReachable(timeoutMs = 10000) {
|
|
125
152
|
try {
|
|
126
|
-
const
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
})
|
|
133
|
-
clearTimeout(timeoutId)
|
|
134
|
-
if (!res.ok) return false
|
|
135
|
-
const json = await res.json()
|
|
136
|
-
return Array.isArray(json.data) && json.data.length > 0
|
|
137
|
-
} finally {
|
|
138
|
-
clearTimeout(timeoutId)
|
|
139
|
-
}
|
|
153
|
+
const acptoapi = await getAcptoapi()
|
|
154
|
+
const result = await Promise.race([
|
|
155
|
+
acptoapi.chat({ model: getAcptoapiModel(), messages: [{ role: 'user', content: 'ping' }], max_tokens: 4 }),
|
|
156
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('reachability probe timeout')), timeoutMs)),
|
|
157
|
+
])
|
|
158
|
+
return !!(result && result.choices && result.choices.length)
|
|
140
159
|
} catch { return false }
|
|
141
160
|
}
|
|
@@ -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
|
@@ -9,7 +9,7 @@ import { randomUUID } from 'node:crypto'
|
|
|
9
9
|
|
|
10
10
|
const log = logger('agent')
|
|
11
11
|
|
|
12
|
-
export function createAgentMachine({ provider, model, maxIterations = 90, callLLM, enabledToolsets = ['core'], disabledToolsets = [], events, sessionKey } = {}) {
|
|
12
|
+
export function createAgentMachine({ provider, model, maxIterations = 90, callLLM, enabledToolsets = ['core'], disabledToolsets = [], events, sessionKey, toolCtx = null, tool_choice } = {}) {
|
|
13
13
|
const baseLLM = callLLM || resolveCallLLM({ provider, model })
|
|
14
14
|
const llm = events ? async (input) => {
|
|
15
15
|
const t0 = Date.now()
|
|
@@ -36,6 +36,19 @@ export function createAgentMachine({ provider, model, maxIterations = 90, callLL
|
|
|
36
36
|
provider, model,
|
|
37
37
|
enabledToolsets, disabledToolsets,
|
|
38
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,
|
|
39
52
|
}),
|
|
40
53
|
states: {
|
|
41
54
|
idle: {
|
|
@@ -54,9 +67,13 @@ export function createAgentMachine({ provider, model, maxIterations = 90, callLL
|
|
|
54
67
|
invoke: {
|
|
55
68
|
src: fromPromise(async ({ input }) => {
|
|
56
69
|
const schemas = await getEnabledToolSchemas(input.enabledToolsets, input.disabledToolsets)
|
|
57
|
-
|
|
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 }))
|
|
58
75
|
}),
|
|
59
|
-
input: ({ context }) => ({ messages: context.messages, model: context.model, provider: context.provider, enabledToolsets: context.enabledToolsets, disabledToolsets: context.disabledToolsets, sessionKey: context.sessionKey, iterations: context.iterations }),
|
|
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 }),
|
|
60
77
|
onDone: [
|
|
61
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 }] }) },
|
|
62
79
|
{ target: 'done', actions: assign({ messages: ({ context, event }) => [...context.messages, { role: 'assistant', content: event.output.content || '' }], lastResult: ({ context, event }) => {
|
|
@@ -99,7 +116,7 @@ export function createAgentMachine({ provider, model, maxIterations = 90, callLL
|
|
|
99
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 }) }
|
|
100
117
|
const pre = await h.hooks.invoke('preToolCall', { name: tname, args: targs }); pushExtras(pre)
|
|
101
118
|
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)
|
|
119
|
+
const res = await h.pi.dispatchTool(tname, (pre && pre.args) || targs, input.toolCtx || {})
|
|
103
120
|
pushExtras(await h.hooks.invoke('postToolCall', { name: tname, args: targs, result: res }))
|
|
104
121
|
return { content: res, extras: callExtras }
|
|
105
122
|
})
|
|
@@ -108,7 +125,7 @@ export function createAgentMachine({ provider, model, maxIterations = 90, callLL
|
|
|
108
125
|
}
|
|
109
126
|
return { results, extras }
|
|
110
127
|
}),
|
|
111
|
-
input: ({ context }) => ({ messages: context.messages, sessionKey: context.sessionKey, iterations: context.iterations }),
|
|
128
|
+
input: ({ context }) => ({ messages: context.messages, sessionKey: context.sessionKey, iterations: context.iterations, toolCtx: context.toolCtx }),
|
|
112
129
|
onDone: { target: 'prompting', actions: assign({
|
|
113
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],
|
|
114
131
|
iterations: ({ context }) => context.iterations + 1,
|
|
@@ -180,6 +197,26 @@ function mergeHookExtras(messages, r, tag) {
|
|
|
180
197
|
}
|
|
181
198
|
|
|
182
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
|
+
|
|
183
220
|
// session-end hooks + trajectory. Shared by runTurn (fresh) and resumeTurn
|
|
184
221
|
// (rehydrated from a persisted snapshot after a refresh/restart).
|
|
185
222
|
async function driveAgentActor({ pa, h, events, prompt, provider, model, skill, cwd, witnessPath, timeoutMs, sessionKey }) {
|
|
@@ -200,6 +237,9 @@ async function driveAgentActor({ pa, h, events, prompt, provider, model, skill,
|
|
|
200
237
|
await h.hooks.invoke('onSessionEnd', { reason: out?.error ? 'error' : 'ok', iterations: out?.iterations })
|
|
201
238
|
const errorStack = out?.error ? (events.find(e => e.type === 'llm_call' && !e.ok)?.stack || null) : null
|
|
202
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 })
|
|
203
243
|
// Completed turn leaves no step-journal residue.
|
|
204
244
|
await clearSteps(sessionKey)
|
|
205
245
|
// Unsubscribe, flush the final snapshot (persistent-actor clears it on
|
|
@@ -212,12 +252,19 @@ async function driveAgentActor({ pa, h, events, prompt, provider, model, skill,
|
|
|
212
252
|
})
|
|
213
253
|
}
|
|
214
254
|
|
|
215
|
-
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 } = {}) {
|
|
216
256
|
const events = []; const h = await bootHost()
|
|
217
257
|
await h.hooks.invoke('onSessionStart', { prompt, model, provider, skill, cwd })
|
|
218
258
|
let initMessages = [...messages]; const sysParts = []
|
|
219
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.`)
|
|
220
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 (_) {}
|
|
221
268
|
if (sysParts.length) initMessages.unshift({ role: 'user', content: sysParts.join('\n\n') })
|
|
222
269
|
const inbound = await h.hooks.invoke('onMessageInbound', { content: prompt })
|
|
223
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 } }
|
|
@@ -225,7 +272,7 @@ export async function runTurn({ prompt, messages = [], model, provider, callLLM,
|
|
|
225
272
|
// Persist the turn snapshot under kind=agent so an interrupted turn (process
|
|
226
273
|
// refresh mid-tool-call) resumes exactly where it stopped via resumeTurn.
|
|
227
274
|
const key = sessionKey || randomUUID()
|
|
228
|
-
const machine = createAgentMachine({ model, provider, callLLM, enabledToolsets, disabledToolsets, maxIterations, events, sessionKey: key })
|
|
275
|
+
const machine = createAgentMachine({ model, provider, callLLM, enabledToolsets, disabledToolsets, maxIterations, events, sessionKey: key, toolCtx, tool_choice })
|
|
229
276
|
const pa = await createPersistentActor(machine, { kind: 'agent', key, input: { messages: initMessages } })
|
|
230
277
|
pa.actor.send({ type: 'SUBMIT', prompt })
|
|
231
278
|
return await driveAgentActor({ pa, h, events, prompt, provider, model, skill, cwd, witnessPath, timeoutMs, sessionKey: key })
|
|
@@ -234,14 +281,16 @@ export async function runTurn({ prompt, messages = [], model, provider, callLLM,
|
|
|
234
281
|
// Rehydrate an interrupted turn from its persisted snapshot and drive it to
|
|
235
282
|
// completion. Returns null if no live snapshot exists for the key (already
|
|
236
283
|
// completed or never persisted) — caller falls back to a fresh runTurn.
|
|
237
|
-
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 } = {}) {
|
|
238
285
|
if (!sessionKey) throw new Error('resumeTurn requires sessionKey')
|
|
239
|
-
const { load } = await import('../machines/snapshot-store.js')
|
|
240
|
-
if (!(await load('agent', sessionKey))) return null
|
|
241
286
|
const events = []; const h = await bootHost()
|
|
242
|
-
const machine = createAgentMachine({ model, provider, callLLM, enabledToolsets, disabledToolsets, maxIterations, events, sessionKey })
|
|
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.
|
|
243
292
|
const pa = await createPersistentActor(machine, { kind: 'agent', key: sessionKey, input: { messages: [] } })
|
|
244
|
-
if (!pa.resumed)
|
|
293
|
+
if (!pa.resumed) return null
|
|
245
294
|
return await driveAgentActor({ pa, h, events, prompt: '', provider, model, skill, cwd, witnessPath, timeoutMs, sessionKey })
|
|
246
295
|
}
|
|
247
296
|
|