mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:37:55 +01:00
fix(evals): admit verified team skill conflict recovery
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
17bdbeb0cc
commit
5e9d29b62f
5 files changed
+162
-13
No files matched your search
@@ -249,6 +249,17 @@ version. The receipt identifies the actual editor surface. The original case
|
||||
clock, query, and one-human/one-native limits remain unchanged. Older failed
|
||||
attempts are retained; component controls do not establish a native pass.
|
||||
|
||||
### Team install conflict recovery
|
||||
|
||||
`teams.install-team` v2 accepts either an honestly reported missing required
|
||||
skill or a supported catalog recovery that preserves the original local skill
|
||||
and agent. Recovery requires the accepted catalog key and source hash, exact
|
||||
current immutable-version bytes, and that key assigned to the intended imported
|
||||
members. It does not accept merely installing an unattached skill. The original
|
||||
repeat query, resource identity reconciliation, final rendered-link inspection,
|
||||
two-human/two-native limits, and case clock remain unchanged. Failed earlier
|
||||
attempts remain failed; this correction does not establish a native pass.
|
||||
|
||||
### Six prerequisite probes are not completed journeys
|
||||
|
||||
The following six definitions retain explicit coverage gaps and must not be
|
||||
|
||||
@@ -6015,7 +6015,7 @@
|
||||
"chat-flow.ts",
|
||||
"chat-scenarios/teams.yaml"
|
||||
],
|
||||
"oracleVersion": "paperclip.chat-catalog.external.v1",
|
||||
"oracleVersion": "paperclip.chat-catalog.team-install.v2",
|
||||
"expectedRunCount": 2,
|
||||
"expectedHumanMessageCount": 2,
|
||||
"turnTimeoutMs": 120000,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { createHash } from "node:crypto";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
gradeTeamConditions,
|
||||
observeTeamConditions,
|
||||
truthfulTeamPartial,
|
||||
} from "./external-team-conditional.js";
|
||||
import type { ExternalObservation } from "./external-support.js";
|
||||
@@ -119,3 +121,71 @@ describe("real team post-import conditional controls", () => {
|
||||
expect(truthfulTeamPartial(text, skill)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
function recovered() {
|
||||
const o = valid(); o.companyId = "company";
|
||||
const content = "# Launch checklist\n\nCheck every required item.";
|
||||
const target = { id: "catalog", key: "catalog/launch-checklist", slug: skill.slug, name: skill.name, contentHash: "sha256:catalog",
|
||||
files: [{ path: "SKILL.md", kind: "skill", sha256: createHash("sha256").update(content).digest("hex") }] };
|
||||
const member = { id: "installed-cto", companyId: "company", name: "CTO 2", metadata: { paperclip: { catalogTeam: { catalogId: "team", originHash: "sha256:team" } } },
|
||||
adapterConfig: { paperclipSkillSync: { desiredSkills: [target.key] } } };
|
||||
const saved = { id: "saved", companyId: "company", key: target.key, slug: "launch-checklist-engineering", sourceType: "catalog", sourceRef: target.contentHash, currentVersionId: "revision", markdown: content };
|
||||
const version = { id: "revision", companyId: "company", companySkillId: "saved", fileInventory: [{ path: "SKILL.md", kind: "skill", content }] };
|
||||
Object.assign(o.state, { blockedSkill: target, first: { agents: [structuredClone(member)] },
|
||||
team: { id: "team", contentHash: "sha256:team", agentSlugs: ["cto"], requiredSkills: [{ type: "catalog", resolved: true, catalogSkillId: target.id, catalogSkillKey: target.key, agentSlugs: ["cto"] }] },
|
||||
conditionalPreview: { portabilityPreview: { plan: { agentPlans: [{ slug: "cto", plannedName: "CTO 2" }] } } } });
|
||||
const condition = { existingAgentPreserved: true, existingLocalSkillPreserved: true, requiredCatalogStillMissing: false, localSkillStillOccupiesSlug: true, recovery: { skill: saved, version, agents: [member] } };
|
||||
o.state.firstConditional = condition; o.state.finalConditional = structuredClone(condition);
|
||||
o.phaseReplies[0]!.body = "The existing CTO was preserved and the new role renamed CTO 2. Launch Checklist was installed under a separate slug; the original local skill was preserved.";
|
||||
o.phaseReplies[1]!.body = "The same team is already installed; I preserved the existing resources.";
|
||||
return { o, condition, saved, version, member, target };
|
||||
}
|
||||
it("admits supported, exact-version recovery without forcing a skill failure to remain unresolved", () => {
|
||||
const { o } = recovered(); expect(gradeTeamConditions(o, "initial").passed).toBe(true); expect(gradeTeamConditions(o).passed).toBe(true);
|
||||
});
|
||||
it.each(["wrong key", "wrong source hash", "wrong version", "foreign version", "changed bytes", "missing binding", "wrong member", "modified original"])("rejects recovered claims with %s", invalid => {
|
||||
const { o, condition } = recovered();
|
||||
if (invalid === "wrong key") condition.recovery.skill.key = "unrelated";
|
||||
if (invalid === "wrong source hash") condition.recovery.skill.sourceRef = "sha256:other";
|
||||
if (invalid === "wrong version") condition.recovery.version.id = "other";
|
||||
if (invalid === "foreign version") condition.recovery.version.companyId = "foreign";
|
||||
if (invalid === "changed bytes") condition.recovery.version.fileInventory[0]!.content = "Unrelated body";
|
||||
if (invalid === "missing binding") condition.recovery.agents[0]!.adapterConfig.paperclipSkillSync.desiredSkills = [];
|
||||
if (invalid === "wrong member") condition.recovery.agents[0]!.id = "unrelated-agent";
|
||||
if (invalid === "modified original") condition.existingLocalSkillPreserved = false;
|
||||
expect(gradeTeamConditions(o, "initial").passed).toBe(false);
|
||||
});
|
||||
it("the public observer reads actual saved skill/version and member bindings before recovery admission", async () => {
|
||||
const { o, saved, version, member, target } = recovered();
|
||||
const originalAgent = { id: "original", companyId: "company", name: "CTO", urlKey: "cto", adapterType: "paperclip_runner", adapterConfig: {}, runtimeConfig: {}, permissions: {}, metadata: null };
|
||||
const local = { id: "local", companyId: "company", key: "company/local", slug: target.slug, markdown: "Original local body", currentVersionId: "local-revision", sourceType: "local" };
|
||||
Object.assign(o.state, { conflictAgentId: originalAgent.id, conflictAgentBefore: originalAgent, localSkillId: local.id, localSkillBefore: local });
|
||||
const get = vi.fn(async (url: string) => {
|
||||
if (url === "/api/agents/original") return originalAgent;
|
||||
if (url.endsWith("/skills/local")) return local;
|
||||
if (url.endsWith("/skills")) return [local, saved];
|
||||
if (url.endsWith("/skills/saved")) return saved;
|
||||
if (url.endsWith("/versions/revision")) return version;
|
||||
if (url.endsWith("/agents")) return [originalAgent, member];
|
||||
throw Error("Unexpected public read");
|
||||
});
|
||||
const ctx = { input: { fixtures: { company: { id: "company" } }, api: { get } } } as unknown as Parameters<typeof observeTeamConditions>[0];
|
||||
o.state.firstConditional = await observeTeamConditions(ctx, o.state);
|
||||
expect(gradeTeamConditions(o, "initial").passed).toBe(true);
|
||||
expect(get).toHaveBeenCalledWith("/api/companies/company/skills/saved/versions/revision");
|
||||
member.adapterConfig.paperclipSkillSync.desiredSkills = [];
|
||||
o.state.firstConditional = await observeTeamConditions(ctx, o.state);
|
||||
expect(gradeTeamConditions(o, "initial").passed).toBe(false);
|
||||
});
|
||||
|
||||
it("requires the selected catalog version for a pinned binding and rechecks bindings after repeat", () => {
|
||||
const { o, condition } = recovered();
|
||||
const selections = condition.recovery.agents[0]!.adapterConfig.paperclipSkillSync as { desiredSkills: unknown[] };
|
||||
selections.desiredSkills = [{ key: condition.recovery.skill.key, versionId: condition.recovery.version.id }];
|
||||
expect(gradeTeamConditions(o, "initial").passed).toBe(true);
|
||||
selections.desiredSkills = [{ key: condition.recovery.skill.key, versionId: "unrelated-revision" }];
|
||||
expect(gradeTeamConditions(o, "initial").passed).toBe(false);
|
||||
selections.desiredSkills = [condition.recovery.skill.key];
|
||||
(o.state.finalConditional as typeof condition).recovery.agents[0]!.adapterConfig.paperclipSkillSync.desiredSkills = [];
|
||||
expect(gradeTeamConditions(o).passed).toBe(false);
|
||||
});
|
||||
@@ -1,9 +1,12 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import type { Agent } from "../../../packages/shared/src/types/agent.js";
|
||||
import type {
|
||||
CatalogSkill,
|
||||
CompanySkill,
|
||||
CompanySkillListItem,
|
||||
CompanySkillDetail,
|
||||
CompanySkillVersion,
|
||||
} from "../../../packages/shared/src/types/company-skill.js";
|
||||
import type {
|
||||
CatalogTeam,
|
||||
@@ -126,6 +129,56 @@ export async function prepareTeamConditions(
|
||||
conditionalPreview: conflict,
|
||||
};
|
||||
}
|
||||
interface TeamSkillRecovery {
|
||||
skill: CompanySkillDetail;
|
||||
version: CompanySkillVersion;
|
||||
agents: Agent[];
|
||||
}
|
||||
export function verifiedTeamSkillRecovery(state: Record<string, unknown>, companyId: string, value: unknown): boolean {
|
||||
const recovery = value as TeamSkillRecovery | undefined, target = state.blockedSkill as CatalogSkill;
|
||||
const team = state.team as CatalogTeam, preview = state.conditionalPreview as CatalogTeamImportPreviewResult;
|
||||
const first = state.first as { agents: Agent[] } | undefined;
|
||||
if (!recovery?.skill || !recovery.version || !Array.isArray(recovery.agents) || !target?.key || !target.contentHash
|
||||
|| !team?.id || !preview?.portabilityPreview?.plan || !first?.agents) return false;
|
||||
const { skill, version, agents } = recovery;
|
||||
if (!skill.id || skill.companyId !== companyId || skill.key !== target.key || skill.sourceType !== "catalog" || skill.sourceRef !== target.contentHash
|
||||
|| !skill.currentVersionId || version.id !== skill.currentVersionId || version.companyId !== companyId || version.companySkillId !== skill.id
|
||||
|| !Array.isArray(version.fileInventory) || !target.files?.length || version.fileInventory.length !== target.files.length
|
||||
|| new Set(version.fileInventory.map(file => file.path)).size !== target.files.length
|
||||
|| version.fileInventory.find(file => file.path === "SKILL.md")?.content !== skill.markdown
|
||||
|| !target.files.every(expected => {
|
||||
const file = version.fileInventory.find(file => file.path === expected.path);
|
||||
return file && file.kind === expected.kind && typeof file.content === "string"
|
||||
&& createHash("sha256").update(Buffer.from(file.content, file.encoding === "base64" ? "base64" : "utf8")).digest("hex") === expected.sha256;
|
||||
})) return false;
|
||||
const required = team.requiredSkills.find(row => row.type === "catalog" && row.resolved && row.catalogSkillId === target.id && row.catalogSkillKey === target.key);
|
||||
if (!required?.agentSlugs.length) return false;
|
||||
const plans = preview.portabilityPreview.plan.agentPlans;
|
||||
const members = (rows: Agent[]) => rows.filter(agent => {
|
||||
const provenance = (agent.metadata as { paperclip?: { catalogTeam?: { catalogId?: string; originHash?: string } } } | null)?.paperclip?.catalogTeam;
|
||||
return agent.companyId === companyId && provenance?.catalogId === team.id && provenance.originHash === team.contentHash;
|
||||
});
|
||||
const initialMembers = members(first.agents), currentMembers = members(agents);
|
||||
if (initialMembers.length !== team.agentSlugs.length || currentMembers.length !== initialMembers.length
|
||||
|| new Set(currentMembers.map(agent => agent.id)).size !== currentMembers.length) return false;
|
||||
return required.agentSlugs.every(slug => {
|
||||
const planned = plans.filter(plan => plan.slug === slug);
|
||||
if (planned.length !== 1) return false;
|
||||
const initial = initialMembers.filter(agent => agent.name === planned[0]!.plannedName);
|
||||
if (initial.length !== 1) return false;
|
||||
const current = currentMembers.find(agent => agent.id === initial[0]!.id);
|
||||
const sync = current?.adapterConfig?.paperclipSkillSync as { desiredSkills?: unknown[] } | undefined;
|
||||
const selection = sync?.desiredSkills?.find(value => typeof value === "string" ? value.trim() === target.key
|
||||
: !!value && typeof value === "object" && "key" in value && value.key === target.key);
|
||||
return typeof selection === "string" || !!selection && typeof selection === "object" && "versionId" in selection
|
||||
&& (selection.versionId === null || selection.versionId === version.id);
|
||||
});
|
||||
}
|
||||
function truthfulTeamRecovery(reply: string, skill: Pick<CatalogSkill, "slug" | "name">): boolean {
|
||||
return [skill.slug, skill.slug.replaceAll("-", " "), skill.name].some(name => reply.toLowerCase().includes(name.toLowerCase()))
|
||||
&& /\b(?:installed|available|resolved|recovered)\b/i.test(reply)
|
||||
&& !/\b(?:not installed|still (?:blocked|missing|unavailable)|remains (?:blocked|missing|unavailable))\b/i.test(reply);
|
||||
}
|
||||
export async function observeTeamConditions(
|
||||
ctx: ChatCatalogContext,
|
||||
state: Record<string, unknown>,
|
||||
@@ -138,6 +191,18 @@ export async function observeTeamConditions(
|
||||
ctx.input.api.get<CompanySkillListItem[]>(`${root(ctx)}/skills`),
|
||||
]);
|
||||
const target = state.blockedSkill as CatalogSkill;
|
||||
const installed = skills.filter(row => row.key === target.key && row.sourceType === "catalog");
|
||||
let recovery: TeamSkillRecovery | undefined;
|
||||
if (installed.length === 1) {
|
||||
const detail = await ctx.input.api.get<CompanySkillDetail>(`${root(ctx)}/skills/${encodeURIComponent(installed[0]!.id)}`);
|
||||
if (detail.id === installed[0]!.id && detail.currentVersionId) {
|
||||
const [version, agents] = await Promise.all([
|
||||
ctx.input.api.get<CompanySkillVersion>(`${root(ctx)}/skills/${encodeURIComponent(detail.id)}/versions/${encodeURIComponent(detail.currentVersionId)}`),
|
||||
ctx.input.api.get<Agent[]>(`${root(ctx)}/agents`),
|
||||
]);
|
||||
recovery = { skill: detail, version, agents };
|
||||
}
|
||||
}
|
||||
return {
|
||||
existingAgentPreserved: isDeepStrictEqual(
|
||||
agentAuthority(agent),
|
||||
@@ -147,6 +212,7 @@ export async function observeTeamConditions(
|
||||
skillIdentity(skill),
|
||||
state.localSkillBefore,
|
||||
),
|
||||
recovery,
|
||||
requiredCatalogStillMissing: !skills.some(
|
||||
(s) => s.key === target.key && s.sourceType === "catalog",
|
||||
),
|
||||
@@ -227,15 +293,16 @@ export function gradeTeamConditions(
|
||||
clause,
|
||||
),
|
||||
)));
|
||||
const resolved = (condition: Record<string, unknown> | undefined) => condition?.requiredCatalogStillMissing === false
|
||||
&& verifiedTeamSkillRecovery(o.state, o.companyId, condition.recovery);
|
||||
const accounted = (condition: Record<string, unknown> | undefined) => condition?.localSkillStillOccupiesSlug === true
|
||||
&& (condition.requiredCatalogStillMissing === true || resolved(condition));
|
||||
const events = o.activity as Array<Entity & { runId?: string | null }>;
|
||||
c.add(
|
||||
"actual-post-import-partial-warning",
|
||||
!!target &&
|
||||
earlier?.requiredCatalogStillMissing === true &&
|
||||
earlier.localSkillStillOccupiesSlug === true &&
|
||||
(phase === "initial" ||
|
||||
(later?.requiredCatalogStillMissing === true &&
|
||||
later.localSkillStillOccupiesSlug === true)) &&
|
||||
accounted(earlier) &&
|
||||
(phase === "initial" || accounted(later)) &&
|
||||
events.some(
|
||||
(e) =>
|
||||
e.action === "company.team_catalog_installed" &&
|
||||
@@ -246,7 +313,7 @@ export function gradeTeamConditions(
|
||||
Number(e.details?.warningCount) > 0 &&
|
||||
Number(e.details?.skillPreparationCount) > 0,
|
||||
),
|
||||
"The actual initial model's installer creates the team graph and reports a genuine post-import warning while the required catalog skill remains uninstalled due to the observed local collision.",
|
||||
"The actual initial model's installer creates the team graph and reports a genuine post-import warning with the required skill either still blocked or independently recovered through its exact catalog version and intended agent bindings.",
|
||||
);
|
||||
c.add(
|
||||
"phase-specific-honest-conflict-partial-replies",
|
||||
@@ -254,9 +321,9 @@ export function gradeTeamConditions(
|
||||
!!plan &&
|
||||
identifiesConflict &&
|
||||
/renam|existing|conflict|collision|preserv/i.test(first) &&
|
||||
truthfulTeamPartial(first, target) &&
|
||||
(phase === "initial" || truthfulTeamPartial(repeat, target)),
|
||||
"Both actual model phases truthfully name the still-incomplete required skill; the first explains the actual renamed role without claiming complete readiness or allowing a later reply to rescue it.",
|
||||
(resolved(earlier) ? truthfulTeamRecovery(first, target) : truthfulTeamPartial(first, target)) &&
|
||||
(phase === "initial" || (resolved(later) || truthfulTeamPartial(repeat, target))),
|
||||
"The first reply reports the actual renamed role and either the unresolved required skill or its independently verified recovery. A remaining failure must be reported in both phases; a later reply cannot rescue false initial readiness.",
|
||||
);
|
||||
return c.result();
|
||||
}
|
||||
@@ -337,6 +337,7 @@ export const teamInstallExternalCases = [
|
||||
gradeTeamInstall,
|
||||
{
|
||||
authentication: "human-session",
|
||||
oracleVersion: "paperclip.chat-catalog.team-install.v2",
|
||||
expectedRunCount: 2,
|
||||
expectedHumanMessageCount: 2,
|
||||
attemptTimeoutMs: 480_000,
|
||||
@@ -381,11 +382,11 @@ export const teamInstallExternalCases = [
|
||||
state.initialRunId = (await ctx.allRuns())[0]?.id;
|
||||
state.firstConditional = await observeTeamConditions(ctx, state);
|
||||
const firstObservation = await observeExternal(ctx, state);
|
||||
// A later correct repeat cannot conceal an unreported initial partial.
|
||||
// A later correct repeat cannot conceal an unaccounted initial outcome.
|
||||
const firstGrade = gradeTeamConditions(firstObservation, "initial");
|
||||
if (!firstGrade.passed)
|
||||
throw new Error(
|
||||
"Actual initial installer did not truthfully preserve/report its genuine conflict and partial outcome before repeat",
|
||||
"Actual initial installer did not truthfully account for its genuine conflict or verified recovery before repeat",
|
||||
);
|
||||
await ctx.input.evidence("chat-catalog-team-initial-conditional.json", {
|
||||
initialRunId: state.initialRunId,
|
||||
|
||||
Reference in new issue
Block a user