@x-code-cli/core 0.1.3 → 0.1.5
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/LICENSE +21 -0
- package/README.en.md +15 -0
- package/README.md +15 -0
- package/dist/agent/api-errors.d.ts +11 -0
- package/dist/agent/api-errors.d.ts.map +1 -0
- package/dist/agent/api-errors.js +134 -0
- package/dist/agent/api-errors.js.map +1 -0
- package/dist/agent/context-window.d.ts +26 -0
- package/dist/agent/context-window.d.ts.map +1 -0
- package/dist/agent/context-window.js +126 -0
- package/dist/agent/context-window.js.map +1 -0
- package/dist/agent/loop-state.d.ts +14 -0
- package/dist/agent/loop-state.d.ts.map +1 -0
- package/dist/agent/loop-state.js +12 -0
- package/dist/agent/loop-state.js.map +1 -0
- package/dist/agent/loop.d.ts +11 -15
- package/dist/agent/loop.d.ts.map +1 -1
- package/dist/agent/loop.js +213 -381
- package/dist/agent/loop.js.map +1 -1
- package/dist/agent/messages.d.ts +0 -2
- package/dist/agent/messages.d.ts.map +1 -1
- package/dist/agent/messages.js +0 -32
- package/dist/agent/messages.js.map +1 -1
- package/dist/agent/provider-compat.d.ts +17 -0
- package/dist/agent/provider-compat.d.ts.map +1 -0
- package/dist/agent/provider-compat.js +31 -0
- package/dist/agent/provider-compat.js.map +1 -0
- package/dist/agent/stream-utils.d.ts +33 -0
- package/dist/agent/stream-utils.d.ts.map +1 -0
- package/dist/agent/stream-utils.js +14 -0
- package/dist/agent/stream-utils.js.map +1 -0
- package/dist/agent/system-prompt.d.ts +1 -3
- package/dist/agent/system-prompt.d.ts.map +1 -1
- package/dist/agent/system-prompt.js +34 -23
- package/dist/agent/system-prompt.js.map +1 -1
- package/dist/agent/tool-execution.d.ts +11 -0
- package/dist/agent/tool-execution.d.ts.map +1 -0
- package/dist/agent/tool-execution.js +171 -0
- package/dist/agent/tool-execution.js.map +1 -0
- package/dist/config/index.d.ts +19 -8
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +66 -32
- package/dist/config/index.js.map +1 -1
- package/dist/index.d.ts +7 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -6
- package/dist/index.js.map +1 -1
- package/dist/knowledge/auto-memory.d.ts +1 -1
- package/dist/knowledge/auto-memory.d.ts.map +1 -1
- package/dist/knowledge/auto-memory.js +55 -16
- package/dist/knowledge/auto-memory.js.map +1 -1
- package/dist/knowledge/init.d.ts +1 -2
- package/dist/knowledge/init.d.ts.map +1 -1
- package/dist/knowledge/init.js +83 -69
- package/dist/knowledge/init.js.map +1 -1
- package/dist/knowledge/loader.d.ts +0 -9
- package/dist/knowledge/loader.d.ts.map +1 -1
- package/dist/knowledge/loader.js +54 -99
- package/dist/knowledge/loader.js.map +1 -1
- package/dist/knowledge/session.d.ts +1 -1
- package/dist/knowledge/session.d.ts.map +1 -1
- package/dist/knowledge/session.js +2 -1
- package/dist/knowledge/session.js.map +1 -1
- package/dist/permissions/index.d.ts +2 -0
- package/dist/permissions/index.d.ts.map +1 -1
- package/dist/permissions/index.js +35 -14
- package/dist/permissions/index.js.map +1 -1
- package/dist/tools/glob.d.ts.map +1 -1
- package/dist/tools/glob.js +3 -1
- package/dist/tools/glob.js.map +1 -1
- package/dist/tools/grep.d.ts.map +1 -1
- package/dist/tools/grep.js +7 -2
- package/dist/tools/grep.js.map +1 -1
- package/dist/tools/index.d.ts +3 -7
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +1 -5
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/list-dir.d.ts.map +1 -1
- package/dist/tools/list-dir.js +3 -1
- package/dist/tools/list-dir.js.map +1 -1
- package/dist/tools/progress.d.ts +6 -0
- package/dist/tools/progress.d.ts.map +1 -0
- package/dist/tools/progress.js +14 -0
- package/dist/tools/progress.js.map +1 -0
- package/dist/tools/read-file.d.ts.map +1 -1
- package/dist/tools/read-file.js +3 -1
- package/dist/tools/read-file.js.map +1 -1
- package/dist/tools/save-knowledge.d.ts +2 -2
- package/dist/tools/save-knowledge.d.ts.map +1 -1
- package/dist/tools/save-knowledge.js +31 -6
- package/dist/tools/save-knowledge.js.map +1 -1
- package/dist/tools/shell-utils.d.ts.map +1 -1
- package/dist/tools/shell-utils.js +7 -0
- package/dist/tools/shell-utils.js.map +1 -1
- package/dist/tools/web-fetch.d.ts.map +1 -1
- package/dist/tools/web-fetch.js +88 -19
- package/dist/tools/web-fetch.js.map +1 -1
- package/dist/tools/web-search.d.ts.map +1 -1
- package/dist/tools/web-search.js +85 -12
- package/dist/tools/web-search.js.map +1 -1
- package/dist/types/index.d.ts +60 -21
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +64 -6
- package/dist/types/index.js.map +1 -1
- package/dist/utils.d.ts +3 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +32 -0
- package/dist/utils.js.map +1 -1
- package/package.json +6 -6
- package/dist/agent/plan-mode.d.ts +0 -11
- package/dist/agent/plan-mode.d.ts.map +0 -1
- package/dist/agent/plan-mode.js +0 -37
- package/dist/agent/plan-mode.js.map +0 -1
- package/dist/agent/pricing.d.ts +0 -9
- package/dist/agent/pricing.d.ts.map +0 -1
- package/dist/agent/pricing.js +0 -47
- package/dist/agent/pricing.js.map +0 -1
- package/dist/knowledge/hooks.d.ts +0 -3
- package/dist/knowledge/hooks.d.ts.map +0 -1
- package/dist/knowledge/hooks.js +0 -59
- package/dist/knowledge/hooks.js.map +0 -1
- package/dist/tools/enter-plan-mode.d.ts +0 -2
- package/dist/tools/enter-plan-mode.d.ts.map +0 -1
- package/dist/tools/enter-plan-mode.js +0 -11
- package/dist/tools/enter-plan-mode.js.map +0 -1
- package/dist/tools/exit-plan-mode.d.ts +0 -2
- package/dist/tools/exit-plan-mode.d.ts.map +0 -1
- package/dist/tools/exit-plan-mode.js +0 -9
- package/dist/tools/exit-plan-mode.js.map +0 -1
package/dist/agent/loop.js
CHANGED
|
@@ -1,148 +1,22 @@
|
|
|
1
|
-
// @x-code-cli/core — Agent Loop (
|
|
2
|
-
import { execa } from 'execa';
|
|
1
|
+
// @x-code-cli/core — Agent Loop (orchestration: streaming, tool calls, permission, context compression)
|
|
3
2
|
import fs from 'node:fs/promises';
|
|
4
3
|
import path from 'node:path';
|
|
5
4
|
import { generateText, streamText } from 'ai';
|
|
6
|
-
import { buildKnowledgeContext
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
5
|
+
import { buildKnowledgeContext } from '../knowledge/loader.js';
|
|
6
|
+
import { generateSessionSummary, saveSessionSummary } from '../knowledge/session.js';
|
|
7
|
+
import { clearProgressReporter, setProgressReporter } from '../tools/progress.js';
|
|
9
8
|
import { toolRegistry, truncateToolResult } from '../tools/index.js';
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
9
|
+
import { debugLog } from '../utils.js';
|
|
10
|
+
import { classifyApiError, isContextTooLongError } from './api-errors.js';
|
|
11
|
+
import { estimateTokenCount, getCompressionThreshold, getMaxOutputTokens } from './context-window.js';
|
|
12
|
+
import { createLoopState } from './loop-state.js';
|
|
13
|
+
import { ensureReasoningContentParts } from './provider-compat.js';
|
|
14
|
+
import { drainStreamResult } from './stream-utils.js';
|
|
14
15
|
import { buildSystemPrompt } from './system-prompt.js';
|
|
16
|
+
import { processToolCalls } from './tool-execution.js';
|
|
17
|
+
/** Number of recent messages to keep verbatim when compressing. */
|
|
15
18
|
const KEEP_RECENT = 6;
|
|
16
|
-
|
|
17
|
-
/** Count occurrences of a substring without creating intermediate arrays */
|
|
18
|
-
function countOccurrences(content, search) {
|
|
19
|
-
let count = 0;
|
|
20
|
-
let pos = 0;
|
|
21
|
-
while ((pos = content.indexOf(search, pos)) !== -1) {
|
|
22
|
-
count++;
|
|
23
|
-
pos += search.length;
|
|
24
|
-
}
|
|
25
|
-
return count;
|
|
26
|
-
}
|
|
27
|
-
/**
|
|
28
|
-
* Ensure all assistant messages have a reasoning content part.
|
|
29
|
-
*
|
|
30
|
-
* DeepSeek Reasoner requires the `reasoning_content` field on every assistant
|
|
31
|
-
* message during tool-call chains. The upstream `@ai-sdk/deepseek` converter
|
|
32
|
-
* sets `reasoning_content: undefined` when no reasoning part exists, and
|
|
33
|
-
* `JSON.stringify` strips `undefined` values — causing the DeepSeek API to
|
|
34
|
-
* reject the request with a 400 "Missing reasoning_content" error.
|
|
35
|
-
*
|
|
36
|
-
* This helper injects an empty `{ type: 'reasoning', text: '' }` part into any
|
|
37
|
-
* assistant message that lacks one, so the converter always produces
|
|
38
|
-
* `"reasoning_content": ""` in the JSON body.
|
|
39
|
-
*/
|
|
40
|
-
function ensureReasoningContentParts(messages, modelId) {
|
|
41
|
-
if (!modelId.includes('deepseek-reasoner'))
|
|
42
|
-
return;
|
|
43
|
-
for (const msg of messages) {
|
|
44
|
-
if (msg.role !== 'assistant')
|
|
45
|
-
continue;
|
|
46
|
-
const content = msg.content;
|
|
47
|
-
if (!Array.isArray(content))
|
|
48
|
-
continue;
|
|
49
|
-
const hasReasoning = content.some((p) => p.type === 'reasoning');
|
|
50
|
-
if (!hasReasoning) {
|
|
51
|
-
// Prepend an empty reasoning part so the converter produces `reasoning_content: ""`
|
|
52
|
-
;
|
|
53
|
-
content.unshift({ type: 'reasoning', text: '' });
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
/** Context window sizes per model (tokens). Falls back to provider default, then 128k. */
|
|
58
|
-
const MODEL_CONTEXT_WINDOWS = {
|
|
59
|
-
// Anthropic
|
|
60
|
-
'anthropic:claude-opus-4-6': 200000,
|
|
61
|
-
'anthropic:claude-sonnet-4-5': 200000,
|
|
62
|
-
'anthropic:claude-haiku-4-5': 200000,
|
|
63
|
-
// OpenAI
|
|
64
|
-
'openai:gpt-4.1': 1047576,
|
|
65
|
-
'openai:gpt-4.1-mini': 1047576,
|
|
66
|
-
'openai:gpt-4.1-nano': 1047576,
|
|
67
|
-
'openai:o3': 200000,
|
|
68
|
-
'openai:o4-mini': 200000,
|
|
69
|
-
// Google
|
|
70
|
-
'google:gemini-2.5-pro': 1000000,
|
|
71
|
-
'google:gemini-2.5-flash': 1000000,
|
|
72
|
-
// DeepSeek
|
|
73
|
-
'deepseek:deepseek-chat': 64000,
|
|
74
|
-
'deepseek:deepseek-reasoner': 64000,
|
|
75
|
-
// Alibaba
|
|
76
|
-
'alibaba:qwen-max': 128000,
|
|
77
|
-
'alibaba:qwen-plus': 128000,
|
|
78
|
-
// xAI
|
|
79
|
-
'xai:grok-3': 131072,
|
|
80
|
-
'xai:grok-3-mini': 131072,
|
|
81
|
-
// Zhipu
|
|
82
|
-
'zhipu:glm-4-plus': 128000,
|
|
83
|
-
// Moonshot
|
|
84
|
-
'moonshotai:kimi-k2.5': 131072,
|
|
85
|
-
};
|
|
86
|
-
/** Provider-level fallback context windows */
|
|
87
|
-
const PROVIDER_CONTEXT_WINDOWS = {
|
|
88
|
-
anthropic: 200000,
|
|
89
|
-
openai: 128000,
|
|
90
|
-
google: 1000000,
|
|
91
|
-
deepseek: 64000,
|
|
92
|
-
alibaba: 128000,
|
|
93
|
-
xai: 128000,
|
|
94
|
-
zhipu: 128000,
|
|
95
|
-
moonshotai: 128000,
|
|
96
|
-
};
|
|
97
|
-
function getTokenBudget(modelId) {
|
|
98
|
-
const contextWindow = MODEL_CONTEXT_WINDOWS[modelId] ?? PROVIDER_CONTEXT_WINDOWS[modelId.split(':')[0]] ?? 128000;
|
|
99
|
-
return Math.floor(contextWindow * DEFAULT_TOKEN_BUDGET_RATIO);
|
|
100
|
-
}
|
|
101
|
-
/** Execute a write tool (writeFile / edit) */
|
|
102
|
-
async function executeWriteTool(toolName, input) {
|
|
103
|
-
if (toolName === 'writeFile') {
|
|
104
|
-
const filePath = input.filePath;
|
|
105
|
-
const content = input.content;
|
|
106
|
-
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
107
|
-
await fs.writeFile(filePath, content, 'utf-8');
|
|
108
|
-
return `File written: ${filePath} (${content.length} characters)`;
|
|
109
|
-
}
|
|
110
|
-
if (toolName === 'edit') {
|
|
111
|
-
const filePath = input.filePath;
|
|
112
|
-
const oldString = input.oldString;
|
|
113
|
-
const newString = input.newString;
|
|
114
|
-
const replaceAll = input.replaceAll ?? false;
|
|
115
|
-
const content = await fs.readFile(filePath, 'utf-8');
|
|
116
|
-
if (!replaceAll) {
|
|
117
|
-
const count = countOccurrences(content, oldString);
|
|
118
|
-
if (count === 0)
|
|
119
|
-
return `Error: old_string not found in ${filePath}`;
|
|
120
|
-
if (count > 1)
|
|
121
|
-
return `Error: old_string is not unique in ${filePath} (found ${count} occurrences). Provide more context or set replaceAll: true.`;
|
|
122
|
-
}
|
|
123
|
-
const newContent = replaceAll ? content.replaceAll(oldString, newString) : content.replace(oldString, newString);
|
|
124
|
-
await fs.writeFile(filePath, newContent, 'utf-8');
|
|
125
|
-
return `File edited: ${filePath}`;
|
|
126
|
-
}
|
|
127
|
-
return 'Error: unknown write tool';
|
|
128
|
-
}
|
|
129
|
-
/** Execute a shell command with streaming */
|
|
130
|
-
async function executeShell(command, timeout, callbacks) {
|
|
131
|
-
const { executable, args } = getShellConfig();
|
|
132
|
-
const proc = execa(executable, [...args, command], {
|
|
133
|
-
timeout,
|
|
134
|
-
reject: false,
|
|
135
|
-
});
|
|
136
|
-
proc.stdout?.on('data', (chunk) => {
|
|
137
|
-
callbacks.onShellOutput(chunk.toString());
|
|
138
|
-
});
|
|
139
|
-
proc.stderr?.on('data', (chunk) => {
|
|
140
|
-
callbacks.onShellOutput(chunk.toString());
|
|
141
|
-
});
|
|
142
|
-
const result = await proc;
|
|
143
|
-
return `exit code: ${result.exitCode}\n${result.stdout}\n${result.stderr}`.trim();
|
|
144
|
-
}
|
|
145
|
-
/** Compress old messages into a summary */
|
|
19
|
+
/** Compress old messages into a summary. */
|
|
146
20
|
export async function compressMessages(messages, model) {
|
|
147
21
|
const recent = messages.slice(-KEEP_RECENT);
|
|
148
22
|
const old = messages.slice(0, -KEEP_RECENT);
|
|
@@ -155,272 +29,223 @@ export async function compressMessages(messages, model) {
|
|
|
155
29
|
});
|
|
156
30
|
return [{ role: 'user', content: `[Previous conversation summary]\n${summary}` }, ...recent];
|
|
157
31
|
}
|
|
158
|
-
/**
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
const providerMatch = msg.match(/^(\w+)\s+API key/i);
|
|
172
|
-
const provider = providerMatch ? providerMatch[1] : 'Provider';
|
|
173
|
-
return {
|
|
174
|
-
message: `${provider} API key is not set. Please set the corresponding environment variable (e.g. ${provider.toUpperCase()}_API_KEY).`,
|
|
175
|
-
retryable: false,
|
|
176
|
-
};
|
|
177
|
-
}
|
|
178
|
-
if (status === 401 || msg.includes('Unauthorized') || msg.includes('Invalid API Key')) {
|
|
179
|
-
return {
|
|
180
|
-
message: 'API authentication failed (401). Please check your API key with /model or reconfigure with `xc init`.',
|
|
181
|
-
retryable: false,
|
|
182
|
-
};
|
|
183
|
-
}
|
|
184
|
-
if (status === 403 || msg.includes('Forbidden')) {
|
|
185
|
-
return {
|
|
186
|
-
message: 'API access forbidden (403). Your API key may not have permission for this model.',
|
|
187
|
-
retryable: false,
|
|
188
|
-
};
|
|
189
|
-
}
|
|
190
|
-
if (status === 503 || msg.includes('Service Unavailable') || msg.includes('overloaded')) {
|
|
191
|
-
return {
|
|
192
|
-
message: 'Model service unavailable (503). Try switching to a different model with /model.',
|
|
193
|
-
retryable: false,
|
|
194
|
-
};
|
|
195
|
-
}
|
|
196
|
-
if (status === 429 || msg.includes('rate limit') || msg.includes('Rate limit')) {
|
|
197
|
-
return {
|
|
198
|
-
message: 'Rate limited (429). Waiting for retry... (AI SDK handles exponential backoff automatically with maxRetries: 3)',
|
|
199
|
-
retryable: true, // AI SDK maxRetries handles this
|
|
200
|
-
};
|
|
32
|
+
/**
|
|
33
|
+
* Proactive compression: compress when either the last real input-token count
|
|
34
|
+
* or the character-based estimate has crossed the threshold.
|
|
35
|
+
*/
|
|
36
|
+
async function checkAndCompressContext(state, model, threshold, callbacks) {
|
|
37
|
+
const needsCompression = state.lastInputTokens > threshold || estimateTokenCount(state.messages) > threshold;
|
|
38
|
+
if (!needsCompression || state.messages.length <= KEEP_RECENT)
|
|
39
|
+
return;
|
|
40
|
+
try {
|
|
41
|
+
const summary = await generateSessionSummary(state.messages, model, state.sessionId, state.startedAt, [
|
|
42
|
+
...state.filesModified,
|
|
43
|
+
]);
|
|
44
|
+
await saveSessionSummary(summary);
|
|
201
45
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
message: `Network error: ${msg}. Retrying...`,
|
|
205
|
-
retryable: true,
|
|
206
|
-
};
|
|
46
|
+
catch {
|
|
47
|
+
// Don't block compression on session save failure
|
|
207
48
|
}
|
|
208
|
-
|
|
49
|
+
state.messages = await compressMessages(state.messages, model);
|
|
50
|
+
state.lastInputTokens = 0;
|
|
51
|
+
callbacks.onContextCompressed('Context compressed to fit context window.');
|
|
209
52
|
}
|
|
210
|
-
/**
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
53
|
+
/**
|
|
54
|
+
* Reactive compact: when a stream errors because the prompt was too long,
|
|
55
|
+
* compress and signal the caller to retry. Mirrors Claude Code's reactiveCompact.
|
|
56
|
+
* Returns true if compression happened (caller should retry this turn).
|
|
57
|
+
*/
|
|
58
|
+
async function handleContextTooLong(state, model, callbacks) {
|
|
59
|
+
if (state.messages.length <= KEEP_RECENT)
|
|
60
|
+
return false;
|
|
61
|
+
state.messages = await compressMessages(state.messages, model);
|
|
62
|
+
state.lastInputTokens = 0;
|
|
63
|
+
callbacks.onContextCompressed('Context too long — automatically compressed. Retrying...');
|
|
64
|
+
return true;
|
|
214
65
|
}
|
|
215
|
-
/**
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
pushToolResult(state, callbacks, toolCallId, toolName, output);
|
|
227
|
-
continue;
|
|
66
|
+
/** Consume streamText output, dispatching chunks to the UI via callbacks.
|
|
67
|
+
* Reasoning-delta chunks (thinking-mode models — DeepSeek-reasoner, o1,
|
|
68
|
+
* etc.) are deliberately ignored: that's the model's internal chain of
|
|
69
|
+
* thought, not user-facing output. The final user-facing answer arrives
|
|
70
|
+
* as regular text-delta chunks. */
|
|
71
|
+
async function streamChunksToUI(result, callbacks) {
|
|
72
|
+
for await (const chunk of result.fullStream) {
|
|
73
|
+
if (chunk.type === 'text-delta') {
|
|
74
|
+
const text = chunk.text ?? '';
|
|
75
|
+
debugLog('stream.text-delta', text);
|
|
76
|
+
callbacks.onTextDelta(text);
|
|
228
77
|
}
|
|
229
|
-
if (
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
output = 'Plan mode exited. No plan file found.';
|
|
239
|
-
}
|
|
78
|
+
else if (chunk.type === 'tool-call') {
|
|
79
|
+
debugLog('stream.tool-call', `${chunk.toolName ?? ''} ${JSON.stringify(chunk.input ?? {})}`);
|
|
80
|
+
const toolCallId = chunk.toolCallId ?? '';
|
|
81
|
+
// Register the progress side-channel BEFORE tools start executing —
|
|
82
|
+
// AI SDK will synchronously invoke `execute(input, { toolCallId })`
|
|
83
|
+
// for auto-executed tools right after this event, and those tools
|
|
84
|
+
// call reportProgress(toolCallId, ...) to stream status updates.
|
|
85
|
+
if (toolCallId) {
|
|
86
|
+
setProgressReporter(toolCallId, (msg) => callbacks.onToolProgress(toolCallId, msg));
|
|
240
87
|
}
|
|
241
|
-
|
|
242
|
-
output = 'Plan mode exited.';
|
|
243
|
-
}
|
|
244
|
-
pushToolResult(state, callbacks, toolCallId, toolName, output);
|
|
245
|
-
continue;
|
|
88
|
+
callbacks.onToolCall(toolCallId, chunk.toolName ?? '', (chunk.input ?? {}));
|
|
246
89
|
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
continue;
|
|
90
|
+
else if (chunk.type === 'tool-result') {
|
|
91
|
+
// Notify UI about auto-executed tool results (readFile, glob, grep, etc.)
|
|
92
|
+
const raw = typeof chunk.output === 'string' ? chunk.output : JSON.stringify(chunk.output ?? '');
|
|
93
|
+
debugLog('stream.tool-result', `${chunk.toolCallId ?? ''} ${raw}`);
|
|
94
|
+
if (chunk.toolCallId)
|
|
95
|
+
clearProgressReporter(chunk.toolCallId);
|
|
96
|
+
callbacks.onToolResult(chunk.toolCallId ?? '', truncateToolResult(raw));
|
|
255
97
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
const approved = await checkPermission({ toolName, input }, options.trustMode, callbacks.onAskPermission);
|
|
259
|
-
if (!approved) {
|
|
260
|
-
pushToolResult(state, callbacks, toolCallId, toolName, 'Permission denied by user.');
|
|
261
|
-
continue;
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
// ── Execute tool ──
|
|
265
|
-
try {
|
|
266
|
-
if (toolName === 'writeFile' || toolName === 'edit') {
|
|
267
|
-
output = await executeWriteTool(toolName, input);
|
|
268
|
-
const filePath = input.filePath;
|
|
269
|
-
state.filesModified.add(filePath);
|
|
270
|
-
}
|
|
271
|
-
else if (toolName === 'shell') {
|
|
272
|
-
const timeout = input.timeout ?? 30000;
|
|
273
|
-
output = await executeShell(input.command, timeout, callbacks);
|
|
274
|
-
}
|
|
275
|
-
else {
|
|
276
|
-
// Tools with execute (readFile, glob, grep, etc.) are auto-executed by AI SDK
|
|
277
|
-
continue;
|
|
278
|
-
}
|
|
98
|
+
else {
|
|
99
|
+
debugLog('stream.other-chunk', chunk.type);
|
|
279
100
|
}
|
|
280
|
-
|
|
281
|
-
|
|
101
|
+
// reasoning-delta / reasoning-start / reasoning-end: intentionally dropped from UI
|
|
102
|
+
// but logged above under stream.other-chunk so we can see them in debug mode.
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/** Pull the response + usage off a completed stream and fold into state. */
|
|
106
|
+
async function collectTurnResponse(result, state, modelId, callbacks) {
|
|
107
|
+
const response = await result.response;
|
|
108
|
+
state.messages.push(...response.messages);
|
|
109
|
+
ensureReasoningContentParts(state.messages, modelId);
|
|
110
|
+
const usage = await result.usage;
|
|
111
|
+
if (usage) {
|
|
112
|
+
state.tokenUsage.inputTokens += usage.inputTokens ?? 0;
|
|
113
|
+
state.tokenUsage.outputTokens += usage.outputTokens ?? 0;
|
|
114
|
+
state.tokenUsage.totalTokens = state.tokenUsage.inputTokens + state.tokenUsage.outputTokens;
|
|
115
|
+
if (usage.inputTokens != null)
|
|
116
|
+
state.lastInputTokens = usage.inputTokens;
|
|
117
|
+
callbacks.onUsageUpdate(state.tokenUsage);
|
|
118
|
+
}
|
|
119
|
+
return result.finishReason;
|
|
120
|
+
}
|
|
121
|
+
/** Run one agent turn: stream to UI, collect response. Resilient to errors. */
|
|
122
|
+
async function runTurn(state, model, options, systemPrompt, callbacks) {
|
|
123
|
+
let result;
|
|
124
|
+
try {
|
|
125
|
+
result = streamText({
|
|
126
|
+
model,
|
|
127
|
+
system: systemPrompt,
|
|
128
|
+
messages: state.messages,
|
|
129
|
+
tools: toolRegistry,
|
|
130
|
+
maxRetries: 3,
|
|
131
|
+
abortSignal: options.abortSignal,
|
|
132
|
+
// Explicit ceiling so provider defaults don't silently truncate long
|
|
133
|
+
// replies. Most providers clamp a too-high value, but some reject it
|
|
134
|
+
// outright with HTTP 400. getMaxOutputTokens applies per-model ceilings;
|
|
135
|
+
// unknown models fall through to the module-level default.
|
|
136
|
+
maxOutputTokens: getMaxOutputTokens(options.modelId),
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
callbacks.onError(new Error(classifyApiError(err).message));
|
|
141
|
+
return { kind: 'error' };
|
|
142
|
+
}
|
|
143
|
+
// Pre-attach .catch(noop) handlers to every sibling promise the SDK exposes
|
|
144
|
+
// (response/usage/finishReason/toolCalls) BEFORE we await the stream. On
|
|
145
|
+
// request failure the SDK rejects all of them in the same tick — if we wait
|
|
146
|
+
// for fullStream to throw and only then drain, Node's unhandled-rejection
|
|
147
|
+
// sweep can run first and terminate the process. Attaching catch handlers
|
|
148
|
+
// early is idempotent: a later `await result.response` still rejects and
|
|
149
|
+
// propagates normally through our error path.
|
|
150
|
+
drainStreamResult(result);
|
|
151
|
+
try {
|
|
152
|
+
await streamChunksToUI(result, callbacks);
|
|
153
|
+
}
|
|
154
|
+
catch (err) {
|
|
155
|
+
// Silently drain all pending AI SDK promises so unhandled-rejection
|
|
156
|
+
// warnings (NoOutputGeneratedError) don't leak to stderr.
|
|
157
|
+
drainStreamResult(result);
|
|
158
|
+
if (isContextTooLongError(err)) {
|
|
159
|
+
const compressed = await handleContextTooLong(state, model, callbacks);
|
|
160
|
+
if (compressed)
|
|
161
|
+
return { kind: 'retry' };
|
|
282
162
|
}
|
|
283
|
-
|
|
284
|
-
|
|
163
|
+
callbacks.onError(new Error(classifyApiError(err).message));
|
|
164
|
+
return { kind: 'error' };
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
const finishReason = await collectTurnResponse(result, state, options.modelId, callbacks);
|
|
168
|
+
debugLog('turn.finish', `reason=${finishReason} turn=${state.turnCount} input=${state.lastInputTokens} total=${state.tokenUsage.totalTokens}`);
|
|
169
|
+
return { kind: 'done', finishReason, result };
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
drainStreamResult(result);
|
|
173
|
+
callbacks.onError(new Error(classifyApiError(err).message));
|
|
174
|
+
return { kind: 'error' };
|
|
285
175
|
}
|
|
286
176
|
}
|
|
287
|
-
/** Main agent loop */
|
|
177
|
+
/** Main agent loop. */
|
|
288
178
|
export async function agentLoop(userMessage, model, options, callbacks, existingState) {
|
|
289
|
-
const state = existingState ??
|
|
290
|
-
messages: [],
|
|
291
|
-
tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0, estimatedCost: 0, costCurrency: 'USD' },
|
|
292
|
-
planMode: false,
|
|
293
|
-
planId: null,
|
|
294
|
-
sessionId: Date.now().toString(36),
|
|
295
|
-
startedAt: new Date().toISOString(),
|
|
296
|
-
filesModified: new Set(),
|
|
297
|
-
turnCount: 0,
|
|
298
|
-
};
|
|
179
|
+
const state = existingState ?? createLoopState();
|
|
299
180
|
state.messages.push({ role: 'user', content: userMessage });
|
|
300
|
-
//
|
|
301
|
-
|
|
302
|
-
//
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
const
|
|
317
|
-
|
|
318
|
-
const tokenBudget = getTokenBudget(options.modelId);
|
|
181
|
+
// Session continuation is handled explicitly by the UI: if the user accepts
|
|
182
|
+
// the resume prompt, the pending work is embedded directly in their first
|
|
183
|
+
// user message. Auto-injecting it into every system prompt made the model
|
|
184
|
+
// treat trivial greetings as "continue exploring", so we no longer do that.
|
|
185
|
+
const fullKnowledgeContext = await buildKnowledgeContext();
|
|
186
|
+
// Detect git repo once — cheap stat, avoids per-turn disk hit
|
|
187
|
+
const isGitRepo = await fs
|
|
188
|
+
.stat(path.join(process.cwd(), '.git'))
|
|
189
|
+
.then(() => true)
|
|
190
|
+
.catch(() => false);
|
|
191
|
+
const compressionThreshold = getCompressionThreshold(options.modelId);
|
|
192
|
+
// Auto-continuation on `length` finish. Reasoning models can exhaust the
|
|
193
|
+
// output token budget before the user-visible reply completes — the old
|
|
194
|
+
// behavior was to stop mid-sentence and surface an error, which looks
|
|
195
|
+
// broken to the user. Instead, we push a short "continue" nudge and loop,
|
|
196
|
+
// capped so a pathologically runaway reply still terminates eventually.
|
|
197
|
+
const MAX_CONTINUATIONS = 3;
|
|
198
|
+
let continuationAttempts = 0;
|
|
319
199
|
while (state.turnCount < options.maxTurns) {
|
|
320
200
|
state.turnCount++;
|
|
321
|
-
|
|
322
|
-
if (estimateTokens(state.messages) > tokenBudget) {
|
|
323
|
-
try {
|
|
324
|
-
const summary = await generateSessionSummary(state.messages, model, state.sessionId, state.startedAt, [
|
|
325
|
-
...state.filesModified,
|
|
326
|
-
]);
|
|
327
|
-
await saveSessionSummary(summary);
|
|
328
|
-
}
|
|
329
|
-
catch {
|
|
330
|
-
// Don't block compression on session save failure
|
|
331
|
-
}
|
|
332
|
-
state.messages = await compressMessages(state.messages, model);
|
|
333
|
-
callbacks.onContextCompressed('Context compressed to fit token budget.');
|
|
334
|
-
}
|
|
201
|
+
await checkAndCompressContext(state, model, compressionThreshold, callbacks);
|
|
335
202
|
const systemPrompt = buildSystemPrompt({
|
|
336
203
|
knowledgeContext: fullKnowledgeContext,
|
|
337
|
-
planMode: state.planMode,
|
|
338
204
|
modelId: options.modelId,
|
|
205
|
+
isGitRepo,
|
|
339
206
|
});
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
result = streamText({
|
|
343
|
-
model,
|
|
344
|
-
system: systemPrompt,
|
|
345
|
-
messages: state.messages,
|
|
346
|
-
tools: toolRegistry,
|
|
347
|
-
maxRetries: 3,
|
|
348
|
-
abortSignal: options.abortSignal,
|
|
349
|
-
});
|
|
350
|
-
}
|
|
351
|
-
catch (err) {
|
|
352
|
-
const classified = classifyApiError(err);
|
|
353
|
-
callbacks.onError(new Error(classified.message));
|
|
354
|
-
break;
|
|
355
|
-
}
|
|
356
|
-
// Stream chunks to UI
|
|
357
|
-
try {
|
|
358
|
-
for await (const chunk of result.fullStream) {
|
|
359
|
-
if (chunk.type === 'text-delta') {
|
|
360
|
-
callbacks.onTextDelta(chunk.text ?? '');
|
|
361
|
-
}
|
|
362
|
-
if (chunk.type === 'tool-call') {
|
|
363
|
-
callbacks.onToolCall(chunk.toolName ?? '', (chunk.input ?? {}));
|
|
364
|
-
}
|
|
365
|
-
// Truncate auto-executed tool results (readFile, glob, grep, etc.)
|
|
366
|
-
if (chunk.type === 'tool-result') {
|
|
367
|
-
const raw = typeof chunk.output === 'string' ? chunk.output : JSON.stringify(chunk.output ?? '');
|
|
368
|
-
const truncated = truncateToolResult(raw);
|
|
369
|
-
if (truncated !== raw) {
|
|
370
|
-
// Result was truncated — the original is already in messages via AI SDK,
|
|
371
|
-
// but we notify via callback so the UI can show it
|
|
372
|
-
callbacks.onToolResult(chunk.toolCallId ?? '', truncated);
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
catch (err) {
|
|
378
|
-
const classified = classifyApiError(err);
|
|
379
|
-
callbacks.onError(new Error(classified.message));
|
|
380
|
-
if (!classified.retryable)
|
|
381
|
-
break;
|
|
382
|
-
// For retryable errors, AI SDK maxRetries already handles retry;
|
|
383
|
-
// if we still get here, the retries were exhausted — break
|
|
384
|
-
break;
|
|
385
|
-
}
|
|
386
|
-
// Collect response + usage (may fail if stream errored)
|
|
387
|
-
let finishReason;
|
|
388
|
-
try {
|
|
389
|
-
const response = await result.response;
|
|
390
|
-
state.messages.push(...response.messages);
|
|
391
|
-
// Workaround: DeepSeek Reasoner requires `reasoning_content` on every
|
|
392
|
-
// assistant message in tool-call chains. Ensure it's always present.
|
|
393
|
-
ensureReasoningContentParts(state.messages, options.modelId);
|
|
394
|
-
const usage = await result.usage;
|
|
395
|
-
if (usage) {
|
|
396
|
-
state.tokenUsage.inputTokens += usage.inputTokens ?? 0;
|
|
397
|
-
state.tokenUsage.outputTokens += usage.outputTokens ?? 0;
|
|
398
|
-
state.tokenUsage.totalTokens = state.tokenUsage.inputTokens + state.tokenUsage.outputTokens;
|
|
399
|
-
const costEstimate = estimateCost(options.modelId, state.tokenUsage.inputTokens, state.tokenUsage.outputTokens);
|
|
400
|
-
state.tokenUsage.estimatedCost = costEstimate.cost;
|
|
401
|
-
state.tokenUsage.costCurrency = costEstimate.currency;
|
|
402
|
-
callbacks.onUsageUpdate(state.tokenUsage);
|
|
403
|
-
}
|
|
404
|
-
finishReason = await result.finishReason;
|
|
405
|
-
}
|
|
406
|
-
catch (err) {
|
|
407
|
-
const classified = classifyApiError(err);
|
|
408
|
-
callbacks.onError(new Error(classified.message));
|
|
207
|
+
const outcome = await runTurn(state, model, options, systemPrompt, callbacks);
|
|
208
|
+
if (outcome.kind === 'error')
|
|
409
209
|
break;
|
|
210
|
+
if (outcome.kind === 'retry') {
|
|
211
|
+
// Don't count a failed attempt that got recovered via reactive compaction.
|
|
212
|
+
state.turnCount--;
|
|
213
|
+
continue;
|
|
410
214
|
}
|
|
411
|
-
if (finishReason === 'tool-calls') {
|
|
215
|
+
if (outcome.finishReason === 'tool-calls') {
|
|
216
|
+
// Any successful tool round means the model is making real progress —
|
|
217
|
+
// reset the consecutive-truncation counter.
|
|
218
|
+
continuationAttempts = 0;
|
|
412
219
|
let toolCalls;
|
|
413
220
|
try {
|
|
414
|
-
toolCalls = await result.toolCalls;
|
|
221
|
+
toolCalls = await outcome.result.toolCalls;
|
|
415
222
|
}
|
|
416
223
|
catch (err) {
|
|
417
|
-
|
|
418
|
-
callbacks.onError(new Error(classified.message));
|
|
224
|
+
callbacks.onError(new Error(classifyApiError(err).message));
|
|
419
225
|
break;
|
|
420
226
|
}
|
|
421
|
-
await
|
|
227
|
+
await processToolCalls(toolCalls, state, options, callbacks);
|
|
422
228
|
continue;
|
|
423
229
|
}
|
|
230
|
+
if (outcome.finishReason === 'length') {
|
|
231
|
+
if (continuationAttempts < MAX_CONTINUATIONS) {
|
|
232
|
+
continuationAttempts++;
|
|
233
|
+
debugLog('turn.length-continuation', `attempt=${continuationAttempts}/${MAX_CONTINUATIONS} turn=${state.turnCount}`);
|
|
234
|
+
// Nudge the model to pick up exactly where it stopped. This goes
|
|
235
|
+
// into state.messages but NOT into UI messages, so the user sees
|
|
236
|
+
// one continuous streamed reply with at most a brief pause.
|
|
237
|
+
state.messages.push({
|
|
238
|
+
role: 'user',
|
|
239
|
+
content: 'Output token limit hit. Resume directly — no apology, no recap. Pick up mid-thought if that is where the cut happened. Break remaining work into smaller pieces.',
|
|
240
|
+
});
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
callbacks.onError(new Error(`Response still truncated after ${MAX_CONTINUATIONS} continuation attempts — ask a narrower question.`));
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
if (outcome.finishReason === 'content-filter') {
|
|
247
|
+
callbacks.onError(new Error('Response stopped by the provider content filter.'));
|
|
248
|
+
}
|
|
424
249
|
break;
|
|
425
250
|
}
|
|
426
251
|
if (state.turnCount >= options.maxTurns) {
|
|
@@ -428,16 +253,23 @@ export async function agentLoop(userMessage, model, options, callbacks, existing
|
|
|
428
253
|
}
|
|
429
254
|
return state;
|
|
430
255
|
}
|
|
431
|
-
/** Save session on exit
|
|
432
|
-
|
|
256
|
+
/** Save session on exit. Summary generation makes an LLM call that can be
|
|
257
|
+
* slow, so we bound it with a 2s timeout — on Ctrl+C we want to return
|
|
258
|
+
* to the shell promptly, not wait for a roundtrip. If the timeout fires
|
|
259
|
+
* or the call fails, we silently skip (session summaries are nice-to-have,
|
|
260
|
+
* not critical for exit). */
|
|
261
|
+
export async function saveSession(state, model, timeoutMs = 2000) {
|
|
262
|
+
const controller = new AbortController();
|
|
263
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
433
264
|
try {
|
|
434
|
-
const summary = await generateSessionSummary(state.messages, model, state.sessionId, state.startedAt, [
|
|
435
|
-
...state.filesModified,
|
|
436
|
-
]);
|
|
265
|
+
const summary = await generateSessionSummary(state.messages, model, state.sessionId, state.startedAt, [...state.filesModified], controller.signal);
|
|
437
266
|
await saveSessionSummary(summary);
|
|
438
267
|
}
|
|
439
268
|
catch {
|
|
440
|
-
//
|
|
269
|
+
// Timeout or any other failure — skip summary silently.
|
|
270
|
+
}
|
|
271
|
+
finally {
|
|
272
|
+
clearTimeout(timer);
|
|
441
273
|
}
|
|
442
274
|
}
|
|
443
275
|
//# sourceMappingURL=loop.js.map
|