@denizokcu/haze 0.0.2 → 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 (45) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +87 -33
  3. package/dist/cli/commands/chat.d.ts +3 -1
  4. package/dist/cli/commands/chat.js +442 -52
  5. package/dist/cli/commands/commands.d.ts +5 -0
  6. package/dist/cli/commands/commands.js +114 -29
  7. package/dist/cli/commands/formatters.js +5 -2
  8. package/dist/cli/commands/streaming.d.ts +5 -1
  9. package/dist/cli/commands/streaming.js +193 -86
  10. package/dist/cli/index.js +5 -2
  11. package/dist/config/inputHistory.js +8 -0
  12. package/dist/config/providers.d.ts +26 -0
  13. package/dist/config/providers.js +88 -0
  14. package/dist/config/settings.d.ts +9 -2
  15. package/dist/core/agent/compaction.d.ts +13 -0
  16. package/dist/core/agent/compaction.js +34 -0
  17. package/dist/core/agent/errors.d.ts +3 -0
  18. package/dist/core/agent/errors.js +13 -0
  19. package/dist/core/agent/events.d.ts +58 -0
  20. package/dist/core/agent/events.js +3 -0
  21. package/dist/core/goal/completionPolicy.d.ts +27 -0
  22. package/dist/core/goal/completionPolicy.js +67 -0
  23. package/dist/core/goal/requestClassifier.d.ts +6 -0
  24. package/dist/core/goal/requestClassifier.js +31 -0
  25. package/dist/core/goal/sessionGoal.d.ts +30 -0
  26. package/dist/core/goal/sessionGoal.js +88 -0
  27. package/dist/core/session/sessionStore.d.ts +37 -0
  28. package/dist/core/session/sessionStore.js +59 -0
  29. package/dist/llm/client.d.ts +1 -1
  30. package/dist/llm/client.js +6 -6
  31. package/dist/llm/hazeTools.d.ts +38 -0
  32. package/dist/llm/hazeTools.js +196 -92
  33. package/dist/llm/initPrompt.js +6 -4
  34. package/dist/llm/systemPrompt.js +3 -3
  35. package/dist/skills/builder/SkillBuilder.d.ts +6 -0
  36. package/dist/skills/builder/SkillBuilder.js +146 -24
  37. package/dist/ui/components/ErrorView.d.ts +2 -1
  38. package/dist/ui/components/Header.d.ts +2 -1
  39. package/dist/ui/components/Header.js +1 -11
  40. package/dist/ui/components/MarkdownText.d.ts +2 -1
  41. package/dist/ui/components/TextInput.d.ts +7 -3
  42. package/dist/ui/components/TextInput.js +112 -27
  43. package/dist/ui/theme.d.ts +1 -0
  44. package/dist/ui/theme.js +2 -1
  45. package/package.json +8 -8
@@ -5,6 +5,11 @@ import { buildSystemPrompt } from '../../llm/systemPrompt.js';
5
5
  import { loadSkillRegistry } from '../../skills/SkillRegistry.js';
6
6
  import { buildSkillTools } from '../../skills/skillTools.js';
7
7
  import { compact, toolCallSummary, toolResultSummary, formatSeconds } from './formatters.js';
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';
8
13
  function stableToolKey(toolCall) {
9
14
  return `${toolCall.toolName}:${JSON.stringify(toolCall.input)}`;
10
15
  }
@@ -28,31 +33,17 @@ function toolOnlyStepCount(steps) {
28
33
  }
29
34
  return count;
30
35
  }
