@denizokcu/haze 0.0.1 → 0.0.3

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 (73) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +169 -70
  3. package/dist/cli/commands/chat.d.ts +4 -1
  4. package/dist/cli/commands/chat.js +606 -24
  5. package/dist/cli/commands/commands.d.ts +5 -0
  6. package/dist/cli/commands/commands.js +220 -11
  7. package/dist/cli/commands/formatters.d.ts +1 -0
  8. package/dist/cli/commands/formatters.js +23 -3
  9. package/dist/cli/commands/skills.d.ts +1 -1
  10. package/dist/cli/commands/skills.js +8 -5
  11. package/dist/cli/commands/streaming.d.ts +7 -1
  12. package/dist/cli/commands/streaming.js +533 -41
  13. package/dist/cli/index.js +5 -12
  14. package/dist/config/inputHistory.js +8 -0
  15. package/dist/config/paths.d.ts +0 -1
  16. package/dist/config/paths.js +0 -1
  17. package/dist/config/providers.d.ts +26 -0
  18. package/dist/config/providers.js +88 -0
  19. package/dist/config/settings.d.ts +9 -2
  20. package/dist/core/agent/compaction.d.ts +13 -0
  21. package/dist/core/agent/compaction.js +34 -0
  22. package/dist/core/agent/errors.d.ts +3 -0
  23. package/dist/core/agent/errors.js +13 -0
  24. package/dist/core/agent/events.d.ts +58 -0
  25. package/dist/core/agent/events.js +3 -0
  26. package/dist/core/goal/completionPolicy.d.ts +27 -0
  27. package/dist/core/goal/completionPolicy.js +67 -0
  28. package/dist/core/goal/requestClassifier.d.ts +6 -0
  29. package/dist/core/goal/requestClassifier.js +31 -0
  30. package/dist/core/goal/sessionGoal.d.ts +30 -0
  31. package/dist/core/goal/sessionGoal.js +88 -0
  32. package/dist/core/session/sessionStore.d.ts +37 -0
  33. package/dist/core/session/sessionStore.js +59 -0
  34. package/dist/llm/client.d.ts +1 -1
  35. package/dist/llm/client.js +6 -6
  36. package/dist/llm/hazeTools.d.ts +70 -0
  37. package/dist/llm/hazeTools.js +311 -97
  38. package/dist/llm/initPrompt.js +7 -5
  39. package/dist/llm/systemPrompt.js +25 -11
  40. package/dist/skills/SkillLoader.d.ts +12 -2
  41. package/dist/skills/SkillLoader.js +64 -18
  42. package/dist/skills/SkillRegistry.d.ts +1 -5
  43. package/dist/skills/SkillRegistry.js +10 -21
  44. package/dist/skills/builder/SkillBuilder.d.ts +31 -1
  45. package/dist/skills/builder/SkillBuilder.js +291 -20
  46. package/dist/skills/skillTools.d.ts +20 -0
  47. package/dist/skills/skillTools.js +25 -0
  48. package/dist/skills/types.d.ts +12 -51
  49. package/dist/ui/components/ErrorView.d.ts +2 -1
  50. package/dist/ui/components/Header.d.ts +4 -2
  51. package/dist/ui/components/Header.js +2 -2
  52. package/dist/ui/components/MarkdownText.d.ts +2 -1
  53. package/dist/ui/components/TextInput.d.ts +13 -2
  54. package/dist/ui/components/TextInput.js +125 -25
  55. package/dist/ui/theme.d.ts +2 -0
  56. package/dist/ui/theme.js +3 -1
  57. package/dist/utils/fs.d.ts +1 -0
  58. package/dist/utils/fs.js +10 -6
  59. package/examples/skills/files/SKILL.md +16 -0
  60. package/examples/skills/files/examples/file-editing.md +3 -0
  61. package/package.json +9 -9
  62. package/dist/skills/installer/SkillInstaller.d.ts +0 -1
  63. package/dist/skills/installer/SkillInstaller.js +0 -48
  64. package/dist/skills/manifestSchema.d.ts +0 -31
  65. package/dist/skills/manifestSchema.js +0 -23
  66. package/dist/tools/ToolExecutor.d.ts +0 -3
  67. package/dist/tools/ToolExecutor.js +0 -15
  68. package/dist/tools/types.d.ts +0 -9
  69. package/dist/tools/types.js +0 -1
  70. package/examples/skills/files/prompts/file_tasks.md +0 -1
  71. package/examples/skills/files/skill.yaml +0 -28
  72. package/examples/skills/files/tools/list_files.ts +0 -21
  73. package/examples/skills/files/tools/read_file.ts +0 -12
@@ -2,41 +2,407 @@ import { stepCountIs, streamText } from 'ai';
2
2
  import { model } from '../../llm/client.js';
3
3
  import { hazeTools } from '../../llm/hazeTools.js';
4
4
  import { buildSystemPrompt } from '../../llm/systemPrompt.js';
5
+ import { loadSkillRegistry } from '../../skills/SkillRegistry.js';
6
+ import { buildSkillTools } from '../../skills/skillTools.js';
5
7
  import { compact, toolCallSummary, toolResultSummary, formatSeconds } from './formatters.js';
