mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 12:59:13 +01:00
## 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 head656d304783, 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, sourcea433748a12(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>
275 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|