31
- function isPlanOnlyRequest(value) {
32
- return /\b(create|make|write|draft|outline)\s+(?:a\s+)?plan\b|\bplan\s+(?:for|to)\b/i.test(value) && !/\bimplement|execute|do\b/i.test(value);
33
- }
34
- function isLikelyActionRequest(value) {
35
- if (isPlanOnlyRequest(value))
36
- return false;
37
- return /\b(add|create|write|implement|update|fix|change|support|wire|test|tests|document|docs|documentation|run|verify)\b/i.test(value);
38
- }
39
- function isValidationRequest(value) {
40
- if (isPlanOnlyRequest(value))
41
- return false;
42
- return /\b(run|verify|test|tests|check|validate)\b/i.test(value);
43
- }
44
- function isPlanImplementationRequest(value) {
45
- return /\b(implement|execute|do)\b.*\bplan\b|\bplan\.md\b|\btest_plan\.md\b/i.test(value);
46
- }
47
- function looksIncomplete(text) {
48
- return /\b(incomplete|what remains|remains:|next:|not implemented|not created|no tests exist|created no docs|has not been|have not been|not yet|never executed|not executed|not run|cannot retry|cannot write|cannot validate|tool budget reached)\b/i.test(text);
49
- }
50
36
  function sanitizeAssistantText(text) {
51
37
  return [...text].filter(char => {
52
38
  const code = char.charCodeAt(0);
53
39
  return !(code <= 8 || code === 11 || code === 12 || (code >= 14 && code <= 31) || code === 127 || code === 155);
54
40
  }).join('');
55
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
+ }
56
47
  function toolInputPath(input) {
57
48
  return typeof input === 'object' && input != null && 'path' in input && typeof input.path === 'string'
58
49
  ? input.path
@@ -61,13 +52,36 @@ function toolInputPath(input) {
61
52
  function isDuplicateSkippedOutput(output) {
62
53
  return typeof output === 'object' && output != null && 'duplicateSkipped' in output && output.duplicateSkipped === true;
63
54
  }
64
- export async function runAgentTurn(value, displayValue, contextFiles, callbacks) {
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) {
65
76
  const displayVal = displayValue ?? value;
66
77
  const userMessage = { role: 'user', text: displayVal };
78
+ callbacks.onEvent?.(agentEvent({ type: 'turn_start', request: value }));
67
79
  callbacks.setBusy(true);
68
- callbacks.addMessage(userMessage);
80
+ if (!retryingExistingRequest)
81
+ callbacks.addMessage(userMessage);
69
82
  const abortController = new AbortController();
70
83
  callbacks.setAbortController?.(abortController);
84
+ let turnStatus = 'failed';
71
85
  let idleTimer;
72
86
  const resetIdleTimer = () => {
73
87
  if (idleTimer)
@@ -77,24 +91,29 @@ export async function runAgentTurn(value, displayValue, contextFiles, callbacks)
77
91
  try {
78
92
  const m = await model();
79
93
  if (!m) {
80
- callbacks.addMessage({ role: 'assistant', text: 'No API key configured. Run /login, then /model x-ai/grok-build-0.1. 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.' });
81
95
  return;
82
96
  }
83
97
  const activeModel = m;
84
98
  const skillRegistry = await loadSkillRegistry();
85
99
  const availableTools = { ...hazeTools, ...buildSkillTools(skillRegistry) };
100
+ const goal = createSessionGoal(value);
101
+ callbacks.setGoalStatus?.(formatGoalStatus(goal));
86
102
  const likelyPlanOnlyRequest = isPlanOnlyRequest(value);
87
103
  const likelyPlanImplementationRequest = isPlanImplementationRequest(value);
88
- const likelyActionRequest = isLikelyActionRequest(value);
104
+ const likelyActionRequest = isActionRequest(value);
89
105
  const likelyValidationRequest = isValidationRequest(value);
90
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.';
91
- const requestMessages = likelyPlanImplementationRequest
92
- ? [...callbacks.getConversation(), { role: 'user', content: value }, { role: 'user', content: planImplementationGuidance }]
93
- : [...callbacks.getConversation(), { role: 'user', content: value }];
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 }];
94
112
  callbacks.setConversation(requestMessages);
95
113
  resetIdleTimer();
96
114
  let currentAssistantId = `assistant-${Date.now()}`;
97
115
  let assistantStarted = false;
116
+ let currentAssistantStarted = false;
98
117
  let currentAssistantText = '';
99
118
  let assistantText = '';
100
119
  let toolEpoch = 0;
@@ -102,11 +121,12 @@ export async function runAgentTurn(value, displayValue, contextFiles, callbacks)
102
121
  let editFileFailed = false;
103
122
  let mutatingToolSucceeded = false;
104
123
  let validationToolSucceeded = false;
124
+ let validationToolFailed = false;
105
125
  let sawReadOnlyTool = false;
106
126
  let sawToolCall = false;
107
127
  let textAfterTool = false;
108
- let forcedContinuationUsed = false;
109
- let secondContinuationUsed = false;
128
+ let completionContinuationCount = 0;
129
+ const maxCompletionContinuations = 4;
110
130
  let editRecoveryPath;
111
131
  let editRecoveryReadSatisfied = false;
112
132
  const toolSummaries = [];
@@ -116,8 +136,14 @@ export async function runAgentTurn(value, displayValue, contextFiles, callbacks)
116
136
  let toolGroupStarted = false;
117
137
  function renderToolGroup(streaming) {
118
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;
119
145
  const grouped = new Map();
120
- for (const item of visibleItems) {
146
+ for (const item of compactItems) {
121
147
  const key = `${item.status}:${item.summary}:${item.result ?? ''}`;
122
148
  const current = grouped.get(key);
123
149
  if (current)
@@ -126,8 +152,10 @@ export async function runAgentTurn(value, displayValue, contextFiles, callbacks)
126
152
  grouped.set(key, { item, count: 1 });
127
153
  }
128
154
  const rows = [...grouped.values()];
129
- const running = visibleItems.some(item => item.status === 'running');
130
- const header = running || streaming ? 'Running tools' : `Tools: ${visibleItems.length} call${visibleItems.length === 1 ? '' : 's'}`;
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}`;
131
159
  const lines = rows.map(({ item, count }) => {
132
160
  const icon = item.status === 'running' ? '…' : item.status === 'success' ? '✓' : '✗';
133
161
  const countText = count > 1 ? ` ×${count}` : '';
@@ -147,34 +175,39 @@ export async function runAgentTurn(value, displayValue, contextFiles, callbacks)
147
175
  }
148
176
  }
149
177
  function recordToolStart(toolCall) {
178
+ callbacks.onEvent?.(agentEvent({ type: 'tool_start', id: toolCall.toolCallId, name: toolCall.toolName, input: toolCall.input }));
150
179
  toolDisplayItems.push({ id: toolCall.toolCallId, summary: toolCallSummary(toolCall.toolName, toolCall.input), status: 'running' });
151
180
  updateToolGroup(true);
152
181
  }
153
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 }));
154
184
  const item = toolDisplayItems.find(candidate => candidate.id === event.toolCall.toolCallId);
155
185
  if (!item)
156
186
  return;
157
- item.status = event.success ? 'success' : 'error';
187
+ item.status = toolOutputOk(event.output, event.success) ? 'success' : 'error';
158
188
  item.result = toolResultSummary(event);
159
189
  item.durationMs = event.durationMs;
160
190
  item.hidden = isDuplicateSkippedOutput(event.output);
161
191
  updateToolGroup(toolDisplayItems.some(candidate => candidate.status === 'running'));
162
192
  }
163
- callbacks.debugLog(`request started with ${requestMessages.length} conversation messages; action=${likelyActionRequest}`);
193
+ callbacks.debugLog(`request started with ${requestMessages.length} conversation messages; intent=${goal.normalizedIntent}; action=${likelyActionRequest}`);
164
194
  function recordToolFinish(event) {
165
195
  const path = toolInputPath(event.toolCall.input);
166
196
  const duplicateSkipped = isDuplicateSkippedOutput(event.output);
167
- if (!event.success && ['editFile', 'replaceLines', 'writeFile'].includes(event.toolCall.toolName)) {
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)) {
168
201
  editFileFailed = true;
169
202
  editRecoveryPath = path;
170
203
  editRecoveryReadSatisfied = false;
171
204
  }
172
- if (event.success && ['listFiles', 'readFile'].includes(event.toolCall.toolName))
205
+ if (ok && ['listFiles', 'readFile'].includes(event.toolCall.toolName))
173
206
  sawReadOnlyTool = true;
174
- if (event.success && event.toolCall.toolName === 'readFile' && path && path === editRecoveryPath && !duplicateSkipped) {
207
+ if (ok && event.toolCall.toolName === 'readFile' && path && path === editRecoveryPath && !duplicateSkipped) {
175
208
  editRecoveryReadSatisfied = true;
176
209
  }
177
- if (event.success && ['editFile', 'replaceLines', 'writeFile'].includes(event.toolCall.toolName)) {
210
+ if (ok && !duplicateSkipped && ['editFile', 'replaceLines', 'writeFile'].includes(event.toolCall.toolName)) {
178
211
  mutatingToolSucceeded = true;
179
212
  if (!path || path === editRecoveryPath) {
180
213
  editRecoveryPath = undefined;
@@ -183,9 +216,10 @@ export async function runAgentTurn(value, displayValue, contextFiles, callbacks)
183
216
  }
184
217
  }
185
218
  if (event.success && event.toolCall.toolName === 'bash') {
186
- const ok = typeof event.output === 'object' && event.output != null && 'ok' in event.output ? Boolean(event.output.ok) : true;
187
219
  if (ok)
188
220
  validationToolSucceeded = true;
221
+ else
222
+ validationToolFailed = true;
189
223
  }
190
224
  }
191
225
  async function streamAssistantResponse(messages, reason, prompt, allowTools = false) {
@@ -194,6 +228,7 @@ export async function runAgentTurn(value, displayValue, contextFiles, callbacks)
194
228
  let responseStarted = false;
195
229
  let responseText = '';
196
230
  let continuationToolCalls = 0;
231
+ let followUpStreamError;
197
232
  const continuationMessages = [
198
233
  ...messages,
199
234
  { role: 'user', content: prompt },
@@ -201,6 +236,7 @@ export async function runAgentTurn(value, displayValue, contextFiles, callbacks)
201
236
  const followUp = streamText({
202
237
  model: activeModel,
203
238
  temperature: 0,
239
+ maxOutputTokens: DEFAULT_MAX_OUTPUT_TOKENS,
204
240
  system: buildSystemPrompt(contextFiles),
205
241
  messages: continuationMessages,
206
242
  tools: availableTools,
@@ -215,7 +251,7 @@ export async function runAgentTurn(value, displayValue, contextFiles, callbacks)
215
251
  toolChoice: 'none',
216
252
  messages: [
217
253
  ...messages,
218
- { role: 'user', content: 'Tool budget reached. If the current request is complete, summarize only current-turn changes and validation. If incomplete, state the concrete blocker briefly; do not claim tools are unavailable and do not recap unrelated earlier tasks.' },
254
+ { role: 'user', content: toolLoopBudgetPrompt() },
219
255
  ],
220
256
  };
221
257
  }
@@ -242,6 +278,7 @@ export async function runAgentTurn(value, displayValue, contextFiles, callbacks)
242
278
  return undefined;
243
279
  },
244
280
  onError({ error }) {
281
+ followUpStreamError = error;
245
282
  callbacks.debugLog(`stream error: ${error instanceof Error ? error.message : String(error)}`);
246
283
  },
247
284
  onFinish(event) {
@@ -271,23 +308,38 @@ export async function runAgentTurn(value, displayValue, contextFiles, callbacks)
271
308
  resetIdleTimer();
272
309
  const delta = sanitizeAssistantText(rawDelta);
273
310
  responseText += delta;
311
+ const displayText = hideSyntheticToolCallMarkup(responseText);
312
+ if (!displayText && !responseStarted)
313
+ continue;
274
314
  if (!responseStarted) {
275
315
  responseStarted = true;
276
- callbacks.addMessage({ id: responseId, role: 'assistant', text: delta, streaming: true });
316
+ callbacks.onEvent?.(agentEvent({ type: 'message_start', id: responseId, role: 'assistant' }));
317
+ callbacks.addMessage({ id: responseId, role: 'assistant', text: displayText, streaming: true });
277
318
  }
278
319
  else {
279
- callbacks.updateMessage(responseId, { text: responseText });
320
+ callbacks.onEvent?.(agentEvent({ type: 'message_update', id: responseId, text: displayText }));
321
+ callbacks.updateMessage(responseId, { text: displayText });
280
322
  }
281
323
  }
324
+ try {
325
+ await followUp.response;
326
+ }
327
+ catch (error) {
328
+ throw followUpStreamError ?? error;
329
+ }
330
+ const finalText = hideSyntheticToolCallMarkup(responseText).trim();
282
331
  if (responseStarted) {
283
- callbacks.setLastAssistantText(responseText.trim());
284
- callbacks.updateMessage(responseId, { streaming: false });
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 });
285
335
  }
286
- return responseText.trim();
336
+ return { text: finalText, id: responseId, started: responseStarted };
287
337
  }
338
+ let streamError;
288
339
  const result = streamText({
289
340
  model: activeModel,
290
341
  temperature: 0,
342
+ maxOutputTokens: DEFAULT_MAX_OUTPUT_TOKENS,
291
343
  system: buildSystemPrompt(contextFiles),
292
344
  messages: requestMessages,
293
345
  tools: availableTools,
@@ -295,6 +347,7 @@ export async function runAgentTurn(value, displayValue, contextFiles, callbacks)
295
347
  abortSignal: abortController.signal,
296
348
  experimental_context: toolExecutionContext,
297
349
  onError({ error }) {
350
+ streamError = error;
298
351
  callbacks.debugLog(`stream error: ${error instanceof Error ? error.message : String(error)}`);
299
352
  },
300
353
  prepareStep({ steps, messages }) {
@@ -346,7 +399,7 @@ export async function runAgentTurn(value, displayValue, contextFiles, callbacks)
346
399
  toolChoice: 'none',
347
400
  messages: [
348
401
  ...messages,
349
- { role: 'user', content: 'Tool budget reached. If the current request is complete, summarize only current-turn changes and validation. If the requested change is incomplete, state the concrete blocker briefly. Do not claim tools are unavailable, recap unrelated earlier tasks, or provide a generic remains list.' },
402
+ { role: 'user', content: toolLoopBudgetPrompt() },
350
403
  ],
351
404
  };
352
405
  }
@@ -386,20 +439,28 @@ export async function runAgentTurn(value, displayValue, contextFiles, callbacks)
386
439
  const delta = sanitizeAssistantText(rawDelta);
387
440
  if (sawToolCall)
388
441
  textAfterTool = true;
389
- if (currentAssistantText.length > 0 && toolEpoch > currentAssistantToolEpoch) {
442
+ if (currentAssistantStarted && currentAssistantText.length > 0 && toolEpoch > currentAssistantToolEpoch) {
443
+ callbacks.onEvent?.(agentEvent({ type: 'message_end', id: currentAssistantId, text: hideSyntheticToolCallMarkup(currentAssistantText).trim() }));
390
444
  callbacks.updateMessage(currentAssistantId, { streaming: false });
391
445
  currentAssistantId = `assistant-${Date.now()}-${Math.random().toString(36).slice(2)}`;
446
+ currentAssistantStarted = false;
392
447
  currentAssistantText = '';
393
448
  currentAssistantToolEpoch = toolEpoch;
394
449
  }
395
450
  assistantText += delta;
396
451
  currentAssistantText += delta;
397
- if (currentAssistantText === delta) {
452
+ const displayText = hideSyntheticToolCallMarkup(currentAssistantText);
453
+ if (!displayText && !currentAssistantStarted)
454
+ continue;
455
+ if (!currentAssistantStarted) {
398
456
  assistantStarted = true;
399
- callbacks.addMessage({ id: currentAssistantId, role: 'assistant', text: currentAssistantText, 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 });
400
460
  }
401
461
  else {
402
- callbacks.updateMessage(currentAssistantId, { text: currentAssistantText });
462
+ callbacks.onEvent?.(agentEvent({ type: 'message_update', id: currentAssistantId, text: displayText }));
463
+ callbacks.updateMessage(currentAssistantId, { text: displayText });
403
464
  }
404
465
  }
405
466
  let completedConversation = callbacks.getConversation();
@@ -408,54 +469,73 @@ export async function runAgentTurn(value, displayValue, contextFiles, callbacks)
408
469
  completedConversation = [...requestMessages, ...response.messages];
409
470
  callbacks.setConversation(completedConversation);
410
471
  }
411
- catch {
412
- // Keep the conversation from onFinish if the response promise is unavailable.
472
+ catch (error) {
473
+ throw streamError ?? error;
413
474
  }
414
475
  callbacks.debugLog(`response stream finished; session has ${completedConversation.length} model messages`);
415
- const finalAssistantText = assistantText.trim();
416
- const assistantAdmitsIncomplete = looksIncomplete(finalAssistantText);
417
- const requestCompletedByTools = mutatingToolSucceeded && validationToolSucceeded && !editRecoveryPath;
418
- const needsActionContinuation = likelyActionRequest
419
- && !requestCompletedByTools
420
- && ((sawReadOnlyTool && !mutatingToolSucceeded) || editFileFailed || assistantAdmitsIncomplete);
421
- const needsValidationContinuation = likelyValidationRequest && !requestCompletedByTools && !validationToolSucceeded && (sawReadOnlyTool || mutatingToolSucceeded || assistantAdmitsIncomplete);
422
- if (assistantStarted) {
423
- callbacks.setLastAssistantText(finalAssistantText);
424
- callbacks.updateMessage(currentAssistantId, { streaming: false });
425
- if ((needsActionContinuation || needsValidationContinuation) && !forcedContinuationUsed) {
426
- forcedContinuationUsed = true;
427
- callbacks.updateMessage(currentAssistantId, { text: 'Continuing to complete the requested change...', streaming: false });
428
- const prompt = editFileFailed
429
- ? 'Your editFile attempt failed. Use the latest readFile line-numbered output and replaceLines to complete the requested change. Continue with any remaining tests or validation if relevant. Do not stop with a summary.'
430
- : needsValidationContinuation
431
- ? 'You have not run the requested validation yet. Continue now by running the appropriate test/check command. Summarize only after the command finishes.'
432
- : mutatingToolSucceeded
433
- ? 'Your previous response says the current request is incomplete. Continue now with the remaining edits and validation for this same request. Do not summarize a plan unless blocked.'
434
- : 'You inspected files but have not made the requested change yet. Continue now by editing or writing the necessary files. Do not summarize a plan unless blocked.';
435
- const continuationText = await streamAssistantResponse(completedConversation, 'current-turn completion gate', prompt, true);
436
- if (!secondContinuationUsed && looksIncomplete(continuationText) && (likelyActionRequest || likelyValidationRequest)) {
437
- secondContinuationUsed = true;
438
- await streamAssistantResponse(callbacks.getConversation(), 'post-continuation completion gate', 'Your previous response still described unfinished work, missing validation, or a tool-budget issue. If any tools are still available, complete the remaining edit or run the final validation now. Only call something a blocker if a concrete tool failure prevents progress.', true);
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 });
439
504
  }
440
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)}.` });
513
+ }
514
+ }
515
+ if (assistantStarted) {
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
+ }
441
524
  else if (sawToolCall && !textAfterTool) {
442
- const followUpText = await streamAssistantResponse(completedConversation, 'tool use completed without follow-up text', 'Continue from the tool result and answer my original request. Do not call tools. Summarize only current-turn changes and validation; do not recap unrelated earlier tasks.', false);
443
- if (!followUpText) {
525
+ const followUp = await streamAssistantResponse(completedConversation, 'tool use completed without follow-up text', noTextAfterToolPrompt(false), false);
526
+ if (!followUp.text) {
444
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.' });
445
528
  }
446
529
  }
447
530
  }