6
- export async function runAgentTurn(value, displayValue, contextFiles, callbacks) {
8
+ import { isActionRequest, isPlanImplementationRequest, isPlanOnlyRequest, isValidationRequest } from '../../core/goal/requestClassifier.js';
9
+ import { completionDecision, looksIncomplete, noTextAfterToolPrompt, postContinuationPrompt, toolLoopBudgetPrompt } from '../../core/goal/completionPolicy.js';
10
+ import { createSessionGoal, formatGoalStatus, observeGoalToolEvent } from '../../core/goal/sessionGoal.js';
11
+ import { agentEvent } from '../../core/agent/events.js';
12
+ import { isContextOverflowError, isRetryableModelError } from '../../core/agent/errors.js';
13
+ function stableToolKey(toolCall) {
14
+ return `${toolCall.toolName}:${JSON.stringify(toolCall.input)}`;
15
+ }
16
+ function uniqueRepeatedToolNames(toolCalls) {
17
+ const seen = new Set();
18
+ const repeated = new Set();
19
+ for (const toolCall of toolCalls) {
20
+ const key = stableToolKey(toolCall);
21
+ if (seen.has(key))
22
+ repeated.add(toolCall.toolName);
23
+ seen.add(key);
24
+ }
25
+ return [...repeated];
26
+ }
27
+ function toolOnlyStepCount(steps) {
28
+ let count = 0;
29
+ for (const step of [...steps].reverse()) {
30
+ if (step.toolCalls.length === 0 || step.text.trim().length > 0)
31
+ break;
32
+ count += 1;
33
+ }
34
+ return count;
35
+ }
36
+ function sanitizeAssistantText(text) {
37
+ return [...text].filter(char => {
38
+ const code = char.charCodeAt(0);
39
+ return !(code <= 8 || code === 11 || code === 12 || (code >= 14 && code <= 31) || code === 127 || code === 155);
40
+ }).join('');
41
+ }
42
+ function hideSyntheticToolCallMarkup(text) {
43
+ return text
44
+ .replace(/(^|\n)\s*(?:```(?:xml)?\s*)?(?:xml\s*)?<tool_call>[\s\S]*?<\/tool_call>\s*(?:```)?/gi, '$1')
45
+ .replace(/(^|\n)\s*(?:```(?:xml)?\s*)?(?:xml\s*)?<tool_call>[\s\S]*$/i, '$1');
46
+ }
47
+ function toolInputPath(input) {
48
+ return typeof input === 'object' && input != null && 'path' in input && typeof input.path === 'string'
49
+ ? input.path
50
+ : undefined;
51
+ }
52
+ function isDuplicateSkippedOutput(output) {
53
+ return typeof output === 'object' && output != null && 'duplicateSkipped' in output && output.duplicateSkipped === true;
54
+ }
55
+ function retryDelayMs(attempt) {
56
+ return Math.min(4000, 1000 * 2 ** attempt);
57
+ }
58
+ async function abortableDelay(milliseconds, signal) {
59
+ if (signal.aborted)
60
+ return;
61
+ await new Promise(resolve => {
62
+ const timer = setTimeout(resolve, milliseconds);
63
+ signal.addEventListener('abort', () => {
64
+ clearTimeout(timer);
65
+ resolve();
66
+ }, { once: true });
67
+ });
68
+ }
69
+ const DEFAULT_MAX_OUTPUT_TOKENS = 8192;
70
+ function toolOutputOk(output, success) {
71
+ if (!success)
72
+ return false;
73
+ return !(typeof output === 'object' && output != null && 'ok' in output && output.ok === false);
74
+ }
75
+ export async function runAgentTurn(value, displayValue, contextFiles, callbacks, retryAttempt = 0, retryingExistingRequest = false, contextOverflowRecovered = false) {
7
76
  const displayVal = displayValue ?? value;
8
77
  const userMessage = { role: 'user', text: displayVal };
78
+ callbacks.onEvent?.(agentEvent({ type: 'turn_start', request: value }));
9
79
  callbacks.setBusy(true);
10
- callbacks.addMessage(userMessage);
80
+ if (!retryingExistingRequest)
81
+ callbacks.addMessage(userMessage);
82
+ const abortController = new AbortController();
83
+ callbacks.setAbortController?.(abortController);
84
+ let turnStatus = 'failed';
85
+ let idleTimer;
86
+ const resetIdleTimer = () => {
87
+ if (idleTimer)
88
+ clearTimeout(idleTimer);
89
+ idleTimer = setTimeout(() => abortController.abort('Haze turn timed out after no model/tool activity.'), 90_000);
90
+ };
11
91
  try {
12
92
  const m = await model();
13
93
  if (!m) {
14
- callbacks.addMessage({ role: 'assistant', text: 'No model configured. Run /login, then /model <model-name>. Haze cannot hallucinate without credentials. Progress.' });
94
+ callbacks.addMessage({ role: 'assistant', text: 'No model provider configured. Run /provider to choose or add a provider. Haze cannot hallucinate without a model. Progress.' });
15
95
  return;
16
96
  }
17
- const lastAssistantText = callbacks.getLastAssistantText();
18
- const refersToPrevious = /\b(this|that|previous|above|it)\b/i.test(value) && lastAssistantText.trim().length > 0;
19
- const userContent = refersToPrevious
20
- ? `${value}\n\nReferenced previous Haze response to preserve exactly:\n${lastAssistantText}`
21
- : value;
22
- const requestMessages = [...callbacks.getConversation(), { role: 'user', content: userContent }];
97
+ const activeModel = m;
98
+ const skillRegistry = await loadSkillRegistry();
99
+ const availableTools = { ...hazeTools, ...buildSkillTools(skillRegistry) };
100
+ const goal = createSessionGoal(value);
101
+ callbacks.setGoalStatus?.(formatGoalStatus(goal));
102
+ const likelyPlanOnlyRequest = isPlanOnlyRequest(value);
103
+ const likelyPlanImplementationRequest = isPlanImplementationRequest(value);
104
+ const likelyActionRequest = isActionRequest(value);
105
+ const likelyValidationRequest = isValidationRequest(value);
106
+ const planImplementationGuidance = 'When implementing a plan file, first identify the concrete required checklist items and compare them with the current files. Do not edit source or tests when the required behavior is already present. Implement the smallest clearly required phase or required items, skip optional/design-question items unless explicitly requested, add tests rather than exploratory one-off scripts where possible, use file tools (not bash) for any file changes, run validation once after code/test edits, then update plan status with file tools if requested. Do not call unresolved optional scope a blocker.';
107
+ const requestMessages = retryingExistingRequest
108
+ ? callbacks.getConversation()
109
+ : likelyPlanImplementationRequest
110
+ ? [...callbacks.getConversation(), { role: 'user', content: value }, { role: 'user', content: planImplementationGuidance }]
111
+ : [...callbacks.getConversation(), { role: 'user', content: value }];
23
112
  callbacks.setConversation(requestMessages);
24
- const assistantId = `assistant-${Date.now()}`;
113
+ resetIdleTimer();
114
+ let currentAssistantId = `assistant-${Date.now()}`;
25
115
  let assistantStarted = false;
116
+ let currentAssistantStarted = false;
117
+ let currentAssistantText = '';
26
118
  let assistantText = '';
119
+ let toolEpoch = 0;
120
+ let currentAssistantToolEpoch = 0;
27
121
  let editFileFailed = false;
28
122
  let mutatingToolSucceeded = false;
123
+ let validationToolSucceeded = false;
124
+ let validationToolFailed = false;
125
+ let sawReadOnlyTool = false;
126
+ let sawToolCall = false;
127
+ let textAfterTool = false;
128
+ let completionContinuationCount = 0;
129
+ const maxCompletionContinuations = 4;
130
+ let editRecoveryPath;
131
+ let editRecoveryReadSatisfied = false;
29
132
  const toolSummaries = [];
30
- callbacks.debugLog(`request started with ${requestMessages.length} conversation messages${refersToPrevious ? ' and previous-response reference' : ''}`);
133
+ const toolExecutionContext = { inFlightToolCalls: new Map() };
134
+ const toolGroupId = `tools-${Date.now()}-${Math.random().toString(36).slice(2)}`;
135
+ const toolDisplayItems = [];
136
+ let toolGroupStarted = false;
137
+ function renderToolGroup(streaming) {
138
+ const visibleItems = toolDisplayItems.filter(item => !item.hidden);
139
+ const running = visibleItems.some(item => item.status === 'running');
140
+ const failures = visibleItems.filter(item => item.status === 'error');
141
+ const changes = visibleItems.filter(item => /^(editFile|replaceLines|writeFile)\b/.test(item.summary));
142
+ const compactItems = !running && visibleItems.length > 12
143
+ ? [...new Map([...failures, ...changes].map(item => [item.id, item])).values()]
144
+ : visibleItems;
145
+ const grouped = new Map();
146
+ for (const item of compactItems) {
147
+ const key = `${item.status}:${item.summary}:${item.result ?? ''}`;
148
+ const current = grouped.get(key);
149
+ if (current)
150
+ current.count += 1;
151
+ else
152
+ grouped.set(key, { item, count: 1 });
153
+ }
154
+ const rows = [...grouped.values()];
155
+ const compactSuffix = !running && visibleItems.length > 12 ? ` · showing ${compactItems.length} important` : '';
156
+ const header = running || streaming
157
+ ? 'Running tools'
158
+ : `${visibleItems.length} call${visibleItems.length === 1 ? '' : 's'} · ${changes.length} change${changes.length === 1 ? '' : 's'} · ${failures.length} failed${compactSuffix}`;
159
+ const lines = rows.map(({ item, count }) => {
160
+ const icon = item.status === 'running' ? '…' : item.status === 'success' ? '✓' : '✗';
161
+ const countText = count > 1 ? ` ×${count}` : '';
162
+ const result = item.status === 'running' ? '' : ` — ${item.result ?? item.status}${item.durationMs == null ? '' : ` in ${formatSeconds(item.durationMs)}`}`;
163
+ return ` ${icon} ${item.summary}${countText}${result}`;
164
+ });
165
+ return [header, ...lines].join('\n');
166
+ }
167
+ function updateToolGroup(streaming = true) {
168
+ const text = renderToolGroup(streaming);
169
+ if (!toolGroupStarted) {
170
+ toolGroupStarted = true;
171
+ callbacks.addMessage({ id: toolGroupId, role: 'tool', text, streaming });
172
+ }
173
+ else {
174
+ callbacks.updateMessage(toolGroupId, { text, streaming });
175
+ }
176
+ }
177
+ function recordToolStart(toolCall) {
178
+ callbacks.onEvent?.(agentEvent({ type: 'tool_start', id: toolCall.toolCallId, name: toolCall.toolName, input: toolCall.input }));
179
+ toolDisplayItems.push({ id: toolCall.toolCallId, summary: toolCallSummary(toolCall.toolName, toolCall.input), status: 'running' });
180
+ updateToolGroup(true);
181
+ }
182
+ function recordToolDisplayFinish(event) {
183
+ callbacks.onEvent?.(agentEvent({ type: 'tool_end', id: event.toolCall.toolCallId, name: event.toolCall.toolName, success: event.success, output: event.output, error: event.error, durationMs: event.durationMs }));
184
+ const item = toolDisplayItems.find(candidate => candidate.id === event.toolCall.toolCallId);
185
+ if (!item)
186
+ return;
187
+ item.status = toolOutputOk(event.output, event.success) ? 'success' : 'error';
188
+ item.result = toolResultSummary(event);
189
+ item.durationMs = event.durationMs;
190
+ item.hidden = isDuplicateSkippedOutput(event.output);
191
+ updateToolGroup(toolDisplayItems.some(candidate => candidate.status === 'running'));
192
+ }
193
+ callbacks.debugLog(`request started with ${requestMessages.length} conversation messages; intent=${goal.normalizedIntent}; action=${likelyActionRequest}`);
194
+ function recordToolFinish(event) {
195
+ const path = toolInputPath(event.toolCall.input);
196
+ const duplicateSkipped = isDuplicateSkippedOutput(event.output);
197
+ const ok = toolOutputOk(event.output, event.success);
198
+ observeGoalToolEvent(goal, { ...event.toolCall, success: ok, output: event.output, duplicateSkipped });
199
+ callbacks.setGoalStatus?.(formatGoalStatus(goal));
200
+ if (!ok && ['editFile', 'replaceLines', 'writeFile'].includes(event.toolCall.toolName)) {
201
+ editFileFailed = true;
202
+ editRecoveryPath = path;
203
+ editRecoveryReadSatisfied = false;
204
+ }
205
+ if (ok && ['listFiles', 'readFile'].includes(event.toolCall.toolName))
206
+ sawReadOnlyTool = true;
207
+ if (ok && event.toolCall.toolName === 'readFile' && path && path === editRecoveryPath && !duplicateSkipped) {
208
+ editRecoveryReadSatisfied = true;
209
+ }
210
+ if (ok && !duplicateSkipped && ['editFile', 'replaceLines', 'writeFile'].includes(event.toolCall.toolName)) {
211
+ mutatingToolSucceeded = true;
212
+ if (!path || path === editRecoveryPath) {
213
+ editRecoveryPath = undefined;
214
+ editRecoveryReadSatisfied = false;
215
+ editFileFailed = false;
216
+ }
217
+ }
218
+ if (event.success && event.toolCall.toolName === 'bash') {
219
+ if (ok)
220
+ validationToolSucceeded = true;
221
+ else
222
+ validationToolFailed = true;
223
+ }
224
+ }
225
+ async function streamAssistantResponse(messages, reason, prompt, allowTools = false) {
226
+ callbacks.debugLog(`requesting assistant ${allowTools ? 'continuation' : 'text'}: ${reason}`);
227
+ const responseId = `assistant-${Date.now()}-${Math.random().toString(36).slice(2)}`;
228
+ let responseStarted = false;
229
+ let responseText = '';
230
+ let continuationToolCalls = 0;
231
+ let followUpStreamError;
232
+ const continuationMessages = [
233
+ ...messages,
234
+ { role: 'user', content: prompt },
235
+ ];
236
+ const followUp = streamText({
237
+ model: activeModel,
238
+ temperature: 0,
239
+ maxOutputTokens: DEFAULT_MAX_OUTPUT_TOKENS,
240
+ system: buildSystemPrompt(contextFiles),
241
+ messages: continuationMessages,
242
+ tools: availableTools,
243
+ toolChoice: allowTools ? 'auto' : 'none',
244
+ stopWhen: stepCountIs(10),
245
+ abortSignal: abortController.signal,
246
+ experimental_context: toolExecutionContext,
247
+ prepareStep({ steps, messages }) {
248
+ continuationToolCalls = steps.flatMap(step => step.toolCalls).length;
249
+ if (continuationToolCalls >= 10 || toolOnlyStepCount(steps) >= 5) {
250
+ return {
251
+ toolChoice: 'none',
252
+ messages: [
253
+ ...messages,
254
+ { role: 'user', content: toolLoopBudgetPrompt() },
255
+ ],
256
+ };
257
+ }
258
+ if (likelyPlanOnlyRequest && mutatingToolSucceeded) {
259
+ return {
260
+ toolChoice: 'none',
261
+ messages: [
262
+ ...messages,
263
+ { role: 'user', content: 'This was a planning request and the plan artifact has been created or updated. Stop using tools and summarize the plan file only; do not implement or validate it.' },
264
+ ],
265
+ };
266
+ }
267
+ if (editRecoveryPath && !editRecoveryReadSatisfied) {
268
+ return {
269
+ activeTools: ['readFile'],
270
+ messages: [
271
+ ...messages,
272
+ { role: 'user', content: `A previous edit failed for ${editRecoveryPath}. Before any further edit or bash inspection, call readFile on exactly ${editRecoveryPath}. Bash/cat does not satisfy this recovery step.` },
273
+ ],
274
+ };
275
+ }
276
+ if (editFileFailed)
277
+ return { activeTools: ['listFiles', 'readFile', 'replaceLines', 'writeFile', 'bash'] };
278
+ return undefined;
279
+ },
280
+ onError({ error }) {
281
+ followUpStreamError = error;
282
+ callbacks.debugLog(`stream error: ${error instanceof Error ? error.message : String(error)}`);
283
+ },
284
+ onFinish(event) {
285
+ callbacks.setConversation([...continuationMessages, ...event.response.messages]);
286
+ callbacks.debugLog(`conversation updated to ${continuationMessages.length + event.response.messages.length} messages after follow-up`);
287
+ },
288
+ experimental_onToolCallStart({ toolCall }) {
289
+ sawToolCall = true;
290
+ recordToolStart(toolCall);
291
+ resetIdleTimer();
292
+ callbacks.debugLog(`follow-up tool start: ${toolCall.toolName} ${compact(toolCall.input)}`);
293
+ },
294
+ experimental_onToolCallFinish(event) {
295
+ resetIdleTimer();
296
+ recordToolFinish(event);
297
+ const summary = toolResultSummary(event);
298
+ toolSummaries.push(`${event.toolCall.toolName}: ${summary}`);
299
+ recordToolDisplayFinish(event);
300
+ if (!isDuplicateSkippedOutput(event.output))
301
+ toolEpoch += 1;
302
+ callbacks.debugLog(event.success
303
+ ? `follow-up tool done: ${event.toolCall.toolName} after ${event.durationMs}ms ${compact(event.output)}`
304
+ : `follow-up tool error: ${event.toolCall.toolName} after ${event.durationMs}ms ${compact(event.error)}`);
305
+ },
306
+ });
307
+ for await (const rawDelta of followUp.textStream) {
308
+ resetIdleTimer();
309
+ const delta = sanitizeAssistantText(rawDelta);
310
+ responseText += delta;
311
+ const displayText = hideSyntheticToolCallMarkup(responseText);
312
+ if (!displayText && !responseStarted)
313
+ continue;
314
+ if (!responseStarted) {
315
+ responseStarted = true;
316
+ callbacks.onEvent?.(agentEvent({ type: 'message_start', id: responseId, role: 'assistant' }));
317
+ callbacks.addMessage({ id: responseId, role: 'assistant', text: displayText, streaming: true });
318
+ }
319
+ else {
320
+ callbacks.onEvent?.(agentEvent({ type: 'message_update', id: responseId, text: displayText }));
321
+ callbacks.updateMessage(responseId, { text: displayText });
322
+ }
323
+ }
324
+ try {
325
+ await followUp.response;
326
+ }
327
+ catch (error) {
328
+ throw followUpStreamError ?? error;
329
+ }
330
+ const finalText = hideSyntheticToolCallMarkup(responseText).trim();
331
+ if (responseStarted) {
332
+ callbacks.setLastAssistantText(finalText);
333
+ callbacks.onEvent?.(agentEvent({ type: 'message_end', id: responseId, text: finalText, hidden: finalText.length === 0 }));
334
+ callbacks.updateMessage(responseId, { text: finalText, streaming: false, hidden: finalText.length === 0 });
335
+ }
336
+ return { text: finalText, id: responseId, started: responseStarted };
337
+ }
338
+ let streamError;
31
339
  const result = streamText({
32
- model: m,
340
+ model: activeModel,
341
+ temperature: 0,
342
+ maxOutputTokens: DEFAULT_MAX_OUTPUT_TOKENS,
33
343
  system: buildSystemPrompt(contextFiles),
34
344
  messages: requestMessages,
35
- tools: hazeTools,
36
- stopWhen: stepCountIs(15),
37
- prepareStep() {
38
- if (mutatingToolSucceeded)
39
- return { toolChoice: 'none' };
345
+ tools: availableTools,
346
+ stopWhen: stepCountIs(12),
347
+ abortSignal: abortController.signal,
348
+ experimental_context: toolExecutionContext,
349
+ onError({ error }) {
350
+ streamError = error;
351
+ callbacks.debugLog(`stream error: ${error instanceof Error ? error.message : String(error)}`);
352
+ },
353
+ prepareStep({ steps, messages }) {
354
+ const toolCalls = steps.flatMap(step => step.toolCalls);
355
+ const repeatedToolNames = uniqueRepeatedToolNames(toolCalls);
356
+ const repeatedToolCall = repeatedToolNames.length > 0;
357
+ const consecutiveToolOnlySteps = toolOnlyStepCount(steps);
358
+ if (likelyPlanOnlyRequest && mutatingToolSucceeded) {
359
+ return {
360
+ toolChoice: 'none',
361
+ messages: [
362
+ ...messages,
363
+ { role: 'user', content: 'This was a planning request and the plan artifact has been created or updated. Stop using tools and summarize the plan file only; do not implement or validate it.' },
364
+ ],
365
+ };
366
+ }
367
+ if (editRecoveryPath && !editRecoveryReadSatisfied) {
368
+ return {
369
+ activeTools: ['readFile'],
370
+ messages: [
371
+ ...messages,
372
+ { role: 'user', content: `A previous edit failed for ${editRecoveryPath}. Before any further edit or bash inspection, call readFile on exactly ${editRecoveryPath}. Bash/cat does not satisfy this recovery step.` },
373
+ ],
374
+ };
375
+ }
376
+ if (repeatedToolCall) {
377
+ const activeTools = Object.keys(availableTools).filter(name => !repeatedToolNames.includes(name));
378
+ callbacks.debugLog(`disabling repeated tools for next step: ${repeatedToolNames.join(', ')}`);
379
+ return {
380
+ activeTools,
381
+ messages: [
382
+ ...messages,
383
+ { role: 'user', content: `You already called ${repeatedToolNames.join(', ')} with the same input. Do not repeat that tool call. Use a different relevant tool. If this is an action request and no file change has been made yet, continue with edit/write tools rather than summarizing.` },
384
+ ],
385
+ };
386
+ }
387
+ if (likelyActionRequest && !mutatingToolSucceeded && consecutiveToolOnlySteps >= 3 && toolCalls.length < 10) {
388
+ callbacks.debugLog('nudging action request toward mutation after read-only steps');
389
+ return {
390
+ messages: [
391
+ ...messages,
392
+ { role: 'user', content: 'You have inspected enough for now. This is an action request; make the requested change with editFile, replaceLines, or writeFile instead of saying tools are unavailable or summarizing.' },
393
+ ],
394
+ };
395
+ }
396
+ if (toolCalls.length >= 12 || consecutiveToolOnlySteps >= 5) {
397
+ callbacks.debugLog('forcing text response to avoid tool loop');
398
+ return {
399
+ toolChoice: 'none',
400
+ messages: [
401
+ ...messages,
402
+ { role: 'user', content: toolLoopBudgetPrompt() },
403
+ ],
404
+ };
405
+ }
40
406
  if (editFileFailed)
41
407
  return { activeTools: ['listFiles', 'readFile', 'replaceLines', 'writeFile', 'bash'] };
42
408
  return undefined;
@@ -44,58 +410,184 @@ export async function runAgentTurn(value, displayValue, contextFiles, callbacks)
44
410
  onStepFinish({ stepNumber, text, toolCalls, toolResults, finishReason }) {
45
411
  callbacks.debugLog(`step ${stepNumber} finished: ${finishReason}; text=${text.length}; toolCalls=${toolCalls.length}; toolResults=${toolResults.length}`);
46
412
  },
47
- onFinish({ response }) {
48
- const nextConversation = [...requestMessages, ...response.messages];
413
+ onFinish(event) {
414
+ const nextConversation = [...requestMessages, ...event.response.messages];
49
415
  callbacks.setConversation(nextConversation);
50
416
  callbacks.debugLog(`conversation updated to ${nextConversation.length} messages`);
51
417
  },
52
418
  experimental_onToolCallStart({ toolCall }) {
53
- const text = toolCallSummary(toolCall.toolName, toolCall.input);
54
- callbacks.addMessage({ id: `tool-${toolCall.toolCallId}`, role: 'tool', text, streaming: true });
419
+ sawToolCall = true;
420
+ recordToolStart(toolCall);
421
+ resetIdleTimer();
55
422
  callbacks.debugLog(`tool start: ${toolCall.toolName} ${compact(toolCall.input)}`);
56
423
  },
57
424
  experimental_onToolCallFinish(event) {
425
+ resetIdleTimer();
58
426
  const summary = toolResultSummary(event);
59
- const text = `${toolCallSummary(event.toolCall.toolName, event.toolCall.input)}\n${event.success ? '✓' : '✗'} ${summary} in ${formatSeconds(event.durationMs)}`;
60
- if (!event.success && event.toolCall.toolName === 'editFile')
61
- editFileFailed = true;
62
- if (event.success && ['editFile', 'replaceLines', 'writeFile'].includes(event.toolCall.toolName))
63
- mutatingToolSucceeded = true;
427
+ recordToolFinish(event);
64
428
  toolSummaries.push(`${event.toolCall.toolName}: ${summary}`);
65
- callbacks.updateMessage(`tool-${event.toolCall.toolCallId}`, { text, streaming: false });
429
+ recordToolDisplayFinish(event);
430
+ if (!isDuplicateSkippedOutput(event.output))
431
+ toolEpoch += 1;
66
432
  callbacks.debugLog(event.success
67
433
  ? `tool done: ${event.toolCall.toolName} after ${event.durationMs}ms ${compact(event.output)}`
68
434
  : `tool error: ${event.toolCall.toolName} after ${event.durationMs}ms ${compact(event.error)}`);
69
435
  }
70
436
  });
71
- for await (const delta of result.textStream) {
437
+ for await (const rawDelta of result.textStream) {
438
+ resetIdleTimer();
439
+ const delta = sanitizeAssistantText(rawDelta);
440
+ if (sawToolCall)
441
+ textAfterTool = true;
442
+ if (currentAssistantStarted && currentAssistantText.length > 0 && toolEpoch > currentAssistantToolEpoch) {
443
+ callbacks.onEvent?.(agentEvent({ type: 'message_end', id: currentAssistantId, text: hideSyntheticToolCallMarkup(currentAssistantText).trim() }));
444
+ callbacks.updateMessage(currentAssistantId, { streaming: false });
445
+ currentAssistantId = `assistant-${Date.now()}-${Math.random().toString(36).slice(2)}`;
446
+ currentAssistantStarted = false;
447
+ currentAssistantText = '';
448
+ currentAssistantToolEpoch = toolEpoch;
449
+ }
72
450
  assistantText += delta;
73
- if (!assistantStarted) {
451
+ currentAssistantText += delta;
452
+ const displayText = hideSyntheticToolCallMarkup(currentAssistantText);
453
+ if (!displayText && !currentAssistantStarted)
454
+ continue;
455
+ if (!currentAssistantStarted) {
74
456
  assistantStarted = true;
75
- callbacks.addMessage({ id: assistantId, role: 'assistant', text: delta, streaming: true });
457
+ currentAssistantStarted = true;
458
+ callbacks.onEvent?.(agentEvent({ type: 'message_start', id: currentAssistantId, role: 'assistant' }));
459
+ callbacks.addMessage({ id: currentAssistantId, role: 'assistant', text: displayText, streaming: true });
76
460
  }
77
461
  else {
78
- callbacks.updateMessage(assistantId, { text: assistantText });
462
+ callbacks.onEvent?.(agentEvent({ type: 'message_update', id: currentAssistantId, text: displayText }));
463
+ callbacks.updateMessage(currentAssistantId, { text: displayText });
464
+ }
465
+ }
466
+ let completedConversation = callbacks.getConversation();
467
+ try {
468
+ const response = await result.response;
469
+ completedConversation = [...requestMessages, ...response.messages];
470
+ callbacks.setConversation(completedConversation);
471
+ }
472
+ catch (error) {
473
+ throw streamError ?? error;
474
+ }
475
+ callbacks.debugLog(`response stream finished; session has ${completedConversation.length} model messages`);
476
+ const finalAssistantText = hideSyntheticToolCallMarkup(assistantText).trim();
477
+ const decideCompletion = (text) => completionDecision({
478
+ request: value,
479
+ goal,
480
+ assistantText: text,
481
+ sawReadOnlyTool,
482
+ sawToolCall,
483
+ mutatingToolSucceeded,
484
+ validationToolSucceeded,
485
+ validationToolFailed,
486
+ editFileFailed,
487
+ editRecoveryPath,
488
+ });
489
+ let decision = decideCompletion(finalAssistantText);
490
+ async function runCompletionLoop(seedConversation, seedText) {
491
+ let loopConversation = seedConversation;
492
+ let latestText = seedText;
493
+ while ((decision.needsActionContinuation || decision.needsValidationContinuation) && completionContinuationCount < maxCompletionContinuations) {
494
+ completionContinuationCount += 1;
495
+ const prompt = decision.continuationPrompt
496
+ ?? (looksIncomplete(latestText) ? postContinuationPrompt() : 'Continue the same user goal until it is complete, blocked by a concrete issue, or needs a user decision. Focus on the concrete blocker, not a generic plan.');
497
+ const continuation = await streamAssistantResponse(loopConversation, `completion gate ${completionContinuationCount}`, prompt, true);
498
+ loopConversation = callbacks.getConversation();
499
+ if (continuation.text)
500
+ latestText = continuation.text;
501
+ decision = decideCompletion(latestText);
502
+ if (continuation.started && (decision.needsActionContinuation || decision.needsValidationContinuation)) {
503
+ callbacks.updateMessage(continuation.id, { hidden: true });
504
+ }
505
+ }
506
+ if ((decision.needsActionContinuation || decision.needsValidationContinuation) && completionContinuationCount >= maxCompletionContinuations) {
507
+ callbacks.addMessage({ role: 'assistant', text: 'Stopped after the bounded completion loop. The current goal may still need work; ask me to continue and I will resume from the latest tool results.' });
508
+ }
509
+ if (!latestText && toolSummaries.length > 0) {
510
+ const followUp = await streamAssistantResponse(loopConversation, 'completion loop ended without text', noTextAfterToolPrompt(false), false);
511
+ if (!followUp.text)
512
+ callbacks.addMessage({ role: 'assistant', text: `Finished tool work but the model did not produce a final response. Last tool result: ${toolSummaries.at(-1)}.` });
79
513
  }
80
514
  }
81
- callbacks.debugLog(`response stream finished; session has ${callbacks.getConversation().length} model messages`);
82
515
  if (assistantStarted) {
83
- callbacks.setLastAssistantText(assistantText.trim());
84
- callbacks.updateMessage(assistantId, { streaming: false });
516
+ const hidePreToolFragment = sawToolCall && !textAfterTool;
517
+ callbacks.setLastAssistantText(hidePreToolFragment ? '' : finalAssistantText);
518
+ callbacks.onEvent?.(agentEvent({ type: 'message_end', id: currentAssistantId, text: finalAssistantText, hidden: finalAssistantText.length === 0 || hidePreToolFragment }));
519
+ callbacks.updateMessage(currentAssistantId, { text: finalAssistantText, streaming: false, hidden: finalAssistantText.length === 0 || hidePreToolFragment });
520
+ if (decision.needsActionContinuation || decision.needsValidationContinuation) {
521
+ callbacks.updateMessage(currentAssistantId, { streaming: false, hidden: true });
522
+ await runCompletionLoop(completedConversation, finalAssistantText);
523
+ }
524
+ else if (sawToolCall && !textAfterTool) {
525
+ const followUp = await streamAssistantResponse(completedConversation, 'tool use completed without follow-up text', noTextAfterToolPrompt(false), false);
526
+ if (!followUp.text) {
527
+ callbacks.addMessage({ role: 'assistant', text: 'Stopped after tool use without a follow-up response. You can ask me to continue if the task is not complete.' });
528
+ }
529
+ }
530
+ }
531
+ else if (sawToolCall) {
532
+ const allowTools = (likelyActionRequest && (!mutatingToolSucceeded || editFileFailed)) || (likelyValidationRequest && !validationToolSucceeded);
533
+ const prompt = noTextAfterToolPrompt(allowTools);
534
+ const followUp = await streamAssistantResponse(completedConversation, 'tool-only turn completed without text', prompt, allowTools);
535
+ decision = decideCompletion(followUp.text);
536
+ if (allowTools)
537
+ await runCompletionLoop(callbacks.getConversation(), followUp.text);
538
+ if (!followUp.text && completionContinuationCount === 0) {
539
+ const fallback = toolSummaries.length > 0
540
+ ? `Finished tool work but the model did not produce a final response. Last tool result: ${toolSummaries.at(-1)}.`
541
+ : 'Finished without a text response.';
542
+ callbacks.addMessage({ id: currentAssistantId, role: 'assistant', text: fallback, streaming: false });
543
+ }
85
544
  }
86
545
  else {
87
- const fallback = toolSummaries.length > 0
88
- ? `Finished tool work but the model did not produce a final response. Last tool result: ${toolSummaries.at(-1)}.`
89
- : 'Finished without a text response.';
90
- callbacks.addMessage({ id: assistantId, role: 'assistant', text: fallback, streaming: false });
546
+ callbacks.addMessage({ id: currentAssistantId, role: 'assistant', text: 'Finished without a text response.', streaming: false });
91
547
  }
548
+ goal.phase = 'done';
549
+ goal.status = 'complete';
550
+ turnStatus = 'complete';
551
+ callbacks.setGoalStatus?.(undefined);
92
552
  }
93
553
  catch (error) {
94
- const text = error instanceof Error ? error.message : String(error);
95
- callbacks.debugLog(`error: ${text}`);
96
- callbacks.addMessage({ role: 'assistant', text: `Model call failed: ${text}` });
554
+ if (abortController.signal.aborted) {
555
+ turnStatus = 'aborted';
556
+ callbacks.debugLog('request aborted');
557
+ callbacks.addMessage({ role: 'system', text: 'Thinking aborted. You can type again.' });
558
+ }
559
+ else {
560
+ const text = error instanceof Error ? error.message : String(error);
561
+ callbacks.debugLog(`error: ${text}`);
562
+ if (!contextOverflowRecovered && isContextOverflowError(error)) {
563
+ const compacted = callbacks.compactConversation?.('Automatic recovery after provider context overflow. Preserve the active user request and concrete next steps.') ?? false;
564
+ callbacks.onEvent?.(agentEvent({ type: 'context_overflow', recovered: compacted, error: text }));
565
+ if (compacted) {
566
+ callbacks.addMessage({ role: 'system', text: 'Context overflow detected; compacted older context and retrying the same request once.' });
567
+ await runAgentTurn(value, displayValue, contextFiles, callbacks, retryAttempt, true, true);
568
+ return;
569
+ }
570
+ callbacks.addMessage({ role: 'system', text: 'Context overflow detected, but there was not enough conversation history to compact automatically.' });
571
+ }
572
+ const maxRetries = 2;
573
+ if (retryAttempt < maxRetries && isRetryableModelError(error)) {
574
+ const delay = retryDelayMs(retryAttempt);
575
+ callbacks.onEvent?.(agentEvent({ type: 'retry', attempt: retryAttempt + 1, maxAttempts: maxRetries, delayMs: delay, error: text }));
576
+ callbacks.addMessage({ role: 'system', text: `Transient model error; retrying attempt ${retryAttempt + 1}/${maxRetries} in ${formatSeconds(delay)}: ${text}` });
577
+ await abortableDelay(delay, abortController.signal);
578
+ if (abortController.signal.aborted)
579
+ return;
580
+ await runAgentTurn(value, displayValue, contextFiles, callbacks, retryAttempt + 1, true, contextOverflowRecovered);
581
+ return;
582
+ }
583
+ callbacks.addMessage({ role: 'assistant', text: `Model call failed: ${text}` });
584
+ }
97
585
  }
98
586
  finally {
587
+ if (idleTimer)
588
+ clearTimeout(idleTimer);
589
+ callbacks.onEvent?.(agentEvent({ type: 'turn_end', request: value, status: turnStatus }));
590
+ callbacks.setAbortController?.(null);
99
591
  callbacks.setBusy(false);
100
592
  }
101
593
  }