@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.
Files changed (129) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +15 -0
  3. package/README.md +15 -0
  4. package/dist/agent/api-errors.d.ts +11 -0
  5. package/dist/agent/api-errors.d.ts.map +1 -0
  6. package/dist/agent/api-errors.js +134 -0
  7. package/dist/agent/api-errors.js.map +1 -0
  8. package/dist/agent/context-window.d.ts +26 -0
  9. package/dist/agent/context-window.d.ts.map +1 -0
  10. package/dist/agent/context-window.js +126 -0
  11. package/dist/agent/context-window.js.map +1 -0
  12. package/dist/agent/loop-state.d.ts +14 -0
  13. package/dist/agent/loop-state.d.ts.map +1 -0
  14. package/dist/agent/loop-state.js +12 -0
  15. package/dist/agent/loop-state.js.map +1 -0
  16. package/dist/agent/loop.d.ts +11 -15
  17. package/dist/agent/loop.d.ts.map +1 -1
  18. package/dist/agent/loop.js +213 -381
  19. package/dist/agent/loop.js.map +1 -1
  20. package/dist/agent/messages.d.ts +0 -2
  21. package/dist/agent/messages.d.ts.map +1 -1
  22. package/dist/agent/messages.js +0 -32
  23. package/dist/agent/messages.js.map +1 -1
  24. package/dist/agent/provider-compat.d.ts +17 -0
  25. package/dist/agent/provider-compat.d.ts.map +1 -0
  26. package/dist/agent/provider-compat.js +31 -0
  27. package/dist/agent/provider-compat.js.map +1 -0
  28. package/dist/agent/stream-utils.d.ts +33 -0
  29. package/dist/agent/stream-utils.d.ts.map +1 -0
  30. package/dist/agent/stream-utils.js +14 -0
  31. package/dist/agent/stream-utils.js.map +1 -0
  32. package/dist/agent/system-prompt.d.ts +1 -3
  33. package/dist/agent/system-prompt.d.ts.map +1 -1
  34. package/dist/agent/system-prompt.js +34 -23
  35. package/dist/agent/system-prompt.js.map +1 -1
  36. package/dist/agent/tool-execution.d.ts +11 -0
  37. package/dist/agent/tool-execution.d.ts.map +1 -0
  38. package/dist/agent/tool-execution.js +171 -0
  39. package/dist/agent/tool-execution.js.map +1 -0
  40. package/dist/config/index.d.ts +19 -8
  41. package/dist/config/index.d.ts.map +1 -1
  42. package/dist/config/index.js +66 -32
  43. package/dist/config/index.js.map +1 -1
  44. package/dist/index.d.ts +7 -8
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +5 -6
  47. package/dist/index.js.map +1 -1
  48. package/dist/knowledge/auto-memory.d.ts +1 -1
  49. package/dist/knowledge/auto-memory.d.ts.map +1 -1
  50. package/dist/knowledge/auto-memory.js +55 -16
  51. package/dist/knowledge/auto-memory.js.map +1 -1
  52. package/dist/knowledge/init.d.ts +1 -2
  53. package/dist/knowledge/init.d.ts.map +1 -1
  54. package/dist/knowledge/init.js +83 -69
  55. package/dist/knowledge/init.js.map +1 -1
  56. package/dist/knowledge/loader.d.ts +0 -9
  57. package/dist/knowledge/loader.d.ts.map +1 -1
  58. package/dist/knowledge/loader.js +54 -99
  59. package/dist/knowledge/loader.js.map +1 -1
  60. package/dist/knowledge/session.d.ts +1 -1
  61. package/dist/knowledge/session.d.ts.map +1 -1
  62. package/dist/knowledge/session.js +2 -1
  63. package/dist/knowledge/session.js.map +1 -1
  64. package/dist/permissions/index.d.ts +2 -0
  65. package/dist/permissions/index.d.ts.map +1 -1
  66. package/dist/permissions/index.js +35 -14
  67. package/dist/permissions/index.js.map +1 -1
  68. package/dist/tools/glob.d.ts.map +1 -1
  69. package/dist/tools/glob.js +3 -1
  70. package/dist/tools/glob.js.map +1 -1
  71. package/dist/tools/grep.d.ts.map +1 -1
  72. package/dist/tools/grep.js +7 -2
  73. package/dist/tools/grep.js.map +1 -1
  74. package/dist/tools/index.d.ts +3 -7
  75. package/dist/tools/index.d.ts.map +1 -1
  76. package/dist/tools/index.js +1 -5
  77. package/dist/tools/index.js.map +1 -1
  78. package/dist/tools/list-dir.d.ts.map +1 -1
  79. package/dist/tools/list-dir.js +3 -1
  80. package/dist/tools/list-dir.js.map +1 -1
  81. package/dist/tools/progress.d.ts +6 -0
  82. package/dist/tools/progress.d.ts.map +1 -0
  83. package/dist/tools/progress.js +14 -0
  84. package/dist/tools/progress.js.map +1 -0
  85. package/dist/tools/read-file.d.ts.map +1 -1
  86. package/dist/tools/read-file.js +3 -1
  87. package/dist/tools/read-file.js.map +1 -1
  88. package/dist/tools/save-knowledge.d.ts +2 -2
  89. package/dist/tools/save-knowledge.d.ts.map +1 -1
  90. package/dist/tools/save-knowledge.js +31 -6
  91. package/dist/tools/save-knowledge.js.map +1 -1
  92. package/dist/tools/shell-utils.d.ts.map +1 -1
  93. package/dist/tools/shell-utils.js +7 -0
  94. package/dist/tools/shell-utils.js.map +1 -1
  95. package/dist/tools/web-fetch.d.ts.map +1 -1
  96. package/dist/tools/web-fetch.js +88 -19
  97. package/dist/tools/web-fetch.js.map +1 -1
  98. package/dist/tools/web-search.d.ts.map +1 -1
  99. package/dist/tools/web-search.js +85 -12
  100. package/dist/tools/web-search.js.map +1 -1
  101. package/dist/types/index.d.ts +60 -21
  102. package/dist/types/index.d.ts.map +1 -1
  103. package/dist/types/index.js +64 -6
  104. package/dist/types/index.js.map +1 -1
  105. package/dist/utils.d.ts +3 -0
  106. package/dist/utils.d.ts.map +1 -1
  107. package/dist/utils.js +32 -0
  108. package/dist/utils.js.map +1 -1
  109. package/package.json +6 -6
  110. package/dist/agent/plan-mode.d.ts +0 -11
  111. package/dist/agent/plan-mode.d.ts.map +0 -1
  112. package/dist/agent/plan-mode.js +0 -37
  113. package/dist/agent/plan-mode.js.map +0 -1
  114. package/dist/agent/pricing.d.ts +0 -9
  115. package/dist/agent/pricing.d.ts.map +0 -1
  116. package/dist/agent/pricing.js +0 -47
  117. package/dist/agent/pricing.js.map +0 -1
  118. package/dist/knowledge/hooks.d.ts +0 -3
  119. package/dist/knowledge/hooks.d.ts.map +0 -1
  120. package/dist/knowledge/hooks.js +0 -59
  121. package/dist/knowledge/hooks.js.map +0 -1
  122. package/dist/tools/enter-plan-mode.d.ts +0 -2
  123. package/dist/tools/enter-plan-mode.d.ts.map +0 -1
  124. package/dist/tools/enter-plan-mode.js +0 -11
  125. package/dist/tools/enter-plan-mode.js.map +0 -1
  126. package/dist/tools/exit-plan-mode.d.ts +0 -2
  127. package/dist/tools/exit-plan-mode.d.ts.map +0 -1
  128. package/dist/tools/exit-plan-mode.js +0 -9
  129. package/dist/tools/exit-plan-mode.js.map +0 -1
