Files
PaperClipAI/ui/src/components/ConfigureBuiltInAgentModal.tsx
T
DottaandPaperclip 734c265339 Default new Pi agents to Paperclip Runner (#17142)
## Thinking Path

> - Paperclip manages AI agents and their work.
> - Users choose a harness during agent setup.
> - Claude, Codex, and OpenCode already receive their qualified Runner
default.
> - Pi has a qualified native integration but new Pi agents still use
the direct adapter.
> - This change gives new Pi agents the native default on supported
targets.
> - Saved agents keep their runner and compatible settings. Legacy
remains available in Advanced.

## Linked Issues or Issue Description

Refs #17120, #14921, #14922, #14923, #14924, and #14956. Related generic
default work: #15422. This PR is the separate Pi follow-up to the merged
OpenCode change.

**Current behavior**

Choosing Pi for a new agent saves the direct Pi adapter. The native Pi
integration remains behind the generic experimental flag.

**Proposed behavior**

Default new Pi agents to ACPX Pi on macOS ARM64/x64 and GNU/Linux x64
with supported glibc. Keep unsupported targets and explicit Legacy
selections on the direct adapter. Require an explicit provider/model
before test or hire. Permit model-free inactive setup drafts.

**Reason and benefit**

Users choose Pi once and receive its qualified runner. Saved agents
retain their identity and settings. Conversions reject unsupported
legacy thinking and launch settings instead of dropping them.

## What Changed

- Add Pi to the shared platform/default contract and server creation
selection.
- Admit native Pi without the generic experimental flag.
- Preserve provider models, credentials, workspace settings, exact
thinking, and saved native policies.
- Add Pi runner controls, branding, setup resume, onboarding, built-in
configuration, and import handling.
- Add managed Pi OpenRouter routing and matching native provider API-key
accounts. Keep subscription and custom gateway exclusions. Clear
alternate credentials and reject custom Pi provider overrides.
- Keep Pi draft models empty instead of applying Claude's default model.
- Verify setup against the pinned Pi runtime. For native Pi on the
official OpenRouter route, reverify the API key live without requiring a
global Legacy Pi CLI.
- Match Pi’s bounded controller close window to its native snapshot
cleanup and lifetime verification, preserving explicit caller deadlines
and the mandatory drain/suspension proof.
- Bind Pi sidecars to the package that owns the pinned native assets,
including source and vendored server layouts. Other providers retain
their existing package authority.
- Keep protocol-owned finish/block tools out of the ACPX forwarded
catalog. The trusted bridge installs these tools itself; the complete
Rust authorization catalog and terminal receipt ledger remain intact.
This fixes actual Pi session startup and applies to other ACPX
consumers.
- Advance Copilot’s shared-source attestation to v58 because it pins the
shared files touched by Pi. Preserve immutable v57 bytes and historical
decoding; Pi’s own release identity is unchanged.
- Add regression coverage and update the paired maps and developer
documentation.

## Verification

- Passed: full repository typecheck and full production build on final
head 656d304783, including the generated attestation repair.
- Passed: focused shared/server/UI suites covering default selection,
platform fallback, conversion preservation, inactive drafts, managed
account compatibility, credential isolation, OpenRouter routing/model
IDs, exact thinking controls, and saved-account selection. Relevant
batches include 375 server/conversion tests, 267 account/routing tests,
and 110 UI setup/account tests.
- Passed: 179 attestation, historical-profile, qualified-profile, and
native-execution contract tests after CI identified stale Copilot source
hashes. The identity generator check passes; the only delta after the
passing Pi browser run is this Copilot attestation/history repair.
- Passed: 145 managed-account tests, including actual service
preparation and API-key adoption/rejection for native Pi without the
Legacy CLI; 6 pinned-installation probe tests.
- Red then green: an actual task failed session.open because its
54-operation catalog included the bridge-owned terminal tools. The
regression with those tools failed before the filter; all 399 Rust
library tests pass after it. The full product retry exposed an
additional package-authority mismatch. Four authority tests failed
before that repair; 11 authority checks and 14 installation/asset checks
pass afterward. Direct real Pi session initialization with the corrected
package root and catalog succeeds. No model prompt was sent by that
diagnostic.
- Red then green: real Pi posted Hello from Pi but its controller closed
before native cleanup could finish. The cleanup-budget test failed with
the old default and passes with the bounded Pi window. All 46 selected
suspension, drain, and package-authority tests pass after building their
native fixture.
- Passed: token gates, paired map checks, Rust formatting, and git diff
--check.
- Passed real browser acceptance on macOS ARM64, generic native runner
flag disabled, source a433748a12 (Pi code unchanged afterward): the UI
selects Paperclip Runner by default, retains Pi and the selected
model/account, and verifies the pinned runtime plus live OpenRouter key.
A fresh UI-created task, assigned to the UI-created native Pi agent,
posted exactly `Hello from Pi` and reached Done without retry or
restart. The native run and workspace finalization succeeded; the
response and Done persisted after Reload.
- Evidence: 8 preserved browser attempts, including the final passing
18-step journey with all screenshots. Prior failures remain recorded.
The earlier failed checkpoint run recovered on server restart; that
recovery is separate from the fresh final task’s clean success.
- Preserved browser failures: missing account control/inherited Claude
draft model; incompatible managed-account/model-prefix handling; generic
npm probe for Pi; Legacy CLI hello check; duplicate terminal tools and
wrong provider-package authority at actual native session.open; a real
Hello from Pi comment followed by premature checkpoint cleanup timeout.
Each failure has retained screenshots/logs and a corresponding focused
repair. The fresh final task proves these repairs end to end.
- The exact Runner Vitest shard was also run locally after the
attestation fix: the stale-hash failure is gone; 1,833 tests passed, 10
skipped, and two Codex retained-control settlement scenarios failed with
`native_cleanup_maintenance_unproven` during terminal ACK/suspension
replay. Both scenarios passed in an isolated rerun (2/2), and the same
19-test file passed in the preceding Linux CI run. The precise
full-shard timing cause is not established. These Codex scenarios are
outside the changed Pi paths; the original local failures are retained;
no complete local Runner-shard pass is claimed. The corresponding Runner
shard now passes on the final head in hosted Linux CI. All 60 final-head
check runs are now terminal and clean (55 successful, 1 neutral, 4
skipped), and the legacy Snyk status is successful.
- Local full repository tests were attempted but not completed:
provisioning briefly confused a workspace symlink, then a later run was
stopped before browser-driven repairs. No full local-suite pass is
claimed. Final-head hosted CI is green and fresh Greptile review is 5/5
with all review threads resolved; earlier jobs canceled by runner
shutdown are not counted as a pass.

## Risks

- Pi cleanup has a finite 45-second default window, accounting for the
native 30-second snapshot cleanup and 5-second lifetime fence. Other
providers and explicit caller deadlines retain their prior behavior.
- Pi requires explicit operator setup with `paperclipai runtime setup
pi` on the execution host. This PR does not add automatic downloads.
- Linux ARM64, Windows, musl, and unqualified targets retain Legacy.
- Legacy thinking levels minimal, medium, and xhigh cannot be translated
exactly. Native max thinking, warm lifecycle, or restricted permissions
cannot be dropped during a return to Legacy.
- Copilot’s bound profile advances to v58 with exact current launch
admission; v57 remains decodable for recovery. No historical identity is
rewritten.
- Existing agents and immutable run descriptors are not migrated. Cloud
Pi availability is unchanged.

## Model Used

OpenAI GPT-6 through Codex with reasoning, code editing, command
execution, and browser use. The exact serving model ID and
context-window size are not exposed in this session.

## 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 (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-21 17:10:00 -05:00

275 lines
11 KiB
TypeScript

import { useMemo, useState } from "react";
import { agentHarnessType, agentRunner, type AgentRunnerChoice } from "@paperclipai/shared";
import { adaptersApi } from "@/api/adapters";
import { AgentRunnerSelect } from "./AgentRunnerSelect";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Field } from "@/components/agent-config-primitives";
import { AdapterTypeDropdown, ModelDropdown } from "@/components/AgentConfigForm";
import { InlineBanner } from "@/components/InlineBanner";
import { listAdapterOptions } from "@/adapters/metadata";
import { agentsApi } from "@/api/agents";
import { queryKeys } from "@/lib/queryKeys";
import { ApiError } from "@/api/client";
import {
builtInAgentsApi,
type BuiltInAgentState,
} from "@/api/builtInAgents";
import { adapterCuratesModelOrder } from "../lib/model-utils";
/** Adapters whose config completeness is keyed on a non-empty `model`. */
function isModelBasedAdapter(adapterType: string): boolean {
return !["process", "command", "http", "openclaw_gateway", "hermes_gateway"].includes(adapterType);
}
function defaultAdapterType(state: BuiltInAgentState): string {
return state.definition.defaultAdapterType ?? state.definition.allowedAdapterTypes?.[0] ?? "codex_local";
}
function parseBudgetMonthlyCents(value: string): number | undefined {
const trimmed = value.trim();
if (!trimmed) return undefined;
const cents = Math.round(Number(trimmed) * 100);
return Number.isFinite(cents) && cents >= 0 ? cents : undefined;
}
export interface ConfigureBuiltInAgentModalProps {
companyId: string;
state: BuiltInAgentState;
open: boolean;
onOpenChange: (open: boolean) => void;
/** Called after a successful provision (e.g. to navigate to the agent). */
onConfigured?: (result: BuiltInAgentState) => void;
}
/**
* Configure-on-first-use modal for a built-in agent. Reuses the shared
* `AdapterTypeDropdown` + `ModelDropdown` (ux-spec D6 — no second model picker),
* plus an optional monthly budget, and submits to the provision endpoint.
*/
export function ConfigureBuiltInAgentModal({
companyId,
state,
open,
onOpenChange,
onConfigured,
}: ConfigureBuiltInAgentModalProps) {
const queryClient = useQueryClient();
const { definition } = state;
const [adapterType, setAdapterType] = useState<string>(
() => state.agent && ["codex_local", "claude_local", "opencode_local", "pi_local"].includes(agentHarnessType(state.agent.adapterType, state.agent.adapterConfig)) ? agentHarnessType(state.agent.adapterType, state.agent.adapterConfig) : state.agent?.adapterType ?? defaultAdapterType(state),
);
const [runner, setRunner] = useState<AgentRunnerChoice | undefined>();
const environmentId = state.agent?.defaultEnvironmentId ?? null;
const adapters = useQuery({ queryKey: queryKeys.adapters.availability(companyId, environmentId), queryFn: () => adaptersApi.list({ companyId, environmentId }), enabled: open });
const runnerAvailability = adapters.data?.find(item => item.type === adapterType);
const [model, setModel] = useState<string>(() => {
const config = state.agent?.adapterConfig;
const configuredModel = typeof config === "object" && config !== null
? (config as Record<string, unknown>).model
: null;
if (typeof configuredModel === "string") return configuredModel;
const defaultModel = state.definition.defaultAdapterConfig?.model;
return typeof defaultModel === "string" ? defaultModel : "";
});
const [modelOpen, setModelOpen] = useState(false);
const [budgetDollars, setBudgetDollars] = useState<string>(() => {
const cents = definition.defaultBudgetMonthlyCents ?? 0;
return cents > 0 ? String(cents / 100) : "";
});
const [error, setError] = useState<string | null>(null);
// Restrict adapter choices to the registry's allow-list. Non-model adapters
// are still selectable: provisioning creates the row, then full agent config
// collects command/endpoint fields while the built-in remains `needs_setup`.
const disabledTypes = useMemo(() => {
const allowed = new Set(definition.allowedAdapterTypes ?? []);
return new Set(
listAdapterOptions()
.map((option) => option.value)
.filter((value) => allowed.size > 0 && !allowed.has(value)),
);
}, [definition.allowedAdapterTypes]);
const setupSupportedInModal = isModelBasedAdapter(adapterType);
const { data: fetchedModels } = useQuery({
queryKey: queryKeys.agents.adapterModels(companyId, adapterType, null),
queryFn: () => agentsApi.adapterModels(companyId, adapterType, {}),
enabled: open && Boolean(companyId) && setupSupportedInModal,
});
const models = fetchedModels ?? [];
const modelRequired = setupSupportedInModal;
const normalizedModel = model.trim();
const modelKnown =
!normalizedModel ||
models.length === 0 ||
models.some((candidate) => candidate.id === normalizedModel);
const modelError = modelKnown
? null
: `Model “${normalizedModel}” is not available for ${adapterType}. Choose a known model.`;
const budgetMonthlyCents = parseBudgetMonthlyCents(budgetDollars);
const budgetValid = !budgetDollars.trim() || budgetMonthlyCents !== undefined;
const canSubmit =
budgetValid &&
modelKnown &&
(setupSupportedInModal ? !modelRequired || normalizedModel.length > 0 : true);
const submitLabel = setupSupportedInModal
? `Configure & enable ${definition.displayName}`
: `Provision ${definition.displayName}`;
const provision = useMutation({
mutationFn: async () => {
const adapterConfig: Record<string, unknown> = {};
if (model.trim()) adapterConfig.model = model.trim();
const existing = state.agent;
const sameHarness = existing && agentHarnessType(existing.adapterType, existing.adapterConfig) === adapterType;
const savedModel = typeof existing?.adapterConfig.model === "string" ? existing.adapterConfig.model.trim() : "";
const alreadyConfigured = state.status === "ready" || state.status === "paused";
const adapterInput = alreadyConfigured && sameHarness && normalizedModel === savedModel ? {}
: sameHarness ? { adapterType: existing.adapterType, adapterConfig: { ...existing.adapterConfig, ...adapterConfig } }
: { adapterType, adapterConfig };
const result = await builtInAgentsApi.provision(companyId, definition.key, {
...adapterInput,
...(["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterType) && runner !== undefined ? { runner } : {}),
...(budgetMonthlyCents !== undefined ? { budgetMonthlyCents } : {}),
});
return result;
},
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: queryKeys.builtInAgents.list(companyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(companyId) });
if (result.agentId) {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(result.agentId) });
}
onConfigured?.(result);
onOpenChange(false);
},
onError: (err) => {
setError(err instanceof ApiError ? err.message : "Failed to configure the built-in agent.");
},
});
return (
<Dialog open={open} onOpenChange={(next) => (provision.isPending ? undefined : onOpenChange(next))}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Set up the {definition.displayName}</DialogTitle>
<DialogDescription>{definition.shortPurpose}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<InlineBanner tone="info" compact>
Creates <strong>{definition.displayName}</strong> in your roster, badged{" "}
<strong>Built-in</strong>. Organizations that require hire approval will queue this for the
board.
</InlineBanner>
<Field label="Harness">
<AdapterTypeDropdown
value={adapterType}
onChange={(next) => {
setAdapterType(next);
setModel("");
setRunner(undefined);
}}
disabledTypes={disabledTypes}
/>
</Field>
{["codex_local", "claude_local", "opencode_local", "pi_local"].includes(adapterType) && <details className="space-y-3">
<summary className="cursor-pointer text-sm text-muted-foreground">Advanced</summary>
<AgentRunnerSelect value={runner ?? (state.agent ? agentRunner(state.agent.adapterType) : "auto")}
pending={adapters.isFetching} error={adapters.error?.message} onRetry={() => { void adapters.refetch(); }}
defaultRunner={runnerAvailability?.defaultRunner} supportedRunners={runnerAvailability?.supportedRunners}
onChange={setRunner} />
</details>}
{modelRequired && (
// ModelDropdown supplies its own "Model" Field label + hint.
<ModelDropdown
models={models}
value={model}
onChange={setModel}
open={modelOpen}
onOpenChange={setModelOpen}
allowDefault={adapterType !== "opencode_local"}
required
groupByProvider={false}
preserveOrder={adapterCuratesModelOrder(adapterType)}
creatable
/>
)}
{modelError && (
<p className="text-sm text-destructive" role="alert">
{modelError}
</p>
)}
{!setupSupportedInModal && (
<InlineBanner tone="warning" compact>
This adapter needs command or endpoint fields before it can run. Provision the
built-in row now, then finish those fields from the full agent configuration.
</InlineBanner>
)}
<Field label="Monthly budget (optional)" hint="Leave blank for no cap.">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">$</span>
<Input
type="number"
min="0"
step="1"
inputMode="decimal"
placeholder="0"
value={budgetDollars}
onChange={(event) => setBudgetDollars(event.target.value)}
className="w-32"
/>
<span className="text-sm text-muted-foreground">/ month</span>
</div>
</Field>
{error && (
<p className="text-sm text-destructive" role="alert">
{error}
</p>
)}
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={provision.isPending}
>
Not now
</Button>
<Button
onClick={() => {
setError(null);
provision.mutate();
}}
disabled={!canSubmit || provision.isPending}
>
{provision.isPending ? "Configuring…" : submitLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}