@ryuu-reinzz/ryuu-cli 0.0.0-stage → 0.4.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.
Files changed (63) hide show
  1. package/CHANGELOG.md +592 -0
  2. package/LICENSE +21 -0
  3. package/README.md +1269 -2
  4. package/package.json +45 -4
  5. package/src/agent/agent.js +1194 -0
  6. package/src/agent/approvals.js +76 -0
  7. package/src/agent/context.js +106 -0
  8. package/src/agent/planner.js +237 -0
  9. package/src/agent/worklog.js +318 -0
  10. package/src/cli/app.js +471 -0
  11. package/src/cli/commands.js +1155 -0
  12. package/src/cli/input.js +287 -0
  13. package/src/cli/markdown.js +527 -0
  14. package/src/cli/prompts.js +255 -0
  15. package/src/cli/renderer.js +2155 -0
  16. package/src/cli/settings-editor.js +280 -0
  17. package/src/cli/settings-tui.js +669 -0
  18. package/src/cli/tui.js +1737 -0
  19. package/src/cli/width.js +48 -0
  20. package/src/config/load.js +137 -0
  21. package/src/config/scaffold.js +50 -0
  22. package/src/config/settings-registry.js +495 -0
  23. package/src/config/settings.js +304 -0
  24. package/src/config/validate.js +461 -0
  25. package/src/index.js +449 -0
  26. package/src/providers/catalog.js +407 -0
  27. package/src/providers/ids.js +24 -0
  28. package/src/providers/index.js +103 -0
  29. package/src/providers/nvidia.js +671 -0
  30. package/src/providers/registry.js +104 -0
  31. package/src/providers/river.js +198 -0
  32. package/src/providers/tokenharbor.js +38 -0
  33. package/src/providers/vendor-extras.js +82 -0
  34. package/src/services/clipboard.js +38 -0
  35. package/src/services/diff.js +153 -0
  36. package/src/services/doctor.js +126 -0
  37. package/src/services/logger.js +190 -0
  38. package/src/services/preferences.js +249 -0
  39. package/src/services/processes.js +345 -0
  40. package/src/services/provider-settings.js +226 -0
  41. package/src/services/sessions.js +279 -0
  42. package/src/services/undo.js +133 -0
  43. package/src/services/ushell.js +146 -0
  44. package/src/services/websearch/manager.js +616 -0
  45. package/src/services/websearch/mcp.js +233 -0
  46. package/src/services/websearch/normalize.js +284 -0
  47. package/src/services/websearch/providers.js +178 -0
  48. package/src/services/websearch/serper.js +263 -0
  49. package/src/services/workspace.js +589 -0
  50. package/src/tools/filesystem.js +304 -0
  51. package/src/tools/git.js +114 -0
  52. package/src/tools/index.js +48 -0
  53. package/src/tools/project.js +160 -0
  54. package/src/tools/registry.js +118 -0
  55. package/src/tools/search.js +210 -0
  56. package/src/tools/shell.js +279 -0
  57. package/src/tools/url.js +542 -0
  58. package/src/tools/web.js +67 -0
  59. package/src/utils/errors.js +160 -0
  60. package/src/utils/network.js +129 -0
  61. package/src/utils/redaction.js +73 -0
  62. package/src/utils/validation.js +370 -0
  63. package/src/utils/version.js +24 -0
