fix: 将缺失辅助 skill 降级为告警
This commit is contained in:
@@ -144,7 +144,7 @@ spec:
|
|||||||
- name: git-spec
|
- name: git-spec
|
||||||
- name: unidesk-trans
|
- name: unidesk-trans
|
||||||
- name: unidesk-gh
|
- name: unidesk-gh
|
||||||
- name: unidesk-code-queue
|
- name: unidesk-agentrun
|
||||||
- name: unidesk-cicd
|
- name: unidesk-cicd
|
||||||
- name: unidesk-subagent
|
- name: unidesk-subagent
|
||||||
- name: unidesk-decision
|
- name: unidesk-decision
|
||||||
|
|||||||
@@ -45,6 +45,17 @@ interface MaterializedSkillRef {
|
|||||||
sourceBundle: JsonRecord | null;
|
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 {
|
interface GitCheckout {
|
||||||
repoUrl: string;
|
repoUrl: string;
|
||||||
fetchRepoUrl: string;
|
fetchRepoUrl: string;
|
||||||
@@ -242,6 +253,7 @@ export async function materializeResourceBundle(resourceBundleRef: ResourceBundl
|
|||||||
tools: tools.event,
|
tools: tools.event,
|
||||||
skillDirs: skills.event,
|
skillDirs: skills.event,
|
||||||
requiredSkills: requiredSkills.event,
|
requiredSkills: requiredSkills.event,
|
||||||
|
warnings: requiredSkills.warnings,
|
||||||
promptRefs: prompts.event,
|
promptRefs: prompts.event,
|
||||||
initialPrompt: initialPrompt?.summary ?? { available: false, bytes: 0, sha256: null, promptRefCount: prompts.items.length, skillCount: skills.items.length, valuesPrinted: false },
|
initialPrompt: initialPrompt?.summary ?? { available: false, bytes: 0, sha256: null, promptRefCount: prompts.items.length, skillCount: skills.items.length, valuesPrinted: false },
|
||||||
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 } {
|
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, names: [], items: [], valuesPrinted: false } };
|
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 byName = new Map(skills.map((skill) => [skill.name, skill]));
|
||||||
const items: MaterializedSkillRef[] = [];
|
const items: MaterializedSkillRef[] = [];
|
||||||
const eventItems: JsonRecord[] = [];
|
const eventItems: JsonRecord[] = [];
|
||||||
const missing: JsonRecord[] = [];
|
|
||||||
const missingNames: string[] = [];
|
const missingNames: string[] = [];
|
||||||
for (const ref of refs) {
|
for (const ref of refs) {
|
||||||
const skill = byName.get(ref.name);
|
const skill = byName.get(ref.name);
|
||||||
if (!skill) {
|
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);
|
missingNames.push(ref.name);
|
||||||
missing.push(item);
|
|
||||||
eventItems.push(item);
|
eventItems.push(item);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
items.push(skill);
|
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 });
|
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) {
|
const materializedNames = items.map((item) => item.name);
|
||||||
throw new AgentRunError("required-skill-unavailable", `required resource skill ${missingNames.join(", ")} is not materialized`, {
|
const warning: ResourceSkillWarning | null = missingNames.length > 0 ? {
|
||||||
httpStatus: 400,
|
code: "required-skill-unavailable",
|
||||||
details: {
|
severity: "warning",
|
||||||
required: refs.map((ref) => ref.name),
|
scope: "resource-bundle.requiredSkills",
|
||||||
missing,
|
message: `resource skill ${missingNames.join(", ")} is not materialized; continuing with available skills`,
|
||||||
available: skills.map((skill) => ({ name: skill.name, path: skill.path, manifestSha256: skill.manifestSha256, manifestBytes: skill.manifestBytes, sourceBundle: skill.sourceBundle, valuesPrinted: false })),
|
missingNames,
|
||||||
valuesPrinted: false,
|
materializedNames,
|
||||||
},
|
blocking: false,
|
||||||
});
|
valuesPrinted: false,
|
||||||
}
|
} : null;
|
||||||
return {
|
return {
|
||||||
items,
|
items,
|
||||||
event: {
|
event: {
|
||||||
count: refs.length,
|
count: refs.length,
|
||||||
materializedCount: items.length,
|
materializedCount: items.length,
|
||||||
names: items.map((item) => item.name),
|
missingCount: missingNames.length,
|
||||||
|
names: materializedNames,
|
||||||
|
materializedNames,
|
||||||
|
missingNames,
|
||||||
items: eventItems,
|
items: eventItems,
|
||||||
valuesPrinted: false,
|
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 ?? [];
|
const toolCredentials = run.executionPolicy.secretScope.toolCredentials ?? [];
|
||||||
if (toolCredentials.some((item) => item.tool === "unidesk-ssh" || item.tool === "github")) return true;
|
if (toolCredentials.some((item) => item.tool === "unidesk-ssh" || item.tool === "github")) return true;
|
||||||
const requiredSkills = run.resourceBundleRef?.requiredSkills ?? [];
|
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 {
|
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.terminalStatus, "blocked");
|
||||||
assert.equal(missingPromptResult.failureKind, "prompt-unavailable");
|
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;
|
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.terminalStatus, "completed");
|
||||||
assert.equal(missingSkillResult.failureKind, "required-skill-unavailable");
|
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;
|
const missingSkillEnvelope = await client.get(`/api/v1/runs/${missingSkillRun.runId}/commands/${missingSkillRun.commandId}/result`) as JsonRecord;
|
||||||
assert.equal(missingSkillEnvelope.terminalStatus, "blocked");
|
assert.equal(missingSkillEnvelope.terminalStatus, "completed");
|
||||||
assert.equal(missingSkillEnvelope.terminalSource, "terminal_status-event");
|
assert.equal(missingSkillEnvelope.reply, "fake codex stdio reply");
|
||||||
assert.equal(missingSkillEnvelope.failureKind, "required-skill-unavailable");
|
assert.equal(missingSkillEnvelope.failureKind, null);
|
||||||
assert.deepEqual((((missingSkillEnvelope.resourceBundleRef as JsonRecord).requiredSkills as JsonRecord).names), ["dad-dev"]);
|
assert.deepEqual((((missingSkillEnvelope.resourceBundleRef as JsonRecord).requiredSkills as JsonRecord).names), ["dad-dev", "unavailable-skill"]);
|
||||||
const missingSkillBlocker = missingSkillEnvelope.blocker as JsonRecord;
|
const missingSkillMaterialized = ((missingSkillEnvelope.resourceBundleRef as JsonRecord).materialized as JsonRecord);
|
||||||
assert.equal(missingSkillBlocker.failureKind, "required-skill-unavailable");
|
const missingRequiredSkills = missingSkillMaterialized.requiredSkills as JsonRecord;
|
||||||
assert.deepEqual(((missingSkillBlocker.details as JsonRecord).required as string[]), ["dad-dev"]);
|
assert.deepEqual(missingRequiredSkills.materializedNames, ["dad-dev"]);
|
||||||
assert.deepEqual((((missingSkillBlocker.details as JsonRecord).missing as JsonRecord[]).map((item) => item.name)), ["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);
|
assertNoSecretLeak(missingSkillEnvelope);
|
||||||
|
|
||||||
const resumed = await createHwlabRun(client, context, bundle, "hwlab-session-resume", "hello resumed", "hwlab-command-session-resumed");
|
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;
|
const runningResult = await running;
|
||||||
assert.equal(runningResult.terminalStatus, "cancelled");
|
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 {
|
} finally {
|
||||||
await new Promise<void>((resolve) => server.server.close(() => resolve()));
|
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, /runner 容器本地执行冒充 Target 原入口验证/u);
|
||||||
assert.match(effectivePrompt, /不得把 NC01、目标绝对路径或第二条执行路径硬编码/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-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");
|
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");
|
const unideskSsh = toolCredentials.find((item) => item.tool === "unidesk-ssh" && item.purpose === "ssh-passthrough");
|
||||||
assert.ok(unideskSsh, "Artificer must bind the unidesk-ssh route credential");
|
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.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");
|
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;
|
export default selfTest;
|
||||||
|
|||||||
@@ -77,6 +77,12 @@ const selfTest: SelfTestCase = async (context) => {
|
|||||||
const imageSmoke = await smokeImageWorkReadyCapabilities(smokeEnv);
|
const imageSmoke = await smokeImageWorkReadyCapabilities(smokeEnv);
|
||||||
assert.equal(((imageSmoke.smoke as { ok?: unknown }).ok), true);
|
assert.equal(((imageSmoke.smoke as { ok?: unknown }).ok), true);
|
||||||
assert.equal(imageSmoke.valuesPrinted, false);
|
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);
|
const bundleSmoke = await smokeBundledWorkReadyCapabilities(smokeEnv);
|
||||||
assert.equal(((bundleSmoke.smoke as { ok?: unknown }).ok), true);
|
assert.equal(((bundleSmoke.smoke as { ok?: unknown }).ok), true);
|
||||||
assert.equal(bundleSmoke.valuesPrinted, false);
|
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@sha256:1111111111111111111111111111111111111111111111111111111111111111"), true);
|
||||||
assert.equal(isDigestPinnedImage("127.0.0.1:5000/agentrun/agentrun-mgr:self-test"), false);
|
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> {
|
async function createFakeToolBin(dir: string): Promise<string> {
|
||||||
|
|||||||
Reference in New Issue
Block a user