freddie 0.0.86 → 0.0.88

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/CHANGELOG.md CHANGED
@@ -1,6 +1,18 @@
1
1
  ## [Unreleased]
2
2
 
3
3
  ### Fixed
4
+ - `src/agent/llm_resolver.js`: assistant tool_calls returning to provider on the second turn were missing OpenAI's required `type:"function"` and `function:{name,arguments:string}` wrapping. Added `toOpenAIMessages()` that wraps assistant tool_calls and stringifies tool message content. Witnessed: mistral previously errored 422 `messages.2.tool_calls.0.type : property "type" is missing`; after fix returns `Emperor Penguin` through full PLAN → EXECUTE → VERIFY → COMPLETE loop. Trajectory artifact at `penguins/.freddie/trajectories/2026-05-12T15-54-49-902-...json`.
5
+ - `plugins/core-cli/plugin.js`: `freddie exec` now uses `resolveCallLLM` instead of hardcoded acptoapi `callLLM` — previously failed `fetch failed` when acptoapi daemon wasn't running, even with valid `--model` and provider key. Added `--provider`, `--skill`, `--cwd` flags; auto-parses `provider/model` from `--model`.
6
+
7
+ ### Added
8
+ - `src/agent/machine.js`: `writeTrajectory()` writes per-turn JSON to `$FREDDIE_HOME/trajectories/<ts>-<slug>.json` when `agent.save_trajectories=true`. Records prompt, provider, model, cwd, iterations, result, error, state_transitions (PLAN/EXECUTE/VERIFY/COMPLETE), tool_calls, full messages. Filled gap: config flag existed but had no implementation.
9
+ - `scripts/validate-llm-providers.js`: rewritten to dynamically enumerate `.env` keys × acptoapi `PROVIDER_KEYS`. Emits `.gm/llm-validation.log` + `.gm/llm-validation.json`. Live witnessed run: 5/15 REAL_OK across groq, mistral, openrouter, sambanova, claude-cli.
10
+
11
+ ### Witnessed broken (documented honestly, not fixed)
12
+ - opencode `serve --port 4790` listens but every HTTP endpoint times out — likely needs `OPENCODE_SERVER_PASSWORD` or non-HTTP transport.
13
+ - kilo ACP `POST /session` returns 200 but `GET /event` hangs in SSE reader.
14
+ - nvidia/cerebras default models in `acptoapi/lib/auto-chain.js` are stale.
15
+
4
16
  - `src/agent/llm_resolver.js`: fixed tool-calling for all openai-compat providers (groq, mistral, cerebras, openrouter, nvidia, etc.) — `sdk.chat()` was internally using `from:'openai'` format which stripped `url` and `apiKey` before sending to the provider, causing "Failed to parse URL from undefined"; replaced with direct `fetch()` for openai-compat, bypassing the sdk format pipeline entirely
5
17
  - `src/agent/llm_resolver.js`: tool schemas (from `getEnabledToolSchemas`) were never passed to the LLM API — `tools: undefined` was hardcoded; now converts freddie tool schemas to OpenAI `{type:'function', function:{...}}` format and passes them in the request body
6
18
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "freddie",
3
- "version": "0.0.86",
3
+ "version": "0.0.88",
4
4
  "type": "module",
5
5
  "description": "Open JS agent harness built on pi-mono, floosie, xstate, and anentrypoint-design",
6
6
  "bin": {
@@ -26,7 +26,7 @@
26
26
  "plugsdk": "^1.0.15",
27
27
  "xstate": "^5.31.0",
28
28
  "zod": "^4.0.0",
29
- "anentrypoint-design": "^0.0.93",
29
+ "anentrypoint-design": "^0.0.94",
30
30
  "acptoapi": "^1.0.51"
31
31
  },
32
32
  "optionalDependencies": {
@@ -49,10 +49,12 @@ export default {
49
49
  try { ({ callLLM } = await import('../../src/agent/pi-bridge.js')) } catch {}
50
50
  await interactive({ callLLM })
51
51
  } })
