feat(agent): 运行时内部错误上报到日志(onReport)
- PiAgentRuntimeOptions.onReport:本次运行扩展的系统提示 section、能力工具、beforeTool / beforeRequest 钩子抛错 先上报(runId + 位置 + 脱敏信息)再原样抛出;pi-durable 的 Harness onReport 也接进来(source=harness) - server runner / 视觉子流程用 runtimeReporter 写 warn 日志 - 测试:钩子抛错时上报带 runId 与位置、信息已脱敏,调用照常被 block Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
1 parent
003815591c
commit
7c5215663d
5 files changed
+135
-13
No files matched your search
@@ -24,7 +24,7 @@ import { BrowserServiceError, type ChallengeVision, registerChallengeVision } fr
|
|||||||
import { resolveRunUpstream } from "../pi/index.ts";
|
import { resolveRunUpstream } from "../pi/index.ts";
|
||||||
import type { ServerContext } from "../types.ts";
|
import type { ServerContext } from "../types.ts";
|
||||||
import { parseFakeScripts } from "./fake-scripts.ts";
|
import { parseFakeScripts } from "./fake-scripts.ts";
|
||||||
import { type AgentEnv, agentSettings, eventSink, publishStatus, sessionDirOf } from "./runner.ts";
|
import { type AgentEnv, agentSettings, eventSink, publishStatus, runtimeReporter, sessionDirOf } from "./runner.ts";
|
||||||
|
|
||||||
// VIS-1 browser.solve_challenge:有界的视觉子流程(REQ §4.4、§10)。
|
// VIS-1 browser.solve_challenge:有界的视觉子流程(REQ §4.4、§10)。
|
||||||
// - 服务端起一个受限的子 agent_run(父会话 = 调用它的 Agent 运行;白名单只有截图 / 坐标点击 / 输入 / 滚动 / 等待 / 读页面),
|
// - 服务端起一个受限的子 agent_run(父会话 = 调用它的 Agent 运行;白名单只有截图 / 坐标点击 / 输入 / 滚动 / 等待 / 读页面),
|
||||||
@@ -305,6 +305,7 @@ async function runChild(
|
|||||||
const onEvent = eventSink(env);
|
const onEvent = eventSink(env);
|
||||||
const onEventError = (err: unknown) =>
|
const onEventError = (err: unknown) =>
|
||||||
ctx.logger.warn({ runId: child.id, err: { message: (err as Error).message } }, "视觉子流程事件落库失败");
|
ctx.logger.warn({ runId: child.id, err: { message: (err as Error).message } }, "视觉子流程事件落库失败");
|
||||||
|
const onReport = runtimeReporter(ctx);
|
||||||
let scripts: FakeScript[] = [FALLBACK_SCRIPT];
|
let scripts: FakeScript[] = [FALLBACK_SCRIPT];
|
||||||
if (fake) {
|
if (fake) {
|
||||||
try {
|
try {
|
||||||
@@ -315,11 +316,12 @@ async function runChild(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const runtime = fake
|
const runtime = fake
|
||||||
? createFakeRuntime({ registry, scripts, onEvent, onEventError, fake: { vision: true } }).runtime
|
? createFakeRuntime({ registry, scripts, onEvent, onEventError, onReport, fake: { vision: true } }).runtime
|
||||||
: new PiAgentRuntime({
|
: new PiAgentRuntime({
|
||||||
registry,
|
registry,
|
||||||
onEvent,
|
onEvent,
|
||||||
onEventError,
|
onEventError,
|
||||||
|
onReport,
|
||||||
upstream: { spec: upstream.spec, credentials: upstream.credentials },
|
upstream: { spec: upstream.spec, credentials: upstream.credentials },
|
||||||
});
|
});
|
||||||
const systemPrompt = buildSystemPrompt({
|
const systemPrompt = buildSystemPrompt({
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
type FakeScript,
|
type FakeScript,
|
||||||
PiAgentRuntime,
|
PiAgentRuntime,
|
||||||
type RunOutcome,
|
type RunOutcome,
|
||||||
|
type RuntimeReport,
|
||||||
type SessionKind,
|
type SessionKind,
|
||||||
type SkillDoc,
|
type SkillDoc,
|
||||||
selectSkills,
|
selectSkills,
|
||||||
@@ -399,6 +400,15 @@ export async function buildRunPrompt(
|
|||||||
return { systemPrompt, kind, skills, available };
|
return { systemPrompt, kind, skills, available };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 运行时内部错误(本次运行扩展的系统提示 / 能力工具 / 白名单与预算钩子抛错,pi-durable 的 onReport)→ warn 日志。
|
||||||
|
* message 已由运行时脱敏;只记位置与信息,不记原始错误对象。
|
||||||
|
*/
|
||||||
|
export function runtimeReporter(ctx: ServerContext): (r: RuntimeReport) => void {
|
||||||
|
return (r) =>
|
||||||
|
ctx.logger.warn({ runId: r.runId, source: r.source, err: { message: r.message } }, "agent 运行时内部错误");
|
||||||
|
}
|
||||||
|
|
||||||
/** 读 agent_run_event 里最后一轮的结束事件(崩溃发生在"模型已结束、业务状态未写"之间时复用) */
|
/** 读 agent_run_event 里最后一轮的结束事件(崩溃发生在"模型已结束、业务状态未写"之间时复用) */
|
||||||
async function lastOutcomeEvent(env: AgentEnv, runId: string) {
|
async function lastOutcomeEvent(env: AgentEnv, runId: string) {
|
||||||
const r = await env.ctx.db
|
const r = await env.ctx.db
|
||||||
@@ -460,6 +470,7 @@ export async function drive(
|
|||||||
const onEvent = eventSink(env);
|
const onEvent = eventSink(env);
|
||||||
const onEventError = (err: unknown, e: AgentRunEvent) =>
|
const onEventError = (err: unknown, e: AgentRunEvent) =>
|
||||||
ctx.logger.warn({ runId, seq: e.seq, err: { message: (err as Error).message } }, "agent 事件落库失败");
|
ctx.logger.warn({ runId, seq: e.seq, err: { message: (err as Error).message } }, "agent 事件落库失败");
|
||||||
|
const onReport = runtimeReporter(ctx);
|
||||||
let scripts: FakeScript[] = DEMO_SCRIPTS;
|
let scripts: FakeScript[] = DEMO_SCRIPTS;
|
||||||
if (fake) {
|
if (fake) {
|
||||||
try {
|
try {
|
||||||
@@ -475,11 +486,12 @@ export async function drive(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const runtime = fake
|
const runtime = fake
|
||||||
? createFakeRuntime({ registry, scripts, onEvent, onEventError, fake: { vision: true } }).runtime
|
? createFakeRuntime({ registry, scripts, onEvent, onEventError, onReport, fake: { vision: true } }).runtime
|
||||||
: new PiAgentRuntime({
|
: new PiAgentRuntime({
|
||||||
registry,
|
registry,
|
||||||
onEvent,
|
onEvent,
|
||||||
onEventError,
|
onEventError,
|
||||||
|
onReport,
|
||||||
...(upstream ? { upstream: { spec: upstream.spec, credentials: upstream.credentials } } : {}),
|
...(upstream ? { upstream: { spec: upstream.spec, credentials: upstream.credentials } } : {}),
|
||||||
});
|
});
|
||||||
const maxSeq = await ctx.db
|
const maxSeq = await ctx.db
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export {
|
|||||||
DEFAULT_COMPACTION_INSTRUCTIONS,
|
DEFAULT_COMPACTION_INSTRUCTIONS,
|
||||||
PiAgentRuntime,
|
PiAgentRuntime,
|
||||||
type PiAgentRuntimeOptions,
|
type PiAgentRuntimeOptions,
|
||||||
|
type RuntimeReport,
|
||||||
toolContent,
|
toolContent,
|
||||||
} from "./pi/runtime.ts";
|
} from "./pi/runtime.ts";
|
||||||
export { LegacySessionError, sessionPathFor } from "./pi/session.ts";
|
export { LegacySessionError, sessionPathFor } from "./pi/session.ts";
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { Context } from "@earendil-works/chord";
|
||||||
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
||||||
import type { AssistantMessage, Message, ToolResultMessage } from "@earendil-works/pi-ai";
|
import type { AssistantMessage, Message, ToolResultMessage } from "@earendil-works/pi-ai";
|
||||||
import {
|
import {
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
hook,
|
hook,
|
||||||
type SettledSubmissionRecord,
|
type SettledSubmissionRecord,
|
||||||
section,
|
section,
|
||||||
|
type ToolExecutionApi,
|
||||||
type ToolExecutionResult,
|
type ToolExecutionResult,
|
||||||
type ToolRegistration,
|
type ToolRegistration,
|
||||||
ToolTask,
|
ToolTask,
|
||||||
@@ -66,9 +68,23 @@ export interface PiAgentRuntimeOptions {
|
|||||||
*/
|
*/
|
||||||
onEvent?: (event: AgentRunEvent) => void | Promise<void>;
|
onEvent?: (event: AgentRunEvent) => void | Promise<void>;
|
||||||
onEventError?: (error: unknown, event: AgentRunEvent) => void;
|
onEventError?: (error: unknown, event: AgentRunEvent) => void;
|
||||||
|
/**
|
||||||
|
* 运行时内部错误(不影响本次调用结果、但应该看见的):本次运行扩展里的系统提示 section、能力工具、白名单 / 预算钩子抛错,
|
||||||
|
* 以及 pi-durable 的 onReport(提交 / 进度写入等失败)。message 已脱敏。不能抛错。
|
||||||
|
*/
|
||||||
|
onReport?: (report: RuntimeReport) => void;
|
||||||
now?: () => Date;
|
now?: () => Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RuntimeReport {
|
||||||
|
runId: string;
|
||||||
|
/** 出错的位置:section:umt、tool:<工具名>、hook:beforeTool、hook:beforeRequest、harness(pi-durable onReport) */
|
||||||
|
source: string;
|
||||||
|
/** 已脱敏的错误信息 */
|
||||||
|
message: string;
|
||||||
|
error: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
export const DEFAULT_COMPACTION_INSTRUCTIONS =
|
export const DEFAULT_COMPACTION_INSTRUCTIONS =
|
||||||
"保留:本次目标、约束与授权范围、证据引用(对象 ID/记录)、已完成的动作及其结果(已提交≠已入库)、剩余事项。不要保留任何凭证。";
|
"保留:本次目标、约束与授权范围、证据引用(对象 ID/记录)、已完成的动作及其结果(已提交≠已入库)、剩余事项。不要保留任何凭证。";
|
||||||
|
|
||||||
@@ -113,7 +129,11 @@ export class PiAgentRuntime implements AgentRuntimePort {
|
|||||||
};
|
};
|
||||||
let harness: Harness | undefined;
|
let harness: Harness | undefined;
|
||||||
try {
|
try {
|
||||||
harness = await Harness.open(opened.storage, { models, registry, settings: harnessSettings }, ctx);
|
harness = await Harness.open(
|
||||||
|
opened.storage,
|
||||||
|
{ models, registry, settings: harnessSettings, onReport: (error) => run.report("harness", error) },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
const agent = { model: { provider: model.provider, modelId: model.id }, thinkingLevel };
|
const agent = { model: { provider: model.provider, modelId: model.id }, thinkingLevel };
|
||||||
const root = await harness.root(ctx, { agent });
|
const root = await harness.root(ctx, { agent });
|
||||||
// 已有会话:模型 / 思考档位跟随本次设置(换了上游或模型后继续)
|
// 已有会话:模型 / 思考档位跟随本次设置(换了上游或模型后继续)
|
||||||
@@ -192,27 +212,58 @@ class PiAgentRun implements AgentRunHandle {
|
|||||||
const systemPrompt = this.spec.systemPrompt;
|
const systemPrompt = this.spec.systemPrompt;
|
||||||
return defineExtension({
|
return defineExtension({
|
||||||
name: "umt",
|
name: "umt",
|
||||||
sections: [section("umt", () => systemPrompt, { tag: false })],
|
sections: [
|
||||||
|
section(
|
||||||
|
"umt",
|
||||||
|
this.reported("section:umt", () => systemPrompt),
|
||||||
|
{ tag: false },
|
||||||
|
),
|
||||||
|
],
|
||||||
tools: this.capabilities.map((c) => this.toTool(c)),
|
tools: this.capabilities.map((c) => this.toTool(c)),
|
||||||
hooks: [
|
hooks: [
|
||||||
hook(ToolTask, { beforeTool: (call) => this.beforeTool(call.id, call.name) }),
|
hook(ToolTask, {
|
||||||
|
beforeTool: this.reported("hook:beforeTool", (call: { id: string; name: string }) =>
|
||||||
|
this.beforeTool(call.id, call.name),
|
||||||
|
),
|
||||||
|
}),
|
||||||
hook(GenerationTask, {
|
hook(GenerationTask, {
|
||||||
beforeRequest: () => {
|
beforeRequest: this.reported("hook:beforeRequest", () => {
|
||||||
this.checkDeadline();
|
this.checkDeadline();
|
||||||
this.checkCost();
|
this.checkCost();
|
||||||
if (this.budget) this.scheduleAbort();
|
if (this.budget) this.scheduleAbort();
|
||||||
return undefined;
|
return undefined;
|
||||||
},
|
}),
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 记一条运行时内部错误(脱敏后交给 onReport;onReport 自身出错忽略) */
|
||||||
|
report(source: string, error: unknown): void {
|
||||||
|
try {
|
||||||
|
const message = redactString(error instanceof Error ? error.message : String(error));
|
||||||
|
this.options.onReport?.({ runId: this.runId, source, message, error });
|
||||||
|
} catch {
|
||||||
|
// 上报失败不影响运行
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 包一层:抛错时先上报(带位置)再原样抛出,pi-durable 的处理语义不变 */
|
||||||
|
private reported<A extends unknown[], R>(source: string, fn: (...args: A) => R): (...args: A) => R {
|
||||||
|
return (...args: A) => {
|
||||||
|
try {
|
||||||
|
return fn(...args);
|
||||||
|
} catch (error) {
|
||||||
|
this.report(source, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** 能力 → Pi 工具。执行时统一走注册中心 invoke(schema/作用域/授权/幂等/审计) */
|
/** 能力 → Pi 工具。执行时统一走注册中心 invoke(schema/作用域/授权/幂等/审计) */
|
||||||
toTool(entry: RegisteredCapability): ToolRegistration {
|
toTool(entry: RegisteredCapability): ToolRegistration {
|
||||||
const { descriptor } = entry;
|
const { descriptor } = entry;
|
||||||
const registry = this.options.registry;
|
const registry = this.options.registry;
|
||||||
const actor = this.spec.actor;
|
|
||||||
const gate = this.spec.replayGate;
|
const gate = this.spec.replayGate;
|
||||||
// 可挂起重放(见 CreateRunSpec.replayGate):对 harness 声明 safe,执行时用 memo 判断是否重放
|
// 可挂起重放(见 CreateRunSpec.replayGate):对 harness 声明 safe,执行时用 memo 判断是否重放
|
||||||
const gated = descriptor.replay === "never" && !!gate;
|
const gated = descriptor.replay === "never" && !!gate;
|
||||||
@@ -223,7 +274,25 @@ class PiAgentRun implements AgentRunHandle {
|
|||||||
replay: gated || descriptor.replay === "safe" ? "safe" : "unsafe",
|
replay: gated || descriptor.replay === "safe" ? "safe" : "unsafe",
|
||||||
outputLimits: TOOL_OUTPUT_LIMITS,
|
outputLimits: TOOL_OUTPUT_LIMITS,
|
||||||
...(descriptor.kind === "execute" ? { executionMode: "sequential" as const } : {}),
|
...(descriptor.kind === "execute" ? { executionMode: "sequential" as const } : {}),
|
||||||
execute: async (params, api, context): Promise<ToolExecutionResult> => {
|
execute: (params, api, context) =>
|
||||||
|
this.executeTool(entry, gated, params, api, context).catch((error: unknown) => {
|
||||||
|
if (!this.closed && !context.abortSignal?.aborted) this.report(`tool:${entry.toolName}`, error);
|
||||||
|
throw error;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async executeTool(
|
||||||
|
entry: RegisteredCapability,
|
||||||
|
gated: boolean,
|
||||||
|
params: unknown,
|
||||||
|
api: ToolExecutionApi,
|
||||||
|
context: Context,
|
||||||
|
): Promise<ToolExecutionResult> {
|
||||||
|
const { descriptor } = entry;
|
||||||
|
const registry = this.options.registry;
|
||||||
|
const actor = this.spec.actor;
|
||||||
|
const gate = this.spec.replayGate;
|
||||||
const signals = [this.abortController.signal, ...(context.abortSignal ? [context.abortSignal] : [])];
|
const signals = [this.abortController.signal, ...(context.abortSignal ? [context.abortSignal] : [])];
|
||||||
const signal = AbortSignal.any(signals);
|
const signal = AbortSignal.any(signals);
|
||||||
// 稳定的调用 ID:同一存储内唯一,挂起 / 崩溃后重放时不变(审批、子会话等待按它对应)
|
// 稳定的调用 ID:同一存储内唯一,挂起 / 崩溃后重放时不变(审批、子会话等待按它对应)
|
||||||
@@ -233,7 +302,7 @@ class PiAgentRun implements AgentRunHandle {
|
|||||||
if (gated) {
|
if (gated) {
|
||||||
const replayed = (await api.memo<boolean>(STARTED_MEMO, context)) === true;
|
const replayed = (await api.memo<boolean>(STARTED_MEMO, context)) === true;
|
||||||
if (!replayed) await api.memo(STARTED_MEMO, true, context);
|
if (!replayed) await api.memo(STARTED_MEMO, true, context);
|
||||||
else if (!gate(descriptor.id, invocationId))
|
else if (!gate?.(descriptor.id, invocationId))
|
||||||
return errorResult(
|
return errorResult(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
error: {
|
error: {
|
||||||
@@ -275,8 +344,6 @@ class PiAgentRun implements AgentRunHandle {
|
|||||||
} finally {
|
} finally {
|
||||||
this.activeTools -= 1;
|
this.activeTools -= 1;
|
||||||
}
|
}
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async attach(harness: Harness, root: Conversation): Promise<void> {
|
async attach(harness: Harness, root: Conversation): Promise<void> {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
createFakeRuntime,
|
createFakeRuntime,
|
||||||
type FakeScript,
|
type FakeScript,
|
||||||
LegacySessionError,
|
LegacySessionError,
|
||||||
|
type RuntimeReport,
|
||||||
} from "../src/index.ts";
|
} from "../src/index.ts";
|
||||||
import { crashScript, RUN_ID } from "./fixtures/crash-script.ts";
|
import { crashScript, RUN_ID } from "./fixtures/crash-script.ts";
|
||||||
import { type Fixture, makeFixture, settings, tempDir, types } from "./helpers.ts";
|
import { type Fixture, makeFixture, settings, tempDir, types } from "./helpers.ts";
|
||||||
@@ -462,4 +463,43 @@ describe("PiAgentRuntime + FakeModelProvider", () => {
|
|||||||
expect(err).toBeInstanceOf(LegacySessionError);
|
expect(err).toBeInstanceOf(LegacySessionError);
|
||||||
expect((err as Error).message).toContain("旧版本");
|
expect((err as Error).message).toContain("旧版本");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("扩展钩子抛错:上报 onReport(带 runId 与位置、已脱敏),pi-durable 照常按 block 处理", async () => {
|
||||||
|
const fx = makeFixture();
|
||||||
|
const reports: RuntimeReport[] = [];
|
||||||
|
const { runtime } = createFakeRuntime({
|
||||||
|
registry: fx.registry,
|
||||||
|
scripts: [
|
||||||
|
{
|
||||||
|
match: "Dune",
|
||||||
|
steps: [{ toolCalls: [{ capability: "inventory.query", input: { title: "Dune" } }] }],
|
||||||
|
finalText: (r) => `结果:${r.at(-1)?.isError}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
onReport: (r) => reports.push(r),
|
||||||
|
});
|
||||||
|
const run = await runtime.createRun({
|
||||||
|
runId: "run-report",
|
||||||
|
goal: "Dune",
|
||||||
|
systemPrompt: "x",
|
||||||
|
capabilities: ["inventory.query"],
|
||||||
|
settings: settings(),
|
||||||
|
sessionDir: await tempDir(),
|
||||||
|
actor: { actorId: "user:1", taskId: "t", scopeIds: [] },
|
||||||
|
externalUsage: () => {
|
||||||
|
throw new Error("子会话用量读取失败 passkey=abcdef123456");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
opened.push(run);
|
||||||
|
const out = await run.prompt();
|
||||||
|
expect(out).toMatchObject({ status: "completed", finalText: "结果:true" });
|
||||||
|
expect(reports).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
runId: "run-report",
|
||||||
|
source: "hook:beforeTool",
|
||||||
|
message: expect.stringContaining("子会话用量读取失败"),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(reports[0]?.message).not.toContain("abcdef123456");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user