448
531
  else if (sawToolCall) {
449
532
  const allowTools = (likelyActionRequest && (!mutatingToolSucceeded || editFileFailed)) || (likelyValidationRequest && !validationToolSucceeded);
450
- const prompt = allowTools
451
- ? 'Continue the original request now. If it asks for a change, edit or write the necessary files. If it asks to run or verify tests, run the command. Do not provide only a retrospective summary unless blocked.'
452
- : 'Continue from the tool result and answer my original request. Do not call tools. Summarize only current-turn changes and validation; do not recap unrelated earlier tasks.';
453
- const followUpText = await streamAssistantResponse(completedConversation, 'tool-only turn completed without text', prompt, allowTools);
454
- if (!secondContinuationUsed && allowTools && looksIncomplete(followUpText)) {
455
- secondContinuationUsed = true;
456
- await streamAssistantResponse(callbacks.getConversation(), 'post-follow-up completion gate', 'Your previous response still described unfinished work, missing validation, or a tool-budget issue. If any tools are still available, complete the remaining edit or run the final validation now. Only call something a blocker if a concrete tool failure prevents progress.', true);
457
- }
458
- if (!followUpText) {
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) {
459
539
  const fallback = toolSummaries.length > 0
460
540
  ? `Finished tool work but the model did not produce a final response. Last tool result: ${toolSummaries.at(-1)}.`
461
541
  : 'Finished without a text response.';
@@ -465,21 +545,48 @@ export async function runAgentTurn(value, displayValue, contextFiles, callbacks)
465
545
  else {
466
546
  callbacks.addMessage({ id: currentAssistantId, role: 'assistant', text: 'Finished without a text response.', streaming: false });
467
547
  }
548
+ goal.phase = 'done';
549
+ goal.status = 'complete';
550
+ turnStatus = 'complete';
551
+ callbacks.setGoalStatus?.(undefined);
468
552
  }
469
553
  catch (error) {
470
554
  if (abortController.signal.aborted) {
555
+ turnStatus = 'aborted';
471
556
  callbacks.debugLog('request aborted');
472
557
  callbacks.addMessage({ role: 'system', text: 'Thinking aborted. You can type again.' });
473
558
  }
474
559
  else {
475
560
  const text = error instanceof Error ? error.message : String(error);
476
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
+ }
477
583
  callbacks.addMessage({ role: 'assistant', text: `Model call failed: ${text}` });
478
584
  }
479
585
  }
