fix: 将缺失辅助 skill 降级为告警

This commit is contained in:
AgentRun Codex
2026-07-17 16:18:53 +02:00
parent 5d7ede0754
commit 91e13cd180
6 changed files with 81 additions and 33 deletions
+1 -1
View File
@@ -144,7 +144,7 @@ spec:
- name: git-spec
- name: unidesk-trans
- name: unidesk-gh
- name: unidesk-code-queue
- name: unidesk-agentrun
- name: unidesk-cicd
- name: unidesk-subagent
- name: unidesk-decision
+37 -17
View File
@@ -45,6 +45,17 @@ interface MaterializedSkillRef {
sourceBundle: JsonRecord | null;
}
interface ResourceSkillWarning extends JsonRecord {
code: "required-skill-unavailable";
severity: "warning";
scope: "resource-bundle.requiredSkills";
message: string;
missingNames: string[];
materializedNames: string[];
blocking: false;
valuesPrinted: false;
}
interface GitCheckout {
repoUrl: string;
fetchRepoUrl: string;
@@ -242,6 +253,7 @@ export async function materializeResourceBundle(resourceBundleRef: ResourceBundl
tools: tools.event,
skillDirs: skills.event,
requiredSkills: requiredSkills.event,
warnings: requiredSkills.warnings,
promptRefs: prompts.event,
initialPrompt: initialPrompt?.summary ?? { available: false, bytes: 0, sha256: null, promptRefCount: prompts.items.length, skillCount: skills.items.length, valuesPrinted: false },
valuesPrinted: false,
@@ -969,45 +981,53 @@ async function discoverGitBundleSkills(overlayRoot: string, bundles: Materialize
};
}
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 } };
function materializeRequiredSkills(refs: NonNullable<ResourceBundleRef["requiredSkills"]>, skills: MaterializedSkillRef[]): { items: MaterializedSkillRef[]; event: JsonRecord; warnings: JsonRecord } {
if (refs.length === 0) {
return {
items: [],
event: { count: 0, materializedCount: 0, missingCount: 0, names: [], materializedNames: [], missingNames: [], items: [], valuesPrinted: false },
warnings: { count: 0, 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 };
const item = { name: ref.name, path: `.agents/skills/${ref.name}/SKILL.md`, status: "missing", warning: "required-skill-unavailable", severity: "warning", blocking: false, 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,
},
});
}
const materializedNames = items.map((item) => item.name);
const warning: ResourceSkillWarning | null = missingNames.length > 0 ? {
code: "required-skill-unavailable",
severity: "warning",
scope: "resource-bundle.requiredSkills",
message: `resource skill ${missingNames.join(", ")} is not materialized; continuing with available skills`,
missingNames,
materializedNames,
blocking: false,
valuesPrinted: false,
} : null;
return {
items,
event: {
count: refs.length,
materializedCount: items.length,
names: items.map((item) => item.name),
missingCount: missingNames.length,
names: materializedNames,
materializedNames,
missingNames,
items: eventItems,
valuesPrinted: false,
},
warnings: { count: warning ? 1 : 0, items: warning ? [warning] : [], valuesPrinted: false },
};
}
+1 -1
View File
@@ -205,7 +205,7 @@ function requiresBundledWorkReadyTools(run: RunRecord): boolean {
const toolCredentials = run.executionPolicy.secretScope.toolCredentials ?? [];
if (toolCredentials.some((item) => item.tool === "unidesk-ssh" || item.tool === "github")) return true;
const requiredSkills = run.resourceBundleRef?.requiredSkills ?? [];
return requiredSkills.some((item) => item.name === "unidesk-trans" || item.name === "unidesk-gh" || item.name === "unidesk-code-queue" || item.name === "unidesk-cicd" || item.name === "dad-dev");
return requiredSkills.some((item) => item.name === "unidesk-trans" || item.name === "unidesk-gh" || item.name === "unidesk-agentrun" || item.name === "unidesk-cicd" || item.name === "dad-dev");
}
function withResourceAssembly(options: RunnerOnceOptions, resourceEnv: NodeJS.ProcessEnv | undefined, initialPrompt: InitialPromptAssembly | undefined): RunnerOnceOptions {
+32 -12
View File
@@ -232,19 +232,39 @@ process.exit(1);
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 missingSkillRun = await createHwlabRun(client, context, { ...bundle, requiredSkills: [{ name: "dad-dev" }, { name: "unavailable-skill" }] }, "hwlab-session-missing-skill", "continue with available 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");
assert.equal(missingSkillResult.terminalStatus, "completed");
assert.equal(missingSkillResult.failureKind, null);
const missingSkillEvents = await client.get(`/api/v1/runs/${missingSkillRun.runId}/events?afterSeq=0&limit=100`) as { items?: Array<{ type?: string; payload?: JsonRecord }> };
const missingSkillEvent = (missingSkillEvents.items ?? []).find((event) => event.type === "backend_status" && event.payload?.phase === "resource-bundle-materialized")?.payload as JsonRecord;
assert.equal((((missingSkillEvent.warnings as JsonRecord).items as JsonRecord[])[0]?.severity), "warning");
assert.deepEqual((((missingSkillEvent.requiredSkills as JsonRecord).missingNames)), ["unavailable-skill"]);
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"]);
assert.equal(missingSkillEnvelope.terminalStatus, "completed");
assert.equal(missingSkillEnvelope.reply, "fake codex stdio reply");
assert.equal(missingSkillEnvelope.failureKind, null);
assert.deepEqual((((missingSkillEnvelope.resourceBundleRef as JsonRecord).requiredSkills as JsonRecord).names), ["dad-dev", "unavailable-skill"]);
const missingSkillMaterialized = ((missingSkillEnvelope.resourceBundleRef as JsonRecord).materialized as JsonRecord);
const missingRequiredSkills = missingSkillMaterialized.requiredSkills as JsonRecord;
assert.deepEqual(missingRequiredSkills.materializedNames, ["dad-dev"]);
assert.deepEqual(missingRequiredSkills.missingNames, ["unavailable-skill"]);
assert.equal(missingRequiredSkills.materializedCount, 1);
assert.equal(missingRequiredSkills.missingCount, 1);
const missingSkillWarnings = missingSkillMaterialized.warnings as JsonRecord;
assert.equal(missingSkillWarnings.count, 1);
assert.deepEqual((missingSkillWarnings.items as JsonRecord[])[0], {
code: "required-skill-unavailable",
severity: "warning",
scope: "resource-bundle.requiredSkills",
message: "resource skill unavailable-skill is not materialized; continuing with available skills",
missingNames: ["unavailable-skill"],
materializedNames: ["dad-dev"],
blocking: false,
valuesPrinted: false,
});
assert.equal(((missingRequiredSkills.items as JsonRecord[]).find((item) => item.name === "dad-dev"))?.status, "materialized");
assert.equal(((missingRequiredSkills.items as JsonRecord[]).find((item) => item.name === "unavailable-skill"))?.severity, "warning");
assertNoSecretLeak(missingSkillEnvelope);
const resumed = await createHwlabRun(client, context, bundle, "hwlab-session-resume", "hello resumed", "hwlab-command-session-resumed");
@@ -443,7 +463,7 @@ process.exit(1);
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", "resource-required-skill-blocker", "same-run-runner-multiturn", "running-steer", "running-steer-user-message-target-trace", "backend-retry-replays-durable-steers", "backend-replay-barrier-fast-terminal", "backend-replay-rpc-failure-retries", "rebuilt-runner-resumes-acknowledged-turn", "no-event-watchdog-after-tool", "tool-output-hard-timeout", "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-warning", "same-run-runner-multiturn", "running-steer", "running-steer-user-message-target-trace", "backend-retry-replays-durable-steers", "backend-replay-barrier-fast-terminal", "backend-replay-rpc-failure-retries", "rebuilt-runner-resumes-acknowledged-turn", "no-event-watchdog-after-tool", "tool-output-hard-timeout", "running-cancel"] };
} finally {
await new Promise<void>((resolve) => server.server.close(() => resolve()));
}
@@ -41,6 +41,8 @@ const selfTest: SelfTestCase = async (context) => {
assert.match(effectivePrompt, /runner Target /u);
assert.match(effectivePrompt, / NC01/u);
assert.ok(requiredSkills.some((item) => item.name === "unidesk-trans"), "Artificer must require the unidesk-trans skill");
assert.ok(requiredSkills.some((item) => item.name === "unidesk-agentrun"), "Artificer must require the canonical unidesk-agentrun skill");
assert.equal(requiredSkills.some((item) => item.name === "unidesk-code-queue"), false, "Artificer must not retain the retired unidesk-code-queue skill name");
assert.ok(bundles.some((item) => item.name === "agentrun-runner-tools" && item.targetPath === "tools"), "Artificer must materialize the bundled trans executable");
const unideskSsh = toolCredentials.find((item) => item.tool === "unidesk-ssh" && item.purpose === "ssh-passthrough");
assert.ok(unideskSsh, "Artificer must bind the unidesk-ssh route credential");
@@ -49,7 +51,7 @@ const selfTest: SelfTestCase = async (context) => {
assert.equal(effectivePrompt.endsWith(userPrompt), true, "rendered prompt must preserve the complete user business prompt");
assert.equal(effectivePrompt.split(userPrompt).length - 1, 1, "rendered prompt must include the user business prompt exactly once");
return { name: "artificer-nonrecursive-prompt", tests: ["artificer-yaml-nonrecursive-prompt-render", "artificer-target-trans-prompt-render", "artificer-trans-skill-tool-and-secretref-bound", "artificer-user-prompt-preserved"] };
return { name: "artificer-nonrecursive-prompt", tests: ["artificer-yaml-nonrecursive-prompt-render", "artificer-target-trans-prompt-render", "artificer-canonical-agentrun-skill-render", "artificer-trans-skill-tool-and-secretref-bound", "artificer-user-prompt-preserved"] };
};
export default selfTest;
+7 -1
View File
@@ -77,6 +77,12 @@ const selfTest: SelfTestCase = async (context) => {
const imageSmoke = await smokeImageWorkReadyCapabilities(smokeEnv);
assert.equal(((imageSmoke.smoke as { ok?: unknown }).ok), true);
assert.equal(imageSmoke.valuesPrinted, false);
const missingImageToolBin = path.join(context.tmp, "missing-work-ready-bin");
await mkdir(missingImageToolBin, { recursive: true });
await assert.rejects(
() => smokeImageWorkReadyCapabilities({ PATH: missingImageToolBin, AGENTRUN_SELFTEST_WORK_READY_BIN_PATH: " " }),
(error) => error instanceof Error && /runner image is not work-ready; missing required image tools/u.test(error.message),
);
const bundleSmoke = await smokeBundledWorkReadyCapabilities(smokeEnv);
assert.equal(((bundleSmoke.smoke as { ok?: unknown }).ok), true);
assert.equal(bundleSmoke.valuesPrinted, false);
@@ -88,7 +94,7 @@ const selfTest: SelfTestCase = async (context) => {
assert.equal(isDigestPinnedImage("127.0.0.1:5000/agentrun/agentrun-mgr@sha256:1111111111111111111111111111111111111111111111111111111111111111"), true);
assert.equal(isDigestPinnedImage("127.0.0.1:5000/agentrun/agentrun-mgr:self-test"), false);
return { name: "90-runner-image-tools", tests: ["runner image installs required CLI tools", "runner image build verifies work-ready tools", "gitbundle tran delegates to the primary UniDesk workspace", "runner apply-patch helper is bundled", "runner agentrun-git helper is bundled", "work-ready smoke runs without printing secrets", "aipod imageRef validates env image source identity"] };
return { name: "90-runner-image-tools", tests: ["runner image installs required CLI tools", "runner image build verifies work-ready tools", "gitbundle tran delegates to the primary UniDesk workspace", "runner apply-patch helper is bundled", "runner agentrun-git helper is bundled", "work-ready smoke runs without printing secrets", "runner image missing tools fail closed", "aipod imageRef validates env image source identity"] };
};
async function createFakeToolBin(dir: string): Promise<string> {