codeply-cli 0.1.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.
package/lib/agent.mjs ADDED
@@ -0,0 +1,533 @@
1
+ /**
2
+ * Codeply agent loop.
3
+ *
4
+ * The ai-proxy exposes a plain chat completion — no native function calling —
5
+ * so tool use rides on a tag protocol the model writes into its reply and we
6
+ * parse back out. Tags are used rather than JSON because file content goes in
7
+ * verbatim: no escaping pass to get wrong, and models corrupt long JSON strings
8
+ * far more often than they corrupt a closing tag.
9
+ *
10
+ * runAgent() is an async generator so the TUI can render each step as it lands
11
+ * instead of freezing until the whole task finishes.
12
+ */
13
+ import fs from 'fs';
14
+ import path from 'path';
15
+ import { execSync } from 'child_process';
16
+ import { createRequire } from 'module';
17
+ import { executeTool, TOOL_NEEDS_APPROVAL, walkFiles } from './tools.mjs';
18
+
19
+ const require = createRequire(import.meta.url);
20
+ const ai = require('./ai.js');
21
+
22
+ const MAX_STEPS = 24;
23
+ const MAX_MALFORMED_RETRIES = 3;
24
+ // Deliberately conservative. The proxy is a Supabase Edge Function with its own
25
+ // memory/CPU ceiling — it returns 546 when it blows through it — so keeping each
26
+ // request small matters more here than squeezing in extra history.
27
+ const CONTEXT_CHAR_BUDGET = 48000;
28
+
29
+ const FORMAT_CORRECTION =
30
+ '[system] Your action block was not in the required form. Do not invent wrappers like <toolcall>, ' +
31
+ 'do not drop the "codeply:" prefix, and keep the underscore in the name. Write it exactly like this, ' +
32
+ 'opening and closing tags complete:\n\n' +
33
+ '<codeply:read_file>\n<path>run.html</path>\n</codeply:read_file>\n\n' +
34
+ 'Valid names: list_dir, read_file, write_file, edit_file, search, run.';
35
+
36
+ // ─── Protocol ───────────────────────────────────────────────────────────────
37
+
38
+ const TOOL_TAG = /<codeply:([a-z_]+)>([\s\S]*?)<\/codeply:\1>/g;
39
+
40
+ // Params whose value is a raw payload (file content, code blocks) and may
41
+ // legitimately contain angle-bracket tags of its own.
42
+ const CONTAINER_PARAMS = new Set(['content', 'search', 'replace']);
43
+
44
+ /** Tag payloads are written on their own lines; drop only that framing. */
45
+ function trimFraming(value) {
46
+ return value.replace(/^\r?\n/, '').replace(/\r?\n[ \t]*$/, '');
47
+ }
48
+
49
+ /**
50
+ * Pull the parameters out of a tool block.
51
+ *
52
+ * Container params are read first-open to last-close, so writing an HTML file
53
+ * that itself contains `</content>` does not truncate the payload. Their spans
54
+ * are then masked out before the scalar params are matched — otherwise a
55
+ * `<path>` appearing inside that same HTML payload would be picked up as the
56
+ * tool's path. (That is not hypothetical; it is what the test caught.)
57
+ */
58
+ function extractParams(block, spec) {
59
+ const args = {};
60
+ const masked = [...block];
61
+
62
+ for (const name of spec) {
63
+ if (!CONTAINER_PARAMS.has(name)) continue;
64
+ const open = `<${name}>`;
65
+ const close = `</${name}>`;
66
+ const start = block.indexOf(open);
67
+ if (start === -1) continue;
68
+ const end = block.lastIndexOf(close);
69
+ if (end === -1 || end < start + open.length) continue;
70
+ args[name] = trimFraming(block.slice(start + open.length, end));
71
+ for (let i = start; i < end + close.length; i++) masked[i] = ' ';
72
+ }
73
+
74
+ const scalarRegion = masked.join('');
75
+ for (const name of spec) {
76
+ if (CONTAINER_PARAMS.has(name)) continue;
77
+ const m = scalarRegion.match(new RegExp(`<${name}>([\\s\\S]*?)</${name}>`));
78
+ if (m) args[name] = trimFraming(m[1]);
79
+ }
80
+
81
+ return args;
82
+ }
83
+
84
+ const PARAMS = {
85
+ list_dir: ['path'],
86
+ read_file: ['path', 'offset', 'limit'],
87
+ write_file: ['path', 'content'],
88
+ edit_file: ['path', 'search', 'replace'],
89
+ search: ['pattern', 'glob', 'path'],
90
+ run: ['command'],
91
+ };
92
+
93
+ // Names the model actually reaches for when it paraphrases the format.
94
+ // Deliberately excludes `search`, `replace`, `content`, `path` and `command`:
95
+ // those are parameter names, and treating them as action names would misread
96
+ // an edit_file block as a search.
97
+ const NAME_ALIASES = {
98
+ read_file: 'read_file', readfile: 'read_file', read: 'read_file',
99
+ open: 'read_file', cat: 'read_file', view: 'read_file', openfile: 'read_file',
100
+ list_dir: 'list_dir', listdir: 'list_dir', list: 'list_dir',
101
+ ls: 'list_dir', dir: 'list_dir', listfiles: 'list_dir',
102
+ write_file: 'write_file', writefile: 'write_file', write: 'write_file',
103
+ create: 'write_file', createfile: 'write_file',
104
+ edit_file: 'edit_file', editfile: 'edit_file', edit: 'edit_file',
105
+ grep: 'search', findtext: 'search',
106
+ run: 'run', bash: 'run', shell: 'run', exec: 'run', runcommand: 'run',
107
+ };
108
+
109
+ // Any tag whose name resolves to an action, with or without the codeply: prefix.
110
+ const LOOSE_NAME_TAG = new RegExp(
111
+ `</?\\s*(?:codeply[:_-])?(${Object.keys(NAME_ALIASES).join('|')})\\s*/?>`, 'i'
112
+ );
113
+
114
+ // Does this reply look like it was *trying* to act, even though nothing parsed?
115
+ const LOOKS_LIKE_ATTEMPT =
116
+ /<\/?\s*(?:codeply[:_-])?(?:tool_?call|action|function_?call|invoke)\b|<(?:path|command|pattern|content)>/i;
117
+
118
+ /**
119
+ * Best-effort recovery when the strict protocol did not match.
120
+ *
121
+ * gpt-oss-120b paraphrases the format — inventing a <toolcall> wrapper,
122
+ * dropping the codeply: prefix, writing "readfile" for "read_file", even
123
+ * dropping a bracket. Rejecting those wastes the user's turn, so the intent is
124
+ * reconstructed from whatever survived. Checks run most-specific first: the
125
+ * parameter tags are stronger evidence than the action name, because the name
126
+ * is what gets mangled.
127
+ */
128
+ function recoverCall(text) {
129
+ const has = (tag) => new RegExp(`<${tag}>`, 'i').test(text);
130
+
131
+ let name = null;
132
+ if (has('content')) name = 'write_file';
133
+ else if (has('search') && has('replace')) name = 'edit_file';
134
+ else {
135
+ const m = text.match(LOOSE_NAME_TAG);
136
+ if (m) name = NAME_ALIASES[m[1].toLowerCase().replace(/[_-]/g, '')] ?? NAME_ALIASES[m[1].toLowerCase()];
137
+ if (!name && has('command')) name = 'run';
138
+ else if (!name && has('pattern')) name = 'search';
139
+ else if (!name && has('path')) name = 'read_file';
140
+ }
141
+ if (!name || !PARAMS[name]) return null;
142
+
143
+ const args = extractParams(text, PARAMS[name]);
144
+ if (Object.keys(args).length === 0) return null;
145
+ return { name, args, recovered: true };
146
+ }
147
+
148
+ /**
149
+ * Split a model reply into prose and the actions embedded in it.
150
+ * @returns {{prose:string, calls:Array, recovered:boolean, malformed:boolean}}
151
+ */
152
+ export function parseReply(text) {
153
+ const calls = [];
154
+ let prose = '';
155
+ let lastIndex = 0;
156
+ TOOL_TAG.lastIndex = 0;
157
+ let m;
158
+ while ((m = TOOL_TAG.exec(text)) !== null) {
159
+ prose += text.slice(lastIndex, m.index);
160
+ lastIndex = m.index + m[0].length;
161
+ const name = m[1];
162
+ const block = m[2];
163
+ const spec = PARAMS[name];
164
+ if (!spec) { calls.push({ name, args: {}, unknown: true }); continue; }
165
+ const args = extractParams(block, spec);
166
+ // Single-param actions tolerate a bare body: <codeply:read_file>x.js</...>
167
+ if (spec.length && Object.keys(args).length === 0 && block.trim()) {
168
+ args[spec[0]] = block.trim();
169
+ }
170
+ calls.push({ name, args });
171
+ }
172
+ prose += text.slice(lastIndex);
173
+
174
+ if (calls.length > 0) {
175
+ return { prose: prose.trim(), calls, recovered: false, malformed: false };
176
+ }
177
+
178
+ const salvaged = recoverCall(text);
179
+ if (salvaged) {
180
+ // Strip the mangled markup so the user reads the intent, not the debris.
181
+ const cleaned = text
182
+ .replace(/<\/?[a-z_:]+>/gi, ' ')
183
+ .replace(/[ \t]{2,}/g, ' ')
184
+ .trim();
185
+ return { prose: cleaned, calls: [salvaged], recovered: true, malformed: false };
186
+ }
187
+
188
+ return {
189
+ prose: prose.trim(),
190
+ calls: [],
191
+ recovered: false,
192
+ malformed: LOOKS_LIKE_ATTEMPT.test(text),
193
+ };
194
+ }
195
+
196
+ // ─── Project context ────────────────────────────────────────────────────────
197
+
198
+ function gitBranch(cwd) {
199
+ try {
200
+ return execSync('git rev-parse --abbrev-ref HEAD', {
201
+ cwd, stdio: ['ignore', 'pipe', 'ignore'], timeout: 3000,
202
+ }).toString().trim();
203
+ } catch { return null; }
204
+ }
205
+
206
+ /** A compact snapshot of the project, so the agent starts oriented. */
207
+ export function buildProjectContext(cwd) {
208
+ const lines = [`Working directory: ${cwd}`, `Platform: ${process.platform}`];
209
+
210
+ const branch = gitBranch(cwd);
211
+ if (branch) lines.push(`Git branch: ${branch}`);
212
+
213
+ const pkgPath = path.join(cwd, 'package.json');
214
+ if (fs.existsSync(pkgPath)) {
215
+ try {
216
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
217
+ lines.push(`Package: ${pkg.name || '(unnamed)'}${pkg.version ? ' v' + pkg.version : ''}`);
218
+ if (pkg.scripts) lines.push(`npm scripts: ${Object.keys(pkg.scripts).join(', ')}`);
219
+ const deps = Object.keys({ ...pkg.dependencies, ...pkg.devDependencies });
220
+ if (deps.length) lines.push(`Dependencies: ${deps.slice(0, 25).join(', ')}${deps.length > 25 ? ', …' : ''}`);
221
+ } catch {}
222
+ }
223
+
224
+ const files = walkFiles(cwd, { maxFiles: 300, maxDepth: 4 })
225
+ .map((f) => path.relative(cwd, f).replace(/\\/g, '/'))
226
+ .sort();
227
+ if (files.length) {
228
+ lines.push('', `Files (${files.length}${files.length >= 300 ? '+' : ''}):`);
229
+ lines.push(files.slice(0, 200).join('\n'));
230
+ }
231
+
232
+ return lines.join('\n');
233
+ }
234
+
235
+ // ─── Prompt ─────────────────────────────────────────────────────────────────
236
+
237
+ // NOTE ON WORDING: this reference deliberately avoids the words "tool",
238
+ // "call" and "function". The proxy runs openai/gpt-oss-120b, whose harmony
239
+ // training has a dedicated channel for function calls — phrasing this as tool
240
+ // calling made it reach for the *native* mechanism, and Groq then rejected the
241
+ // whole response with "Tool choice is none, but model called a tool" because
242
+ // the proxy declares no tools. Framing it as an output format keeps the model
243
+ // writing plain text. Keep it that way.
244
+ const TOOL_REFERENCE = `OUTPUT FORMAT
245
+
246
+ You have no function-calling ability here. You act purely by writing text.
247
+ When you need something done, write an ACTION BLOCK as literal text in your
248
+ reply, exactly like the examples below, and stop. The program reads your text,
249
+ performs the action, and writes the result back to you as your next message.
250
+
251
+ Never emit a structured/native function invocation. Write the tags as text.
252
+
253
+ <codeply:list_dir>
254
+ <path>src</path>
255
+ </codeply:list_dir>
256
+
257
+ <codeply:read_file>
258
+ <path>src/app.js</path>
259
+ <offset>0</offset>
260
+ <limit>400</limit>
261
+ </codeply:read_file>
262
+
263
+ <codeply:search>
264
+ <pattern>createServer</pattern>
265
+ <glob>**/*.js</glob>
266
+ </codeply:search>
267
+
268
+ <codeply:write_file>
269
+ <path>index.html</path>
270
+ <content>
271
+ ...the complete new file content, verbatim, no escaping...
272
+ </content>
273
+ </codeply:write_file>
274
+
275
+ <codeply:edit_file>
276
+ <path>src/app.js</path>
277
+ <search>
278
+ the exact existing lines, copied character for character
279
+ </search>
280
+ <replace>
281
+ those lines with the change applied
282
+ </replace>
283
+ </codeply:edit_file>
284
+
285
+ <codeply:run>
286
+ <command>node --check index.js</command>
287
+ </codeply:run>
288
+
289
+ RULES
290
+ - Write at most ONE action block per reply, then stop and wait for its result.
291
+ - Do not waste steps. Every action block costs the user several seconds, so never
292
+ perform one whose answer you already have:
293
+ · If the user gave a full path, read it directly — do not list_dir first.
294
+ · After a successful edit_file or write_file, do NOT read the file back. The
295
+ result already confirms it applied. Only re-read a part you have not seen.
296
+ · Do not re-read a file that is already earlier in this conversation.
297
+ - When the result comes back, keep going on your own. Do not ask the user what to do next while you still have obvious work left.
298
+ - read_file before you edit_file. "search" must be copied verbatim from what you just read — never from memory, never with the line-number prefixes the reader adds.
299
+ - edit_file for changes to an existing file. write_file only for new files or a genuine full rewrite.
300
+ - Prefer run for anything you can check mechanically.
301
+ - When the task is finished, reply with prose only and NO action block. That ends your turn.`;
302
+
303
+ const VERIFY_RULES = `VERIFYING YOUR WORK
304
+ You are not done when the code is written. You are done when it is checked.
305
+ - After editing JS/TS, run a syntax check (e.g. node --check <file>).
306
+ - If the project has a test/lint/build script, run the relevant one.
307
+ - If a command fails, read the error, fix the cause, and run it again. Repeat until it passes or you have a specific reason it cannot.
308
+ - Never report success for something you did not verify. If you could not verify it, say exactly that.`;
309
+
310
+ const MODE_PROMPT = {
311
+ Build: `You are Codeply, an autonomous coding agent working in the user's terminal.
312
+
313
+ Do the work the user asked for, end to end. Explore the project before assuming anything about it — read the actual files, do not guess at their contents or invent APIs. Make the change, then verify it.
314
+
315
+ ${VERIFY_RULES}
316
+
317
+ LARGE REWRITES
318
+ One enormous reply can exceed the service's limits and fail outright. For a big
319
+ redesign or a file over ~200 lines, work in stages instead: write_file once with
320
+ the full structure and the main sections, then use edit_file to flesh out or
321
+ restyle one section per step. Each reply stays a manageable size and you keep a
322
+ working file at every point.
323
+
324
+ Keep prose short. A sentence about what you are about to do, then the action block. At the end, a two or three line summary of what changed. No filler, no restating the request back.`,
325
+
326
+ Plan: `You are Codeply, working in Plan mode in the user's terminal.
327
+
328
+ Investigate and produce a concrete plan. You MAY read, list, search, and run read-only commands to ground the plan in what is actually there. You MUST NOT write or edit files in this mode — if the user wants the change applied, tell them to switch to Build mode with tab.
329
+
330
+ Deliver: what you found, the specific files and functions involved, then numbered steps. Be concrete about file paths and names. Flag anything genuinely ambiguous instead of picking silently.`,
331
+
332
+ Ask: `You are Codeply, working in Ask mode in the user's terminal.
333
+
334
+ Answer the user's question. Read files, search, and list to ground your answer in this specific codebase rather than in generalities. Do not write or edit files in this mode.
335
+
336
+ Be concise and concrete. Quote the relevant code and cite it as path:line. If the answer is not in the codebase, say so plainly rather than inventing one.`,
337
+ };
338
+
339
+ const READ_ONLY_MODES = new Set(['Plan', 'Ask']);
340
+
341
+ export function buildSystemPrompt(mode, cwd) {
342
+ const parts = [MODE_PROMPT[mode] || MODE_PROMPT.Build, '', TOOL_REFERENCE];
343
+ if (READ_ONLY_MODES.has(mode)) {
344
+ parts.push('', 'MODE RESTRICTION: write_file and edit_file are disabled. Using them returns an error. run is limited to read-only commands.');
345
+ }
346
+ parts.push('', 'PROJECT CONTEXT', buildProjectContext(cwd));
347
+ return parts.join('\n');
348
+ }
349
+
350
+ // ─── Context budget ─────────────────────────────────────────────────────────
351
+
352
+ /**
353
+ * Keep the transcript under budget by dropping the oldest tool exchanges.
354
+ * The system prompt and the most recent turns always survive.
355
+ */
356
+ function trimTranscript(messages) {
357
+ let total = messages.reduce((n, m) => n + m.content.length, 0);
358
+ if (total <= CONTEXT_CHAR_BUDGET) return messages;
359
+
360
+ const kept = [...messages];
361
+ // index 0 is the system prompt; never touch the last 6 messages.
362
+ for (let i = 1; i < kept.length - 6 && total > CONTEXT_CHAR_BUDGET; i++) {
363
+ if (kept[i].role === 'user' && kept[i].content.startsWith('[tool result')) {
364
+ total -= kept[i].content.length;
365
+ const head = kept[i].content.slice(0, 200);
366
+ kept[i] = { role: 'user', content: `${head}\n[… older tool output dropped to save context]` };
367
+ total += kept[i].content.length;
368
+ }
369
+ }
370
+ return kept;
371
+ }
372
+
373
+ // ─── Model call ─────────────────────────────────────────────────────────────
374
+
375
+ /**
376
+ * gpt-oss-120b occasionally reaches for its native function-calling channel
377
+ * despite the prompt. The proxy declares no tools, so Groq rejects the whole
378
+ * response and we get back nothing usable. Detect that specific failure and
379
+ * retry with a correction rather than killing the user's turn.
380
+ */
381
+ function isNativeToolCallError(error) {
382
+ const s = String(error || '').toLowerCase();
383
+ return s.includes('tool choice is none') ||
384
+ (s.includes('called a tool') && s.includes('tool choice')) ||
385
+ s.includes('tool_choice');
386
+ }
387
+
388
+ const NO_NATIVE_TOOLS_REMINDER =
389
+ '[system] Your last reply tried to invoke a function through the API. That is not available here and the response was discarded. ' +
390
+ 'Write the action block as literal text in your message — the tags exactly as shown in the OUTPUT FORMAT section — and nothing else.';
391
+
392
+ async function callModel(messages) {
393
+ let result = await ai.chat(messages);
394
+ if (result.success || !isNativeToolCallError(result.error)) return { result, messages };
395
+
396
+ const corrected = [...messages, { role: 'user', content: NO_NATIVE_TOOLS_REMINDER }];
397
+ result = await ai.chat(corrected);
398
+ return { result, messages: corrected };
399
+ }
400
+
401
+ // ─── Loop ───────────────────────────────────────────────────────────────────
402
+
403
+ /**
404
+ * Run one user turn to completion.
405
+ *
406
+ * @param {object} o
407
+ * @param {string} o.userMessage
408
+ * @param {Array} o.history prior [{role,content}] turns
409
+ * @param {string} o.mode Build | Plan | Ask
410
+ * @param {string} o.cwd
411
+ * @param {Function} o.approve async ({tool,title,detail,danger}) => 'once'|'always'|'reject'
412
+ * @param {{aborted:boolean}} o.signal
413
+ * @yields {{type:string, ...}} text | tool_start | tool_end | done | error
414
+ */
415
+ export async function* runAgent({ userMessage, history, mode, cwd, approve, signal }) {
416
+ const readOnly = READ_ONLY_MODES.has(mode);
417
+ const messages = [
418
+ { role: 'system', content: buildSystemPrompt(mode, cwd) },
419
+ ...history,
420
+ { role: 'user', content: userMessage },
421
+ ];
422
+
423
+ const ctx = { cwd, approve };
424
+ const transcript = [{ role: 'user', content: userMessage }];
425
+ let malformedRetries = 0;
426
+
427
+ for (let step = 0; step < MAX_STEPS; step++) {
428
+ if (signal.aborted) { yield { type: 'aborted' }; return; }
429
+
430
+ const attempt = await callModel(trimTranscript(messages));
431
+ const result = attempt.result;
432
+ if (!result.success) {
433
+ yield {
434
+ type: 'error',
435
+ error: isNativeToolCallError(result.error)
436
+ ? `The model kept trying to use the provider's function-calling API, which this proxy does not enable (${result.error}). Try rephrasing, or switch modes with tab.`
437
+ : result.error,
438
+ };
439
+ return;
440
+ }
441
+ // The correction message, if one was needed, has to stay in the transcript.
442
+ if (attempt.messages.length > messages.length) {
443
+ messages.push(attempt.messages[attempt.messages.length - 1]);
444
+ }
445
+
446
+ // gpt-oss puts chain-of-thought in a separate field; content can be null.
447
+ const reply = result.data?.choices?.[0]?.message?.content ?? '';
448
+ if (!reply.trim()) {
449
+ messages.push({
450
+ role: 'user',
451
+ content: '[system] Your last reply was empty. Either write one action block as text, or give your final answer.',
452
+ });
453
+ continue;
454
+ }
455
+ messages.push({ role: 'assistant', content: reply });
456
+
457
+ const { prose, calls, recovered, malformed } = parseReply(reply);
458
+ if (prose) {
459
+ yield { type: 'text', text: prose };
460
+ transcript.push({ role: 'assistant', content: prose });
461
+ }
462
+
463
+ if (calls.length === 0) {
464
+ // A reply that was clearly *reaching* for an action but came out
465
+ // unparseable must not be mistaken for "finished" — that silently drops
466
+ // the user's task. Correct it and let it try again.
467
+ if (malformed && malformedRetries < MAX_MALFORMED_RETRIES) {
468
+ malformedRetries++;
469
+ messages.push({ role: 'user', content: FORMAT_CORRECTION });
470
+ continue;
471
+ }
472
+ // No action block: the model considers the turn finished.
473
+ yield { type: 'done', steps: step + 1 };
474
+ return { transcript };
475
+ }
476
+
477
+ if (signal.aborted) { yield { type: 'aborted' }; return; }
478
+
479
+ const call = calls[0];
480
+ if (calls.length > 1) {
481
+ messages.push({
482
+ role: 'user',
483
+ content: '[note] You wrote several action blocks at once. Only the first was performed. Write one per reply.',
484
+ });
485
+ }
486
+ if (recovered) {
487
+ // It worked this time, but only because we guessed. Nudge it back on
488
+ // format so the next step does not depend on the same guess.
489
+ messages.push({ role: 'user', content: FORMAT_CORRECTION });
490
+ }
491
+
492
+ if (readOnly && (call.name === 'write_file' || call.name === 'edit_file')) {
493
+ messages.push({
494
+ role: 'user',
495
+ content: `[tool result: ${call.name}] Blocked — ${mode} mode cannot modify files. Describe the change instead, or tell the user to press tab for Build mode.`,
496
+ });
497
+ yield { type: 'tool_end', name: call.name, args: call.args, ok: false, summary: `blocked in ${mode} mode` };
498
+ continue;
499
+ }
500
+
501
+ yield { type: 'tool_start', name: call.name, args: call.args };
502
+
503
+ const needsApproval = TOOL_NEEDS_APPROVAL.has(call.name);
504
+ const started = Date.now();
505
+ const out = await executeTool(call.name, call.args, ctx);
506
+ const ms = Date.now() - started;
507
+
508
+ yield {
509
+ type: 'tool_end',
510
+ name: call.name,
511
+ args: call.args,
512
+ ok: out.ok,
513
+ meta: out.meta,
514
+ summary: out.meta?.label,
515
+ ms,
516
+ needsApproval,
517
+ };
518
+
519
+ messages.push({ role: 'user', content: `[tool result: ${call.name}]\n${out.output}` });
520
+
521
+ // A declined action is the user's answer, not a retry prompt.
522
+ if (out.meta?.rejected) {
523
+ messages.push({
524
+ role: 'user',
525
+ content: '[note] The user declined that action. Do not retry it. Either continue without it or stop and explain what you would have done.',
526
+ });
527
+ }
528
+ }
529
+
530
+ yield { type: 'error', error: `Stopped after ${MAX_STEPS} steps without finishing. Narrow the request and try again.` };
531
+ }
532
+
533
+ export { MAX_STEPS };