lazyclaw 3.99.27 → 4.2.0

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.
@@ -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
+ }
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env node
2
+ // Detached worker for `lazyclaw loop --detach`.
3
+ //
4
+ // Invoked by the parent CLI with the same provider/model the parent
5
+ // resolved, plus a loop id pointing at the state directory the parent
6
+ // pre-created. Streams nothing to stdout — we are headless. Every
7
+ // iteration's outcome lands in iterations.log, and the final disposition
8
+ // lands in result.json + meta.status. SIGTERM flips status to `killed`
9
+ // and unwinds cleanly so a follow-up SIGKILL is rarely needed.
10
+ //
11
+ // Argv contract (all required except --until / --session-existing):
12
+ // --loop-id <id>
13
+ // --prompt <text>
14
+ // --max <N>
15
+ // --provider <name>
16
+ // --until <regex>
17
+ // --session-existing <id> reuse the named chat session
18
+ // --cfg-dir <path> override LAZYCLAW_CONFIG_DIR
19
+ // --model <name> provider-specific model name
20
+ //
21
+ // LC_FAIL_AT_ITER=<N> is honored as a test hook: exits with code 7 just
22
+ // before iteration N's provider call. Used by phase 2 spec to assert
23
+ // the `failed` meta status path.
24
+
25
+ import path from 'node:path';
26
+ import { fileURLToPath } from 'node:url';
27
+
28
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
29
+ const REPO_ROOT = path.resolve(HERE, '..');
30
+
31
+ function parseArgs(argv) {
32
+ const out = { _: [] };
33
+ for (let i = 0; i < argv.length; i++) {
34
+ const t = argv[i];
35
+ if (t.startsWith('--')) {
36
+ const key = t.slice(2);
37
+ const v = argv[i + 1];
38
+ if (v === undefined || v.startsWith('--')) {
39
+ out[key] = true;
40
+ } else {
41
+ out[key] = v;
42
+ i++;
43
+ }
44
+ } else {
45
+ out._.push(t);
46
+ }
47
+ }
48
+ return out;
49
+ }
50
+
51
+ const args = parseArgs(process.argv.slice(2));
52
+ const loopId = args['loop-id'];
53
+ if (!loopId) {
54
+ process.stderr.write('loop-worker: --loop-id required\n');
55
+ process.exit(2);
56
+ }
57
+
58
+ if (args['cfg-dir']) {
59
+ process.env.LAZYCLAW_CONFIG_DIR = args['cfg-dir'];
60
+ }
61
+
62
+ const loops = await import(path.join(REPO_ROOT, 'loops.mjs'));
63
+ const sessions = await import(path.join(REPO_ROOT, 'sessions.mjs'));
64
+ const loopEngine = await import(path.join(REPO_ROOT, 'loop-engine.mjs'));
65
+ const registryUrl = path.join(REPO_ROOT, 'providers', 'registry.mjs');
66
+ const { PROVIDERS } = await import(registryUrl);
67
+
68
+ const cfgDir = process.env.LAZYCLAW_CONFIG_DIR || loops.defaultConfigDir();
69
+ const sessionId = args['session-existing'] || `loop:${loopId}`;
70
+
71
+ const provName = args.provider || 'mock';
72
+ const prov = PROVIDERS[provName];
73
+ if (!prov) {
74
+ loops.patchMeta(loopId, { status: 'failed', finishedAt: new Date().toISOString() }, cfgDir);
75
+ loops.writeResult(loopId, { error: `unknown provider: ${provName}` }, cfgDir);
76
+ process.exit(2);
77
+ }
78
+
79
+ const until = args.until ? loopEngine.compileUntil(args.until) : null;
80
+ const max = Number(args.max) || loopEngine.LOOP_MAX_DEFAULT;
81
+
82
+ // Initial meta — pid was filled by parent. We update startedAt here so
83
+ // the timestamp reflects when the worker actually started executing, not
84
+ // when the parent forked us.
85
+ loops.patchMeta(loopId, { status: 'running', startedAt: new Date().toISOString() }, cfgDir);
86
+
87
+ const ac = new AbortController();
88
+ function onTerm(sig) {
89
+ ac.abort();
90
+ loops.patchMeta(loopId, { status: 'killed', finishedAt: new Date().toISOString(), signal: sig }, cfgDir);
91
+ loops.writeResult(loopId, { stoppedBy: 'kill', signal: sig }, cfgDir);
92
+ // Give the in-flight stream a moment to unwind before we exit.
93
+ setTimeout(() => process.exit(143), 50);
94
+ }
95
+ process.on('SIGTERM', () => onTerm('SIGTERM'));
96
+ process.on('SIGINT', () => onTerm('SIGINT'));
97
+
98
+ const failAtIter = Number(process.env.LC_FAIL_AT_ITER) || 0;
99
+ let iterCounter = 0;
100
+
101
+ async function sendOnce(messages, signal) {
102
+ iterCounter++;
103
+ if (failAtIter && iterCounter === failAtIter) {
104
+ process.exit(7);
105
+ }
106
+ let acc = '';
107
+ for await (const chunk of prov.sendMessage(messages, {
108
+ apiKey: process.env.LAZYCLAW_API_KEY || '',
109
+ model: args.model,
110
+ signal,
111
+ })) {
112
+ acc += chunk;
113
+ }
114
+ return acc;
115
+ }
116
+
117
+ const messages = [];
118
+ // Hydrate prior turns if reusing an existing session — the engine appends
119
+ // every successful pair, so resume semantics line up with `/loop` in REPL.
120
+ if (sessionId && sessions.sessionPath) {
121
+ try {
122
+ const prior = sessions.loadTurns(sessionId, cfgDir);
123
+ for (const t of prior) messages.push({ role: t.role, content: t.content });
124
+ } catch { /* fresh session */ }
125
+ }
126
+
127
+ const persist = (role, content) => {
128
+ try { sessions.appendTurn(sessionId, role, content, cfgDir); }
129
+ catch { /* surface via result.json on failure */ }
130
+ };
131
+
132
+ const onIteration = ({ i, max: m, reply }) => {
133
+ loops.appendIteration(loopId, {
134
+ iteration: i,
135
+ of: m,
136
+ bytes: reply.length,
137
+ preview: reply.slice(0, 200),
138
+ }, cfgDir);
139
+ };
140
+
141
+ try {
142
+ const result = await loopEngine.runLoop({
143
+ prompt: args.prompt || '',
144
+ max,
145
+ until,
146
+ messages,
147
+ sendOnce,
148
+ persist,
149
+ onIteration,
150
+ signal: ac.signal,
151
+ });
152
+ const finalStatus = result.stoppedBy === 'abort' ? 'killed' : 'completed';
153
+ loops.patchMeta(loopId, { status: finalStatus, finishedAt: new Date().toISOString() }, cfgDir);
154
+ loops.writeResult(loopId, result, cfgDir);
155
+ process.exit(0);
156
+ } catch (err) {
157
+ loops.patchMeta(loopId, { status: 'failed', finishedAt: new Date().toISOString() }, cfgDir);
158
+ loops.writeResult(loopId, { error: err?.message || String(err), stack: err?.stack }, cfgDir);
159
+ process.exit(1);
160
+ }
package/sessions.mjs CHANGED
@@ -17,6 +17,7 @@
17
17
  import fs from 'node:fs';
18
18
  import path from 'node:path';
19
19
  import os from 'node:os';
20
+ import { appendRecent as _memoryAppendRecent } from './memory.mjs';
20
21
 
21
22
  const SESSIONS_DIRNAME = 'sessions';
22
23
 
@@ -74,6 +75,10 @@ export function appendTurn(id, role, content, configDir = defaultConfigDir()) {
74
75
  fs.mkdirSync(path.dirname(p), { recursive: true });
75
76
  const line = JSON.stringify({ role, content: String(content ?? ''), ts: Date.now() }) + '\n';
76
77
  fs.appendFileSync(p, line);
78
+ // Write-through to the memory recency log. Best-effort; failures
79
+ // never propagate up — a missing or broken memory store must not
80
+ // break the session-write path.
81
+ _memoryAppendRecent(id, role, content, configDir);
77
82
  }
78
83
 
79
84
  export function clearSession(id, configDir = defaultConfigDir()) {