From 79f8a09c934eb89903814e4ecd005feddd3c24c4 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Fri, 3 Jul 2026 19:00:04 -0600 Subject: [PATCH] feat(agent-interface): add AgentProfileDiff contract --- .changeset/full-agentprofile-diffs.md | 5 + packages/agent-interface/src/index.ts | 1 + .../agent-interface/src/profile-diff.test.ts | 148 ++++++++ packages/agent-interface/src/profile-diff.ts | 334 ++++++++++++++++++ .../agent-interface/src/profile-schema.ts | 61 ++++ 5 files changed, 549 insertions(+) create mode 100644 .changeset/full-agentprofile-diffs.md create mode 100644 packages/agent-interface/src/profile-diff.test.ts create mode 100644 packages/agent-interface/src/profile-diff.ts diff --git a/.changeset/full-agentprofile-diffs.md b/.changeset/full-agentprofile-diffs.md new file mode 100644 index 0000000..71b56cc --- /dev/null +++ b/.changeset/full-agentprofile-diffs.md @@ -0,0 +1,5 @@ +--- +"@tangle-network/agent-interface": minor +--- + +Add AgentProfileDiff, a portable full-profile patch contract with apply, prune, changed-axis, and schema validation helpers. diff --git a/packages/agent-interface/src/index.ts b/packages/agent-interface/src/index.ts index 15b1a97..c5382be 100644 --- a/packages/agent-interface/src/index.ts +++ b/packages/agent-interface/src/index.ts @@ -806,6 +806,7 @@ export interface SdkProviderAdapter { } export * from "./interaction.js"; export * from "./agent-profile.js"; +export * from "./profile-diff.js"; export * from "./harness.js"; export * from "./harness-capabilities.js"; export * from "./profile-schema.js"; diff --git a/packages/agent-interface/src/profile-diff.test.ts b/packages/agent-interface/src/profile-diff.test.ts new file mode 100644 index 0000000..8e653b3 --- /dev/null +++ b/packages/agent-interface/src/profile-diff.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from "vitest"; +import { agentProfileDiffSchema } from "./profile-schema.js"; +import { + applyAgentProfileDiff, + changedAgentProfileAxes, + defineAgentProfileDiff, + pruneAgentProfileDiff, +} from "./profile-diff.js"; +import { defineInlineResource, type AgentProfile } from "./agent-profile.js"; + +const baseProfile: AgentProfile = { + name: "baseline", + prompt: { + systemPrompt: "Solve directly.", + instructions: ["Keep answers short."], + }, + model: { + default: "zai/glm-4.7", + reasoningEffort: "low", + }, + tools: { + browser: true, + shell: false, + }, + mcp: { + records: { transport: "http", url: "https://mcp.example.test" }, + }, + resources: { + files: [ + { + path: ".agent-profile/old.md", + resource: defineInlineResource("old", "old"), + }, + ], + skills: [defineInlineResource("read-state", "Read state first.")], + }, + hooks: { + postFinish: [{ command: "pnpm test", blocking: true }], + }, +}; + +describe("AgentProfileDiff", () => { + it("applies a full-profile overlay and named removals", () => { + const diff = defineAgentProfileDiff({ + schemaVersion: 1, + kind: "agent-profile-diff", + id: "stateful-small-model-pack", + set: { + prompt: { + instructions: ["Verify state before DONE."], + }, + model: { + small: "moonshot/kimi-k2", + reasoningEffort: "high", + }, + tools: { + shell: true, + }, + resources: { + files: [ + { + path: ".agent-profile/policy.md", + resource: defineInlineResource("policy", "Read, mutate, verify."), + }, + ], + skills: [defineInlineResource("verify-state", "Check state after writes.")], + }, + hooks: { + preFinish: [{ command: "node verify.mjs", blocking: true }], + }, + }, + remove: { + resources: { + files: [".agent-profile/old.md"], + skills: ["read-state"], + }, + }, + }); + + const profile = applyAgentProfileDiff(baseProfile, diff); + + expect(profile.prompt?.systemPrompt).toBe("Solve directly."); + expect(profile.prompt?.instructions).toEqual([ + "Keep answers short.", + "Verify state before DONE.", + ]); + expect(profile.model).toEqual({ + small: "moonshot/kimi-k2", + reasoningEffort: "high", + }); + expect(profile.tools).toEqual({ browser: true, shell: true }); + expect(profile.resources?.files?.map((file) => file.path)).toEqual([ + ".agent-profile/policy.md", + ]); + expect(profile.resources?.skills?.map((skill) => skill.name)).toEqual([ + "verify-state", + ]); + expect(profile.hooks?.postFinish).toHaveLength(1); + expect(profile.hooks?.preFinish).toHaveLength(1); + }); + + it("reports and prunes changed axes for causal ablations", () => { + const diff = defineAgentProfileDiff({ + schemaVersion: 1, + kind: "agent-profile-diff", + set: { + prompt: { instructions: ["Use evidence first."] }, + model: { reasoningEffort: "medium" }, + resources: { skills: [defineInlineResource("evidence-first", "Use evidence.")] }, + }, + }); + + expect(changedAgentProfileAxes(diff)).toEqual(["model", "prompt", "resources"]); + + const pruned = pruneAgentProfileDiff(diff, ["resources"]); + expect(changedAgentProfileAxes(pruned)).toEqual(["model", "prompt"]); + expect(applyAgentProfileDiff(baseProfile, pruned).resources?.skills?.map((s) => s.name)).toEqual([ + "read-state", + ]); + }); + + it("validates generated diffs with the public schema", () => { + const parsed = agentProfileDiffSchema.parse({ + schemaVersion: 1, + kind: "agent-profile-diff", + source: { + kind: "frontier-author", + artifacts: ["traces://session/example"], + }, + set: { + subagents: { + verifier: { + description: "Check final state.", + prompt: "Verify the answer against observable state.", + model: "zai/glm-4.7", + maxSteps: 2, + }, + }, + }, + remove: { + model: ["small"], + }, + }); + + expect(parsed.source?.kind).toBe("frontier-author"); + expect(changedAgentProfileAxes(parsed)).toEqual(["model", "subagents"]); + }); +}); diff --git a/packages/agent-interface/src/profile-diff.ts b/packages/agent-interface/src/profile-diff.ts new file mode 100644 index 0000000..016478b --- /dev/null +++ b/packages/agent-interface/src/profile-diff.ts @@ -0,0 +1,334 @@ +import type { + AgentProfile, + AgentProfileResources, + AgentProfileResourceRef, +} from "./agent-profile.js"; +import { mergeAgentProfiles } from "./agent-profile.js"; + +export type AgentProfileDiffAxis = + | "identity" + | "prompt" + | "model" + | "permissions" + | "tools" + | "mcp" + | "connections" + | "subagents" + | "resources" + | "hooks" + | "modes" + | "confidential" + | "metadata" + | "extensions"; + +export type AgentProfileRemoveList = true | readonly string[]; + +export interface AgentProfilePromptRemoval { + systemPrompt?: true; + instructions?: AgentProfileRemoveList; +} + +export interface AgentProfileResourceRemoval { + files?: AgentProfileRemoveList; + tools?: AgentProfileRemoveList; + skills?: AgentProfileRemoveList; + agents?: AgentProfileRemoveList; + commands?: AgentProfileRemoveList; + instructions?: true; + failOnError?: true; +} + +export interface AgentProfileDiffRemoval { + identity?: true; + tags?: AgentProfileRemoveList; + prompt?: true | AgentProfilePromptRemoval; + model?: AgentProfileRemoveList; + permissions?: AgentProfileRemoveList; + tools?: AgentProfileRemoveList; + mcp?: AgentProfileRemoveList; + connections?: AgentProfileRemoveList; + subagents?: AgentProfileRemoveList; + resources?: true | AgentProfileResourceRemoval; + hooks?: AgentProfileRemoveList; + modes?: AgentProfileRemoveList; + confidential?: true; + metadata?: AgentProfileRemoveList; + extensions?: AgentProfileRemoveList; +} + +/** + * A portable profile improvement artifact. + * + * `set` is an AgentProfile overlay: profile arrays are appended with the same + * semantics as {@link mergeAgentProfiles}. `remove` deletes whole axes or named + * entries after the overlay is applied. This keeps the optimized unit the full + * AgentProfile instead of a benchmark-specific file mount. + */ +export interface AgentProfileDiff { + schemaVersion: 1; + kind: "agent-profile-diff"; + id?: string; + title?: string; + description?: string; + rationale?: string; + source?: { + kind: + | "trace" + | "frontier-author" + | "human" + | "optimizer" + | "compound"; + artifacts?: readonly string[]; + notes?: readonly string[]; + }; + set?: AgentProfile; + remove?: AgentProfileDiffRemoval; + metadata?: Record; +} + +export function defineAgentProfileDiff(diff: T): T { + return diff; +} + +function asMutable(value: readonly T[] | undefined): T[] | undefined { + return value ? [...value] : undefined; +} + +function removeKeys( + record: T | undefined, + removal: AgentProfileRemoveList | undefined, +): T | undefined { + if (!record || removal === undefined) return record; + if (removal === true) return undefined; + const next: Record = { + ...(record as Record), + }; + for (const key of removal) delete next[key]; + return Object.keys(next).length > 0 ? (next as T) : undefined; +} + +function removeValues( + values: string[] | undefined, + removal: AgentProfileRemoveList | undefined, +): string[] | undefined { + if (!values || removal === undefined) return values; + if (removal === true) return undefined; + const removeSet = new Set(removal); + const next = values.filter((value) => !removeSet.has(value)); + return next.length > 0 ? next : undefined; +} + +function resourceName(resource: AgentProfileResourceRef): string | undefined { + return resource.kind === "inline" ? resource.name : resource.name ?? resource.path; +} + +function removeResourceRefs( + refs: T[] | undefined, + removal: AgentProfileRemoveList | undefined, +): T[] | undefined { + if (!refs || removal === undefined) return refs; + if (removal === true) return undefined; + const removeSet = new Set(removal); + const next = refs.filter((ref) => { + const name = resourceName(ref); + return !(name && removeSet.has(name)); + }); + return next.length > 0 ? next : undefined; +} + +function removeResources( + resources: AgentProfileResources | undefined, + removal: true | AgentProfileResourceRemoval | undefined, +): AgentProfileResources | undefined { + if (!resources || removal === undefined) return resources; + if (removal === true) return undefined; + + const next: AgentProfileResources = { ...resources }; + if (removal.files !== undefined) { + if (removal.files === true) { + next.files = undefined; + } else { + const removeSet = new Set(removal.files); + next.files = next.files?.filter((file) => { + const name = resourceName(file.resource); + return !removeSet.has(file.path) && !(name && removeSet.has(name)); + }); + } + } + next.tools = removeResourceRefs(next.tools, removal.tools); + next.skills = removeResourceRefs(next.skills, removal.skills); + next.agents = removeResourceRefs(next.agents, removal.agents); + next.commands = removeResourceRefs(next.commands, removal.commands); + if (removal.instructions) next.instructions = undefined; + if (removal.failOnError) next.failOnError = undefined; + + for (const key of [ + "files", + "tools", + "skills", + "agents", + "commands", + ] as const) { + if (next[key]?.length === 0) next[key] = undefined; + } + + return Object.values(next).some((value) => value !== undefined) + ? next + : undefined; +} + +function applyRemoval(profile: AgentProfile, remove?: AgentProfileDiffRemoval): AgentProfile { + if (!remove) return profile; + const next: AgentProfile = { ...profile }; + + if (remove.identity) { + next.name = undefined; + next.description = undefined; + next.version = undefined; + } + next.tags = removeValues(asMutable(next.tags), remove.tags); + + if (remove.prompt === true) { + next.prompt = undefined; + } else if (remove.prompt && next.prompt) { + const prompt = { ...next.prompt }; + if (remove.prompt.systemPrompt) prompt.systemPrompt = undefined; + prompt.instructions = removeValues(prompt.instructions, remove.prompt.instructions); + next.prompt = Object.values(prompt).some((value) => value !== undefined) + ? prompt + : undefined; + } + + next.model = removeKeys(next.model, remove.model); + next.permissions = removeKeys(next.permissions, remove.permissions); + next.tools = removeKeys(next.tools, remove.tools); + next.mcp = removeKeys(next.mcp, remove.mcp); + next.subagents = removeKeys(next.subagents, remove.subagents); + next.resources = removeResources(next.resources, remove.resources); + next.hooks = removeKeys(next.hooks, remove.hooks); + next.modes = removeKeys(next.modes, remove.modes); + if (remove.confidential) next.confidential = undefined; + next.metadata = removeKeys(next.metadata, remove.metadata); + next.extensions = removeKeys(next.extensions, remove.extensions); + + if (next.connections && remove.connections !== undefined) { + if (remove.connections === true) { + next.connections = undefined; + } else { + const removeSet = new Set(remove.connections); + const filtered = next.connections.filter( + (connection) => + !removeSet.has(connection.connectionId) && + !(connection.alias && removeSet.has(connection.alias)), + ); + next.connections = filtered.length > 0 ? filtered : undefined; + } + } + + return next; +} + +export function applyAgentProfileDiff( + base: AgentProfile, + diff: AgentProfileDiff, +): AgentProfile { + const merged = mergeAgentProfiles(base, diff.set) ?? {}; + return applyRemoval(merged, diff.remove); +} + +export function changedAgentProfileAxes(diff: AgentProfileDiff): AgentProfileDiffAxis[] { + const axes = new Set(); + const set = diff.set; + if (set) { + if (set.name || set.description || set.version || set.tags) axes.add("identity"); + for (const axis of [ + "prompt", + "model", + "permissions", + "tools", + "mcp", + "connections", + "subagents", + "resources", + "hooks", + "modes", + "confidential", + "metadata", + "extensions", + ] as const) { + if (set[axis] !== undefined) axes.add(axis); + } + } + const remove = diff.remove; + if (remove) { + if (remove.identity || remove.tags) axes.add("identity"); + for (const axis of [ + "prompt", + "model", + "permissions", + "tools", + "mcp", + "connections", + "subagents", + "resources", + "hooks", + "modes", + "confidential", + "metadata", + "extensions", + ] as const) { + if (remove[axis] !== undefined) axes.add(axis); + } + } + return [...axes].sort(); +} + +export function pruneAgentProfileDiff( + diff: AgentProfileDiff, + axesToRemove: readonly AgentProfileDiffAxis[], +): AgentProfileDiff { + const removeSet = new Set(axesToRemove); + const set = diff.set ? { ...diff.set } : undefined; + const remove = diff.remove ? { ...diff.remove } : undefined; + + if (removeSet.has("identity") && set) { + set.name = undefined; + set.description = undefined; + set.version = undefined; + set.tags = undefined; + } + if (removeSet.has("identity") && remove) { + remove.identity = undefined; + remove.tags = undefined; + } + + for (const axis of [ + "prompt", + "model", + "permissions", + "tools", + "mcp", + "connections", + "subagents", + "resources", + "hooks", + "modes", + "confidential", + "metadata", + "extensions", + ] as const) { + if (!removeSet.has(axis)) continue; + if (set) set[axis] = undefined; + if (remove) remove[axis] = undefined; + } + + return { + ...diff, + ...(set && Object.values(set).some((value) => value !== undefined) + ? { set } + : { set: undefined }), + ...(remove && Object.values(remove).some((value) => value !== undefined) + ? { remove } + : { remove: undefined }), + }; +} diff --git a/packages/agent-interface/src/profile-schema.ts b/packages/agent-interface/src/profile-schema.ts index 02b551e..ec18422 100644 --- a/packages/agent-interface/src/profile-schema.ts +++ b/packages/agent-interface/src/profile-schema.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import type { AgentProfile } from "./agent-profile.js"; +import type { AgentProfileDiff } from "./profile-diff.js"; import { SANDBOX_SIZE_PRESET_NAMES, type SandboxSizePreset, @@ -115,6 +116,41 @@ export const agentProfileConnectionSchema = z.object({ alias: z.string().min(1).optional(), }); +const removeListSchema = z.union([z.literal(true), z.array(z.string().min(1))]); + +export const agentProfilePromptRemovalSchema = z.object({ + systemPrompt: z.literal(true).optional(), + instructions: removeListSchema.optional(), +}); + +export const agentProfileResourceRemovalSchema = z.object({ + files: removeListSchema.optional(), + tools: removeListSchema.optional(), + skills: removeListSchema.optional(), + agents: removeListSchema.optional(), + commands: removeListSchema.optional(), + instructions: z.literal(true).optional(), + failOnError: z.literal(true).optional(), +}); + +export const agentProfileDiffRemovalSchema = z.object({ + identity: z.literal(true).optional(), + tags: removeListSchema.optional(), + prompt: z.union([z.literal(true), agentProfilePromptRemovalSchema]).optional(), + model: removeListSchema.optional(), + permissions: removeListSchema.optional(), + tools: removeListSchema.optional(), + mcp: removeListSchema.optional(), + connections: removeListSchema.optional(), + subagents: removeListSchema.optional(), + resources: z.union([z.literal(true), agentProfileResourceRemovalSchema]).optional(), + hooks: removeListSchema.optional(), + modes: removeListSchema.optional(), + confidential: z.literal(true).optional(), + metadata: removeListSchema.optional(), + extensions: removeListSchema.optional(), +}); + /** * The complete provider-neutral agent profile schema — the runtime validator for * the canonical {@link AgentProfile} TS contract. Kept structurally in lock-step @@ -147,6 +183,31 @@ export const agentProfileSchema = z.object({ .optional(), }); +export const agentProfileDiffSchema: z.ZodType = z.object({ + schemaVersion: z.literal(1), + kind: z.literal("agent-profile-diff"), + id: z.string().min(1).optional(), + title: z.string().min(1).optional(), + description: z.string().optional(), + rationale: z.string().optional(), + source: z + .object({ + kind: z.enum([ + "trace", + "frontier-author", + "human", + "optimizer", + "compound", + ]), + artifacts: z.array(z.string().min(1)).optional(), + notes: z.array(z.string()).optional(), + }) + .optional(), + set: agentProfileSchema.optional(), + remove: agentProfileDiffRemovalSchema.optional(), + metadata: z.record(z.string(), z.unknown()).optional(), +}); + // ── Compile-time drift guard ────────────────────────────────────────────────── // The Zod schema and the hand-written {@link AgentProfile} interface must agree in // BOTH directions. If either drifts (a field added to one only, an incompatible