@trim21/personal-pi-extensions 0.1.633 → 0.1.635
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 +1 -1
- package/src/gh/base.ts +13 -141
- package/src/gh/tools/watch-run.ts +2 -1
- package/src/lib/gh-process.ts +157 -0
- package/src/lib/github.ts +4 -35
- package/src/spawn-agent-progress.ts +214 -0
- package/src/spawn-agent.ts +11 -132
package/package.json
CHANGED
package/src/gh/base.ts
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared infrastructure of the GitHub read-only tools:
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* Shared infrastructure of the GitHub read-only tools: result shaping, the
|
|
3
|
+
* octokit-backed client, and the checks-wait pipeline (running a `gh` process
|
|
4
|
+
* itself lives in `lib/gh-process.ts`, which also owns the proxy env shared by
|
|
5
|
+
* the subprocess and octokit). Each tool lives in `tools/<tool-name>.ts`;
|
|
6
|
+
* anything used by more than one tool belongs here.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
|
-
import { spawn } from "node:child_process";
|
|
9
9
|
import { existsSync } from "node:fs";
|
|
10
10
|
import { delimiter, join } from "node:path";
|
|
11
11
|
|
|
12
12
|
import { Type } from "typebox";
|
|
13
13
|
import { Value } from "typebox/value";
|
|
14
14
|
|
|
15
|
+
import { type GhResult, httpProxy, runGh } from "../lib/gh-process.js";
|
|
15
16
|
import {
|
|
16
17
|
type ActionJob,
|
|
17
18
|
type CheckRun,
|
|
@@ -19,17 +20,11 @@ import {
|
|
|
19
20
|
createGithubChecks,
|
|
20
21
|
createGithubSearch,
|
|
21
22
|
type GithubChecksClient,
|
|
23
|
+
type GithubClientOptions,
|
|
22
24
|
type GithubSearch,
|
|
23
25
|
renderHits,
|
|
24
26
|
} from "../lib/github.js";
|
|
25
27
|
import { type ToolPendant } from "../lib/pendant.js";
|
|
26
|
-
import { createHttpProxy } from "../lib/proxy.js";
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* 代理配置(~/.pi/agent/proxy.json,回退到 HTTP(S)_PROXY 环境变量)在本模块内共享:
|
|
30
|
-
* `gh` 子进程与 octokit 请求都从这里取,配置只在首次使用时读一次。
|
|
31
|
-
*/
|
|
32
|
-
export const httpProxy = createHttpProxy();
|
|
33
28
|
|
|
34
29
|
/** A tool result: what the model sees plus the structured details payload. */
|
|
35
30
|
export interface ToolResult {
|
|
@@ -46,18 +41,6 @@ export interface ToolCall<Params> {
|
|
|
46
41
|
onUpdate?: (update: ToolResult) => void;
|
|
47
42
|
}
|
|
48
43
|
|
|
49
|
-
export interface GhResult {
|
|
50
|
-
stdout: string;
|
|
51
|
-
stderr: string;
|
|
52
|
-
code: number;
|
|
53
|
-
killed: boolean;
|
|
54
|
-
combined: string;
|
|
55
|
-
/** Why the process was killed, when `killed` is true. */
|
|
56
|
-
reason?: "timeout" | "abort";
|
|
57
|
-
/** When the process could not be started at all (e.g. `gh` not found in PATH). */
|
|
58
|
-
spawnError?: string;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
44
|
/**
|
|
62
45
|
* Check whether the `gh` CLI is on the system, scanning PATH like
|
|
63
46
|
* `findDefaultBwrap`. The extension registers no tools when `gh` is missing, so
|
|
@@ -78,120 +61,6 @@ export function isGhAvailable(): boolean {
|
|
|
78
61
|
return false;
|
|
79
62
|
}
|
|
80
63
|
|
|
81
|
-
export function runGh(
|
|
82
|
-
args: string[],
|
|
83
|
-
ctx: {
|
|
84
|
-
cwd?: string;
|
|
85
|
-
signal?: AbortSignal;
|
|
86
|
-
timeout?: number;
|
|
87
|
-
/** 追加到子进程环境变量(覆盖进程环境与代理配置),供测试或调用方定制。 */
|
|
88
|
-
env?: NodeJS.ProcessEnv;
|
|
89
|
-
},
|
|
90
|
-
): Promise<GhResult> {
|
|
91
|
-
return new Promise((resolve) => {
|
|
92
|
-
const proc = spawn("gh", args, {
|
|
93
|
-
cwd: ctx.cwd,
|
|
94
|
-
shell: false,
|
|
95
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
96
|
-
// gh 是 Go 程序,只认环境变量形式的代理配置;ctx.env 最后合并,调用方可覆盖。
|
|
97
|
-
env: { ...process.env, ...httpProxy.env, ...ctx.env, GH_PAGER: "cat" },
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
let stdout = "";
|
|
101
|
-
let stderr = "";
|
|
102
|
-
const combined: string[] = [];
|
|
103
|
-
let killed = false;
|
|
104
|
-
let killReason: "timeout" | "abort" | undefined;
|
|
105
|
-
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
106
|
-
let onAbort: (() => void) | undefined;
|
|
107
|
-
|
|
108
|
-
const killProcess = (reason: "timeout" | "abort") => {
|
|
109
|
-
if (killed) {
|
|
110
|
-
return;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
killed = true;
|
|
114
|
-
killReason = reason;
|
|
115
|
-
proc.kill("SIGTERM");
|
|
116
|
-
setTimeout(() => {
|
|
117
|
-
if (!proc.killed) {
|
|
118
|
-
proc.kill("SIGKILL");
|
|
119
|
-
}
|
|
120
|
-
}, 5000);
|
|
121
|
-
};
|
|
122
|
-
|
|
123
|
-
if (ctx.signal) {
|
|
124
|
-
onAbort = () => killProcess("abort");
|
|
125
|
-
if (ctx.signal.aborted) {
|
|
126
|
-
killProcess("abort");
|
|
127
|
-
} else {
|
|
128
|
-
ctx.signal.addEventListener("abort", onAbort, { once: true });
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// Default timeout: 10 minutes. Long operations like downloading a CI job's
|
|
133
|
-
// full log routinely take well over 30s, so a short default would kill them
|
|
134
|
-
// mid-transfer; combined with `code ?? 0` that would silently cache a
|
|
135
|
-
// truncated log as success. A killed process must never look successful.
|
|
136
|
-
const timeout = ctx.timeout ?? 600_000;
|
|
137
|
-
if (timeout > 0) {
|
|
138
|
-
timeoutId = setTimeout(() => killProcess("timeout"), timeout);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
proc.stdout.on("data", (data: Buffer) => {
|
|
142
|
-
const text = data.toString();
|
|
143
|
-
stdout += text;
|
|
144
|
-
combined.push(text);
|
|
145
|
-
});
|
|
146
|
-
proc.stderr.on("data", (data: Buffer) => {
|
|
147
|
-
const text = data.toString();
|
|
148
|
-
stderr += text;
|
|
149
|
-
combined.push(text);
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
proc.on("close", (code) => {
|
|
153
|
-
if (timeoutId) {
|
|
154
|
-
clearTimeout(timeoutId);
|
|
155
|
-
}
|
|
156
|
-
if (onAbort && ctx.signal) {
|
|
157
|
-
ctx.signal.removeEventListener("abort", onAbort);
|
|
158
|
-
}
|
|
159
|
-
resolve({
|
|
160
|
-
stdout,
|
|
161
|
-
stderr,
|
|
162
|
-
// When killed by a signal the close event's code is null — including
|
|
163
|
-
// kills we did not initiate. Report failure instead of pretending it
|
|
164
|
-
// succeeded. -1 is a sentinel for "did not exit normally" — distinct
|
|
165
|
-
// from a real gh failure exit code (1), which is always in 0-255.
|
|
166
|
-
code: code ?? -1,
|
|
167
|
-
killed,
|
|
168
|
-
combined: combined.join(""),
|
|
169
|
-
reason: killReason,
|
|
170
|
-
});
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
proc.on("error", (err: Error) => {
|
|
174
|
-
if (timeoutId) {
|
|
175
|
-
clearTimeout(timeoutId);
|
|
176
|
-
}
|
|
177
|
-
if (onAbort && ctx.signal) {
|
|
178
|
-
ctx.signal.removeEventListener("abort", onAbort);
|
|
179
|
-
}
|
|
180
|
-
// spawn 失败(如 gh 不在 PATH → ENOENT、cwd 不存在)时进程从未启动,
|
|
181
|
-
// 没有任何 stdout/stderr;把底层错误带上,否则会退化成无信息的 "exit code 1"。
|
|
182
|
-
resolve({
|
|
183
|
-
stdout,
|
|
184
|
-
stderr,
|
|
185
|
-
code: 1,
|
|
186
|
-
killed,
|
|
187
|
-
combined: combined.join(""),
|
|
188
|
-
reason: killReason,
|
|
189
|
-
spawnError: err.message,
|
|
190
|
-
});
|
|
191
|
-
});
|
|
192
|
-
});
|
|
193
|
-
}
|
|
194
|
-
|
|
195
64
|
/**
|
|
196
65
|
* Error thrown by `ghExec` when the `gh` invocation exits non-zero.
|
|
197
66
|
* The message carries the toolcall input (JSON) wrapped in `<input>` markers,
|
|
@@ -474,10 +343,13 @@ export class GhClient {
|
|
|
474
343
|
readonly search: GithubSearch;
|
|
475
344
|
readonly checks: GithubChecksClient;
|
|
476
345
|
|
|
477
|
-
constructor(
|
|
346
|
+
constructor(
|
|
347
|
+
fetchImpl: typeof globalThis.fetch = httpProxy.fetch,
|
|
348
|
+
options: Pick<GithubClientOptions, "token"> = {},
|
|
349
|
+
) {
|
|
478
350
|
this.fetch = fetchImpl;
|
|
479
|
-
this.search = createGithubSearch({ fetch: fetchImpl });
|
|
480
|
-
this.checks = createGithubChecks({ fetch: fetchImpl });
|
|
351
|
+
this.search = createGithubSearch({ fetch: fetchImpl, ...options });
|
|
352
|
+
this.checks = createGithubChecks({ fetch: fetchImpl, ...options });
|
|
481
353
|
}
|
|
482
354
|
}
|
|
483
355
|
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { runGh } from "../../lib/gh-process.js";
|
|
5
|
+
import { type GhClient, repoArgs, subtitlePendant } from "../base.js";
|
|
5
6
|
|
|
6
7
|
export function addWatchRunTool(_gh: GhClient, pi: ExtensionAPI) {
|
|
7
8
|
pi.registerTool({
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `gh` subprocess adapter: start a `gh` process, collect its output, apply the
|
|
3
|
+
* shared timeout / kill semantics, and expose the proxy layer the subprocess
|
|
4
|
+
* inherits. It lives in `lib/` so both the gh tool layer (`src/gh/`) and the
|
|
5
|
+
* octokit clients (`lib/github.ts`) can depend on it without a cycle.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
|
|
10
|
+
import { createHttpProxy } from "./proxy.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 代理配置(~/.pi/agent/proxy.json,回退到 HTTP(S)_PROXY 环境变量)在本模块内共享:
|
|
14
|
+
* `gh` 子进程与 octokit 请求都从这里取,配置只在首次使用时读一次。
|
|
15
|
+
*/
|
|
16
|
+
export const httpProxy = createHttpProxy();
|
|
17
|
+
|
|
18
|
+
export interface GhResult {
|
|
19
|
+
stdout: string;
|
|
20
|
+
stderr: string;
|
|
21
|
+
code: number;
|
|
22
|
+
killed: boolean;
|
|
23
|
+
combined: string;
|
|
24
|
+
/** Why the process was killed, when `killed` is true. */
|
|
25
|
+
reason?: "timeout" | "abort";
|
|
26
|
+
/** When the process could not be started at all (e.g. `gh` not found in PATH). */
|
|
27
|
+
spawnError?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface GhRunContext {
|
|
31
|
+
cwd?: string;
|
|
32
|
+
signal?: AbortSignal;
|
|
33
|
+
timeout?: number;
|
|
34
|
+
/** 追加到子进程环境变量(覆盖进程环境与代理配置),供测试或调用方定制。 */
|
|
35
|
+
env?: NodeJS.ProcessEnv;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function runGh(args: string[], ctx: GhRunContext): Promise<GhResult> {
|
|
39
|
+
return new Promise((resolve) => {
|
|
40
|
+
const proc = spawn("gh", args, {
|
|
41
|
+
cwd: ctx.cwd,
|
|
42
|
+
shell: false,
|
|
43
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
44
|
+
// gh 是 Go 程序,只认环境变量形式的代理配置;ctx.env 最后合并,调用方可覆盖。
|
|
45
|
+
env: { ...process.env, ...httpProxy.env, ...ctx.env, GH_PAGER: "cat" },
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
let stdout = "";
|
|
49
|
+
let stderr = "";
|
|
50
|
+
const combined: string[] = [];
|
|
51
|
+
let killed = false;
|
|
52
|
+
let killReason: "timeout" | "abort" | undefined;
|
|
53
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
54
|
+
let onAbort: (() => void) | undefined;
|
|
55
|
+
|
|
56
|
+
const killProcess = (reason: "timeout" | "abort") => {
|
|
57
|
+
if (killed) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
killed = true;
|
|
62
|
+
killReason = reason;
|
|
63
|
+
proc.kill("SIGTERM");
|
|
64
|
+
setTimeout(() => {
|
|
65
|
+
if (!proc.killed) {
|
|
66
|
+
proc.kill("SIGKILL");
|
|
67
|
+
}
|
|
68
|
+
}, 5000);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
if (ctx.signal) {
|
|
72
|
+
onAbort = () => killProcess("abort");
|
|
73
|
+
if (ctx.signal.aborted) {
|
|
74
|
+
killProcess("abort");
|
|
75
|
+
} else {
|
|
76
|
+
ctx.signal.addEventListener("abort", onAbort, { once: true });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Default timeout: 10 minutes. Long operations like downloading a CI job's
|
|
81
|
+
// full log routinely take well over 30s, so a short default would kill them
|
|
82
|
+
// mid-transfer; combined with `code ?? 0` that would silently cache a
|
|
83
|
+
// truncated log as success. A killed process must never look successful.
|
|
84
|
+
const timeout = ctx.timeout ?? 600_000;
|
|
85
|
+
if (timeout > 0) {
|
|
86
|
+
timeoutId = setTimeout(() => killProcess("timeout"), timeout);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
proc.stdout.on("data", (data: Buffer) => {
|
|
90
|
+
const text = data.toString();
|
|
91
|
+
stdout += text;
|
|
92
|
+
combined.push(text);
|
|
93
|
+
});
|
|
94
|
+
proc.stderr.on("data", (data: Buffer) => {
|
|
95
|
+
const text = data.toString();
|
|
96
|
+
stderr += text;
|
|
97
|
+
combined.push(text);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
proc.on("close", (code) => {
|
|
101
|
+
if (timeoutId) {
|
|
102
|
+
clearTimeout(timeoutId);
|
|
103
|
+
}
|
|
104
|
+
if (onAbort && ctx.signal) {
|
|
105
|
+
ctx.signal.removeEventListener("abort", onAbort);
|
|
106
|
+
}
|
|
107
|
+
resolve({
|
|
108
|
+
stdout,
|
|
109
|
+
stderr,
|
|
110
|
+
// When killed by a signal the close event's code is null — including
|
|
111
|
+
// kills we did not initiate. Report failure instead of pretending it
|
|
112
|
+
// succeeded. -1 is a sentinel for "did not exit normally" — distinct
|
|
113
|
+
// from a real gh failure exit code (1), which is always in 0-255.
|
|
114
|
+
code: code ?? -1,
|
|
115
|
+
killed,
|
|
116
|
+
combined: combined.join(""),
|
|
117
|
+
reason: killReason,
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
proc.on("error", (err: Error) => {
|
|
122
|
+
if (timeoutId) {
|
|
123
|
+
clearTimeout(timeoutId);
|
|
124
|
+
}
|
|
125
|
+
if (onAbort && ctx.signal) {
|
|
126
|
+
ctx.signal.removeEventListener("abort", onAbort);
|
|
127
|
+
}
|
|
128
|
+
// spawn 失败(如 gh 不在 PATH → ENOENT、cwd 不存在)时进程从未启动,
|
|
129
|
+
// 没有任何 stdout/stderr;把底层错误带上,否则会退化成无信息的 "exit code 1"。
|
|
130
|
+
resolve({
|
|
131
|
+
stdout,
|
|
132
|
+
stderr,
|
|
133
|
+
code: 1,
|
|
134
|
+
killed,
|
|
135
|
+
combined: combined.join(""),
|
|
136
|
+
reason: killReason,
|
|
137
|
+
spawnError: err.message,
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Read the logged-in token from the system `gh` CLI (`gh auth token`). */
|
|
144
|
+
export async function ghAuthToken(): Promise<string> {
|
|
145
|
+
const result = await runGh(["auth", "token"], { timeout: 10_000 });
|
|
146
|
+
if (result.spawnError) {
|
|
147
|
+
throw new Error(`failed to start gh: ${result.spawnError}`);
|
|
148
|
+
}
|
|
149
|
+
const token = result.stdout.trim();
|
|
150
|
+
if (token && result.code === 0) {
|
|
151
|
+
return token;
|
|
152
|
+
}
|
|
153
|
+
throw new Error(
|
|
154
|
+
result.stderr.trim() ||
|
|
155
|
+
`gh auth token exited with code ${result.code} — run "gh auth login" first`,
|
|
156
|
+
);
|
|
157
|
+
}
|
package/src/lib/github.ts
CHANGED
|
@@ -8,11 +8,10 @@
|
|
|
8
8
|
* rendered state is derived from `pull_request.merged_at`.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { spawn } from "node:child_process";
|
|
12
|
-
|
|
13
11
|
import { Octokit } from "octokit";
|
|
14
12
|
import { type Static, Type } from "typebox";
|
|
15
13
|
|
|
14
|
+
import { ghAuthToken } from "./gh-process.js";
|
|
16
15
|
import { parseWithSchema } from "./parse-with-schema.js";
|
|
17
16
|
|
|
18
17
|
export type SearchKind = "issue" | "pr";
|
|
@@ -218,38 +217,6 @@ function normalize(raw: SearchItem): SearchHit {
|
|
|
218
217
|
};
|
|
219
218
|
}
|
|
220
219
|
|
|
221
|
-
function ghAuthToken(): Promise<string> {
|
|
222
|
-
return new Promise((resolve, reject) => {
|
|
223
|
-
const proc = spawn("gh", ["auth", "token"], { stdio: ["ignore", "pipe", "pipe"] });
|
|
224
|
-
let stdout = "";
|
|
225
|
-
let stderr = "";
|
|
226
|
-
const timer = setTimeout(() => proc.kill("SIGTERM"), 10_000);
|
|
227
|
-
proc.stdout.on("data", (d: Buffer) => {
|
|
228
|
-
stdout += String(d);
|
|
229
|
-
});
|
|
230
|
-
proc.stderr.on("data", (d: Buffer) => {
|
|
231
|
-
stderr += String(d);
|
|
232
|
-
});
|
|
233
|
-
proc.on("error", (err) => {
|
|
234
|
-
clearTimeout(timer);
|
|
235
|
-
reject(new Error(`failed to start gh: ${err.message}`));
|
|
236
|
-
});
|
|
237
|
-
proc.on("close", (code) => {
|
|
238
|
-
clearTimeout(timer);
|
|
239
|
-
const token = stdout.trim();
|
|
240
|
-
if (code === 0 && token) {
|
|
241
|
-
resolve(token);
|
|
242
|
-
} else {
|
|
243
|
-
reject(
|
|
244
|
-
new Error(
|
|
245
|
-
stderr.trim() || `gh auth token exited with code ${code} — run "gh auth login" first`,
|
|
246
|
-
),
|
|
247
|
-
);
|
|
248
|
-
}
|
|
249
|
-
});
|
|
250
|
-
});
|
|
251
|
-
}
|
|
252
|
-
|
|
253
220
|
/**
|
|
254
221
|
* Error thrown when the GitHub search API rejects the request. Carries the
|
|
255
222
|
* original toolcall params so the model can see the exact input.
|
|
@@ -291,6 +258,8 @@ export interface GithubClientOptions {
|
|
|
291
258
|
* proxy dispatcher attached. Defaults to the global fetch.
|
|
292
259
|
*/
|
|
293
260
|
fetch?: typeof globalThis.fetch;
|
|
261
|
+
/** gh token provider;缺省读系统 `gh auth token`(见 lib/gh-process.ts)。 */
|
|
262
|
+
token?: () => Promise<string>;
|
|
294
263
|
}
|
|
295
264
|
|
|
296
265
|
/**
|
|
@@ -304,7 +273,7 @@ function createGithubApi(options: GithubClientOptions = {}): GithubApi {
|
|
|
304
273
|
|
|
305
274
|
async function getClient(): Promise<Octokit> {
|
|
306
275
|
client ??= new Octokit({
|
|
307
|
-
auth: await ghAuthToken(),
|
|
276
|
+
auth: await (options.token ?? ghAuthToken)(),
|
|
308
277
|
...(options.fetch && { request: { fetch: options.fetch } }),
|
|
309
278
|
});
|
|
310
279
|
return client;
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Progress state machine for the `spawn_agent` tool.
|
|
3
|
+
*
|
|
4
|
+
* The parent sees a rolling log: `tool: <name>` lines for tool calls and
|
|
5
|
+
* `text: <content>` lines for completed text blocks, keeping the last
|
|
6
|
+
* `MAX_PROGRESS_LINES` lines. Consecutive tool calls are merged into a single
|
|
7
|
+
* `tool:` line (`read x 2, glob`) and the merged content is folded to the
|
|
8
|
+
* first/last 9 chars joined by `…`, so a burst of tool calls or a long text
|
|
9
|
+
* block does not flood the window. Only a text block starts a new line —
|
|
10
|
+
* thinking is a transient status line, so it does not break the merge across
|
|
11
|
+
* turn boundaries. Line content is sanitized first: markdown marker characters
|
|
12
|
+
* are stripped and whitespace (including newlines) is collapsed to single
|
|
13
|
+
* spaces, so one log entry is always exactly one rendered line.
|
|
14
|
+
*
|
|
15
|
+
* The rendered panel is the window, then an optional transient
|
|
16
|
+
* `thinking ( N chars )` line while the model is thinking, then a fixed footer
|
|
17
|
+
* line: the subagent name as a code span (`` `scout` ``) followed by the live
|
|
18
|
+
* usage stats when there are any. The thinking line and the footer ride
|
|
19
|
+
* outside the rolling window, so the footer is never trimmed. The model name
|
|
20
|
+
* on the footer is the model the session actually uses, which differs from the
|
|
21
|
+
* frontmatter string when the declared model resolved to nothing and the SDK
|
|
22
|
+
* fell back to a default.
|
|
23
|
+
*
|
|
24
|
+
* State is held in the closure of a `createSubagentProgress` instance, one per
|
|
25
|
+
* run. The `noteX` methods only mutate state and return nothing; pushing the
|
|
26
|
+
* rendered text to the host (`onUpdate`) is the caller's job, so the caller
|
|
27
|
+
* decides when a state change is worth a refresh.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Progress log keeps only the most recent lines (rolling window).
|
|
32
|
+
* 展开后面板最多 6 行内容:4 行日志 + 可能的一行瞬态 thinking + 固定的 metadata 行。
|
|
33
|
+
*/
|
|
34
|
+
const MAX_PROGRESS_LINES = 4;
|
|
35
|
+
/** Progress line content (without the `tool:` / `text:` prefix) is capped at 21 chars; longer text is folded to the first/last 9 chars joined by ` … `. */
|
|
36
|
+
const MAX_PROGRESS_CHARS_PER_LINE = 21;
|
|
37
|
+
/**
|
|
38
|
+
* 进度内容会被 pi 按 markdown 渲染,这些标记字符会改变显示效果(代码块、粗体、
|
|
39
|
+
* 链接、标题等),因此在进日志前统一删掉。
|
|
40
|
+
*/
|
|
41
|
+
const PROGRESS_MARKDOWN_MARKERS_RE = /[`*_~[\]<>#|]/g;
|
|
42
|
+
|
|
43
|
+
/** 进度面板要展示的运行统计(`runAgent` 的 `result.usage`)。 */
|
|
44
|
+
export interface ProgressUsage {
|
|
45
|
+
turns: number;
|
|
46
|
+
cost: number;
|
|
47
|
+
contextTokens: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 一次子代理运行的进度状态机:改状态用 `noteX`,取文本用 `render`。 */
|
|
51
|
+
export interface SubagentProgress {
|
|
52
|
+
/** 工具调用开始:合并进当前 `tool:` 行。 */
|
|
53
|
+
noteToolCall(rawName: string): void;
|
|
54
|
+
/** 一个完成的 text 块:作为 `text:` 行写入并打断工具合并。 */
|
|
55
|
+
noteTextBlock(content: string): void;
|
|
56
|
+
/** thinking 开始:打开瞬态行(计数从 0 开始)。 */
|
|
57
|
+
thinkingStart(): void;
|
|
58
|
+
/** thinking 增量:累加字符数。 */
|
|
59
|
+
thinkingDelta(length: number): void;
|
|
60
|
+
/** thinking 结束:关闭瞬态行。 */
|
|
61
|
+
thinkingEnd(): void;
|
|
62
|
+
/** 渲染面板:滚动窗口 + 瞬态 thinking 行 + footer。 */
|
|
63
|
+
render(usage: ProgressUsage, model?: string): string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Fold over-long progress line content: keep the first/last 9 chars joined by
|
|
68
|
+
* ` … ` (space, ellipsis, space), so the folded line never exceeds
|
|
69
|
+
* `MAX_PROGRESS_CHARS_PER_LINE` chars (9 + 3 + 9 = 21). Shorter text is
|
|
70
|
+
* returned as-is.
|
|
71
|
+
*/
|
|
72
|
+
function foldProgressLine(text: string): string {
|
|
73
|
+
if (text.length <= MAX_PROGRESS_CHARS_PER_LINE) {
|
|
74
|
+
return text;
|
|
75
|
+
}
|
|
76
|
+
const keep = Math.floor((MAX_PROGRESS_CHARS_PER_LINE - 3) / 2);
|
|
77
|
+
return `${text.slice(0, keep)} … ${text.slice(-keep)}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 进度行是「单行内容 + markdown 渲染」:内容里的换行会打乱按行滚动的窗口,
|
|
82
|
+
* markdown 标记会改变渲染效果。先删掉标记字符,再把换行/制表符/连续空格折成
|
|
83
|
+
* 单个空格并去掉首尾空白,保证一条日志恒为一行。
|
|
84
|
+
*/
|
|
85
|
+
function sanitizeProgressLine(text: string): string {
|
|
86
|
+
return text.replaceAll(PROGRESS_MARKDOWN_MARKERS_RE, "").replaceAll(/\s+/g, " ").trim();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* token 计数的可读格式。进度面板 footer 与 spawn_agent 的输出截断提示共用,
|
|
91
|
+
* 改动会同时影响两处。
|
|
92
|
+
*/
|
|
93
|
+
export function formatTokens(count: number): string {
|
|
94
|
+
if (count < 1000) {
|
|
95
|
+
return count.toString();
|
|
96
|
+
}
|
|
97
|
+
if (count < 10_000) {
|
|
98
|
+
return `${(count / 1000).toFixed(1)}k`;
|
|
99
|
+
}
|
|
100
|
+
if (count < 1_000_000) {
|
|
101
|
+
return `${Math.round(count / 1000)}k`;
|
|
102
|
+
}
|
|
103
|
+
return `${(count / 1_000_000).toFixed(1)}M`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function formatUsageStats(usage: ProgressUsage, model?: string): string {
|
|
107
|
+
const parts: string[] = [];
|
|
108
|
+
if (usage.turns) {
|
|
109
|
+
parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
|
|
110
|
+
}
|
|
111
|
+
if (usage.cost) {
|
|
112
|
+
parts.push(`$${usage.cost.toFixed(4)}`);
|
|
113
|
+
}
|
|
114
|
+
if (usage.contextTokens > 0) {
|
|
115
|
+
parts.push(`ctx:${formatTokens(usage.contextTokens)}`);
|
|
116
|
+
}
|
|
117
|
+
if (model) {
|
|
118
|
+
parts.push(model);
|
|
119
|
+
}
|
|
120
|
+
return parts.join(" ");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function toolSegment(name: string, count: number): string {
|
|
124
|
+
return count > 1 ? `${name} x ${count}` : name;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Create the progress state machine for one subagent run.
|
|
129
|
+
*
|
|
130
|
+
* `maxLines` only controls the rolling window of log lines — the transient
|
|
131
|
+
* thinking line and the footer ride outside it. Defaults to
|
|
132
|
+
* `MAX_PROGRESS_LINES`.
|
|
133
|
+
*/
|
|
134
|
+
export function createSubagentProgress(options: {
|
|
135
|
+
/** 面板标题(子代理名)。 */
|
|
136
|
+
name: string;
|
|
137
|
+
/** 滚动窗口行数,缺省 4。 */
|
|
138
|
+
maxLines?: number;
|
|
139
|
+
}): SubagentProgress {
|
|
140
|
+
const maxLines = options.maxLines ?? MAX_PROGRESS_LINES;
|
|
141
|
+
let logLines: string[] = [];
|
|
142
|
+
// 工具调用行合并:连续的 tool_execution_start 事件合并在同一 `tool:` 行
|
|
143
|
+
// (如 `tool: read x 2, glob`),相同工具名连续出现时计为 `name x N`,
|
|
144
|
+
// 不同名按调用顺序罗列;只有写入日志行的事件(text 块)打断合并,thinking
|
|
145
|
+
// 不写日志行、也不打断合并。
|
|
146
|
+
let toolLineSegments: string[] = [];
|
|
147
|
+
let toolLine: { name: string; count: number } | undefined;
|
|
148
|
+
// 思考中状态:thinkingStart 打开、thinkingDelta 累计字符数、thinkingEnd
|
|
149
|
+
// 关闭;非 undefined 时 render 在 footer 上方插一行瞬态 thinking 状态。
|
|
150
|
+
let thinkingChars: number | undefined;
|
|
151
|
+
|
|
152
|
+
function pushLogLine(line: string) {
|
|
153
|
+
logLines.push(line);
|
|
154
|
+
if (logLines.length > maxLines) {
|
|
155
|
+
logLines = logLines.slice(-maxLines);
|
|
156
|
+
}
|
|
157
|
+
// 写进日志的新行都会打断工具调用合并,下一批调用另起一行。
|
|
158
|
+
toolLine = undefined;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function appendToolLine(rawName: string) {
|
|
162
|
+
const name = sanitizeProgressLine(rawName);
|
|
163
|
+
const firstInBatch = toolLine === undefined;
|
|
164
|
+
if (toolLine === undefined) {
|
|
165
|
+
toolLineSegments = [];
|
|
166
|
+
toolLine = { name, count: 1 };
|
|
167
|
+
} else if (toolLine.name === name) {
|
|
168
|
+
toolLine.count++;
|
|
169
|
+
} else {
|
|
170
|
+
toolLineSegments.push(toolSegment(toolLine.name, toolLine.count));
|
|
171
|
+
toolLine = { name, count: 1 };
|
|
172
|
+
}
|
|
173
|
+
const parts = [...toolLineSegments, toolSegment(toolLine.name, toolLine.count)].join(", ");
|
|
174
|
+
const line = `tool: ${foldProgressLine(parts)}`;
|
|
175
|
+
if (firstInBatch) {
|
|
176
|
+
logLines.push(line);
|
|
177
|
+
if (logLines.length > maxLines) {
|
|
178
|
+
logLines = logLines.slice(-maxLines);
|
|
179
|
+
}
|
|
180
|
+
} else {
|
|
181
|
+
logLines[logLines.length - 1] = line;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
noteToolCall: appendToolLine,
|
|
187
|
+
noteTextBlock(content) {
|
|
188
|
+
pushLogLine(`text: ${foldProgressLine(sanitizeProgressLine(content))}`);
|
|
189
|
+
},
|
|
190
|
+
thinkingStart() {
|
|
191
|
+
thinkingChars = 0;
|
|
192
|
+
},
|
|
193
|
+
thinkingDelta(length) {
|
|
194
|
+
thinkingChars = (thinkingChars ?? 0) + length;
|
|
195
|
+
},
|
|
196
|
+
thinkingEnd() {
|
|
197
|
+
thinkingChars = undefined;
|
|
198
|
+
},
|
|
199
|
+
render(usage, model) {
|
|
200
|
+
// 最后一行固定是「子代理名 + 运行中统计」:名字用 code span 标出,进度流里
|
|
201
|
+
// 一眼能看出属于哪个 subagent;usage 与它同行,TUI 始终能看到实时 token 开销。
|
|
202
|
+
// 这行位于滚动窗口之外,因此永远不会被挤掉。
|
|
203
|
+
const usageLine = formatUsageStats(usage, model);
|
|
204
|
+
const name = sanitizeProgressLine(options.name);
|
|
205
|
+
const footer = usageLine ? `\`${name}\` ${usageLine}` : `\`${name}\``;
|
|
206
|
+
const lines = [...logLines];
|
|
207
|
+
if (thinkingChars !== undefined) {
|
|
208
|
+
lines.push(`thinking ( ${thinkingChars} chars )`);
|
|
209
|
+
}
|
|
210
|
+
lines.push(footer);
|
|
211
|
+
return lines.join("\n");
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|
package/src/spawn-agent.ts
CHANGED
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
* returns its final output to the parent model. Progress is streamed through
|
|
13
13
|
* `onUpdate`, the same channel the built-in bash tool uses for live output.
|
|
14
14
|
* Progress is a rolling log: `tool: <name>` lines for tool calls and
|
|
15
|
-
* `text: <content>` lines for completed text blocks, keeping the last
|
|
16
|
-
* `MAX_PROGRESS_LINES`
|
|
15
|
+
* `text: <content>` lines for completed text blocks, keeping only the last few
|
|
16
|
+
* lines (`MAX_PROGRESS_LINES` in spawn-agent-progress.ts). Consecutive tool calls are merged into a
|
|
17
17
|
* single `tool:` line (`read x 2, glob`) and over-long line content is
|
|
18
18
|
* folded to the first/last 9 chars joined by `…`, so a burst of tool calls
|
|
19
19
|
* or a long text block does not flood the window; only a text block starts
|
|
@@ -79,6 +79,7 @@ import {
|
|
|
79
79
|
formatAgentList,
|
|
80
80
|
loadSpawnAgentConfig,
|
|
81
81
|
} from "./spawn-agent-agents.js";
|
|
82
|
+
import { createSubagentProgress, formatTokens } from "./spawn-agent-progress.js";
|
|
82
83
|
|
|
83
84
|
// ── constants ────────────────────────────────────────────────────────────────
|
|
84
85
|
|
|
@@ -96,18 +97,6 @@ export const SUBAGENT_DEFAULT_SANDBOX: BwrapConfig = completeBwrapConfig({
|
|
|
96
97
|
fs: { mode: "readonly" },
|
|
97
98
|
network: { mode: "block" },
|
|
98
99
|
});
|
|
99
|
-
/**
|
|
100
|
-
* Progress log keeps only the most recent lines (rolling window).
|
|
101
|
-
* 展开后面板最多 6 行内容:4 行日志 + 可能的一行瞬态 thinking + 固定的 metadata 行。
|
|
102
|
-
*/
|
|
103
|
-
const MAX_PROGRESS_LINES = 4;
|
|
104
|
-
/** Progress line content (without the `tool:` / `text:` prefix) is capped at 21 chars; longer text is folded to the first/last 9 chars joined by ` … `. */
|
|
105
|
-
const MAX_PROGRESS_CHARS_PER_LINE = 21;
|
|
106
|
-
/**
|
|
107
|
-
* 进度内容会被 pi 按 markdown 渲染,这些标记字符会改变显示效果(代码块、粗体、
|
|
108
|
-
* 链接、标题等),因此在进日志前统一删掉。
|
|
109
|
-
*/
|
|
110
|
-
const PROGRESS_MARKDOWN_MARKERS_RE = /[`*_~[\]<>#|]/g;
|
|
111
100
|
/** 错误消息里 stderr 的展示上限。 */
|
|
112
101
|
const MAX_STDERR_ERROR_BYTES = 4 * 1024;
|
|
113
102
|
/** 全局默认配置:~/.pi/agent/spawn-agent.json,字段可被 frontmatter 覆盖。 */
|
|
@@ -231,59 +220,6 @@ export function formatSubagentError(result: SubagentResult): { reason: string; m
|
|
|
231
220
|
return { reason, message: parts.length > 0 ? parts.join("\n") : "(no output)" };
|
|
232
221
|
}
|
|
233
222
|
|
|
234
|
-
/**
|
|
235
|
-
* Fold over-long progress line content: keep the first/last 9 chars joined by
|
|
236
|
-
* ` … ` (space, ellipsis, space), so the folded line never exceeds
|
|
237
|
-
* `MAX_PROGRESS_CHARS_PER_LINE` chars (9 + 3 + 9 = 21). Shorter text is
|
|
238
|
-
* returned as-is.
|
|
239
|
-
*/
|
|
240
|
-
function foldProgressLine(text: string): string {
|
|
241
|
-
if (text.length <= MAX_PROGRESS_CHARS_PER_LINE) {
|
|
242
|
-
return text;
|
|
243
|
-
}
|
|
244
|
-
const keep = Math.floor((MAX_PROGRESS_CHARS_PER_LINE - 3) / 2);
|
|
245
|
-
return `${text.slice(0, keep)} … ${text.slice(-keep)}`;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
/**
|
|
249
|
-
* 进度行是「单行内容 + markdown 渲染」:内容里的换行会打乱按行滚动的窗口,
|
|
250
|
-
* markdown 标记会改变渲染效果。先删掉标记字符,再把换行/制表符/连续空格折成
|
|
251
|
-
* 单个空格并去掉首尾空白,保证一条日志恒为一行。
|
|
252
|
-
*/
|
|
253
|
-
function sanitizeProgressLine(text: string): string {
|
|
254
|
-
return text.replaceAll(PROGRESS_MARKDOWN_MARKERS_RE, "").replaceAll(/\s+/g, " ").trim();
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
function formatTokens(count: number): string {
|
|
258
|
-
if (count < 1000) {
|
|
259
|
-
return count.toString();
|
|
260
|
-
}
|
|
261
|
-
if (count < 10_000) {
|
|
262
|
-
return `${(count / 1000).toFixed(1)}k`;
|
|
263
|
-
}
|
|
264
|
-
if (count < 1_000_000) {
|
|
265
|
-
return `${Math.round(count / 1000)}k`;
|
|
266
|
-
}
|
|
267
|
-
return `${(count / 1_000_000).toFixed(1)}M`;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
function formatUsageStats(usage: UsageStats, model?: string): string {
|
|
271
|
-
const parts: string[] = [];
|
|
272
|
-
if (usage.turns) {
|
|
273
|
-
parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
|
|
274
|
-
}
|
|
275
|
-
if (usage.cost) {
|
|
276
|
-
parts.push(`$${usage.cost.toFixed(4)}`);
|
|
277
|
-
}
|
|
278
|
-
if (usage.contextTokens > 0) {
|
|
279
|
-
parts.push(`ctx:${formatTokens(usage.contextTokens)}`);
|
|
280
|
-
}
|
|
281
|
-
if (model) {
|
|
282
|
-
parts.push(model);
|
|
283
|
-
}
|
|
284
|
-
return parts.join(" ");
|
|
285
|
-
}
|
|
286
|
-
|
|
287
223
|
/** 子 agent 结果的折叠面板 markdown:父 agent 的 prompt 与父 agent 看到的结果。 */
|
|
288
224
|
function formatPendantMarkdown(task: string, response: string): string {
|
|
289
225
|
return `# prompt:\n${task.trim()}\n# response\n${response.trim()}`;
|
|
@@ -375,10 +311,6 @@ export function resolveModel(
|
|
|
375
311
|
|
|
376
312
|
type OnUpdateCallback = (partial: AgentToolResult<SubagentDetails>) => void;
|
|
377
313
|
|
|
378
|
-
function toolSegment(name: string, count: number): string {
|
|
379
|
-
return count > 1 ? `${name} x ${count}` : name;
|
|
380
|
-
}
|
|
381
|
-
|
|
382
314
|
/** The subset of AgentSession runAgent relies on (injectable for tests). */
|
|
383
315
|
export interface SubagentSession {
|
|
384
316
|
agent: { state: { messages: AgentMessage[] } };
|
|
@@ -478,65 +410,11 @@ export async function runAgent(
|
|
|
478
410
|
// fallback 到默认模型,回显配置原文会掩盖这次 fallback。
|
|
479
411
|
result.model = session.model?.id;
|
|
480
412
|
|
|
481
|
-
|
|
482
|
-
// 工具调用行合并:连续的 tool_execution_start 事件合并在同一 `tool:` 行
|
|
483
|
-
// (如 `tool: read x 2, glob`),相同工具名连续出现时计为 `name x N`,
|
|
484
|
-
// 不同名按调用顺序罗列;只有写入日志行的事件(text 块)打断合并,thinking
|
|
485
|
-
// 不写日志行、也不打断合并。
|
|
486
|
-
let toolLineSegments: string[] = [];
|
|
487
|
-
let toolLine: { name: string; count: number } | undefined;
|
|
488
|
-
// 思考中状态:thinking_start 打开、thinking_delta 累计字符数、thinking_end
|
|
489
|
-
// 关闭;非 undefined 时 emitUpdate 在 footer 上方插一行瞬态 thinking 状态。
|
|
490
|
-
let thinkingChars: number | undefined;
|
|
491
|
-
|
|
492
|
-
const pushLogLine = (line: string) => {
|
|
493
|
-
logLines.push(line);
|
|
494
|
-
if (logLines.length > MAX_PROGRESS_LINES) {
|
|
495
|
-
logLines = logLines.slice(-MAX_PROGRESS_LINES);
|
|
496
|
-
}
|
|
497
|
-
// 写进日志的新行都会打断工具调用合并,下一批调用另起一行。
|
|
498
|
-
toolLine = undefined;
|
|
499
|
-
};
|
|
500
|
-
|
|
501
|
-
const appendToolLine = (rawName: string) => {
|
|
502
|
-
const name = sanitizeProgressLine(rawName);
|
|
503
|
-
const firstInBatch = toolLine === undefined;
|
|
504
|
-
if (toolLine === undefined) {
|
|
505
|
-
toolLineSegments = [];
|
|
506
|
-
toolLine = { name, count: 1 };
|
|
507
|
-
} else if (toolLine.name === name) {
|
|
508
|
-
toolLine.count++;
|
|
509
|
-
} else {
|
|
510
|
-
toolLineSegments.push(toolSegment(toolLine.name, toolLine.count));
|
|
511
|
-
toolLine = { name, count: 1 };
|
|
512
|
-
}
|
|
513
|
-
const parts = [...toolLineSegments, toolSegment(toolLine.name, toolLine.count)].join(", ");
|
|
514
|
-
const line = `tool: ${foldProgressLine(parts)}`;
|
|
515
|
-
if (firstInBatch) {
|
|
516
|
-
logLines.push(line);
|
|
517
|
-
if (logLines.length > MAX_PROGRESS_LINES) {
|
|
518
|
-
logLines = logLines.slice(-MAX_PROGRESS_LINES);
|
|
519
|
-
}
|
|
520
|
-
} else {
|
|
521
|
-
logLines[logLines.length - 1] = line;
|
|
522
|
-
}
|
|
523
|
-
emitUpdate();
|
|
524
|
-
};
|
|
413
|
+
const progress = createSubagentProgress({ name: result.agent });
|
|
525
414
|
|
|
526
415
|
const emitUpdate = () => {
|
|
527
|
-
// 最后一行固定是「子代理名 + 运行中统计」:名字用 code span 标出,进度流里
|
|
528
|
-
// 一眼能看出属于哪个 subagent;usage 与它同行,TUI 始终能看到实时 token 开销。
|
|
529
|
-
// 这行位于滚动窗口之外,因此永远不会被挤掉。
|
|
530
|
-
const usageLine = formatUsageStats(result.usage, result.model);
|
|
531
|
-
const name = sanitizeProgressLine(result.agent);
|
|
532
|
-
const footer = usageLine ? `\`${name}\` ${usageLine}` : `\`${name}\``;
|
|
533
|
-
const lines = [...logLines];
|
|
534
|
-
if (thinkingChars !== undefined) {
|
|
535
|
-
lines.push(`thinking ( ${thinkingChars} chars )`);
|
|
536
|
-
}
|
|
537
|
-
lines.push(footer);
|
|
538
416
|
onUpdate?.({
|
|
539
|
-
content: [{ type: "text", text:
|
|
417
|
+
content: [{ type: "text", text: progress.render(result.usage, result.model) }],
|
|
540
418
|
details: {},
|
|
541
419
|
});
|
|
542
420
|
};
|
|
@@ -550,24 +428,24 @@ export async function runAgent(
|
|
|
550
428
|
const delta = event.assistantMessageEvent;
|
|
551
429
|
switch (delta.type) {
|
|
552
430
|
case "text_end": {
|
|
553
|
-
|
|
431
|
+
progress.noteTextBlock(delta.content);
|
|
554
432
|
emitUpdate();
|
|
555
433
|
break;
|
|
556
434
|
}
|
|
557
435
|
case "thinking_start": {
|
|
558
436
|
// thinking 不产生日志行,因此不打断工具行合并:跨轮次的连续工具调用
|
|
559
437
|
// 仍累加到同一 `tool:` 行。
|
|
560
|
-
|
|
438
|
+
progress.thinkingStart();
|
|
561
439
|
emitUpdate();
|
|
562
440
|
break;
|
|
563
441
|
}
|
|
564
442
|
case "thinking_delta": {
|
|
565
|
-
|
|
443
|
+
progress.thinkingDelta(delta.delta.length);
|
|
566
444
|
emitUpdate();
|
|
567
445
|
break;
|
|
568
446
|
}
|
|
569
447
|
case "thinking_end": {
|
|
570
|
-
|
|
448
|
+
progress.thinkingEnd();
|
|
571
449
|
emitUpdate();
|
|
572
450
|
break;
|
|
573
451
|
}
|
|
@@ -577,7 +455,8 @@ export async function runAgent(
|
|
|
577
455
|
break;
|
|
578
456
|
}
|
|
579
457
|
case "tool_execution_start": {
|
|
580
|
-
|
|
458
|
+
progress.noteToolCall(event.toolName);
|
|
459
|
+
emitUpdate();
|
|
581
460
|
break;
|
|
582
461
|
}
|
|
583
462
|
case "message_end": {
|