fix: 将缺失辅助 skill 降级为告警
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user