openaxies 1.0.4 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openaxies",
3
- "version": "1.0.4",
3
+ "version": "1.1.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
package/src/App.js CHANGED
@@ -44,10 +44,16 @@ export default function AppRoot() {
44
44
  const [ovIdx, setOvIdx] = React.useState(0);
45
45
  const [showT, setShowT] = React.useState(true);
46
46
  const [tool, setTool] = React.useState(null);
47
+ const [pendingQ, setPendingQ] = React.useState(false);
48
+ const [qText, setQText] = React.useState('');
49
+ const [todos, setTodos] = React.useState([]);
50
+ const [toolCalls, setToolCalls] = React.useState([]);
47
51
 
48
52
  const acRef = React.useRef(null);
49
53
  const accRef = React.useRef('');
50
54
  const lastRef = React.useRef('');
55
+ const qResolveRef = React.useRef(null);
56
+ const genRef = React.useRef(null);
51
57
 
52
58
  React.useEffect(() => {
53
59
  if (!think) return;
@@ -67,7 +73,7 @@ export default function AppRoot() {
67
73
  const hd = function (s) { return R + ' ' + s + ' '.repeat(Math.max(0, W - s.length)) + ' ' + R; };
68
74
  return h(Box, { flexShrink: 0, flexDirection: 'column', paddingLeft: 1, paddingRight: 1 },
69
75
  h(Text, { color: '#555577' }, '\u250C' + D.repeat(W + 2) + '\u2510'),
70
- h(Text, { color: '#555577' }, hd('\u203A_ OpenAxies (v1.0.3)')),
76
+ h(Text, { color: '#555577' }, hd('\u203A_ OpenAxies (v1.1.0)')),
71
77
  h(Text, { color: '#555577' }, hd('')),
72
78
  h(Text, { color: '#555577' }, hd('model: ' + label + ' /model to change')),
73
79
  h(Text, { color: '#555577' }, hd('directory: ~')),
@@ -80,6 +86,7 @@ export default function AppRoot() {
80
86
  if (!t || busy) return;
81
87
  setReady(true); lastRef.current = t; accRef.current = '';
82
88
  setBusy(true); setStream(''); setThinkTxt(''); setThinkSec(0); setTool(null);
89
+ setToolCalls([]);
83
90
  setMsgs(p => [...p, { role: 'user', c: t }]);
84
91
  setInp('');
85
92
 
@@ -93,12 +100,43 @@ export default function AppRoot() {
93
100
  const hist = msgs.map(x => ({ role: x.role === 'user' ? 'user' : 'assistant', content: x.c || '' }));
94
101
  hist.push({ role: 'user', content: t });
95
102
 
96
- for await (const ev of callModel({ id: model }, hist, ac.signal)) {
97
- if (ac.signal.aborted) break;
98
- if (ev.type === 'tool') { setTool({ tool: ev.tool, q: ev.query, sites: ev.sites }); continue; }
99
- if (ev.type === 'token' || ev.type === 'thinking') {
103
+ const gen = callModel({ id: model }, hist, ac.signal);
104
+ genRef.current = gen;
105
+ let result = await gen.next();
106
+
107
+ while (!result.done && !ac.signal.aborted) {
108
+ const ev = result.value;
109
+ if (ev.type === 'question') {
110
+ setPendingQ(true); setQText(ev.text);
111
+ const answer = await new Promise(resolve => { qResolveRef.current = resolve; });
112
+ setPendingQ(false); setQText('');
113
+ result = await gen.next(answer);
114
+ continue;
115
+ }
116
+ if (ev.type === 'tool_call') {
117
+ setToolCalls(p => [...p, { tool: ev.tool, args: ev.args }]);
118
+ setTool({ tool: ev.tool, query: ev.args?.query || '', sites: 0 });
119
+ result = await gen.next();
120
+ continue;
121
+ }
122
+ if (ev.type === 'tool_result') {
123
+ setToolCalls(p => [...p, { tool: ev.tool, result: ev.result, done: true }]);
124
+ result = await gen.next();
125
+ continue;
126
+ }
127
+ if (ev.type === 'todo_add') {
128
+ setTodos(p => [...p, { text: ev.text, status: 'pending' }]);
129
+ result = await gen.next();
130
+ continue;
131
+ }
132
+ if (ev.type === 'todo_done') {
133
+ setTodos(p => p.map((td, i) => i === ev.id ? { ...td, status: 'done' } : td));
134
+ result = await gen.next();
135
+ continue;
136
+ }
137
+ if (ev.type === 'token') {
100
138
  const c = typeof ev.content === 'string' ? ev.content : '';
101
- if (!c) continue;
139
+ if (!c) { result = await gen.next(); continue; }
102
140
  if (c.includes('<think>')) op = true;
103
141
  accRef.current += c;
104
142
  const cl = strip(accRef.current);
@@ -107,16 +145,20 @@ export default function AppRoot() {
107
145
  if (st) { setThink(true); setThinkTxt(cl); setStream(''); }
108
146
  else { op = false; setThink(false); setStream(cl); setThinkTxt(''); }
109
147
  } else setStream(cl);
148
+ result = await gen.next();
149
+ continue;
110
150
  }
111
151
  if (ev.type === 'done' || ev.type === 'timeout') {
112
152
  if (ev.type === 'timeout' && accRef.current) accRef.current += '\n\u2014timed out\u2014';
113
153
  break;
114
154
  }
155
+ result = await gen.next();
115
156
  }
116
157
  } catch (e) {
117
158
  if (!ac.signal.aborted) setMsgs(p => [...p, { role: 'asst', c: 'Error: ' + (e.message || 'connection') }]);
118
159
  } finally {
119
- setBusy(false); setThink(false); acRef.current = null;
160
+ setBusy(false); setThink(false); setPendingQ(false);
161
+ acRef.current = null; genRef.current = null;
120
162
  const f = accRef.current;
121
163
  if (f) setMsgs(p => [...p, { role: 'asst', c: strip(f) }]);
122
164
  setStream(''); setThinkTxt('');
@@ -131,7 +173,7 @@ export default function AppRoot() {
131
173
  function cmd(item) {
132
174
  if (!item) return;
133
175
  if (item.t === '/exit') { exit(); return; }
134
- if (item.t === '/clear') setMsgs([]);
176
+ if (item.t === '/clear') { setMsgs([]); setTodos([]); }
135
177
  if (item.t === '/model') cycle();
136
178
  if (item.t === '/thoughts') setShowT(p => !p);
137
179
  if (item.t === '/resume') { const q = lastRef.current; if (q && !busy) submit(q); return; }
@@ -140,6 +182,13 @@ export default function AppRoot() {
140
182
  }
141
183
 
142
184
  useInput((i, k) => {
185
+ if (k.ctrl && (i === 'c' || i === 'C')) { exit(); return; }
186
+ if (k.escape && pendingQ) {
187
+ if (acRef.current) acRef.current.abort();
188
+ setPendingQ(false); setQText(''); setBusy(false); setStream(''); setThink(false);
189
+ return;
190
+ }
191
+ if (pendingQ) return;
143
192
  if (overlay) {
144
193
  if (k.escape) { setOverlay(false); setInp(''); setOvIdx(0); return; }
145
194
  if (k.upArrow) setOvIdx(p => p > 0 ? p - 1 : CMDS.length - 1);
@@ -148,13 +197,13 @@ export default function AppRoot() {
148
197
  return;
149
198
  }
150
199
  if (!ready && !busy) { if (k.return) { setReady(true); setInp(''); } return; }
151
- if (k.escape && busy) { if (acRef.current) acRef.current.abort(); setBusy(false); setStream(''); setThink(false); return; }
200
+ if (k.escape && busy) { if (acRef.current) acRef.current.abort(); setBusy(false); setStream(''); setThink(false); setPendingQ(false); return; }
152
201
  if (k.ctrl) {
153
202
  if (i === 'c' || i === 'C') { exit(); return; }
154
203
  if (i === 't' || i === 'T') { setShowT(p => !p); return; }
155
204
  if (i === 'p' || i === 'P') { cycle(); return; }
156
205
  if (i === 'r' || i === 'R') { const q = lastRef.current; if (q && !busy) submit(q); return; }
157
- if (i === 'l' || i === 'L') { setMsgs([]); return; }
206
+ if (i === 'l' || i === 'L') { setMsgs([]); setTodos([]); return; }
158
207
  if (i === 'k' || i === 'K') { setInp('/'); setOverlay(true); setOvIdx(0); return; }
159
208
  }
160
209
  });
@@ -166,7 +215,7 @@ export default function AppRoot() {
166
215
  ...STARTUP.map((l, i) => h(Box, { key: 'l' + i, height: 1, paddingLeft: 2 }, h(Text, { color: '#00BBFF', bold: true }, l))),
167
216
  h(Box, { height: 1 }),
168
217
  h(Box, { height: 1, paddingLeft: 2 }, h(Text, { color: '#FFFFFF', bold: true }, 'OpenAxies')),
169
- h(Box, { height: 1, paddingLeft: 2 }, h(Text, { color: '#777788' }, 'local agent')),
218
+ h(Box, { height: 1, paddingLeft: 2 }, h(Text, { color: '#777788' }, 'autonomous agent')),
170
219
  h(Box, { height: 2 }),
171
220
  h(Box, { height: 1, paddingLeft: 2 }, h(Text, { color: '#555577' }, 'Press Enter to begin')),
172
221
  h(Box, { flexGrow: 1 }),
@@ -180,10 +229,16 @@ export default function AppRoot() {
180
229
  if (m.role === 'user') entries.push({ t: 'user', c: m.c });
181
230
  else if (m.role === 'asst') entries.push({ t: 'asst', c: m.c });
182
231
  }
183
- if (tool && busy) entries.push({ t: 'tool', tool: tool.tool, q: tool.q, sites: tool.sites });
232
+ for (const tc of toolCalls) {
233
+ if (tc.done) entries.push({ t: 'tool', tool: tc.tool, result: tc.result });
234
+ }
235
+ if (tool && busy) entries.push({ t: 'tool', tool: tool.tool, q: tool.query, sites: tool.sites });
236
+ for (const td of todos) {
237
+ entries.push({ t: 'todo', text: td.text, status: td.status });
238
+ }
184
239
  if (think && thinkTxt) entries.push({ t: 'thought', c: thinkTxt, sec: thinkSec.toFixed(1) + 's' });
185
240
  if (busy && stream && !think) entries.push({ t: 'asst', c: stream });
186
-
241
+ if (pendingQ) entries.push({ t: 'question', c: qText });
187
242
  if (overlay) entries.push({ t: '_ov', idx: ovIdx });
188
243
 
189
244
  const maxChat = Math.max(3, rows - 7);
@@ -213,6 +268,12 @@ export default function AppRoot() {
213
268
  h(Text, { color: '#88FF88' }, '\u2022 '),
214
269
  h(Text, { color: '#FFFFFF', wrap: 'wrap' }, e.c),
215
270
  ));
271
+ } else if (e.t === 'todo') {
272
+ const mark = e.status === 'done' ? '\u2713' : '\u25A1';
273
+ const color = e.status === 'done' ? '#44AA44' : '#888899';
274
+ chEls.push(h(Box, { key: k, height: 1, paddingLeft: 2 },
275
+ h(Text, { color: color }, '\u2022 ' + mark + ' ' + e.text),
276
+ ));
216
277
  } else if (e.t === 'thought') {
217
278
  chEls.push(h(Box, { key: k, height: 1, paddingLeft: 2 },
218
279
  h(Text, { color: '#FF9F43', bold: true }, '\u2022 Thinking \u2022 ' + e.sec),
@@ -220,7 +281,12 @@ export default function AppRoot() {
220
281
  if (e.c) chEls.push(h(Box, { key: k + 'c', height: 1, paddingLeft: 4 }, h(Text, { color: '#FF9F43', wrap: 'wrap' }, e.c)));
221
282
  } else if (e.t === 'tool') {
222
283
  chEls.push(h(Box, { key: k, height: 1, paddingLeft: 2 },
223
- h(Text, { color: '#5B5B8A' }, '\u2022 ' + (e.tool || 'tool') + (e.q ? ' "' + e.q + '"' : '') + (e.sites ? ' (' + e.sites + ' sites)' : '')),
284
+ h(Text, { color: '#5B5B8A' }, '\u2022 ' + (e.tool || 'tool') + (e.q ? ' ' + e.q : '') + (e.sites ? ' (' + e.sites + ' sites)' : '') + (e.result ? ' \u2713' : ' \u2026')),
285
+ ));
286
+ } else if (e.t === 'question') {
287
+ chEls.push(h(Box, { key: k, height: 1, paddingLeft: 2 },
288
+ h(Text, { color: '#FFD700' }, '\u2753 '),
289
+ h(Text, { color: '#FFD700', wrap: 'wrap' }, e.c),
224
290
  ));
225
291
  }
226
292
  }
@@ -233,16 +299,22 @@ export default function AppRoot() {
233
299
 
234
300
  // Input line
235
301
  const inputBar = h(Box, { height: 1, flexShrink: 0, paddingLeft: 2, paddingRight: 2 },
236
- h(Text, { color: '#888899' }, '\u203A '),
237
- h(Text, { color: hex.greenOnline }, '\u258C'),
302
+ h(Text, { color: pendingQ ? '#FFD700' : '#888899' }, pendingQ ? '\u2753 ' : '\u203A '),
303
+ pendingQ ? h(Text, { color: '#FFFFFF' }, '\u258C') : h(Text, { color: hex.greenOnline }, '\u258C'),
238
304
  h(Box, { width: 1 }),
239
305
  h(TextInput, {
240
306
  value: inp,
241
307
  onChange: setInp,
242
- onSubmit: v => { if (typeof v === 'string' && v.trim() && !busy) submit(v); },
243
- placeholder: '',
308
+ onSubmit: v => {
309
+ if (pendingQ && qResolveRef.current) {
310
+ qResolveRef.current(v);
311
+ setInp('');
312
+ return;
313
+ }
314
+ if (typeof v === 'string' && v.trim() && !busy) submit(v);
315
+ },
316
+ placeholder: pendingQ ? '' : '',
244
317
  focus: !overlay,
245
- showCursor: true,
246
318
  }),
247
319
  );
248
320
 
@@ -0,0 +1,46 @@
1
+ import { TOOL_DEFS } from './tools.js';
2
+
3
+ export function buildSystemPrompt(cwd) {
4
+ const toolLines = TOOL_DEFS.map(t => {
5
+ const args = Object.entries(t.args).map(([k, v]) => `${k}: ${v}`).join(', ');
6
+ return `- ${t.name}(${args}): ${t.desc}`;
7
+ }).join('\n');
8
+
9
+ return `You are OpenAxies, an autonomous AI coding agent running in the terminal.
10
+
11
+ You have access to tools. When you need to gather information, modify files, or interact with the system, call a tool.
12
+
13
+ TOOLS:
14
+ ${toolLines}
15
+
16
+ IMPORTANT RULES:
17
+ 1. When you need to use a tool, respond with EXACTLY this format (no extra text before/after):
18
+ Action: tool_name
19
+ Action Input: {"arg1": "value1", "arg2": "value2"}
20
+
21
+ 2. After each tool result (Observation), decide what to do next.
22
+
23
+ 3. When you have enough information to respond to the user, use:
24
+ Final Answer: your complete response here
25
+
26
+ 4. You can call multiple tools sequentially. Each tool call is followed by an Observation.
27
+
28
+ 5. Current working directory: ${cwd}
29
+
30
+ 6. For coding tasks:
31
+ - Read files before editing them
32
+ - Use glob/grep to find relevant files
33
+ - Write clear, correct code
34
+ - Run bash commands to verify your work (tests, lint, etc.)
35
+ - Edit files with edit_file (search/replace) when possible
36
+
37
+ 7. For web searches, use web_search to get current information.
38
+
39
+ 8. If you need user input, use ask_question.
40
+
41
+ 9. Track your progress with todo_add and todo_done.
42
+
43
+ 10. Keep your Final Answer concise and helpful. Show the user what you did.
44
+
45
+ Remember: only call ONE tool at a time. Wait for the Observation before deciding next step.`;
46
+ }
@@ -0,0 +1,129 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { execSync } from 'child_process';
4
+ import { searchWeb } from '../providers/websearch.js';
5
+
6
+ function r(s) { return typeof s === 'string' ? s : ''; }
7
+
8
+ function simpleGlob(pattern) {
9
+ const parts = pattern.split('/');
10
+ const hasStar = parts.some(p => p.includes('*'));
11
+ if (!hasStar) return fs.existsSync(pattern) ? [pattern] : [];
12
+ const root = pattern.startsWith('/') ? '/' : '.';
13
+ const results = [];
14
+ function walk(dir, idx) {
15
+ if (idx >= parts.length) { results.push(dir); return; }
16
+ const part = parts[idx];
17
+ if (part.includes('**')) {
18
+ walk(dir, idx + 1);
19
+ try { for (const e of fs.readdirSync(dir)) { const p = dir + '/' + e; if (fs.statSync(p).isDirectory()) walk(p, idx); } } catch {}
20
+ } else if (part.includes('*')) {
21
+ const re = new RegExp('^' + part.replace(/\*/g, '.*') + '$');
22
+ try { for (const e of fs.readdirSync(dir)) { if (re.test(e)) walk(dir + '/' + e, idx + 1); } } catch {}
23
+ } else {
24
+ const p = dir + '/' + part;
25
+ if (fs.existsSync(p)) walk(p, idx + 1);
26
+ }
27
+ }
28
+ walk(root, 0);
29
+ return results;
30
+ }
31
+
32
+ export const TOOL_DEFS = [
33
+ { name: 'read_file', args: { path: 'string' }, desc: 'Read a file from the filesystem' },
34
+ { name: 'write_file', args: { path: 'string', content: 'string' }, desc: 'Write content to a file (overwrites)' },
35
+ { name: 'edit_file', args: { path: 'string', old_string: 'string', new_string: 'string' }, desc: 'Edit a file by replacing old_string with new_string' },
36
+ { name: 'glob', args: { pattern: 'string' }, desc: 'Find files matching a glob pattern (e.g. "src/**/*.js")' },
37
+ { name: 'grep', args: { pattern: 'string', path: 'string' }, desc: 'Search file contents with a regex pattern' },
38
+ { name: 'bash', args: { command: 'string' }, desc: 'Execute a shell command and get output' },
39
+ { name: 'web_search', args: { query: 'string' }, desc: 'Search the web for current information' },
40
+ { name: 'ask_question', args: { question: 'string' }, desc: 'Ask the user a question when you need clarification' },
41
+ { name: 'todo_add', args: { text: 'string' }, desc: 'Add a task to the todo list' },
42
+ { name: 'todo_done', args: { id: 'number' }, desc: 'Mark a todo item as complete by its index (0-based)' },
43
+ ];
44
+
45
+ export function parseToolCall(text) {
46
+ const s = r(text);
47
+ const m = s.match(/Action:\s*(\w+)\s*\nAction Input:\s*(\{[\s\S]*?\}|"[^"]*"|`[^`]*`|\S+)/);
48
+ if (!m) return null;
49
+ const name = m[1];
50
+ let raw = m[2].trim();
51
+ let args = {};
52
+ try {
53
+ if (raw.startsWith('{')) args = JSON.parse(raw);
54
+ else if (raw.startsWith('"')) args = { query: JSON.parse(raw) };
55
+ else if (raw.startsWith('`')) args = { command: raw.slice(1, -1) };
56
+ else args = { path: raw };
57
+ } catch { return null; }
58
+ return { name, args };
59
+ }
60
+
61
+ export async function execTool(toolCall, signal, questionCb) {
62
+ const { name, args } = toolCall;
63
+ try {
64
+ switch (name) {
65
+ case 'read_file': {
66
+ const p = r(args.path);
67
+ if (!fs.existsSync(p)) return 'Error: file not found: ' + p;
68
+ const c = fs.readFileSync(p, 'utf8');
69
+ return 'File ' + p + ':\n' + c.slice(0, 8000) + (c.length > 8000 ? '\n... [truncated]' : '');
70
+ }
71
+ case 'write_file': {
72
+ const p = r(args.path);
73
+ const d = path.dirname(p);
74
+ if (d && !fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
75
+ fs.writeFileSync(p, r(args.content));
76
+ const lines = r(args.content).split('\n').length;
77
+ return 'Wrote ' + p + ' (' + lines + ' lines)';
78
+ }
79
+ case 'edit_file': {
80
+ const p = r(args.path);
81
+ if (!fs.existsSync(p)) return 'Error: file not found: ' + p;
82
+ let c = fs.readFileSync(p, 'utf8');
83
+ const old = r(args.old_string);
84
+ const nw = r(args.new_string);
85
+ if (!c.includes(old)) return 'Error: old_string not found in ' + p;
86
+ c = c.replace(old, nw);
87
+ fs.writeFileSync(p, c);
88
+ return 'Edited ' + p;
89
+ }
90
+ case 'glob': {
91
+ const results = simpleGlob(r(args.pattern));
92
+ return results.length > 0 ? results.join('\n') : 'No files matched: ' + r(args.pattern);
93
+ }
94
+ case 'grep': {
95
+ const pat = r(args.pattern).replace(/"/g, '\\"');
96
+ const pth = r(args.path) || '.';
97
+ try {
98
+ const out = execSync('rg -l "' + pat + '" ' + pth + ' 2>/dev/null || true', { encoding: 'utf8', maxBuffer: 1024 * 1024 });
99
+ return out.trim() || 'No matches for: ' + r(args.pattern);
100
+ } catch { return 'No matches for: ' + r(args.pattern); }
101
+ }
102
+ case 'bash': {
103
+ const out = execSync(r(args.command), { encoding: 'utf8', maxBuffer: 1024 * 1024, timeout: 30000 });
104
+ return out.slice(0, 4000) || '(no output)';
105
+ }
106
+ case 'web_search': {
107
+ const res = await searchWeb(r(args.query), signal);
108
+ return res.sites > 0 ? 'Search results for "' + r(args.query) + '":\n' + res.summary : 'No search results';
109
+ }
110
+ case 'ask_question': {
111
+ if (typeof questionCb === 'function') {
112
+ const answer = await questionCb(r(args.question));
113
+ return 'User answered: ' + answer;
114
+ }
115
+ return 'Error: no question handler';
116
+ }
117
+ case 'todo_add': {
118
+ return 'Todo added: ' + r(args.text);
119
+ }
120
+ case 'todo_done': {
121
+ return 'Todo ' + args.id + ' marked done';
122
+ }
123
+ default:
124
+ return 'Error: unknown tool "' + name + '"';
125
+ }
126
+ } catch (e) {
127
+ return 'Error: ' + (e.message || String(e));
128
+ }
129
+ }
@@ -1,5 +1,7 @@
1
+ import { StateGraph, START, END } from '@langchain/langgraph';
1
2
  import { streamResponse } from './streaming.js';
2
- import { runWebSearchGraph, shouldUseWebSearch } from './websearch.js';
3
+ import { buildSystemPrompt } from '../agent/prompt.js';
4
+ import { parseToolCall, execTool } from '../agent/tools.js';
3
5
 
4
6
  const MODEL_ROUTES = Object.freeze({
5
7
  'openaxis/openaxis-llama': Object.freeze([
@@ -17,91 +19,124 @@ const MODEL_ROUTES = Object.freeze({
17
19
  });
18
20
 
19
21
  function checkMessages(messages) {
20
- if (Array.isArray(messages) === false) {
21
- throw new Error('messages must be an array');
22
- }
22
+ if (!Array.isArray(messages)) throw new Error('messages must be an array');
23
23
  for (let i = 0; i < messages.length; i++) {
24
- const msg = messages[i];
25
- if (msg === null || msg === undefined || typeof msg !== 'object') {
26
- throw new Error('message at index ' + i + ' must be an object');
27
- }
28
- if (typeof msg.role !== 'string' || msg.role.length === 0) {
29
- throw new Error('message at index ' + i + ' must have a non-empty role');
30
- }
31
- if (typeof msg.content !== 'string') {
32
- throw new Error('message at index ' + i + ' content must be a string');
33
- }
24
+ const m = messages[i];
25
+ if (!m || typeof m !== 'object') throw new Error('message at ' + i + ' must be an object');
26
+ if (typeof m.role !== 'string' || !m.role) throw new Error('message at ' + i + ' must have non-empty role');
27
+ if (typeof m.content !== 'string') throw new Error('message at ' + i + ' content must be string');
34
28
  }
35
29
  return messages;
36
30
  }
37
31
 
38
- export async function* callModel(modelConfig, messages, signal) {
39
- if (modelConfig === null || modelConfig === undefined || typeof modelConfig !== 'object') {
40
- throw new Error('modelConfig must be an object');
41
- }
42
- if (typeof modelConfig.id !== 'string') {
43
- throw new Error('modelConfig.id must be a string');
44
- }
45
-
46
- checkMessages(messages);
47
-
48
- const endpoints = MODEL_ROUTES[modelConfig.id];
49
- if (endpoints === null || endpoints === undefined) {
50
- throw new Error('No endpoints configured for model: ' + modelConfig.id);
51
- }
52
-
53
- const modelShort = modelConfig.id.replace(/^[^/]+\//, '');
54
- let modelMessages = messages;
55
- let webUsed = false;
56
- let webSites = 0;
57
- let webQuery = '';
32
+ function buildRouterGraph() {
33
+ const graph = new StateGraph({
34
+ channels: {
35
+ lastResponse: { reducer: (a, b) => b ?? a, default: () => '' },
36
+ iteration: { reducer: (a, b) => b ?? a, default: () => 0 },
37
+ decision: { reducer: (a, b) => b ?? a, default: () => 'final' },
38
+ toolName: { reducer: (a, b) => b ?? a, default: () => null },
39
+ toolArgs: { reducer: (a, b) => b ?? a, default: () => null },
40
+ }
41
+ });
58
42
 
59
- if (shouldUseWebSearch(messages) === true) {
60
- try {
61
- const webResult = await runWebSearchGraph(messages, signal);
62
- if (webResult.used === true && webResult.sites > 0) {
63
- webUsed = true;
64
- webSites = webResult.sites;
65
- webQuery = webResult.query || '';
66
- modelMessages = [{
67
- role: 'system',
68
- content:
69
- 'Current web search results (use when relevant, cite URLs inline):\n\n' +
70
- webResult.summary +
71
- '\n\nNow respond to the user using these results as needed.',
72
- }].concat(messages);
73
- }
74
- } catch (_) {
43
+ graph.addNode('router', async (state) => {
44
+ const tc = parseToolCall(state.lastResponse);
45
+ if (tc) {
46
+ return { decision: 'tool', toolName: tc.name, toolArgs: tc.args };
75
47
  }
76
- }
48
+ return { decision: 'final' };
49
+ });
50
+
51
+ graph.addEdge(START, 'router');
52
+ graph.addEdge('router', END);
77
53
 
78
- yield { type: 'tool', tool: 'websearch', used: webUsed, sites: webSites, query: webQuery };
54
+ return graph.compile();
55
+ }
79
56
 
57
+ async function* callLLMStream(endpoints, modelShort, messages, signal) {
80
58
  const body = {
81
59
  model: modelShort,
82
- messages: modelMessages,
60
+ messages,
83
61
  max_tokens: 4096,
84
62
  temperature: 0.7,
85
63
  stream: true,
86
64
  };
87
65
 
88
66
  let lastError = null;
89
-
90
- for (let i = 0; i < endpoints.length; i++) {
91
- const endpoint = endpoints[i];
67
+ for (const ep of endpoints) {
92
68
  try {
93
- const stream = streamResponse(endpoint, body, signal);
94
- for await (const event of stream) {
95
- yield event;
69
+ const stream = streamResponse(ep, body, signal);
70
+ for await (const ev of stream) {
71
+ yield ev;
96
72
  }
97
73
  return;
98
74
  } catch (err) {
99
75
  lastError = err;
100
- if (signal !== null && signal !== undefined && signal.aborted === true) {
76
+ if (signal?.aborted) return;
77
+ }
78
+ }
79
+ throw lastError || new Error('All endpoints failed');
80
+ }
81
+
82
+ const routerGraph = buildRouterGraph();
83
+
84
+ export async function* callModel(modelConfig, messages, signal) {
85
+ if (!modelConfig || typeof modelConfig !== 'object') throw new Error('modelConfig must be object');
86
+ if (typeof modelConfig.id !== 'string') throw new Error('modelConfig.id must be string');
87
+ checkMessages(messages);
88
+
89
+ const endpoints = MODEL_ROUTES[modelConfig.id];
90
+ if (!endpoints) throw new Error('No endpoints for ' + modelConfig.id);
91
+ const modelShort = modelConfig.id.replace(/^[^/]+\//, '');
92
+ const systemPrompt = buildSystemPrompt(process.cwd());
93
+
94
+ let msgs = [{ role: 'system', content: systemPrompt }].concat(messages);
95
+ let fullResponse = '';
96
+
97
+ for (let iter = 0; iter < 15 && !signal?.aborted; iter++) {
98
+ fullResponse = '';
99
+
100
+ const llmStream = callLLMStream(endpoints, modelShort, msgs, signal);
101
+ for await (const ev of llmStream) {
102
+ if (ev.type === 'token') {
103
+ fullResponse += ev.content || '';
104
+ yield ev;
105
+ } else if (ev.type === 'done') {
106
+ break;
107
+ } else if (ev.type === 'timeout') {
108
+ yield ev;
101
109
  return;
102
110
  }
103
111
  }
104
- }
105
112
 
106
- throw lastError !== null ? lastError : new Error('All endpoints failed for ' + modelConfig.id);
113
+ if (signal?.aborted) return;
114
+
115
+ const state = await routerGraph.invoke({ lastResponse: fullResponse, iteration: iter });
116
+
117
+ if (state.decision === 'final') {
118
+ yield { type: 'done' };
119
+ return;
120
+ }
121
+
122
+ yield { type: 'tool_call', tool: state.toolName, args: state.toolArgs };
123
+
124
+ let result;
125
+ if (state.toolName === 'ask_question') {
126
+ const answer = yield { type: 'question', text: state.toolArgs.question };
127
+ result = 'User answered: ' + (answer || '');
128
+ } else if (state.toolName === 'todo_add') {
129
+ result = await execTool({ name: state.toolName, args: state.toolArgs }, signal);
130
+ yield { type: 'todo_add', text: state.toolArgs.text, result };
131
+ } else if (state.toolName === 'todo_done') {
132
+ result = await execTool({ name: state.toolName, args: state.toolArgs }, signal);
133
+ yield { type: 'todo_done', id: state.toolArgs.id, result };
134
+ } else {
135
+ result = await execTool({ name: state.toolName, args: state.toolArgs }, signal);
136
+ yield { type: 'tool_result', tool: state.toolName, result: result.substring(0, 300) };
137
+ }
138
+
139
+ msgs.push({ role: 'assistant', content: fullResponse });
140
+ msgs.push({ role: 'user', content: 'Observation: ' + result });
141
+ }
107
142
  }
@@ -46,7 +46,7 @@ export function shouldUseWebSearch(messages) {
46
46
  return asksExternalFact === true && hasNamedThing === true;
47
47
  }
48
48
 
49
- async function searchWeb(query, signal) {
49
+ export async function searchWeb(query, signal) {
50
50
  const key = getWebSearchKey();
51
51
  if (key.length === 0) {
52
52
  return {