52
- C({ name: 'exec', description: 'Run a single prompt through the agent and exit', options: [{ flag: '--prompt <prompt>', required: true }, { flag: '--model <model>', default: '' }, { flag: '--timeout <ms>', default: '60000' }], action: async (opts) => {
52
+ C({ name: 'exec', description: 'Run a single prompt through the agent and exit', options: [{ flag: '--prompt <prompt>', required: true }, { flag: '--model <model>', default: '' }, { flag: '--provider <provider>', default: '' }, { flag: '--skill <skill>', default: '' }, { flag: '--cwd <cwd>', default: '' }, { flag: '--timeout <ms>', default: '60000' }], action: async (opts) => {
53
53
  const { runTurn } = await import('../../src/agent/machine.js')
54
- const { callLLM } = await import('../../src/agent/acptoapi-bridge.js')
55
- const out = await runTurn({ prompt: opts.prompt, callLLM, model: opts.model || undefined, timeoutMs: Number(opts.timeout) })
54
+ let provider = opts.provider || undefined
55
+ let model = opts.model || undefined
56
+ if (!provider && model && /^[a-z][a-z0-9-]*\//.test(model)) { provider = model.split('/')[0]; model = model.slice(provider.length + 1) }
57
+ const out = await runTurn({ prompt: opts.prompt, provider, model, skill: opts.skill || undefined, cwd: opts.cwd || undefined, timeoutMs: Number(opts.timeout) })
56
58
  if (out.error) { console.error('error:', out.error); process.exit(1) }
57
59
  console.log(out.result || out.messages?.at(-1)?.content || '')
58
60
  process.exit(0)
@@ -65,8 +65,18 @@ function toOpenAITools(schemas) {
65
65
  return schemas.map(s => ({ type: 'function', function: { name: s.name, description: s.description || '', parameters: s.parameters || { type: 'object', properties: {} } } }))
66
66
  }
67
67
 
68
+ function toOpenAIMessages(messages) {
69
+ return messages.map(m => {
70
+ if (m.role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length) {
71
+ return { role: 'assistant', content: m.content || '', tool_calls: m.tool_calls.map(tc => ({ id: tc.id, type: 'function', function: { name: tc.name || tc.function?.name, arguments: typeof (tc.arguments || tc.function?.arguments) === 'string' ? (tc.arguments || tc.function?.arguments) : JSON.stringify(tc.arguments || tc.function?.arguments || {}) } })) }
72
+ }
73
+ if (m.role === 'tool') return { role: 'tool', tool_call_id: m.tool_call_id, content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content) }
74
+ return m
75
+ })
76
+ }
77
+
68
78
  async function directOpenAICompatChat(url, apiKey, model, messages, tools) {
69
- const body = { model, messages, ...(tools?.length ? { tools } : {}) }
79
+ const body = { model, messages: toOpenAIMessages(messages), ...(tools?.length ? { tools } : {}) }
70
80
  const res = await fetch(url, {
71
81
  method: 'POST',
72
82
  headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },
@@ -90,7 +100,7 @@ async function sdkChat(provider, model, input) {
90
100
  result = await directOpenAICompatChat(r.url, apiKey, r.model, input.messages, openaiTools)
91
101
  } else {
92
102
  const { buffer: sdkBuffer } = sdk
93
- result = await sdkBuffer({ from: null, to: 'openai', provider: r.provider, model: r.model, messages: input.messages, apiKey, ...(openaiTools ? { tools: openaiTools } : {}) })
103
+ result = await sdkBuffer({ from: null, to: 'openai', provider: r.provider, model: r.model, messages: toOpenAIMessages(input.messages), apiKey, ...(openaiTools ? { tools: openaiTools } : {}) })
94
104
  }
95
105
  const choice = result?.choices?.[0]?.message || {}
96
106
  const content = typeof choice.content === 'string' ? choice.content : ''
@@ -156,23 +166,7 @@ export function resolveCallLLM({ provider, model } = {}) {
156
166
 
157
167
  const preference = getConfigValue('agent.model_preference', [])
158
168
  if (Array.isArray(preference) && preference.length > 0) {
159
- const errors = []
160
- for (const pref of preference) {
161
- const p = pref.provider
162
- const m = pref.model || model || input.model || defaultModel(p)
163
- if (!await hasKey(p)) continue
164
- if (!isAvailable(p)) continue
165
- try {
166
- return await sdkChat(p, m, input)
167
- } catch (e) {
168
- markFailed(p)
169
- errors.push(`${p}: ${e.message}`)
170
- }
171
- }
172
- if (errors.length) {
173
- const status = getStatus().map(s => `${s.provider}(ok=${s.ok},fails=${s.failCount})`).join(', ')
174
- throw new Error(`all preference providers failed: ${errors.join('; ')} | sampler: ${status}`)
175
- }
169
+ try { return await tryChain(preference, input, model) } catch (e) { if (!/chain empty/.test(e.message)) throw e }
176
170
  }
177
171
 
178
172
  const links = sdk.buildAutoChain(model || input.model)
@@ -85,6 +85,35 @@ export function createAgentMachine({ provider, model, maxIterations = 90, callLL
85
85
  })
86
86
  }
87
87
 
88
+ async function writeTrajectory(out, { prompt, provider, model, skill, cwd }) {
89
+ try {
90
+ const { getConfigValue } = await import('../config.js')
91
+ if (!getConfigValue('agent.save_trajectories', false)) return
92
+ const { getFreddieHome } = await import('../home.js')
93
+ const fs = await import('node:fs')
94
+ const path = await import('node:path')
95
+ const dir = path.join(getFreddieHome(), 'trajectories')
96
+ fs.mkdirSync(dir, { recursive: true })
97
+ const states = []
98
+ const toolCalls = []
99
+ for (const m of out.messages || []) {
100
+ if (m.role === 'assistant' && m.tool_calls?.length) { states.push('EXECUTE'); for (const tc of m.tool_calls) toolCalls.push({ name: tc.name || tc.function?.name, arguments: tc.arguments || tc.function?.arguments || {} }) }
101
+ else if (m.role === 'user') states.push('PLAN')
102
+ else if (m.role === 'assistant') states.push('COMPLETE')
103
+ else if (m.role === 'tool') states.push('VERIFY')
104
+ }
105
+ const ts = new Date().toISOString().replace(/[:.]/g, '-').replace(/Z$/, '')
106
+ const slug = (prompt || 'turn').slice(0, 40).replace(/[^a-zA-Z0-9-]+/g, '-').replace(/^-+|-+$/g, '').toLowerCase()
107
+ const file = path.join(dir, `${ts}-${slug}.json`)
108
+ fs.writeFileSync(file, JSON.stringify({
109
+ ts, prompt, provider, model, skill, cwd,
110
+ iterations: out.iterations, result: out.result, error: out.error,
111
+ state_transitions: states, tool_calls: toolCalls,
112
+ messages: out.messages,
113
+ }, null, 2))
114
+ } catch (_) {}
115
+ }
116
+
88
117
  export async function runTurn({ prompt, messages = [], model, provider, callLLM, enabledToolsets, disabledToolsets, maxIterations = 90, timeoutMs = 30000, cwd, skill } = {}) {
89
118
  const initMessages = [...messages]
90
119
  const systemParts = []
@@ -104,7 +133,7 @@ export async function runTurn({ prompt, messages = [], model, provider, callLLM,
104
133
  actor.subscribe(snap => {
105
134
  if (snap.status === 'done') {
106
135
  clearTimeout(t)
107
- resolve(snap.output)
136
+ writeTrajectory(snap.output, { prompt, provider, model, skill, cwd }).finally(() => resolve(snap.output))
108
137
  }
109
138
  })
110
139
  })
package/src/web/app.js CHANGED
@@ -16,7 +16,17 @@ function routeFromHash() {
16
16
  const p = m && m[1];
17
17
  return ROUTES.find(r => r.path === p) ? p : 'home';
18
18
  }
19
- const state = { active: routeFromHash(), ts: new Date().toLocaleTimeString(), body: null, error: null };
19
+ const state = { active: routeFromHash(), ts: new Date().toLocaleTimeString(), body: null, error: null, sampler: { ok: 0, bad: 0, total: 0 } };
20
+
21
+ async function refreshSampler() {
22
+ try {
23
+ const j = await fetch('/api/models/sampler').then(r => r.json());
24
+ const ents = Object.values(j.status || {});
25
+ state.sampler = { total: ents.length, ok: ents.filter(s => s && s.available !== false).length, bad: ents.filter(s => s && s.available === false).length };
26
+ } catch { state.sampler = { ok: 0, bad: 0, total: 0 }; }
27
+ }
28
+ await refreshSampler();
29
+ setInterval(() => { refreshSampler().then(rerender); }, 15000);
20
30
 
21
31
  function buildSide() {
22
32
  return Side({ sections: [{ group: 'freddie', items: ROUTES.map(r => ({
@@ -30,8 +40,11 @@ function view() {
30
40
  const route = ROUTES.find(r => r.path === state.active) || ROUTES[0];
31
41
  const body = state.body || EmptyState({ text: 'loading…', glyph: '◌' });
32
42
  const main = h('div', { key: state.active, class: 'fd-page' }, ...(Array.isArray(body) ? body : [body]));
43
+ const samplerPill = state.sampler.total > 0
44
+ ? Chip({ tone: state.sampler.bad > 0 ? 'miss' : 'ok', children: 'sampler ' + state.sampler.ok + '/' + state.sampler.total })
45
+ : Chip({ tone: 'neutral', children: 'sampler —' });
33
46
  return AppShell({
34
- topbar: Topbar({ brand: 'freddie', leaf: 'dashboard', items: [], active: '' }),
47
+ topbar: Topbar({ brand: 'freddie', leaf: samplerPill, items: [], active: '' }),
35
48
  crumb: Crumb({ trail: ['freddie'], leaf: route.path, right: state.error ? Chip({ tone: 'miss', children: 'error' }) : Chip({ tone: 'ok', children: 'live' }) }),
36
49
  side: buildSide(),
37
50
  main,
@@ -75,4 +88,12 @@ applyDiff(root, view());
75
88
  loadActive();
76
89
 
77
90
  if (!window.__debug) window.__debug = {};
78
- window.__debug.dashboard = () => ({ booted: true, tools: host0.pi.tools.size, skills: host0.pi.skills.size, active: state.active });
91
+ window.__debug.dashboard = () => ({ booted: true, tools: host0.pi.tools.size, skills: host0.pi.skills.size, active: state.active, sampler: state.sampler });
92
+
93
+ window.addEventListener('keydown', ev => {
94
+ if ((ev.metaKey || ev.ctrlKey) && (ev.key === 'k' || ev.key === 'K')) {
95
+ ev.preventDefault();
96
+ if (state.active !== 'chat') setActive('chat');
97
+ setTimeout(() => { const ta = root.querySelector('textarea[name="prompt"]'); if (ta) ta.focus(); }, 100);
98
+ }
99
+ });