feat: 为 gitbundle 装配 required skills (#138)
* Add gitbundle required skills validation * fix: 限定 required skill blocked result 覆盖 --------- Co-authored-by: AgentRun Codex <agentrun@example.invalid> Co-authored-by: Codex <codex@pikas.tech>
This commit is contained in:
@@ -8,6 +8,7 @@ export type FailureKind =
|
||||
| "secret-unavailable"
|
||||
| "prompt-unavailable"
|
||||
| "prompt-too-large"
|
||||
| "required-skill-unavailable"
|
||||
| "skill-unavailable"
|
||||
| "runner-lease-conflict"
|
||||
| "backend-failed"
|
||||
@@ -81,6 +82,9 @@ export interface ResourceBundleRef extends JsonRecord {
|
||||
inject?: "thread-start";
|
||||
required?: boolean;
|
||||
}>;
|
||||
requiredSkills?: Array<{
|
||||
name: string;
|
||||
}>;
|
||||
submodules?: false;
|
||||
lfs?: false;
|
||||
credentialRef?: SecretRef;
|
||||
|
||||
@@ -91,6 +91,7 @@ export function validateResourceBundleRef(value: unknown): ResourceBundleRef | n
|
||||
rejectLegacyResourceBundleFields(record);
|
||||
const result: ResourceBundleRef = { kind: "gitbundle", repoUrl, ...(commitId ? { commitId } : {}), ...(ref ? { ref } : {}), bundles: validateResourceGitBundles(record.bundles, repoUrl, commitId, ref) };
|
||||
if (record.promptRefs !== undefined) result.promptRefs = validateResourcePromptRefs(record.promptRefs);
|
||||
if (record.requiredSkills !== undefined) result.requiredSkills = validateResourceRequiredSkills(record.requiredSkills);
|
||||
if (record.submodules !== undefined && record.submodules !== false) throw new AgentRunError("schema-invalid", "resourceBundleRef.submodules can only be false in v0.1", { httpStatus: 400 });
|
||||
if (record.lfs !== undefined && record.lfs !== false) throw new AgentRunError("schema-invalid", "resourceBundleRef.lfs can only be false in v0.1", { httpStatus: 400 });
|
||||
if (record.submodules === false) result.submodules = false;
|
||||
@@ -160,6 +161,22 @@ function validateResourcePromptRefs(value: unknown): NonNullable<ResourceBundleR
|
||||
});
|
||||
}
|
||||
|
||||
function validateResourceRequiredSkills(value: unknown): NonNullable<ResourceBundleRef["requiredSkills"]> {
|
||||
if (!Array.isArray(value)) throw new AgentRunError("schema-invalid", "resourceBundleRef.requiredSkills must be an array", { httpStatus: 400 });
|
||||
if (value.length > 32) throw new AgentRunError("schema-invalid", "resourceBundleRef.requiredSkills must contain at most 32 entries", { httpStatus: 400 });
|
||||
const seen = new Set<string>();
|
||||
return value.map((entry, index) => {
|
||||
const record = asRecord(entry, `resourceBundleRef.requiredSkills[${index}]`);
|
||||
const allowedKeys = new Set(["name"]);
|
||||
const extraKeys = Object.keys(record).filter((key) => !allowedKeys.has(key));
|
||||
if (extraKeys.length > 0) throw new AgentRunError("schema-invalid", `resourceBundleRef.requiredSkills[${index}] only supports name`, { httpStatus: 400, details: { rejectedKeys: extraKeys.sort(), valuesPrinted: false } });
|
||||
const name = validateResourceName(requiredString(record, "name"), `resourceBundleRef.requiredSkills[${index}].name`);
|
||||
if (seen.has(name)) throw new AgentRunError("schema-invalid", `resourceBundleRef.requiredSkills name ${name} is duplicated`, { httpStatus: 400 });
|
||||
seen.add(name);
|
||||
return { name };
|
||||
});
|
||||
}
|
||||
|
||||
function validateResourceName(name: string, fieldName: string): string {
|
||||
if (!/^[a-z][a-z0-9._-]{0,62}$/u.test(name)) throw new AgentRunError("schema-invalid", `${fieldName} must be a lowercase resource name`, { httpStatus: 400 });
|
||||
return name;
|
||||
|
||||
+63
-8
@@ -1,5 +1,5 @@
|
||||
import type { AgentRunStore } from "./store.js";
|
||||
import type { CommandRecord, JsonRecord, JsonValue, RunEvent, RunRecord, RunnerJobRecord, TerminalStatus } from "../common/types.js";
|
||||
import type { CommandRecord, FailureKind, JsonRecord, JsonValue, RunEvent, RunRecord, RunnerJobRecord, TerminalStatus } from "../common/types.js";
|
||||
import { boundedTextSummary, outputBytesFromPayload, outputTruncatedFromPayload } from "../common/output.js";
|
||||
|
||||
const maxToolCallSummaryItems = 40;
|
||||
@@ -35,12 +35,14 @@ export async function buildRunResult(store: AgentRunStore, runId: string, comman
|
||||
const latestJob = jobs.at(-1) ?? null;
|
||||
const commandTerminal = command ? terminalFromCommand(command) : null;
|
||||
const terminalEventStatus = terminalFromEvents(scopedEvents);
|
||||
const terminal = commandTerminal ?? terminalEventStatus ?? run.terminalStatus;
|
||||
const terminalSource = commandTerminal ? "command-record" : terminalEventStatus ? "terminal_status-event" : run.terminalStatus ? "run-record" : "none";
|
||||
const failureKind = resultFailureKind(run, command, scopedEvents, terminal);
|
||||
const preliminaryTerminal = commandTerminal ?? terminalEventStatus ?? run.terminalStatus;
|
||||
const failureKind = resultFailureKind(run, command, scopedEvents, preliminaryTerminal);
|
||||
const terminal = resultTerminal(commandTerminal, terminalEventStatus, run.terminalStatus, failureKind);
|
||||
const terminalSource = resultTerminalSource(commandTerminal, terminalEventStatus, run.terminalStatus, failureKind);
|
||||
const failureMessage = resultFailureMessage(run, command, scopedEvents, terminal);
|
||||
const failureDetails = resultFailureDetails(scopedEvents, terminal);
|
||||
const reply = assistantReply(scopedEvents);
|
||||
const blocker = terminal === "blocked" || terminal === "failed" ? { failureKind, message: failureMessage } : null;
|
||||
const blocker = terminal === "blocked" || terminal === "failed" ? { failureKind, message: failureMessage, details: failureDetails } : null;
|
||||
const liveness = livenessSnapshot(run, command, events, scopedEvents, terminal);
|
||||
const steerDelivery = command?.type === "steer" ? steerDeliverySummary(events, command.id) : null;
|
||||
return {
|
||||
@@ -72,6 +74,7 @@ export async function buildRunResult(store: AgentRunStore, runId: string, comman
|
||||
finalAssistantOutputTruncated: reply.outputTruncated,
|
||||
failureKind,
|
||||
failureMessage,
|
||||
failureDetails,
|
||||
blocker,
|
||||
liveness,
|
||||
...(steerDelivery ? { steerDelivery } : {}),
|
||||
@@ -292,25 +295,64 @@ function terminalFromCommand(command: CommandRecord): TerminalStatus | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function resultTerminal(commandTerminal: TerminalStatus | null, terminalEventStatus: TerminalStatus | null, runTerminalStatus: TerminalStatus | null, failureKind: FailureKind | null): TerminalStatus | null {
|
||||
if (commandTerminal === "failed" && terminalEventStatus === "blocked" && failureKind === "required-skill-unavailable") return "blocked";
|
||||
return commandTerminal ?? terminalEventStatus ?? runTerminalStatus;
|
||||
}
|
||||
|
||||
function resultTerminalSource(commandTerminal: TerminalStatus | null, terminalEventStatus: TerminalStatus | null, runTerminalStatus: TerminalStatus | null, failureKind: FailureKind | null): string {
|
||||
if (commandTerminal === "failed" && terminalEventStatus === "blocked" && failureKind === "required-skill-unavailable") return "terminal_status-event";
|
||||
if (commandTerminal) return "command-record";
|
||||
if (terminalEventStatus) return "terminal_status-event";
|
||||
if (runTerminalStatus) return "run-record";
|
||||
return "none";
|
||||
}
|
||||
|
||||
function eventsForCommand(events: RunEvent[], commandId: string): RunEvent[] {
|
||||
const scoped = events.filter((event) => event.payload.commandId === commandId || typeof event.payload.commandId !== "string");
|
||||
return scoped.length > 0 ? scoped : events;
|
||||
}
|
||||
|
||||
function failureKindFromEvents(events: RunEvent[]): string | null {
|
||||
function failureKindFromEvents(events: RunEvent[]): FailureKind | null {
|
||||
for (const event of [...events].reverse()) {
|
||||
const value = event.payload.failureKind;
|
||||
if (typeof value === "string") return value;
|
||||
if (isFailureKind(value)) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resultFailureKind(run: RunRecord, command: CommandRecord | null, events: RunEvent[], terminal: TerminalStatus | null): string | null {
|
||||
function resultFailureKind(run: RunRecord, command: CommandRecord | null, events: RunEvent[], terminal: TerminalStatus | null): FailureKind | null {
|
||||
if (terminal === "completed") return null;
|
||||
if (command) return failureKindFromEvents(events);
|
||||
return run.failureKind ?? failureKindFromEvents(events);
|
||||
}
|
||||
|
||||
function isFailureKind(value: unknown): value is FailureKind {
|
||||
return typeof value === "string" && [
|
||||
"cancelled",
|
||||
"tenant-policy-denied",
|
||||
"secret-unavailable",
|
||||
"prompt-unavailable",
|
||||
"prompt-too-large",
|
||||
"required-skill-unavailable",
|
||||
"skill-unavailable",
|
||||
"runner-lease-conflict",
|
||||
"backend-failed",
|
||||
"backend-timeout",
|
||||
"backend-response-invalid",
|
||||
"thread-resume-failed",
|
||||
"provider-auth-failed",
|
||||
"provider-invalid-tool-call",
|
||||
"provider-compact-unsupported",
|
||||
"provider-rate-limited",
|
||||
"provider-unavailable",
|
||||
"provider-stream-disconnected",
|
||||
"provider-refused-retry-recovered",
|
||||
"infra-failed",
|
||||
"schema-invalid",
|
||||
].includes(value);
|
||||
}
|
||||
|
||||
function messageFromEvents(events: RunEvent[]): string | null {
|
||||
for (const event of [...events].reverse()) {
|
||||
const value = event.payload.message;
|
||||
@@ -325,6 +367,18 @@ function resultFailureMessage(run: RunRecord, command: CommandRecord | null, eve
|
||||
return run.failureMessage ?? messageFromEvents(events);
|
||||
}
|
||||
|
||||
function detailsFromEvents(events: RunEvent[]): JsonRecord | null {
|
||||
for (const event of [...events].reverse()) {
|
||||
const value = event.payload.details;
|
||||
if (typeof value === "object" && value !== null && !Array.isArray(value)) return value as JsonRecord;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resultFailureDetails(events: RunEvent[], terminal: TerminalStatus | null): JsonRecord | null {
|
||||
return terminal === "completed" ? null : detailsFromEvents(events);
|
||||
}
|
||||
|
||||
function assistantReply(events: RunEvent[]): AssistantReplySummary {
|
||||
const assistantEvents = events.filter((event) => event.type === "assistant_message");
|
||||
const latestAuthoritative = [...assistantEvents].reverse().find((event) => (event.payload.replyAuthority === true || event.payload.final === true) && textPayload(event.payload).length > 0);
|
||||
@@ -474,6 +528,7 @@ function resourceBundleSummary(run: RunRecord, events: RunEvent[]): JsonRecord |
|
||||
valuesPrinted: false,
|
||||
},
|
||||
promptRefs: run.resourceBundleRef.promptRefs ? { count: run.resourceBundleRef.promptRefs.length, names: run.resourceBundleRef.promptRefs.map((item) => item.name), required: run.resourceBundleRef.promptRefs.filter((item) => item.required === true).map((item) => item.name), valuesPrinted: false } : { count: 0, names: [], required: [], valuesPrinted: false },
|
||||
requiredSkills: run.resourceBundleRef.requiredSkills ? { count: run.resourceBundleRef.requiredSkills.length, names: run.resourceBundleRef.requiredSkills.map((item) => item.name), valuesPrinted: false } : { count: 0, names: [], valuesPrinted: false },
|
||||
materialized: materialized as JsonValue,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -761,6 +761,7 @@ export function summarizeResourceBundleRef(resourceBundleRef: RunRecord["resourc
|
||||
valuesPrinted: false,
|
||||
},
|
||||
promptRefs: resourceBundleRef.promptRefs ? { count: resourceBundleRef.promptRefs.length, names: resourceBundleRef.promptRefs.map((item) => item.name), required: resourceBundleRef.promptRefs.filter((item) => item.required === true).map((item) => item.name), valuesPrinted: false } : { count: 0, names: [], required: [], valuesPrinted: false },
|
||||
requiredSkills: resourceBundleRef.requiredSkills ? { count: resourceBundleRef.requiredSkills.length, names: resourceBundleRef.requiredSkills.map((item) => item.name), valuesPrinted: false } : { count: 0, names: [], valuesPrinted: false },
|
||||
submodules: resourceBundleRef.submodules ?? false,
|
||||
lfs: resourceBundleRef.lfs ?? false,
|
||||
credentialRef: resourceBundleRef.credentialRef ? { name: resourceBundleRef.credentialRef.name, namespace: resourceBundleRef.credentialRef.namespace ?? null, keys: resourceBundleRef.credentialRef.keys ?? [], valuesPrinted: false } : null,
|
||||
|
||||
@@ -117,7 +117,7 @@ export function failureKindFromError(error: unknown): FailureKind {
|
||||
|
||||
export function terminalStatusForFailure(failureKind: FailureKind): TerminalStatus {
|
||||
if (failureKind === "cancelled") return "cancelled";
|
||||
if (failureKind === "secret-unavailable" || failureKind === "tenant-policy-denied" || failureKind === "schema-invalid" || failureKind === "prompt-unavailable" || failureKind === "prompt-too-large" || failureKind === "skill-unavailable") return "blocked";
|
||||
if (failureKind === "secret-unavailable" || failureKind === "tenant-policy-denied" || failureKind === "schema-invalid" || failureKind === "prompt-unavailable" || failureKind === "prompt-too-large" || failureKind === "required-skill-unavailable" || failureKind === "skill-unavailable") return "blocked";
|
||||
return "failed";
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ interface MaterializedSkillRef {
|
||||
manifestBytes: number;
|
||||
manifestSha256: string;
|
||||
summary: string;
|
||||
sourceBundle: JsonRecord | null;
|
||||
}
|
||||
|
||||
interface GitCheckout {
|
||||
@@ -91,7 +92,8 @@ export async function materializeResourceBundle(resourceBundleRef: ResourceBundl
|
||||
const defaultCheckout = await checkoutFor(defaultSource);
|
||||
const materializedBundles = await materializeGitBundles(workspacePath, resourceBundleRef, defaultSource, defaultCheckout, checkoutFor);
|
||||
const tools = await prepareGitBundleTools(workspacePath, env);
|
||||
const skills = await discoverGitBundleSkills(workspacePath);
|
||||
const skills = await discoverGitBundleSkills(workspacePath, materializedBundles);
|
||||
const requiredSkills = materializeRequiredSkills(resourceBundleRef.requiredSkills ?? [], skills.items);
|
||||
const prompts = await materializePromptRefs(defaultCheckout.checkoutPath, resourceBundleRef.promptRefs ?? []);
|
||||
const initialPrompt = assembleInitialPrompt(prompts.items, skills.items);
|
||||
return {
|
||||
@@ -116,6 +118,7 @@ export async function materializeResourceBundle(resourceBundleRef: ResourceBundl
|
||||
},
|
||||
tools: tools.event,
|
||||
skillDirs: skills.event,
|
||||
requiredSkills: requiredSkills.event,
|
||||
promptRefs: prompts.event,
|
||||
initialPrompt: initialPrompt?.summary ?? { available: false, bytes: 0, sha256: null, promptRefCount: prompts.items.length, skillCount: skills.items.length, valuesPrinted: false },
|
||||
valuesPrinted: false,
|
||||
@@ -215,7 +218,7 @@ async function prepareGitBundleTools(workspacePath: string, env: NodeJS.ProcessE
|
||||
const names: string[] = [];
|
||||
const items: JsonRecord[] = [];
|
||||
if (installedBinPath) await mkdir(installedBinPath, { recursive: true });
|
||||
for (const entry of entries) {
|
||||
for (const entry of [...entries].sort((left, right) => left.name.localeCompare(right.name))) {
|
||||
if (!entry.isFile()) continue;
|
||||
const filePath = path.join(sourceBinPath, entry.name);
|
||||
const text = await readFile(filePath, "utf8");
|
||||
@@ -283,7 +286,7 @@ async function materializePromptRefs(checkoutPath: string, refs: NonNullable<Res
|
||||
};
|
||||
}
|
||||
|
||||
async function discoverGitBundleSkills(workspacePath: string): Promise<{ items: MaterializedSkillRef[]; skillsDir?: string; event: JsonRecord }> {
|
||||
async function discoverGitBundleSkills(workspacePath: string, bundles: MaterializedGitBundle[]): Promise<{ items: MaterializedSkillRef[]; skillsDir?: string; event: JsonRecord }> {
|
||||
const skillsDir = path.join(workspacePath, ".agents", "skills");
|
||||
let entries;
|
||||
try {
|
||||
@@ -294,22 +297,24 @@ async function discoverGitBundleSkills(workspacePath: string): Promise<{ items:
|
||||
}
|
||||
const items: MaterializedSkillRef[] = [];
|
||||
const eventItems: JsonRecord[] = [];
|
||||
for (const entry of entries) {
|
||||
for (const entry of [...entries].sort((left, right) => left.name.localeCompare(right.name))) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const aggregateAs = entry.name;
|
||||
const relativeManifestPath = `.agents/skills/${aggregateAs}/SKILL.md`;
|
||||
const manifestPath = path.join(skillsDir, aggregateAs, "SKILL.md");
|
||||
const sourceBundle = skillSourceBundle(relativeManifestPath, bundles);
|
||||
let manifestText: string;
|
||||
try {
|
||||
manifestText = await readFile(manifestPath, "utf8");
|
||||
} catch (error) {
|
||||
eventItems.push({ name: aggregateAs, path: `.agents/skills/${aggregateAs}/SKILL.md`, required: true, aggregateAs, status: "missing", error: fileErrorSummary(error), valuesPrinted: false });
|
||||
eventItems.push({ name: aggregateAs, path: relativeManifestPath, required: true, aggregateAs, status: "missing", error: fileErrorSummary(error), sourceBundle, valuesPrinted: false });
|
||||
continue;
|
||||
}
|
||||
const bytes = Buffer.byteLength(manifestText, "utf8");
|
||||
const sha = sha256Text(manifestText);
|
||||
const summary = skillSummary(manifestText);
|
||||
items.push({ name: aggregateAs, path: `.agents/skills/${aggregateAs}/SKILL.md`, aggregateAs, required: true, registryPath: manifestPath, manifestBytes: bytes, manifestSha256: sha, summary });
|
||||
eventItems.push({ name: aggregateAs, path: `.agents/skills/${aggregateAs}/SKILL.md`, aggregateAs, required: true, status: "materialized", manifestSha256: sha, manifestBytes: bytes, registryPath: pathSummary(manifestPath), summary, valuesPrinted: false });
|
||||
items.push({ name: aggregateAs, path: relativeManifestPath, aggregateAs, required: true, registryPath: manifestPath, manifestBytes: bytes, manifestSha256: sha, summary, sourceBundle });
|
||||
eventItems.push({ name: aggregateAs, path: relativeManifestPath, aggregateAs, required: true, status: "materialized", manifestSha256: sha, manifestBytes: bytes, registryPath: pathSummary(manifestPath), sourceBundle, summary, valuesPrinted: false });
|
||||
}
|
||||
return {
|
||||
items,
|
||||
@@ -325,6 +330,70 @@ async function discoverGitBundleSkills(workspacePath: string): Promise<{ items:
|
||||
};
|
||||
}
|
||||
|
||||
function materializeRequiredSkills(refs: NonNullable<ResourceBundleRef["requiredSkills"]>, skills: MaterializedSkillRef[]): { items: MaterializedSkillRef[]; event: JsonRecord } {
|
||||
if (refs.length === 0) return { items: [], event: { count: 0, materializedCount: 0, names: [], items: [], valuesPrinted: false } };
|
||||
const byName = new Map(skills.map((skill) => [skill.name, skill]));
|
||||
const items: MaterializedSkillRef[] = [];
|
||||
const eventItems: JsonRecord[] = [];
|
||||
const missing: JsonRecord[] = [];
|
||||
const missingNames: string[] = [];
|
||||
for (const ref of refs) {
|
||||
const skill = byName.get(ref.name);
|
||||
if (!skill) {
|
||||
const item = { name: ref.name, path: `.agents/skills/${ref.name}/SKILL.md`, status: "missing", valuesPrinted: false };
|
||||
missingNames.push(ref.name);
|
||||
missing.push(item);
|
||||
eventItems.push(item);
|
||||
continue;
|
||||
}
|
||||
items.push(skill);
|
||||
eventItems.push({ name: skill.name, path: skill.path, aggregateAs: skill.aggregateAs, status: "materialized", manifestSha256: skill.manifestSha256, manifestBytes: skill.manifestBytes, sourceBundle: skill.sourceBundle, summary: skill.summary, valuesPrinted: false });
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
throw new AgentRunError("required-skill-unavailable", `required resource skill ${missingNames.join(", ")} is not materialized`, {
|
||||
httpStatus: 400,
|
||||
details: {
|
||||
required: refs.map((ref) => ref.name),
|
||||
missing,
|
||||
available: skills.map((skill) => ({ name: skill.name, path: skill.path, manifestSha256: skill.manifestSha256, manifestBytes: skill.manifestBytes, sourceBundle: skill.sourceBundle, valuesPrinted: false })),
|
||||
valuesPrinted: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
return {
|
||||
items,
|
||||
event: {
|
||||
count: refs.length,
|
||||
materializedCount: items.length,
|
||||
names: items.map((item) => item.name),
|
||||
items: eventItems,
|
||||
valuesPrinted: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function skillSourceBundle(relativePath: string, bundles: MaterializedGitBundle[]): JsonRecord | null {
|
||||
const match = bundles
|
||||
.filter((bundle) => relativePathMatchesTarget(relativePath, bundle.targetPath))
|
||||
.sort((left, right) => right.targetPath.length - left.targetPath.length)[0];
|
||||
if (!match) return null;
|
||||
return {
|
||||
name: match.name,
|
||||
repoUrl: match.repoUrl,
|
||||
commitId: match.commitId,
|
||||
requestedCommitId: match.requestedCommitId,
|
||||
requestedRef: match.requestedRef,
|
||||
subpath: match.subpath,
|
||||
targetPath: match.targetPath,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function relativePathMatchesTarget(relativePath: string, targetPath: string): boolean {
|
||||
const normalizedTarget = targetPath.replace(/\/+$/u, "");
|
||||
return relativePath === normalizedTarget || relativePath.startsWith(`${normalizedTarget}/`);
|
||||
}
|
||||
|
||||
function assembleInitialPrompt(promptRefs: MaterializedPromptRef[], skills: MaterializedSkillRef[]): InitialPromptAssembly | undefined {
|
||||
if (promptRefs.length === 0 && skills.length === 0) return undefined;
|
||||
const sections: string[] = [
|
||||
|
||||
+17
-5
@@ -30,6 +30,13 @@ interface CommandExecutionResult extends JsonRecord {
|
||||
failureKind: FailureKind | null;
|
||||
}
|
||||
|
||||
interface RunnerFailure {
|
||||
terminalStatus: TerminalStatus;
|
||||
failureKind: FailureKind;
|
||||
message: string;
|
||||
details?: JsonRecord | null;
|
||||
}
|
||||
|
||||
export async function runOnce(options: RunnerOnceOptions): Promise<JsonRecord> {
|
||||
const api = new RunnerManagerApi(options.managerUrl);
|
||||
const targetRun = await api.getRun(options.runId);
|
||||
@@ -70,7 +77,7 @@ export async function runOnce(options: RunnerOnceOptions): Promise<JsonRecord> {
|
||||
let resourceEnv: NodeJS.ProcessEnv | undefined;
|
||||
let initialPrompt: InitialPromptAssembly | undefined;
|
||||
let materializationAttempted = false;
|
||||
let materializationFailure: { failureKind: FailureKind; terminalStatus: TerminalStatus; message: string } | null = null;
|
||||
let materializationFailure: RunnerFailure | null = null;
|
||||
let backendSession: BackendSession | null = null;
|
||||
|
||||
try {
|
||||
@@ -104,7 +111,7 @@ export async function runOnce(options: RunnerOnceOptions): Promise<JsonRecord> {
|
||||
}
|
||||
} catch (error) {
|
||||
const failureKind = failureKindFromError(error);
|
||||
materializationFailure = { failureKind, terminalStatus: terminalStatusForFailure(failureKind), message: errorMessage(error) };
|
||||
materializationFailure = { failureKind, terminalStatus: terminalStatusForFailure(failureKind), message: errorMessage(error), details: failureDetailsFromError(error) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,6 +314,10 @@ async function reportNonTerminalCommandFailure(api: RunnerManagerApi, runId: str
|
||||
await api.reportCommandStatus(commandId, { terminalStatus: failure.terminalStatus, failureKind: failure.failureKind, failureMessage: failure.message, ...(control ? { threadId: control.threadId, turnId: control.turnId } : {}) });
|
||||
}
|
||||
|
||||
function failureDetailsFromError(error: unknown): JsonRecord | null {
|
||||
return error instanceof AgentRunError ? error.details : null;
|
||||
}
|
||||
|
||||
function steerPrompt(payload: JsonRecord): string | null {
|
||||
for (const key of ["prompt", "message", "text"]) {
|
||||
const value = payload[key];
|
||||
@@ -466,9 +477,10 @@ function annotateCommandEvent(event: BackendEvent, commandId: string, attemptId:
|
||||
return { ...event, payload: { ...event.payload, commandId, attemptId, runnerId } };
|
||||
}
|
||||
|
||||
async function reportCommandFailure(api: RunnerManagerApi, runId: string, commandId: string, runner: RunnerRecord, attemptId: string, failure: { terminalStatus: TerminalStatus; failureKind: FailureKind; message: string }, phase: string, options: { terminalRun?: boolean } = {}): Promise<CommandExecutionResult> {
|
||||
await api.appendEvent(runId, { type: "error", payload: { failureKind: failure.failureKind, message: failure.message, phase, commandId, attemptId, runnerId: runner.id } });
|
||||
await api.appendEvent(runId, { type: "terminal_status", payload: { terminalStatus: failure.terminalStatus, failureKind: failure.failureKind, message: failure.message, commandId, attemptId, runnerId: runner.id } });
|
||||
async function reportCommandFailure(api: RunnerManagerApi, runId: string, commandId: string, runner: RunnerRecord, attemptId: string, failure: RunnerFailure, phase: string, options: { terminalRun?: boolean } = {}): Promise<CommandExecutionResult> {
|
||||
const details = failure.details ? { details: failure.details } : {};
|
||||
await api.appendEvent(runId, { type: "error", payload: { failureKind: failure.failureKind, message: failure.message, phase, commandId, attemptId, runnerId: runner.id, ...details } });
|
||||
await api.appendEvent(runId, { type: "terminal_status", payload: { terminalStatus: failure.terminalStatus, failureKind: failure.failureKind, message: failure.message, commandId, attemptId, runnerId: runner.id, ...details } });
|
||||
await api.reportCommandStatus(commandId, { terminalStatus: failure.terminalStatus, failureKind: failure.failureKind, failureMessage: failure.message });
|
||||
if (options.terminalRun === true) await api.reportStatus(runId, { terminalStatus: failure.terminalStatus, failureKind: failure.failureKind, failureMessage: failure.message });
|
||||
return { commandId, terminalStatus: failure.terminalStatus, failureKind: failure.failureKind } as CommandExecutionResult;
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { JsonRecord, ResourceBundleRef } from "../../common/types.js";
|
||||
import { assertNoSecretLeak, type SelfTestCase, type SelfTestContext } from "../harness.js";
|
||||
|
||||
const execFile = promisify(execFileCallback);
|
||||
type LocalBundle = { repoUrl: string; commitId: string; branch?: string; bundles?: ResourceBundleRef["bundles"]; promptRefs?: ResourceBundleRef["promptRefs"] };
|
||||
type LocalBundle = { repoUrl: string; commitId: string; branch?: string; bundles?: ResourceBundleRef["bundles"]; promptRefs?: ResourceBundleRef["promptRefs"]; requiredSkills?: ResourceBundleRef["requiredSkills"] };
|
||||
type RefResolvedBundle = LocalBundle & { branch: string; latestCommitId: string };
|
||||
|
||||
const selfTest: SelfTestCase = async (context) => {
|
||||
@@ -93,12 +93,19 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin
|
||||
assert.equal(((resultEnvelope.sessionRef as JsonRecord).threadId), "thread_selftest_1");
|
||||
assert.equal(((resultEnvelope.resourceBundleRef as JsonRecord).commitId), bundle.commitId);
|
||||
assert.equal(((resultEnvelope.resourceBundleRef as JsonRecord).kind), "gitbundle");
|
||||
assert.deepEqual((((resultEnvelope.resourceBundleRef as JsonRecord).requiredSkills as JsonRecord).names), ["dad-dev"]);
|
||||
const resultBundleTargets = (((resultEnvelope.resourceBundleRef as JsonRecord).bundles as JsonRecord).items as JsonRecord[]).map((item) => item.targetPath);
|
||||
assert.deepEqual(resultBundleTargets, ["tools", ".agents/skills"]);
|
||||
const materialized = ((resultEnvelope.resourceBundleRef as JsonRecord).materialized as JsonRecord);
|
||||
assert.deepEqual(((materialized.tools as JsonRecord).names), ["hwpod"]);
|
||||
assert.equal(((materialized.tools as JsonRecord).installed), true);
|
||||
assert.deepEqual(((materialized.skillDirs as JsonRecord).names), ["hwpod-cli", "hwpod-ctl"]);
|
||||
assert.deepEqual(((materialized.skillDirs as JsonRecord).names), ["dad-dev", "hwpod-cli", "hwpod-ctl"]);
|
||||
const requiredSkillItems = ((materialized.requiredSkills as JsonRecord).items as JsonRecord[]);
|
||||
assert.deepEqual(((materialized.requiredSkills as JsonRecord).names), ["dad-dev"]);
|
||||
assert.equal(requiredSkillItems[0]?.status, "materialized");
|
||||
assert.equal(typeof requiredSkillItems[0]?.manifestSha256, "string");
|
||||
assert.equal(typeof requiredSkillItems[0]?.manifestBytes, "number");
|
||||
assert.equal(((requiredSkillItems[0]?.sourceBundle as JsonRecord).commitId), bundle.commitId);
|
||||
assertNoSecretLeak(resultEnvelope);
|
||||
|
||||
const refBundle = await createRefResolvedLocalGitBundle(context);
|
||||
@@ -129,6 +136,7 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin
|
||||
const firstAssemblyInput = turnInputText(assemblyInputs[0]);
|
||||
const secondAssemblyInput = turnInputText(assemblyInputs[1]);
|
||||
assert.match(firstAssemblyInput, /HWLAB v0\.2 runtime prompt self-test/u);
|
||||
assert.match(firstAssemblyInput, /dad-dev/u);
|
||||
assert.match(firstAssemblyInput, /hwpod-cli/u);
|
||||
assert.match(firstAssemblyInput, /hwpod-ctl/u);
|
||||
assert.match(firstAssemblyInput, /hwpod-compiler-cli/u);
|
||||
@@ -149,7 +157,8 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin
|
||||
assert.deepEqual(assemblyBundleTargets, ["tools", ".agents/skills"]);
|
||||
const assemblyMaterialized = assemblyResource.materialized as JsonRecord;
|
||||
assert.deepEqual(((assemblyMaterialized.promptRefs as JsonRecord).names), ["hwlab-v02-runtime"]);
|
||||
assert.deepEqual(((assemblyMaterialized.skillDirs as JsonRecord).names), ["hwpod-cli", "hwpod-ctl"]);
|
||||
assert.deepEqual(((assemblyMaterialized.skillDirs as JsonRecord).names), ["dad-dev", "hwpod-cli", "hwpod-ctl"]);
|
||||
assert.deepEqual(((assemblyMaterialized.requiredSkills as JsonRecord).names), ["dad-dev"]);
|
||||
assert.equal(((assemblyMaterialized.initialPrompt as JsonRecord).available), true);
|
||||
assertNoSecretLeak(assemblyEnvelope);
|
||||
|
||||
@@ -158,6 +167,21 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin
|
||||
assert.equal(missingPromptResult.terminalStatus, "blocked");
|
||||
assert.equal(missingPromptResult.failureKind, "prompt-unavailable");
|
||||
|
||||
const missingSkillRun = await createHwlabRun(client, context, { ...bundle, bundles: [{ name: "hwlab-tools", subpath: "tools", targetPath: "tools" }], requiredSkills: [{ name: "dad-dev" }] }, "hwlab-session-missing-skill", "missing required skill", "hwlab-command-missing-skill");
|
||||
const missingSkillResult = await runOnce({ managerUrl: server.baseUrl, runId: missingSkillRun.runId, codexCommand: context.fakeCodexCommand, codexArgs: context.fakeCodexArgs, codexHome: context.codexHome, env: { CODEX_HOME: context.codexHome, AGENTRUN_WORKSPACE_ROOT: path.join(context.tmp, "workspaces-missing-skill") }, oneShot: true }) as JsonRecord;
|
||||
assert.equal(missingSkillResult.terminalStatus, "blocked");
|
||||
assert.equal(missingSkillResult.failureKind, "required-skill-unavailable");
|
||||
const missingSkillEnvelope = await client.get(`/api/v1/runs/${missingSkillRun.runId}/commands/${missingSkillRun.commandId}/result`) as JsonRecord;
|
||||
assert.equal(missingSkillEnvelope.terminalStatus, "blocked");
|
||||
assert.equal(missingSkillEnvelope.terminalSource, "terminal_status-event");
|
||||
assert.equal(missingSkillEnvelope.failureKind, "required-skill-unavailable");
|
||||
assert.deepEqual((((missingSkillEnvelope.resourceBundleRef as JsonRecord).requiredSkills as JsonRecord).names), ["dad-dev"]);
|
||||
const missingSkillBlocker = missingSkillEnvelope.blocker as JsonRecord;
|
||||
assert.equal(missingSkillBlocker.failureKind, "required-skill-unavailable");
|
||||
assert.deepEqual(((missingSkillBlocker.details as JsonRecord).required as string[]), ["dad-dev"]);
|
||||
assert.deepEqual((((missingSkillBlocker.details as JsonRecord).missing as JsonRecord[]).map((item) => item.name)), ["dad-dev"]);
|
||||
assertNoSecretLeak(missingSkillEnvelope);
|
||||
|
||||
const resumed = await createHwlabRun(client, context, bundle, "hwlab-session-resume", "hello resumed", "hwlab-command-session-resumed");
|
||||
const resumedRun = await client.get(`/api/v1/runs/${resumed.runId}`) as JsonRecord;
|
||||
assert.equal(((resumedRun.sessionRef as JsonRecord).threadId), "thread_selftest_1");
|
||||
@@ -237,7 +261,7 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin
|
||||
const runningResult = await running;
|
||||
assert.equal(runningResult.terminalStatus, "cancelled");
|
||||
|
||||
return { name: "hwlab-manual-dispatch", tests: ["runner-job-idempotency", "pending-cancel", "result-envelope", "session-ref-resume", "resource-gitbundle-materialization", "gitbundle-ref-resolution", "gitbundle-tools-path", "gitbundle-skill-dir-assembly", "resource-prompt-required-blocker", "same-run-runner-multiturn", "running-steer", "idle-after-tool-liveness", "running-cancel"] };
|
||||
return { name: "hwlab-manual-dispatch", tests: ["runner-job-idempotency", "pending-cancel", "result-envelope", "session-ref-resume", "resource-gitbundle-materialization", "gitbundle-ref-resolution", "gitbundle-tools-path", "gitbundle-skill-dir-assembly", "resource-prompt-required-blocker", "resource-required-skill-blocker", "same-run-runner-multiturn", "running-steer", "idle-after-tool-liveness", "running-cancel"] };
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.server.close(() => resolve()));
|
||||
}
|
||||
@@ -262,6 +286,15 @@ async function createLocalGitBundle(context: SelfTestContext, repoName = "bundle
|
||||
"Compile D601-F103-V2 through hwpod-cli -> hwpod-compiler-cli -> /v1/hwpod-node-ops -> hwpod-node.",
|
||||
"Do not invent fallback hardware paths or legacy profile routes.",
|
||||
].join("\n"), "utf8");
|
||||
await mkdir(path.join(repo, "skills", "dad-dev"), { recursive: true });
|
||||
await writeFile(path.join(repo, "skills", "dad-dev", "SKILL.md"), [
|
||||
"---",
|
||||
"name: dad-dev",
|
||||
"description: Track dad-dev issue progress and validate delivery evidence for AgentRun work.",
|
||||
"---",
|
||||
"# dad-dev",
|
||||
"Record P1-P4 evidence and remaining risk for AgentRun issue work.",
|
||||
].join("\n"), "utf8");
|
||||
await mkdir(path.join(repo, "skills", "hwpod-cli", "scripts"), { recursive: true });
|
||||
await writeFile(path.join(repo, "skills", "hwpod-cli", "SKILL.md"), [
|
||||
"---",
|
||||
@@ -282,10 +315,10 @@ async function createLocalGitBundle(context: SelfTestContext, repoName = "bundle
|
||||
"Use hwpod-ctl for HWPOD runtime inspection and control-plane state.",
|
||||
].join("\n"), "utf8");
|
||||
await writeFile(path.join(repo, "skills", "hwpod-ctl", "scripts", "hwpod-ctl.mjs"), "console.log(JSON.stringify({ ok: true, cli: 'hwpod-ctl-skill-selftest' }));\n", "utf8");
|
||||
await execFile("git", ["add", "README.md", "tools/hwpod", "tools/hwpod-cli.ts", "tools/src/hwpod-harness-lib.ts", "tools/hwpod-node.test.ts", "internal/agent/prompts/hwlab-v02-runtime.md", "skills/hwpod-cli/SKILL.md", "skills/hwpod-cli/scripts/hwpod-cli.mjs", "skills/hwpod-ctl/SKILL.md", "skills/hwpod-ctl/scripts/hwpod-ctl.mjs"], { cwd: repo });
|
||||
await execFile("git", ["add", "README.md", "tools/hwpod", "tools/hwpod-cli.ts", "tools/src/hwpod-harness-lib.ts", "tools/hwpod-node.test.ts", "internal/agent/prompts/hwlab-v02-runtime.md", "skills/dad-dev/SKILL.md", "skills/hwpod-cli/SKILL.md", "skills/hwpod-cli/scripts/hwpod-cli.mjs", "skills/hwpod-ctl/SKILL.md", "skills/hwpod-ctl/scripts/hwpod-ctl.mjs"], { cwd: repo });
|
||||
await execFile("git", ["-c", "user.email=selftest@example.invalid", "-c", "user.name=AgentRun SelfTest", "commit", "-m", "bundle selftest"], { cwd: repo });
|
||||
const { stdout } = await execFile("git", ["rev-parse", "HEAD"], { cwd: repo });
|
||||
return { repoUrl: repo, commitId: stdout.trim() };
|
||||
return { repoUrl: repo, commitId: stdout.trim(), requiredSkills: [{ name: "dad-dev" }] };
|
||||
}
|
||||
|
||||
async function createRefResolvedLocalGitBundle(context: SelfTestContext): Promise<RefResolvedBundle> {
|
||||
@@ -304,6 +337,7 @@ async function createRefResolvedLocalGitBundle(context: SelfTestContext): Promis
|
||||
async function createHwlabRun(client: ManagerClient, context: SelfTestContext, bundle: LocalBundle, sessionId: string, prompt: string, idempotencyKey: string, timeoutMs = 15_000): Promise<{ runId: string; commandId: string }> {
|
||||
const resourceBundleRef: ResourceBundleRef = { kind: "gitbundle", repoUrl: bundle.repoUrl, commitId: bundle.commitId, bundles: bundle.bundles ?? defaultGitBundles(), submodules: false, lfs: false };
|
||||
if (bundle.promptRefs) resourceBundleRef.promptRefs = bundle.promptRefs;
|
||||
if (bundle.requiredSkills) resourceBundleRef.requiredSkills = bundle.requiredSkills;
|
||||
const run = await client.post("/api/v1/runs", {
|
||||
tenantId: "hwlab",
|
||||
projectId: "pikasTech/HWLAB",
|
||||
|
||||
Reference in New Issue
Block a user