@agi-cli/server 0.1.95 → 0.1.97

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agi-cli/server",
3
- "version": "0.1.95",
3
+ "version": "0.1.97",
4
4
  "description": "HTTP API server for AGI CLI",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -29,8 +29,8 @@
29
29
  "typecheck": "tsc --noEmit"
30
30
  },
31
31
  "dependencies": {
32
- "@agi-cli/sdk": "0.1.95",
33
- "@agi-cli/database": "0.1.95",
32
+ "@agi-cli/sdk": "0.1.97",
33
+ "@agi-cli/database": "0.1.97",
34
34
  "drizzle-orm": "^0.44.5",
35
35
  "hono": "^4.9.9",
36
36
  "zod": "^4.1.8"
package/src/index.ts CHANGED
@@ -228,7 +228,10 @@ export {
228
228
  resolveAgentConfig,
229
229
  defaultToolsForAgent,
230
230
  } from './runtime/agent-registry.ts';
231
- export { composeSystemPrompt } from './runtime/prompt.ts';
231
+ export {
232
+ composeSystemPrompt,
233
+ type ComposedSystemPrompt,
234
+ } from './runtime/prompt.ts';
232
235
  export {
233
236
  AskServiceError,
234
237
  handleAskRequest,
@@ -317,7 +317,12 @@ export async function resolveAgentConfig(
317
317
  const provider = normalizeProvider(entry?.provider);
318
318
  const model = normalizeModel(entry?.model);
319
319
  debugLog(`[agent] ${name} prompt source: ${promptSource}`);
320
- debugLog(`[agent] ${name} prompt:\n${prompt}`);
320
+ debugLog(
321
+ `[agent] ${name} prompt summary: ${JSON.stringify({
322
+ length: prompt.length,
323
+ lines: prompt.split('\n').length,
324
+ })}`,
325
+ );
321
326
  return {
322
327
  name,
323
328
  prompt,
@@ -0,0 +1,204 @@
1
+ import { debugLog } from '../debug.ts';
2
+
3
+ type ToolResultPart = {
4
+ type: string;
5
+ state?: string;
6
+ toolCallId?: string;
7
+ input?: unknown;
8
+ output?: unknown;
9
+ [key: string]: unknown;
10
+ };
11
+
12
+ type ToolResultInfo = {
13
+ toolName: string;
14
+ callId: string;
15
+ args: unknown;
16
+ result: unknown;
17
+ };
18
+
19
+ type TargetDescriptor = {
20
+ keys: string[];
21
+ summary: string;
22
+ };
23
+
24
+ type TrackedPart = {
25
+ part: ToolResultPart;
26
+ summary: string;
27
+ summarized: boolean;
28
+ };
29
+
30
+ export class ToolHistoryTracker {
31
+ private readonly targets = new Map<string, TrackedPart>();
32
+
33
+ register(part: ToolResultPart, info: ToolResultInfo) {
34
+ const descriptor = describeToolResult(info);
35
+ if (!descriptor) return;
36
+
37
+ const entry: TrackedPart = {
38
+ part,
39
+ summary: descriptor.summary,
40
+ summarized: false,
41
+ };
42
+
43
+ for (const key of descriptor.keys) {
44
+ const previous = this.targets.get(key);
45
+ if (previous && previous.part !== part) {
46
+ this.applySummary(previous);
47
+ }
48
+ this.targets.set(key, entry);
49
+ }
50
+ }
51
+
52
+ private applySummary(entry: TrackedPart) {
53
+ if (entry.summarized) return;
54
+ // Keep this entry as a tool result so the history still produces tool_result blocks.
55
+ entry.part.state = 'output-available';
56
+ entry.part.output = entry.summary;
57
+ (entry.part as { summaryText?: string }).summaryText = entry.summary;
58
+ delete (entry.part as { errorText?: unknown }).errorText;
59
+ delete (entry.part as { rawInput?: unknown }).rawInput;
60
+ delete (entry.part as { callProviderMetadata?: unknown })
61
+ .callProviderMetadata;
62
+ delete (entry.part as { providerMetadata?: unknown }).providerMetadata;
63
+ entry.summarized = true;
64
+ debugLog(`[history] summarized tool output -> ${entry.summary}`);
65
+ }
66
+ }
67
+
68
+ function describeToolResult(info: ToolResultInfo): TargetDescriptor | null {
69
+ const { toolName } = info;
70
+ switch (toolName) {
71
+ case 'read':
72
+ return describeRead(info);
73
+ case 'glob':
74
+ case 'grep':
75
+ return describePatternTool(info, toolName);
76
+ case 'write':
77
+ return describeWrite(info);
78
+ case 'apply_patch':
79
+ return describePatch(info);
80
+ default:
81
+ return null;
82
+ }
83
+ }
84
+
85
+ function describeRead(info: ToolResultInfo): TargetDescriptor | null {
86
+ const args = getRecord(info.args);
87
+ if (!args) return null;
88
+ const path = getString(args.path);
89
+ if (!path) return null;
90
+ const startLine = getNumber(args.startLine);
91
+ const endLine = getNumber(args.endLine);
92
+
93
+ let rangeLabel = 'entire file';
94
+ let rangeKey = 'all';
95
+ if (startLine !== undefined || endLine !== undefined) {
96
+ const start = startLine ?? 1;
97
+ const end = endLine ?? 'end';
98
+ rangeLabel = `lines ${start}–${end}`;
99
+ rangeKey = `${start}-${end}`;
100
+ }
101
+
102
+ const key = `read:${normalizePath(path)}:${rangeKey}`;
103
+ const summary = `[previous read] ${normalizePath(path)} (${rangeLabel})`;
104
+ return { keys: [key], summary };
105
+ }
106
+
107
+ function describePatternTool(
108
+ info: ToolResultInfo,
109
+ toolName: string,
110
+ ): TargetDescriptor | null {
111
+ const args = getRecord(info.args);
112
+ if (!args) return null;
113
+ const pattern =
114
+ getString(args.pattern) ??
115
+ getString(args.filePattern) ??
116
+ getString(args.path);
117
+ if (!pattern) return null;
118
+ const key = `${toolName}:${pattern}`;
119
+ const summary = `[previous ${toolName}] ${pattern}`;
120
+ return { keys: [key], summary };
121
+ }
122
+
123
+ function describeWrite(info: ToolResultInfo): TargetDescriptor | null {
124
+ const result = getRecord(info.result);
125
+ if (!result) return null;
126
+ const path = getString(result.path);
127
+ if (!path) return null;
128
+ const bytes = getNumber(result.bytes);
129
+ const sizeLabel =
130
+ typeof bytes === 'number' && Number.isFinite(bytes)
131
+ ? `${bytes} bytes`
132
+ : 'unknown size';
133
+ const key = `write:${normalizePath(path)}`;
134
+ const summary = `[previous write] ${normalizePath(path)} (${sizeLabel})`;
135
+ return { keys: [key], summary };
136
+ }
137
+
138
+ function describePatch(info: ToolResultInfo): TargetDescriptor | null {
139
+ const result = getRecord(info.result);
140
+ if (!result) return null;
141
+
142
+ const files = new Set<string>();
143
+
144
+ const changes = getArray(result.changes);
145
+ if (changes) {
146
+ for (const change of changes) {
147
+ const changeObj = getRecord(change);
148
+ const filePath = changeObj && getString(changeObj.filePath);
149
+ if (filePath) files.add(normalizePath(filePath));
150
+ }
151
+ }
152
+
153
+ const artifact = getRecord(result.artifact);
154
+ if (artifact) {
155
+ const summary = getRecord(artifact.summary);
156
+ const summaryFiles = getArray(summary?.files);
157
+ if (summaryFiles) {
158
+ for (const item of summaryFiles) {
159
+ if (typeof item === 'string') files.add(normalizePath(item));
160
+ else {
161
+ const record = getRecord(item);
162
+ const value = record && getString(record.path);
163
+ if (value) files.add(normalizePath(value));
164
+ }
165
+ }
166
+ }
167
+ }
168
+
169
+ const fileList = Array.from(files);
170
+ const keys = fileList.length
171
+ ? fileList.map((file) => `apply_patch:${file}`)
172
+ : [`apply_patch:${info.callId}`];
173
+
174
+ const summary =
175
+ fileList.length > 0
176
+ ? `[previous patch] ${fileList.join(', ')}`
177
+ : '[previous patch] (unknown files)';
178
+
179
+ return { keys, summary };
180
+ }
181
+
182
+ function getRecord(value: unknown): Record<string, unknown> | null {
183
+ return value && typeof value === 'object' && !Array.isArray(value)
184
+ ? (value as Record<string, unknown>)
185
+ : null;
186
+ }
187
+
188
+ function getArray(value: unknown): unknown[] | null {
189
+ return Array.isArray(value) ? value : null;
190
+ }
191
+
192
+ function getString(value: unknown): string | null {
193
+ if (typeof value === 'string') return value;
194
+ return null;
195
+ }
196
+
197
+ function getNumber(value: unknown): number | undefined {
198
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
199
+ return undefined;
200
+ }
201
+
202
+ function normalizePath(path: string): string {
203
+ return path.replace(/\\/g, '/');
204
+ }
@@ -3,6 +3,7 @@ import type { getDb } from '@agi-cli/database';
3
3
  import { messages, messageParts } from '@agi-cli/database/schema';
4
4
  import { eq, asc } from 'drizzle-orm';
5
5
  import { debugLog } from './debug.ts';
6
+ import { ToolHistoryTracker } from './history/tool-history-tracker.ts';
6
7
 
7
8
  /**
8
9
  * Builds the conversation history for a session from the database,
@@ -19,12 +20,14 @@ export async function buildHistoryMessages(
19
20
  .orderBy(asc(messages.createdAt));
20
21
 
21
22
  const ui: UIMessage[] = [];
23
+ const toolHistory = new ToolHistoryTracker();
22
24
 
23
25
  for (const m of rows) {
24
26
  if (m.role === 'assistant' && m.status !== 'complete') {
25
27
  debugLog(
26
- `[buildHistoryMessages] Skipping assistant message ${m.id} with status ${m.status}`,
28
+ `[buildHistoryMessages] Skipping assistant message ${m.id} with status ${m.status} (current turn still in progress)`,
27
29
  );
30
+ logPendingToolParts(db, m.id);
28
31
  continue;
29
32
  }
30
33
 
@@ -103,13 +106,21 @@ export async function buildHistoryMessages(
103
106
  // Skip error parts in history
104
107
  }
105
108
 
109
+ const toolResultsById = new Map(
110
+ toolResults.map((result) => [result.callId, result]),
111
+ );
112
+
106
113
  const hasIncompleteTools = toolCalls.some(
107
- (call) => !toolResults.find((result) => result.callId === call.callId),
114
+ (call) => !toolResultsById.has(call.callId),
108
115
  );
109
116
 
110
117
  if (hasIncompleteTools) {
118
+ const pendingCalls = toolCalls
119
+ .filter((call) => !toolResultsById.has(call.callId))
120
+ .map((call) => `${call.name}#${call.callId}`)
121
+ .join(', ');
111
122
  debugLog(
112
- `[buildHistoryMessages] Incomplete tool calls for assistant message ${m.id}, pushing text only`,
123
+ `[buildHistoryMessages] Incomplete tool calls for assistant message ${m.id}, skipping tool data (pending: ${pendingCalls || 'unknown'})`,
113
124
  );
114
125
  if (assistantParts.length) {
115
126
  ui.push({ id: m.id, role: 'assistant', parts: assistantParts });
@@ -122,47 +133,92 @@ export async function buildHistoryMessages(
122
133
  if (call.name === 'finish') continue;
123
134
 
124
135
  const toolType = `tool-${call.name}` as `tool-${string}`;
125
- const result = toolResults.find((r) => r.callId === call.callId);
136
+ const result = toolResultsById.get(call.callId);
126
137
 
127
138
  if (result) {
128
- const outputStr = (() => {
129
- const r = result.result;
130
- if (typeof r === 'string') return r;
131
- try {
132
- return JSON.stringify(r);
133
- } catch {
134
- return String(r);
135
- }
136
- })();
137
-
138
- assistantParts.push({
139
+ const part = {
139
140
  type: toolType,
140
141
  state: 'output-available',
141
142
  toolCallId: call.callId,
142
143
  input: call.args,
143
- output: outputStr,
144
- } as never);
144
+ output: (() => {
145
+ const r = result.result;
146
+ if (typeof r === 'string') return r;
147
+ try {
148
+ return JSON.stringify(r);
149
+ } catch {
150
+ return String(r);
151
+ }
152
+ })(),
153
+ };
154
+
155
+ toolHistory.register(part, {
156
+ toolName: call.name,
157
+ callId: call.callId,
158
+ args: call.args,
159
+ result: result.result,
160
+ });
161
+
162
+ assistantParts.push(part as never);
145
163
  }
146
164
  }
147
165
 
148
166
  if (assistantParts.length) {
149
167
  ui.push({ id: m.id, role: 'assistant', parts: assistantParts });
168
+ }
169
+ }
170
+ }
150
171
 
151
- if (toolResults.length) {
152
- const userParts: UIMessage['parts'] = toolResults.map((r) => {
153
- const out =
154
- typeof r.result === 'string'
155
- ? r.result
156
- : JSON.stringify(r.result);
157
- return { type: 'text', text: out };
172
+ return convertToModelMessages(ui);
173
+ }
174
+
175
+ async function logPendingToolParts(
176
+ db: Awaited<ReturnType<typeof getDb>>,
177
+ messageId: string,
178
+ ) {
179
+ try {
180
+ const parts = await db
181
+ .select()
182
+ .from(messageParts)
183
+ .where(eq(messageParts.messageId, messageId))
184
+ .orderBy(asc(messageParts.index));
185
+
186
+ const pendingCalls: string[] = [];
187
+ for (const part of parts) {
188
+ if (part.type !== 'tool_call') continue;
189
+ try {
190
+ const obj = JSON.parse(part.content ?? '{}') as {
191
+ name?: string;
192
+ callId?: string;
193
+ };
194
+ if (obj.name && obj.callId) {
195
+ const resultExists = parts.some((candidate) => {
196
+ if (candidate.type !== 'tool_result') return false;
197
+ try {
198
+ const parsed = JSON.parse(candidate.content ?? '{}') as {
199
+ callId?: string;
200
+ };
201
+ return parsed.callId === obj.callId;
202
+ } catch {
203
+ return false;
204
+ }
158
205
  });
159
- if (userParts.length) {
160
- ui.push({ id: m.id, role: 'user', parts: userParts });
206
+ if (!resultExists) {
207
+ pendingCalls.push(`${obj.name}#${obj.callId}`);
161
208
  }
162
209
  }
163
- }
210
+ } catch {}
164
211
  }
212
+ if (pendingCalls.length) {
213
+ debugLog(
214
+ `[buildHistoryMessages] Pending tool calls for assistant message ${messageId}: ${pendingCalls.join(', ')}`,
215
+ );
216
+ }
217
+ } catch (err) {
218
+ debugLog(
219
+ `[buildHistoryMessages] Failed to inspect pending tool calls for ${messageId}: ${
220
+ err instanceof Error ? err.message : String(err)
221
+ }`,
222
+ );
165
223
  }
166
-
167
- return convertToModelMessages(ui);
168
224
  }
@@ -246,19 +246,23 @@ async function generateSessionTitle(args: {
246
246
  },
247
247
  ];
248
248
 
249
- debugLog('[TITLE_GEN] Using OAuth mode:');
250
- debugLog(`[TITLE_GEN] System prompt (spoof): ${spoofPrompt}`);
251
- debugLog(`[TITLE_GEN] User message (instructions + content):`);
252
- debugLog(`[TITLE_GEN] Instructions: ${titlePrompt}`);
253
- debugLog(`[TITLE_GEN] Content: ${promptText.substring(0, 100)}...`);
249
+ debugLog(
250
+ `[TITLE_GEN] Using OAuth mode (prompts: spoof:${provider}, title-generator, user-request)`,
251
+ );
252
+ debugLog(
253
+ `[TITLE_GEN] User content preview: ${promptText.substring(0, 100)}...`,
254
+ );
254
255
  } else {
255
256
  // API key mode: normal flow
256
257
  system = titlePrompt;
257
258
  messagesArray = [{ role: 'user', content: promptText }];
258
259
 
259
- debugLog('[TITLE_GEN] Using API key mode:');
260
- debugLog(`[TITLE_GEN] System prompt: ${system}`);
261
- debugLog(`[TITLE_GEN] User message: ${promptText.substring(0, 100)}...`);
260
+ debugLog(
261
+ `[TITLE_GEN] Using API key mode (prompts: title-generator, user-request)`,
262
+ );
263
+ debugLog(
264
+ `[TITLE_GEN] User content preview: ${promptText.substring(0, 100)}...`,
265
+ );
262
266
  }
263
267
 
264
268
  debugLog('[TITLE_GEN] Calling generateText...');
@@ -12,6 +12,12 @@ import ANTHROPIC_SPOOF_PROMPT from '@agi-cli/sdk/prompts/providers/anthropicSpoo
12
12
  };
13
13
 
14
14
  import { getTerminalManager } from '@agi-cli/sdk';
15
+
16
+ export type ComposedSystemPrompt = {
17
+ prompt: string;
18
+ components: string[];
19
+ };
20
+
15
21
  export async function composeSystemPrompt(options: {
16
22
  provider: string;
17
23
  model?: string;
@@ -22,9 +28,17 @@ export async function composeSystemPrompt(options: {
22
28
  includeEnvironment?: boolean;
23
29
  includeProjectTree?: boolean;
24
30
  userContext?: string;
25
- }): Promise<string> {
31
+ }): Promise<ComposedSystemPrompt> {
32
+ const components: string[] = [];
26
33
  if (options.spoofPrompt) {
27
- return options.spoofPrompt.trim();
34
+ const prompt = options.spoofPrompt.trim();
35
+ const providerComponent = options.provider
36
+ ? `spoof:${options.provider}`
37
+ : 'spoof:unknown';
38
+ return {
39
+ prompt,
40
+ components: [providerComponent],
41
+ };
28
42
  }
29
43
 
30
44
  const parts: string[] = [];
@@ -41,6 +55,18 @@ export async function composeSystemPrompt(options: {
41
55
  baseInstructions.trim(),
42
56
  options.agentPrompt.trim(),
43
57
  );
58
+ if (providerPrompt.trim()) {
59
+ const providerComponent = options.provider
60
+ ? `provider:${options.provider}`
61
+ : 'provider:unknown';
62
+ components.push(providerComponent);
63
+ }
64
+ if (baseInstructions.trim()) {
65
+ components.push('base');
66
+ }
67
+ if (options.agentPrompt.trim()) {
68
+ components.push('agent');
69
+ }
44
70
 
45
71
  if (options.oneShot) {
46
72
  const oneShotBlock =
@@ -51,6 +77,7 @@ export async function composeSystemPrompt(options: {
51
77
  '</system-reminder>',
52
78
  ].join('\n');
53
79
  parts.push(oneShotBlock);
80
+ components.push('mode:oneshot');
54
81
  }
55
82
 
56
83
  if (options.includeEnvironment !== false) {
@@ -60,6 +87,10 @@ export async function composeSystemPrompt(options: {
60
87
  );
61
88
  if (envAndInstructions) {
62
89
  parts.push(envAndInstructions);
90
+ components.push('environment');
91
+ if (options.includeProjectTree) {
92
+ components.push('project-tree');
93
+ }
63
94
  }
64
95
  }
65
96
 
@@ -71,6 +102,7 @@ export async function composeSystemPrompt(options: {
71
102
  '</user-provided-state-context>',
72
103
  ].join('\n');
73
104
  parts.push(userContextBlock);
105
+ components.push('user-context');
74
106
  }
75
107
 
76
108
  // Add terminal context if available
@@ -79,17 +111,27 @@ export async function composeSystemPrompt(options: {
79
111
  const terminalContext = terminalManager.getContext();
80
112
  if (terminalContext) {
81
113
  parts.push(terminalContext);
114
+ components.push('terminal-context');
82
115
  }
83
116
  }
84
117
 
85
118
  const composed = parts.filter(Boolean).join('\n\n').trim();
86
- if (composed) return composed;
119
+ if (composed) {
120
+ return {
121
+ prompt: composed,
122
+ components: dedupeComponents(components),
123
+ };
124
+ }
87
125
 
88
- return [
126
+ const fallback = [
89
127
  'You are a concise, friendly coding agent.',
90
128
  'Be precise and actionable. Use tools when needed, prefer small diffs.',
91
129
  'Stream your answer; call finish when done.',
92
130
  ].join(' ');
131
+ return {
132
+ prompt: fallback,
133
+ components: dedupeComponents([...components, 'fallback']),
134
+ };
93
135
  }
94
136
 
95
137
  export function getProviderSpoofPrompt(provider: string): string | undefined {
@@ -98,3 +140,15 @@ export function getProviderSpoofPrompt(provider: string): string | undefined {
98
140
  }
99
141
  return undefined;
100
142
  }
143
+
144
+ function dedupeComponents(input: string[]): string[] {
145
+ const seen = new Set<string>();
146
+ const out: string[] = [];
147
+ for (const item of input) {
148
+ if (!item) continue;
149
+ if (seen.has(item)) continue;
150
+ seen.add(item);
151
+ out.push(item);
152
+ }
153
+ return out;
154
+ }
@@ -61,6 +61,12 @@ export async function runSessionLoop(sessionId: string) {
61
61
  * Main function to run the assistant for a given request.
62
62
  */
63
63
  async function runAssistant(opts: RunOpts) {
64
+ const separator = '='.repeat(72);
65
+ debugLog(separator);
66
+ debugLog(
67
+ `[RUNNER] Starting turn for session ${opts.sessionId}, message ${opts.assistantMessageId}`,
68
+ );
69
+
64
70
  const cfgTimer = time('runner:loadConfig+db');
65
71
  const cfg = await loadConfig(opts.projectRoot);
66
72
  const db = await getDb(cfg.projectRoot);
@@ -99,14 +105,19 @@ async function runAssistant(opts: RunOpts) {
99
105
  : undefined;
100
106
 
101
107
  debugLog(`[RUNNER] needsSpoof (OAuth): ${needsSpoof}`);
102
- debugLog(`[RUNNER] spoofPrompt: ${spoofPrompt || 'NONE'}`);
108
+ debugLog(
109
+ `[RUNNER] spoofPrompt: ${spoofPrompt ? `present (${opts.provider})` : 'none'}`,
110
+ );
103
111
 
104
112
  let system: string;
113
+ let systemComponents: string[] = [];
114
+ let oauthFullPromptComponents: string[] | undefined;
105
115
  let additionalSystemMessages: Array<{ role: 'system'; content: string }> = [];
106
116
 
107
117
  if (spoofPrompt) {
108
118
  // OAuth mode: short spoof in system field, full instructions in messages array
109
119
  system = spoofPrompt;
120
+ systemComponents = [`spoof:${opts.provider || 'unknown'}`];
110
121
  const fullPrompt = await composeSystemPrompt({
111
122
  provider: opts.provider,
112
123
  model: opts.model,
@@ -117,26 +128,27 @@ async function runAssistant(opts: RunOpts) {
117
128
  includeProjectTree: isFirstMessage,
118
129
  userContext: opts.userContext,
119
130
  });
131
+ oauthFullPromptComponents = fullPrompt.components;
120
132
 
121
133
  // FIX: Always add the system message for OAuth because:
122
134
  // 1. System messages are NOT stored in the database
123
135
  // 2. buildHistoryMessages only returns user/assistant messages
124
136
  // 3. We need the full instructions on every turn
125
- additionalSystemMessages = [{ role: 'system', content: fullPrompt }];
137
+ additionalSystemMessages = [{ role: 'system', content: fullPrompt.prompt }];
126
138
 
127
139
  debugLog('[RUNNER] OAuth mode: additionalSystemMessages created');
128
- debugLog(`[RUNNER] fullPrompt length: ${fullPrompt.length}`);
140
+ const includesUserContext =
141
+ !!opts.userContext && fullPrompt.prompt.includes(opts.userContext);
129
142
  debugLog(
130
- `[RUNNER] fullPrompt contains userContext: ${fullPrompt.includes('<user-provided-state-context>') ? 'YES' : 'NO'}`,
143
+ `[system] oauth-full summary: ${JSON.stringify({
144
+ components: oauthFullPromptComponents ?? [],
145
+ length: fullPrompt.prompt.length,
146
+ includesUserContext,
147
+ })}`,
131
148
  );
132
- if (opts.userContext && fullPrompt.includes(opts.userContext)) {
133
- debugLog('[RUNNER] ✅ userContext IS in fullPrompt');
134
- } else if (opts.userContext) {
135
- debugLog('[RUNNER] ❌ userContext NOT in fullPrompt!');
136
- }
137
149
  } else {
138
150
  // API key mode: full instructions in system field
139
- system = await composeSystemPrompt({
151
+ const composed = await composeSystemPrompt({
140
152
  provider: opts.provider,
141
153
  model: opts.model,
142
154
  projectRoot: cfg.projectRoot,
@@ -146,10 +158,16 @@ async function runAssistant(opts: RunOpts) {
146
158
  includeProjectTree: isFirstMessage,
147
159
  userContext: opts.userContext,
148
160
  });
161
+ system = composed.prompt;
162
+ systemComponents = composed.components;
149
163
  }
150
164
  systemTimer.end();
151
- debugLog('[system] composed prompt (provider+base+agent):');
152
- debugLog(system);
165
+ debugLog(
166
+ `[system] summary: ${JSON.stringify({
167
+ components: systemComponents,
168
+ length: system.length,
169
+ })}`,
170
+ );
153
171
 
154
172
  const toolsTimer = time('runner:discoverTools');
155
173
  const allTools = await discoverProjectTools(cfg.projectRoot);
@@ -500,5 +518,10 @@ async function runAssistant(opts: RunOpts) {
500
518
  await completeAssistantMessage({}, opts, db);
501
519
  } catch {}
502
520
  throw err;
521
+ } finally {
522
+ debugLog(
523
+ `[RUNNER] Turn complete for session ${opts.sessionId}, message ${opts.assistantMessageId}`,
524
+ );
525
+ debugLog(separator);
503
526
  }
504
527
  }
@@ -6,6 +6,7 @@ import { publish } from '../events/bus.ts';
6
6
  export type StepExecutionState = {
7
7
  chain: Promise<void>;
8
8
  failed: boolean;
9
+ failedToolName?: string;
9
10
  };
10
11
 
11
12
  export type ToolAdapterContext = {
@@ -10,6 +10,13 @@ import type {
10
10
  } from '../runtime/tool-context.ts';
11
11
  import { isToolError } from '@agi-cli/sdk/tools/error';
12
12
 
13
+ function isSkippedToolCallError(error: unknown): boolean {
14
+ if (!isToolError(error)) return false;
15
+ const details = (error as { details?: unknown }).details;
16
+ if (!details || typeof details !== 'object') return false;
17
+ return 'skippedTool' in (details as Record<string, unknown>);
18
+ }
19
+
13
20
  export type { ToolAdapterContext } from '../runtime/tool-context.ts';
14
21
 
15
22
  type ToolExecuteSignature = Tool['execute'] extends (
@@ -50,6 +57,10 @@ export function adaptTools(
50
57
  ) {
51
58
  const out: Record<string, Tool> = {};
52
59
  const pendingCalls = new Map<string, PendingCallMeta[]>();
60
+ const failureState: { active: boolean; toolName?: string } = {
61
+ active: false,
62
+ toolName: undefined,
63
+ };
53
64
  let firstToolCallReported = false;
54
65
 
55
66
  if (!ctx.stepExecution) {
@@ -289,19 +300,32 @@ export function adaptTools(
289
300
  : 0;
290
301
  let stepState = stepStates.get(stepKey);
291
302
  if (!stepState) {
292
- stepState = { chain: Promise.resolve(), failed: false };
303
+ stepState = {
304
+ chain: Promise.resolve(),
305
+ failed: false,
306
+ failedToolName: undefined,
307
+ };
293
308
  stepStates.set(stepKey, stepState);
294
309
  }
295
310
 
296
311
  const executeWithGuards = async (): Promise<ToolExecuteReturn> => {
297
312
  try {
298
- if (stepState.failed) {
299
- const skipError = {
300
- ok: false,
301
- error: `Cannot execute "${name}" because a previous tool call in this step failed. Retry the failing tool before continuing with "${name}".`,
302
- details: { skippedTool: name },
303
- };
304
- throw skipError;
313
+ if (failureState.active) {
314
+ const expectedTool = failureState.toolName;
315
+ if (!expectedTool || expectedTool !== name) {
316
+ const skipError = {
317
+ ok: false,
318
+ error: expectedTool
319
+ ? `Cannot execute "${name}" because "${expectedTool}" failed earlier in this step. Retry "${expectedTool}" before using other tools.`
320
+ : `Cannot execute "${name}" because a previous tool call in this session failed. Retry that tool before continuing with "${name}".`,
321
+ details: {
322
+ skippedTool: name,
323
+ reason: 'previous_tool_failed',
324
+ expectedTool,
325
+ },
326
+ };
327
+ throw skipError;
328
+ }
305
329
  }
306
330
  // Handle session-relative paths and cwd tools
307
331
  let res: ToolExecuteReturn | { cwd: string } | null | undefined;
@@ -415,6 +439,12 @@ export function adaptTools(
415
439
 
416
440
  // Special-case: keep progress_update result lightweight; publish first, persist best-effort
417
441
  if (name === 'progress_update') {
442
+ stepState.failed = false;
443
+ stepState.failedToolName = undefined;
444
+ if (failureState.active && failureState.toolName === name) {
445
+ failureState.active = false;
446
+ failureState.toolName = undefined;
447
+ }
418
448
  publish({
419
449
  type: 'tool.result',
420
450
  sessionId: ctx.sessionId,
@@ -444,6 +474,13 @@ export function adaptTools(
444
474
  return result as ToolExecuteReturn;
445
475
  }
446
476
 
477
+ stepState.failed = false;
478
+ stepState.failedToolName = undefined;
479
+ if (failureState.active && failureState.toolName === name) {
480
+ failureState.active = false;
481
+ failureState.toolName = undefined;
482
+ }
483
+
447
484
  await ctx.db.insert(messageParts).values({
448
485
  id: resultPartId,
449
486
  messageId: ctx.messageId,
@@ -510,7 +547,14 @@ export function adaptTools(
510
547
  }
511
548
  return result as ToolExecuteReturn;
512
549
  } catch (error) {
550
+ if (isSkippedToolCallError(error)) {
551
+ throw error;
552
+ }
553
+
513
554
  stepState.failed = true;
555
+ stepState.failedToolName = name;
556
+ failureState.active = true;
557
+ failureState.toolName = name;
514
558
 
515
559
  // Tool execution failed
516
560
  if (