@zhijiewang/openharness 2.29.0 → 2.30.1
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/README.md +8 -5
- package/README.zh-CN.md +8 -5
- package/dist/Tool.d.ts +4 -0
- package/dist/commands/ai.js +4 -4
- package/dist/commands/git.js +1 -1
- package/dist/commands/info.js +30 -3
- package/dist/commands/session.js +1 -2
- package/dist/commands/settings.js +1 -1
- package/dist/commands/skills.js +2 -5
- package/dist/components/InitWizard.js +1 -1
- package/dist/harness/config.js +3 -7
- package/dist/harness/plugins.js +1 -1
- package/dist/harness/telemetry.js +18 -12
- package/dist/harness/traces.d.ts +31 -1
- package/dist/harness/traces.js +85 -4
- package/dist/providers/anthropic.js +4 -1
- package/dist/query/index.js +208 -195
- package/dist/query/tools.js +5 -0
- package/dist/query/types.d.ts +3 -0
- package/dist/repl.js +22 -1
- package/dist/services/AgentDispatcher.js +15 -28
- package/dist/services/StreamingToolExecutor.js +102 -11
- package/dist/tools/CronTool/index.d.ts +2 -2
- package/dist/tools/DiagnosticsTool/index.d.ts +1 -1
- package/dist/tools/GrepTool/index.d.ts +2 -2
- package/dist/tools/PowerShellTool/index.js +11 -2
- package/package.json +1 -1
package/dist/query/index.js
CHANGED
|
@@ -41,6 +41,11 @@ export async function* query(userMessage, config, existingMessages = []) {
|
|
|
41
41
|
const maxTurns = config.maxTurns ?? DEFAULT_MAX_TURNS;
|
|
42
42
|
const routerCfg = readOhConfig()?.modelRouter ?? {};
|
|
43
43
|
const router = new ModelRouter(routerCfg, config.model ?? "");
|
|
44
|
+
const querySpanId = config.tracer?.startSpan("query", {
|
|
45
|
+
model: config.model,
|
|
46
|
+
permissionMode: config.permissionMode,
|
|
47
|
+
toolCount: config.tools.length,
|
|
48
|
+
});
|
|
44
49
|
const toolContext = {
|
|
45
50
|
workingDir: config.workingDir ?? process.cwd(),
|
|
46
51
|
abortSignal: config.abortSignal,
|
|
@@ -51,6 +56,8 @@ export async function* query(userMessage, config, existingMessages = []) {
|
|
|
51
56
|
permissionMode: config.permissionMode,
|
|
52
57
|
askUserQuestion: config.askUserQuestion,
|
|
53
58
|
gitCommitPerTool: config.gitCommitPerTool,
|
|
59
|
+
tracer: config.tracer,
|
|
60
|
+
parentSpanId: querySpanId,
|
|
54
61
|
};
|
|
55
62
|
const estimateTokens = makeTokenEstimator(config.provider);
|
|
56
63
|
const contextManager = new ContextManager(undefined, config.model);
|
|
@@ -99,224 +106,230 @@ export async function* query(userMessage, config, existingMessages = []) {
|
|
|
99
106
|
consecutiveErrors: 0,
|
|
100
107
|
};
|
|
101
108
|
// ── Main loop ──
|
|
102
|
-
|
|
103
|
-
state.turn
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
109
|
+
try {
|
|
110
|
+
while (state.turn < maxTurns) {
|
|
111
|
+
state.turn++;
|
|
112
|
+
if (config.abortSignal?.aborted) {
|
|
113
|
+
yield { type: "turn_complete", reason: "aborted" };
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (config.maxCost && config.maxCost > 0 && state.totalCost >= config.maxCost) {
|
|
117
|
+
yield { type: "error", message: `Budget exceeded: $${state.totalCost.toFixed(4)}` };
|
|
118
|
+
yield { type: "turn_complete", reason: "budget_exceeded" };
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
// Context window management
|
|
122
|
+
// ── Context window management with circuit breaker ──
|
|
123
|
+
const contextWindow = getContextWindow(config.model);
|
|
124
|
+
const estimatedTokens = estimateMessagesTokens(state.messages, estimateTokens);
|
|
125
|
+
const MAX_COMPRESSION_FAILURES = 3;
|
|
126
|
+
if (estimatedTokens > contextWindow * 0.8 && (state.compressionFailures ?? 0) < MAX_COMPRESSION_FAILURES) {
|
|
127
|
+
const tokensBefore = estimatedTokens;
|
|
128
|
+
let strategy = "basic";
|
|
129
|
+
state.messages = compressMessages(state.messages, Math.floor(contextWindow * 0.6));
|
|
130
|
+
const afterBasic = estimateMessagesTokens(state.messages, estimateTokens);
|
|
131
|
+
if (afterBasic > contextWindow * 0.7 && state.messages.length > 4) {
|
|
132
|
+
try {
|
|
133
|
+
state.messages = await summarizeConversation(config.provider, state.messages, config.model, Math.floor(contextWindow * 0.5));
|
|
134
|
+
strategy = "llm-summarization";
|
|
135
|
+
state.compressionFailures = 0; // Reset on success
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
state.compressionFailures = (state.compressionFailures ?? 0) + 1;
|
|
139
|
+
strategy = "basic-only (llm failed)";
|
|
140
|
+
}
|
|
128
141
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
142
|
+
const tokensAfter = estimateMessagesTokens(state.messages, estimateTokens);
|
|
143
|
+
yield {
|
|
144
|
+
type: "error",
|
|
145
|
+
message: `Context compressed (${strategy}): ${tokensBefore} → ${tokensAfter} tokens. Re-read any files you need.`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
else if (estimatedTokens > contextWindow * 0.8) {
|
|
149
|
+
yield {
|
|
150
|
+
type: "error",
|
|
151
|
+
message: "Context compression disabled (3 consecutive failures). Consider starting a new session.",
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
// ── Dynamic prompt: refresh memories if changed, inject warnings ──
|
|
155
|
+
try {
|
|
156
|
+
const { memoryVersion, loadActiveMemories, memoriesToPrompt } = await import("../harness/memory.js");
|
|
157
|
+
const currentVer = memoryVersion();
|
|
158
|
+
if (currentVer > lastMemoryVer) {
|
|
159
|
+
const fresh = memoriesToPrompt(loadActiveMemories());
|
|
160
|
+
// Replace or append memory section in fullSystemPrompt
|
|
161
|
+
if (fullSystemPrompt.includes("# Remembered Context")) {
|
|
162
|
+
fullSystemPrompt = fullSystemPrompt.replace(/# Remembered Context[\s\S]*?(?=\n# |$)/, fresh);
|
|
163
|
+
}
|
|
164
|
+
else if (fresh) {
|
|
165
|
+
fullSystemPrompt += `\n\n${fresh}`;
|
|
166
|
+
}
|
|
167
|
+
lastMemoryVer = currentVer;
|
|
132
168
|
}
|
|
133
169
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
type: "error",
|
|
143
|
-
message: "Context compression disabled (3 consecutive failures). Consider starting a new session.",
|
|
144
|
-
};
|
|
145
|
-
}
|
|
146
|
-
// ── Dynamic prompt: refresh memories if changed, inject warnings ──
|
|
147
|
-
try {
|
|
148
|
-
const { memoryVersion, loadActiveMemories, memoriesToPrompt } = await import("../harness/memory.js");
|
|
149
|
-
const currentVer = memoryVersion();
|
|
150
|
-
if (currentVer > lastMemoryVer) {
|
|
151
|
-
const fresh = memoriesToPrompt(loadActiveMemories());
|
|
152
|
-
// Replace or append memory section in fullSystemPrompt
|
|
153
|
-
if (fullSystemPrompt.includes("# Remembered Context")) {
|
|
154
|
-
fullSystemPrompt = fullSystemPrompt.replace(/# Remembered Context[\s\S]*?(?=\n# |$)/, fresh);
|
|
170
|
+
catch {
|
|
171
|
+
/* memory refresh optional */
|
|
172
|
+
}
|
|
173
|
+
let turnPrompt = fullSystemPrompt;
|
|
174
|
+
if (config.maxCost && config.maxCost > 0) {
|
|
175
|
+
const pct = state.totalCost / config.maxCost;
|
|
176
|
+
if (pct >= 0.9) {
|
|
177
|
+
turnPrompt += `\n\n⚠️ BUDGET CRITICAL: Only $${(config.maxCost - state.totalCost).toFixed(4)} remaining. Provide final response NOW.`;
|
|
155
178
|
}
|
|
156
|
-
else if (
|
|
157
|
-
|
|
179
|
+
else if (pct >= 0.7) {
|
|
180
|
+
turnPrompt += `\n\n⚠️ BUDGET WARNING: ${Math.round((1 - pct) * 100)}% budget remaining. Start consolidating.`;
|
|
158
181
|
}
|
|
159
|
-
lastMemoryVer = currentVer;
|
|
160
182
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
/* memory refresh optional */
|
|
164
|
-
}
|
|
165
|
-
let turnPrompt = fullSystemPrompt;
|
|
166
|
-
if (config.maxCost && config.maxCost > 0) {
|
|
167
|
-
const pct = state.totalCost / config.maxCost;
|
|
168
|
-
if (pct >= 0.9) {
|
|
169
|
-
turnPrompt += `\n\n⚠️ BUDGET CRITICAL: Only $${(config.maxCost - state.totalCost).toFixed(4)} remaining. Provide final response NOW.`;
|
|
183
|
+
if (state.turn >= maxTurns * 0.9 && maxTurns > 1) {
|
|
184
|
+
turnPrompt += `\n\n⚠️ TURN LIMIT: ${maxTurns - state.turn} turn(s) remaining. Wrap up.`;
|
|
170
185
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
toolCallCount: state.lastTurnToolCount ?? 0,
|
|
189
|
-
contextUsage: ctxUsage,
|
|
190
|
-
isFinalResponse: (state.lastTurnHadTools === false || state.lastTurnHadTools === undefined) && state.turn > 1,
|
|
191
|
-
role: config.role,
|
|
192
|
-
});
|
|
193
|
-
for await (const event of config.provider.stream(state.messages, turnPrompt, apiTools, selection.model)) {
|
|
194
|
-
if (config.abortSignal?.aborted)
|
|
195
|
-
break;
|
|
196
|
-
switch (event.type) {
|
|
197
|
-
case "text_delta":
|
|
198
|
-
assistantContent += event.content;
|
|
199
|
-
yield event;
|
|
200
|
-
break;
|
|
201
|
-
case "tool_call_start":
|
|
202
|
-
toolCalls.push({ id: event.callId, toolName: event.toolName, arguments: {} });
|
|
203
|
-
yield event;
|
|
186
|
+
// ── LLM call with streaming ──
|
|
187
|
+
let assistantContent = "";
|
|
188
|
+
const toolCalls = [];
|
|
189
|
+
let streamError = null;
|
|
190
|
+
const streamingExecutor = new StreamingToolExecutor(config.tools, toolContext, config.permissionMode, config.askUser, config.abortSignal);
|
|
191
|
+
try {
|
|
192
|
+
const ctxUsage = estimateRouteContextUsage(state.messages, config.provider, config.model ?? "");
|
|
193
|
+
const selection = router.select({
|
|
194
|
+
turn: state.turn,
|
|
195
|
+
hadToolCalls: state.lastTurnHadTools ?? false,
|
|
196
|
+
toolCallCount: state.lastTurnToolCount ?? 0,
|
|
197
|
+
contextUsage: ctxUsage,
|
|
198
|
+
isFinalResponse: (state.lastTurnHadTools === false || state.lastTurnHadTools === undefined) && state.turn > 1,
|
|
199
|
+
role: config.role,
|
|
200
|
+
});
|
|
201
|
+
for await (const event of config.provider.stream(state.messages, turnPrompt, apiTools, selection.model)) {
|
|
202
|
+
if (config.abortSignal?.aborted)
|
|
204
203
|
break;
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
204
|
+
switch (event.type) {
|
|
205
|
+
case "text_delta":
|
|
206
|
+
assistantContent += event.content;
|
|
207
|
+
yield event;
|
|
208
|
+
break;
|
|
209
|
+
case "tool_call_start":
|
|
210
|
+
toolCalls.push({ id: event.callId, toolName: event.toolName, arguments: {} });
|
|
211
|
+
yield event;
|
|
212
|
+
break;
|
|
213
|
+
case "tool_call_complete": {
|
|
214
|
+
const tc = toolCalls.find((t) => t.id === event.callId);
|
|
215
|
+
if (tc) {
|
|
216
|
+
const idx = toolCalls.indexOf(tc);
|
|
217
|
+
toolCalls[idx] = { ...tc, arguments: event.arguments };
|
|
218
|
+
}
|
|
219
|
+
if (streamingExecutor) {
|
|
220
|
+
streamingExecutor.addTool({ id: event.callId, toolName: event.toolName, arguments: event.arguments });
|
|
221
|
+
}
|
|
222
|
+
break;
|
|
210
223
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
224
|
+
case "cost_update":
|
|
225
|
+
state.totalCost += event.cost;
|
|
226
|
+
state.totalInputTokens += event.inputTokens;
|
|
227
|
+
state.totalOutputTokens += event.outputTokens;
|
|
228
|
+
yield event;
|
|
229
|
+
break;
|
|
230
|
+
case "error":
|
|
231
|
+
yield event;
|
|
232
|
+
break;
|
|
215
233
|
}
|
|
216
|
-
case "cost_update":
|
|
217
|
-
state.totalCost += event.cost;
|
|
218
|
-
state.totalInputTokens += event.inputTokens;
|
|
219
|
-
state.totalOutputTokens += event.outputTokens;
|
|
220
|
-
yield event;
|
|
221
|
-
break;
|
|
222
|
-
case "error":
|
|
223
|
-
yield event;
|
|
224
|
-
break;
|
|
225
234
|
}
|
|
235
|
+
state.consecutiveErrors = 0;
|
|
226
236
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
237
|
+
catch (err) {
|
|
238
|
+
streamError = err instanceof Error ? err : new Error(String(err));
|
|
239
|
+
state.consecutiveErrors++;
|
|
240
|
+
// Circuit breaker
|
|
241
|
+
if (state.consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
|
|
242
|
+
yield {
|
|
243
|
+
type: "error",
|
|
244
|
+
message: `Too many consecutive errors (${state.consecutiveErrors}): ${streamError.message}`,
|
|
245
|
+
};
|
|
246
|
+
yield { type: "turn_complete", reason: "error" };
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
// Error recovery cascade
|
|
250
|
+
if (isRateLimitError(streamError) || isOverloadError(streamError)) {
|
|
251
|
+
const attempt = state.consecutiveErrors;
|
|
252
|
+
const isOverload = isOverloadError(streamError);
|
|
253
|
+
if (attempt <= MAX_RATE_LIMIT_RETRIES) {
|
|
254
|
+
const baseRetry = 2 ** attempt * (isOverload ? 2 : 1);
|
|
255
|
+
const retryIn = baseRetry * (0.5 + Math.random());
|
|
256
|
+
yield { type: "rate_limited", retryIn: Math.round(retryIn), attempt };
|
|
257
|
+
await new Promise((r) => setTimeout(r, retryIn * 1000));
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
yield {
|
|
261
|
+
type: "error",
|
|
262
|
+
message: `${isOverload ? "Server overloaded" : "Rate limit exceeded"} after ${MAX_RATE_LIMIT_RETRIES} retries.`,
|
|
263
|
+
};
|
|
264
|
+
yield { type: "turn_complete", reason: "error" };
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (isPromptTooLongError(streamError)) {
|
|
268
|
+
state.promptTooLongRetries = (state.promptTooLongRetries ?? 0) + 1;
|
|
269
|
+
if (state.promptTooLongRetries > 2) {
|
|
270
|
+
yield { type: "error", message: "Context still too long after 2 compression attempts." };
|
|
271
|
+
yield { type: "turn_complete", reason: "error" };
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
state.messages = compressMessages(state.messages, Math.floor(contextWindow * 0.5));
|
|
275
|
+
state.transition = "retry_prompt_too_long";
|
|
276
|
+
yield { type: "error", message: "Context too long, compressing history..." };
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (isNetworkError(streamError)) {
|
|
280
|
+
state.transition = "retry_network";
|
|
281
|
+
const delay = 1000 * 2 ** (state.consecutiveErrors - 1);
|
|
282
|
+
yield { type: "error", message: `Network error, retrying in ${delay / 1000}s...` };
|
|
283
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
yield { type: "error", message: streamError.message };
|
|
238
287
|
yield { type: "turn_complete", reason: "error" };
|
|
239
288
|
return;
|
|
240
289
|
}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
const baseRetry = 2 ** attempt * (isOverload ? 2 : 1);
|
|
247
|
-
const retryIn = baseRetry * (0.5 + Math.random());
|
|
248
|
-
yield { type: "rate_limited", retryIn: Math.round(retryIn), attempt };
|
|
249
|
-
await new Promise((r) => setTimeout(r, retryIn * 1000));
|
|
250
|
-
continue;
|
|
251
|
-
}
|
|
290
|
+
if (config.abortSignal?.aborted) {
|
|
291
|
+
yield { type: "turn_complete", reason: "aborted" };
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if (assistantContent === "" && toolCalls.length === 0) {
|
|
252
295
|
yield {
|
|
253
296
|
type: "error",
|
|
254
|
-
message:
|
|
297
|
+
message: "No response received. Check that your model server is running and the model name is correct.",
|
|
255
298
|
};
|
|
256
|
-
yield { type: "turn_complete", reason: "error" };
|
|
257
299
|
return;
|
|
258
300
|
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
yield { type: "turn_complete", reason: "error" };
|
|
264
|
-
return;
|
|
265
|
-
}
|
|
266
|
-
state.messages = compressMessages(state.messages, Math.floor(contextWindow * 0.5));
|
|
267
|
-
state.transition = "retry_prompt_too_long";
|
|
268
|
-
yield { type: "error", message: "Context too long, compressing history..." };
|
|
269
|
-
continue;
|
|
301
|
+
state.messages.push(createAssistantMessage(assistantContent, toolCalls.length > 0 ? toolCalls : undefined));
|
|
302
|
+
if (toolCalls.length === 0) {
|
|
303
|
+
yield { type: "turn_complete", reason: "completed" };
|
|
304
|
+
return;
|
|
270
305
|
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
306
|
+
// Collect streaming tool results
|
|
307
|
+
await streamingExecutor.waitForAll();
|
|
308
|
+
const completedResults = [...streamingExecutor.getCompletedResults()];
|
|
309
|
+
const executedIds = new Set(completedResults.map((r) => r.toolCall.id));
|
|
310
|
+
for (const { callId, chunk } of streamingExecutor.outputChunks) {
|
|
311
|
+
yield { type: "tool_output_delta", callId, chunk };
|
|
277
312
|
}
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
}
|
|
293
|
-
state.messages.push(createAssistantMessage(assistantContent, toolCalls.length > 0 ? toolCalls : undefined));
|
|
294
|
-
if (toolCalls.length === 0) {
|
|
295
|
-
yield { type: "turn_complete", reason: "completed" };
|
|
296
|
-
return;
|
|
297
|
-
}
|
|
298
|
-
// Collect streaming tool results
|
|
299
|
-
await streamingExecutor.waitForAll();
|
|
300
|
-
const completedResults = [...streamingExecutor.getCompletedResults()];
|
|
301
|
-
const executedIds = new Set(completedResults.map((r) => r.toolCall.id));
|
|
302
|
-
for (const { callId, chunk } of streamingExecutor.outputChunks) {
|
|
303
|
-
yield { type: "tool_output_delta", callId, chunk };
|
|
304
|
-
}
|
|
305
|
-
for (const { toolCall: tc, result } of completedResults) {
|
|
306
|
-
yield { type: "tool_call_end", callId: tc.id, output: result.output, isError: result.isError };
|
|
307
|
-
// Apply context budget to tool output
|
|
308
|
-
const budgetedOutput = contextManager.enforceToolBudget(tc.toolName, result.output);
|
|
309
|
-
state.messages.push(createToolResultMessage({ callId: tc.id, output: budgetedOutput, isError: result.isError }));
|
|
310
|
-
}
|
|
311
|
-
// Execute remaining tools not started during streaming
|
|
312
|
-
const remaining = toolCalls.filter((tc) => !executedIds.has(tc.id));
|
|
313
|
-
if (remaining.length > 0) {
|
|
314
|
-
yield* executeToolCalls(remaining, config.tools, toolContext, config.permissionMode, config.askUser, state, config.permissionPromptTool);
|
|
313
|
+
for (const { toolCall: tc, result } of completedResults) {
|
|
314
|
+
yield { type: "tool_call_end", callId: tc.id, output: result.output, isError: result.isError };
|
|
315
|
+
// Apply context budget to tool output
|
|
316
|
+
const budgetedOutput = contextManager.enforceToolBudget(tc.toolName, result.output);
|
|
317
|
+
state.messages.push(createToolResultMessage({ callId: tc.id, output: budgetedOutput, isError: result.isError }));
|
|
318
|
+
}
|
|
319
|
+
// Execute remaining tools not started during streaming
|
|
320
|
+
const remaining = toolCalls.filter((tc) => !executedIds.has(tc.id));
|
|
321
|
+
if (remaining.length > 0) {
|
|
322
|
+
yield* executeToolCalls(remaining, config.tools, toolContext, config.permissionMode, config.askUser, state, config.permissionPromptTool);
|
|
323
|
+
}
|
|
324
|
+
state.lastTurnHadTools = toolCalls.length > 0;
|
|
325
|
+
state.lastTurnToolCount = toolCalls.length;
|
|
326
|
+
state.transition = "next_turn";
|
|
315
327
|
}
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
328
|
+
yield { type: "turn_complete", reason: "max_turns" };
|
|
329
|
+
}
|
|
330
|
+
finally {
|
|
331
|
+
if (querySpanId)
|
|
332
|
+
config.tracer?.endSpan(querySpanId, "ok", { turns: state.turn });
|
|
319
333
|
}
|
|
320
|
-
yield { type: "turn_complete", reason: "max_turns" };
|
|
321
334
|
}
|
|
322
335
|
//# sourceMappingURL=index.js.map
|
package/dist/query/tools.js
CHANGED
|
@@ -216,6 +216,7 @@ export async function executeSingleTool(toolCall, tools, context, permissionMode
|
|
|
216
216
|
return { output: "Blocked by preToolUse hook.", isError: true };
|
|
217
217
|
}
|
|
218
218
|
// Execute with timeout and result budgeting
|
|
219
|
+
const toolSpanId = context.tracer?.startSpan(`tool:${tool.name}`, { riskLevel: tool.riskLevel }, context.parentSpanId);
|
|
219
220
|
try {
|
|
220
221
|
const toolAbort = AbortSignal.timeout(TOOL_TIMEOUT_MS);
|
|
221
222
|
const contextWithTimeout = { ...context, abortSignal: context.abortSignal ?? toolAbort };
|
|
@@ -225,6 +226,8 @@ export async function executeSingleTool(toolCall, tools, context, permissionMode
|
|
|
225
226
|
toolAbort.addEventListener("abort", () => reject(new Error(`Tool '${tool.name}' timed out after ${TOOL_TIMEOUT_MS / 1000}s`)));
|
|
226
227
|
}),
|
|
227
228
|
]);
|
|
229
|
+
if (toolSpanId)
|
|
230
|
+
context.tracer?.endSpan(toolSpanId, result.isError ? "error" : "ok");
|
|
228
231
|
// Hook: postToolUse / postToolUseFailure (mutually exclusive — strict CC parity)
|
|
229
232
|
if (result.isError) {
|
|
230
233
|
emitHook("postToolUseFailure", {
|
|
@@ -300,6 +303,8 @@ export async function executeSingleTool(toolCall, tools, context, permissionMode
|
|
|
300
303
|
catch (err) {
|
|
301
304
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
302
305
|
const errName = err instanceof Error ? err.name : "ExecutionError";
|
|
306
|
+
if (toolSpanId)
|
|
307
|
+
context.tracer?.endSpan(toolSpanId, "error", { error: errMsg });
|
|
303
308
|
emitHook("postToolUseFailure", {
|
|
304
309
|
toolName: tool.name,
|
|
305
310
|
toolArgs: JSON.stringify(toolCall.arguments).slice(0, 1000),
|
package/dist/query/types.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared types for the query loop sub-modules.
|
|
3
3
|
*/
|
|
4
|
+
import type { SessionTracer } from "../harness/traces.js";
|
|
4
5
|
import type { Provider } from "../providers/base.js";
|
|
5
6
|
import type { Tools } from "../Tool.js";
|
|
6
7
|
import type { Message } from "../types/message.js";
|
|
@@ -32,6 +33,8 @@ export type QueryConfig = {
|
|
|
32
33
|
* the tool is missing, throws, or returns malformed JSON.
|
|
33
34
|
*/
|
|
34
35
|
permissionPromptTool?: string;
|
|
36
|
+
/** Optional session tracer. When set, query() emits `query` and `tool:<Name>` spans. */
|
|
37
|
+
tracer?: SessionTracer;
|
|
35
38
|
};
|
|
36
39
|
export type TransitionReason = "next_turn" | "retry_network" | "retry_prompt_too_long" | "retry_max_output_tokens";
|
|
37
40
|
export type QueryLoopState = {
|
package/dist/repl.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Imperative REPL — extracted business logic from React REPL.tsx.
|
|
3
3
|
* Uses TerminalRenderer for display instead of Ink.
|
|
4
4
|
*/
|
|
5
|
+
import { readdirSync, statSync } from "node:fs";
|
|
5
6
|
import { homedir } from "node:os";
|
|
6
7
|
import { getCommandEntries } from "./commands/index.js";
|
|
7
8
|
import { roll } from "./cybergotchi/bones.js";
|
|
@@ -64,6 +65,26 @@ export async function startREPL(config) {
|
|
|
64
65
|
// Initialize checkpoints for file rewind
|
|
65
66
|
const { initCheckpoints } = await import("./harness/checkpoints.js");
|
|
66
67
|
initCheckpoints(session.id);
|
|
68
|
+
// Optional session-wide tracer. Opt-in via OH_TRACE=1 env var.
|
|
69
|
+
// Persists OTel-style spans to ~/.oh/traces/<sessionId>.jsonl.
|
|
70
|
+
// When OH_OTLP_ENDPOINT is also set, ships each ended span via fire-and-forget
|
|
71
|
+
// HTTP POST to the configured collector (Jaeger, Honeycomb, Grafana Tempo, etc.).
|
|
72
|
+
// OH_OTLP_HEADERS is a JSON-encoded headers object, e.g. '{"Authorization":"Bearer ..."}'.
|
|
73
|
+
let tracer;
|
|
74
|
+
if (process.env.OH_TRACE === "1") {
|
|
75
|
+
const { SessionTracer } = await import("./harness/traces.js");
|
|
76
|
+
const otlpEndpoint = process.env.OH_OTLP_ENDPOINT;
|
|
77
|
+
let otlpHeaders;
|
|
78
|
+
if (process.env.OH_OTLP_HEADERS) {
|
|
79
|
+
try {
|
|
80
|
+
otlpHeaders = JSON.parse(process.env.OH_OTLP_HEADERS);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
/* malformed JSON in env — skip headers, ship without auth */
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
tracer = new SessionTracer(session.id, otlpEndpoint ? { endpoint: otlpEndpoint, headers: otlpHeaders } : undefined);
|
|
87
|
+
}
|
|
67
88
|
// Start background cron executor
|
|
68
89
|
const { CronExecutor } = await import("./services/CronExecutor.js");
|
|
69
90
|
const cronExecutor = new CronExecutor(config.provider, config.tools, config.systemPrompt, config.permissionMode, config.model);
|
|
@@ -165,7 +186,6 @@ export async function startREPL(config) {
|
|
|
165
186
|
const dir = lastSep >= 0 ? expanded.slice(0, lastSep + 1) : ".";
|
|
166
187
|
const prefix = lastSep >= 0 ? expanded.slice(lastSep + 1) : expanded;
|
|
167
188
|
try {
|
|
168
|
-
const { readdirSync, statSync } = require("node:fs");
|
|
169
189
|
const entries = readdirSync(dir)
|
|
170
190
|
.filter((name) => name.toLowerCase().startsWith(prefix.toLowerCase()))
|
|
171
191
|
.slice(0, 10);
|
|
@@ -900,6 +920,7 @@ export async function startREPL(config) {
|
|
|
900
920
|
askUserQuestion,
|
|
901
921
|
model: currentModel || undefined,
|
|
902
922
|
abortSignal: abortController.signal,
|
|
923
|
+
tracer,
|
|
903
924
|
};
|
|
904
925
|
try {
|
|
905
926
|
for await (const event of query(prompt, queryConfig, messages)) {
|
|
@@ -161,6 +161,13 @@ export class AgentDispatcher {
|
|
|
161
161
|
if (filtered.length > 0)
|
|
162
162
|
taskTools = filtered;
|
|
163
163
|
}
|
|
164
|
+
// Plumb cwd through config.workingDir so parallel runTask calls don't
|
|
165
|
+
// race on the global process.cwd(). The query loop seeds ToolContext
|
|
166
|
+
// with this value; built-in tools (FileRead, Glob, Bash, …) honor it.
|
|
167
|
+
// Previously this method called `process.chdir(worktreePath)` and a
|
|
168
|
+
// matching `process.chdir(originalCwd)` in `finally` — but since
|
|
169
|
+
// `process.cwd()` is process-wide, two concurrent tasks would clobber
|
|
170
|
+
// each other's directory mid-execution.
|
|
164
171
|
const config = {
|
|
165
172
|
provider: this.provider,
|
|
166
173
|
tools: taskTools,
|
|
@@ -169,6 +176,7 @@ export class AgentDispatcher {
|
|
|
169
176
|
model: this.model,
|
|
170
177
|
maxTurns: 20,
|
|
171
178
|
abortSignal: this.abortSignal,
|
|
179
|
+
workingDir: worktreePath ?? cwd,
|
|
172
180
|
};
|
|
173
181
|
// Inject blocker results as context
|
|
174
182
|
let promptWithContext = task.prompt;
|
|
@@ -184,37 +192,16 @@ export class AgentDispatcher {
|
|
|
184
192
|
promptWithContext = `${blockerContext}\n\n---\n\n${task.prompt}`;
|
|
185
193
|
}
|
|
186
194
|
}
|
|
187
|
-
const originalCwd = process.cwd();
|
|
188
|
-
if (worktreePath) {
|
|
189
|
-
try {
|
|
190
|
-
process.chdir(worktreePath);
|
|
191
|
-
}
|
|
192
|
-
catch {
|
|
193
|
-
/* ignore */
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
195
|
let output = "";
|
|
197
196
|
let errorMessage = null;
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
break;
|
|
205
|
-
}
|
|
206
|
-
forwardChildEvent(event, taskCallId, this.emitChildEvent);
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
finally {
|
|
210
|
-
if (worktreePath) {
|
|
211
|
-
try {
|
|
212
|
-
process.chdir(originalCwd);
|
|
213
|
-
}
|
|
214
|
-
catch {
|
|
215
|
-
/* ignore */
|
|
216
|
-
}
|
|
197
|
+
for await (const event of query(promptWithContext, config)) {
|
|
198
|
+
if (event.type === "text_delta")
|
|
199
|
+
output += event.content;
|
|
200
|
+
if (event.type === "error") {
|
|
201
|
+
errorMessage = event.message;
|
|
202
|
+
break;
|
|
217
203
|
}
|
|
204
|
+
forwardChildEvent(event, taskCallId, this.emitChildEvent);
|
|
218
205
|
}
|
|
219
206
|
if (errorMessage !== null) {
|
|
220
207
|
result = { id: task.id, output: `Error: ${errorMessage}`, isError: true, durationMs: Date.now() - start };
|