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
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Read tool — reads a file (UTF-8) from the agent's workspace.
|
|
2
|
+
//
|
|
3
|
+
// Paths are resolved relative to the workspace cwd. Absolute paths are
|
|
4
|
+
// allowed (the user opted into "lazyclaw 모든 권한"). Use the bash tool
|
|
5
|
+
// to read binary blobs.
|
|
6
|
+
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
10
|
+
export const NAME = 'read';
|
|
11
|
+
export const DESCRIPTION = 'Read a file from disk (UTF-8). Returns the file contents or an error.';
|
|
12
|
+
export const PARAMETERS = {
|
|
13
|
+
type: 'object',
|
|
14
|
+
properties: {
|
|
15
|
+
path: { type: 'string', description: 'Relative or absolute path to the file.' },
|
|
16
|
+
maxBytes: { type: 'number', description: 'Optional cap; default 500 KB. Larger files are truncated.' },
|
|
17
|
+
},
|
|
18
|
+
required: ['path'],
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const DEFAULT_MAX_BYTES = 500_000;
|
|
22
|
+
|
|
23
|
+
export async function exec(args, { cwd = process.cwd() } = {}) {
|
|
24
|
+
if (!args || typeof args.path !== 'string' || !args.path) {
|
|
25
|
+
return { ok: false, error: 'read: path is required' };
|
|
26
|
+
}
|
|
27
|
+
const max = Math.max(1024, Math.min(Number.isFinite(+args.maxBytes) ? +args.maxBytes : DEFAULT_MAX_BYTES, 5_000_000));
|
|
28
|
+
const resolved = path.isAbsolute(args.path) ? args.path : path.resolve(cwd, args.path);
|
|
29
|
+
try {
|
|
30
|
+
const stat = fs.statSync(resolved);
|
|
31
|
+
if (!stat.isFile()) return { ok: false, error: `read: not a regular file: ${resolved}` };
|
|
32
|
+
const buf = fs.readFileSync(resolved);
|
|
33
|
+
const truncated = buf.length > max;
|
|
34
|
+
const slice = truncated ? buf.subarray(0, max) : buf;
|
|
35
|
+
return {
|
|
36
|
+
ok: true,
|
|
37
|
+
path: resolved,
|
|
38
|
+
bytes: stat.size,
|
|
39
|
+
content: slice.toString('utf8'),
|
|
40
|
+
truncated,
|
|
41
|
+
};
|
|
42
|
+
} catch (err) {
|
|
43
|
+
return { ok: false, error: `read: ${err?.message || err}` };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Write tool — overwrites or creates a file with the given content.
|
|
2
|
+
//
|
|
3
|
+
// Creates parent directories as needed. Atomic-ish: writes to <path>.tmp
|
|
4
|
+
// then renames over the target so a crash mid-write doesn't leave a
|
|
5
|
+
// partially-written file.
|
|
6
|
+
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
10
|
+
export const NAME = 'write';
|
|
11
|
+
export const DESCRIPTION = 'Create or overwrite a file with the given UTF-8 content. Returns {bytesWritten, path}.';
|
|
12
|
+
export const PARAMETERS = {
|
|
13
|
+
type: 'object',
|
|
14
|
+
properties: {
|
|
15
|
+
path: { type: 'string', description: 'Relative or absolute path to write.' },
|
|
16
|
+
content: { type: 'string', description: 'The new file contents (UTF-8).' },
|
|
17
|
+
},
|
|
18
|
+
required: ['path', 'content'],
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export async function exec(args, { cwd = process.cwd() } = {}) {
|
|
22
|
+
if (!args || typeof args.path !== 'string' || !args.path) {
|
|
23
|
+
return { ok: false, error: 'write: path is required' };
|
|
24
|
+
}
|
|
25
|
+
if (typeof args.content !== 'string') {
|
|
26
|
+
return { ok: false, error: 'write: content must be a string' };
|
|
27
|
+
}
|
|
28
|
+
const resolved = path.isAbsolute(args.path) ? args.path : path.resolve(cwd, args.path);
|
|
29
|
+
try {
|
|
30
|
+
fs.mkdirSync(path.dirname(resolved), { recursive: true });
|
|
31
|
+
const tmp = resolved + '.tmp';
|
|
32
|
+
fs.writeFileSync(tmp, args.content);
|
|
33
|
+
fs.renameSync(tmp, resolved);
|
|
34
|
+
return {
|
|
35
|
+
ok: true,
|
|
36
|
+
path: resolved,
|
|
37
|
+
bytesWritten: Buffer.byteLength(args.content, 'utf8'),
|
|
38
|
+
};
|
|
39
|
+
} catch (err) {
|
|
40
|
+
return { ok: false, error: `write: ${err?.message || err}` };
|
|
41
|
+
}
|
|
42
|
+
}
|
package/memory.mjs
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// Layered memory for LazyClaw.
|
|
2
|
+
//
|
|
3
|
+
// Three storage shapes under <configDir>/memory/:
|
|
4
|
+
// core.md — single curated file. User-edited or LLM-edited.
|
|
5
|
+
// Long-lived; survives `dream()`. Mounted into
|
|
6
|
+
// every goal tick + every `/loop --use-memory`.
|
|
7
|
+
// recent.jsonl — append-only log of {sessionId, role, content,
|
|
8
|
+
// ts}, one line per call to sessions.appendTurn.
|
|
9
|
+
// Capped softly at RECENT_CAP entries; truncated
|
|
10
|
+
// hard to RECENT_KEEP_AFTER_DREAM after dream().
|
|
11
|
+
// episodic/<topic>.md — one file per topic produced by dream().
|
|
12
|
+
// Filenames are kebab-case slugs derived from
|
|
13
|
+
// the topic strings the provider returned.
|
|
14
|
+
//
|
|
15
|
+
// `appendRecent` is the only entry point sessions.appendTurn calls. It
|
|
16
|
+
// swallows every error — memory must not break the session-write path.
|
|
17
|
+
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import os from 'node:os';
|
|
21
|
+
|
|
22
|
+
const MEMORY_DIRNAME = 'memory';
|
|
23
|
+
const RECENT_CAP = 200;
|
|
24
|
+
const RECENT_KEEP_AFTER_DREAM = 50;
|
|
25
|
+
|
|
26
|
+
export function defaultConfigDir() {
|
|
27
|
+
return process.env.LAZYCLAW_CONFIG_DIR || path.join(os.homedir(), '.lazyclaw');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function memoryDir(configDir = defaultConfigDir()) { return path.join(configDir, MEMORY_DIRNAME); }
|
|
31
|
+
export function corePath(configDir = defaultConfigDir()) { return path.join(memoryDir(configDir), 'core.md'); }
|
|
32
|
+
export function recentPath(configDir = defaultConfigDir()) { return path.join(memoryDir(configDir), 'recent.jsonl'); }
|
|
33
|
+
export function episodicDir(configDir = defaultConfigDir()) { return path.join(memoryDir(configDir), 'episodic'); }
|
|
34
|
+
|
|
35
|
+
export function loadCore(configDir = defaultConfigDir()) {
|
|
36
|
+
const p = corePath(configDir);
|
|
37
|
+
if (!fs.existsSync(p)) return '';
|
|
38
|
+
return fs.readFileSync(p, 'utf8');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function setCore(text, configDir = defaultConfigDir()) {
|
|
42
|
+
fs.mkdirSync(memoryDir(configDir), { recursive: true });
|
|
43
|
+
fs.writeFileSync(corePath(configDir), String(text || ''));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function loadRecent(n = 20, configDir = defaultConfigDir()) {
|
|
47
|
+
const p = recentPath(configDir);
|
|
48
|
+
if (!fs.existsSync(p)) return [];
|
|
49
|
+
const lines = fs.readFileSync(p, 'utf8').split('\n').filter(Boolean);
|
|
50
|
+
const slice = n > 0 ? lines.slice(-n) : lines;
|
|
51
|
+
const out = [];
|
|
52
|
+
for (const l of slice) {
|
|
53
|
+
try { out.push(JSON.parse(l)); } catch { /* skip malformed */ }
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function listEpisodic(configDir = defaultConfigDir()) {
|
|
59
|
+
const dir = episodicDir(configDir);
|
|
60
|
+
if (!fs.existsSync(dir)) return [];
|
|
61
|
+
return fs.readdirSync(dir).filter(f => f.endsWith('.md')).map(f => f.slice(0, -3));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function loadEpisodic(topic, configDir = defaultConfigDir()) {
|
|
65
|
+
const p = path.join(episodicDir(configDir), `${topic}.md`);
|
|
66
|
+
if (!fs.existsSync(p)) return '';
|
|
67
|
+
return fs.readFileSync(p, 'utf8');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Sync, swallowed-failure write-through called from sessions.appendTurn.
|
|
71
|
+
export function appendRecent(sessionId, role, content, configDir = defaultConfigDir()) {
|
|
72
|
+
try {
|
|
73
|
+
fs.mkdirSync(memoryDir(configDir), { recursive: true });
|
|
74
|
+
const line = JSON.stringify({
|
|
75
|
+
sessionId,
|
|
76
|
+
role,
|
|
77
|
+
content: String(content ?? ''),
|
|
78
|
+
ts: Date.now(),
|
|
79
|
+
}) + '\n';
|
|
80
|
+
fs.appendFileSync(recentPath(configDir), line);
|
|
81
|
+
// Cheap stat-based check to avoid read-rewrite on every append.
|
|
82
|
+
const st = fs.statSync(recentPath(configDir));
|
|
83
|
+
if (st.size > 1_000_000) {
|
|
84
|
+
const lines = fs.readFileSync(recentPath(configDir), 'utf8').split('\n').filter(Boolean);
|
|
85
|
+
if (lines.length > RECENT_CAP) {
|
|
86
|
+
fs.writeFileSync(recentPath(configDir), lines.slice(-RECENT_CAP).join('\n') + '\n');
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
} catch { /* swallow — memory failure must not break session writes */ }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// `dream(sessionId)` consolidates recent.jsonl into per-topic episodic
|
|
93
|
+
// files using the active provider, then truncates recent.jsonl to the
|
|
94
|
+
// last RECENT_KEEP_AFTER_DREAM entries. Returns { topics: [slug,...] }.
|
|
95
|
+
//
|
|
96
|
+
// The mock provider doesn't return JSON, so we accept any string and
|
|
97
|
+
// fall back to a single "recent-<date>" topic containing the raw reply.
|
|
98
|
+
// Real providers (Anthropic / OpenAI) typically obey the JSON instruction.
|
|
99
|
+
export async function dream(sessionId, { provider, model, apiKey } = {}, configDir = defaultConfigDir()) {
|
|
100
|
+
if (!provider) throw new Error('dream() requires a provider');
|
|
101
|
+
const turns = loadRecent(1000, configDir);
|
|
102
|
+
if (turns.length === 0) return { topics: [] };
|
|
103
|
+
|
|
104
|
+
const prompt = [
|
|
105
|
+
'Below are recent chat turns. Group them under topics and summarise each topic in one paragraph.',
|
|
106
|
+
'Dedupe aggressively. If two turns cover the same topic, merge.',
|
|
107
|
+
'Output strict JSON of shape: {"topics": [{"topic": "kebab-case-slug", "summary": "..."}]}',
|
|
108
|
+
'',
|
|
109
|
+
'Turns:',
|
|
110
|
+
...turns.map(t => `- [${t.role}@${t.sessionId}]: ${String(t.content).slice(0, 500)}`),
|
|
111
|
+
].join('\n');
|
|
112
|
+
|
|
113
|
+
let raw = '';
|
|
114
|
+
for await (const chunk of provider.sendMessage([{ role: 'user', content: prompt }], { apiKey, model })) {
|
|
115
|
+
raw += chunk;
|
|
116
|
+
}
|
|
117
|
+
let parsed = null;
|
|
118
|
+
try {
|
|
119
|
+
const m = raw.match(/\{[\s\S]*\}/);
|
|
120
|
+
parsed = m ? JSON.parse(m[0]) : null;
|
|
121
|
+
} catch { parsed = null; }
|
|
122
|
+
const topics = (parsed?.topics && Array.isArray(parsed.topics) && parsed.topics.length)
|
|
123
|
+
? parsed.topics
|
|
124
|
+
: [{ topic: 'recent-' + new Date().toISOString().slice(0, 10), summary: raw.slice(0, 4000) || '(no content)' }];
|
|
125
|
+
|
|
126
|
+
fs.mkdirSync(episodicDir(configDir), { recursive: true });
|
|
127
|
+
const written = [];
|
|
128
|
+
for (const t of topics) {
|
|
129
|
+
if (!t || !t.topic) continue;
|
|
130
|
+
const slug = String(t.topic).toLowerCase().replace(/[^a-z0-9_.-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80) || 'untitled';
|
|
131
|
+
const p = path.join(episodicDir(configDir), `${slug}.md`);
|
|
132
|
+
fs.writeFileSync(p, `# ${slug}\n\n${t.summary || ''}\n`);
|
|
133
|
+
written.push(slug);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Hard truncate recent.jsonl after a successful dream.
|
|
137
|
+
const rp = recentPath(configDir);
|
|
138
|
+
if (fs.existsSync(rp)) {
|
|
139
|
+
const lines = fs.readFileSync(rp, 'utf8').split('\n').filter(Boolean);
|
|
140
|
+
if (lines.length > RECENT_KEEP_AFTER_DREAM) {
|
|
141
|
+
fs.writeFileSync(rp, lines.slice(-RECENT_KEEP_AFTER_DREAM).join('\n') + '\n');
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return { topics: written };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Returns a single string suitable for prepending to a tick / loop
|
|
148
|
+
// prompt. Core memory always comes first; episodic files are included
|
|
149
|
+
// only when their topic slug substring-matches a word ≥3 chars from
|
|
150
|
+
// the goal name + description.
|
|
151
|
+
export function getMemoryForGoal(name, description = '', configDir = defaultConfigDir()) {
|
|
152
|
+
const parts = [];
|
|
153
|
+
const core = loadCore(configDir);
|
|
154
|
+
if (core.trim()) parts.push(`## Core memory\n${core}`);
|
|
155
|
+
const keywords = String(name + ' ' + description).toLowerCase()
|
|
156
|
+
.split(/[^a-z0-9]+/).filter(w => w.length >= 3);
|
|
157
|
+
if (keywords.length) {
|
|
158
|
+
const topics = listEpisodic(configDir);
|
|
159
|
+
for (const t of topics) {
|
|
160
|
+
const tl = t.toLowerCase();
|
|
161
|
+
if (keywords.some(k => tl.includes(k))) {
|
|
162
|
+
const body = loadEpisodic(t, configDir);
|
|
163
|
+
if (body.trim()) parts.push(`## Episodic: ${t}\n${body}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return parts.join('\n\n');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Recall helper used by `/loop --recall "<query>"`. Tokenises the query,
|
|
171
|
+
// scores recent and episodic entries by overlap, returns the top-N
|
|
172
|
+
// matches as a single concatenated string. Cheap, no external index.
|
|
173
|
+
export function recall(query, { topN = 3 } = {}, configDir = defaultConfigDir()) {
|
|
174
|
+
const tokens = String(query || '').toLowerCase().split(/[^a-z0-9]+/).filter(w => w.length >= 3);
|
|
175
|
+
if (!tokens.length) return '';
|
|
176
|
+
const candidates = [];
|
|
177
|
+
for (const slug of listEpisodic(configDir)) {
|
|
178
|
+
const body = loadEpisodic(slug, configDir);
|
|
179
|
+
candidates.push({ source: `episodic:${slug}`, body });
|
|
180
|
+
}
|
|
181
|
+
for (const turn of loadRecent(200, configDir)) {
|
|
182
|
+
candidates.push({ source: `recent:${turn.sessionId || '?'}`, body: String(turn.content || '') });
|
|
183
|
+
}
|
|
184
|
+
const scored = candidates.map((c) => {
|
|
185
|
+
const lower = c.body.toLowerCase();
|
|
186
|
+
let score = 0;
|
|
187
|
+
for (const t of tokens) {
|
|
188
|
+
if (lower.includes(t)) score += 1;
|
|
189
|
+
}
|
|
190
|
+
return { ...c, score };
|
|
191
|
+
}).filter(c => c.score > 0).sort((a, b) => b.score - a.score).slice(0, topN);
|
|
192
|
+
return scored.map(c => `## ${c.source} (score ${c.score})\n${c.body}`).join('\n\n');
|
|
193
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lazyclaw",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Lazy, elegant terminal CLI for chatting with Claude / OpenAI / Gemini / Ollama
|
|
3
|
+
"version": "4.2.1",
|
|
4
|
+
"description": "Lazy, elegant terminal CLI for chatting with Claude / OpenAI / Gemini / Ollama, orchestrating multi-step LLM workflows, and running multi-agent Slack teams with cross-task memory. Banner-on-launch, slash-command ghost autocomplete, persistent sessions, local HTTP gateway.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
|
7
7
|
"anthropic",
|
|
@@ -15,22 +15,25 @@
|
|
|
15
15
|
"workflow",
|
|
16
16
|
"agent"
|
|
17
17
|
],
|
|
18
|
-
"homepage": "https://github.com/cmblir/
|
|
18
|
+
"homepage": "https://github.com/cmblir/lazyclaw#readme",
|
|
19
19
|
"bugs": {
|
|
20
|
-
"url": "https://github.com/cmblir/
|
|
20
|
+
"url": "https://github.com/cmblir/lazyclaw/issues"
|
|
21
21
|
},
|
|
22
22
|
"license": "MIT",
|
|
23
23
|
"author": "cmblir",
|
|
24
24
|
"repository": {
|
|
25
25
|
"type": "git",
|
|
26
|
-
"url": "git+https://github.com/cmblir/
|
|
27
|
-
"directory": "src/lazyclaw"
|
|
26
|
+
"url": "git+https://github.com/cmblir/lazyclaw.git"
|
|
28
27
|
},
|
|
29
28
|
"type": "module",
|
|
30
29
|
"main": "cli.mjs",
|
|
31
30
|
"bin": {
|
|
32
31
|
"lazyclaw": "cli.mjs"
|
|
33
32
|
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"test": "playwright test",
|
|
35
|
+
"test:bench": "node scripts/bench-providers.mjs"
|
|
36
|
+
},
|
|
34
37
|
"files": [
|
|
35
38
|
"cli.mjs",
|
|
36
39
|
"daemon.mjs",
|
|
@@ -46,15 +49,32 @@
|
|
|
46
49
|
"sandbox.mjs",
|
|
47
50
|
"skills_install.mjs",
|
|
48
51
|
"cron.mjs",
|
|
52
|
+
"loop-engine.mjs",
|
|
53
|
+
"loops.mjs",
|
|
54
|
+
"goals.mjs",
|
|
55
|
+
"memory.mjs",
|
|
56
|
+
"agents.mjs",
|
|
57
|
+
"teams.mjs",
|
|
58
|
+
"tasks.mjs",
|
|
59
|
+
"channels/",
|
|
49
60
|
"providers/",
|
|
50
61
|
"workflow/",
|
|
51
62
|
"web/",
|
|
63
|
+
"mas/",
|
|
64
|
+
"docs/multi-agent.md",
|
|
65
|
+
"scripts/loop-worker.mjs",
|
|
52
66
|
"README.md",
|
|
53
67
|
"LICENSE"
|
|
54
68
|
],
|
|
55
69
|
"engines": {
|
|
56
70
|
"node": ">=18"
|
|
57
71
|
},
|
|
72
|
+
"devDependencies": {
|
|
73
|
+
"@playwright/test": "^1.59.1",
|
|
74
|
+
"@types/node": "^25.6.0",
|
|
75
|
+
"playwright": "^1.59.1",
|
|
76
|
+
"typescript": "^6.0.3"
|
|
77
|
+
},
|
|
58
78
|
"publishConfig": {
|
|
59
79
|
"access": "public"
|
|
60
80
|
}
|
package/providers/registry.mjs
CHANGED
|
@@ -42,7 +42,14 @@ export const mockProvider = {
|
|
|
42
42
|
name: 'mock',
|
|
43
43
|
async *sendMessage(messages, opts = {}) {
|
|
44
44
|
const last = messages[messages.length - 1];
|
|
45
|
-
const
|
|
45
|
+
const sys = messages.find((m) => m.role === 'system')?.content || '';
|
|
46
|
+
// When a system message is present, prefix the echo with [sys:...]
|
|
47
|
+
// so callers (and especially tests) can verify what the provider
|
|
48
|
+
// saw in the system slot. No system → byte-identical to the prior
|
|
49
|
+
// shape so existing assertions stay green.
|
|
50
|
+
const reply = sys
|
|
51
|
+
? `[sys:${String(sys).slice(0, 8000)}]\nmock-reply: ${last?.content ?? ''}`
|
|
52
|
+
: `mock-reply: ${last?.content ?? ''}`;
|
|
46
53
|
// Honor opts.signal so the chat REPL's Ctrl+C handler (and any
|
|
47
54
|
// other caller) can stop the stream mid-flight. The other concrete
|
|
48
55
|
// providers already do this; the mock should match for symmetry.
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Anthropic tool-use adapter.
|
|
2
|
+
//
|
|
3
|
+
// Unlike providers/anthropic.mjs (which streams a single text response),
|
|
4
|
+
// this module makes ONE non-streaming Messages API call at a time and
|
|
5
|
+
// parses the result into a normalized envelope the agent-turn runner
|
|
6
|
+
// can act on:
|
|
7
|
+
//
|
|
8
|
+
// { kind: 'final', text }
|
|
9
|
+
// { kind: 'tool_calls', text?, calls: [{id, name, input}], assistantContent }
|
|
10
|
+
//
|
|
11
|
+
// `assistantContent` is the raw `content` array from the API response;
|
|
12
|
+
// the caller echoes it back verbatim on the next request so the model
|
|
13
|
+
// can correlate tool_result blocks with the right tool_use ids.
|
|
14
|
+
//
|
|
15
|
+
// Anthropic's docs (Messages API tool-use, accessed Jan 2026):
|
|
16
|
+
// https://docs.anthropic.com/en/api/messages
|
|
17
|
+
// https://docs.anthropic.com/en/docs/build-with-claude/tool-use
|
|
18
|
+
|
|
19
|
+
const ANTHROPIC_VERSION = '2023-06-01';
|
|
20
|
+
const DEFAULT_MAX_TOKENS = 4096;
|
|
21
|
+
const DEFAULT_BASE = 'https://api.anthropic.com/v1';
|
|
22
|
+
|
|
23
|
+
export class AnthropicToolUseError extends Error {
|
|
24
|
+
constructor(message, code, body) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = 'AnthropicToolUseError';
|
|
27
|
+
this.code = code || 'ANTHROPIC_ERR';
|
|
28
|
+
if (body) this.body = body;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Convert a registry schema entry into Anthropic's `input_schema` shape.
|
|
33
|
+
// Today the two are identical (both JSON Schema). We materialise this
|
|
34
|
+
// helper anyway so future divergences (e.g. Anthropic-specific keys)
|
|
35
|
+
// have a single place to land.
|
|
36
|
+
export function toAnthropicTools(schemas) {
|
|
37
|
+
return (schemas || []).map((s) => ({
|
|
38
|
+
name: s.name,
|
|
39
|
+
description: s.description,
|
|
40
|
+
input_schema: s.parameters,
|
|
41
|
+
}));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function callOnce({
|
|
45
|
+
messages,
|
|
46
|
+
tools = [],
|
|
47
|
+
model,
|
|
48
|
+
apiKey,
|
|
49
|
+
system,
|
|
50
|
+
maxTokens = DEFAULT_MAX_TOKENS,
|
|
51
|
+
baseUrl,
|
|
52
|
+
fetchImpl,
|
|
53
|
+
signal,
|
|
54
|
+
} = {}) {
|
|
55
|
+
if (!Array.isArray(messages) || messages.length === 0) {
|
|
56
|
+
throw new AnthropicToolUseError('messages[] is required and non-empty', 'NO_MESSAGES');
|
|
57
|
+
}
|
|
58
|
+
if (!apiKey) {
|
|
59
|
+
throw new AnthropicToolUseError('apiKey is required', 'NO_API_KEY');
|
|
60
|
+
}
|
|
61
|
+
const url = `${(baseUrl || DEFAULT_BASE).replace(/\/$/, '')}/messages`;
|
|
62
|
+
const fetchFn = fetchImpl || globalThis.fetch;
|
|
63
|
+
const body = {
|
|
64
|
+
model: model || 'claude-opus-4-7',
|
|
65
|
+
max_tokens: maxTokens,
|
|
66
|
+
messages,
|
|
67
|
+
};
|
|
68
|
+
if (system && String(system).trim()) body.system = String(system);
|
|
69
|
+
if (tools && tools.length) body.tools = tools;
|
|
70
|
+
|
|
71
|
+
const res = await fetchFn(url, {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: {
|
|
74
|
+
'Content-Type': 'application/json',
|
|
75
|
+
'x-api-key': apiKey,
|
|
76
|
+
'anthropic-version': ANTHROPIC_VERSION,
|
|
77
|
+
},
|
|
78
|
+
body: JSON.stringify(body),
|
|
79
|
+
signal,
|
|
80
|
+
});
|
|
81
|
+
if (!res.ok) {
|
|
82
|
+
let raw = '';
|
|
83
|
+
try { raw = await res.text(); } catch { /* ignore */ }
|
|
84
|
+
throw new AnthropicToolUseError(`HTTP ${res.status}: ${raw.slice(0, 300)}`, 'HTTP_FAIL', raw);
|
|
85
|
+
}
|
|
86
|
+
const json = await res.json();
|
|
87
|
+
return parseResponse(json);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function parseResponse(json) {
|
|
91
|
+
const content = Array.isArray(json?.content) ? json.content : [];
|
|
92
|
+
const textParts = [];
|
|
93
|
+
const calls = [];
|
|
94
|
+
for (const block of content) {
|
|
95
|
+
if (!block || typeof block !== 'object') continue;
|
|
96
|
+
if (block.type === 'text' && typeof block.text === 'string') {
|
|
97
|
+
textParts.push(block.text);
|
|
98
|
+
} else if (block.type === 'tool_use') {
|
|
99
|
+
if (!block.id || !block.name) continue;
|
|
100
|
+
calls.push({ id: block.id, name: block.name, input: block.input ?? {} });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const text = textParts.join('');
|
|
104
|
+
if (calls.length === 0) {
|
|
105
|
+
return { kind: 'final', text, raw: json };
|
|
106
|
+
}
|
|
107
|
+
return { kind: 'tool_calls', text, calls, assistantContent: content, raw: json };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Anthropic accepts the agent_turn-native {role, content} shape
|
|
111
|
+
// directly; no transformation needed. These helpers exist so the
|
|
112
|
+
// agent-turn runner can call the same names across all adapters.
|
|
113
|
+
export function normalizeHistory(turns) {
|
|
114
|
+
return Array.isArray(turns) ? [...turns] : [];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function initialUserMessage(text) {
|
|
118
|
+
return { role: 'user', content: String(text) };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Build the `messages` entries the runner appends to record the model's
|
|
122
|
+
// own turn. Anthropic packs everything into one assistant message whose
|
|
123
|
+
// content is the array of blocks the API returned, so we return that
|
|
124
|
+
// inside a one-element array for shape parity with adapters that need
|
|
125
|
+
// multiple entries (OpenAI).
|
|
126
|
+
export function assistantTurnMessages(resp) {
|
|
127
|
+
return [{ role: 'assistant', content: resp.assistantContent }];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Build the `messages` entries the runner appends after executing tools
|
|
131
|
+
// so the next callOnce request has correctly-shaped tool_result blocks.
|
|
132
|
+
// Anthropic groups all results inside a single user-role message; the
|
|
133
|
+
// array wrapper exists for shape parity with adapters that emit one
|
|
134
|
+
// tool-result message per call (OpenAI).
|
|
135
|
+
//
|
|
136
|
+
// `results` is an array aligned with the assistant turn's tool_calls:
|
|
137
|
+
// [{ id, content, isError? }]
|
|
138
|
+
export function toolResultMessages(results) {
|
|
139
|
+
const content = (results || []).map((r) => ({
|
|
140
|
+
type: 'tool_result',
|
|
141
|
+
tool_use_id: r.id,
|
|
142
|
+
content: typeof r.content === 'string' ? r.content : JSON.stringify(r.content),
|
|
143
|
+
...(r.isError ? { is_error: true } : {}),
|
|
144
|
+
}));
|
|
145
|
+
return [{ role: 'user', content }];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Back-compat alias kept until external callers migrate.
|
|
149
|
+
export function buildToolResultsMessage(results) {
|
|
150
|
+
return toolResultMessages(results)[0];
|
|
151
|
+
}
|