@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.
- package/CHANGELOG.md +10 -0
- package/README.md +87 -33
- package/dist/cli/commands/chat.d.ts +3 -1
- package/dist/cli/commands/chat.js +442 -52
- package/dist/cli/commands/commands.d.ts +5 -0
- package/dist/cli/commands/commands.js +114 -29
- package/dist/cli/commands/formatters.js +5 -2
- package/dist/cli/commands/streaming.d.ts +5 -1
- package/dist/cli/commands/streaming.js +193 -86
- package/dist/cli/index.js +5 -2
- package/dist/config/inputHistory.js +8 -0
- package/dist/config/providers.d.ts +26 -0
- package/dist/config/providers.js +88 -0
- package/dist/config/settings.d.ts +9 -2
- package/dist/core/agent/compaction.d.ts +13 -0
- package/dist/core/agent/compaction.js +34 -0
- package/dist/core/agent/errors.d.ts +3 -0
- package/dist/core/agent/errors.js +13 -0
- package/dist/core/agent/events.d.ts +58 -0
- package/dist/core/agent/events.js +3 -0
- package/dist/core/goal/completionPolicy.d.ts +27 -0
- package/dist/core/goal/completionPolicy.js +67 -0
- package/dist/core/goal/requestClassifier.d.ts +6 -0
- package/dist/core/goal/requestClassifier.js +31 -0
- package/dist/core/goal/sessionGoal.d.ts +30 -0
- package/dist/core/goal/sessionGoal.js +88 -0
- package/dist/core/session/sessionStore.d.ts +37 -0
- package/dist/core/session/sessionStore.js +59 -0
- package/dist/llm/client.d.ts +1 -1
- package/dist/llm/client.js +6 -6
- package/dist/llm/hazeTools.d.ts +38 -0
- package/dist/llm/hazeTools.js +196 -92
- package/dist/llm/initPrompt.js +6 -4
- package/dist/llm/systemPrompt.js +3 -3
- package/dist/skills/builder/SkillBuilder.d.ts +6 -0
- package/dist/skills/builder/SkillBuilder.js +146 -24
- package/dist/ui/components/ErrorView.d.ts +2 -1
- package/dist/ui/components/Header.d.ts +2 -1
- package/dist/ui/components/Header.js +1 -11
- package/dist/ui/components/MarkdownText.d.ts +2 -1
- package/dist/ui/components/TextInput.d.ts +7 -3
- package/dist/ui/components/TextInput.js +112 -27
- package/dist/ui/theme.d.ts +1 -0
- package/dist/ui/theme.js +2 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
92
|
-
?
|
|
93
|
-
:
|
|
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
|
|
109
|
-
|
|
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
|
|
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
|
|
130
|
-
const header = running || streaming
|
|
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
|
-
|
|
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 (
|
|
205
|
+
if (ok && ['listFiles', 'readFile'].includes(event.toolCall.toolName))
|
|
173
206
|
sawReadOnlyTool = true;
|
|
174
|
-
if (
|
|
207
|
+
if (ok && event.toolCall.toolName === 'readFile' && path && path === editRecoveryPath && !duplicateSkipped) {
|
|
175
208
|
editRecoveryReadSatisfied = true;
|
|
176
209
|
}
|
|
177
|
-
if (
|
|
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:
|
|
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.
|
|
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.
|
|
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(
|
|
284
|
-
callbacks.
|
|
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
|
|
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:
|
|
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
|
-
|
|
452
|
+
const displayText = hideSyntheticToolCallMarkup(currentAssistantText);
|
|
453
|
+
if (!displayText && !currentAssistantStarted)
|
|
454
|
+
continue;
|
|
455
|
+
if (!currentAssistantStarted) {
|
|
398
456
|
assistantStarted = true;
|
|
399
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
|
443
|
-
if (!
|
|
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
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
-
|
|
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;
|