@@ -1,148 +1,22 @@
1
- // @x-code-cli/core — Agent Loop (core logic: streaming, tool calls, permission, context compression)
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, loadRuleFiles } from '../knowledge/loader.js';
7
- import { formatSessionForPrompt, generateSessionSummary, loadLatestSession, saveSessionSummary, } from '../knowledge/session.js';
8
- import { checkPermission } from '../permissions/index.js';
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 { getShellConfig } from '../tools/shell-utils.js';
11
- import { estimateTokens, toolResultMessage } from './messages.js';
12
- import { ensurePlansDir, generatePlanId, getPlanPath } from './plan-mode.js';
13
- import { estimateCost } from './pricing.js';
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
- const DEFAULT_TOKEN_BUDGET_RATIO = 0.8;
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
- /** Classify API error and return a user-friendly recovery message */
159
- function classifyApiError(err) {
160
- const msg = err instanceof Error ? err.message : String(err);
161
- const statusMatch = msg.match(/(\d{3})/);
162
- const status = statusMatch ? Number(statusMatch[1]) : 0;
163
- if (msg.includes('Missing `reasoning_content`') || msg.includes('reasoning_content')) {
164
- return {
165
- message: 'DeepSeek Reasoner requires reasoning_content in assistant messages during tool-call chains. This is usually an SDK compatibility issue — please report it.',
166
- retryable: false,
167
- };
168
- }
169
- if (msg.includes('API key is missing') || msg.includes('API_KEY')) {
170
- // Extract provider name from message like "DeepSeek API key API key is missing..."
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
- if (msg.includes('timeout') || msg.includes('ETIMEDOUT') || msg.includes('ECONNRESET')) {
203
- return {
204
- message: `Network error: ${msg}. Retrying...`,
205
- retryable: true,
206
- };
46
+ catch {
47
+ // Don't block compression on session save failure
207
48
  }
208
- return { message: msg, retryable: false };
49
+ state.messages = await compressMessages(state.messages, model);
50
+ state.lastInputTokens = 0;
51
+ callbacks.onContextCompressed('Context compressed to fit context window.');
209
52
  }
210
- /** Helper to push a tool result to state and notify the UI */
211
- function pushToolResult(state, callbacks, toolCallId, toolName, output) {
212
- state.messages.push(toolResultMessage(toolCallId, toolName, output));
213
- callbacks.onToolResult(toolCallId, output);
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
- /** Handle all tool calls from a single model turn */
216
- async function handleToolCalls(toolCalls, state, options, callbacks) {
217
- for (const tc of toolCalls) {
218
- const { toolName, input, toolCallId } = tc;
219
- let output;
220
- // ── Plan mode tools ──
221
- if (toolName === 'enterPlanMode') {
222
- state.planMode = true;
223
- state.planId = generatePlanId();
224
- await ensurePlansDir();
225
- output = `Plan mode activated. Plan ID: ${state.planId}. Use only read-only tools. Save plan to ${getPlanPath(state.planId)}`;
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 (toolName === 'exitPlanMode') {
230
- state.planMode = false;
231
- if (state.planId) {
232
- const planPath = getPlanPath(state.planId);
233
- try {
234
- const planContent = await fs.readFile(planPath, 'utf-8');
235
- output = `Plan ready for review:\n\n${planContent}`;
236
- }
237
- catch {
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
- else {
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
- // ── askUser tool ──
248
- if (toolName === 'askUser') {
249
- const question = input.question;
250
- const optionsList = input.options;
251
- const answer = await callbacks.onAskUser(question, optionsList);
252
- output = `User answered: ${answer}`;
253
- pushToolResult(state, callbacks, toolCallId, toolName, output);
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
- // ── Permission check for write tools and shell ──
257
- if (toolName === 'writeFile' || toolName === 'edit' || toolName === 'shell') {
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
- catch (err) {
281
- output = `Error: ${err instanceof Error ? err.message : String(err)}`;
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
- output = truncateToolResult(output);
284
- pushToolResult(state, callbacks, toolCallId, toolName, output);
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
- // Load rules once shared between @rule-name resolution and buildKnowledgeContext
301
- const rules = await loadRuleFiles();
302
- // Check for @rule-name references in user message
303
- const ruleRefs = userMessage.match(/@([\w-]+)/g);
304
- let extraRuleContext = '';
305
- if (ruleRefs) {
306
- for (const ref of ruleRefs) {
307
- const ruleName = ref.slice(1); // remove @
308
- const rule = rules.find((r) => r.filename === ruleName);
309
- if (rule) {
310
- extraRuleContext += `\n\n### Rule: ${rule.filename}\n${rule.content}`;
311
- }
312
- }
313
- }
314
- const sessionSummary = await loadLatestSession();
315
- const sessionContext = sessionSummary ? formatSessionForPrompt(sessionSummary) : undefined;
316
- const knowledgeContext = await buildKnowledgeContext({ sessionContext, rules });
317
- const fullKnowledgeContext = knowledgeContext + extraRuleContext;
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
- // Context compression check — also saves session summary before compressing
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
- let result;
341
- try {
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
- const classified = classifyApiError(err);
418
- callbacks.onError(new Error(classified.message));
224
+ callbacks.onError(new Error(classifyApiError(err).message));
419
225
  break;
420
226
  }
421
- await handleToolCalls(toolCalls, state, options, callbacks);
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
- export async function saveSession(state, model) {
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
- // Don't crash on session save failure
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