lazyclaw 4.3.0 → 5.0.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.ko.md +44 -0
- package/README.md +172 -508
- package/channels/handoff.mjs +41 -0
- package/channels/loader.mjs +124 -0
- package/channels/threads.mjs +116 -0
- package/cli.mjs +430 -7
- package/daemon.mjs +13 -0
- package/mas/agent_turn.mjs +28 -0
- package/mas/confidence.mjs +108 -0
- package/mas/index_db.mjs +242 -0
- package/mas/nudge.mjs +97 -0
- package/mas/prompt_stack.mjs +80 -0
- package/mas/skill_synth.mjs +124 -25
- package/mas/tool_runner.mjs +10 -61
- package/mas/tools/browser.mjs +77 -0
- package/mas/tools/clarify.mjs +36 -0
- package/mas/tools/coding.mjs +109 -0
- package/mas/tools/delegation.mjs +53 -0
- package/mas/tools/edit.mjs +36 -0
- package/mas/tools/git.mjs +110 -0
- package/mas/tools/ha.mjs +34 -0
- package/mas/tools/learning.mjs +168 -0
- package/mas/tools/media.mjs +105 -0
- package/mas/tools/os.mjs +152 -0
- package/mas/tools/patch.mjs +91 -0
- package/mas/tools/recall.mjs +103 -0
- package/mas/tools/registry.mjs +93 -0
- package/mas/tools/scheduling.mjs +62 -0
- package/mas/tools/web.mjs +137 -0
- package/mas/toolsets.mjs +64 -0
- package/mas/trajectory_export.mjs +169 -0
- package/mas/trajectory_store.mjs +179 -0
- package/mas/user_modeler.mjs +108 -0
- package/package.json +20 -3
- package/providers/codex_cli.mjs +200 -0
- package/providers/gemini_cli.mjs +179 -0
- package/providers/registry.mjs +61 -1
- package/sandbox/base.mjs +82 -0
- package/sandbox/confiners/bubblewrap.mjs +21 -0
- package/sandbox/confiners/firejail.mjs +16 -0
- package/sandbox/confiners/landlock.mjs +14 -0
- package/sandbox/confiners/seatbelt.mjs +28 -0
- package/sandbox/daytona.mjs +37 -0
- package/sandbox/docker.mjs +91 -0
- package/sandbox/index.mjs +67 -0
- package/sandbox/local.mjs +59 -0
- package/sandbox/modal.mjs +53 -0
- package/sandbox/singularity.mjs +39 -0
- package/sandbox/ssh.mjs +56 -0
- package/sandbox.mjs +11 -127
- package/scripts/hermes-import.mjs +111 -0
- package/scripts/migrate-v5.mjs +342 -0
- package/scripts/openclaw-import.mjs +71 -0
- package/sessions.mjs +20 -1
package/mas/skill_synth.mjs
CHANGED
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
import * as skills from '../skills.mjs';
|
|
19
19
|
import { runTextCompletion } from './provider_adapters.mjs';
|
|
20
20
|
import { redactSecrets, neutralizeRoleLabels } from './redact.mjs';
|
|
21
|
+
import { indexSkill as _indexSkill } from './index_db.mjs';
|
|
22
|
+
import { parseFrontmatter } from '../skills.mjs';
|
|
21
23
|
|
|
22
24
|
const SECTION_RE = /^#{1,6}\s+/;
|
|
23
25
|
const MAX_NAME_LEN = 48;
|
|
@@ -106,23 +108,69 @@ export function parseSynthOutput(text) {
|
|
|
106
108
|
}
|
|
107
109
|
|
|
108
110
|
// Build a complete SKILL.md: a flat-YAML frontmatter block followed by
|
|
109
|
-
// the skill body.
|
|
111
|
+
// the skill body. v5: adds trained_by / trained_on_model / trajectory_ref /
|
|
112
|
+
// confidence / cross_cli_tested (array) / anti_pattern (boolean) and a
|
|
113
|
+
// group fallback. The frontmatter shape round-trips through
|
|
110
114
|
// skills.parseFrontmatter(). `ts` is injected (not read from the clock)
|
|
111
115
|
// so the output is deterministic and testable.
|
|
112
|
-
export function assembleSkillDoc({
|
|
116
|
+
export function assembleSkillDoc({
|
|
117
|
+
name,
|
|
118
|
+
description = '',
|
|
119
|
+
createdBy = 'agent',
|
|
120
|
+
sourceTask = '',
|
|
121
|
+
body = '',
|
|
122
|
+
version = 1,
|
|
123
|
+
ts = new Date(),
|
|
124
|
+
// v5 additions:
|
|
125
|
+
trainedBy = null,
|
|
126
|
+
trainedOnModel = null,
|
|
127
|
+
trajectoryRef = null,
|
|
128
|
+
confidence = null,
|
|
129
|
+
crossCliTested = null, // array of {provider, model, outcome, tested_at}
|
|
130
|
+
outcome = 'done', // 'done' | 'failed' | 'abandoned' (spec §0.1 C1)
|
|
131
|
+
group = null,
|
|
132
|
+
} = {}) {
|
|
113
133
|
const date = (ts instanceof Date ? ts : new Date(ts)).toISOString().slice(0, 10);
|
|
134
|
+
const isAntiPattern = outcome === 'failed';
|
|
135
|
+
const finalGroup = group || (isAntiPattern ? 'anti-pattern' : deriveGroup(name));
|
|
114
136
|
const fm = [
|
|
115
137
|
'---',
|
|
116
138
|
`name: ${escapeYaml(stripControl(name))}`,
|
|
117
139
|
`description: ${escapeYaml(description)}`,
|
|
118
140
|
`version: ${version}`,
|
|
141
|
+
`group: ${escapeYaml(finalGroup)}`,
|
|
119
142
|
`created_by: ${createdBy}`,
|
|
120
143
|
];
|
|
121
144
|
if (sourceTask) fm.push(`source_task: ${sourceTask}`);
|
|
122
|
-
fm.push(`created_at: ${date}
|
|
145
|
+
fm.push(`created_at: ${date}`);
|
|
146
|
+
if (trainedBy) fm.push(`trained_by: ${escapeYaml(trainedBy)}`);
|
|
147
|
+
if (trainedOnModel) fm.push(`trained_on_model: ${escapeYaml(trainedOnModel)}`);
|
|
148
|
+
if (trajectoryRef) fm.push(`trajectory_ref: ${escapeYaml(trajectoryRef)}`);
|
|
149
|
+
if (confidence !== null && confidence !== undefined) {
|
|
150
|
+
fm.push(`confidence: ${Number(confidence).toFixed(2)}`);
|
|
151
|
+
}
|
|
152
|
+
if (isAntiPattern) fm.push(`anti_pattern: true`);
|
|
153
|
+
if (Array.isArray(crossCliTested) && crossCliTested.length) {
|
|
154
|
+
fm.push('cross_cli_tested:');
|
|
155
|
+
for (const t of crossCliTested) {
|
|
156
|
+
fm.push(` - provider: ${escapeYaml(t.provider || '')}`);
|
|
157
|
+
if (t.model) fm.push(` model: ${escapeYaml(t.model)}`);
|
|
158
|
+
if (t.outcome) fm.push(` outcome: ${escapeYaml(t.outcome)}`);
|
|
159
|
+
if (t.tested_at) fm.push(` tested_at: ${escapeYaml(t.tested_at)}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
fm.push('---', '');
|
|
123
163
|
return `${fm.join('\n')}\n${String(body).trim()}\n`;
|
|
124
164
|
}
|
|
125
165
|
|
|
166
|
+
// Canonical fallback (spec §0.1 C5): filename hyphen prefix → 'legacy'.
|
|
167
|
+
function deriveGroup(name) {
|
|
168
|
+
const s = String(name || '');
|
|
169
|
+
const dash = s.indexOf('-');
|
|
170
|
+
if (dash > 0) return s.slice(0, dash);
|
|
171
|
+
return 'legacy';
|
|
172
|
+
}
|
|
173
|
+
|
|
126
174
|
// Drop control characters (incl. newlines) from a single-line frontmatter
|
|
127
175
|
// value so an embedded \n can't break out of its key into an injected one.
|
|
128
176
|
function stripControl(v) {
|
|
@@ -157,43 +205,39 @@ export class SkillSynthError extends Error {
|
|
|
157
205
|
// completion with no tools advertised, the agent's own role as the
|
|
158
206
|
// system prompt. The difference is the ASK — a reusable skill in a
|
|
159
207
|
// fixed section layout instead of free-text lessons.
|
|
160
|
-
export async function synthesizeSkill({
|
|
208
|
+
export async function synthesizeSkill({
|
|
209
|
+
agent, task, apiKey, baseUrl, fetchImpl,
|
|
210
|
+
outcome = 'done',
|
|
211
|
+
trainedBy = null,
|
|
212
|
+
trainedOnModel = null,
|
|
213
|
+
trajectoryRef = null,
|
|
214
|
+
confidence = null,
|
|
215
|
+
crossCliTested = null,
|
|
216
|
+
} = {}) {
|
|
161
217
|
if (!agent || !task) throw new SkillSynthError('agent and task are required', 'SKILL_SYNTH_BAD_INPUT');
|
|
218
|
+
if (outcome !== 'done' && outcome !== 'failed' && outcome !== 'abandoned') {
|
|
219
|
+
throw new SkillSynthError(`bad outcome "${outcome}"`, 'SKILL_SYNTH_BAD_OUTCOME');
|
|
220
|
+
}
|
|
162
221
|
|
|
163
|
-
// Redact secrets from the transcript BEFORE it leaves for the model,
|
|
164
|
-
// so a token pasted into the task never reaches the LLM or the file.
|
|
165
222
|
const transcript = redactSecrets(
|
|
166
223
|
(Array.isArray(task.turns) ? task.turns : [])
|
|
167
224
|
.map((t) => {
|
|
168
225
|
const who = t.agent === 'user' ? 'User' : t.agent === 'system' ? 'System' : t.agent;
|
|
169
|
-
// Defang any forged role label inside the (model-controlled) body
|
|
170
|
-
// so a turn can't inject its own [System]/[User] authority line.
|
|
171
226
|
return `[${who}] ${neutralizeRoleLabels(t.text || '')}`;
|
|
172
227
|
})
|
|
173
228
|
.join('\n\n') || '(no turns)'
|
|
174
229
|
);
|
|
175
230
|
|
|
176
|
-
const userMessage =
|
|
177
|
-
|
|
178
|
-
transcript
|
|
179
|
-
`\n\nDistil this into a REUSABLE skill that a future agent could load to handle a similar task faster. ` +
|
|
180
|
-
`Reply in EXACTLY this format and nothing else:\n\n` +
|
|
181
|
-
`name: <short kebab-case skill name>\n` +
|
|
182
|
-
`description: <one line, ≤ 120 chars, describing WHEN this skill applies>\n\n` +
|
|
183
|
-
`## When to Use\n<bullet conditions that signal this skill is relevant>\n\n` +
|
|
184
|
-
`## Procedure\n<numbered, concrete steps — real file paths / commands where known>\n\n` +
|
|
185
|
-
`## Pitfalls\n<gotchas and dead-ends you hit, so next time they're avoided>\n\n` +
|
|
186
|
-
`## Verification\n<how to confirm the task actually succeeded>\n\n` +
|
|
187
|
-
`Be concrete and specific to what happened. If the task was too trivial to be worth a reusable skill, reply with the single word NONE.`;
|
|
231
|
+
const userMessage = outcome === 'failed'
|
|
232
|
+
? buildAntiPatternPrompt(task, transcript)
|
|
233
|
+
: buildSkillPrompt(task, transcript);
|
|
188
234
|
|
|
189
235
|
const text = (await runTextCompletion({
|
|
190
236
|
provider: agent.provider,
|
|
191
237
|
model: agent.model,
|
|
192
238
|
system: agent.role || '',
|
|
193
239
|
userMessage,
|
|
194
|
-
apiKey,
|
|
195
|
-
baseUrl,
|
|
196
|
-
fetchImpl,
|
|
240
|
+
apiKey, baseUrl, fetchImpl,
|
|
197
241
|
})).trim();
|
|
198
242
|
if (!text || /^none\b/i.test(text)) return null;
|
|
199
243
|
|
|
@@ -201,8 +245,51 @@ export async function synthesizeSkill({ agent, task, apiKey, baseUrl, fetchImpl
|
|
|
201
245
|
const description = sanitizeDescription(parsed.description);
|
|
202
246
|
const body = sanitizeSkillBody(parsed.body);
|
|
203
247
|
if (!body.trim()) return null;
|
|
204
|
-
const doc = assembleSkillDoc({
|
|
205
|
-
|
|
248
|
+
const doc = assembleSkillDoc({
|
|
249
|
+
name: parsed.name,
|
|
250
|
+
description,
|
|
251
|
+
createdBy: 'agent',
|
|
252
|
+
sourceTask: task.id,
|
|
253
|
+
body,
|
|
254
|
+
outcome,
|
|
255
|
+
trainedBy,
|
|
256
|
+
trainedOnModel,
|
|
257
|
+
trajectoryRef,
|
|
258
|
+
confidence,
|
|
259
|
+
crossCliTested,
|
|
260
|
+
});
|
|
261
|
+
return { name: parsed.name, description, body, doc, sourceTask: task.id, outcome };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function buildSkillPrompt(task, transcript) {
|
|
265
|
+
return (
|
|
266
|
+
`You just finished task "${task.title || '(untitled)'}" (id ${task.id}). Here is the full transcript:\n\n` +
|
|
267
|
+
transcript +
|
|
268
|
+
`\n\nDistil this into a REUSABLE skill that a future agent could load to handle a similar task faster. ` +
|
|
269
|
+
`Reply in EXACTLY this format and nothing else:\n\n` +
|
|
270
|
+
`name: <short kebab-case skill name>\n` +
|
|
271
|
+
`description: <one line, ≤ 120 chars, describing WHEN this skill applies>\n\n` +
|
|
272
|
+
`## When to Use\n<bullet conditions that signal this skill is relevant>\n\n` +
|
|
273
|
+
`## Procedure\n<numbered, concrete steps — real file paths / commands where known>\n\n` +
|
|
274
|
+
`## Pitfalls\n<gotchas and dead-ends you hit, so next time they're avoided>\n\n` +
|
|
275
|
+
`## Verification\n<how to confirm the task actually succeeded>\n\n` +
|
|
276
|
+
`Be concrete and specific to what happened. If the task was too trivial to be worth a reusable skill, reply with the single word NONE.`
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function buildAntiPatternPrompt(task, transcript) {
|
|
281
|
+
return (
|
|
282
|
+
`Task "${task.title || '(untitled)'}" (id ${task.id}) FAILED. Transcript:\n\n` +
|
|
283
|
+
transcript +
|
|
284
|
+
`\n\nDistil this into an ANTI-PATTERN note that a future agent will read and avoid. ` +
|
|
285
|
+
`Reply in EXACTLY this format and nothing else:\n\n` +
|
|
286
|
+
`name: <short kebab-case anti-pattern name, prefixed with "avoid-">\n` +
|
|
287
|
+
`description: <one line, ≤ 120 chars, describing the failure mode to avoid>\n\n` +
|
|
288
|
+
`## What Failed\n<concrete description of what was attempted and how it broke>\n\n` +
|
|
289
|
+
`## Why\n<root cause, with file paths or error messages where known>\n\n` +
|
|
290
|
+
`## Avoid\n<the rule the next agent should follow instead>\n\n` +
|
|
291
|
+
`Be specific. If the failure was too transient to generalise, reply with the single word NONE.`
|
|
292
|
+
);
|
|
206
293
|
}
|
|
207
294
|
|
|
208
295
|
// Install a synthesised skill without ever clobbering a human-authored
|
|
@@ -228,5 +315,17 @@ export function installSynthesized({ name, description = '', body = '', sourceTa
|
|
|
228
315
|
ts,
|
|
229
316
|
});
|
|
230
317
|
const p = skills.installSkill(finalName, doc, configDir);
|
|
318
|
+
// Phase A: FTS5 mirror (spec §4.4). Group fallback per canonical C5.
|
|
319
|
+
try {
|
|
320
|
+
const { meta, body: skillBody } = parseFrontmatter(doc);
|
|
321
|
+
const group = meta.group
|
|
322
|
+
|| (finalName.includes('-') ? finalName.split('-')[0] : 'legacy');
|
|
323
|
+
_indexSkill({
|
|
324
|
+
skill_name: finalName,
|
|
325
|
+
trained_by: meta.trained_by || createdBy === 'agent' ? 'agent' : 'user',
|
|
326
|
+
group_name: group,
|
|
327
|
+
content: skillBody,
|
|
328
|
+
}, configDir);
|
|
329
|
+
} catch { /* swallow */ }
|
|
231
330
|
return { skill: finalName, path: p, version };
|
|
232
331
|
}
|
package/mas/tool_runner.mjs
CHANGED
|
@@ -2,17 +2,8 @@
|
|
|
2
2
|
// the agent is allowed to use the tool, runs the tool, audits the call,
|
|
3
3
|
// and returns a uniform { ok, result?, error? } shape that the provider
|
|
4
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
5
|
|
|
11
|
-
import * as
|
|
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 skillViewTool from './tools/skill_view.mjs';
|
|
6
|
+
import * as registry from './tools/registry.mjs';
|
|
16
7
|
import * as audit from './audit.mjs';
|
|
17
8
|
|
|
18
9
|
export class ToolError extends Error {
|
|
@@ -23,72 +14,30 @@ export class ToolError extends Error {
|
|
|
23
14
|
}
|
|
24
15
|
}
|
|
25
16
|
|
|
26
|
-
const TOOLS = {
|
|
27
|
-
bash: bashTool,
|
|
28
|
-
read: readTool,
|
|
29
|
-
write: writeTool,
|
|
30
|
-
grep: grepTool,
|
|
31
|
-
skill_view: skillViewTool,
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
const NOT_IMPLEMENTED_TOOLS = ['web_search', 'web_fetch', 'slack_post'];
|
|
35
|
-
|
|
36
17
|
export function listToolSchemas(names) {
|
|
37
18
|
const out = [];
|
|
38
|
-
const wanted = Array.isArray(names) && names.length ? names :
|
|
19
|
+
const wanted = Array.isArray(names) && names.length ? names : registry.listNames();
|
|
39
20
|
for (const name of wanted) {
|
|
40
|
-
const t =
|
|
21
|
+
const t = registry.lookup(name);
|
|
41
22
|
if (!t) continue;
|
|
42
|
-
out.push({ name: t.
|
|
23
|
+
out.push({ name: t.name, description: t.description, parameters: t.parameters });
|
|
43
24
|
}
|
|
44
25
|
return out;
|
|
45
26
|
}
|
|
46
27
|
|
|
47
|
-
export function isImplemented(name) {
|
|
48
|
-
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export function knownTool(name) {
|
|
52
|
-
return TOOLS[name] !== undefined || NOT_IMPLEMENTED_TOOLS.includes(name);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// Run one tool call. The agent record's `tools` field is the whitelist;
|
|
56
|
-
// when the call falls outside it, we throw ToolError('TOOL_DENIED') so
|
|
57
|
-
// the caller can surface a structured error back to the LLM rather than
|
|
58
|
-
// silently dropping the call.
|
|
59
|
-
//
|
|
60
|
-
// Tools that mutate state / run arbitrary code. When an `approve` hook is
|
|
61
|
-
// supplied (e.g. backed by the gateway's remote exec-approval), these are
|
|
62
|
-
// gated on a human decision before they run; read-only tools are not.
|
|
63
|
-
const SENSITIVE_TOOLS = new Set(['bash', 'write']);
|
|
28
|
+
export function isImplemented(name) { return registry.lookup(name) !== null; }
|
|
29
|
+
export function knownTool(name) { return registry.lookup(name) !== null; }
|
|
64
30
|
|
|
65
|
-
// opts.cwd — where bash/read/write/grep root themselves; defaults to
|
|
66
|
-
// process.cwd() so it can be overridden in tests.
|
|
67
|
-
// opts.taskId — when set, every call is appended to the task's audit
|
|
68
|
-
// log. Unit tests can omit it.
|
|
69
|
-
// opts.approve — optional async (call) => { approved, reason }. When
|
|
70
|
-
// present, a sensitive tool call is held until it resolves; a non-approval
|
|
71
|
-
// blocks the tool (returns a structured error instead of executing).
|
|
72
31
|
export async function runTool({ agent, tool, args, taskId, configDir, cwd, approve } = {}) {
|
|
73
32
|
if (!agent || !Array.isArray(agent.tools)) {
|
|
74
33
|
throw new ToolError('agent record with .tools[] is required', 'TOOL_BAD_AGENT');
|
|
75
34
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
35
|
+
const impl = registry.lookup(tool);
|
|
36
|
+
if (!impl) throw new ToolError(`unknown tool "${tool}"`, 'TOOL_UNKNOWN');
|
|
79
37
|
if (!agent.tools.includes(tool)) {
|
|
80
38
|
throw new ToolError(`agent "${agent.name}" is not allowed to call tool "${tool}" (whitelist=[${agent.tools.join(', ')}])`, 'TOOL_DENIED');
|
|
81
39
|
}
|
|
82
|
-
|
|
83
|
-
if (!impl) {
|
|
84
|
-
// Known but not yet implemented (web_search etc.) — Phase 12+x will fill in.
|
|
85
|
-
const result = { ok: false, error: `tool "${tool}" is registered but not implemented yet` };
|
|
86
|
-
audit.append({ taskId, agent: agent.name, tool, args, result, ok: false, configDir });
|
|
87
|
-
return result;
|
|
88
|
-
}
|
|
89
|
-
// Human-in-the-loop gate for sensitive tools. A denial (or an erroring
|
|
90
|
-
// hook — fail closed) blocks execution and is audited like any result.
|
|
91
|
-
if (typeof approve === 'function' && SENSITIVE_TOOLS.has(tool)) {
|
|
40
|
+
if (typeof approve === 'function' && impl.sensitive) {
|
|
92
41
|
let verdict;
|
|
93
42
|
try { verdict = await approve({ tool, args, agent: agent.name }); }
|
|
94
43
|
catch (err) { verdict = { approved: false, reason: `approval error: ${err?.message || err}` }; }
|
|
@@ -100,7 +49,7 @@ export async function runTool({ agent, tool, args, taskId, configDir, cwd, appro
|
|
|
100
49
|
}
|
|
101
50
|
let result;
|
|
102
51
|
try {
|
|
103
|
-
result = await impl.exec(args || {}, { cwd: cwd || process.cwd(), configDir });
|
|
52
|
+
result = await impl.exec(args || {}, { cwd: cwd || process.cwd(), configDir, taskId, agent });
|
|
104
53
|
} catch (err) {
|
|
105
54
|
result = { ok: false, error: `${tool} threw: ${err?.message || err}` };
|
|
106
55
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// browser — playwright-driven browser_navigate / click / back / screenshot.
|
|
2
|
+
// Playwright is already a devDep (playwright.config.ts); we lazy-import and
|
|
3
|
+
// return a structured error if it is missing at runtime. A persistent
|
|
4
|
+
// headless Chromium context is reused across calls in the same process.
|
|
5
|
+
|
|
6
|
+
let _backend = null;
|
|
7
|
+
export function __setBrowserBackend(b) { _backend = b; }
|
|
8
|
+
|
|
9
|
+
let _ctx = null;
|
|
10
|
+
async function ensureCtx() {
|
|
11
|
+
if (_backend) return _backend;
|
|
12
|
+
if (_ctx) return _ctx;
|
|
13
|
+
let pw;
|
|
14
|
+
try { pw = await import('playwright'); }
|
|
15
|
+
catch { throw new Error('browser: playwright not installed (npm i playwright)'); }
|
|
16
|
+
const browser = await pw.chromium.launch({ headless: true });
|
|
17
|
+
const context = await browser.newContext();
|
|
18
|
+
const page = await context.newPage();
|
|
19
|
+
_ctx = {
|
|
20
|
+
navigate: async (url) => { await page.goto(url, { waitUntil: 'domcontentloaded' }); return { url: page.url(), title: await page.title() }; },
|
|
21
|
+
click: async (sel) => { await page.click(sel); return { clicked: sel }; },
|
|
22
|
+
back: async () => { await page.goBack(); return { url: page.url() }; },
|
|
23
|
+
screenshot: async (path) => { await page.screenshot({ path, fullPage: true }); return { path }; },
|
|
24
|
+
};
|
|
25
|
+
return _ctx;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function safeHttp(url) {
|
|
29
|
+
try {
|
|
30
|
+
const u = new URL(url);
|
|
31
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') return false;
|
|
32
|
+
return true;
|
|
33
|
+
} catch { return false; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const browser_navigate = {
|
|
37
|
+
name: 'browser_navigate', category: 'browser', sensitive: true,
|
|
38
|
+
description: 'Navigate to an http(s) URL in a headless Chromium session.',
|
|
39
|
+
parameters: { type: 'object', properties: { url: { type: 'string' } }, required: ['url'] },
|
|
40
|
+
async exec(args) {
|
|
41
|
+
if (!safeHttp(args.url)) return { ok: false, error: 'browser_navigate: http(s) only' };
|
|
42
|
+
try { const ctx = await ensureCtx(); return { ok: true, ...(await ctx.navigate(args.url)) }; }
|
|
43
|
+
catch (e) { return { ok: false, error: `browser_navigate: ${e.message}` }; }
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const browser_click = {
|
|
48
|
+
name: 'browser_click', category: 'browser', sensitive: true,
|
|
49
|
+
description: 'Click an element by CSS selector.',
|
|
50
|
+
parameters: { type: 'object', properties: { selector: { type: 'string' } }, required: ['selector'] },
|
|
51
|
+
async exec(args) {
|
|
52
|
+
try { const ctx = await ensureCtx(); return { ok: true, ...(await ctx.click(args.selector)) }; }
|
|
53
|
+
catch (e) { return { ok: false, error: `browser_click: ${e.message}` }; }
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const browser_back = {
|
|
58
|
+
name: 'browser_back', category: 'browser', sensitive: true,
|
|
59
|
+
description: 'Navigate back in browser history.',
|
|
60
|
+
parameters: { type: 'object', properties: {} },
|
|
61
|
+
async exec() {
|
|
62
|
+
try { const ctx = await ensureCtx(); return { ok: true, ...(await ctx.back()) }; }
|
|
63
|
+
catch (e) { return { ok: false, error: `browser_back: ${e.message}` }; }
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const browser_screenshot = {
|
|
68
|
+
name: 'browser_screenshot', category: 'browser', sensitive: true,
|
|
69
|
+
description: 'Capture a full-page PNG to <path>.',
|
|
70
|
+
parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
|
|
71
|
+
async exec(args) {
|
|
72
|
+
try { const ctx = await ensureCtx(); return { ok: true, ...(await ctx.screenshot(args.path)) }; }
|
|
73
|
+
catch (e) { return { ok: false, error: `browser_screenshot: ${e.message}` }; }
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export const TOOLS = [browser_navigate, browser_click, browser_back, browser_screenshot];
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// clarify — surface a question to the human user mid-turn. The actual
|
|
2
|
+
// prompting mechanism is host-dependent (REPL prompt, Slack DM, gateway
|
|
3
|
+
// SSE), so the runtime hooks an asker via __setAsker. Returns the user's
|
|
4
|
+
// reply as a plain string. Pattern lifted from Hermes (spec §7 sub-12).
|
|
5
|
+
|
|
6
|
+
let _asker = null;
|
|
7
|
+
export function __setAsker(fn) { _asker = fn; }
|
|
8
|
+
|
|
9
|
+
export const TOOL = {
|
|
10
|
+
name: 'clarify',
|
|
11
|
+
category: 'agents',
|
|
12
|
+
sensitive: false,
|
|
13
|
+
description: 'Ask the user a clarifying question and wait for the reply.',
|
|
14
|
+
parameters: {
|
|
15
|
+
type: 'object',
|
|
16
|
+
properties: { question: { type: 'string' }, choices: { type: 'array', items: { type: 'string' } } },
|
|
17
|
+
required: ['question'],
|
|
18
|
+
},
|
|
19
|
+
async exec(args, ctx) {
|
|
20
|
+
if (!args?.question) return { ok: false, error: 'clarify: question required' };
|
|
21
|
+
if (typeof _asker === 'function') {
|
|
22
|
+
try {
|
|
23
|
+
const answer = await _asker({ question: args.question, choices: args.choices });
|
|
24
|
+
return { ok: true, answer };
|
|
25
|
+
} catch (e) { return { ok: false, error: `clarify: ${e.message}` }; }
|
|
26
|
+
}
|
|
27
|
+
if (ctx?.isTTY) {
|
|
28
|
+
const readline = await import('node:readline/promises');
|
|
29
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
30
|
+
const answer = await rl.question(`[clarify] ${args.question} > `);
|
|
31
|
+
rl.close();
|
|
32
|
+
return { ok: true, answer };
|
|
33
|
+
}
|
|
34
|
+
return { ok: false, error: 'clarify: no asker bound and not running in a TTY' };
|
|
35
|
+
},
|
|
36
|
+
};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// coding — sandboxed code runners (python_exec, node_exec), data tools
|
|
2
|
+
// (sql_query stub, http_request that reuses web_fetch SSRF policy),
|
|
3
|
+
// and a pure helper (regex_match).
|
|
4
|
+
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { TOOLS as webTools } from './web.mjs';
|
|
10
|
+
|
|
11
|
+
function runProc(cmd, args, opts = {}) {
|
|
12
|
+
return new Promise(resolve => {
|
|
13
|
+
let p;
|
|
14
|
+
try { p = spawn(cmd, args, { cwd: opts.cwd, env: opts.env || process.env }); }
|
|
15
|
+
catch (e) { return resolve({ ok: false, error: e.message }); }
|
|
16
|
+
let out = '', err = '';
|
|
17
|
+
const timeout = setTimeout(() => { try { p.kill('SIGKILL'); } catch {} }, opts.timeoutMs || 30_000);
|
|
18
|
+
p.on('error', e => { clearTimeout(timeout); resolve({ ok: false, error: e.message }); });
|
|
19
|
+
p.stdout?.on('data', d => out += d.toString());
|
|
20
|
+
p.stderr?.on('data', d => err += d.toString());
|
|
21
|
+
p.on('close', code => { clearTimeout(timeout); resolve({ ok: code === 0, stdout: out, stderr: err, exitCode: code }); });
|
|
22
|
+
if (opts.stdin != null) { p.stdin.write(opts.stdin); p.stdin.end(); }
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const python_exec = {
|
|
27
|
+
name: 'python_exec', category: 'coding', sensitive: true,
|
|
28
|
+
description: 'Run a Python snippet in a sandboxed subprocess. 30s timeout.',
|
|
29
|
+
parameters: {
|
|
30
|
+
type: 'object',
|
|
31
|
+
properties: { code: { type: 'string' }, timeoutMs: { type: 'number' } },
|
|
32
|
+
required: ['code'],
|
|
33
|
+
},
|
|
34
|
+
async exec(args, ctx) {
|
|
35
|
+
const py = ctx?.python || process.env.LAZYCLAW_PYTHON || 'python3';
|
|
36
|
+
return runProc(py, ['-c', args.code], { cwd: ctx?.cwd, timeoutMs: args.timeoutMs });
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const node_exec = {
|
|
41
|
+
name: 'node_exec', category: 'coding', sensitive: true,
|
|
42
|
+
description: 'Run a Node.js snippet in a sandboxed subprocess. 30s timeout.',
|
|
43
|
+
parameters: {
|
|
44
|
+
type: 'object',
|
|
45
|
+
properties: { code: { type: 'string' }, timeoutMs: { type: 'number' } },
|
|
46
|
+
required: ['code'],
|
|
47
|
+
},
|
|
48
|
+
async exec(args, ctx) {
|
|
49
|
+
const node = ctx?.node || process.execPath;
|
|
50
|
+
return runProc(node, ['-e', args.code], { cwd: ctx?.cwd, timeoutMs: args.timeoutMs });
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const sql_query = {
|
|
55
|
+
name: 'sql_query', category: 'coding', sensitive: true,
|
|
56
|
+
description: 'Run a read-only SQL query against the agent\'s bound database. Returns rows.',
|
|
57
|
+
parameters: {
|
|
58
|
+
type: 'object',
|
|
59
|
+
properties: { sql: { type: 'string' }, params: { type: 'array' } },
|
|
60
|
+
required: ['sql'],
|
|
61
|
+
},
|
|
62
|
+
async exec(args, ctx) {
|
|
63
|
+
const db = ctx?.db || null;
|
|
64
|
+
if (!db) return { ok: false, error: 'sql_query: no database bound to agent context' };
|
|
65
|
+
try {
|
|
66
|
+
const stmt = db.prepare(args.sql);
|
|
67
|
+
const rows = stmt.all(...(args.params || []));
|
|
68
|
+
return { ok: true, rows };
|
|
69
|
+
} catch (e) { return { ok: false, error: `sql_query: ${e.message}` }; }
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const http_request = {
|
|
74
|
+
name: 'http_request', category: 'coding', sensitive: true,
|
|
75
|
+
description: 'Generic HTTP client. Reuses web_fetch SSRF policy.',
|
|
76
|
+
parameters: {
|
|
77
|
+
type: 'object',
|
|
78
|
+
properties: {
|
|
79
|
+
url: { type: 'string' }, method: { type: 'string' },
|
|
80
|
+
headers: { type: 'object' }, body: { type: 'string' },
|
|
81
|
+
},
|
|
82
|
+
required: ['url'],
|
|
83
|
+
},
|
|
84
|
+
async exec(args, ctx) {
|
|
85
|
+
const wf = webTools.find(t => t.name === 'web_fetch');
|
|
86
|
+
return wf.exec(args, ctx);
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const regex_match = {
|
|
91
|
+
name: 'regex_match', category: 'coding', sensitive: false,
|
|
92
|
+
description: 'Run a regex over a string and return the matches.',
|
|
93
|
+
parameters: {
|
|
94
|
+
type: 'object',
|
|
95
|
+
properties: { pattern: { type: 'string' }, flags: { type: 'string' }, text: { type: 'string' } },
|
|
96
|
+
required: ['pattern', 'text'],
|
|
97
|
+
},
|
|
98
|
+
async exec(args) {
|
|
99
|
+
try {
|
|
100
|
+
const re = new RegExp(args.pattern, args.flags || '');
|
|
101
|
+
const matches = args.flags?.includes('g')
|
|
102
|
+
? [...args.text.matchAll(re)].map(m => m[0])
|
|
103
|
+
: (args.text.match(re) || []);
|
|
104
|
+
return { ok: true, matches };
|
|
105
|
+
} catch (e) { return { ok: false, error: `regex_match: ${e.message}` }; }
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export const TOOLS = [python_exec, node_exec, sql_query, http_request, regex_match];
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// delegation — task_spawn (named agent), delegate (worker provider).
|
|
2
|
+
// Both lazy-import providers/orchestrator.mjs / mas/agent_turn.mjs to
|
|
3
|
+
// avoid pulling those into every process that imports the registry.
|
|
4
|
+
|
|
5
|
+
let _dispatcher = null;
|
|
6
|
+
export function __setDispatcher(fn) { _dispatcher = fn; }
|
|
7
|
+
|
|
8
|
+
async function dispatchDelegate(job) {
|
|
9
|
+
if (_dispatcher) return _dispatcher(job);
|
|
10
|
+
const orch = await import('../../providers/orchestrator.mjs').catch(() => null);
|
|
11
|
+
if (!orch || typeof orch.dispatchWorker !== 'function') {
|
|
12
|
+
return { ok: false, error: 'delegate: orchestrator.dispatchWorker unavailable' };
|
|
13
|
+
}
|
|
14
|
+
return orch.dispatchWorker(job);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function dispatchSpawn(job) {
|
|
18
|
+
const at = await import('../agent_turn.mjs').catch(() => null);
|
|
19
|
+
if (!at || typeof at.runAgentTurn !== 'function') {
|
|
20
|
+
return { ok: false, error: 'task_spawn: agent_turn.runAgentTurn unavailable' };
|
|
21
|
+
}
|
|
22
|
+
return at.runAgentTurn(job);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const task_spawn = {
|
|
26
|
+
name: 'task_spawn', category: 'agents', sensitive: true,
|
|
27
|
+
description: 'Spawn an agent by name with a prompt; returns the final answer.',
|
|
28
|
+
parameters: {
|
|
29
|
+
type: 'object',
|
|
30
|
+
properties: { agent: { type: 'string' }, prompt: { type: 'string' } },
|
|
31
|
+
required: ['agent', 'prompt'],
|
|
32
|
+
},
|
|
33
|
+
async exec(args) {
|
|
34
|
+
if (!args?.agent || !args?.prompt) return { ok: false, error: 'task_spawn: agent + prompt required' };
|
|
35
|
+
return dispatchSpawn({ agent: args.agent, prompt: args.prompt });
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const delegate = {
|
|
40
|
+
name: 'delegate', category: 'agents', sensitive: true,
|
|
41
|
+
description: 'Dispatch a subtask to a worker provider (claude-cli, codex-cli, gemini-cli, anthropic, openai, gemini, ollama).',
|
|
42
|
+
parameters: {
|
|
43
|
+
type: 'object',
|
|
44
|
+
properties: { worker: { type: 'string' }, prompt: { type: 'string' }, model: { type: 'string' } },
|
|
45
|
+
required: ['worker', 'prompt'],
|
|
46
|
+
},
|
|
47
|
+
async exec(args) {
|
|
48
|
+
if (!args?.worker || !args?.prompt) return { ok: false, error: 'delegate: worker + prompt required' };
|
|
49
|
+
return dispatchDelegate({ worker: args.worker, prompt: args.prompt, model: args.model });
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const TOOLS = [task_spawn, delegate];
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// edit — find/replace exactly one occurrence of `old` with `new` inside a file
|
|
2
|
+
// rooted at the agent cwd. Refuses when `old` is missing or not unique so the
|
|
3
|
+
// LLM cannot silently overwrite the wrong span (spec §7 sub-bullet 1).
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
export const TOOL = {
|
|
9
|
+
name: 'edit',
|
|
10
|
+
category: 'fs',
|
|
11
|
+
sensitive: true,
|
|
12
|
+
description: 'Replace exactly one occurrence of `old` with `new` inside a workspace file. Fails if `old` is missing or appears more than once.',
|
|
13
|
+
parameters: {
|
|
14
|
+
type: 'object',
|
|
15
|
+
properties: {
|
|
16
|
+
path: { type: 'string', description: 'File path relative to cwd.' },
|
|
17
|
+
old: { type: 'string', description: 'Exact substring to replace.' },
|
|
18
|
+
new: { type: 'string', description: 'Replacement text.' },
|
|
19
|
+
},
|
|
20
|
+
required: ['path', 'old', 'new'],
|
|
21
|
+
},
|
|
22
|
+
async exec(args, { cwd = process.cwd() } = {}) {
|
|
23
|
+
if (!args || typeof args.path !== 'string') return { ok: false, error: 'edit: path required' };
|
|
24
|
+
if (typeof args.old !== 'string' || typeof args.new !== 'string') return { ok: false, error: 'edit: old/new strings required' };
|
|
25
|
+
const abs = path.resolve(cwd, args.path);
|
|
26
|
+
if (!abs.startsWith(path.resolve(cwd))) return { ok: false, error: 'edit: path escapes workspace' };
|
|
27
|
+
let src;
|
|
28
|
+
try { src = fs.readFileSync(abs, 'utf8'); } catch (e) { return { ok: false, error: `edit: ${e.message}` }; }
|
|
29
|
+
const idx = src.indexOf(args.old);
|
|
30
|
+
if (idx === -1) return { ok: false, error: `edit: \`old\` not found in ${args.path}` };
|
|
31
|
+
if (src.indexOf(args.old, idx + 1) !== -1) return { ok: false, error: `edit: \`old\` not unique in ${args.path}` };
|
|
32
|
+
const next = src.slice(0, idx) + args.new + src.slice(idx + args.old.length);
|
|
33
|
+
fs.writeFileSync(abs, next);
|
|
34
|
+
return { ok: true, path: args.path, bytesWritten: Buffer.byteLength(next) };
|
|
35
|
+
},
|
|
36
|
+
};
|