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,215 @@
|
|
|
1
|
+
// claude-cli tool-use adapter (Phase 19).
|
|
2
|
+
//
|
|
3
|
+
// Unlike the API-based adapters, this one wraps the official `claude`
|
|
4
|
+
// CLI (Claude Code). The CLI runs the *entire* tool-use loop inside
|
|
5
|
+
// itself — bash, edit, read, write, grep, etc. — and emits a single
|
|
6
|
+
// final text answer. From lazyclaw's mention-router perspective every
|
|
7
|
+
// call resolves to `{ kind: 'final', text }` after one iteration, so
|
|
8
|
+
// the multi-agent handoff still works (we just lose lazyclaw's audit
|
|
9
|
+
// log for tools claude ran on its own; the CLI keeps its own log).
|
|
10
|
+
//
|
|
11
|
+
// Wiring choices:
|
|
12
|
+
// - --output-format stream-json + --verbose so we can accumulate
|
|
13
|
+
// text deltas exactly like providers/claude_cli.mjs does (proven
|
|
14
|
+
// parser, no second JSON shape to maintain).
|
|
15
|
+
// - --permission-mode bypassPermissions because spec §10 #6 ships
|
|
16
|
+
// destructive-pattern confirmation OFF by default. Audit log
|
|
17
|
+
// still captures every tool the CLI runs (via the CLI's own
|
|
18
|
+
// telemetry — we don't double-write here).
|
|
19
|
+
// - --tools maps the lazyclaw whitelist into claude's built-in
|
|
20
|
+
// names (bash → Bash, etc.). When the whitelist is empty we pass
|
|
21
|
+
// `""` so tools are fully disabled.
|
|
22
|
+
// - --system-prompt carries the agent role + memory + team metadata
|
|
23
|
+
// the mention router builds.
|
|
24
|
+
// - LAZYCLAW_CLAUDE_BIN overrides the binary path so tests can
|
|
25
|
+
// point at a deterministic shim script.
|
|
26
|
+
|
|
27
|
+
import { spawn } from 'node:child_process';
|
|
28
|
+
|
|
29
|
+
const DEFAULT_BIN = 'claude';
|
|
30
|
+
const LAZYCLAW_TO_CLAUDE_TOOL = {
|
|
31
|
+
bash: 'Bash',
|
|
32
|
+
read: 'Read',
|
|
33
|
+
write: 'Write',
|
|
34
|
+
grep: 'Grep',
|
|
35
|
+
web_search: 'WebSearch',
|
|
36
|
+
web_fetch: 'WebFetch',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export class ClaudeCliToolUseError extends Error {
|
|
40
|
+
constructor(message, code, body) {
|
|
41
|
+
super(message);
|
|
42
|
+
this.name = 'ClaudeCliToolUseError';
|
|
43
|
+
this.code = code || 'CLAUDE_CLI_ERR';
|
|
44
|
+
if (body) this.body = body;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The schemas value from listToolSchemas comes in lazyclaw form; claude
|
|
49
|
+
// expects a comma-separated string of its OWN built-in tool names.
|
|
50
|
+
// Returning a string rather than an array lets us pass it as a single
|
|
51
|
+
// CLI argument unchanged. An empty string is meaningful — it disables
|
|
52
|
+
// all tools — so callers should distinguish "no tools whitelisted" from
|
|
53
|
+
// "tools field omitted".
|
|
54
|
+
export function toClaudeTools(schemas) {
|
|
55
|
+
if (!Array.isArray(schemas) || schemas.length === 0) return '';
|
|
56
|
+
const names = schemas
|
|
57
|
+
.map((s) => LAZYCLAW_TO_CLAUDE_TOOL[s?.name])
|
|
58
|
+
.filter(Boolean);
|
|
59
|
+
return [...new Set(names)].join(',');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function normalizeHistory(turns) {
|
|
63
|
+
return Array.isArray(turns) ? [...turns] : [];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function initialUserMessage(text) {
|
|
67
|
+
return { role: 'user', content: String(text) };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Build the single prompt string the CLI sees. Concatenate every
|
|
71
|
+
// non-system message, prefixing prior assistant turns with a "[prior]
|
|
72
|
+
// " marker so the model can tell them apart from the live user turn.
|
|
73
|
+
// Mirrors the established pattern in providers/claude_cli.mjs.
|
|
74
|
+
function buildPrompt(messages) {
|
|
75
|
+
const lastUser = [...messages].reverse().find((m) => m && m.role === 'user');
|
|
76
|
+
if (!lastUser) return '';
|
|
77
|
+
const history = messages
|
|
78
|
+
.filter((m) => m !== lastUser && m && m.role !== 'system')
|
|
79
|
+
.map((m) => {
|
|
80
|
+
const tag = m.role === 'assistant' ? '[prior assistant]' : '[prior user]';
|
|
81
|
+
return `${tag} ${typeof m.content === 'string' ? m.content : JSON.stringify(m.content)}`;
|
|
82
|
+
})
|
|
83
|
+
.join('\n\n');
|
|
84
|
+
return history ? `${history}\n\n${lastUser.content}` : String(lastUser.content);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function extractTextDelta(obj) {
|
|
88
|
+
if (!obj || typeof obj !== 'object') return '';
|
|
89
|
+
if (obj.type !== 'stream_event') return '';
|
|
90
|
+
const ev = obj.event || {};
|
|
91
|
+
if (ev.type === 'content_block_delta') {
|
|
92
|
+
const d = ev.delta || {};
|
|
93
|
+
if (d.type === 'text_delta' && typeof d.text === 'string') return d.text;
|
|
94
|
+
}
|
|
95
|
+
return '';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Walk stream-json output to completion, accumulating text deltas, and
|
|
99
|
+
// return the final concatenated reply. The CLI also emits an
|
|
100
|
+
// 'assistant' record carrying the full message content; we fall back
|
|
101
|
+
// to that when no `stream_event` deltas were observed (some claude
|
|
102
|
+
// versions only emit the consolidated record).
|
|
103
|
+
async function readUntilDone(proc) {
|
|
104
|
+
return new Promise((resolve, reject) => {
|
|
105
|
+
let stdout = '';
|
|
106
|
+
let stderr = '';
|
|
107
|
+
let acc = '';
|
|
108
|
+
let assistantFallback = '';
|
|
109
|
+
let resultText = '';
|
|
110
|
+
proc.stdout.setEncoding('utf8');
|
|
111
|
+
proc.stderr.setEncoding('utf8');
|
|
112
|
+
proc.stdout.on('data', (chunk) => {
|
|
113
|
+
stdout += chunk;
|
|
114
|
+
let nl;
|
|
115
|
+
while ((nl = stdout.indexOf('\n')) >= 0) {
|
|
116
|
+
const line = stdout.slice(0, nl).trim();
|
|
117
|
+
stdout = stdout.slice(nl + 1);
|
|
118
|
+
if (!line) continue;
|
|
119
|
+
let obj;
|
|
120
|
+
try { obj = JSON.parse(line); } catch { continue; }
|
|
121
|
+
const delta = extractTextDelta(obj);
|
|
122
|
+
if (delta) acc += delta;
|
|
123
|
+
// Fallback: consolidated assistant content block (no streaming).
|
|
124
|
+
if (obj?.type === 'assistant' && obj?.message?.content) {
|
|
125
|
+
for (const block of obj.message.content) {
|
|
126
|
+
if (block?.type === 'text' && typeof block.text === 'string') {
|
|
127
|
+
assistantFallback += block.text;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (obj?.type === 'result' && typeof obj.result === 'string') {
|
|
132
|
+
resultText = obj.result;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
proc.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
137
|
+
proc.on('error', reject);
|
|
138
|
+
proc.on('close', (code) => {
|
|
139
|
+
if (code !== 0) {
|
|
140
|
+
return reject(new ClaudeCliToolUseError(`claude CLI exit ${code}: ${stderr.slice(0, 300)}`, 'CLAUDE_CLI_EXIT', stderr));
|
|
141
|
+
}
|
|
142
|
+
// Prefer accumulated stream deltas; fall back to the assistant
|
|
143
|
+
// record or the final result text when streaming was disabled.
|
|
144
|
+
const text = acc || assistantFallback || resultText || '';
|
|
145
|
+
resolve(text);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function callOnce({
|
|
151
|
+
messages,
|
|
152
|
+
tools = [],
|
|
153
|
+
model,
|
|
154
|
+
apiKey, // unused — the CLI authenticates itself
|
|
155
|
+
system,
|
|
156
|
+
baseUrl, // unused
|
|
157
|
+
fetchImpl, // unused
|
|
158
|
+
signal,
|
|
159
|
+
bin,
|
|
160
|
+
cwd,
|
|
161
|
+
permissionMode = 'bypassPermissions',
|
|
162
|
+
} = {}) {
|
|
163
|
+
if (!Array.isArray(messages) || messages.length === 0) {
|
|
164
|
+
throw new ClaudeCliToolUseError('messages[] is required and non-empty', 'NO_MESSAGES');
|
|
165
|
+
}
|
|
166
|
+
const prompt = buildPrompt(messages);
|
|
167
|
+
if (!prompt) {
|
|
168
|
+
throw new ClaudeCliToolUseError('messages produced an empty prompt', 'NO_PROMPT');
|
|
169
|
+
}
|
|
170
|
+
const args = [
|
|
171
|
+
'-p', prompt,
|
|
172
|
+
'--output-format', 'stream-json',
|
|
173
|
+
'--include-partial-messages',
|
|
174
|
+
'--verbose',
|
|
175
|
+
'--permission-mode', permissionMode,
|
|
176
|
+
];
|
|
177
|
+
if (model) args.push('--model', model);
|
|
178
|
+
if (system && String(system).trim()) {
|
|
179
|
+
args.push('--system-prompt', String(system));
|
|
180
|
+
}
|
|
181
|
+
// Phase 19: pass the lazyclaw whitelist through to claude's --tools
|
|
182
|
+
// even when empty (an empty string explicitly disables every tool).
|
|
183
|
+
const toolsArg = toClaudeTools(tools);
|
|
184
|
+
args.push('--tools', toolsArg);
|
|
185
|
+
|
|
186
|
+
const binPath = bin || process.env.LAZYCLAW_CLAUDE_BIN || DEFAULT_BIN;
|
|
187
|
+
let proc;
|
|
188
|
+
try {
|
|
189
|
+
proc = spawn(binPath, args, {
|
|
190
|
+
cwd: cwd || process.cwd(),
|
|
191
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
192
|
+
env: process.env,
|
|
193
|
+
});
|
|
194
|
+
} catch (err) {
|
|
195
|
+
if (err?.code === 'ENOENT') {
|
|
196
|
+
throw new ClaudeCliToolUseError(`claude CLI binary not found at "${binPath}"`, 'CLAUDE_CLI_NOT_FOUND');
|
|
197
|
+
}
|
|
198
|
+
throw err;
|
|
199
|
+
}
|
|
200
|
+
const onAbort = () => { try { proc.kill('SIGTERM'); } catch { /* gone */ } };
|
|
201
|
+
if (signal) signal.addEventListener('abort', onAbort);
|
|
202
|
+
try {
|
|
203
|
+
const text = await readUntilDone(proc);
|
|
204
|
+
return { kind: 'final', text, raw: null };
|
|
205
|
+
} finally {
|
|
206
|
+
if (signal) signal.removeEventListener('abort', onAbort);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// The CLI handles tools internally — these helpers exist to keep the
|
|
211
|
+
// adapter surface symmetrical with anthropic/openai/gemini. Neither
|
|
212
|
+
// path is actually exercised at runtime because callOnce always
|
|
213
|
+
// returns kind:'final'.
|
|
214
|
+
export function assistantTurnMessages(_resp) { return []; }
|
|
215
|
+
export function toolResultMessages(_results) { return []; }
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Gemini tool-use adapter.
|
|
2
|
+
//
|
|
3
|
+
// Calls POST /v1beta/models/{model}:generateContent non-streaming and
|
|
4
|
+
// normalises the response into the agent_turn envelope shape:
|
|
5
|
+
// { kind: 'final', text }
|
|
6
|
+
// { kind: 'tool_calls', text?, calls, assistantContent }
|
|
7
|
+
//
|
|
8
|
+
// Wire-level peculiarities this module hides:
|
|
9
|
+
// - role names are "user" and "model" (not "assistant")
|
|
10
|
+
// - message body is `parts: [{text}, {functionCall}, ...]`
|
|
11
|
+
// - functionCall.args is a native JSON object (no string parsing)
|
|
12
|
+
// - functionResponse goes back in a role:user message inside its own
|
|
13
|
+
// `parts` block; correlation is by `name`, not an id
|
|
14
|
+
// - system prompt rides on `system_instruction.parts[0].text`
|
|
15
|
+
// - tools are declared inside ONE `tools[0].function_declarations` list
|
|
16
|
+
// - some JSON-Schema keys (`additionalProperties`, `$schema`,
|
|
17
|
+
// `examples`) are rejected by Gemini; we strip them before sending
|
|
18
|
+
//
|
|
19
|
+
// Docs: https://ai.google.dev/api/rest/v1beta/models/generateContent
|
|
20
|
+
|
|
21
|
+
const DEFAULT_BASE = 'https://generativelanguage.googleapis.com/v1beta';
|
|
22
|
+
|
|
23
|
+
export class GeminiToolUseError extends Error {
|
|
24
|
+
constructor(message, code, body) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = 'GeminiToolUseError';
|
|
27
|
+
this.code = code || 'GEMINI_ERR';
|
|
28
|
+
if (body) this.body = body;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Gemini's function_declarations.parameters schema is a strict subset of
|
|
33
|
+
// JSON Schema. Strip the keys it rejects so a registry-shared schema can
|
|
34
|
+
// be reused across providers without forcing the runner to know about
|
|
35
|
+
// per-provider quirks.
|
|
36
|
+
function sanitizeGeminiSchema(schema) {
|
|
37
|
+
if (!schema || typeof schema !== 'object') return schema;
|
|
38
|
+
if (Array.isArray(schema)) return schema.map(sanitizeGeminiSchema);
|
|
39
|
+
const { additionalProperties, $schema, examples, ...rest } = schema;
|
|
40
|
+
const out = {};
|
|
41
|
+
for (const [k, v] of Object.entries(rest)) {
|
|
42
|
+
out[k] = sanitizeGeminiSchema(v);
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function toGeminiTools(schemas) {
|
|
48
|
+
if (!schemas || schemas.length === 0) return [];
|
|
49
|
+
const declarations = schemas.map((s) => ({
|
|
50
|
+
name: s.name,
|
|
51
|
+
description: s.description,
|
|
52
|
+
parameters: sanitizeGeminiSchema(s.parameters),
|
|
53
|
+
}));
|
|
54
|
+
return [{ function_declarations: declarations }];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// agent_turn passes history as plain [{role:'user'|'assistant', content:'text'}].
|
|
58
|
+
// Convert each entry into Gemini's parts shape. Anything that looks like
|
|
59
|
+
// an already-Gemini message (has `parts`) is passed through unchanged so
|
|
60
|
+
// callers can pre-build native turns when they need to.
|
|
61
|
+
export function normalizeHistory(turns) {
|
|
62
|
+
if (!Array.isArray(turns)) return [];
|
|
63
|
+
return turns.map((t) => {
|
|
64
|
+
if (t && typeof t === 'object' && Array.isArray(t.parts)) return t;
|
|
65
|
+
const role = t?.role === 'assistant' ? 'model' : (t?.role || 'user');
|
|
66
|
+
const text = typeof t?.content === 'string' ? t.content : '';
|
|
67
|
+
return { role, parts: [{ text }] };
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function initialUserMessage(text) {
|
|
72
|
+
return { role: 'user', parts: [{ text: String(text) }] };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function callOnce({
|
|
76
|
+
messages,
|
|
77
|
+
tools = [],
|
|
78
|
+
model,
|
|
79
|
+
apiKey,
|
|
80
|
+
system,
|
|
81
|
+
baseUrl,
|
|
82
|
+
fetchImpl,
|
|
83
|
+
signal,
|
|
84
|
+
} = {}) {
|
|
85
|
+
if (!Array.isArray(messages) || messages.length === 0) {
|
|
86
|
+
throw new GeminiToolUseError('messages[] is required and non-empty', 'NO_MESSAGES');
|
|
87
|
+
}
|
|
88
|
+
if (!apiKey) {
|
|
89
|
+
throw new GeminiToolUseError('apiKey is required', 'NO_API_KEY');
|
|
90
|
+
}
|
|
91
|
+
const m = model || 'gemini-2.5-flash';
|
|
92
|
+
const url = `${(baseUrl || DEFAULT_BASE).replace(/\/$/, '')}/models/${encodeURIComponent(m)}:generateContent`;
|
|
93
|
+
const fetchFn = fetchImpl || globalThis.fetch;
|
|
94
|
+
|
|
95
|
+
const body = { contents: messages };
|
|
96
|
+
if (tools && tools.length) body.tools = tools;
|
|
97
|
+
if (system && String(system).trim()) {
|
|
98
|
+
body.system_instruction = { parts: [{ text: String(system) }] };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const res = await fetchFn(url, {
|
|
102
|
+
method: 'POST',
|
|
103
|
+
headers: {
|
|
104
|
+
'Content-Type': 'application/json',
|
|
105
|
+
'x-goog-api-key': apiKey,
|
|
106
|
+
},
|
|
107
|
+
body: JSON.stringify(body),
|
|
108
|
+
signal,
|
|
109
|
+
});
|
|
110
|
+
if (!res.ok) {
|
|
111
|
+
let raw = '';
|
|
112
|
+
try { raw = await res.text(); } catch { /* ignore */ }
|
|
113
|
+
throw new GeminiToolUseError(`HTTP ${res.status}: ${raw.slice(0, 300)}`, 'HTTP_FAIL', raw);
|
|
114
|
+
}
|
|
115
|
+
const json = await res.json();
|
|
116
|
+
return parseResponse(json);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function parseResponse(json) {
|
|
120
|
+
const candidate = json?.candidates?.[0];
|
|
121
|
+
const parts = candidate?.content?.parts || [];
|
|
122
|
+
const textParts = [];
|
|
123
|
+
const calls = [];
|
|
124
|
+
for (const p of parts) {
|
|
125
|
+
if (!p || typeof p !== 'object') continue;
|
|
126
|
+
if (typeof p.text === 'string') textParts.push(p.text);
|
|
127
|
+
else if (p.functionCall && p.functionCall.name) {
|
|
128
|
+
// Gemini has no `id` field; we synthesise a stable one from the
|
|
129
|
+
// call index + name so multi-call turns can still be correlated
|
|
130
|
+
// by the runner. We strip the id again before sending the
|
|
131
|
+
// functionResponse back (Gemini matches by name).
|
|
132
|
+
calls.push({
|
|
133
|
+
id: `gem_${calls.length}_${p.functionCall.name}`,
|
|
134
|
+
name: p.functionCall.name,
|
|
135
|
+
input: p.functionCall.args || {},
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const text = textParts.join('');
|
|
140
|
+
if (calls.length === 0) {
|
|
141
|
+
return { kind: 'final', text, raw: json };
|
|
142
|
+
}
|
|
143
|
+
// assistantContent is the entire model turn so the runner can echo it
|
|
144
|
+
// back into `contents` for the next request.
|
|
145
|
+
return {
|
|
146
|
+
kind: 'tool_calls',
|
|
147
|
+
text,
|
|
148
|
+
calls,
|
|
149
|
+
assistantContent: candidate?.content || { role: 'model', parts },
|
|
150
|
+
raw: json,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function assistantTurnMessages(resp) {
|
|
155
|
+
// Echo the model turn straight back so the next request includes the
|
|
156
|
+
// functionCall parts the API expects to see paired with responses.
|
|
157
|
+
return [resp.assistantContent];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function toolResultMessages(results) {
|
|
161
|
+
// All tool responses fit into a SINGLE user-role message whose parts
|
|
162
|
+
// are functionResponse entries. Gemini matches by name, so we drop
|
|
163
|
+
// the synthetic id.
|
|
164
|
+
const parts = (results || []).map((r) => ({
|
|
165
|
+
functionResponse: {
|
|
166
|
+
name: nameFromSyntheticId(r.id),
|
|
167
|
+
response: normaliseToolResponse(r.content, r.isError),
|
|
168
|
+
},
|
|
169
|
+
}));
|
|
170
|
+
return [{ role: 'user', parts }];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function nameFromSyntheticId(id) {
|
|
174
|
+
if (typeof id !== 'string') return String(id || 'unknown');
|
|
175
|
+
// synthetic shape: `gem_<idx>_<name>`. Strip the prefix.
|
|
176
|
+
const m = /^gem_\d+_(.+)$/.exec(id);
|
|
177
|
+
return m ? m[1] : id;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function normaliseToolResponse(content, isError) {
|
|
181
|
+
// Gemini wants an object, not a free-form string. Wrap primitives.
|
|
182
|
+
let body;
|
|
183
|
+
if (content === null || content === undefined) body = {};
|
|
184
|
+
else if (typeof content === 'string') body = { content };
|
|
185
|
+
else if (typeof content === 'object') body = content;
|
|
186
|
+
else body = { content: String(content) };
|
|
187
|
+
if (isError) return { ...body, is_error: true };
|
|
188
|
+
return body;
|
|
189
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// OpenAI tool-use adapter.
|
|
2
|
+
//
|
|
3
|
+
// Calls POST /v1/chat/completions non-streaming and normalises the
|
|
4
|
+
// response into the same envelope shape Anthropic's adapter uses:
|
|
5
|
+
// { kind: 'final', text }
|
|
6
|
+
// { kind: 'tool_calls', text?, calls, assistantContent }
|
|
7
|
+
//
|
|
8
|
+
// Wire-level differences vs Anthropic that this module hides:
|
|
9
|
+
// - `function.arguments` is a JSON STRING; we parse it.
|
|
10
|
+
// - `content` on the assistant message can be null when tool_calls is
|
|
11
|
+
// present; downstream code receives `text: ''` in that case.
|
|
12
|
+
// - Each tool result becomes its own message with role='tool' and the
|
|
13
|
+
// `tool_call_id` field — so `toolResultMessages()` returns N
|
|
14
|
+
// entries (vs Anthropic's single user-message wrapper).
|
|
15
|
+
//
|
|
16
|
+
// Docs: https://platform.openai.com/docs/guides/function-calling
|
|
17
|
+
|
|
18
|
+
const DEFAULT_BASE = 'https://api.openai.com/v1';
|
|
19
|
+
const DEFAULT_MAX_TOKENS = 4096;
|
|
20
|
+
|
|
21
|
+
export class OpenAIToolUseError extends Error {
|
|
22
|
+
constructor(message, code, body) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = 'OpenAIToolUseError';
|
|
25
|
+
this.code = code || 'OPENAI_ERR';
|
|
26
|
+
if (body) this.body = body;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function toOpenAITools(schemas) {
|
|
31
|
+
return (schemas || []).map((s) => ({
|
|
32
|
+
type: 'function',
|
|
33
|
+
function: {
|
|
34
|
+
name: s.name,
|
|
35
|
+
description: s.description,
|
|
36
|
+
parameters: s.parameters,
|
|
37
|
+
},
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function callOnce({
|
|
42
|
+
messages,
|
|
43
|
+
tools = [],
|
|
44
|
+
model,
|
|
45
|
+
apiKey,
|
|
46
|
+
system,
|
|
47
|
+
maxTokens = DEFAULT_MAX_TOKENS,
|
|
48
|
+
baseUrl,
|
|
49
|
+
fetchImpl,
|
|
50
|
+
signal,
|
|
51
|
+
} = {}) {
|
|
52
|
+
if (!Array.isArray(messages) || messages.length === 0) {
|
|
53
|
+
throw new OpenAIToolUseError('messages[] is required and non-empty', 'NO_MESSAGES');
|
|
54
|
+
}
|
|
55
|
+
if (!apiKey) {
|
|
56
|
+
throw new OpenAIToolUseError('apiKey is required', 'NO_API_KEY');
|
|
57
|
+
}
|
|
58
|
+
const url = `${(baseUrl || DEFAULT_BASE).replace(/\/$/, '')}/chat/completions`;
|
|
59
|
+
const fetchFn = fetchImpl || globalThis.fetch;
|
|
60
|
+
|
|
61
|
+
// OpenAI carries the system prompt as the first message rather than a
|
|
62
|
+
// separate field. We only prepend when the caller has NOT already
|
|
63
|
+
// injected one (history replays may include the system turn).
|
|
64
|
+
const fullMessages = [];
|
|
65
|
+
const hasSystem = messages.some((m) => m?.role === 'system');
|
|
66
|
+
if (!hasSystem && system && String(system).trim()) {
|
|
67
|
+
fullMessages.push({ role: 'system', content: String(system) });
|
|
68
|
+
}
|
|
69
|
+
fullMessages.push(...messages);
|
|
70
|
+
|
|
71
|
+
const body = {
|
|
72
|
+
model: model || 'gpt-4.1',
|
|
73
|
+
messages: fullMessages,
|
|
74
|
+
max_tokens: maxTokens,
|
|
75
|
+
};
|
|
76
|
+
if (tools && tools.length) body.tools = tools;
|
|
77
|
+
|
|
78
|
+
const res = await fetchFn(url, {
|
|
79
|
+
method: 'POST',
|
|
80
|
+
headers: {
|
|
81
|
+
'Content-Type': 'application/json',
|
|
82
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
83
|
+
},
|
|
84
|
+
body: JSON.stringify(body),
|
|
85
|
+
signal,
|
|
86
|
+
});
|
|
87
|
+
if (!res.ok) {
|
|
88
|
+
let raw = '';
|
|
89
|
+
try { raw = await res.text(); } catch { /* ignore */ }
|
|
90
|
+
throw new OpenAIToolUseError(`HTTP ${res.status}: ${raw.slice(0, 300)}`, 'HTTP_FAIL', raw);
|
|
91
|
+
}
|
|
92
|
+
const json = await res.json();
|
|
93
|
+
return parseResponse(json);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function parseResponse(json) {
|
|
97
|
+
const choice = Array.isArray(json?.choices) ? json.choices[0] : null;
|
|
98
|
+
const msg = choice?.message || {};
|
|
99
|
+
const rawToolCalls = Array.isArray(msg.tool_calls) ? msg.tool_calls : [];
|
|
100
|
+
const text = typeof msg.content === 'string' ? msg.content : '';
|
|
101
|
+
if (rawToolCalls.length === 0) {
|
|
102
|
+
return { kind: 'final', text, raw: json };
|
|
103
|
+
}
|
|
104
|
+
const calls = rawToolCalls.map((tc) => {
|
|
105
|
+
let input = {};
|
|
106
|
+
const a = tc?.function?.arguments;
|
|
107
|
+
if (typeof a === 'string') {
|
|
108
|
+
try { input = JSON.parse(a); } catch { input = {}; }
|
|
109
|
+
} else if (a && typeof a === 'object') {
|
|
110
|
+
input = a;
|
|
111
|
+
}
|
|
112
|
+
return { id: tc.id, name: tc?.function?.name, input };
|
|
113
|
+
});
|
|
114
|
+
return { kind: 'tool_calls', text, calls, assistantContent: msg, raw: json };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// OpenAI accepts the agent_turn-native {role, content} shape directly.
|
|
118
|
+
export function normalizeHistory(turns) {
|
|
119
|
+
return Array.isArray(turns) ? [...turns] : [];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function initialUserMessage(text) {
|
|
123
|
+
return { role: 'user', content: String(text) };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Echo the assistant turn so the next request preserves the model's
|
|
127
|
+
// reasoning and tool_calls ids for correlation. OpenAI already gives us
|
|
128
|
+
// a full message object; we just wrap it.
|
|
129
|
+
export function assistantTurnMessages(resp) {
|
|
130
|
+
return [resp.assistantContent];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Each tool result becomes its own role:tool message.
|
|
134
|
+
export function toolResultMessages(results) {
|
|
135
|
+
return (results || []).map((r) => ({
|
|
136
|
+
role: 'tool',
|
|
137
|
+
tool_call_id: r.id,
|
|
138
|
+
content: typeof r.content === 'string' ? r.content : JSON.stringify(r.content),
|
|
139
|
+
}));
|
|
140
|
+
}
|