feat(runner): declare ACPX driver profile (#12390)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - A native driver must declare its supported behavior before the coordinator can select it. > - ACP-compatible agents do not all expose the same event families. > - A loose config could also select an unqualified model, permission policy, or executable field. > - This pull request defines the ACPX descriptor, capability matrix, and strict configuration parser. > - The benefit is one reviewable admission contract before any ACPX runtime is wired. ## Linked Issues or Issue Description **Agent or provider** Qualified Pi, Claude, and Codex ACP servers through the internal ACPX driver. **Why this adapter is useful** The runner needs a truthful capability descriptor and a closed configuration boundary before it can create an ACPX session. The boundary must reject arbitrary commands and unqualified models. **How the agent is invoked** A later pull request will implement the private runtime behind this descriptor. This pull request does not launch a process, add a dependency, register an adapter, or make ACPX selectable. **Additional context** This pull request is stacked on #12389. Pi reports plan events as unsupported. Claude and Codex report structured plan support. ## What Changed - Add the ACPX driver descriptor and native runtime-context capability declaration. - Add an agent-specific typed event capability matrix. - Add strict config validation for agent, exact qualified model, and permission mode. - Default the permission mode to `approve-all` when the field is absent. - Reject non-object config, unknown fields, unqualified models, and unsupported permission values. - Add table-driven tests for all qualified agents and failure cases. ## Verification - Runner TypeScript typecheck — passed. - Runner TypeScript tests — passed, including 8 new driver-profile assertions. - `pnpm -r typecheck` — passed for all applicable workspaces. - `pnpm build` — passed, including runner binary, server, UI, and workspace packages. - Prettier and `git diff --check` — passed. - The diff contains 2 files and does not change `pnpm-lock.yaml`, a workflow, a dependency, a public export, server selection, or UI behavior. ## Risks The main risk is advertising a capability that a qualified ACP server cannot supply. The matrix is explicit per agent, and tests cover the one current difference. Config validation rejects unknown fields so a caller cannot smuggle an executable or an unsupported runtime setting into this boundary. ## Model Used OpenAI Codex with GPT-5 and repository tool use. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either linked an existing public item or described the issue in this PR - [x] I have not referenced internal or instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal task identifier - [x] I have run the affected tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have documented the compatibility and admission boundary - [ ] All applicable GitHub Actions are green - [ ] Greptile is 5/5 with every actionable comment resolved - [x] I will address all review findings before requesting merge
This commit is contained in:
parent
7a8c6825a5
commit
da6124adf2
|
|
@ -0,0 +1,104 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
acpxCapabilities,
|
||||
acpxDriverDescriptor,
|
||||
validateAcpxDriverConfig,
|
||||
} from "./driver-profile.js";
|
||||
|
||||
describe("ACPX driver profile", () => {
|
||||
it.each([
|
||||
["codex", "available"],
|
||||
["claude", "available"],
|
||||
["pi", "unsupported"],
|
||||
] as const)(
|
||||
"advertises structured plans for %s as %s",
|
||||
(agent, availability) => {
|
||||
const plan = acpxCapabilities(agent).typedEventFamilies?.find(
|
||||
(family) => family.family === "plan",
|
||||
);
|
||||
expect(plan).toMatchObject({
|
||||
availability,
|
||||
detailLevel: availability === "available" ? "structured" : "summary",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("describes only implemented ACPX capability boundaries", () => {
|
||||
expect(acpxDriverDescriptor("claude")).toMatchObject({
|
||||
kind: "acpx_runtime",
|
||||
displayName: "Claude via ACPX",
|
||||
version: "0.13.1",
|
||||
protocolVersion: "acp/v1",
|
||||
runtimeContextCapabilities: {
|
||||
instructions: "native",
|
||||
skills: "native",
|
||||
mcp: "native",
|
||||
},
|
||||
capabilities: {
|
||||
resume: true,
|
||||
steering: false,
|
||||
interruption: true,
|
||||
dynamicTools: true,
|
||||
runtimeRequestResolution: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["pi", "openrouter/deepseek/deepseek-v4-flash-0731"],
|
||||
["claude", "claude-sonnet-5"],
|
||||
["codex", "gpt-5.6-sol"],
|
||||
] as const)("accepts the exact qualified %s model", (agent, model) => {
|
||||
expect(validateAcpxDriverConfig({ agent, model })).toEqual({
|
||||
ok: true,
|
||||
config: { agent, model, permissionMode: "approve-all" },
|
||||
issues: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed for unknown fields and unqualified settings", () => {
|
||||
expect(validateAcpxDriverConfig(null)).toMatchObject({
|
||||
ok: false,
|
||||
issues: [{ code: "invalid_config" }],
|
||||
});
|
||||
expect(
|
||||
validateAcpxDriverConfig({
|
||||
agent: "codex",
|
||||
model: "gpt-5.6-sol",
|
||||
command: "/tmp/arbitrary-provider",
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
issues: [{ path: "command", code: "unknown_field" }],
|
||||
});
|
||||
expect(
|
||||
validateAcpxDriverConfig({ agent: "codex", model: "other" }),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
issues: [{ path: "model", code: "invalid_model" }],
|
||||
});
|
||||
expect(
|
||||
validateAcpxDriverConfig({
|
||||
agent: "claude",
|
||||
model: "claude-sonnet-5",
|
||||
permissionMode: "unrestricted",
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
issues: [{ path: "permissionMode", code: "invalid_permission_mode" }],
|
||||
});
|
||||
for (const permissionMode of ["", 42, undefined]) {
|
||||
expect(
|
||||
validateAcpxDriverConfig({
|
||||
agent: "claude",
|
||||
model: "claude-sonnet-5",
|
||||
permissionMode,
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
issues: [{ path: "permissionMode", code: "invalid_permission_mode" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
import type {
|
||||
HarnessDriverConfigValidation,
|
||||
HarnessDriverDescriptor,
|
||||
} from "../../contracts/harness-driver.js";
|
||||
import type { NativeAcpxPermissionMode } from "../../contracts/native-execution.js";
|
||||
import type { NativeSessionCapabilities } from "../../contracts/types.js";
|
||||
import { providerFamilyCapabilities } from "../../provider-events.js";
|
||||
import {
|
||||
ACPX_DRIVER_KIND,
|
||||
QUALIFIED_ACPX_VERSION,
|
||||
resolveQualifiedAcpxProfile,
|
||||
type QualifiedAcpxAgent,
|
||||
} from "./qualified-profiles.js";
|
||||
|
||||
const ACPX_AGENTS = ["pi", "claude", "codex"] as const;
|
||||
const ACPX_PERMISSION_MODES = [
|
||||
"approve-all",
|
||||
"approve-reads",
|
||||
"deny-all",
|
||||
] as const;
|
||||
const ACPX_CONFIG_FIELDS = new Set(["agent", "model", "permissionMode"]);
|
||||
|
||||
export interface ValidatedAcpxDriverConfig extends Record<string, unknown> {
|
||||
agent: QualifiedAcpxAgent;
|
||||
model: string;
|
||||
permissionMode: NativeAcpxPermissionMode;
|
||||
}
|
||||
|
||||
export function acpxCapabilities(
|
||||
agent: QualifiedAcpxAgent,
|
||||
): NativeSessionCapabilities {
|
||||
return {
|
||||
resume: true,
|
||||
typedEvents: true,
|
||||
typedEventFamilies: providerFamilyCapabilities({
|
||||
plan: agent === "pi" ? "unsupported" : "available",
|
||||
tool_execution: "available",
|
||||
model_identity: "available",
|
||||
review: "available",
|
||||
provider_notice: "available",
|
||||
artifact: "policy_disabled",
|
||||
}),
|
||||
steering: false,
|
||||
interruption: true,
|
||||
structuredResult: true,
|
||||
read: true,
|
||||
reconciliation: true,
|
||||
usage: true,
|
||||
dynamicTools: true,
|
||||
runtimeRequestResolution: true,
|
||||
runtimeRequestHandoff: true,
|
||||
goals: false,
|
||||
threadLineage: false,
|
||||
unsupported: ["steering", "goals", "threadLineage"],
|
||||
};
|
||||
}
|
||||
|
||||
export function acpxDriverDescriptor(
|
||||
agent: QualifiedAcpxAgent,
|
||||
): HarnessDriverDescriptor {
|
||||
return {
|
||||
kind: ACPX_DRIVER_KIND,
|
||||
displayName: `${displayAgent(agent)} via ACPX`,
|
||||
version: QUALIFIED_ACPX_VERSION,
|
||||
protocolVersion: "acp/v1",
|
||||
runtimeContextCapabilities: {
|
||||
instructions: "native",
|
||||
skills: "native",
|
||||
mcp: "native",
|
||||
},
|
||||
capabilities: acpxCapabilities(agent),
|
||||
};
|
||||
}
|
||||
|
||||
export function validateAcpxDriverConfig(
|
||||
value: unknown,
|
||||
): HarnessDriverConfigValidation {
|
||||
const config = record(value);
|
||||
if (config === null) {
|
||||
return invalid("", "invalid_config", "ACPX config must be an object.");
|
||||
}
|
||||
const unknownField = Object.keys(config).find(
|
||||
(field) => !ACPX_CONFIG_FIELDS.has(field),
|
||||
);
|
||||
if (unknownField !== undefined) {
|
||||
return invalid(
|
||||
unknownField,
|
||||
"unknown_field",
|
||||
`ACPX config does not support ${unknownField}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const agent = text(config.agent);
|
||||
if (!isAcpxAgent(agent)) {
|
||||
return invalid(
|
||||
"agent",
|
||||
"invalid_agent",
|
||||
"ACPX agent must be pi, claude, or codex.",
|
||||
);
|
||||
}
|
||||
const model = text(config.model);
|
||||
try {
|
||||
resolveQualifiedAcpxProfile(agent, model);
|
||||
} catch (error) {
|
||||
return invalid("model", "invalid_model", safeErrorMessage(error));
|
||||
}
|
||||
const permissionMode = Object.prototype.hasOwnProperty.call(
|
||||
config,
|
||||
"permissionMode",
|
||||
)
|
||||
? text(config.permissionMode)
|
||||
: "approve-all";
|
||||
if (!isPermissionMode(permissionMode)) {
|
||||
return invalid(
|
||||
"permissionMode",
|
||||
"invalid_permission_mode",
|
||||
"ACPX permission mode must be approve-all, approve-reads, or deny-all.",
|
||||
);
|
||||
}
|
||||
|
||||
const validated: ValidatedAcpxDriverConfig = {
|
||||
agent,
|
||||
model,
|
||||
permissionMode,
|
||||
};
|
||||
return { ok: true, config: validated, issues: [] };
|
||||
}
|
||||
|
||||
function invalid(
|
||||
path: string,
|
||||
code: string,
|
||||
message: string,
|
||||
): HarnessDriverConfigValidation {
|
||||
return { ok: false, config: null, issues: [{ path, code, message }] };
|
||||
}
|
||||
|
||||
function isAcpxAgent(value: string): value is QualifiedAcpxAgent {
|
||||
return (ACPX_AGENTS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function isPermissionMode(value: string): value is NativeAcpxPermissionMode {
|
||||
return (ACPX_PERMISSION_MODES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function displayAgent(agent: QualifiedAcpxAgent): string {
|
||||
if (agent === "pi") return "Pi";
|
||||
if (agent === "claude") return "Claude";
|
||||
return "Codex";
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function text(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function safeErrorMessage(error: unknown): string {
|
||||
return error instanceof Error
|
||||
? error.message.slice(0, 1_000)
|
||||
: "Invalid model.";
|
||||
}
|
||||
Loading…
Reference in New Issue