fix: 增加 runs events 低噪声 summary

This commit is contained in:
Codex
2026-06-09 22:53:42 +08:00
parent 5919a3c737
commit 3850604c2b
3 changed files with 216 additions and 5 deletions
+5 -2
View File
@@ -36,7 +36,7 @@ CLI 官方 TypeScript 入口固定为 `scripts/agentrun-cli.ts`。在 G14 非交
```bash ```bash
./scripts/agentrun runs create --json-file <run.json> ./scripts/agentrun runs create --json-file <run.json>
./scripts/agentrun runs show <runId> ./scripts/agentrun runs show <runId>
./scripts/agentrun runs events <runId> --after-seq <n> --limit <n> ./scripts/agentrun runs events <runId> --after-seq <n> --limit <n> [--summary|--tail-summary] [--tail <n>] [--summary-chars <n>] [--format json|tsv]
./scripts/agentrun runs result <runId> [--command-id <commandId>] ./scripts/agentrun runs result <runId> [--command-id <commandId>]
./scripts/agentrun runs cancel <runId> [--reason <text>] ./scripts/agentrun runs cancel <runId> [--reason <text>]
./scripts/agentrun commands create <runId> --type turn|steer|interrupt --json-file <payload.json> ./scripts/agentrun commands create <runId> --type turn|steer|interrupt --json-file <payload.json>
@@ -84,7 +84,10 @@ CLI 官方 TypeScript 入口固定为 `scripts/agentrun-cli.ts`。在 G14 非交
- `runner start` 返回 attemptId、job/process identity、logPath 和后续 status/events 命令。 - `runner start` 返回 attemptId、job/process identity、logPath 和后续 status/events 命令。
- `runner jobs` / `runner job-status` 返回 manager 持久化的 runner Job 最小状态摘要,包括 attemptId、runnerId、namespace、jobName、phase、terminalStatus、logPath、retention 和 redacted Kubernetes identity;业务方不需要直连 Kubernetes 才能定位当前 attempt。 - `runner jobs` / `runner job-status` 返回 manager 持久化的 runner Job 最小状态摘要,包括 attemptId、runnerId、namespace、jobName、phase、terminalStatus、logPath、retention 和 redacted Kubernetes identity;业务方不需要直连 Kubernetes 才能定位当前 attempt。
- 查询类命令返回当前 state、terminal_status、failureKind、event cursor 或 logPath。 - 查询类命令返回当前 state、terminal_status、failureKind、event cursor 或 logPath。
- `events` 默认分页且有界,必须支持 `afterSeq``limit` - `events` 默认分页且有界,必须支持 `afterSeq``limit`;默认输出保持 manager raw JSON
- `events --summary` 返回低噪声 JSON summary,单条至少包含 `seq``type``method``status``command``text``exitCode``durationMs``outputTruncated``outputBytes``outputSummary``summary`;summary 文本必须压缩换行并继续沿用 redaction,不能泄漏 Secret/env value。
- `events --tail <n>``--after-seq`/`--limit` 组合时,只显示本次分页结果最后 `n` 条 summary`--tail-summary` 是默认取最多 20 条 tail 的低噪声快捷入口。
- `events --format tsv` 只在显式请求时输出 TSV 文本,用于人工现场跟踪和上层 trace 压缩;默认 JSON envelope 行为不得改变。
- `server start` 默认以本地后台进程启动 manager,立刻返回 pid、pidFile、logPath、baseUrl 和后续 `status/stop` 命令;只有显式 `--foreground` 才允许占用当前终端。启动前必须检查 pidFile 与端口占用,避免同一端口上堆叠临时 manager。 - `server start` 默认以本地后台进程启动 manager,立刻返回 pid、pidFile、logPath、baseUrl 和后续 `status/stop` 命令;只有显式 `--foreground` 才允许占用当前终端。启动前必须检查 pidFile 与端口占用,避免同一端口上堆叠临时 manager。
- `server status` 必须同时返回本地 pid/port/logPath 状态和 `/health/readiness` 结果;即使 readiness 失败,也要输出结构化 JSON 和 failure details。 - `server status` 必须同时返回本地 pid/port/logPath 状态和 `/health/readiness` 结果;即使 readiness 失败,也要输出结构化 JSON 和 failure details。
- `server logs` 必须返回有界日志尾部、bytes、truncated 和 logPath;找不到日志文件时也必须返回非空 JSON。 - `server logs` 必须返回有界日志尾部、bytes、truncated 和 logPath;找不到日志文件时也必须返回非空 JSON。
+177 -3
View File
@@ -14,15 +14,30 @@ import type { BackendProfile, CommandRecord, JsonRecord, JsonValue, RunRecord, S
import { AgentRunError, errorToJson } from "../../src/common/errors.js"; import { AgentRunError, errorToJson } from "../../src/common/errors.js";
import type { RunnerOnceOptions } from "../../src/runner/run-once.js"; import type { RunnerOnceOptions } from "../../src/runner/run-once.js";
import { backendProfileSpec, isBackendProfile } from "../../src/common/backend-profiles.js"; import { backendProfileSpec, isBackendProfile } from "../../src/common/backend-profiles.js";
import { outputBytesFromPayload, outputTruncatedFromPayload } from "../../src/common/output.js";
import { redactText } from "../../src/common/redaction.js";
interface ParsedArgs { interface ParsedArgs {
positional: string[]; positional: string[];
flags: Map<string, string | boolean>; flags: Map<string, string | boolean>;
} }
const plainTextOutputMarker = Symbol("agentrun.plainTextOutput");
interface PlainTextOutput {
[plainTextOutputMarker]: true;
text: string;
}
type CliResult = JsonValue | PlainTextOutput;
export async function runCli(argv: string[]): Promise<void> { export async function runCli(argv: string[]): Promise<void> {
try { try {
const result = await dispatch(parseArgs(argv)); const result = await dispatch(parseArgs(argv));
if (isPlainTextOutput(result)) {
process.stdout.write(result.text.endsWith("\n") ? result.text : `${result.text}\n`);
return;
}
print({ ok: true, data: result }); print({ ok: true, data: result });
} catch (error) { } catch (error) {
const status = error instanceof AgentRunError ? error.httpStatus : 1; const status = error instanceof AgentRunError ? error.httpStatus : 1;
@@ -31,7 +46,7 @@ export async function runCli(argv: string[]): Promise<void> {
} }
} }
async function dispatch(args: ParsedArgs): Promise<JsonValue> { async function dispatch(args: ParsedArgs): Promise<CliResult> {
const [group, command, id] = args.positional; const [group, command, id] = args.positional;
if (!group || group === "help" || group === "--help") return help(args); if (!group || group === "help" || group === "--help") return help(args);
if (args.flags.get("help") === true) return help(args, group); if (args.flags.get("help") === true) return help(args, group);
@@ -74,7 +89,7 @@ async function dispatch(args: ParsedArgs): Promise<JsonValue> {
if (group === "queue" && command === "refresh" && id) return client(args).post(`/api/v1/queue/tasks/${encodeURIComponent(id)}/refresh`, {}); if (group === "queue" && command === "refresh" && id) return client(args).post(`/api/v1/queue/tasks/${encodeURIComponent(id)}/refresh`, {});
if (group === "runs" && command === "create") return client(args).post("/api/v1/runs", await jsonFile(args)); if (group === "runs" && command === "create") return client(args).post("/api/v1/runs", await jsonFile(args));
if (group === "runs" && command === "show" && id) return client(args).get(`/api/v1/runs/${encodeURIComponent(id)}`); if (group === "runs" && command === "show" && id) return client(args).get(`/api/v1/runs/${encodeURIComponent(id)}`);
if (group === "runs" && command === "events" && id) return client(args).get(`/api/v1/runs/${encodeURIComponent(id)}/events?afterSeq=${flag(args, "after-seq", "0")}&limit=${flag(args, "limit", "100")}`); if (group === "runs" && command === "events" && id) return runEvents(args, id);
if (group === "runs" && command === "result" && id) { if (group === "runs" && command === "result" && id) {
const commandId = optionalFlag(args, "command-id"); const commandId = optionalFlag(args, "command-id");
return client(args).get(`/api/v1/runs/${encodeURIComponent(id)}/result${commandId ? `?commandId=${encodeURIComponent(commandId)}` : ""}`); return client(args).get(`/api/v1/runs/${encodeURIComponent(id)}/result${commandId ? `?commandId=${encodeURIComponent(commandId)}` : ""}`);
@@ -136,6 +151,165 @@ async function listRunnerJobs(args: ParsedArgs): Promise<JsonValue> {
return client(args).get(`/api/v1/runs/${encodeURIComponent(runId)}/runner-jobs${commandId ? `?commandId=${encodeURIComponent(commandId)}` : ""}`); return client(args).get(`/api/v1/runs/${encodeURIComponent(runId)}/runner-jobs${commandId ? `?commandId=${encodeURIComponent(commandId)}` : ""}`);
} }
async function runEvents(args: ParsedArgs, runId: string): Promise<CliResult> {
const afterSeq = integerFlag(args, "after-seq", 0, { min: 0 });
const limit = integerFlag(args, "limit", 100, { min: 1, max: 500 });
const page = await client(args).get(`/api/v1/runs/${encodeURIComponent(runId)}/events?afterSeq=${afterSeq}&limit=${limit}`);
const format = optionalFlag(args, "format") ?? "json";
if (format !== "json" && format !== "tsv") throw new AgentRunError("schema-invalid", "runs events --format must be json or tsv", { httpStatus: 2 });
const tail = tailFlag(args, limit);
const wantsSummary = args.flags.get("summary") === true || args.flags.get("tail-summary") === true || tail !== null || format === "tsv";
if (!wantsSummary) return page;
const summary = summarizeRunEventPage(page, {
runId,
afterSeq,
limit,
tail: tail ?? (args.flags.get("tail-summary") === true ? Math.min(limit, 20) : null),
summaryChars: integerFlag(args, "summary-chars", 900, { min: 1, max: 4_000 }),
});
if (format === "tsv") return plainTextOutput(renderRunEventSummaryTsv(summary));
return summary;
}
interface RunEventSummaryOptions {
runId: string;
afterSeq: number;
limit: number;
tail: number | null;
summaryChars: number;
}
export function summarizeRunEventPage(page: JsonValue, options: RunEventSummaryOptions): JsonRecord {
const events = eventPageItems(page);
const selected = options.tail === null ? events : events.slice(-options.tail);
const items = selected.map((event) => summarizeRunEvent(event, options.summaryChars));
const lastSeq = items.length > 0 ? items[items.length - 1]?.seq ?? null : null;
return {
action: "runs-events-summary",
runId: options.runId,
afterSeq: options.afterSeq,
limit: options.limit,
tail: options.tail,
sourceCount: events.length,
count: items.length,
lastSeq,
nextAfterSeq: lastSeq,
valuesPrinted: false,
items,
};
}
function summarizeRunEvent(event: JsonRecord, summaryChars: number): JsonRecord {
const payload = jsonRecordValue(event.payload) ?? {};
const command = boundedSummaryString(stringValue(payload.command), summaryChars);
const text = boundedSummaryString(stringValue(payload.text), summaryChars);
const outputSummary = boundedSummaryString(stringValue(payload.outputSummary) ?? nestedSummaryText(payload), summaryChars);
const message = boundedSummaryString(stringValue(payload.failureMessage) ?? stringValue(payload.message), summaryChars);
const summary = text ?? command ?? outputSummary ?? message ?? "";
const summaryTruncated = [command, text, outputSummary, message].some((value) => value?.endsWith("...") === true);
return {
seq: numberValue(event.seq) ?? 0,
type: stringValue(event.type) ?? "unknown",
method: stringValue(payload.method),
status: stringValue(payload.status) ?? stringValue(payload.terminalStatus) ?? stringValue(payload.phase),
command,
text,
exitCode: numberValue(payload.exitCode),
durationMs: numberValue(payload.durationMs),
outputTruncated: outputTruncatedFromPayload(payload) || summaryTruncated,
outputBytes: outputBytesFromPayload(payload),
outputSummary,
summary,
};
}
export function renderRunEventSummaryTsv(summary: JsonRecord): string {
const items = Array.isArray(summary.items) ? summary.items.filter((item): item is JsonRecord => jsonRecordValue(item) !== null) : [];
const rows = items.map((item) => [
item.seq,
item.type,
item.method,
item.status,
item.exitCode,
item.durationMs,
item.outputTruncated,
item.outputBytes,
item.summary,
].map(tsvCell).join("\t"));
return ["seq\ttype\tmethod\tstatus\texitCode\tdurationMs\toutputTruncated\toutputBytes\tsummary", ...rows].join("\n");
}
function eventPageItems(page: JsonValue): JsonRecord[] {
const record = jsonRecordValue(page);
if (!record) throw new AgentRunError("schema-invalid", "runs events response must be an object", { httpStatus: 2 });
if (!Array.isArray(record.items)) throw new AgentRunError("schema-invalid", "runs events response.items must be an array", { httpStatus: 2 });
return record.items.map((item) => {
const event = jsonRecordValue(item);
if (!event) throw new AgentRunError("schema-invalid", "runs events item must be an object", { httpStatus: 2 });
return event;
});
}
function nestedSummaryText(payload: JsonRecord): string | null {
const summary = payload.summary;
if (typeof summary === "string") return summary;
const summaryRecord = jsonRecordValue(summary);
return summaryRecord ? stringValue(summaryRecord.text) : null;
}
function boundedSummaryString(value: string | null, limit: number): string | null {
if (!value) return null;
const normalized = redactText(value).replace(/\s+/gu, " ").trim();
if (normalized.length === 0) return null;
return normalized.length > limit ? `${normalized.slice(0, Math.max(0, limit - 3))}...` : normalized;
}
function integerFlag(args: ParsedArgs, name: string, fallback: number, options: { min: number; max?: number }): number {
const raw = optionalFlag(args, name);
const value = raw === null ? fallback : Number(raw);
if (!Number.isInteger(value) || value < options.min || (options.max !== undefined && value > options.max)) {
const maxText = options.max === undefined ? "" : ` and <= ${options.max}`;
throw new AgentRunError("schema-invalid", `--${name} must be an integer >= ${options.min}${maxText}`, { httpStatus: 2 });
}
return value;
}
function tailFlag(args: ParsedArgs, limit: number): number | null {
const value = args.flags.get("tail");
if (value === undefined) return null;
if (value === true) return Math.min(limit, 20);
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < 1 || parsed > limit) throw new AgentRunError("schema-invalid", `--tail must be an integer between 1 and --limit (${limit})`, { httpStatus: 2 });
return parsed;
}
function stringValue(value: JsonValue | undefined): string | null {
return typeof value === "string" && value.length > 0 ? value : null;
}
function numberValue(value: JsonValue | undefined): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function jsonRecordValue(value: unknown): JsonRecord | null {
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as JsonRecord : null;
}
function tsvCell(value: JsonValue | undefined): string {
if (value === null || value === undefined) return "";
return String(value).replace(/[\t\r\n]+/gu, " ");
}
function plainTextOutput(text: string): PlainTextOutput {
return { [plainTextOutputMarker]: true, text };
}
function isPlainTextOutput(value: CliResult): value is PlainTextOutput {
return typeof value === "object" && value !== null && plainTextOutputMarker in value;
}
async function listSessions(args: ParsedArgs): Promise<JsonValue> { async function listSessions(args: ParsedArgs): Promise<JsonValue> {
const params = new URLSearchParams(); const params = new URLSearchParams();
const state = optionalFlag(args, "state") ?? (args.flags.get("running") === true ? "running" : args.flags.get("unread") === true ? "unread" : args.flags.get("all") === true ? "all" : null); const state = optionalFlag(args, "state") ?? (args.flags.get("running") === true ? "running" : args.flags.get("unread") === true ? "unread" : args.flags.get("all") === true ? "all" : null);
@@ -814,7 +988,7 @@ function help(args: ParsedArgs, group?: string): JsonRecord {
const commands = [ const commands = [
"runs create --json-file <run.json>", "runs create --json-file <run.json>",
"runs show <runId>", "runs show <runId>",
"runs events <runId> --after-seq <n> --limit <n>", "runs events <runId> --after-seq <n> --limit <n> [--summary|--tail-summary] [--tail <n>] [--summary-chars <n>] [--format json|tsv]",
"runs result <runId> [--command-id <commandId>]", "runs result <runId> [--command-id <commandId>]",
"runs cancel <runId> [--reason <text>]", "runs cancel <runId> [--reason <text>]",
"sessions ps [--state default|running|unread|terminal|idle|all] [--profile codex|deepseek|minimax-m3|dsflash-go|<dynamic-profile>|M3] [--reader-id <reader>]", "sessions ps [--state default|running|unread|terminal|idle|all] [--profile codex|deepseek|minimax-m3|dsflash-go|<dynamic-profile>|M3] [--reader-id <reader>]",
@@ -0,0 +1,34 @@
import assert from "node:assert/strict";
import type { JsonRecord } from "../../common/types.js";
import { assertNoSecretLeak, type SelfTestContext, type SelfTestResult } from "../harness.js";
import { renderRunEventSummaryTsv, summarizeRunEventPage } from "../../../scripts/src/cli.js";
export default function selfTest(_context: SelfTestContext): SelfTestResult {
const summary = summarizeRunEventPage({ items: [
{ seq: 11, type: "backend_status", payload: { phase: "run-claimed", summary: { text: "runner claimed" } } },
{ seq: 12, type: "tool_call", payload: { method: "shell", status: "completed", command: "curl -H 'Authorization: Bearer test-token-material' https://example.invalid", exitCode: 0, durationMs: 321, outputSummary: "downloaded archive\nnext line", outputBytes: 4096, outputTruncated: true } },
{ seq: 13, type: "assistant_message", payload: { text: "final assistant text that is intentionally longer than the selected summary limit" } },
] }, { runId: "run_selftest", afterSeq: 10, limit: 3, tail: 2, summaryChars: 40 });
assert.equal(summary.action, "runs-events-summary");
assert.equal(summary.sourceCount, 3);
assert.equal(summary.count, 2);
const items = summary.items as JsonRecord[];
assert.deepEqual(items.map((item) => item.seq), [12, 13]);
assert.equal(items[0]?.method, "shell");
assert.equal(items[0]?.status, "completed");
assert.equal(items[0]?.exitCode, 0);
assert.equal(items[0]?.durationMs, 321);
assert.equal(items[0]?.outputTruncated, true);
assert.equal(String(items[0]?.command).includes("test-token-material"), false);
assert.equal(String(items[0]?.outputSummary).includes("\n"), false);
assert.equal(String(items[1]?.summary).endsWith("..."), true);
assertNoSecretLeak(summary);
const tsv = renderRunEventSummaryTsv(summary);
assert.match(tsv, /^seq\ttype\tmethod\tstatus\texitCode\tdurationMs\toutputTruncated\toutputBytes\tsummary\n/u);
assert.equal(tsv.includes("\n12\ttool_call\tshell\tcompleted\t0\t321\ttrue\t4096"), true);
assert.equal(tsv.includes("test-token-material"), false);
return { name: "15-cli-events-summary", tests: ["runs-events-summary-tail", "runs-events-summary-tsv-redaction"] };
}