Add agent instructions bundle editing

Expose first-class instructions bundle APIs, preserve agent prompt bundles in portability flows, and replace the Agent Detail prompts tab with file-backed bundle editing while retiring bootstrap prompt UI.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-03-17 13:42:00 -05:00
parent 827b09d7a5
commit e980c2ef64
16 changed files with 1482 additions and 138 deletions

View file

@ -118,6 +118,10 @@ function redactEnvValue(key: string, value: unknown): string {
}
}
function isMarkdown(pathValue: string) {
return pathValue.toLowerCase().endsWith(".md");
}
function formatEnvForDisplay(envValue: unknown): string {
const env = asRecord(envValue);
if (!env) return "<unable-to-parse>";
@ -1300,6 +1304,7 @@ function AgentConfigurePage({
updatePermissions={updatePermissions}
companyId={companyId}
hidePromptTemplate
hideInstructionsFile
/>
<div>
<h3 className="text-sm font-medium mb-3">API Keys</h3>
@ -1371,6 +1376,7 @@ function ConfigurationTab({
onSavingChange,
updatePermissions,
hidePromptTemplate,
hideInstructionsFile,
}: {
agent: Agent;
companyId?: string;
@ -1380,6 +1386,7 @@ function ConfigurationTab({
onSavingChange: (saving: boolean) => void;
updatePermissions: { mutate: (canCreate: boolean) => void; isPending: boolean };
hidePromptTemplate?: boolean;
hideInstructionsFile?: boolean;
}) {
const queryClient = useQueryClient();
const [awaitingRefreshAfterSave, setAwaitingRefreshAfterSave] = useState(false);
@ -1434,6 +1441,7 @@ function ConfigurationTab({
onCancelActionChange={onCancelActionChange}
hideInlineSave
hidePromptTemplate={hidePromptTemplate}
hideInstructionsFile={hideInstructionsFile}
sectionLayout="cards"
/>
@ -1479,13 +1487,16 @@ function PromptsTab({
}) {
const queryClient = useQueryClient();
const { selectedCompanyId } = useCompany();
const [selectedFile, setSelectedFile] = useState<string>("AGENTS.md");
const [draft, setDraft] = useState<string | null>(null);
const [bundleDraft, setBundleDraft] = useState<{
mode: "managed" | "external";
rootPath: string;
entryFile: string;
} | null>(null);
const [newFilePath, setNewFilePath] = useState("");
const [awaitingRefresh, setAwaitingRefresh] = useState(false);
const lastAgentRef = useRef(agent);
const currentValue = String(agent.adapterConfig?.promptTemplate ?? "");
const displayValue = draft ?? currentValue;
const isDirty = draft !== null && draft !== currentValue;
const lastFileVersionRef = useRef<string | null>(null);
const isLocal =
agent.adapterType === "claude_local" ||
@ -1495,10 +1506,60 @@ function PromptsTab({
agent.adapterType === "hermes_local" ||
agent.adapterType === "cursor";
const updateAgent = useMutation({
mutationFn: (data: Record<string, unknown>) => agentsApi.update(agent.id, data, companyId),
const { data: bundle, isLoading: bundleLoading } = useQuery({
queryKey: queryKeys.agents.instructionsBundle(agent.id),
queryFn: () => agentsApi.instructionsBundle(agent.id, companyId),
enabled: Boolean(companyId && isLocal),
});
const currentMode = bundleDraft?.mode ?? bundle?.mode ?? "managed";
const currentEntryFile = bundleDraft?.entryFile ?? bundle?.entryFile ?? "AGENTS.md";
const currentRootPath = bundleDraft?.rootPath ?? bundle?.rootPath ?? "";
const fileOptions = bundle?.files.map((file) => file.path) ?? [];
const selectedOrEntryFile = selectedFile || currentEntryFile;
const selectedFileExists = fileOptions.includes(selectedOrEntryFile);
const { data: selectedFileDetail, isLoading: fileLoading } = useQuery({
queryKey: queryKeys.agents.instructionsFile(agent.id, selectedOrEntryFile),
queryFn: () => agentsApi.instructionsFile(agent.id, selectedOrEntryFile, companyId),
enabled: Boolean(companyId && isLocal && selectedFileExists),
});
const updateBundle = useMutation({
mutationFn: (data: {
mode?: "managed" | "external";
rootPath?: string | null;
entryFile?: string;
clearLegacyPromptTemplate?: boolean;
}) => agentsApi.updateInstructionsBundle(agent.id, data, companyId),
onMutate: () => setAwaitingRefresh(true),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.instructionsBundle(agent.id) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) });
},
onError: () => setAwaitingRefresh(false),
});
const saveFile = useMutation({
mutationFn: (data: { path: string; content: string; clearLegacyPromptTemplate?: boolean }) =>
agentsApi.saveInstructionsFile(agent.id, data, companyId),
onMutate: () => setAwaitingRefresh(true),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.instructionsBundle(agent.id) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.instructionsFile(agent.id, variables.path) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) });
},
onError: () => setAwaitingRefresh(false),
});
const deleteFile = useMutation({
mutationFn: (relativePath: string) => agentsApi.deleteInstructionsFile(agent.id, relativePath, companyId),
onMutate: () => setAwaitingRefresh(true),
onSuccess: (_, relativePath) => {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.instructionsBundle(agent.id) });
queryClient.removeQueries({ queryKey: queryKeys.agents.instructionsFile(agent.id, relativePath) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) });
},
@ -1513,60 +1574,339 @@ function PromptsTab({
});
useEffect(() => {
if (awaitingRefresh && agent !== lastAgentRef.current) {
setAwaitingRefresh(false);
setDraft(null);
if (!bundle) return;
const availablePaths = bundle.files.map((file) => file.path);
if (availablePaths.length === 0) {
if (selectedFile !== bundle.entryFile) setSelectedFile(bundle.entryFile);
return;
}
lastAgentRef.current = agent;
}, [agent, awaitingRefresh]);
if (!availablePaths.includes(selectedFile)) {
setSelectedFile(availablePaths.includes(bundle.entryFile) ? bundle.entryFile : availablePaths[0]!);
}
}, [bundle, selectedFile]);
const isSaving = updateAgent.isPending || awaitingRefresh;
useEffect(() => {
const versionKey = selectedFileDetail ? `${selectedFileDetail.path}:${selectedFileDetail.content}` : `draft:${selectedOrEntryFile}`;
if (awaitingRefresh) {
setAwaitingRefresh(false);
setBundleDraft(null);
setDraft(null);
lastFileVersionRef.current = versionKey;
return;
}
if (lastFileVersionRef.current !== versionKey) {
setDraft(null);
lastFileVersionRef.current = versionKey;
}
}, [awaitingRefresh, selectedFileDetail, selectedOrEntryFile]);
useEffect(() => {
if (!bundle) return;
setBundleDraft((current) => {
if (current) return current;
return {
mode: bundle.mode ?? "managed",
rootPath: bundle.rootPath ?? "",
entryFile: bundle.entryFile,
};
});
}, [bundle]);
const currentContent = selectedFileExists ? (selectedFileDetail?.content ?? "") : "";
const displayValue = draft ?? currentContent;
const bundleDirty = Boolean(
bundleDraft &&
(
bundleDraft.mode !== (bundle?.mode ?? "managed") ||
bundleDraft.rootPath !== (bundle?.rootPath ?? "") ||
bundleDraft.entryFile !== (bundle?.entryFile ?? "AGENTS.md")
),
);
const fileDirty = draft !== null && draft !== currentContent;
const isDirty = bundleDirty || fileDirty;
const isSaving = updateBundle.isPending || saveFile.isPending || deleteFile.isPending || awaitingRefresh;
useEffect(() => { onSavingChange(isSaving); }, [onSavingChange, isSaving]);
useEffect(() => { onDirtyChange(isDirty); }, [onDirtyChange, isDirty]);
useEffect(() => {
onSaveActionChange(isDirty ? () => {
updateAgent.mutate({ adapterConfig: { promptTemplate: draft } });
const save = async () => {
const shouldClearLegacy =
Boolean(bundle?.legacyPromptTemplateActive) || Boolean(bundle?.legacyBootstrapPromptTemplateActive);
if (bundleDirty && bundleDraft) {
await updateBundle.mutateAsync({
mode: bundleDraft.mode,
rootPath: bundleDraft.mode === "external" ? bundleDraft.rootPath : null,
entryFile: bundleDraft.entryFile,
});
}
if (fileDirty) {
await saveFile.mutateAsync({
path: selectedOrEntryFile,
content: displayValue,
clearLegacyPromptTemplate: shouldClearLegacy,
});
}
};
void save().catch(() => undefined);
} : null);
}, [onSaveActionChange, isDirty, draft, updateAgent]);
}, [
bundle,
bundleDirty,
bundleDraft,
displayValue,
fileDirty,
isDirty,
onSaveActionChange,
saveFile,
selectedOrEntryFile,
updateBundle,
]);
useEffect(() => {
onCancelActionChange(isDirty ? () => setDraft(null) : null);
}, [onCancelActionChange, isDirty]);
onCancelActionChange(isDirty ? () => {
setDraft(null);
if (bundle) {
setBundleDraft({
mode: bundle.mode ?? "managed",
rootPath: bundle.rootPath ?? "",
entryFile: bundle.entryFile,
});
}
} : null);
}, [bundle, isDirty, onCancelActionChange]);
if (!isLocal) {
return (
<div className="max-w-3xl">
<p className="text-sm text-muted-foreground">
Prompt templates are only available for local adapters.
Instructions bundles are only available for local adapters.
</p>
</div>
);
}
if (bundleLoading && !bundle) {
return <div className="max-w-5xl text-sm text-muted-foreground">Loading instructions bundle</div>;
}
return (
<div className="max-w-3xl space-y-4">
<div>
<h3 className="text-sm font-medium mb-3">Prompt Template</h3>
<div className="border border-border rounded-lg p-4 space-y-3">
<p className="text-sm text-muted-foreground">
{help.promptTemplate}
</p>
<MarkdownEditor
value={displayValue}
onChange={(v) => setDraft(v ?? "")}
placeholder="You are agent {{ agent.name }}. Your role is {{ agent.role }}..."
contentClassName="min-h-[88px] text-sm font-mono"
imageUploadHandler={async (file) => {
const namespace = `agents/${agent.id}/prompt-template`;
const asset = await uploadMarkdownImage.mutateAsync({ file, namespace });
return asset.contentPath;
}}
/>
<div className="rounded-md border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-100">
Prompt template is replayed on every heartbeat. Keep it compact and dynamic to avoid recurring token cost and cache churn.
<div className="max-w-6xl space-y-4">
<div className="border border-border rounded-lg p-4 space-y-4">
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="text-sm font-medium">Instructions Bundle</h3>
<p className="mt-1 text-sm text-muted-foreground">
`AGENTS.md` is the entry file. Sibling files like `HEARTBEAT.md`, `SOUL.md`, `TOOLS.md`, and arbitrary custom files live in the same bundle.
</p>
</div>
<div className="text-xs text-muted-foreground">
{bundle?.files.length ?? 0} files
</div>
</div>
{(bundle?.legacyPromptTemplateActive || bundle?.legacyBootstrapPromptTemplateActive) && (
<div className="rounded-md border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-100">
Legacy inline prompt fields are still active for this agent. The next bundle save will migrate behavior into file-backed instructions and clear those legacy fields.
</div>
)}
{(bundle?.warnings ?? []).map((warning) => (
<div key={warning} className="rounded-md border border-sky-500/25 bg-sky-500/10 px-3 py-2 text-xs text-sky-100">
{warning}
</div>
))}
<div className="grid gap-3 md:grid-cols-3">
<label className="space-y-1">
<span className="text-xs font-medium text-muted-foreground">Mode</span>
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant={currentMode === "managed" ? "default" : "outline"}
onClick={() => setBundleDraft((current) => ({
mode: "managed",
rootPath: current?.rootPath ?? bundle?.rootPath ?? "",
entryFile: current?.entryFile ?? bundle?.entryFile ?? "AGENTS.md",
}))}
>
Managed
</Button>
<Button
type="button"
size="sm"
variant={currentMode === "external" ? "default" : "outline"}
onClick={() => setBundleDraft((current) => ({
mode: "external",
rootPath: current?.rootPath ?? bundle?.rootPath ?? "",
entryFile: current?.entryFile ?? bundle?.entryFile ?? "AGENTS.md",
}))}
>
External
</Button>
</div>
</label>
<label className="space-y-1">
<span className="text-xs font-medium text-muted-foreground">Entry file</span>
<Input
value={currentEntryFile}
onChange={(event) => {
const nextEntryFile = event.target.value || "AGENTS.md";
if (selectedOrEntryFile === currentEntryFile) {
setSelectedFile(nextEntryFile);
}
setBundleDraft((current) => ({
mode: current?.mode ?? bundle?.mode ?? "managed",
rootPath: current?.rootPath ?? bundle?.rootPath ?? "",
entryFile: nextEntryFile,
}));
}}
className="font-mono text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs font-medium text-muted-foreground">Root path</span>
<Input
value={currentRootPath}
onChange={(event) => setBundleDraft((current) => ({
mode: current?.mode ?? bundle?.mode ?? "managed",
rootPath: event.target.value,
entryFile: current?.entryFile ?? bundle?.entryFile ?? "AGENTS.md",
}))}
disabled={currentMode === "managed"}
className="font-mono text-sm"
placeholder={currentMode === "managed" ? "Managed by Paperclip" : "/absolute/path/to/agent/prompts"}
/>
</label>
</div>
</div>
<div className="grid gap-4 lg:grid-cols-[260px_minmax(0,1fr)]">
<div className="border border-border rounded-lg p-3 space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium">Files</h4>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
const candidate = newFilePath.trim();
if (!candidate) return;
setSelectedFile(candidate);
setDraft("");
setNewFilePath("");
}}
disabled={!newFilePath.trim()}
>
Add
</Button>
</div>
<div className="flex gap-2">
<Input
value={newFilePath}
onChange={(event) => setNewFilePath(event.target.value)}
placeholder="docs/TOOLS.md"
className="font-mono text-sm"
/>
</div>
<div className="flex flex-wrap gap-2">
{["HEARTBEAT.md", "SOUL.md", "TOOLS.md"].map((filePath) => (
<Button
key={filePath}
type="button"
size="sm"
variant="outline"
onClick={() => {
setSelectedFile(filePath);
if (!fileOptions.includes(filePath)) setDraft("");
}}
>
{filePath}
</Button>
))}
</div>
<div className="space-y-1">
{[...new Set([currentEntryFile, ...fileOptions])].map((filePath) => {
const file = bundle?.files.find((entry) => entry.path === filePath);
return (
<button
key={filePath}
type="button"
className={cn(
"flex w-full items-center justify-between rounded-md border px-3 py-2 text-left text-sm",
filePath === selectedOrEntryFile ? "border-foreground/30 bg-accent/30" : "border-border",
)}
onClick={() => {
setSelectedFile(filePath);
if (!fileOptions.includes(filePath)) setDraft("");
}}
>
<span className="truncate font-mono">{filePath}</span>
<span className="ml-3 shrink-0 text-[11px] text-muted-foreground">
{file?.isEntryFile ? "entry" : file ? `${file.size}b` : "new"}
</span>
</button>
);
})}
</div>
</div>
<div className="border border-border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between gap-3">
<div>
<h4 className="text-sm font-medium font-mono">{selectedOrEntryFile}</h4>
<p className="text-xs text-muted-foreground">
{selectedFileExists
? `${selectedFileDetail?.language ?? "text"} file`
: "New file in this bundle"}
</p>
</div>
{selectedFileExists && selectedOrEntryFile !== currentEntryFile && (
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
if (confirm(`Delete ${selectedOrEntryFile}?`)) {
deleteFile.mutate(selectedOrEntryFile, {
onSuccess: () => {
setSelectedFile(currentEntryFile);
setDraft(null);
},
});
}
}}
disabled={deleteFile.isPending}
>
Delete
</Button>
)}
</div>
{selectedFileExists && fileLoading && !selectedFileDetail ? (
<p className="text-sm text-muted-foreground">Loading file</p>
) : isMarkdown(selectedOrEntryFile) ? (
<MarkdownEditor
value={displayValue}
onChange={(value) => setDraft(value ?? "")}
placeholder="# Agent instructions"
contentClassName="min-h-[420px] text-sm font-mono"
imageUploadHandler={async (file) => {
const namespace = `agents/${agent.id}/instructions/${selectedOrEntryFile.replaceAll("/", "-")}`;
const asset = await uploadMarkdownImage.mutateAsync({ file, namespace });
return asset.contentPath;
}}
/>
) : (
<textarea
value={displayValue}
onChange={(event) => setDraft(event.target.value)}
className="min-h-[420px] w-full rounded-md border border-border bg-transparent px-3 py-2 font-mono text-sm outline-none"
placeholder="File contents"
/>
)}
</div>
</div>
</div>