@@ -0,0 +1,1194 @@
1
+ import fsp from 'node:fs/promises';
2
+ import { ApprovalManager } from './approvals.js';
3
+ import { buildSystemPrompt, buildTaskBrief, buildToolCatalog, extractPlan, summarizeToolResult } from './planner.js';
4
+ import { Worklog } from './worklog.js';
5
+ import { compactMessages, conversationChars } from './context.js';
6
+ import { CancelledError, RyuuError, describeError, isRecoverableError } from '../utils/errors.js';
7
+ import { parseToolArguments, toToolArgumentsString, validateSchema } from '../utils/validation.js';
8
+ import { createSessionRecord } from '../services/sessions.js';
9
+ import { createUnifiedDiff } from '../services/diff.js';
10
+ import { buildProjectSummary } from '../tools/project.js';
11
+
12
+ const UNDO_WRITE_TOOLS = new Set(['write_file', 'edit_file', 'create_file']);
13
+ const CHANGE_TOOLS = new Set(['write_file', 'edit_file', 'create_file', 'move_file', 'delete_file']);
14
+
15
+ /** How many times an identical failing call may be repeated before it is blocked. */
16
+ const MAX_REPEATED_FAILURES = 2;
17
+
18
+ const nowIso = () => new Date().toISOString();
19
+
20
+ const WRITE_TOOLS = new Set(['create_file', 'write_file', 'edit_file', 'move_file', 'delete_file']);
21
+
22
+ /** One short, human label for what a tool is about to touch. */
23
+ function describeToolTarget(name, args = {}) {
24
+ if (name === 'run_command') {
25
+ const argv = Array.isArray(args.argv) ? args.argv.join(' ') : String(args.shell_command || '');
26
+ return argv.slice(0, 120);
27
+ }
28
+ if (name === 'start_process') return (args.argv || []).join(' ').slice(0, 120);
29
+ if (name === 'search_files' || name === 'web_search') return String(args.query || args.pattern || '').slice(0, 90);
30
+ if (name === 'read_url') return String(args.url || '').slice(0, 120);
31
+ if (name === 'list_files' || name === 'tree') return String(args.path || '.').slice(0, 80);
32
+ if (name === 'move_file') return `${args.from} -> ${args.to}`.slice(0, 120);
33
+ if (args.path) {
34
+ const lineRange = args.start_line || args.end_line ? ` lines ${args.start_line || 1}-${args.end_line || 'end'}` : '';
35
+ return `${args.path}${lineRange}`.slice(0, 120);
36
+ }
37
+ return '';
38
+ }
39
+
40
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
41
+
42
+ /** Sleep that rejects as soon as the task is cancelled. */
43
+ function abortableDelay(ms, signal) {
44
+ return new Promise((resolve, reject) => {
45
+ if (signal?.aborted) {
46
+ reject(new CancelledError('Task cancelled by user.'));
47
+ return;
48
+ }
49
+ const onAbort = () => {
50
+ clearTimeout(timer);
51
+ reject(new CancelledError('Task cancelled by user.'));
52
+ };
53
+ const timer = setTimeout(() => {
54
+ signal?.removeEventListener('abort', onAbort);
55
+ resolve();
56
+ }, ms);
57
+ signal?.addEventListener('abort', onAbort, { once: true });
58
+ });
59
+ }
60
+
61
+ /**
62
+ * Wording that gets an empty model turn going again. Rotated so a model that
63
+ * keeps replying with nothing is not told the exact same thing forever.
64
+ */
65
+ function emptyResponseNudge(attempt) {
66
+ const nudges = [
67
+ 'Empty response. Continue the task: call the next tool, or write the final answer. Do not restart the analysis.',
68
+ 'The response was empty again. Reply with a tool call or the final answer — never an empty turn, and do not repeat work already done.',
69
+ 'Still empty. Do not re-analyze or re-read anything the worklog already covers: if the task is finished, write the result now; otherwise call the next tool.',
70
+ 'Empty response repeated. State the next concrete action and call the tool for it, or deliver the final answer below.',
71
+ ];
72
+ return nudges[Math.min(Math.max(0, attempt - 1), nudges.length - 1)];
73
+ }
74
+
75
+ /**
76
+ * Replace invalid `function.arguments` in a message history with valid JSON.
77
+ *
78
+ * The provider re-validates every tool call it receives, so a single broken
79
+ * payload makes the whole next request fail with a 400. Repairing it in place
80
+ * keeps the conversation usable and the task alive.
81
+ *
82
+ * @returns {number} how many calls were repaired
83
+ */
84
+ function repairMessageHistory(messages = []) {
85
+ let repaired = 0;
86
+ for (const message of messages) {
87
+ if (!message || !Array.isArray(message.tool_calls)) continue;
88
+ for (const call of message.tool_calls) {
89
+ const fn = call?.function;
90
+ if (!fn) continue;
91
+ const original = typeof fn.arguments === 'string' ? fn.arguments : JSON.stringify(fn.arguments ?? {});
92
+ const fixed = toToolArgumentsString(original);
93
+ if (fixed !== original) {
94
+ fn.arguments = fixed;
95
+ repaired += 1;
96
+ }
97
+ }
98
+ }
99
+ return repaired;
100
+ }
101
+
102
+ /**
103
+ * The autonomous loop:
104
+ * model -> tool calls -> validation -> approval -> real execution ->
105
+ * tool results -> model -> ... until final answer / limit / cancel.
106
+ *
107
+ * Tools always run sequentially, so two calls can never race on the same file.
108
+ */
109
+ export class Agent {
110
+ constructor({
111
+ provider,
112
+ registry,
113
+ workspace,
114
+ config,
115
+ renderer = {},
116
+ logger = null,
117
+ approvals = null,
118
+ sessionStore = null,
119
+ undo = null,
120
+ projectSummary = null,
121
+ processes = null,
122
+ }) {
123
+ if (!provider) throw new Error('Agent requires a provider.');
124
+ if (!registry) throw new Error('Agent requires a tool registry.');
125
+ if (!workspace) throw new Error('Agent requires a workspace.');
126
+ this.provider = provider;
127
+ this.registry = registry;
128
+ this.workspace = workspace;
129
+ this.config = config;
130
+ this.renderer = renderer;
131
+ this.logger = logger;
132
+ this.approvals = approvals || new ApprovalManager({ policy: config.approvalPolicy, logger, interactive: false });
133
+ this.sessionStore = sessionStore;
134
+ this.undo = undo;
135
+ this.projectSummary = projectSummary;
136
+ this.processes = processes;
137
+ this.session = null;
138
+ // Conversation memory shared by every task in this process.
139
+ this.conversation = [];
140
+ // Verified facts (files read/changed, commands, findings, plan) shared by
141
+ // every task in this session. This is what stops a follow-up task from
142
+ // re-reading and re-analysing work the agent already did.
143
+ this.worklog = new Worklog();
144
+ // The session prompt is assembled once and reused byte-for-byte.
145
+ this.systemMessage = null;
146
+ this.#systemPromptSource = null;
147
+ }
148
+
149
+ #systemPromptSource = null;
150
+
151
+ /** Drop all remembered turns and the verified worklog. */
152
+ clearMemory() {
153
+ this.conversation = [];
154
+ this.worklog.reset();
155
+ }
156
+
157
+ /** Verified session facts, exposed for /worklog and /memory. */
158
+ getWorklog() {
159
+ return this.worklog;
160
+ }
161
+
162
+ /**
163
+ * Point the loop at another provider (used by /provider). The conversation
164
+ * memory and worklog stay untouched, so a switch never loses context.
165
+ */
166
+ setProvider(provider) {
167
+ if (!provider) throw new Error('Agent requires a provider.');
168
+ this.provider = provider;
169
+ }
170
+
171
+ /**
172
+ * Replace the remembered turns (used by /resume to restore a saved session).
173
+ * A saved worklog can be restored with it so the agent resumes with the same
174
+ * verified facts instead of re-reading every file.
175
+ */
176
+ setConversation(entries, { worklog = null } = {}) {
177
+ this.conversation = Array.isArray(entries)
178
+ ? entries
179
+ .filter((entry) => entry && (entry.role === 'user' || entry.role === 'assistant'))
180
+ .map((entry) => ({ role: entry.role, content: String(entry.content || '').slice(0, 8000) }))
181
+ : [];
182
+ this.worklog = worklog ? Worklog.fromJSON(worklog) : new Worklog();
183
+ this.#boundConversation();
184
+ }
185
+
186
+ /** A read-only view of the remembered turns. */
187
+ getConversation() {
188
+ return this.conversation.map((entry) => ({ ...entry }));
189
+ }
190
+
191
+ /**
192
+ * Remember a shell command the user ran themselves (`/bash`, `!cmd`).
193
+ *
194
+ * It becomes a user turn in the shared conversation, so the next task knows
195
+ * what happened outside the model's own tool calls (tests run, files listed,
196
+ * a deploy attempted) instead of asking the user to repeat it. Output is
197
+ * bounded: this is context, not a log file.
198
+ */
199
+ recordUserShell({ command, cwd = '.', stdout = '', stderr = '', exitCode = null, durationMs = 0, truncated = false } = {}) {
200
+ const text = String(command ?? '').trim();
201
+ if (!text) return false;
202
+ const output = [stdout, stderr]
203
+ .map((part) => String(part ?? '').trimEnd())
204
+ .filter((part) => part.length > 0)
205
+ .join('\n');
206
+ const bounded = output.length > 4000 ? `${output.slice(0, 4000)}\n… (output truncated for context)` : output;
207
+ this.conversation.push({
208
+ role: 'user',
209
+ content: [
210
+ `[user shell] $ ${text}`,
211
+ `[cwd ${cwd}] exit ${exitCode ?? '?'} · ${Math.round(Number(durationMs) || 0)}ms${truncated ? ' · output truncated' : ''}`,
212
+ bounded || '(no output)',
213
+ ].join('\n'),
214
+ });
215
+ this.worklog.recordCommand({
216
+ argv: ['sh', '-c', text],
217
+ exitCode: Number.isInteger(exitCode) ? exitCode : null,
218
+ durationMs: Number(durationMs) || null,
219
+ status: 'user',
220
+ });
221
+ this.#boundConversation();
222
+ return true;
223
+ }
224
+
225
+ /**
226
+ * Remember one finished task so the next task can build on it.
227
+ *
228
+ * Only user/assistant text is kept — never raw tool messages — so the stored
229
+ * history is always valid for the provider and stays small. The evidence line
230
+ * preserves which files changed and which commands ran.
231
+ */
232
+ #remember(task, finalText, { status, changedFiles = [], commands = [] } = {}) {
233
+ if (!task) return;
234
+ const files = changedFiles.slice(0, 12);
235
+ const ran = commands.slice(-5).map((cmd) => {
236
+ const argv = Array.isArray(cmd.argv) ? cmd.argv.join(' ') : String(cmd.argv || '');
237
+ return `${argv} -> exit ${cmd.exitCode ?? '?'}`;
238
+ });
239
+ const evidence = [files.length > 0 ? `files: ${files.join(', ')}` : '', ran.length > 0 ? `commands: ${ran.join('; ')}` : '']
240
+ .filter(Boolean)
241
+ .join(' · ');
242
+ const reply = [
243
+ finalText || `(no final answer; task ended with status: ${status})`,
244
+ evidence ? `[evidence] status=${status} · ${evidence}` : `[evidence] status=${status}`,
245
+ ].join('\n');
246
+ this.conversation.push({ role: 'user', content: task }, { role: 'assistant', content: reply });
247
+ this.worklog.recordTask({ task, status, changedFiles });
248
+ this.#boundConversation();
249
+ }
250
+
251
+ /** Keep memory inside half of agent.maxContextChars so a task still fits. */
252
+ #boundConversation() {
253
+ const budget = Math.max(2000, Math.floor(Number(this.config.maxContextChars) / 2));
254
+ while (this.conversation.length > 4 && conversationChars(this.conversation) > budget) {
255
+ this.conversation.splice(0, 2);
256
+ }
257
+ if (this.conversation.length > 100) {
258
+ this.conversation.splice(0, this.conversation.length - 100);
259
+ }
260
+ }
261
+
262
+ #emit(event, payload) {
263
+ const fn = this.renderer?.[event];
264
+ if (typeof fn === 'function') {
265
+ try {
266
+ fn.call(this.renderer, payload);
267
+ } catch (error) {
268
+ this.logger?.warn('renderer error', { event, error: error.message });
269
+ }
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Ask the model, with a heartbeat and automatic reconnection.
275
+ *
276
+ * A provider failure is not the end of the task: the link is re-established
277
+ * with a bounded exponential backoff while the UI keeps beating, so the run
278
+ * continues as soon as the API answers again. A history poisoned by an
279
+ * invalid tool payload is repaired before the retry, because the provider
280
+ * re-validates the whole conversation on every request.
281
+ */
282
+ async #requestModel(messages, { step, signal, onReasoning, onContent, onFirstToken = null, deadline, timeoutMs = null }) {
283
+ // 0 means unlimited: the heartbeat keeps reconnecting until the provider
284
+ // answers, the task deadline is reached, or the user cancels. A positive
285
+ // value still caps the attempts for anyone who wants a hard stop.
286
+ const configured = Number(this.config.maxRecoveryAttempts ?? 0);
287
+ const maxAttempts = Number.isFinite(configured) && configured > 0 ? configured : Infinity;
288
+ const attemptLabel = Number.isFinite(maxAttempts) ? maxAttempts : 0;
289
+ const baseDelay = Math.max(100, Number(this.config.recoveryBackoffMs ?? 1000));
290
+ let attempt = 0;
291
+ for (;;) {
292
+ if (signal?.aborted) throw new CancelledError('Task cancelled by user.');
293
+ try {
294
+ // Capabilities are declared per provider; never assume every endpoint
295
+ // supports tools or streaming, and never claim reasoning we did not get.
296
+ const capabilities = this.provider.capabilities || null;
297
+ const tools = capabilities?.tools === false ? [] : this.registry.toOpenAITools();
298
+ const response = await this.provider.chat({
299
+ messages,
300
+ tools,
301
+ signal,
302
+ stream: capabilities?.streaming !== false,
303
+ onReasoning,
304
+ onContent,
305
+ onFirstToken,
306
+ // A stream that stops producing bytes is aborted instead of waiting
307
+ // for the request timeout, so a hung provider is reported, not slept on.
308
+ stallTimeoutMs: this.config.streamIdleTimeoutMs ?? 0,
309
+ timeoutMs: timeoutMs ?? undefined,
310
+ });
311
+ if (attempt > 0) {
312
+ this.#emit('heartbeat', {
313
+ phase: 'restored',
314
+ step,
315
+ attempt,
316
+ maxAttempts: attemptLabel,
317
+ message: 'Provider link restored — the run continues.',
318
+ });
319
+ }
320
+ return response;
321
+ } catch (error) {
322
+ if (error instanceof CancelledError || signal?.aborted) throw error;
323
+ if (attempt >= maxAttempts || !isRecoverableError(error)) throw error;
324
+ attempt += 1;
325
+ if (error.code === 'INVALID_TOOL_ARGUMENTS') {
326
+ const repaired = repairMessageHistory(messages);
327
+ if (repaired > 0) {
328
+ this.logger?.warn('repaired invalid tool-call arguments in history', { count: repaired });
329
+ }
330
+ }
331
+ const delayMs = Math.min(30000, baseDelay * 2 ** (attempt - 1));
332
+ if (deadline && Date.now() + delayMs > deadline) {
333
+ // No time left to reconnect: surface the original failure.
334
+ throw error;
335
+ }
336
+ this.#emit('heartbeat', {
337
+ phase: 'retry',
338
+ step,
339
+ attempt,
340
+ maxAttempts: attemptLabel,
341
+ delayMs,
342
+ code: error.code || 'PROVIDER_ERROR',
343
+ message: error.message,
344
+ });
345
+ this.logger?.warn('provider recovery attempt', {
346
+ code: error.code,
347
+ attempt,
348
+ maxAttempts: attemptLabel || 'unlimited',
349
+ delayMs,
350
+ });
351
+ await abortableDelay(delayMs, signal);
352
+ }
353
+ }
354
+ }
355
+
356
+ /**
357
+ * The project summary (manifest, scripts, language mix) used by the system
358
+ * prompt. The host usually provides it; when it does not, it is built once
359
+ * and cached so the model always knows the project's own commands.
360
+ */
361
+ async #projectSummary() {
362
+ if (this.projectSummary !== undefined && this.projectSummary !== null) return this.projectSummary;
363
+ try {
364
+ this.projectSummary = await buildProjectSummary({ workspace: this.workspace });
365
+ } catch (error) {
366
+ this.logger?.warn('project summary unavailable', { error: error.message });
367
+ this.projectSummary = null;
368
+ }
369
+ return this.projectSummary;
370
+ }
371
+
372
+ /**
373
+ * The session prompt, assembled once and reused byte-for-byte.
374
+ *
375
+ * Rebuilding it per request did not change its content, but it re-serialized
376
+ * the project summary and tool catalog on every step and made the prefix
377
+ * unstable for provider-side caching. It is now built on the first request of
378
+ * the session and only rebuilt if the host swaps the project summary.
379
+ */
380
+ async #systemPrompt() {
381
+ const projectSummary = await this.#projectSummary();
382
+ if (this.systemMessage && this.#systemPromptSource === projectSummary) return this.systemMessage;
383
+ this.systemMessage = buildSystemPrompt({
384
+ workspace: this.workspace.root,
385
+ config: this.config,
386
+ projectSummary,
387
+ toolCatalog: buildToolCatalog(this.registry),
388
+ });
389
+ this.#systemPromptSource = projectSummary;
390
+ return this.systemMessage;
391
+ }
392
+
393
+ /** The step/wall-clock budget shown in the task brief and near the deadline. */
394
+ #budgetText({ stepLimit, deadline, modelCalls = 0 }) {
395
+ const secondsLeft = Math.max(0, Math.ceil((deadline - Date.now()) / 1000));
396
+ const steps = Number.isFinite(stepLimit) ? `up to ${stepLimit} steps` : 'unlimited steps';
397
+ return `${steps} · ${secondsLeft}s of wall clock left · ${modelCalls} model call(s) made so far`;
398
+ }
399
+
400
+ /** Current mtime of a workspace path, or null when it cannot be stat'ed. */
401
+ async #mtimeOf(relPath) {
402
+ try {
403
+ const resolved = this.workspace.resolve(relPath);
404
+ const stat = await fsp.stat(resolved.absPath);
405
+ return stat.mtimeMs;
406
+ } catch {
407
+ return null;
408
+ }
409
+ }
410
+
411
+ /**
412
+ * Keep the worklog in sync with a successful tool result. Reads are stamped
413
+ * with the real mtime so "already read" can never hide a file that changed on
414
+ * disk in the meantime.
415
+ */
416
+ async #noteWorklog(name, args, result) {
417
+ if (!result || typeof result !== 'object') return;
418
+ this.worklog.recordToolResult(name, args, result);
419
+ if (name === 'read_file' && result.path) {
420
+ const mtimeMs = await this.#mtimeOf(result.path);
421
+ if (mtimeMs !== null) {
422
+ this.worklog.recordRead({
423
+ path: result.path,
424
+ startLine: result.start_line,
425
+ endLine: result.end_line,
426
+ totalLines: result.total_lines,
427
+ mtimeMs,
428
+ });
429
+ }
430
+ }
431
+ }
432
+
433
+ async #captureBefore(name, args) {
434
+ if (!this.undo || !['write_file', 'edit_file', 'delete_file'].includes(name)) return null;
435
+ try {
436
+ const file = await this.workspace.readText(args.path, { maxBytes: this.config.maxFileBytes });
437
+ return { content: file.text };
438
+ } catch (error) {
439
+ if (error.code === 'NOT_FOUND') return { content: null };
440
+ return null;
441
+ }
442
+ }
443
+
444
+ async #recordUndo(name, args, result, before) {
445
+ if (!this.undo) return;
446
+ try {
447
+ if (name === 'create_file') {
448
+ this.undo.record({ kind: 'write', path: result.relPath, before: null, after: args.content });
449
+ } else if (name === 'write_file' || name === 'edit_file') {
450
+ const current = await this.workspace.readText(result.relPath || args.path);
451
+ this.undo.record({
452
+ kind: 'write',
453
+ path: current.relPath,
454
+ before: before?.content ?? null,
455
+ after: current.text,
456
+ });
457
+ } else if (name === 'delete_file' && result.kind !== 'directory' && before?.content != null) {
458
+ this.undo.record({ kind: 'delete', path: result.relPath, before: before.content });
459
+ } else if (name === 'move_file') {
460
+ this.undo.record({ kind: 'move', path: result.from, to: result.to });
461
+ }
462
+ } catch (error) {
463
+ this.logger?.warn('failed to record undo snapshot', { tool: name, error: error.message });
464
+ }
465
+ }
466
+
467
+ /**
468
+ * The exact lines a file change produced, so the UI can show the user what
469
+ * was rewritten (and they can cancel and correct it).
470
+ */
471
+ #fileDiffPayload(name, args, result, before) {
472
+ if (!CHANGE_TOOLS.has(name) || !result) return null;
473
+ const relPath = result.relPath || result.path || args?.path;
474
+ if (!relPath) return null;
475
+ if (name === 'move_file') {
476
+ return { kind: 'move', path: result.to || relPath, from: result.from, to: result.to, added: 0, removed: 0, diff: '' };
477
+ }
478
+ if (name === 'delete_file') {
479
+ return { kind: 'delete', path: relPath, added: 0, removed: 0, diff: '' };
480
+ }
481
+ let diff = '';
482
+ if (name === 'edit_file' && typeof result.diff === 'string' && result.diff) {
483
+ diff = result.diff;
484
+ } else {
485
+ const older = before?.content ?? '';
486
+ const newer = typeof args?.content === 'string' ? args.content : '';
487
+ if (older !== newer) {
488
+ diff = createUnifiedDiff(older, newer, {
489
+ oldLabel: `a/${relPath}`,
490
+ newLabel: `b/${relPath}`,
491
+ maxLines: 160,
492
+ });
493
+ }
494
+ }
495
+ let added = 0;
496
+ let removed = 0;
497
+ for (const line of diff.split('\n')) {
498
+ if (line.startsWith('+++') || line.startsWith('---')) continue;
499
+ if (line.startsWith('+')) added += 1;
500
+ else if (line.startsWith('-')) removed += 1;
501
+ }
502
+ const kind = name === 'create_file' ? 'create' : name === 'write_file' ? 'write' : 'edit';
503
+ return { kind, path: relPath, diff: diff.slice(0, 20000), added, removed };
504
+ }
505
+
506
+ #trackResult(name, args, result, session, taskProcesses) {
507
+ if (CHANGE_TOOLS.has(name)) {
508
+ const changed = result.relPath || result.to || result.path || args.path || args.from;
509
+ if (changed) session.changedFiles = [...new Set([...session.changedFiles, changed])];
510
+ }
511
+ if (name === 'run_command') {
512
+ session.commands.push({
513
+ argv: result.argv || args.argv || [],
514
+ status: result.status,
515
+ exitCode: result.exit_code,
516
+ durationMs: result.duration_ms,
517
+ timedOut: result.timed_out,
518
+ output: `${result.stdout || ''}${result.stderr ? `\n${result.stderr}` : ''}`.slice(-2000),
519
+ });
520
+ if (session.commands.length > 50) session.commands.shift();
521
+ }
522
+ if (name === 'start_process' && result.process_id) {
523
+ taskProcesses.add(result.process_id);
524
+ }
525
+ if (name === 'stop_process' && result.id) taskProcesses.delete(result.id);
526
+ }
527
+
528
+ #truncateResult(result) {
529
+ const limit = this.config.maxToolOutputBytes;
530
+ let json;
531
+ try {
532
+ json = JSON.stringify(result);
533
+ } catch {
534
+ json = JSON.stringify({ error: 'Tool result was not serializable.' });
535
+ }
536
+ if (json.length <= limit) return json;
537
+ return JSON.stringify({
538
+ truncated: true,
539
+ note: `Tool result exceeded ${limit} characters and was truncated. Narrow the request or use pagination.`,
540
+ preview: json.slice(0, limit),
541
+ });
542
+ }
543
+
544
+ #toolReply(messages, call, payload, session) {
545
+ const content = this.#truncateResult(payload);
546
+ messages.push({ role: 'tool', tool_call_id: call.id, content });
547
+ session.transcript.push({
548
+ role: 'tool',
549
+ name: call.name,
550
+ ok: payload?.ok !== false,
551
+ content,
552
+ at: nowIso(),
553
+ });
554
+ if (session.transcript.length > 200) session.transcript.shift();
555
+ }
556
+
557
+ #buildSummary({ status, finalText, plan, steps, startedAt, session, taskProcesses, error = null }) {
558
+ const running = [...taskProcesses].map((id) => {
559
+ try {
560
+ const record = this.processes?.get(id);
561
+ return record ? { id, label: record.label, status: record.status } : { id, status: 'unknown' };
562
+ } catch {
563
+ return { id, status: 'unknown' };
564
+ }
565
+ }).filter((entry) => entry.status === 'running');
566
+ const tests = session.commands
567
+ .filter((cmd) => /(^|[/\s])(test|tests|check|lint|typecheck|vitest|jest|pytest|tsc|eslint)([\s:]|$)/i.test(cmd.argv.join(' ')))
568
+ .map((cmd) => ({ argv: cmd.argv.join(' '), exitCode: cmd.exitCode, passed: cmd.exitCode === 0 }));
569
+ const summary = {
570
+ sessionId: session.id,
571
+ task: session.task,
572
+ model: this.config.model,
573
+ status,
574
+ steps,
575
+ maxSteps: Number.isFinite(this.config.maxSteps) && this.config.maxSteps > 0 ? this.config.maxSteps : null,
576
+ durationMs: Date.now() - startedAt,
577
+ finalText,
578
+ plan,
579
+ changedFiles: session.changedFiles,
580
+ commands: session.commands.map((cmd) => ({
581
+ argv: cmd.argv,
582
+ status: cmd.status,
583
+ exitCode: cmd.exitCode,
584
+ durationMs: cmd.durationMs,
585
+ timedOut: cmd.timedOut,
586
+ })),
587
+ tests,
588
+ runningProcesses: running,
589
+ tokenUsage: session.tokenUsage,
590
+ metrics: {
591
+ modelCalls: session.modelCalls || 0,
592
+ requestMs: session.requestMs || 0,
593
+ avgRequestMs: session.modelCalls ? Math.round((session.requestMs || 0) / session.modelCalls) : 0,
594
+ firstTokenMs: session.firstTokenMs ?? null,
595
+ cachedReads: session.cachedReads || 0,
596
+ promptTokens: session.tokenUsage.prompt,
597
+ completionTokens: session.tokenUsage.completion,
598
+ },
599
+ approvals: this.approvals.stats,
600
+ issues: session.issues,
601
+ undoCount: this.undo?.size ?? 0,
602
+ logFile: this.logger?.logFile || null,
603
+ error: error ? { code: error.code || 'ERROR', message: describeError(error) } : null,
604
+ };
605
+ return summary;
606
+ }
607
+
608
+ /**
609
+ * Run one task to completion.
610
+ * @returns {Promise<object>} deterministic summary with status and evidence
611
+ */
612
+ async run(task, { signal: externalSignal = null, sessionId = null } = {}) {
613
+ if (typeof task !== 'string' || task.trim() === '') throw new RyuuError('Task must be a non-empty string.', { code: 'EMPTY_TASK' });
614
+ const startedAt = Date.now();
615
+ const deadline = startedAt + this.config.maxTaskDurationMs;
616
+ const controller = new AbortController();
617
+ const onExternalAbort = () => controller.abort();
618
+ if (externalSignal) {
619
+ if (externalSignal.aborted) controller.abort();
620
+ else externalSignal.addEventListener('abort', onExternalAbort, { once: true });
621
+ }
622
+ const signal = controller.signal;
623
+
624
+ // Steps are unlimited by default; agent.maxSteps > 0 adds an optional cap.
625
+ const stepLimit = Number(this.config.maxSteps) > 0 ? this.config.maxSteps : Infinity;
626
+ const stepLimitLabel = Number.isFinite(stepLimit) ? stepLimit : null;
627
+
628
+ const session = createSessionRecord({
629
+ task: task.trim(),
630
+ model: this.config.model,
631
+ workspace: this.workspace.root,
632
+ id: sessionId || undefined,
633
+ });
634
+ this.session = session;
635
+ if (this.undo) this.undo.reset(session.id);
636
+ const taskProcesses = new Set();
637
+
638
+ let plan = '';
639
+ let finalText = '';
640
+ let status = 'failed';
641
+ let steps = 0;
642
+ let error = null;
643
+ // One prompt per session, then memory, then a single task brief that
644
+ // carries the task, the budget and every verified fact from earlier work.
645
+ let messages = [
646
+ { role: 'system', content: await this.#systemPrompt() },
647
+ ...this.conversation.map((entry) => ({ ...entry })),
648
+ {
649
+ role: 'user',
650
+ content: buildTaskBrief({
651
+ task: task.trim(),
652
+ worklog: this.worklog,
653
+ budget: this.#budgetText({ stepLimit, deadline }),
654
+ }),
655
+ },
656
+ ];
657
+ const budgetCeiling = this.config.maxTaskDurationMs;
658
+ let budgetWarned = false;
659
+ session.transcript.push({ role: 'user', content: task.trim(), at: nowIso() });
660
+
661
+ this.#emit('taskStart', {
662
+ task: task.trim(),
663
+ sessionId: session.id,
664
+ model: this.config.model,
665
+ workspace: this.workspace.root,
666
+ maxSteps: stepLimitLabel,
667
+ });
668
+ this.logger?.info('task started', { sessionId: session.id, model: this.config.model, taskLength: task.length });
669
+
670
+ const facts = () => ({
671
+ task: task.trim(),
672
+ plan,
673
+ changedFiles: session.changedFiles,
674
+ commands: session.commands,
675
+ issues: session.issues,
676
+ });
677
+
678
+ const checkAbort = () => {
679
+ if (signal.aborted) throw new CancelledError('Task cancelled by user.');
680
+ if (Date.now() > deadline) {
681
+ throw new RyuuError(
682
+ `Task exceeded the ${Math.round(this.config.maxTaskDurationMs / 1000)}s time limit. Increase agent.maxTaskDurationMs or split the task.`,
683
+ { code: 'TASK_TIMEOUT' },
684
+ );
685
+ }
686
+ };
687
+
688
+ let currentReasoning = '';
689
+ // Identical failing calls are counted so a looping model is stopped early.
690
+ const failureCounts = new Map();
691
+ try {
692
+ let emptyResponses = 0;
693
+ let finished = false;
694
+ for (let step = 1; step <= stepLimit; step += 1) {
695
+ checkAbort();
696
+ steps = step;
697
+ currentReasoning = '';
698
+ // Past 70% of the wall clock, tell the model to converge once. Without
699
+ // this the run only learned about the deadline when it was killed.
700
+ if (!budgetWarned && Date.now() - startedAt > budgetCeiling * 0.7) {
701
+ budgetWarned = true;
702
+ messages.push({
703
+ role: 'system',
704
+ content: `BUDGET: about ${Math.max(0, Math.round((deadline - Date.now()) / 1000))}s of wall clock left. Stop exploring and stop re-analyzing known facts: deliver the smallest correct result now and report what is left.`,
705
+ });
706
+ this.#emit('warning', { message: '70% of the time budget is used — asking the model to converge.' });
707
+ }
708
+ this.#emit('stepStart', { step, maxSteps: stepLimitLabel });
709
+ const compacted = compactMessages(messages, { maxChars: this.config.maxContextChars, facts: facts() });
710
+ if (compacted.compacted) {
711
+ messages = compacted.messages;
712
+ this.#emit('warning', { message: `Context compacted: ${compacted.removedGroups} older exchange(s) removed.` });
713
+ }
714
+
715
+ this.#emit('thinkingStart', { step, maxSteps: stepLimitLabel });
716
+ const requestStarted = Date.now();
717
+ let response;
718
+ let firstTokenMs = null;
719
+ session.modelCalls += 1;
720
+ try {
721
+ response = await this.#requestModel(messages, {
722
+ step,
723
+ signal,
724
+ deadline,
725
+ // A request can never outlive the task deadline.
726
+ timeoutMs: Math.max(1000, Math.min(this.config.requestTimeoutMs, deadline - Date.now())),
727
+ onReasoning: (text) => {
728
+ if (!text) return;
729
+ if (currentReasoning.length < 2_000_000) currentReasoning += text;
730
+ this.#emit('reasoningDelta', { text });
731
+ },
732
+ onContent: (text) => {
733
+ if (!text) return;
734
+ this.#emit('contentDelta', { text });
735
+ },
736
+ onFirstToken: ({ latencyMs } = {}) => {
737
+ firstTokenMs = latencyMs ?? null;
738
+ if (session.firstTokenMs === null) session.firstTokenMs = firstTokenMs;
739
+ this.#emit('progress', {
740
+ step,
741
+ maxSteps: stepLimitLabel,
742
+ phase: 'thinking',
743
+ firstTokenMs,
744
+ requestMs: Date.now() - requestStarted,
745
+ promptTokens: session.tokenUsage.prompt,
746
+ completionTokens: session.tokenUsage.completion,
747
+ });
748
+ },
749
+ });
750
+ } finally {
751
+ const requestMs = Date.now() - requestStarted;
752
+ session.requestMs += requestMs;
753
+ this.#emit('thinkingEnd', {
754
+ step,
755
+ durationMs: requestMs,
756
+ firstTokenMs,
757
+ usage: response?.usage || null,
758
+ finishReason: response?.finishReason || null,
759
+ truncated: response?.finishReason === 'length',
760
+ });
761
+ }
762
+ const reasoning = response.reasoning || currentReasoning;
763
+ const calls = response.toolCalls || [];
764
+ const truncated = response.finishReason === 'length';
765
+
766
+ // The plan is the model's forward-looking step list, and only a
767
+ // response that continues with tool calls can carry one. A final
768
+ // answer (no calls) is never a plan, so numbered explanation lists
769
+ // inside the answer can no longer be mistaken for it. The plan is
770
+ // emitted before the reasoning summary so it leads the step.
771
+ if (calls.length > 0 && response.content && !plan) {
772
+ const extracted = extractPlan(response.content);
773
+ if (extracted) {
774
+ plan = extracted;
775
+ session.plan = plan;
776
+ this.worklog.recordPlan(plan);
777
+ this.#emit('plan', { plan });
778
+ session.transcript.push({ role: 'assistant', content: plan, at: nowIso() });
779
+ }
780
+ }
781
+
782
+ if (reasoning) {
783
+ const logFile = this.logger?.writeReasoning({ sessionId: session.id, step, text: reasoning }) || null;
784
+ this.#emit('reasoningEnd', { logFile });
785
+ }
786
+ session.tokenUsage.prompt += response.usage?.prompt || 0;
787
+ session.tokenUsage.completion += response.usage?.completion || 0;
788
+ session.tokenUsage.total = session.tokenUsage.prompt + session.tokenUsage.completion;
789
+
790
+ if (truncated) {
791
+ const issue = 'The model response hit agent.maxOutputTokens and was cut off; the truncated tool call was discarded instead of executed.';
792
+ if (!session.issues.includes(issue)) session.issues.push(issue);
793
+ this.#emit('warning', {
794
+ message: 'Response truncated at the output-token cap — the incomplete tool call was NOT executed.',
795
+ });
796
+ }
797
+
798
+ if (calls.length === 0) {
799
+ const content = (response.content || '').trim();
800
+ if (!content) {
801
+ emptyResponses += 1;
802
+ // An empty answer is a retry, never a fatal fault: nudge the model,
803
+ // beat the heartbeat, wait a little longer each time, then ask
804
+ // again. There is no attempt cap — only /cancel, the task deadline
805
+ // or a non-retryable provider error ends this.
806
+ messages.push({ role: 'user', content: emptyResponseNudge(emptyResponses) });
807
+ const retryBase = Math.max(100, Number(this.config.recoveryBackoffMs ?? 500));
808
+ const delayMs = Math.min(15000, retryBase * 2 ** Math.min(emptyResponses - 1, 5));
809
+ this.#emit('heartbeat', {
810
+ phase: 'retry',
811
+ step,
812
+ attempt: emptyResponses,
813
+ maxAttempts: 0,
814
+ delayMs,
815
+ code: 'EMPTY_RESPONSE',
816
+ message: 'The model returned an empty response — asking it again.',
817
+ });
818
+ this.logger?.warn('empty model response; retrying', { step, attempt: emptyResponses, delayMs });
819
+ await abortableDelay(delayMs, signal);
820
+ // Retrying must not eat the optional --max-steps budget: the next
821
+ // iteration repeats this same step number.
822
+ step -= 1;
823
+ continue;
824
+ }
825
+ finalText = content;
826
+ status = 'completed';
827
+ finished = true;
828
+ break;
829
+ }
830
+ emptyResponses = 0;
831
+ if (response.content) {
832
+ // When the answer already streamed live, the text is already on screen.
833
+ const streamed = typeof this.renderer?.streamedResponse === 'function' && this.renderer.streamedResponse();
834
+ if (!streamed) this.#emit('note', { text: response.content.slice(0, 600) });
835
+ }
836
+
837
+ messages.push({
838
+ role: 'assistant',
839
+ content: response.content || null,
840
+ tool_calls: calls.map((call) => ({
841
+ id: call.id,
842
+ type: 'function',
843
+ // The provider re-validates every tool call on every request, so a
844
+ // truncated payload is repaired (or replaced) before it is stored.
845
+ function: { name: call.name, arguments: toToolArgumentsString(call.argumentsText) },
846
+ })),
847
+ });
848
+
849
+ // A cut-off response can never be trusted as a real edit: executing a
850
+ // repaired-but-truncated payload used to write half a file to disk.
851
+ if (truncated && calls.length > 0) {
852
+ for (const call of calls) {
853
+ this.#toolReply(
854
+ messages,
855
+ call,
856
+ {
857
+ ok: false,
858
+ error: {
859
+ code: 'TRUNCATED_RESPONSE',
860
+ message:
861
+ 'Your response was cut off at the output-token limit, so this call is incomplete and was NOT executed. Re-send a smaller call: narrow the file range, shorten the content, or split the change into several edits.',
862
+ },
863
+ },
864
+ session,
865
+ );
866
+ }
867
+ this.#emit('workingEnd', { step });
868
+ continue;
869
+ }
870
+
871
+ // Tools are about to run: hand the live status line over to the
872
+ // "working" animation until the first tool result prints.
873
+ this.#emit('workingStart', { step, maxSteps: stepLimitLabel, toolCalls: calls.length });
874
+ const seenInStep = new Set();
875
+ let callIndex = 0;
876
+ for (const call of calls) {
877
+ checkAbort();
878
+ callIndex += 1;
879
+ const duplicateKey = `${call.name}\u0000${call.argumentsText}`;
880
+ if (seenInStep.has(duplicateKey)) {
881
+ this.#toolReply(messages, call, { ok: false, error: { code: 'DUPLICATE_CALL', message: 'Duplicate tool call in the same step was ignored.' } }, session);
882
+ continue;
883
+ }
884
+ seenInStep.add(duplicateKey);
885
+
886
+ let args;
887
+ const parsed = parseToolArguments(call.argumentsText);
888
+ if (!parsed.ok) {
889
+ // The AI produced a broken payload: report it precisely, keep the
890
+ // agent alive with a heartbeat and let the model re-issue the call.
891
+ this.#emit('heartbeat', {
892
+ phase: 'degraded',
893
+ step,
894
+ code: 'INVALID_ARGUMENTS',
895
+ message: `${call.name}: ${parsed.error}`,
896
+ });
897
+ this.#emit('warning', { message: `${call.name}: ${parsed.error}` });
898
+ this.#toolReply(messages, call, {
899
+ ok: false,
900
+ error: {
901
+ code: 'INVALID_ARGUMENTS',
902
+ message: `${parsed.error}. Re-send the arguments as one compact JSON object that matches the schema (no markdown fence, no trailing comma, no truncation).`,
903
+ },
904
+ }, session);
905
+ session.issues.push(`${call.name}: malformed arguments (${parsed.error})`);
906
+ continue;
907
+ }
908
+ args = parsed.value;
909
+ if (parsed.repaired) {
910
+ this.#emit('heartbeat', {
911
+ phase: 'degraded',
912
+ step,
913
+ code: 'ARGUMENTS_REPAIRED',
914
+ message: `${call.name}: truncated arguments were repaired and the call is running.`,
915
+ });
916
+ this.#emit('warning', { message: `${call.name}: truncated arguments repaired before execution` });
917
+ session.issues.push(`${call.name}: truncated arguments were repaired automatically`);
918
+ }
919
+
920
+ if (!this.registry.has(call.name)) {
921
+ this.#emit('warning', { message: `Model requested unknown tool: ${call.name}` });
922
+ this.#toolReply(messages, call, {
923
+ ok: false,
924
+ error: { code: 'UNKNOWN_TOOL', message: `Unknown tool: ${call.name}. Only registered tools are available.` },
925
+ }, session);
926
+ continue;
927
+ }
928
+ const tool = this.registry.get(call.name);
929
+ const schemaErrors = validateSchema(tool.schema, args);
930
+ if (schemaErrors.length > 0) {
931
+ this.#toolReply(messages, call, {
932
+ ok: false,
933
+ error: { code: 'INVALID_ARGUMENTS', message: schemaErrors.join('; ') },
934
+ }, session);
935
+ this.#emit('warning', { message: `${call.name}: invalid arguments (${schemaErrors.join('; ')})` });
936
+ continue;
937
+ }
938
+
939
+ // A file that is already in the session worklog and unchanged on
940
+ // disk does not need to be read again: its content is already in the
941
+ // conversation. This is the guard that stops the agent from
942
+ // re-analysing files it has already analysed.
943
+ if (call.name === 'read_file' && args.path) {
944
+ const mtimeMs = await this.#mtimeOf(args.path);
945
+ if (
946
+ mtimeMs !== null &&
947
+ this.worklog.canSkipRead(args.path, {
948
+ startLine: args.start_line ?? 1,
949
+ endLine: args.end_line ?? null,
950
+ mtimeMs,
951
+ })
952
+ ) {
953
+ const known = this.worklog.file(args.path);
954
+ const range = known?.ranges?.at(-1);
955
+ session.cachedReads += 1;
956
+ this.#emit('toolEnd', {
957
+ name: call.name,
958
+ ok: true,
959
+ durationMs: 0,
960
+ cached: true,
961
+ summary: `${args.path} already read and unchanged — not read again`,
962
+ });
963
+ this.#toolReply(
964
+ messages,
965
+ call,
966
+ {
967
+ ok: true,
968
+ cached: true,
969
+ path: args.path,
970
+ earlier_range: range ? `${range[0]}-${range[1]}` : null,
971
+ note:
972
+ 'This file is unchanged since you read it earlier in this session, so it was not read again. Its content is already in the conversation: use it, or read a different file or line range. Do not repeat this read.',
973
+ },
974
+ session,
975
+ );
976
+ continue;
977
+ }
978
+ }
979
+
980
+ // A model that keeps re-issuing the same failing call is looping.
981
+ // Block the third identical failure so the run spends its steps on
982
+ // new information instead of the same exit code.
983
+ const callKey = `${call.name}\u0000${(call.argumentsText || '').trim()}`;
984
+ const previousFailures = failureCounts.get(callKey) || 0;
985
+ if (previousFailures >= MAX_REPEATED_FAILURES) {
986
+ this.#emit('warning', {
987
+ message: `${call.name}: repeated failing call blocked after ${previousFailures} identical failures`,
988
+ });
989
+ this.#toolReply(messages, call, {
990
+ ok: false,
991
+ error: {
992
+ code: 'REPEATED_FAILURE',
993
+ message: `This exact ${call.name} call has already failed ${previousFailures} times with the same result, so it was not executed again. Repeating it unchanged cannot produce new information — change the tool, change the arguments, or report the finding to the user.`,
994
+ },
995
+ }, session);
996
+ session.issues.push(`${call.name}: repeated identical failing call was blocked`);
997
+ continue;
998
+ }
999
+
1000
+ let preview = '';
1001
+ try {
1002
+ if (tool.preview) preview = String(await tool.preview(args, { workspace: this.workspace, config: this.config })).slice(0, 6000);
1003
+ } catch (previewError) {
1004
+ this.#toolReply(messages, call, {
1005
+ ok: false,
1006
+ error: { code: previewError.code || 'PREVIEW_FAILED', message: previewError.message },
1007
+ }, session);
1008
+ this.#emit('toolEnd', { name: call.name, ok: false, durationMs: 0, summary: previewError.message });
1009
+ continue;
1010
+ }
1011
+
1012
+ const assessment = typeof tool.assess === 'function' ? tool.assess(args) : null;
1013
+ if (tool.requiresApproval) {
1014
+ const decision = await this.approvals.request({
1015
+ tool: call.name,
1016
+ risk: tool.risk,
1017
+ args,
1018
+ preview,
1019
+ reasons: assessment?.reasons || [],
1020
+ critical: assessment?.level === 'critical',
1021
+ });
1022
+ if (!decision.approved) {
1023
+ this.#emit('toolDenied', { name: call.name, reason: decision.reason });
1024
+ session.transcript.push({ role: 'tool', name: call.name, ok: false, content: decision.reason, at: nowIso() });
1025
+ this.#toolReply(messages, call, {
1026
+ ok: false,
1027
+ denied: true,
1028
+ error: {
1029
+ code: 'APPROVAL_DENIED',
1030
+ message: `The user did not approve ${call.name}. Do not retry this action without a new, explicit request.`,
1031
+ reason: decision.reason,
1032
+ },
1033
+ }, session);
1034
+ continue;
1035
+ }
1036
+ }
1037
+
1038
+ this.#emit('toolStart', {
1039
+ name: call.name,
1040
+ args,
1041
+ risk: tool.risk,
1042
+ index: callIndex,
1043
+ total: calls.length,
1044
+ detail: describeToolTarget(call.name, args),
1045
+ });
1046
+ const toolStarted = Date.now();
1047
+ const before = await this.#captureBefore(call.name, args);
1048
+ try {
1049
+ const { result } = await this.registry.execute(call.name, args, {
1050
+ workspace: this.workspace,
1051
+ config: this.config,
1052
+ logger: this.logger,
1053
+ processes: this.processes,
1054
+ signal,
1055
+ agent: this,
1056
+ session,
1057
+ });
1058
+ const durationMs = Date.now() - toolStarted;
1059
+ this.#trackResult(call.name, args, result, session, taskProcesses);
1060
+ await this.#noteWorklog(call.name, args, result);
1061
+ await this.#recordUndo(call.name, args, result, before);
1062
+ // A command that exits non-zero is a failed attempt too: count it
1063
+ // so the same command is not re-run forever.
1064
+ if (result?.ok === false) {
1065
+ failureCounts.set(callKey, previousFailures + 1);
1066
+ }
1067
+ this.#emit('toolEnd', {
1068
+ name: call.name,
1069
+ ok: true,
1070
+ durationMs,
1071
+ summary: summarizeToolResult(call.name, result),
1072
+ });
1073
+ // The command the user approved and what it actually printed: the
1074
+ // renderer shows a preview and keeps the complete output one click
1075
+ // (or `/expand <id>`) away.
1076
+ if (call.name === 'run_command' && result) {
1077
+ this.#emit('commandOutput', {
1078
+ argv: result.argv || args.argv || [],
1079
+ stdout: result.stdout ?? '',
1080
+ stderr: result.stderr ?? '',
1081
+ exitCode: result.exit_code ?? null,
1082
+ status: result.status ?? '',
1083
+ processId: result.process_id ?? null,
1084
+ timedOut: Boolean(result.timed_out),
1085
+ truncated: Boolean(result.truncated),
1086
+ });
1087
+ }
1088
+ const fileDiff = this.#fileDiffPayload(call.name, args, result, before);
1089
+ if (fileDiff) this.#emit('fileDiff', fileDiff);
1090
+ this.#toolReply(messages, call, { ok: true, result }, session);
1091
+ this.logger?.debug('tool succeeded', { name: call.name, durationMs });
1092
+ } catch (toolError) {
1093
+ const durationMs = Date.now() - toolStarted;
1094
+ const message = toolError?.message || String(toolError);
1095
+ failureCounts.set(callKey, previousFailures + 1);
1096
+ this.#emit('toolEnd', { name: call.name, ok: false, durationMs, summary: message });
1097
+ session.issues.push(`${call.name}: ${message.slice(0, 300)}`);
1098
+ this.#toolReply(messages, call, {
1099
+ ok: false,
1100
+ error: { code: toolError.code || 'TOOL_ERROR', message },
1101
+ }, session);
1102
+ this.logger?.warn('tool failed', { name: call.name, code: toolError.code, message: message.slice(0, 300) });
1103
+ }
1104
+ }
1105
+ this.#emit('workingEnd', { step });
1106
+ }
1107
+
1108
+ if (!finished && status !== 'completed') {
1109
+ status = 'step-limit';
1110
+ session.issues.push(`Reached the ${stepLimitLabel}-step limit before the model gave a final answer.`);
1111
+ finalText = `Step limit (${stepLimitLabel}) reached. Partial work is recorded above; raise agent.maxSteps (0 = unlimited) or continue with a narrower task.`;
1112
+ }
1113
+ } catch (caught) {
1114
+ error = caught;
1115
+ if (currentReasoning) {
1116
+ const logFile = this.logger?.writeReasoning({ sessionId: session.id, step: steps, text: currentReasoning }) || null;
1117
+ this.#emit('reasoningEnd', { logFile, partial: true });
1118
+ }
1119
+ if (caught instanceof CancelledError || signal.aborted) {
1120
+ status = 'cancelled';
1121
+ finalText = 'Task cancelled. No further tool calls were executed.';
1122
+ } else {
1123
+ status = 'failed';
1124
+ finalText = '';
1125
+ session.issues.push(describeError(caught));
1126
+ this.#emit('error', { error: caught });
1127
+ }
1128
+ this.logger?.warn('task ended with error', { code: caught.code, message: caught.message });
1129
+ } finally {
1130
+ if (externalSignal) externalSignal.removeEventListener('abort', onExternalAbort);
1131
+ // Do not leave processes from a cancelled/failed task running silently.
1132
+ if (status === 'cancelled' || status === 'failed') {
1133
+ const processes = this.processes;
1134
+ if (processes) {
1135
+ for (const id of taskProcesses) {
1136
+ try {
1137
+ const record = processes.get(id);
1138
+ if (record.status === 'running') {
1139
+ await processes.stop(id, { graceMs: 1000 }).catch(() => {});
1140
+ }
1141
+ } catch {
1142
+ // Process may have exited already.
1143
+ }
1144
+ }
1145
+ }
1146
+ }
1147
+ }
1148
+
1149
+ session.status = status;
1150
+ session.steps = steps;
1151
+ session.durationMs = Date.now() - startedAt;
1152
+ session.tokenUsage.total = session.tokenUsage.prompt + session.tokenUsage.completion;
1153
+ // Persist the verified worklog so /resume and the next task continue
1154
+ // instead of re-reading everything.
1155
+ session.worklog = this.worklog.toJSON();
1156
+ if (finalText) session.transcript.push({ role: 'assistant', content: finalText, at: nowIso() });
1157
+ // Remember this exchange so the next task in the session knows about it.
1158
+ this.#remember(task.trim(), finalText, {
1159
+ status,
1160
+ changedFiles: session.changedFiles,
1161
+ commands: session.commands,
1162
+ });
1163
+ session.conversation = this.conversation.slice(-40);
1164
+ if (this.sessionStore) {
1165
+ try {
1166
+ await this.sessionStore.save(session);
1167
+ } catch (saveError) {
1168
+ this.logger?.warn('failed to save session', { error: saveError.message });
1169
+ session.issues.push(`Session could not be saved: ${saveError.message}`);
1170
+ }
1171
+ }
1172
+
1173
+ const summary = this.#buildSummary({
1174
+ status,
1175
+ finalText,
1176
+ plan,
1177
+ steps,
1178
+ startedAt,
1179
+ session,
1180
+ taskProcesses,
1181
+ error,
1182
+ });
1183
+ this.#emit('final', summary);
1184
+ this.logger?.info('task finished', {
1185
+ sessionId: session.id,
1186
+ status,
1187
+ steps,
1188
+ durationMs: summary.durationMs,
1189
+ changedFiles: summary.changedFiles.length,
1190
+ issues: summary.issues.length,
1191
+ });
1192
+ return summary;
1193
+ }
1194
+ }