@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.
- package/CHANGELOG.md +24 -0
- package/README.md +169 -70
- package/dist/cli/commands/chat.d.ts +4 -1
- package/dist/cli/commands/chat.js +606 -24
- package/dist/cli/commands/commands.d.ts +5 -0
- package/dist/cli/commands/commands.js +220 -11
- package/dist/cli/commands/formatters.d.ts +1 -0
- package/dist/cli/commands/formatters.js +23 -3
- package/dist/cli/commands/skills.d.ts +1 -1
- package/dist/cli/commands/skills.js +8 -5
- package/dist/cli/commands/streaming.d.ts +7 -1
- package/dist/cli/commands/streaming.js +533 -41
- package/dist/cli/index.js +5 -12
- package/dist/config/inputHistory.js +8 -0
- package/dist/config/paths.d.ts +0 -1
- package/dist/config/paths.js +0 -1
- 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 +70 -0
- package/dist/llm/hazeTools.js +311 -97
- package/dist/llm/initPrompt.js +7 -5
- package/dist/llm/systemPrompt.js +25 -11
- package/dist/skills/SkillLoader.d.ts +12 -2
- package/dist/skills/SkillLoader.js +64 -18
- package/dist/skills/SkillRegistry.d.ts +1 -5
- package/dist/skills/SkillRegistry.js +10 -21
- package/dist/skills/builder/SkillBuilder.d.ts +31 -1
- package/dist/skills/builder/SkillBuilder.js +291 -20
- package/dist/skills/skillTools.d.ts +20 -0
- package/dist/skills/skillTools.js +25 -0
- package/dist/skills/types.d.ts +12 -51
- package/dist/ui/components/ErrorView.d.ts +2 -1
- package/dist/ui/components/Header.d.ts +4 -2
- package/dist/ui/components/Header.js +2 -2
- package/dist/ui/components/MarkdownText.d.ts +2 -1
- package/dist/ui/components/TextInput.d.ts +13 -2
- package/dist/ui/components/TextInput.js +125 -25
- package/dist/ui/theme.d.ts +2 -0
- package/dist/ui/theme.js +3 -1
- package/dist/utils/fs.d.ts +1 -0
- package/dist/utils/fs.js +10 -6
- package/examples/skills/files/SKILL.md +16 -0
- package/examples/skills/files/examples/file-editing.md +3 -0
- package/package.json +9 -9
- package/dist/skills/installer/SkillInstaller.d.ts +0 -1
- package/dist/skills/installer/SkillInstaller.js +0 -48
- package/dist/skills/manifestSchema.d.ts +0 -31
- package/dist/skills/manifestSchema.js +0 -23
- package/dist/tools/ToolExecutor.d.ts +0 -3
- package/dist/tools/ToolExecutor.js +0 -15
- package/dist/tools/types.d.ts +0 -9
- package/dist/tools/types.js +0 -1
- package/examples/skills/files/prompts/file_tasks.md +0 -1
- package/examples/skills/files/skill.yaml +0 -28
- package/examples/skills/files/tools/list_files.ts +0 -21
- 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
|
-
|
|
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
|
-
|
|
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 /
|
|
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
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
340
|
+
model: activeModel,
|
|
341
|
+
temperature: 0,
|
|
342
|
+
maxOutputTokens: DEFAULT_MAX_OUTPUT_TOKENS,
|
|
33
343
|
system: buildSystemPrompt(contextFiles),
|
|
34
344
|
messages: requestMessages,
|
|
35
|
-
tools:
|
|
36
|
-
stopWhen: stepCountIs(
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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(
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
451
|
+
currentAssistantText += delta;
|
|
452
|
+
const displayText = hideSyntheticToolCallMarkup(currentAssistantText);
|
|
453
|
+
if (!displayText && !currentAssistantStarted)
|
|
454
|
+
continue;
|
|
455
|
+
if (!currentAssistantStarted) {
|
|
74
456
|
assistantStarted = true;
|
|
75
|
-
|
|
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.
|
|
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
|
-
|
|
84
|
-
callbacks.
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
}
|