@zhijiewang/openharness 2.28.0 → 2.30.0
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/info.js +28 -0
- package/dist/harness/traces.d.ts +8 -1
- package/dist/harness/traces.js +18 -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 +21 -0
- package/dist/services/StreamingToolExecutor.js +5 -0
- package/dist/tools/AgentTool/index.js +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -21,7 +21,7 @@ AI coding agent in your terminal. Works with any LLM -- free local models or clo
|
|
|
21
21
|
<img src="assets/openharness_v0.11.1_4.gif" alt="OpenHarness demo" width="800" />
|
|
22
22
|
</p>
|
|
23
23
|
|
|
24
|
-
[](https://www.npmjs.com/package/@zhijiewang/openharness) [](https://www.npmjs.com/package/@zhijiewang/openharness) [](LICENSE) ](https://www.npmjs.com/package/@zhijiewang/openharness) [](https://www.npmjs.com/package/@zhijiewang/openharness) [](LICENSE)     [](https://github.com/zhijiewong/openharness) [](https://github.com/zhijiewong/openharness/issues) [](https://github.com/zhijiewong/openharness/pulls)
|
|
25
25
|
|
|
26
26
|
**English** | [简体中文](README.zh-CN.md)
|
|
27
27
|
|
|
@@ -32,7 +32,7 @@ AI coding agent in your terminal. Works with any LLM -- free local models or clo
|
|
|
32
32
|
- [Quick Start](#quick-start)
|
|
33
33
|
- [Why OpenHarness?](#why-openharness)
|
|
34
34
|
- [Terminal UI](#terminal-ui)
|
|
35
|
-
- [Tools (
|
|
35
|
+
- [Tools (44)](#tools-43)
|
|
36
36
|
- [Slash Commands](#slash-commands)
|
|
37
37
|
- [Permission Modes](#permission-modes)
|
|
38
38
|
- [Hooks](#hooks)
|
|
@@ -114,8 +114,11 @@ Scrolling is handled by the terminal's native scrollbar. Completed messages flow
|
|
|
114
114
|
- **Syntax highlighting** — keywords, strings, comments, numbers, types (JS/TS/Python/Rust/Go and 20+ languages)
|
|
115
115
|
- **Collapsible code blocks** — blocks over 8 lines auto-collapse; `Ctrl+K` to expand all
|
|
116
116
|
- **Collapsible thinking** — thinking blocks collapse to a one-line summary after completion; `Ctrl+O` to expand
|
|
117
|
-
- **Shimmer spinner** — animated
|
|
118
|
-
- **Tool call display** — args preview, live streaming output, result summaries (line counts, elapsed time), expand/collapse with `Tab
|
|
117
|
+
- **Shimmer spinner** — animated indicator with stage label (`Thinking`, `Running <Tool>`, `Calling <server>:<tool>`, `Running N tools`) and color transitions (magenta → yellow at 30s → red at 60s)
|
|
118
|
+
- **Tool call display** — args preview, live streaming output, result summaries (line counts, elapsed time), expand/collapse with `Tab`. Tool name color-coded by category (read tools cyan, mutating tools yellow, exec tools magenta, MCP tools green)
|
|
119
|
+
- **Rich tool output** — JSON files render as a colored static tree (depth-3 collapse, line truncation); markdown files render with full styling (headings, code blocks, tables) instead of plain split-on-newline. Renderer dispatches via `outputType` field stamped by FileReadTool / WebFetchTool, with a heuristic fallback for unstamped tools
|
|
120
|
+
- **Nested tool calls** — when `Agent` or `ParallelAgents` spawns inner tool calls (Read, Bash, Edit), the children render indented under their spawning parent. ParallelAgents shows per-task `Task` wrapper rows so child calls group by task instead of flat under the bundled parent. Depth-3 indent limit with `… (N more level)` collapse marker
|
|
121
|
+
- **Multi-line input wrap glyph** — every non-last line of a multi-line input ends with a dim `↵` continuation marker so the wrap is visually obvious
|
|
119
122
|
- **Permission prompts** — bordered box with risk coloring, bold colored **Y**es/**N**o/**D**iff keys, syntax-highlighted inline diffs
|
|
120
123
|
- **Status line** — model name, token count, cost, context usage bar (customizable via config)
|
|
121
124
|
- **Context warning** — yellow alert when context window exceeds 75%
|
|
@@ -146,7 +149,7 @@ statusLineFormat: '{model} │ {tokens} │ {cost} │ {ctx}'
|
|
|
146
149
|
|
|
147
150
|
Available variables: `{model}`, `{tokens}` (input↑ output↓), `{cost}` ($X.XXXX), `{ctx}` (context usage bar). Empty sections are automatically collapsed.
|
|
148
151
|
|
|
149
|
-
## Tools (
|
|
152
|
+
## Tools (44)
|
|
150
153
|
|
|
151
154
|
| Tool | Risk | Description |
|
|
152
155
|
|------|------|-------------|
|
package/README.zh-CN.md
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
<img src="assets/openharness_v0.11.1_4.gif" alt="OpenHarness demo" width="800" />
|
|
22
22
|
</p>
|
|
23
23
|
|
|
24
|
-
[](https://www.npmjs.com/package/@zhijiewang/openharness) [](https://www.npmjs.com/package/@zhijiewang/openharness) [](LICENSE) ](https://www.npmjs.com/package/@zhijiewang/openharness) [](https://www.npmjs.com/package/@zhijiewang/openharness) [](LICENSE)     [](https://github.com/zhijiewong/openharness) [](https://github.com/zhijiewong/openharness/issues) [](https://github.com/zhijiewong/openharness/pulls)
|
|
25
25
|
|
|
26
26
|
[English](README.md) | **简体中文**
|
|
27
27
|
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
- [快速开始](#快速开始)
|
|
33
33
|
- [为什么选择 OpenHarness?](#为什么选择-openharness)
|
|
34
34
|
- [终端界面](#终端界面)
|
|
35
|
-
- [工具(
|
|
35
|
+
- [工具(44 个)](#工具43-个)
|
|
36
36
|
- [斜杠命令](#斜杠命令)
|
|
37
37
|
- [权限模式](#权限模式)
|
|
38
38
|
- [钩子](#钩子)
|
|
@@ -114,8 +114,11 @@ OpenHarness 采用受 Ink/Claude Code 默认模式启发的顺序式终端渲染
|
|
|
114
114
|
- **语法高亮** —— 关键字、字符串、注释、数字、类型(支持 JS/TS/Python/Rust/Go 等 20+ 种语言)
|
|
115
115
|
- **可折叠代码块** —— 超过 8 行的代码块会自动折叠;按 `Ctrl+K` 全部展开
|
|
116
116
|
- **可折叠思考块** —— 思考块在完成后会折叠为一行摘要;按 `Ctrl+O` 展开
|
|
117
|
-
- **流光加载动画** ——
|
|
118
|
-
- **工具调用显示** —— 参数预览、实时流式输出、结果摘要(行数、耗时),按 `Tab`
|
|
117
|
+
- **流光加载动画** —— 带阶段标签(`Thinking`、`Running <Tool>`、`Calling <server>:<tool>`、`Running N tools`)和颜色过渡的指示器(30 秒后洋红 → 黄,60 秒后 → 红)
|
|
118
|
+
- **工具调用显示** —— 参数预览、实时流式输出、结果摘要(行数、耗时),按 `Tab` 展开/折叠。工具名称按类别着色(读取类青色、修改类黄色、执行类品红色、MCP 类绿色)
|
|
119
|
+
- **富工具输出** —— JSON 文件以彩色静态树形渲染(深度 3 级折叠、行数截断);Markdown 文件完整渲染样式(标题、代码块、表格),不再是普通的按行拆分。渲染器通过 `outputType` 字段分发(FileReadTool / WebFetchTool 会标注),未标注的工具走启发式回退路径
|
|
120
|
+
- **嵌套工具调用** —— 当 `Agent` 或 `ParallelAgents` 派生内层工具调用(Read、Bash、Edit)时,子调用会缩进显示在派生它的父调用之下。ParallelAgents 还会显示每个任务的 `Task` 包装行,使子调用按任务分组,而不是平铺在合并的父调用之下。深度 3 级缩进上限,超过显示 `… (N more level)` 折叠标记
|
|
121
|
+
- **多行输入折行符** —— 多行输入的每一非末尾行都以暗色 `↵` 续行符结尾,使折行视觉清晰
|
|
119
122
|
- **权限提示** —— 带边框的提示框,按风险级别着色,醒目的 **Y**es/**N**o/**D**iff 按键,内联 diff 带语法高亮
|
|
120
123
|
- **状态栏** —— 显示模型名称、token 计数、费用、上下文占用条(可通过配置自定义)
|
|
121
124
|
- **上下文告警** —— 上下文窗口超过 75% 时显示黄色警告
|
|
@@ -146,7 +149,7 @@ statusLineFormat: '{model} │ {tokens} │ {cost} │ {ctx}'
|
|
|
146
149
|
|
|
147
150
|
可用变量:`{model}`、`{tokens}`(输入↑ 输出↓)、`{cost}`($X.XXXX)、`{ctx}`(上下文占用条)。空片段会自动折叠。
|
|
148
151
|
|
|
149
|
-
## 工具(
|
|
152
|
+
## 工具(44 个)
|
|
150
153
|
|
|
151
154
|
| 工具 | 风险 | 描述 |
|
|
152
155
|
|------|------|-------------|
|
package/dist/Tool.d.ts
CHANGED
|
@@ -29,6 +29,10 @@ export type ToolContext = {
|
|
|
29
29
|
gitCommitPerTool?: boolean;
|
|
30
30
|
/** Forward an inner-query tool event to the outer event stream, stamped with the parent's callId. Used by AgentTool and AgentDispatcher to surface nested tool calls. */
|
|
31
31
|
emitChildEvent?: (event: ToolCallStart | ToolCallComplete | ToolCallEnd | ToolOutputDelta) => void;
|
|
32
|
+
/** Optional session tracer for OTel-style span emission around tool execution. */
|
|
33
|
+
tracer?: import("./harness/traces.js").SessionTracer;
|
|
34
|
+
/** Optional parent span ID for the current tool execution (set by query loop). */
|
|
35
|
+
parentSpanId?: string;
|
|
32
36
|
};
|
|
33
37
|
export type Tool<Input extends z.ZodType = z.ZodType> = {
|
|
34
38
|
readonly name: string;
|
package/dist/commands/info.js
CHANGED
|
@@ -11,6 +11,7 @@ import { getContextWindow } from "../harness/cost.js";
|
|
|
11
11
|
import { getHooks, invalidateHookCache } from "../harness/hooks.js";
|
|
12
12
|
import { discoverPlugins, discoverSkills } from "../harness/plugins.js";
|
|
13
13
|
import { invalidateSandboxCache } from "../harness/sandbox.js";
|
|
14
|
+
import { formatTrace, listTracedSessions, loadTrace } from "../harness/traces.js";
|
|
14
15
|
import { invalidateVerificationCache } from "../harness/verification.js";
|
|
15
16
|
import { normalizeMcpConfig } from "../mcp/config-normalize.js";
|
|
16
17
|
import { connectedMcpServers, disconnectMcpClients, loadMcpTools } from "../mcp/loader.js";
|
|
@@ -47,6 +48,7 @@ export function registerInfoCommands(register, getCommandMap) {
|
|
|
47
48
|
"memory",
|
|
48
49
|
"doctor",
|
|
49
50
|
"hooks",
|
|
51
|
+
"traces",
|
|
50
52
|
"context",
|
|
51
53
|
"mcp",
|
|
52
54
|
"mcp-login",
|
|
@@ -358,6 +360,32 @@ export function registerInfoCommands(register, getCommandMap) {
|
|
|
358
360
|
register("hooks", "List loaded hooks grouped by event", () => {
|
|
359
361
|
return { output: formatHooksReport(getHooks()), handled: true };
|
|
360
362
|
});
|
|
363
|
+
register("traces", "List sessions with persisted OTel-style traces (or show one with /traces <sessionId>)", (args) => {
|
|
364
|
+
const id = args.trim();
|
|
365
|
+
if (id) {
|
|
366
|
+
const spans = loadTrace(id);
|
|
367
|
+
if (spans.length === 0)
|
|
368
|
+
return { output: `No trace found for session ${id}.`, handled: true };
|
|
369
|
+
return { output: formatTrace(spans), handled: true };
|
|
370
|
+
}
|
|
371
|
+
const sessions = listTracedSessions();
|
|
372
|
+
if (sessions.length === 0) {
|
|
373
|
+
return {
|
|
374
|
+
output: "No persisted traces. Tracing is opt-in — start oh with OH_TRACE=1 to record spans to ~/.oh/traces/.",
|
|
375
|
+
handled: true,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
const lines = [`${sessions.length} session(s) with traces (most recent first):`, ""];
|
|
379
|
+
for (const sid of sessions.slice(0, 20)) {
|
|
380
|
+
const spans = loadTrace(sid);
|
|
381
|
+
const totalMs = spans.reduce((sum, s) => sum + s.durationMs, 0);
|
|
382
|
+
const errors = spans.filter((s) => s.status === "error").length;
|
|
383
|
+
const errSuffix = errors > 0 ? ` ${errors} error(s)` : "";
|
|
384
|
+
lines.push(` ${sid} ${spans.length} spans, ${totalMs}ms total${errSuffix}`);
|
|
385
|
+
}
|
|
386
|
+
lines.push("", "Run `/traces <sessionId>` to see the full span tree.");
|
|
387
|
+
return { output: lines.join("\n"), handled: true };
|
|
388
|
+
});
|
|
361
389
|
register("context", "Show context window usage breakdown", (_args, ctx) => {
|
|
362
390
|
const ctxWindow = getContextWindow(ctx.model);
|
|
363
391
|
let userTokens = 0, assistantTokens = 0, toolTokens = 0, systemTokens = 0;
|
package/dist/harness/traces.d.ts
CHANGED
|
@@ -22,16 +22,23 @@ export type TraceEvent = {
|
|
|
22
22
|
timestamp: number;
|
|
23
23
|
attributes?: Record<string, unknown>;
|
|
24
24
|
};
|
|
25
|
+
export type OTLPConfig = {
|
|
26
|
+
endpoint: string;
|
|
27
|
+
headers?: Record<string, string>;
|
|
28
|
+
};
|
|
25
29
|
export declare class SessionTracer {
|
|
26
30
|
private sessionId;
|
|
27
31
|
private spans;
|
|
28
32
|
private activeSpans;
|
|
29
33
|
private spanCounter;
|
|
30
|
-
|
|
34
|
+
private otlp?;
|
|
35
|
+
constructor(sessionId: string, otlp?: OTLPConfig);
|
|
31
36
|
/** Start a new span. Returns the span ID. */
|
|
32
37
|
startSpan(name: string, attributes?: Record<string, unknown>, parentSpanId?: string): string;
|
|
33
38
|
/** End a span and record it. */
|
|
34
39
|
endSpan(spanId: string, status?: "ok" | "error", extraAttributes?: Record<string, unknown>): TraceSpan | null;
|
|
40
|
+
/** Fire-and-forget POST of a single span to the configured OTLP HTTP endpoint. Errors swallowed — telemetry must never crash the agent. */
|
|
41
|
+
private shipSpanOTLP;
|
|
35
42
|
/** Get all completed spans */
|
|
36
43
|
getSpans(): TraceSpan[];
|
|
37
44
|
/** Get a summary of the trace */
|
package/dist/harness/traces.js
CHANGED
|
@@ -18,8 +18,10 @@ export class SessionTracer {
|
|
|
18
18
|
spans = [];
|
|
19
19
|
activeSpans = new Map();
|
|
20
20
|
spanCounter = 0;
|
|
21
|
-
|
|
21
|
+
otlp;
|
|
22
|
+
constructor(sessionId, otlp) {
|
|
22
23
|
this.sessionId = sessionId;
|
|
24
|
+
this.otlp = otlp;
|
|
23
25
|
}
|
|
24
26
|
/** Start a new span. Returns the span ID. */
|
|
25
27
|
startSpan(name, attributes = {}, parentSpanId) {
|
|
@@ -50,8 +52,23 @@ export class SessionTracer {
|
|
|
50
52
|
this.spans = this.spans.slice(-MAX_IN_MEMORY_SPANS);
|
|
51
53
|
}
|
|
52
54
|
this.persistSpan(span);
|
|
55
|
+
if (this.otlp)
|
|
56
|
+
this.shipSpanOTLP(span);
|
|
53
57
|
return span;
|
|
54
58
|
}
|
|
59
|
+
/** Fire-and-forget POST of a single span to the configured OTLP HTTP endpoint. Errors swallowed — telemetry must never crash the agent. */
|
|
60
|
+
shipSpanOTLP(span) {
|
|
61
|
+
if (!this.otlp)
|
|
62
|
+
return;
|
|
63
|
+
const payload = exportTraceOTLP(this.sessionId, [span]);
|
|
64
|
+
fetch(this.otlp.endpoint, {
|
|
65
|
+
method: "POST",
|
|
66
|
+
headers: { "Content-Type": "application/json", ...(this.otlp.headers ?? {}) },
|
|
67
|
+
body: JSON.stringify(payload),
|
|
68
|
+
}).catch(() => {
|
|
69
|
+
/* swallow — telemetry must not interfere with the agent */
|
|
70
|
+
});
|
|
71
|
+
}
|
|
55
72
|
/** Get all completed spans */
|
|
56
73
|
getSpans() {
|
|
57
74
|
return [...this.spans];
|
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
|
@@ -64,6 +64,26 @@ export async function startREPL(config) {
|
|
|
64
64
|
// Initialize checkpoints for file rewind
|
|
65
65
|
const { initCheckpoints } = await import("./harness/checkpoints.js");
|
|
66
66
|
initCheckpoints(session.id);
|
|
67
|
+
// Optional session-wide tracer. Opt-in via OH_TRACE=1 env var.
|
|
68
|
+
// Persists OTel-style spans to ~/.oh/traces/<sessionId>.jsonl.
|
|
69
|
+
// When OH_OTLP_ENDPOINT is also set, ships each ended span via fire-and-forget
|
|
70
|
+
// HTTP POST to the configured collector (Jaeger, Honeycomb, Grafana Tempo, etc.).
|
|
71
|
+
// OH_OTLP_HEADERS is a JSON-encoded headers object, e.g. '{"Authorization":"Bearer ..."}'.
|
|
72
|
+
let tracer;
|
|
73
|
+
if (process.env.OH_TRACE === "1") {
|
|
74
|
+
const { SessionTracer } = await import("./harness/traces.js");
|
|
75
|
+
const otlpEndpoint = process.env.OH_OTLP_ENDPOINT;
|
|
76
|
+
let otlpHeaders;
|
|
77
|
+
if (process.env.OH_OTLP_HEADERS) {
|
|
78
|
+
try {
|
|
79
|
+
otlpHeaders = JSON.parse(process.env.OH_OTLP_HEADERS);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
/* malformed JSON in env — skip headers, ship without auth */
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
tracer = new SessionTracer(session.id, otlpEndpoint ? { endpoint: otlpEndpoint, headers: otlpHeaders } : undefined);
|
|
86
|
+
}
|
|
67
87
|
// Start background cron executor
|
|
68
88
|
const { CronExecutor } = await import("./services/CronExecutor.js");
|
|
69
89
|
const cronExecutor = new CronExecutor(config.provider, config.tools, config.systemPrompt, config.permissionMode, config.model);
|
|
@@ -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)) {
|
|
@@ -94,8 +94,11 @@ export class StreamingToolExecutor {
|
|
|
94
94
|
this.outputChunks.push({ callId: id, chunk });
|
|
95
95
|
},
|
|
96
96
|
};
|
|
97
|
+
const toolSpanId = callContext.tracer?.startSpan(`tool:${tool.name}`, { riskLevel: tool.riskLevel }, callContext.parentSpanId);
|
|
97
98
|
try {
|
|
98
99
|
tracked.result = await tool.call(parsed.data, callContext);
|
|
100
|
+
if (toolSpanId)
|
|
101
|
+
callContext.tracer?.endSpan(toolSpanId, tracked.result.isError ? "error" : "ok");
|
|
99
102
|
// Verification loop: auto-run lint/typecheck after file-modifying tools
|
|
100
103
|
if (tracked.result && !tracked.result.isError && ["Edit", "Write", "MultiEdit"].includes(tool.name)) {
|
|
101
104
|
try {
|
|
@@ -132,6 +135,8 @@ export class StreamingToolExecutor {
|
|
|
132
135
|
output: `Error: ${err instanceof Error ? err.message : String(err)}`,
|
|
133
136
|
isError: true,
|
|
134
137
|
};
|
|
138
|
+
if (toolSpanId)
|
|
139
|
+
callContext.tracer?.endSpan(toolSpanId, "error", { error: tracked.result.output });
|
|
135
140
|
}
|
|
136
141
|
tracked.status = "completed";
|
|
137
142
|
this.processQueue(); // Process next queued tools
|
|
@@ -165,10 +165,10 @@ export const AgentTool = {
|
|
|
165
165
|
}
|
|
166
166
|
else if (event.type === "tool_output_delta") {
|
|
167
167
|
outputChunks.push(event.chunk);
|
|
168
|
-
|
|
168
|
+
const forwarded = forwardInnerEvent(event, context);
|
|
169
|
+
if (!forwarded && context.onOutputChunk && context.callId) {
|
|
169
170
|
context.onOutputChunk(context.callId, event.chunk);
|
|
170
171
|
}
|
|
171
|
-
forwardInnerEvent(event, context);
|
|
172
172
|
}
|
|
173
173
|
else if (event.type === "tool_call_start" ||
|
|
174
174
|
event.type === "tool_call_complete" ||
|