lazyclaw 3.99.28 → 4.2.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.
@@ -0,0 +1,182 @@
1
+ // Engine for `/loop` REPL command and `lazyclaw loop` detached subcommand.
2
+ //
3
+ // Repeats one prompt against the active provider up to N times, stopping
4
+ // early when (a) an `--until` regex matches the latest assistant turn, or
5
+ // (b) an external `AbortSignal` fires (Ctrl+C in the REPL, SIGTERM in the
6
+ // detached worker).
7
+ //
8
+ // Design constraints from spec:
9
+ // - Default --max is 3, hard ceiling 50 (refuse otherwise — runaway guard)
10
+ // - Per-iteration persistence: both the user and assistant turns must
11
+ // reach the session jsonl. On abort mid-iteration only completed
12
+ // pairs land — we defer the user-turn `persist` call until the
13
+ // assistant turn succeeds so a Ctrl+C between them leaves no orphan.
14
+ // - The engine is pure: callers inject `sendOnce` and `persist`. This
15
+ // lets the REPL stream chunks to stdout while the detached worker
16
+ // buffers silently and writes to its own iterations.log.
17
+
18
+ export const LOOP_MAX_CEILING = 50;
19
+ export const LOOP_MAX_DEFAULT = 3;
20
+
21
+ export class LoopError extends Error {
22
+ constructor(message, code) {
23
+ super(message);
24
+ this.name = 'LoopError';
25
+ this.code = code || 'LOOP_ERR';
26
+ }
27
+ }
28
+
29
+ // Lightweight tokenizer for the `/loop` argument tail. Honors double
30
+ // quotes so `/loop "say hi" --until "DONE"` produces three tokens. No
31
+ // escape sequences, no nested quotes — the spec doesn't require shell
32
+ // fidelity and adding it would just give the user more rope.
33
+ export function splitArgs(raw) {
34
+ const out = [];
35
+ let buf = '';
36
+ let inQuote = false;
37
+ for (let i = 0; i < raw.length; i++) {
38
+ const ch = raw[i];
39
+ if (ch === '"') { inQuote = !inQuote; continue; }
40
+ if (!inQuote && /\s/.test(ch)) {
41
+ if (buf) { out.push(buf); buf = ''; }
42
+ continue;
43
+ }
44
+ buf += ch;
45
+ }
46
+ if (inQuote) throw new LoopError('unterminated quoted argument', 'LOOP_BAD_QUOTE');
47
+ if (buf) out.push(buf);
48
+ return out;
49
+ }
50
+
51
+ export function parseLoopArgs(raw) {
52
+ const argv = splitArgs(raw);
53
+ let max = LOOP_MAX_DEFAULT;
54
+ let until = null;
55
+ let session = null;
56
+ let detach = false;
57
+ let useMemory = false;
58
+ let recall = null;
59
+ const promptParts = [];
60
+ for (let i = 0; i < argv.length; i++) {
61
+ const t = argv[i];
62
+ if (t === '--max') {
63
+ const v = argv[++i];
64
+ if (v === undefined) throw new LoopError('--max requires a value', 'LOOP_BAD_FLAG');
65
+ const n = Number(v);
66
+ if (!Number.isInteger(n) || n <= 0) {
67
+ throw new LoopError(`--max must be a positive integer, got "${v}"`, 'LOOP_BAD_FLAG');
68
+ }
69
+ max = n;
70
+ } else if (t === '--until') {
71
+ const v = argv[++i];
72
+ if (v === undefined) throw new LoopError('--until requires a regex', 'LOOP_BAD_FLAG');
73
+ until = v;
74
+ } else if (t === '--session') {
75
+ const v = argv[++i];
76
+ if (v === undefined) throw new LoopError('--session requires an id', 'LOOP_BAD_FLAG');
77
+ session = v;
78
+ } else if (t === '--detach') {
79
+ detach = true;
80
+ } else if (t === '--use-memory') {
81
+ useMemory = true;
82
+ } else if (t === '--recall') {
83
+ const v = argv[++i];
84
+ if (v === undefined) throw new LoopError('--recall requires a query', 'LOOP_BAD_FLAG');
85
+ recall = v;
86
+ } else if (t.startsWith('--')) {
87
+ throw new LoopError(`unknown flag ${t}`, 'LOOP_BAD_FLAG');
88
+ } else {
89
+ promptParts.push(t);
90
+ }
91
+ }
92
+ if (max > LOOP_MAX_CEILING) {
93
+ throw new LoopError(
94
+ `--max ${max} exceeds ceiling ${LOOP_MAX_CEILING} (runaway guard). Refusing.`,
95
+ 'LOOP_OVER_CEILING',
96
+ );
97
+ }
98
+ return { prompt: promptParts.join(' '), max, until, session, detach, useMemory, recall };
99
+ }
100
+
101
+ export function compileUntil(pattern) {
102
+ if (!pattern) return null;
103
+ try { return new RegExp(pattern); }
104
+ catch (e) { throw new LoopError(`bad --until regex: ${e?.message || e}`, 'LOOP_BAD_REGEX'); }
105
+ }
106
+
107
+ /**
108
+ * Run one prompt N times. Mutates `messages` in place.
109
+ *
110
+ * @param {object} o
111
+ * @param {string} o.prompt
112
+ * @param {number} o.max
113
+ * @param {RegExp|null} o.until
114
+ * @param {Array<{role:string,content:string}>} o.messages
115
+ * @param {(messages: any[], signal: AbortSignal|undefined) => Promise<string>} o.sendOnce
116
+ * @param {((role: 'user'|'assistant', content: string) => void)|undefined} o.persist
117
+ * @param {((evt: { i: number, max: number, reply: string }) => void)|undefined} o.onIteration
118
+ * @param {AbortSignal|undefined} o.signal
119
+ * @returns {Promise<{ iterations: number, stoppedBy: 'max'|'until'|'abort', lastReply: string }>}
120
+ */
121
+ export async function runLoop({ prompt, max, until, messages, sendOnce, persist, onIteration, signal, buildSystem }) {
122
+ if (!prompt || !prompt.trim()) {
123
+ throw new LoopError('prompt is required', 'LOOP_NO_PROMPT');
124
+ }
125
+ if (!Number.isInteger(max) || max <= 0) {
126
+ throw new LoopError('max must be a positive integer', 'LOOP_BAD_MAX');
127
+ }
128
+ if (max > LOOP_MAX_CEILING) {
129
+ throw new LoopError(`max ${max} exceeds ceiling ${LOOP_MAX_CEILING}`, 'LOOP_OVER_CEILING');
130
+ }
131
+ if (typeof sendOnce !== 'function') {
132
+ throw new LoopError('sendOnce is required', 'LOOP_NO_SENDER');
133
+ }
134
+ let i = 0;
135
+ let lastReply = '';
136
+ let stoppedBy = 'max';
137
+ while (i < max) {
138
+ if (signal?.aborted) { stoppedBy = 'abort'; break; }
139
+ i++;
140
+ // Per-iteration system rebuild. The caller decides what `sys` is —
141
+ // memory.loadCore(), recall results, the chat's prior skill block,
142
+ // or any combination. Empty / falsy return = remove the system
143
+ // message. The rebuild runs every iteration so a parallel writer
144
+ // mutating core.md mid-loop is reflected in the next call.
145
+ if (buildSystem) {
146
+ const sys = buildSystem();
147
+ const sysIdx = messages.findIndex(m => m.role === 'system');
148
+ if (sys && String(sys).trim()) {
149
+ if (sysIdx >= 0) messages[sysIdx] = { role: 'system', content: sys };
150
+ else messages.unshift({ role: 'system', content: sys });
151
+ } else if (sysIdx >= 0) {
152
+ messages.splice(sysIdx, 1);
153
+ }
154
+ }
155
+ messages.push({ role: 'user', content: prompt });
156
+ let reply;
157
+ try {
158
+ reply = await sendOnce(messages, signal);
159
+ } catch (err) {
160
+ // Roll back the unpaired user turn so the in-memory messages stay
161
+ // consistent. The persist() call hasn't happened yet for this
162
+ // iteration, so the session jsonl is untouched.
163
+ messages.pop();
164
+ if (err?.code === 'ABORT' || signal?.aborted) {
165
+ stoppedBy = 'abort';
166
+ i--;
167
+ break;
168
+ }
169
+ throw err;
170
+ }
171
+ lastReply = reply;
172
+ persist?.('user', prompt);
173
+ messages.push({ role: 'assistant', content: lastReply });
174
+ persist?.('assistant', lastReply);
175
+ onIteration?.({ i, max, reply: lastReply });
176
+ if (until && until.test(lastReply)) {
177
+ stoppedBy = 'until';
178
+ break;
179
+ }
180
+ }
181
+ return { iterations: i, stoppedBy, lastReply };
182
+ }
package/loops.mjs ADDED
@@ -0,0 +1,135 @@
1
+ // Persistent state and helpers for `lazyclaw loop` runs.
2
+ //
3
+ // Storage layout under <configDir>/loops/<loopId>/:
4
+ // meta.json — { prompt, max, until, sessionId, pid, pgid?, status,
5
+ // startedAt, finishedAt?, provider, model }
6
+ // iterations.log — newline-delimited iteration summaries (one per turn)
7
+ // result.json — final outcome ({ stoppedBy, iterations, lastReply,
8
+ // error?, exitCode? }) on completion
9
+ //
10
+ // Status transitions:
11
+ // running -> completed | killed | failed | budget_exhausted
12
+ //
13
+ // Why three files instead of one:
14
+ // - meta.json mutates with the status field; iterations.log is append-only;
15
+ // result.json is written exactly once. Keeping them separate avoids
16
+ // read-modify-write contention between the worker (appending iter logs)
17
+ // and any reader (`lazyclaw loops show <id>`) running concurrently.
18
+
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+ import os from 'node:os';
22
+ import crypto from 'node:crypto';
23
+
24
+ const LOOPS_DIRNAME = 'loops';
25
+
26
+ export function defaultConfigDir() {
27
+ return process.env.LAZYCLAW_CONFIG_DIR || path.join(os.homedir(), '.lazyclaw');
28
+ }
29
+
30
+ export function loopsDir(configDir = defaultConfigDir()) {
31
+ return path.join(configDir, LOOPS_DIRNAME);
32
+ }
33
+
34
+ export function loopDir(loopId, configDir = defaultConfigDir()) {
35
+ if (!loopId || /[/\\]/.test(loopId) || loopId === '.' || loopId === '..') {
36
+ throw new Error(`invalid loop id: ${loopId}`);
37
+ }
38
+ return path.join(loopsDir(configDir), loopId);
39
+ }
40
+
41
+ export function newLoopId() {
42
+ // ISO timestamp (filesystem-safe) + 6 random hex chars. Sorts
43
+ // chronologically and avoids collisions across rapid `--detach` invocations.
44
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
45
+ const suffix = crypto.randomBytes(3).toString('hex');
46
+ return `${ts}-${suffix}`;
47
+ }
48
+
49
+ export function writeMeta(loopId, meta, configDir = defaultConfigDir()) {
50
+ const dir = loopDir(loopId, configDir);
51
+ fs.mkdirSync(dir, { recursive: true });
52
+ const tmp = path.join(dir, '.meta.json.tmp');
53
+ fs.writeFileSync(tmp, JSON.stringify(meta, null, 2));
54
+ fs.renameSync(tmp, path.join(dir, 'meta.json'));
55
+ }
56
+
57
+ export function readMeta(loopId, configDir = defaultConfigDir()) {
58
+ const dir = loopDir(loopId, configDir);
59
+ const p = path.join(dir, 'meta.json');
60
+ if (!fs.existsSync(p)) return null;
61
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); }
62
+ catch { return null; }
63
+ }
64
+
65
+ export function patchMeta(loopId, patch, configDir = defaultConfigDir()) {
66
+ const cur = readMeta(loopId, configDir) || {};
67
+ writeMeta(loopId, { ...cur, ...patch }, configDir);
68
+ }
69
+
70
+ export function appendIteration(loopId, entry, configDir = defaultConfigDir()) {
71
+ const dir = loopDir(loopId, configDir);
72
+ fs.mkdirSync(dir, { recursive: true });
73
+ const line = JSON.stringify({ ts: Date.now(), ...entry }) + '\n';
74
+ fs.appendFileSync(path.join(dir, 'iterations.log'), line);
75
+ }
76
+
77
+ export function readIterations(loopId, configDir = defaultConfigDir()) {
78
+ const p = path.join(loopDir(loopId, configDir), 'iterations.log');
79
+ if (!fs.existsSync(p)) return [];
80
+ const out = [];
81
+ for (const line of fs.readFileSync(p, 'utf8').split('\n')) {
82
+ if (!line) continue;
83
+ try { out.push(JSON.parse(line)); } catch { /* skip */ }
84
+ }
85
+ return out;
86
+ }
87
+
88
+ export function writeResult(loopId, result, configDir = defaultConfigDir()) {
89
+ const dir = loopDir(loopId, configDir);
90
+ fs.mkdirSync(dir, { recursive: true });
91
+ const tmp = path.join(dir, '.result.json.tmp');
92
+ fs.writeFileSync(tmp, JSON.stringify(result, null, 2));
93
+ fs.renameSync(tmp, path.join(dir, 'result.json'));
94
+ }
95
+
96
+ export function readResult(loopId, configDir = defaultConfigDir()) {
97
+ const p = path.join(loopDir(loopId, configDir), 'result.json');
98
+ if (!fs.existsSync(p)) return null;
99
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); }
100
+ catch { return null; }
101
+ }
102
+
103
+ export function listLoops(configDir = defaultConfigDir()) {
104
+ const dir = loopsDir(configDir);
105
+ if (!fs.existsSync(dir)) return [];
106
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
107
+ const out = [];
108
+ for (const ent of entries) {
109
+ if (!ent.isDirectory()) continue;
110
+ const meta = readMeta(ent.name, configDir);
111
+ if (!meta) continue;
112
+ out.push({ id: ent.name, ...meta });
113
+ }
114
+ out.sort((a, b) => String(b.startedAt).localeCompare(String(a.startedAt)));
115
+ return out;
116
+ }
117
+
118
+ // Inspect a meta record and decide whether the worker is still alive.
119
+ // We don't kill or reap; just synthesize a more truthful status field
120
+ // for `loops list` / `loops show` after the process has gone away.
121
+ export function isProcessAlive(pid) {
122
+ if (!pid) return false;
123
+ try { process.kill(pid, 0); return true; }
124
+ catch (e) { return e?.code === 'EPERM'; }
125
+ }
126
+
127
+ export function reconcileStatus(meta) {
128
+ if (!meta) return meta;
129
+ if (meta.status === 'running' && !isProcessAlive(meta.pid)) {
130
+ // Worker exited without flipping the status. Most likely a crash
131
+ // before the SIGTERM handler / finally block could update meta.
132
+ return { ...meta, status: 'failed' };
133
+ }
134
+ return meta;
135
+ }
@@ -0,0 +1,188 @@
1
+ // Per-agent memory store — Phase 18.
2
+ //
3
+ // Each agent gets a plain-markdown file at
4
+ // <configDir>/memory/agents/<name>.md
5
+ // with newest reflections at the TOP. Reads truncate to a configurable
6
+ // byte budget (default 12 KB so the system prompt doesn't balloon).
7
+ // Writes are append-to-top so the freshest entry is always inside the
8
+ // truncation window.
9
+ //
10
+ // All I/O is best-effort: a missing/unreadable file returns '' and a
11
+ // failed write is logged (when a logger is supplied) but never thrown.
12
+ // The router calls these from the hot path; we don't want a transient
13
+ // fs hiccup to kill a multi-agent turn.
14
+
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import os from 'node:os';
18
+
19
+ export const DEFAULT_MAX_CHARS = 12 * 1024;
20
+ const AGENTS_MEM_DIR = path.join('memory', 'agents');
21
+
22
+ export class AgentMemoryError extends Error {
23
+ constructor(message, code) {
24
+ super(message);
25
+ this.name = 'AgentMemoryError';
26
+ this.code = code || 'AGENT_MEMORY_ERR';
27
+ }
28
+ }
29
+
30
+ export function defaultConfigDir() {
31
+ return process.env.LAZYCLAW_CONFIG_DIR || path.join(os.homedir(), '.lazyclaw');
32
+ }
33
+
34
+ export function memoryPath(name, configDir = defaultConfigDir()) {
35
+ if (!name || typeof name !== 'string' || !/^[a-zA-Z0-9_-]+$/.test(name)) {
36
+ throw new AgentMemoryError(`bad agent name "${name}"`, 'AGENT_MEMORY_BAD_NAME');
37
+ }
38
+ return path.join(configDir, AGENTS_MEM_DIR, `${name}.md`);
39
+ }
40
+
41
+ // Read the agent's memory, truncated to `maxChars` from the top
42
+ // (newest-first). Returns '' when the file is missing.
43
+ export function readMemory(name, configDir = defaultConfigDir(), maxChars = DEFAULT_MAX_CHARS) {
44
+ let p;
45
+ try { p = memoryPath(name, configDir); }
46
+ catch { return ''; }
47
+ if (!fs.existsSync(p)) return '';
48
+ try {
49
+ const raw = fs.readFileSync(p, 'utf8');
50
+ if (raw.length <= maxChars) return raw;
51
+ // Cut at a paragraph boundary if possible so a truncated entry
52
+ // doesn't bleed into the truncation marker.
53
+ let cut = raw.slice(0, maxChars);
54
+ const lastBlank = cut.lastIndexOf('\n\n');
55
+ if (lastBlank > maxChars * 0.6) cut = cut.slice(0, lastBlank);
56
+ return cut + '\n\n…[older entries truncated]\n';
57
+ } catch {
58
+ return '';
59
+ }
60
+ }
61
+
62
+ export function writeRaw(name, text, configDir = defaultConfigDir()) {
63
+ const p = memoryPath(name, configDir);
64
+ fs.mkdirSync(path.dirname(p), { recursive: true });
65
+ const tmp = p + '.tmp';
66
+ fs.writeFileSync(tmp, String(text ?? ''));
67
+ fs.renameSync(tmp, p);
68
+ return p;
69
+ }
70
+
71
+ export function clear(name, configDir = defaultConfigDir()) {
72
+ let p;
73
+ try { p = memoryPath(name, configDir); }
74
+ catch { return false; }
75
+ if (!fs.existsSync(p)) return false;
76
+ fs.unlinkSync(p);
77
+ return true;
78
+ }
79
+
80
+ // Prepend a new reflection block to the top of the file. Body should
81
+ // be the LLM-generated bullets (no header) — we add the date + task
82
+ // header ourselves so the format stays consistent.
83
+ export function prependEntry(name, { taskId, title, body, ts = new Date() } = {}, configDir = defaultConfigDir()) {
84
+ const cleanBody = String(body || '').trim();
85
+ if (!cleanBody) return null;
86
+ const date = ts.toISOString().slice(0, 10);
87
+ const header = `## ${date} — task ${taskId}${title ? ` (${title})` : ''}`;
88
+ const entry = `${header}\n${cleanBody}\n\n`;
89
+ const p = memoryPath(name, configDir);
90
+ let existing = '';
91
+ if (fs.existsSync(p)) {
92
+ try { existing = fs.readFileSync(p, 'utf8'); } catch { /* keep going */ }
93
+ }
94
+ if (!existing) {
95
+ existing = `# ${name} — memory\n\n`;
96
+ }
97
+ // Split off the "# name — memory" title (line 1) so new entries land
98
+ // ABOVE the older entries but BELOW the title.
99
+ let title_line, rest;
100
+ const firstNewline = existing.indexOf('\n');
101
+ if (firstNewline >= 0 && existing.startsWith('# ')) {
102
+ title_line = existing.slice(0, firstNewline + 1);
103
+ rest = existing.slice(firstNewline + 1).replace(/^\n+/, '');
104
+ } else {
105
+ title_line = `# ${name} — memory\n`;
106
+ rest = existing;
107
+ }
108
+ const next = `${title_line}\n${entry}${rest}`;
109
+ fs.mkdirSync(path.dirname(p), { recursive: true });
110
+ const tmp = p + '.tmp';
111
+ fs.writeFileSync(tmp, next);
112
+ fs.renameSync(tmp, p);
113
+ return { path: p, entry };
114
+ }
115
+
116
+ // Build the system-prompt block the router injects between agent.role
117
+ // and the team-metadata footer. Returns '' when memory is empty.
118
+ export function buildMemoryBlock(name, configDir = defaultConfigDir(), maxChars = DEFAULT_MAX_CHARS) {
119
+ const raw = readMemory(name, configDir, maxChars);
120
+ if (!raw.trim()) return '';
121
+ return [
122
+ '---',
123
+ '',
124
+ 'What you remember from prior tasks (newest first):',
125
+ '',
126
+ raw.trim(),
127
+ '',
128
+ '---',
129
+ '',
130
+ ].join('\n');
131
+ }
132
+
133
+ // Run one reflection LLM call for an agent. Returns the trimmed bullet
134
+ // body (without the date header) so the caller can either prepend it
135
+ // to the on-disk file (auto mode) or surface it to the user (manual
136
+ // command). Throws on hard failure; the router catches and logs.
137
+ //
138
+ // We use the agent's own provider via the existing tool-use callOnce
139
+ // adapters because they are already wired up with apiKey/baseUrl
140
+ // passthrough. No tools are advertised — reflection is pure text.
141
+ export async function reflectOnce({ agent, task, apiKey, baseUrl, fetchImpl, maxBullets = 6 } = {}) {
142
+ if (!agent || !task) throw new AgentMemoryError('agent and task are required', 'AGENT_MEMORY_BAD_INPUT');
143
+ const adapter = await pickAdapter(agent.provider);
144
+
145
+ const transcript = (Array.isArray(task.turns) ? task.turns : [])
146
+ .map((t) => {
147
+ const who = t.agent === 'user' ? 'User' : t.agent === 'system' ? 'System' : t.agent;
148
+ return `[${who}] ${t.text || ''}`;
149
+ })
150
+ .join('\n\n') || '(no turns)';
151
+
152
+ const userMessage =
153
+ `You just finished task "${task.title || '(untitled)'}" (id ${task.id}). Here is the full transcript:\n\n` +
154
+ transcript +
155
+ `\n\nWrite a SHORT markdown block (≤ ${maxBullets} bullet points) capturing what you learned ` +
156
+ `during this task that would be useful next time. Be concrete: file paths, decisions, ` +
157
+ `gotchas, teammate preferences. Do NOT repeat generic advice. Do NOT exceed ${maxBullets} ` +
158
+ `bullets. Reply with the bullets only — no headers, no preamble.`;
159
+
160
+ const initialUser = adapter.initialUserMessage
161
+ ? adapter.initialUserMessage(userMessage)
162
+ : { role: 'user', content: userMessage };
163
+
164
+ const resp = await adapter.callOnce({
165
+ messages: [initialUser],
166
+ tools: [],
167
+ model: agent.model,
168
+ apiKey,
169
+ system: agent.role || '',
170
+ baseUrl,
171
+ fetchImpl,
172
+ });
173
+ if (resp.kind !== 'final') {
174
+ throw new AgentMemoryError(`reflection expected text reply, got ${resp.kind}`, 'AGENT_MEMORY_NO_TEXT');
175
+ }
176
+ const text = (resp.text || '').trim();
177
+ return text;
178
+ }
179
+
180
+ async function pickAdapter(provider) {
181
+ switch (provider) {
182
+ case 'anthropic': return await import('../providers/tool_use/anthropic.mjs');
183
+ case 'openai': return await import('../providers/tool_use/openai.mjs');
184
+ case 'gemini': return await import('../providers/tool_use/gemini.mjs');
185
+ default:
186
+ throw new AgentMemoryError(`provider "${provider}" does not support reflection`, 'AGENT_MEMORY_NO_PROVIDER');
187
+ }
188
+ }
@@ -0,0 +1,141 @@
1
+ // Agent turn runner — given an agent record, a thread of history, and
2
+ // a new user message, drives the provider-specific tool-use loop until
3
+ // the model emits a final text reply (or the iteration budget runs
4
+ // out).
5
+ //
6
+ // Provider routing:
7
+ // anthropic → providers/tool_use/anthropic.mjs (Phase 12b)
8
+ // openai → providers/tool_use/openai.mjs (Phase 12c — todo)
9
+ // gemini → providers/tool_use/gemini.mjs (Phase 12d — todo)
10
+ // claude-cli → not supported (subprocess provider — Phase 12 scope
11
+ // excludes it; runAgentTurn throws so callers can flag
12
+ // the agent in the dashboard).
13
+ //
14
+ // The loop:
15
+ // 1. Build messages = [...history, {role:user, content:input}]
16
+ // 2. Call adapter.callOnce → response
17
+ // 3. If response.kind === 'final', return { text, turns, iterations }
18
+ // 4. Else (tool_calls): for each call, run the tool, append a
19
+ // tool_result message, loop back to step 2.
20
+ // 5. If iterations > opts.maxIterations (default 10), bail with
21
+ // partial text and `stoppedBy: 'budget'`.
22
+
23
+ import { listToolSchemas, runTool, ToolError } from './tool_runner.mjs';
24
+ import * as anthropic from '../providers/tool_use/anthropic.mjs';
25
+ import * as openai from '../providers/tool_use/openai.mjs';
26
+ import * as gemini from '../providers/tool_use/gemini.mjs';
27
+
28
+ export class AgentTurnError extends Error {
29
+ constructor(message, code) {
30
+ super(message);
31
+ this.name = 'AgentTurnError';
32
+ this.code = code || 'AGENT_TURN_ERR';
33
+ }
34
+ }
35
+
36
+ const DEFAULT_MAX_ITERATIONS = 10;
37
+
38
+ function adapterFor(provider) {
39
+ switch (provider) {
40
+ case 'anthropic': return { ...anthropic, toolSchemas: anthropic.toAnthropicTools };
41
+ case 'openai': return { ...openai, toolSchemas: openai.toOpenAITools };
42
+ case 'gemini': return { ...gemini, toolSchemas: gemini.toGeminiTools };
43
+ default:
44
+ throw new AgentTurnError(`provider "${provider}" does not support tool-use yet`, 'PROVIDER_UNSUPPORTED');
45
+ }
46
+ }
47
+
48
+ // Run one full agent turn. Returns:
49
+ // { text, iterations, stoppedBy: 'final' | 'budget' | 'tool_error', toolCalls }
50
+ //
51
+ // `toolCalls` lists every tool invocation that actually ran (with its
52
+ // result). `stoppedBy: 'tool_error'` means the runner aborted because a
53
+ // tool denied/threw; the previous text (if any) is returned but the
54
+ // next provider call was skipped.
55
+ export async function runAgentTurn({
56
+ agent,
57
+ userMessage,
58
+ history = [],
59
+ taskId,
60
+ configDir,
61
+ cwd,
62
+ fetchImpl,
63
+ baseUrl,
64
+ apiKey,
65
+ maxIterations = DEFAULT_MAX_ITERATIONS,
66
+ signal,
67
+ } = {}) {
68
+ if (!agent) throw new AgentTurnError('agent is required', 'NO_AGENT');
69
+ const adapter = adapterFor(agent.provider);
70
+
71
+ const tools = adapter.toolSchemas(listToolSchemas(agent.tools));
72
+
73
+ // Seed messages from prior history + the new user input. Callers
74
+ // pass history in a provider-neutral [{role, content}] shape; the
75
+ // adapter normalises it into its native message format (e.g. Gemini's
76
+ // `parts: [...]` representation). The new user message is wrapped
77
+ // identically.
78
+ const normalize = adapter.normalizeHistory || ((h) => [...h]);
79
+ const initialUser = adapter.initialUserMessage || ((t) => ({ role: 'user', content: t }));
80
+ const messages = normalize(history);
81
+ if (userMessage && String(userMessage).trim()) {
82
+ messages.push(initialUser(String(userMessage)));
83
+ }
84
+
85
+ const toolCalls = [];
86
+ let iterations = 0;
87
+ let lastText = '';
88
+
89
+ while (iterations < maxIterations) {
90
+ if (signal?.aborted) return { text: lastText, iterations, stoppedBy: 'abort', toolCalls };
91
+ iterations++;
92
+ const resp = await adapter.callOnce({
93
+ messages, tools, model: agent.model, apiKey, system: agent.role,
94
+ fetchImpl, baseUrl, signal,
95
+ });
96
+ if (resp.text) lastText = resp.text;
97
+
98
+ if (resp.kind === 'final') {
99
+ return { text: resp.text || '', iterations, stoppedBy: 'final', toolCalls };
100
+ }
101
+
102
+ // tool_calls path: echo the model's assistant turn back so future
103
+ // tool_result messages correlate, then run each tool and append the
104
+ // adapter-shaped tool-result entries (one for Anthropic, N for
105
+ // OpenAI, …).
106
+ messages.push(...adapter.assistantTurnMessages(resp));
107
+ const results = [];
108
+ let toolErrored = false;
109
+ for (const call of resp.calls) {
110
+ let result;
111
+ let ok = true;
112
+ try {
113
+ result = await runTool({
114
+ agent, tool: call.name, args: call.input,
115
+ taskId, configDir, cwd,
116
+ });
117
+ if (result && result.ok === false) ok = false;
118
+ } catch (err) {
119
+ ok = false;
120
+ if (err instanceof ToolError) {
121
+ result = { ok: false, error: err.message, code: err.code };
122
+ } else {
123
+ result = { ok: false, error: `runTool threw: ${err?.message || err}` };
124
+ }
125
+ }
126
+ toolCalls.push({ id: call.id, name: call.name, input: call.input, result, ok });
127
+ results.push({ id: call.id, content: result, isError: !ok });
128
+ if (!ok) toolErrored = true;
129
+ }
130
+ messages.push(...adapter.toolResultMessages(results));
131
+
132
+ // We feed every tool error (denied/unknown/runtime) back to the
133
+ // model so it can recover. Only an extraordinary error (e.g. the
134
+ // provider returned a malformed envelope) bails out here.
135
+ if (toolErrored && process.env.LAZYCLAW_TOOL_STRICT === '1') {
136
+ return { text: lastText, iterations, stoppedBy: 'tool_error', toolCalls };
137
+ }
138
+ }
139
+
140
+ return { text: lastText, iterations, stoppedBy: 'budget', toolCalls };
141
+ }