lazyclaw 3.99.28 → 4.2.1
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/README.md +128 -2
- package/agents.mjs +179 -0
- package/channels/base.mjs +120 -0
- package/channels/http.mjs +54 -0
- package/channels/slack.mjs +465 -0
- package/channels/stub.mjs +52 -0
- package/cli.mjs +1607 -119
- package/daemon.mjs +171 -0
- package/docs/multi-agent.md +256 -0
- package/goals.mjs +128 -0
- package/loop-engine.mjs +182 -0
- package/loops.mjs +135 -0
- package/mas/agent_memory.mjs +189 -0
- package/mas/agent_turn.mjs +147 -0
- package/mas/audit.mjs +62 -0
- package/mas/mention_router.mjs +360 -0
- package/mas/tool_runner.mjs +87 -0
- package/mas/tools/bash.mjs +78 -0
- package/mas/tools/grep.mjs +91 -0
- package/mas/tools/read.mjs +45 -0
- package/mas/tools/write.mjs +42 -0
- package/memory.mjs +193 -0
- package/package.json +26 -6
- package/providers/registry.mjs +8 -1
- package/providers/tool_use/anthropic.mjs +151 -0
- package/providers/tool_use/claude_cli.mjs +215 -0
- package/providers/tool_use/gemini.mjs +189 -0
- package/providers/tool_use/openai.mjs +140 -0
- package/scripts/loop-worker.mjs +160 -0
- package/sessions.mjs +5 -0
- package/tasks.mjs +220 -0
- package/teams.mjs +199 -0
- package/web/dashboard.html +166 -0
package/loop-engine.mjs
ADDED
|
@@ -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,189 @@
|
|
|
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
|
+
case 'claude-cli': return await import('../providers/tool_use/claude_cli.mjs');
|
|
186
|
+
default:
|
|
187
|
+
throw new AgentMemoryError(`provider "${provider}" does not support reflection`, 'AGENT_MEMORY_NO_PROVIDER');
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
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
|
+
import * as claudeCli from '../providers/tool_use/claude_cli.mjs';
|
|
28
|
+
|
|
29
|
+
export class AgentTurnError extends Error {
|
|
30
|
+
constructor(message, code) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.name = 'AgentTurnError';
|
|
33
|
+
this.code = code || 'AGENT_TURN_ERR';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const DEFAULT_MAX_ITERATIONS = 10;
|
|
38
|
+
|
|
39
|
+
function adapterFor(provider) {
|
|
40
|
+
switch (provider) {
|
|
41
|
+
case 'anthropic': return { ...anthropic, toolSchemas: anthropic.toAnthropicTools };
|
|
42
|
+
case 'openai': return { ...openai, toolSchemas: openai.toOpenAITools };
|
|
43
|
+
case 'gemini': return { ...gemini, toolSchemas: gemini.toGeminiTools };
|
|
44
|
+
// claude-cli runs the tool-use loop INSIDE the binary. Our adapter
|
|
45
|
+
// resolves every call to kind:'final' so the mention router still
|
|
46
|
+
// gets a normalised reply, even though no tool_calls envelope is
|
|
47
|
+
// ever observed.
|
|
48
|
+
case 'claude-cli': return { ...claudeCli, toolSchemas: (s) => s };
|
|
49
|
+
default:
|
|
50
|
+
throw new AgentTurnError(`provider "${provider}" does not support tool-use yet`, 'PROVIDER_UNSUPPORTED');
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Run one full agent turn. Returns:
|
|
55
|
+
// { text, iterations, stoppedBy: 'final' | 'budget' | 'tool_error', toolCalls }
|
|
56
|
+
//
|
|
57
|
+
// `toolCalls` lists every tool invocation that actually ran (with its
|
|
58
|
+
// result). `stoppedBy: 'tool_error'` means the runner aborted because a
|
|
59
|
+
// tool denied/threw; the previous text (if any) is returned but the
|
|
60
|
+
// next provider call was skipped.
|
|
61
|
+
export async function runAgentTurn({
|
|
62
|
+
agent,
|
|
63
|
+
userMessage,
|
|
64
|
+
history = [],
|
|
65
|
+
taskId,
|
|
66
|
+
configDir,
|
|
67
|
+
cwd,
|
|
68
|
+
fetchImpl,
|
|
69
|
+
baseUrl,
|
|
70
|
+
apiKey,
|
|
71
|
+
maxIterations = DEFAULT_MAX_ITERATIONS,
|
|
72
|
+
signal,
|
|
73
|
+
} = {}) {
|
|
74
|
+
if (!agent) throw new AgentTurnError('agent is required', 'NO_AGENT');
|
|
75
|
+
const adapter = adapterFor(agent.provider);
|
|
76
|
+
|
|
77
|
+
const tools = adapter.toolSchemas(listToolSchemas(agent.tools));
|
|
78
|
+
|
|
79
|
+
// Seed messages from prior history + the new user input. Callers
|
|
80
|
+
// pass history in a provider-neutral [{role, content}] shape; the
|
|
81
|
+
// adapter normalises it into its native message format (e.g. Gemini's
|
|
82
|
+
// `parts: [...]` representation). The new user message is wrapped
|
|
83
|
+
// identically.
|
|
84
|
+
const normalize = adapter.normalizeHistory || ((h) => [...h]);
|
|
85
|
+
const initialUser = adapter.initialUserMessage || ((t) => ({ role: 'user', content: t }));
|
|
86
|
+
const messages = normalize(history);
|
|
87
|
+
if (userMessage && String(userMessage).trim()) {
|
|
88
|
+
messages.push(initialUser(String(userMessage)));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const toolCalls = [];
|
|
92
|
+
let iterations = 0;
|
|
93
|
+
let lastText = '';
|
|
94
|
+
|
|
95
|
+
while (iterations < maxIterations) {
|
|
96
|
+
if (signal?.aborted) return { text: lastText, iterations, stoppedBy: 'abort', toolCalls };
|
|
97
|
+
iterations++;
|
|
98
|
+
const resp = await adapter.callOnce({
|
|
99
|
+
messages, tools, model: agent.model, apiKey, system: agent.role,
|
|
100
|
+
fetchImpl, baseUrl, signal,
|
|
101
|
+
});
|
|
102
|
+
if (resp.text) lastText = resp.text;
|
|
103
|
+
|
|
104
|
+
if (resp.kind === 'final') {
|
|
105
|
+
return { text: resp.text || '', iterations, stoppedBy: 'final', toolCalls };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// tool_calls path: echo the model's assistant turn back so future
|
|
109
|
+
// tool_result messages correlate, then run each tool and append the
|
|
110
|
+
// adapter-shaped tool-result entries (one for Anthropic, N for
|
|
111
|
+
// OpenAI, …).
|
|
112
|
+
messages.push(...adapter.assistantTurnMessages(resp));
|
|
113
|
+
const results = [];
|
|
114
|
+
let toolErrored = false;
|
|
115
|
+
for (const call of resp.calls) {
|
|
116
|
+
let result;
|
|
117
|
+
let ok = true;
|
|
118
|
+
try {
|
|
119
|
+
result = await runTool({
|
|
120
|
+
agent, tool: call.name, args: call.input,
|
|
121
|
+
taskId, configDir, cwd,
|
|
122
|
+
});
|
|
123
|
+
if (result && result.ok === false) ok = false;
|
|
124
|
+
} catch (err) {
|
|
125
|
+
ok = false;
|
|
126
|
+
if (err instanceof ToolError) {
|
|
127
|
+
result = { ok: false, error: err.message, code: err.code };
|
|
128
|
+
} else {
|
|
129
|
+
result = { ok: false, error: `runTool threw: ${err?.message || err}` };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
toolCalls.push({ id: call.id, name: call.name, input: call.input, result, ok });
|
|
133
|
+
results.push({ id: call.id, content: result, isError: !ok });
|
|
134
|
+
if (!ok) toolErrored = true;
|
|
135
|
+
}
|
|
136
|
+
messages.push(...adapter.toolResultMessages(results));
|
|
137
|
+
|
|
138
|
+
// We feed every tool error (denied/unknown/runtime) back to the
|
|
139
|
+
// model so it can recover. Only an extraordinary error (e.g. the
|
|
140
|
+
// provider returned a malformed envelope) bails out here.
|
|
141
|
+
if (toolErrored && process.env.LAZYCLAW_TOOL_STRICT === '1') {
|
|
142
|
+
return { text: lastText, iterations, stoppedBy: 'tool_error', toolCalls };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { text: lastText, iterations, stoppedBy: 'budget', toolCalls };
|
|
147
|
+
}
|