feat: 补齐 HWLAB 基线 AgentRun 执行元语

This commit is contained in:
Codex
2026-06-01 13:43:27 +08:00
parent 4dc697fe23
commit f4ee644233
17 changed files with 555 additions and 18 deletions
+22 -1
View File
@@ -51,6 +51,7 @@ export function isBackendProfile(value: string): value is BackendProfile {
}
export function backendCapability(spec: BackendProfileSpec): JsonRecord {
const defaultSecretRef = { name: spec.defaultSecretName, keys: [...spec.requiredSecretKeys], valuesPrinted: false };
return {
profile: spec.profile,
backendKind: spec.backendKind,
@@ -59,12 +60,32 @@ export function backendCapability(spec: BackendProfileSpec): JsonRecord {
command: spec.command,
status: spec.status,
requiredSecretKeys: [...spec.requiredSecretKeys],
defaultSecretRef: { name: spec.defaultSecretName, keys: [...spec.requiredSecretKeys] },
defaultSecretRef,
preflight: {
readiness: "requires-profile-secret-ref",
defaultSecretRef,
requiredSecretKeys: [...spec.requiredSecretKeys],
profileIsolation: spec.profileIsolation,
credentialValuesPrinted: false,
valuesPrinted: false,
},
profileIsolation: spec.profileIsolation,
description: spec.description,
};
}
export function mergeBackendCapability(profile: string, storedCapabilities: JsonRecord): JsonRecord {
const spec = backendProfileSpec(profile);
if (!spec) return { profile, ...storedCapabilities, valuesPrinted: false };
const base = backendCapability(spec);
return {
...base,
...storedCapabilities,
defaultSecretRef: base.defaultSecretRef,
preflight: base.preflight,
};
}
export function backendCapabilities(): JsonRecord[] {
return backendProfileSpecs.map(backendCapability);
}
+88
View File
@@ -0,0 +1,88 @@
import { AgentRunError } from "./errors.js";
import type { EventType, JsonRecord, RunEvent, TerminalStatus } from "./types.js";
import { boundedTextSummary, commandOutputPayload } from "./output.js";
import { redactJson } from "./redaction.js";
export const eventTypes = ["backend_status", "assistant_message", "tool_call", "command_output", "diff", "error", "terminal_status"] as const satisfies readonly EventType[];
export const terminalStatuses = ["completed", "failed", "blocked", "cancelled"] as const satisfies readonly TerminalStatus[];
const eventTypeSet = new Set<string>(eventTypes);
const terminalStatusSet = new Set<string>(terminalStatuses);
export function isEventType(value: unknown): value is EventType {
return typeof value === "string" && eventTypeSet.has(value);
}
export function isTerminalStatus(value: unknown): value is TerminalStatus {
return typeof value === "string" && terminalStatusSet.has(value);
}
export function requireEventType(value: unknown): EventType {
if (isEventType(value)) return value;
throw new AgentRunError("schema-invalid", `event.type ${String(value)} is not supported`, { httpStatus: 400, details: { allowedEventTypes: [...eventTypes] } });
}
export function normalizeRunEventPayload(type: EventType, payload: JsonRecord): JsonRecord {
if (type === "terminal_status") return normalizeTerminalStatusPayload(payload);
if (type === "command_output") return normalizeCommandOutputPayload(payload);
if (type === "assistant_message") return normalizeTextPayload(payload);
if (type === "tool_call") return normalizeToolCallPayload(payload);
return payload;
}
export function eventContractSummary(events: RunEvent[]): JsonRecord {
const issues: JsonRecord[] = [];
let terminalStatusCount = 0;
for (let index = 0; index < events.length; index += 1) {
const event = events[index];
if (!eventTypeSet.has(event.type)) issues.push({ code: "event-type-invalid", seq: event.seq, type: event.type });
if (event.seq !== index + 1) issues.push({ code: "seq-not-contiguous", expectedSeq: index + 1, actualSeq: event.seq });
if (event.type === "terminal_status") {
terminalStatusCount += 1;
if (!isTerminalStatus(event.payload.terminalStatus)) issues.push({ code: "terminal-status-invalid", seq: event.seq, terminalStatus: String(event.payload.terminalStatus ?? "") });
}
}
if (terminalStatusCount > 1) issues.push({ code: "terminal-status-duplicated", terminalStatusCount });
return {
ok: issues.length === 0,
eventCount: events.length,
lastSeq: events.at(-1)?.seq ?? 0,
terminalStatusCount,
issues,
};
}
function normalizeTerminalStatusPayload(payload: JsonRecord): JsonRecord {
if (!isTerminalStatus(payload.terminalStatus)) {
throw new AgentRunError("schema-invalid", "terminal_status event requires terminalStatus completed|failed|blocked|cancelled", { httpStatus: 400, details: { allowedTerminalStatuses: [...terminalStatuses] } });
}
return payload;
}
function normalizeCommandOutputPayload(payload: JsonRecord): JsonRecord {
const { text: _text, delta: _delta, content: _content, summary: _summary, ...rest } = payload;
const value = typeof payload.text === "string" ? payload.text : typeof payload.delta === "string" ? payload.delta : typeof payload.content === "string" ? payload.content : "";
const stream = typeof payload.stream === "string" ? payload.stream : "stdout";
return { ...rest, ...commandOutputPayload(stream, value) };
}
function normalizeTextPayload(payload: JsonRecord): JsonRecord {
const { text: _text, delta: _delta, content: _content, summary: _summary, ...rest } = payload;
const value = typeof payload.text === "string" ? payload.text : typeof payload.delta === "string" ? payload.delta : typeof payload.content === "string" ? payload.content : "";
const summary = boundedTextSummary(value);
return { ...rest, text: summary.text, summary, textBytes: summary.textBytes, textTruncated: summary.textTruncated };
}
function normalizeToolCallPayload(payload: JsonRecord): JsonRecord {
const redacted = redactJson(payload);
const json = JSON.stringify(redacted);
const summary = boundedTextSummary(json);
if (summary.outputTruncated !== true) return { ...redacted, summary, outputBytes: summary.outputBytes, outputTruncated: false };
return {
method: typeof payload.method === "string" ? payload.method : null,
itemPreview: summary.text,
summary,
outputBytes: summary.outputBytes,
outputTruncated: true,
};
}
+62
View File
@@ -0,0 +1,62 @@
import type { JsonRecord } from "./types.js";
import { redactText } from "./redaction.js";
const defaultOutputLimitChars = 4_000;
export interface BoundedTextOptions {
limitChars?: number;
}
export function boundedTextSummary(value: unknown, options: BoundedTextOptions = {}): JsonRecord {
const limitChars = positiveLimit(options.limitChars ?? defaultOutputLimitChars);
const redacted = redactText(typeof value === "string" ? value : value === null || value === undefined ? "" : String(value));
const outputBytes = Buffer.byteLength(redacted, "utf8");
const outputTruncated = redacted.length > limitChars;
const text = outputTruncated ? redacted.slice(0, limitChars) : redacted;
return {
text,
textChars: redacted.length,
textBytes: outputBytes,
outputBytes,
limitChars,
textTruncated: outputTruncated,
outputTruncated,
};
}
export function commandOutputPayload(stream: string, value: unknown, options: BoundedTextOptions = {}): JsonRecord {
const summary = boundedTextSummary(value, options);
return {
stream: stream === "stderr" ? "stderr" : "stdout",
text: summary.text,
summary,
outputBytes: summary.outputBytes,
outputTruncated: summary.outputTruncated,
textBytes: summary.textBytes,
textTruncated: summary.textTruncated,
};
}
export function outputBytesFromPayload(payload: JsonRecord): number {
for (const key of ["outputBytes", "textBytes"] as const) {
const value = payload[key];
if (typeof value === "number" && Number.isFinite(value) && value >= 0) return value;
}
const summary = payload.summary;
if (typeof summary === "object" && summary !== null && !Array.isArray(summary)) {
const outputBytes = (summary as JsonRecord).outputBytes;
if (typeof outputBytes === "number" && Number.isFinite(outputBytes) && outputBytes >= 0) return outputBytes;
}
const text = typeof payload.text === "string" ? payload.text : "";
return Buffer.byteLength(text, "utf8");
}
export function outputTruncatedFromPayload(payload: JsonRecord): boolean {
if (payload.outputTruncated === true || payload.textTruncated === true || payload.truncated === true) return true;
const summary = payload.summary;
return typeof summary === "object" && summary !== null && !Array.isArray(summary) && (summary as JsonRecord).outputTruncated === true;
}
function positiveLimit(value: number): number {
return Number.isFinite(value) && value > 0 ? Math.floor(value) : defaultOutputLimitChars;
}