480
586
  finally {
481
587
  if (idleTimer)
482
588
  clearTimeout(idleTimer);
589
+ callbacks.onEvent?.(agentEvent({ type: 'turn_end', request: value, status: turnStatus }));
483
590
  callbacks.setAbortController?.(null);
484
591
  callbacks.setBusy(false);
485
592
  }
package/dist/cli/index.js CHANGED
@@ -11,9 +11,12 @@ program
11
11
  .name('haze')
12
12
  .description('A pragmatic, intentionally limited agentic CLI.')
13
13
  .version(pkg.version)
14
- .option('--debug', 'show simple model/tool debug logs in the chat UI');
14
+ .option('--debug', 'show simple model/tool debug logs in the chat UI')
15
+ .option('-c, --continue', 'resume the latest saved session for this workspace')
16
+ .option('--no-session', 'run without saving or resuming a durable session');
15
17
  program.action(async () => {
16
- await chatCommand({ debug: Boolean(program.opts().debug), version: pkg.version });
18
+ const opts = program.opts();
19
+ await chatCommand({ debug: Boolean(opts.debug), continueSession: Boolean(opts.continue), noSession: opts.session === false, version: pkg.version });
17
20
  });
18
21
  program.parseAsync().catch(error => {
19
22
  console.error(error instanceof Error ? error.message : error);
@@ -4,17 +4,25 @@ import { HAZE_DIR } from './paths.js';
4
4
  const HISTORY_DIR = path.join(HAZE_DIR, 'history');
5
5
  export const INPUT_HISTORY_FILE = path.join(HISTORY_DIR, 'input-history.json');
6
6
  const MAX_HISTORY_ITEMS = 500;
7
+ const DISABLE_PERSISTENT_HISTORY = process.env.VITEST === 'true' || process.env.NODE_ENV === 'test';
8
+ let testHistory = [];
7
9
  function normalizeHistory(value) {
8
10
  if (!Array.isArray(value))
9
11
  return [];
10
12
  return value.filter((item) => typeof item === 'string' && item.trim().length > 0);
11
13
  }
12
14
  export async function readInputHistory() {
15
+ if (DISABLE_PERSISTENT_HISTORY)
16
+ return testHistory.slice(-MAX_HISTORY_ITEMS);
13
17
  const data = await fs.readJson(INPUT_HISTORY_FILE).catch(() => []);
14
18
  return normalizeHistory(data).slice(-MAX_HISTORY_ITEMS);
15
19
  }
16
20
  export async function writeInputHistory(history) {
17
21
  const normalized = normalizeHistory(history).slice(-MAX_HISTORY_ITEMS);
22
+ if (DISABLE_PERSISTENT_HISTORY) {
23
+ testHistory = normalized;
24
+ return;
25
+ }
18
26
  await fs.ensureDir(HISTORY_DIR);
19
27
  await fs.writeJson(INPUT_HISTORY_FILE, normalized, { spaces: 2 });
20
28
  }
@@ -0,0 +1,26 @@
1
+ import type { HazeSettings, HazeProviderSettings } from './settings.js';
2
+ export declare const DEFAULT_PROVIDER_NAME = "openrouter";
3
+ export declare const DEFAULT_PROVIDER_URL = "https://openrouter.ai/api/v1";
4
+ export declare const DEFAULT_MODEL = "x-ai/grok-build-0.1";
5
+ export type ModelResolution = {
6
+ status: 'found';
7
+ provider: HazeProviderSettings;
8
+ model: string;
9
+ } | {
10
+ status: 'ambiguous';
11
+ model: string;
12
+ providers: HazeProviderSettings[];
13
+ } | {
14
+ status: 'missing';
15
+ };
16
+ export declare function configuredProviders(settings: HazeSettings): HazeProviderSettings[];
17
+ export declare function findProvider(settings: HazeSettings, name: string): HazeProviderSettings | undefined;
18
+ export declare function activeProvider(settings: HazeSettings): HazeProviderSettings;
19
+ export declare function activeModel(settings: HazeSettings): {
20
+ provider: HazeProviderSettings;
21
+ model: string;
22
+ };
23
+ export declare function resolveModelSelector(settings: HazeSettings, selector: string): ModelResolution;
24
+ export declare function modelSelector(provider: HazeProviderSettings, model: string): string;
25
+ export declare function upsertProvider(settings: HazeSettings, provider: HazeProviderSettings): HazeProviderSettings[];
26
+ export declare function providerHasKey(settings: HazeSettings, provider: HazeProviderSettings): boolean;