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/mas/audit.mjs
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Per-task audit log for every tool call an agent makes.
|
|
2
|
+
//
|
|
3
|
+
// Records appended one JSON object per line to
|
|
4
|
+
// <configDir>/tasks/<id>.audit.jsonl. Stores hashes of the args and
|
|
5
|
+
// result so a runaway agent can't blow the disk with verbose tool I/O,
|
|
6
|
+
// while still giving operators something to grep against when they need
|
|
7
|
+
// forensics. Set LAZYCLAW_AUDIT_RAW=1 to additionally inline the raw
|
|
8
|
+
// args/result bodies — useful in development, off by default.
|
|
9
|
+
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import crypto from 'node:crypto';
|
|
14
|
+
|
|
15
|
+
export class AuditError extends Error {
|
|
16
|
+
constructor(message) { super(message); this.name = 'AuditError'; }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function defaultConfigDir() {
|
|
20
|
+
return process.env.LAZYCLAW_CONFIG_DIR || path.join(os.homedir(), '.lazyclaw');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function auditPath(taskId, configDir = defaultConfigDir()) {
|
|
24
|
+
if (!taskId || typeof taskId !== 'string') throw new AuditError('taskId required');
|
|
25
|
+
return path.join(configDir, 'tasks', `${taskId}.audit.jsonl`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function hashJson(obj) {
|
|
29
|
+
const s = (obj === undefined) ? '' : JSON.stringify(obj);
|
|
30
|
+
return 'sha256:' + crypto.createHash('sha256').update(s).digest('hex').slice(0, 16);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function append({ taskId, agent, tool, args, result, ok = true, configDir = defaultConfigDir() } = {}) {
|
|
34
|
+
if (!taskId) return; // skip silently when called outside a task scope (Phase 12a unit tests)
|
|
35
|
+
const file = auditPath(taskId, configDir);
|
|
36
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
37
|
+
const entry = {
|
|
38
|
+
ts: new Date().toISOString(),
|
|
39
|
+
agent: agent || 'unknown',
|
|
40
|
+
tool,
|
|
41
|
+
args_hash: hashJson(args),
|
|
42
|
+
result_hash: hashJson(result),
|
|
43
|
+
ok: !!ok,
|
|
44
|
+
};
|
|
45
|
+
if (process.env.LAZYCLAW_AUDIT_RAW === '1') {
|
|
46
|
+
entry.args = args;
|
|
47
|
+
entry.result = result;
|
|
48
|
+
}
|
|
49
|
+
fs.appendFileSync(file, JSON.stringify(entry) + '\n');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function read(taskId, configDir = defaultConfigDir()) {
|
|
53
|
+
const file = auditPath(taskId, configDir);
|
|
54
|
+
if (!fs.existsSync(file)) return [];
|
|
55
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
56
|
+
const out = [];
|
|
57
|
+
for (const line of raw.split('\n')) {
|
|
58
|
+
if (!line) continue;
|
|
59
|
+
try { out.push(JSON.parse(line)); } catch { /* skip malformed */ }
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
// Mention router — drives a multi-agent task through one Slack thread.
|
|
2
|
+
//
|
|
3
|
+
// Flow per `runTaskTurn` call:
|
|
4
|
+
//
|
|
5
|
+
// 1. Append the user message (if any) to task.turns.
|
|
6
|
+
// 2. Enqueue the team lead for the first turn.
|
|
7
|
+
// 3. Pop an agent off the queue, build its turn context from task.turns
|
|
8
|
+
// (system = agent.role + team metadata; user = formatted thread
|
|
9
|
+
// transcript + "your turn as X"), and call runAgentTurn.
|
|
10
|
+
// 4. Append the agent's reply to task.turns and (if Slack is wired)
|
|
11
|
+
// post it into the task's thread.
|
|
12
|
+
// 5. If the reply contains the [[TASK_DONE]] marker, flip status to
|
|
13
|
+
// 'done' and stop. Otherwise, extract @mentions of teammates and
|
|
14
|
+
// enqueue them. When the speaker isn't the lead and made no
|
|
15
|
+
// mentions, hand control back to the lead.
|
|
16
|
+
// 6. Loop until queue empties or maxAgentTurns is reached.
|
|
17
|
+
//
|
|
18
|
+
// History across turns: every agent sees the entire thread transcript
|
|
19
|
+
// rendered as one big user message — that's the simplest representation
|
|
20
|
+
// that survives multi-speaker alternation rules in all three providers.
|
|
21
|
+
|
|
22
|
+
import * as agentTurn from './agent_turn.mjs';
|
|
23
|
+
import * as agentsMod from '../agents.mjs';
|
|
24
|
+
import * as tasksMod from '../tasks.mjs';
|
|
25
|
+
import * as agentMemory from './agent_memory.mjs';
|
|
26
|
+
|
|
27
|
+
export class MentionRouterError extends Error {
|
|
28
|
+
constructor(message, code) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.name = 'MentionRouterError';
|
|
31
|
+
this.code = code || 'ROUTER_ERR';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const DONE_MARKER = '[[TASK_DONE]]';
|
|
36
|
+
const DEFAULT_MAX_AGENT_TURNS = 12;
|
|
37
|
+
|
|
38
|
+
// Extract @AgentName mentions out of an agent's text. Only resolves
|
|
39
|
+
// matches that are present in `teamAgents`; everything else (including
|
|
40
|
+
// "@channel", "@here", or stray emails) is ignored. Preserves order,
|
|
41
|
+
// dedupes, and skips the speaker so an agent that re-mentions itself
|
|
42
|
+
// doesn't loop.
|
|
43
|
+
export function extractMentions(text, teamAgents, speaker = null) {
|
|
44
|
+
if (typeof text !== 'string' || !text) return [];
|
|
45
|
+
const set = new Set(teamAgents.map((a) => a.toLowerCase()));
|
|
46
|
+
const out = [];
|
|
47
|
+
const seen = new Set();
|
|
48
|
+
const re = /(?:^|[^\w@])@([a-zA-Z][a-zA-Z0-9_-]*)/g;
|
|
49
|
+
let m;
|
|
50
|
+
while ((m = re.exec(text)) !== null) {
|
|
51
|
+
const name = m[1];
|
|
52
|
+
const key = name.toLowerCase();
|
|
53
|
+
if (!set.has(key)) continue;
|
|
54
|
+
if (speaker && key === String(speaker).toLowerCase()) continue;
|
|
55
|
+
if (seen.has(key)) continue;
|
|
56
|
+
seen.add(key);
|
|
57
|
+
// Recover the canonical (original) name from teamAgents.
|
|
58
|
+
const canonical = teamAgents.find((a) => a.toLowerCase() === key) || name;
|
|
59
|
+
out.push(canonical);
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Render task.turns into a single string. Each turn becomes:
|
|
65
|
+
// "[agentName] text..."
|
|
66
|
+
// The "user" pseudo-agent maps to "User"; the "system" pseudo-agent maps
|
|
67
|
+
// to "System" (the kickoff turn lazyclaw seeds during task start).
|
|
68
|
+
export function renderTranscript(turns) {
|
|
69
|
+
if (!Array.isArray(turns) || turns.length === 0) return '(no turns yet)';
|
|
70
|
+
return turns.map((t) => {
|
|
71
|
+
const who = t.agent === 'user' ? 'User' : t.agent === 'system' ? 'System' : t.agent;
|
|
72
|
+
return `[${who}] ${t.text}`;
|
|
73
|
+
}).join('\n\n');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Build the per-turn prompt the agent sees. System prompt = agent.role
|
|
77
|
+
// + memory block (Phase 18) + team metadata so the model knows who its
|
|
78
|
+
// teammates are and how to terminate; user prompt = task spec +
|
|
79
|
+
// transcript + a tag indicating whose turn this is.
|
|
80
|
+
export function buildTurnContext({ task, team, agent, agentRecord, teammates, configDir }) {
|
|
81
|
+
const memberList = teammates.length
|
|
82
|
+
? teammates.map((a) => `@${a}`).join(', ')
|
|
83
|
+
: '(no other agents in this team)';
|
|
84
|
+
const role = agentRecord.role || '';
|
|
85
|
+
// Phase 18: per-agent memory block, truncated to the agent's
|
|
86
|
+
// memoryMaxChars (default 12 KB). When the file is empty/missing the
|
|
87
|
+
// helper returns '' so the prompt looks exactly like it did before
|
|
88
|
+
// Phase 18.
|
|
89
|
+
const memBlock = agentMemory.buildMemoryBlock(
|
|
90
|
+
agentRecord.name,
|
|
91
|
+
configDir,
|
|
92
|
+
Number.isFinite(+agentRecord.memoryMaxChars) ? +agentRecord.memoryMaxChars : agentMemory.DEFAULT_MAX_CHARS,
|
|
93
|
+
);
|
|
94
|
+
const system = [
|
|
95
|
+
role,
|
|
96
|
+
role && '\n\n---\n',
|
|
97
|
+
memBlock || null,
|
|
98
|
+
`You are *${agentRecord.displayName || agentRecord.name}* on team "${team.displayName || team.name}".`,
|
|
99
|
+
`Teammates you can mention with @name: ${memberList}.`,
|
|
100
|
+
`When the task is complete, end your message with the marker ${DONE_MARKER}.`,
|
|
101
|
+
].filter(Boolean).join('\n');
|
|
102
|
+
const userParts = [
|
|
103
|
+
`# Task: ${task.title}`,
|
|
104
|
+
task.description ? `\n${task.description}\n` : '',
|
|
105
|
+
'# Conversation so far:\n',
|
|
106
|
+
renderTranscript(task.turns),
|
|
107
|
+
`\n\n# Your turn (as ${agentRecord.name}):`,
|
|
108
|
+
];
|
|
109
|
+
return { system, user: userParts.join('') };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Post a single message into the task's Slack thread. Best-effort: log
|
|
113
|
+
// + swallow when the bot token is missing or the API call fails so the
|
|
114
|
+
// router doesn't crash mid-task on a transient Slack error.
|
|
115
|
+
//
|
|
116
|
+
// When agentRecord has displayName / iconEmoji, the post is sent under
|
|
117
|
+
// the agent's persona via chat.postMessage's `username` / `icon_emoji`
|
|
118
|
+
// fields (requires the bot's chat:write.customize scope — Slack
|
|
119
|
+
// silently ignores them when the scope is missing). The message text
|
|
120
|
+
// is no longer manually prefixed with the agent name because the
|
|
121
|
+
// custom username already shows it in Slack's UI.
|
|
122
|
+
async function postToThread({ task, agentRecord, text, logger = () => {}, sender }) {
|
|
123
|
+
if (!task.slackChannel || !task.slackThreadTs) return null;
|
|
124
|
+
let slack = sender;
|
|
125
|
+
let owned = false;
|
|
126
|
+
if (!slack) {
|
|
127
|
+
const { SlackChannel } = await import('../channels/slack.mjs');
|
|
128
|
+
slack = new SlackChannel({ requireInbound: false });
|
|
129
|
+
owned = true;
|
|
130
|
+
try {
|
|
131
|
+
await slack.start(async () => '', {});
|
|
132
|
+
} catch (err) {
|
|
133
|
+
logger(`[router] slack start failed: ${err?.message || err}\n`);
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const threadId = `${task.slackChannel}:${task.slackThreadTs}`;
|
|
138
|
+
// When we have a real agent persona, push the text under that
|
|
139
|
+
// persona's username + icon. Otherwise (user message or system note)
|
|
140
|
+
// fall back to the bot's default identity with no decoration.
|
|
141
|
+
let body;
|
|
142
|
+
let sendOpts = {};
|
|
143
|
+
if (agentRecord) {
|
|
144
|
+
body = String(text);
|
|
145
|
+
if (agentRecord.displayName) sendOpts.username = agentRecord.displayName;
|
|
146
|
+
if (agentRecord.iconEmoji) sendOpts.icon_emoji = agentRecord.iconEmoji;
|
|
147
|
+
} else {
|
|
148
|
+
body = String(text);
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
const res = await slack.send(threadId, body, sendOpts);
|
|
152
|
+
return res?.ts || null;
|
|
153
|
+
} catch (err) {
|
|
154
|
+
logger(`[router] slack send failed: ${err?.message || err}\n`);
|
|
155
|
+
return null;
|
|
156
|
+
} finally {
|
|
157
|
+
if (owned) await slack.stop().catch(() => {});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// "X is thinking…" placeholder posted into the thread before an agent
|
|
162
|
+
// turn so a human reader knows work is happening. Returns the ts of
|
|
163
|
+
// the placeholder so the caller can delete it once the real reply is
|
|
164
|
+
// in. No-op when Slack isn't wired or the post fails.
|
|
165
|
+
async function postTypingPlaceholder({ task, agentRecord, logger = () => {}, sender }) {
|
|
166
|
+
if (!task.slackChannel || !task.slackThreadTs) return { ts: null, sender: null };
|
|
167
|
+
let slack = sender;
|
|
168
|
+
let owned = false;
|
|
169
|
+
if (!slack) {
|
|
170
|
+
const { SlackChannel } = await import('../channels/slack.mjs');
|
|
171
|
+
slack = new SlackChannel({ requireInbound: false });
|
|
172
|
+
owned = true;
|
|
173
|
+
try {
|
|
174
|
+
await slack.start(async () => '', {});
|
|
175
|
+
} catch (err) {
|
|
176
|
+
logger(`[router] slack start failed: ${err?.message || err}\n`);
|
|
177
|
+
return { ts: null, sender: null };
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const threadId = `${task.slackChannel}:${task.slackThreadTs}`;
|
|
181
|
+
const sendOpts = {};
|
|
182
|
+
if (agentRecord?.displayName) sendOpts.username = agentRecord.displayName;
|
|
183
|
+
if (agentRecord?.iconEmoji) sendOpts.icon_emoji = agentRecord.iconEmoji;
|
|
184
|
+
try {
|
|
185
|
+
const res = await slack.send(threadId, `_:hourglass_flowing_sand: thinking…_`, sendOpts);
|
|
186
|
+
return { ts: res?.ts || null, sender: owned ? slack : null, channel: task.slackChannel };
|
|
187
|
+
} catch (err) {
|
|
188
|
+
logger(`[router] slack typing post failed: ${err?.message || err}\n`);
|
|
189
|
+
if (owned) await slack.stop().catch(() => {});
|
|
190
|
+
return { ts: null, sender: null };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Fire one reflection LLM call per agent that actually spoke during
|
|
195
|
+
// this task. Each successful reflection is prepended to the agent's
|
|
196
|
+
// memory file. Failures are logged but never thrown — a sticky
|
|
197
|
+
// transcript that won't reflect shouldn't poison the user's terminal.
|
|
198
|
+
async function autoReflect({ task, agentsById, apiKey, baseUrl, fetchImpl, configDir, logger = () => {} }) {
|
|
199
|
+
if (!task || !Array.isArray(task.turns)) return;
|
|
200
|
+
const participants = new Set();
|
|
201
|
+
for (const t of task.turns) {
|
|
202
|
+
if (t.agent && t.agent !== 'user' && t.agent !== 'system') participants.add(t.agent);
|
|
203
|
+
}
|
|
204
|
+
for (const name of participants) {
|
|
205
|
+
const agentRecord = agentsById[name];
|
|
206
|
+
if (!agentRecord) continue;
|
|
207
|
+
if (agentRecord.memoryWrite && agentRecord.memoryWrite !== 'auto') continue;
|
|
208
|
+
try {
|
|
209
|
+
const body = await agentMemory.reflectOnce({
|
|
210
|
+
agent: agentRecord,
|
|
211
|
+
task,
|
|
212
|
+
apiKey,
|
|
213
|
+
baseUrl,
|
|
214
|
+
fetchImpl,
|
|
215
|
+
});
|
|
216
|
+
if (body && body.trim()) {
|
|
217
|
+
agentMemory.prependEntry(name, { taskId: task.id, title: task.title, body }, configDir);
|
|
218
|
+
logger(`[memory] ${name} reflected on ${task.id}\n`);
|
|
219
|
+
}
|
|
220
|
+
} catch (err) {
|
|
221
|
+
logger(`[memory] reflection failed for ${name}: ${err?.message || err}\n`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function clearTypingPlaceholder(placeholder, logger) {
|
|
227
|
+
if (!placeholder?.ts || !placeholder?.channel) return;
|
|
228
|
+
const slack = placeholder.sender;
|
|
229
|
+
if (!slack) return; // sender wasn't owned by us; skip
|
|
230
|
+
try { await slack.deleteMessage(placeholder.channel, placeholder.ts); }
|
|
231
|
+
catch (err) { logger(`[router] slack typing delete failed: ${err?.message || err}\n`); }
|
|
232
|
+
finally { await slack.stop().catch(() => {}); }
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Run agents in this team until the queue empties or budget runs out.
|
|
236
|
+
//
|
|
237
|
+
// Returns { task, iterations, stoppedBy: 'idle' | 'done' | 'budget' }.
|
|
238
|
+
// - 'idle' — natural end (queue empty, no one to speak)
|
|
239
|
+
// - 'done' — an agent emitted DONE_MARKER (task.status flipped)
|
|
240
|
+
// - 'budget' — maxAgentTurns hit before queue drained
|
|
241
|
+
export async function runTaskTurn({
|
|
242
|
+
task,
|
|
243
|
+
team,
|
|
244
|
+
agentsById,
|
|
245
|
+
userMessage,
|
|
246
|
+
configDir,
|
|
247
|
+
cwd,
|
|
248
|
+
apiKey,
|
|
249
|
+
fetchImpl,
|
|
250
|
+
baseUrl,
|
|
251
|
+
logger = () => {},
|
|
252
|
+
maxAgentTurns = DEFAULT_MAX_AGENT_TURNS,
|
|
253
|
+
signal,
|
|
254
|
+
} = {}) {
|
|
255
|
+
if (!task || !team || !agentsById) {
|
|
256
|
+
throw new MentionRouterError('task, team, agentsById are required', 'ROUTER_BAD_INPUT');
|
|
257
|
+
}
|
|
258
|
+
if (!team.lead || !team.agents.includes(team.lead)) {
|
|
259
|
+
throw new MentionRouterError(`team "${team.name}" has no valid lead`, 'ROUTER_NO_LEAD');
|
|
260
|
+
}
|
|
261
|
+
// Closed tasks reject further ticks so a stray `task tick` after a
|
|
262
|
+
// [[TASK_DONE]] or an explicit abandon doesn't reopen the loop.
|
|
263
|
+
if (task.status === 'done' || task.status === 'abandoned') {
|
|
264
|
+
throw new MentionRouterError(`task "${task.id}" is ${task.status} — cannot run further turns`, 'ROUTER_CLOSED');
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
let current = task;
|
|
268
|
+
|
|
269
|
+
// A pending task (no Slack thread was opened at task start) gets
|
|
270
|
+
// promoted to running on its first tick so the dashboard reflects
|
|
271
|
+
// that work has actually started.
|
|
272
|
+
if (current.status === 'pending') {
|
|
273
|
+
current = tasksMod.patchTask(current.id, { status: 'running' }, configDir);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Seed: append the user message if provided, and (also) push it to
|
|
277
|
+
// Slack so anyone reading the thread sees the prompt.
|
|
278
|
+
if (userMessage && String(userMessage).trim()) {
|
|
279
|
+
current = tasksMod.appendTurn(current.id, { agent: 'user', text: String(userMessage), ts: new Date().toISOString() }, configDir);
|
|
280
|
+
await postToThread({ task: current, agentRecord: null, text: `*User*: ${userMessage}`, logger });
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const queue = [team.lead];
|
|
284
|
+
let iterations = 0;
|
|
285
|
+
let stoppedBy = 'idle';
|
|
286
|
+
|
|
287
|
+
while (queue.length > 0 && iterations < maxAgentTurns) {
|
|
288
|
+
if (signal?.aborted) { stoppedBy = 'abort'; break; }
|
|
289
|
+
const speaker = queue.shift();
|
|
290
|
+
const agentRecord = agentsById[speaker];
|
|
291
|
+
if (!agentRecord) {
|
|
292
|
+
logger(`[router] no agent record for "${speaker}" — skipping\n`);
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
iterations++;
|
|
296
|
+
const teammates = team.agents.filter((a) => a !== speaker);
|
|
297
|
+
const ctx = buildTurnContext({ task: current, team, agent: speaker, agentRecord, teammates, configDir });
|
|
298
|
+
|
|
299
|
+
// Post a "thinking…" placeholder so the user sees the bot picked
|
|
300
|
+
// up the turn before the LLM finishes. Cleared right after the
|
|
301
|
+
// real reply lands so we never leave a stale placeholder in the
|
|
302
|
+
// thread.
|
|
303
|
+
const typing = await postTypingPlaceholder({ task: current, agentRecord, logger });
|
|
304
|
+
|
|
305
|
+
let result;
|
|
306
|
+
try {
|
|
307
|
+
result = await agentTurn.runAgentTurn({
|
|
308
|
+
agent: { ...agentRecord, role: ctx.system },
|
|
309
|
+
userMessage: ctx.user,
|
|
310
|
+
history: [],
|
|
311
|
+
taskId: current.id,
|
|
312
|
+
configDir, cwd, apiKey, fetchImpl, baseUrl, signal,
|
|
313
|
+
});
|
|
314
|
+
} catch (err) {
|
|
315
|
+
await clearTypingPlaceholder(typing, logger);
|
|
316
|
+
logger(`[router] agent "${speaker}" threw: ${err?.message || err}\n`);
|
|
317
|
+
current = tasksMod.appendTurn(current.id, { agent: speaker, text: `(error: ${err?.message || err})`, ts: new Date().toISOString(), error: true }, configDir);
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
await clearTypingPlaceholder(typing, logger);
|
|
321
|
+
|
|
322
|
+
const replyText = (result.text || '').trim();
|
|
323
|
+
const ts = new Date().toISOString();
|
|
324
|
+
current = tasksMod.appendTurn(current.id, { agent: speaker, text: replyText, ts, toolCalls: result.toolCalls?.length ? result.toolCalls : undefined }, configDir);
|
|
325
|
+
|
|
326
|
+
// Slack mirror — only the user-visible text, with the agent name
|
|
327
|
+
// prefixed so a human reader can follow who said what.
|
|
328
|
+
if (replyText) await postToThread({ task: current, agentRecord, text: replyText, logger });
|
|
329
|
+
|
|
330
|
+
if (replyText.includes(DONE_MARKER)) {
|
|
331
|
+
current = tasksMod.patchTask(current.id, { status: 'done' }, configDir);
|
|
332
|
+
await postToThread({ task: current, agentRecord: null, text: `:white_check_mark: ${DONE_MARKER} — task closed by *${agentRecord.displayName || speaker}*.`, logger });
|
|
333
|
+
stoppedBy = 'done';
|
|
334
|
+
// Phase 18: fire one reflection LLM call per participating agent
|
|
335
|
+
// whose memoryWrite is 'auto'. We pick "participating" off the
|
|
336
|
+
// task.turns rather than team.agents so an agent who never spoke
|
|
337
|
+
// doesn't reflect on a task they weren't really in.
|
|
338
|
+
await autoReflect({
|
|
339
|
+
task: current,
|
|
340
|
+
agentsById,
|
|
341
|
+
apiKey, baseUrl, fetchImpl,
|
|
342
|
+
configDir, logger,
|
|
343
|
+
});
|
|
344
|
+
break;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const mentions = extractMentions(replyText, team.agents, speaker);
|
|
348
|
+
for (const m of mentions) queue.push(m);
|
|
349
|
+
|
|
350
|
+
// When a non-lead speaker doesn't hand off, return control to the
|
|
351
|
+
// lead so the conversation doesn't strand mid-team. The lead can
|
|
352
|
+
// choose to terminate next turn.
|
|
353
|
+
if (mentions.length === 0 && speaker !== team.lead) {
|
|
354
|
+
queue.push(team.lead);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (iterations >= maxAgentTurns) stoppedBy = 'budget';
|
|
359
|
+
return { task: current, iterations, stoppedBy };
|
|
360
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Tool runner — given an agent record and a tool invocation, validates
|
|
2
|
+
// the agent is allowed to use the tool, runs the tool, audits the call,
|
|
3
|
+
// and returns a uniform { ok, result?, error? } shape that the provider
|
|
4
|
+
// adapters serialise into their respective tool-result content blocks.
|
|
5
|
+
//
|
|
6
|
+
// `bash`, `read`, `write`, `grep` ship with Phase 12a. `web_search`,
|
|
7
|
+
// `web_fetch`, `slack_post` are advertised in the registry's
|
|
8
|
+
// metadata-only entry so the dashboard can show them, but their `exec`
|
|
9
|
+
// throws TOOL_NOT_IMPLEMENTED until later phases wire them up.
|
|
10
|
+
|
|
11
|
+
import * as bashTool from './tools/bash.mjs';
|
|
12
|
+
import * as readTool from './tools/read.mjs';
|
|
13
|
+
import * as writeTool from './tools/write.mjs';
|
|
14
|
+
import * as grepTool from './tools/grep.mjs';
|
|
15
|
+
import * as audit from './audit.mjs';
|
|
16
|
+
|
|
17
|
+
export class ToolError extends Error {
|
|
18
|
+
constructor(message, code) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = 'ToolError';
|
|
21
|
+
this.code = code || 'TOOL_ERR';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const TOOLS = {
|
|
26
|
+
bash: bashTool,
|
|
27
|
+
read: readTool,
|
|
28
|
+
write: writeTool,
|
|
29
|
+
grep: grepTool,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const NOT_IMPLEMENTED_TOOLS = ['web_search', 'web_fetch', 'slack_post'];
|
|
33
|
+
|
|
34
|
+
export function listToolSchemas(names) {
|
|
35
|
+
const out = [];
|
|
36
|
+
const wanted = Array.isArray(names) && names.length ? names : Object.keys(TOOLS);
|
|
37
|
+
for (const name of wanted) {
|
|
38
|
+
const t = TOOLS[name];
|
|
39
|
+
if (!t) continue;
|
|
40
|
+
out.push({ name: t.NAME, description: t.DESCRIPTION, parameters: t.PARAMETERS });
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function isImplemented(name) {
|
|
46
|
+
return Boolean(TOOLS[name]);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function knownTool(name) {
|
|
50
|
+
return TOOLS[name] !== undefined || NOT_IMPLEMENTED_TOOLS.includes(name);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Run one tool call. The agent record's `tools` field is the whitelist;
|
|
54
|
+
// when the call falls outside it, we throw ToolError('TOOL_DENIED') so
|
|
55
|
+
// the caller can surface a structured error back to the LLM rather than
|
|
56
|
+
// silently dropping the call.
|
|
57
|
+
//
|
|
58
|
+
// opts.cwd — where bash/read/write/grep root themselves; defaults to
|
|
59
|
+
// process.cwd() so it can be overridden in tests.
|
|
60
|
+
// opts.taskId — when set, every call is appended to the task's audit
|
|
61
|
+
// log. Unit tests can omit it.
|
|
62
|
+
export async function runTool({ agent, tool, args, taskId, configDir, cwd } = {}) {
|
|
63
|
+
if (!agent || !Array.isArray(agent.tools)) {
|
|
64
|
+
throw new ToolError('agent record with .tools[] is required', 'TOOL_BAD_AGENT');
|
|
65
|
+
}
|
|
66
|
+
if (!knownTool(tool)) {
|
|
67
|
+
throw new ToolError(`unknown tool "${tool}"`, 'TOOL_UNKNOWN');
|
|
68
|
+
}
|
|
69
|
+
if (!agent.tools.includes(tool)) {
|
|
70
|
+
throw new ToolError(`agent "${agent.name}" is not allowed to call tool "${tool}" (whitelist=[${agent.tools.join(', ')}])`, 'TOOL_DENIED');
|
|
71
|
+
}
|
|
72
|
+
const impl = TOOLS[tool];
|
|
73
|
+
if (!impl) {
|
|
74
|
+
// Known but not yet implemented (web_search etc.) — Phase 12+x will fill in.
|
|
75
|
+
const result = { ok: false, error: `tool "${tool}" is registered but not implemented yet` };
|
|
76
|
+
audit.append({ taskId, agent: agent.name, tool, args, result, ok: false, configDir });
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
let result;
|
|
80
|
+
try {
|
|
81
|
+
result = await impl.exec(args || {}, { cwd: cwd || process.cwd() });
|
|
82
|
+
} catch (err) {
|
|
83
|
+
result = { ok: false, error: `${tool} threw: ${err?.message || err}` };
|
|
84
|
+
}
|
|
85
|
+
audit.append({ taskId, agent: agent.name, tool, args, result, ok: !!result?.ok, configDir });
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Bash tool — runs a shell command, captures stdout/stderr/exit.
|
|
2
|
+
//
|
|
3
|
+
// Workspace is constrained to the lazyclaw process cwd (spec §5.2). We
|
|
4
|
+
// don't sandbox further (per §10 #6 — destructive-pattern confirmation
|
|
5
|
+
// is OFF by default); the audit log captures every invocation so post-hoc
|
|
6
|
+
// forensics work.
|
|
7
|
+
//
|
|
8
|
+
// Timeout defaults to 30s so a runaway command can't stall the whole
|
|
9
|
+
// agent turn. Override via args.timeoutMs (capped at 5 minutes).
|
|
10
|
+
|
|
11
|
+
import { spawn } from 'node:child_process';
|
|
12
|
+
|
|
13
|
+
export const NAME = 'bash';
|
|
14
|
+
export const DESCRIPTION = 'Run a shell command in the agent\'s workspace. Returns {stdout, stderr, exitCode}. Timeout 30s by default.';
|
|
15
|
+
export const PARAMETERS = {
|
|
16
|
+
type: 'object',
|
|
17
|
+
properties: {
|
|
18
|
+
command: { type: 'string', description: 'The shell command to execute.' },
|
|
19
|
+
timeoutMs: { type: 'number', description: 'Optional override (max 300000).' },
|
|
20
|
+
},
|
|
21
|
+
required: ['command'],
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const MAX_TIMEOUT_MS = 5 * 60_000;
|
|
25
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
26
|
+
const MAX_OUTPUT_BYTES = 200_000; // 200 KB per stream — bigger gets truncated
|
|
27
|
+
|
|
28
|
+
export async function exec(args, { cwd = process.cwd() } = {}) {
|
|
29
|
+
if (!args || typeof args.command !== 'string' || args.command.trim() === '') {
|
|
30
|
+
return { ok: false, error: 'bash: command is required (non-empty string)' };
|
|
31
|
+
}
|
|
32
|
+
const timeoutMs = Math.min(
|
|
33
|
+
Math.max(Number.isFinite(+args.timeoutMs) ? +args.timeoutMs : DEFAULT_TIMEOUT_MS, 100),
|
|
34
|
+
MAX_TIMEOUT_MS
|
|
35
|
+
);
|
|
36
|
+
return new Promise((resolve) => {
|
|
37
|
+
const child = spawn('sh', ['-c', args.command], { cwd, env: process.env });
|
|
38
|
+
let stdout = '', stderr = '';
|
|
39
|
+
let outBytes = 0, errBytes = 0;
|
|
40
|
+
let truncated = false;
|
|
41
|
+
let timedOut = false;
|
|
42
|
+
const tm = setTimeout(() => {
|
|
43
|
+
timedOut = true;
|
|
44
|
+
try { child.kill('SIGTERM'); } catch { /* gone */ }
|
|
45
|
+
setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* gone */ } }, 1000);
|
|
46
|
+
}, timeoutMs);
|
|
47
|
+
child.stdout.on('data', (chunk) => {
|
|
48
|
+
const s = chunk.toString();
|
|
49
|
+
outBytes += s.length;
|
|
50
|
+
if (outBytes > MAX_OUTPUT_BYTES) {
|
|
51
|
+
if (!truncated) stdout += s.slice(0, Math.max(0, MAX_OUTPUT_BYTES - (outBytes - s.length))) + '\n…[truncated]\n';
|
|
52
|
+
truncated = true;
|
|
53
|
+
} else {
|
|
54
|
+
stdout += s;
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
child.stderr.on('data', (chunk) => {
|
|
58
|
+
const s = chunk.toString();
|
|
59
|
+
errBytes += s.length;
|
|
60
|
+
if (errBytes > MAX_OUTPUT_BYTES) {
|
|
61
|
+
if (!truncated) stderr += s.slice(0, Math.max(0, MAX_OUTPUT_BYTES - (errBytes - s.length))) + '\n…[truncated]\n';
|
|
62
|
+
truncated = true;
|
|
63
|
+
} else {
|
|
64
|
+
stderr += s;
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
child.on('close', (code) => {
|
|
68
|
+
clearTimeout(tm);
|
|
69
|
+
resolve({
|
|
70
|
+
ok: true,
|
|
71
|
+
stdout, stderr,
|
|
72
|
+
exitCode: code,
|
|
73
|
+
timedOut,
|
|
74
|
+
truncated,
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Grep tool — recursive substring / regex search over the workspace.
|
|
2
|
+
//
|
|
3
|
+
// Pure JS scanner — no shell dependency on rg/grep. Walks the directory
|
|
4
|
+
// tree honoring a small built-in ignore list (node_modules, .git,
|
|
5
|
+
// dist/, build/, etc.) so a default search doesn't trawl megabytes of
|
|
6
|
+
// dependency code.
|
|
7
|
+
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
export const NAME = 'grep';
|
|
12
|
+
export const DESCRIPTION = 'Search files for a substring or /regex/. Returns matching lines with path + line number.';
|
|
13
|
+
export const PARAMETERS = {
|
|
14
|
+
type: 'object',
|
|
15
|
+
properties: {
|
|
16
|
+
pattern: { type: 'string', description: 'Plain substring or "/pattern/flags" regex form.' },
|
|
17
|
+
path: { type: 'string', description: 'Root to search; defaults to workspace cwd.' },
|
|
18
|
+
maxMatches: { type: 'number', description: 'Cap on results; default 200.' },
|
|
19
|
+
},
|
|
20
|
+
required: ['pattern'],
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const DEFAULT_MAX_MATCHES = 200;
|
|
24
|
+
const IGNORE_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'coverage', '.playwright', 'playwright-report', 'test-results', '.lazyclaw']);
|
|
25
|
+
const MAX_FILE_BYTES = 1_000_000; // skip files bigger than 1 MB
|
|
26
|
+
const TEXT_EXT = new Set([
|
|
27
|
+
'.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.json', '.md', '.txt',
|
|
28
|
+
'.html', '.css', '.scss', '.sass', '.yml', '.yaml', '.toml', '.ini',
|
|
29
|
+
'.py', '.rb', '.go', '.rs', '.c', '.cc', '.cpp', '.h', '.hpp', '.java',
|
|
30
|
+
'.kt', '.swift', '.sh', '.bash', '.zsh', '.fish', '.sql', '.xml',
|
|
31
|
+
'.svg', '.vue', '.svelte', '.lua', '.php', '.gitignore', '',
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
function compilePattern(raw) {
|
|
35
|
+
const m = /^\/(.+)\/([gimsuy]*)$/.exec(raw);
|
|
36
|
+
if (m) return new RegExp(m[1], m[2].includes('g') ? m[2] : m[2] + 'g');
|
|
37
|
+
// Escape for substring literal use and force `g` so we can scan
|
|
38
|
+
// multiple lines per file without restarting state.
|
|
39
|
+
return new RegExp(raw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function* walk(root) {
|
|
43
|
+
let stack = [root];
|
|
44
|
+
while (stack.length) {
|
|
45
|
+
const dir = stack.pop();
|
|
46
|
+
let entries;
|
|
47
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
|
48
|
+
catch { continue; }
|
|
49
|
+
for (const ent of entries) {
|
|
50
|
+
if (IGNORE_DIRS.has(ent.name)) continue;
|
|
51
|
+
const full = path.join(dir, ent.name);
|
|
52
|
+
if (ent.isDirectory()) stack.push(full);
|
|
53
|
+
else if (ent.isFile()) yield full;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function exec(args, { cwd = process.cwd() } = {}) {
|
|
59
|
+
if (!args || typeof args.pattern !== 'string' || !args.pattern) {
|
|
60
|
+
return { ok: false, error: 'grep: pattern is required' };
|
|
61
|
+
}
|
|
62
|
+
const root = args.path
|
|
63
|
+
? (path.isAbsolute(args.path) ? args.path : path.resolve(cwd, args.path))
|
|
64
|
+
: cwd;
|
|
65
|
+
const max = Math.max(1, Math.min(Number.isFinite(+args.maxMatches) ? +args.maxMatches : DEFAULT_MAX_MATCHES, 1000));
|
|
66
|
+
let re;
|
|
67
|
+
try { re = compilePattern(args.pattern); }
|
|
68
|
+
catch (err) { return { ok: false, error: `grep: bad pattern: ${err?.message || err}` }; }
|
|
69
|
+
|
|
70
|
+
const matches = [];
|
|
71
|
+
let truncated = false;
|
|
72
|
+
for (const file of walk(root)) {
|
|
73
|
+
const ext = path.extname(file).toLowerCase();
|
|
74
|
+
if (TEXT_EXT.size && !TEXT_EXT.has(ext)) continue;
|
|
75
|
+
let stat;
|
|
76
|
+
try { stat = fs.statSync(file); } catch { continue; }
|
|
77
|
+
if (stat.size > MAX_FILE_BYTES) continue;
|
|
78
|
+
let content;
|
|
79
|
+
try { content = fs.readFileSync(file, 'utf8'); } catch { continue; }
|
|
80
|
+
const lines = content.split('\n');
|
|
81
|
+
for (let i = 0; i < lines.length; i++) {
|
|
82
|
+
re.lastIndex = 0;
|
|
83
|
+
if (re.test(lines[i])) {
|
|
84
|
+
matches.push({ path: file, line: i + 1, text: lines[i].slice(0, 500) });
|
|
85
|
+
if (matches.length >= max) { truncated = true; break; }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (truncated) break;
|
|
89
|
+
}
|
|
90
|
+
return { ok: true, count: matches.length, truncated, matches };
|
|
91
|
+
}
|