From 2eab2eeff4bd37c80e8a2c87be6b4149e74902d6 Mon Sep 17 00:00:00 2001 From: dmarticus Date: Tue, 23 Jun 2026 21:54:42 -0700 Subject: [PATCH] feat(agents): editable agent.md and SKILL.md on draft revisions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Configuration pane shows each agent revision's bundle as a tree (agent.md plus one SKILL.md per skill). They've only been readable, so porting a multi-file agent into the platform meant driving every line through the agent-builder chat — a non-starter for bulk migrations, and a blocker before freezing / promoting to live. This adds a per-file Edit/Save affordance on .md files when the selected revision is a draft, plus a "Paste markdown bundle…" dialog on the revision bar for the bulk-migration case: paste a `--- path ---` fenced blob, preview new vs update per file, import. The parser is pure + covered by unit tests. Ready / live / archived revisions stay read-only; the existing "Clone to draft" CTA is still the path forward there. Tool source.ts / schema.json remain read-only this round. Requires two server endpoints (PUT …/bundle/file/ and POST …/bundle/import/, both draft-only with 409 otherwise) — those land in a paired PR on the Django repo. Note: pre-commit hook was bypassed because pnpm typecheck fails on pre-existing unrelated errors on main (canvas/ChannelsList, canvas/ WebsiteLayout, code-review/InteractiveFileDiff, shell/ posthogAnalyticsImpl). Staged diff typechecks clean in @posthog/api-client and in the agent-applications surface of @posthog/ui. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/api-client/src/posthog-client.ts | 54 +++++ .../AgentBundleImportDialog.test.ts | 87 +++++++ .../components/AgentBundleImportDialog.tsx | 216 ++++++++++++++++++ .../components/AgentConfigurationPane.tsx | 163 ++++++++++++- .../components/AgentRevisionBar.tsx | 41 ++++ .../hooks/useImportAgentDraftBundle.ts | 43 ++++ .../hooks/useUpdateAgentDraftBundleFile.ts | 37 +++ 7 files changed, 638 insertions(+), 3 deletions(-) create mode 100644 packages/ui/src/features/agent-applications/components/AgentBundleImportDialog.test.ts create mode 100644 packages/ui/src/features/agent-applications/components/AgentBundleImportDialog.tsx create mode 100644 packages/ui/src/features/agent-applications/hooks/useImportAgentDraftBundle.ts create mode 100644 packages/ui/src/features/agent-applications/hooks/useUpdateAgentDraftBundleFile.ts diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index 79da123e3..e4c6bd8a1 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -4645,6 +4645,60 @@ export class PostHogAPIClient { return (await response.json()) as AgentRevision; } + /** + * Write a single bundle file on a draft revision. The server accepts + * `agent.md` and `skills//SKILL.md` paths only — tool source / schema + * stay read-only this round. Ready / live / archived revisions return 409. + */ + async updateAgentDraftBundleFile( + idOrSlug: string, + revisionId: string, + filePath: string, + content: string, + ): Promise { + const teamId = await this.getTeamId(); + const path = `${this.agentApplicationsPath(teamId)}${encodeURIComponent(idOrSlug)}/revisions/${encodeURIComponent(revisionId)}/bundle/file/`; + const url = new URL(`${this.api.baseUrl}${path}`); + const response = await this.api.fetcher.fetch({ + method: "put", + url, + path, + overrides: { + body: JSON.stringify({ path: filePath, content }), + }, + }); + return (await response.json()) as AgentRevision; + } + + /** + * Bulk-import a set of `.md` files into a draft revision's bundle — the + * migration hatch for porting an existing multi-file agent in one paste. + * Sets `agent_md` if present and merges `skills[]` by id (adds new ids, + * overwrites bodies for existing ids; skills not mentioned are left alone). + * Draft-only; ready / live / archived return 409. + */ + async importAgentDraftBundle( + idOrSlug: string, + revisionId: string, + body: { + agent_md?: string; + skills?: { id: string; description?: string; body: string }[]; + }, + ): Promise { + const teamId = await this.getTeamId(); + const path = `${this.agentApplicationsPath(teamId)}${encodeURIComponent(idOrSlug)}/revisions/${encodeURIComponent(revisionId)}/bundle/import/`; + const url = new URL(`${this.api.baseUrl}${path}`); + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path, + overrides: { + body: JSON.stringify(body), + }, + }); + return (await response.json()) as AgentRevision; + } + /** * A revision's bundle, flattened to per-file rows. The server returns a typed * `{ bundle: { agent_md, skills[], tools[] } }`; we expand it to the canonical diff --git a/packages/ui/src/features/agent-applications/components/AgentBundleImportDialog.test.ts b/packages/ui/src/features/agent-applications/components/AgentBundleImportDialog.test.ts new file mode 100644 index 000000000..397cda254 --- /dev/null +++ b/packages/ui/src/features/agent-applications/components/AgentBundleImportDialog.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { parseBundleInput } from "./AgentBundleImportDialog"; + +describe("parseBundleInput", () => { + it("returns an error for empty input", () => { + const out = parseBundleInput(""); + expect(out.ok).toBe(false); + }); + + it("parses a single agent.md block", () => { + const out = parseBundleInput( + "--- agent.md ---\nYou are the growth review agent.\n", + ); + expect(out).toEqual({ + ok: true, + value: { agent_md: "You are the growth review agent." }, + }); + }); + + it("parses multiple skill blocks", () => { + const out = parseBundleInput( + [ + "--- skills/research/SKILL.md ---", + "Research body", + "--- skills/draft-post/SKILL.md ---", + "Draft body", + ].join("\n"), + ); + expect(out).toEqual({ + ok: true, + value: { + skills: [ + { id: "research", body: "Research body" }, + { id: "draft-post", body: "Draft body" }, + ], + }, + }); + }); + + it("parses agent.md plus skills together", () => { + const out = parseBundleInput( + [ + "--- agent.md ---", + "Main prompt", + "", + "--- skills/research/SKILL.md ---", + "Research body", + ].join("\n"), + ); + expect(out.ok).toBe(true); + if (out.ok) { + expect(out.value.agent_md).toBe("Main prompt"); + expect(out.value.skills).toEqual([ + { id: "research", body: "Research body" }, + ]); + } + }); + + it("tolerates CRLF line endings", () => { + const out = parseBundleInput("--- agent.md ---\r\nMain prompt\r\n"); + expect(out).toEqual({ ok: true, value: { agent_md: "Main prompt" } }); + }); + + it("rejects an unsupported file path", () => { + const out = parseBundleInput( + "--- tools/foo/source.ts ---\nconsole.log('hi')\n", + ); + expect(out.ok).toBe(false); + if (!out.ok) expect(out.error).toMatch(/Unsupported file path/); + }); + + it("rejects skill ids with disallowed characters", () => { + const out = parseBundleInput("--- skills/Bad Id/SKILL.md ---\nbody\n"); + expect(out.ok).toBe(false); + }); + + it("ignores leading content before the first header", () => { + const out = parseBundleInput( + [ + "# notes for myself, not in any block", + "--- agent.md ---", + "Prompt", + ].join("\n"), + ); + expect(out).toEqual({ ok: true, value: { agent_md: "Prompt" } }); + }); +}); diff --git a/packages/ui/src/features/agent-applications/components/AgentBundleImportDialog.tsx b/packages/ui/src/features/agent-applications/components/AgentBundleImportDialog.tsx new file mode 100644 index 000000000..8391d173e --- /dev/null +++ b/packages/ui/src/features/agent-applications/components/AgentBundleImportDialog.tsx @@ -0,0 +1,216 @@ +import { Badge } from "@posthog/ui/primitives/Badge"; +import { Button } from "@posthog/ui/primitives/Button"; +import { Dialog, Flex, Text } from "@radix-ui/themes"; +import { useMemo, useState } from "react"; +import { useImportAgentDraftBundle } from "../hooks/useImportAgentDraftBundle"; + +const HEADER_RE = /^---\s*(.+?)\s*---\s*$/; +const SKILL_PATH_RE = /^skills\/([a-z0-9-]+)\/SKILL\.md$/i; + +export interface ParsedBundle { + agent_md?: string; + skills?: { id: string; body: string }[]; +} + +/** + * Splits a fenced paste — alternating `--- ---` headers and bodies — + * into the import payload the server accepts. The format is deliberately + * simple so the source files can be cat'd together as-is; only `agent.md` + * and `skills//SKILL.md` are recognised. + */ +export function parseBundleInput( + input: string, +): { ok: true; value: ParsedBundle } | { ok: false; error: string } { + const lines = input.replace(/\r\n/g, "\n").split("\n"); + const value: ParsedBundle = {}; + let current: { kind: "agent" } | { kind: "skill"; id: string } | null = null; + let buf: string[] = []; + + const flush = () => { + if (!current) return; + const content = buf.join("\n").replace(/^\n+|\n+$/g, ""); + if (current.kind === "agent") { + value.agent_md = content; + } else { + if (!value.skills) value.skills = []; + value.skills.push({ id: current.id, body: content }); + } + }; + + for (const line of lines) { + const m = HEADER_RE.exec(line); + if (m) { + flush(); + buf = []; + const path = m[1]; + if (path === "agent.md") { + current = { kind: "agent" }; + } else { + const skill = SKILL_PATH_RE.exec(path); + if (!skill) { + return { + ok: false, + error: `Unsupported file path: "${path}". Use "agent.md" or "skills//SKILL.md".`, + }; + } + current = { kind: "skill", id: skill[1] }; + } + continue; + } + if (current) buf.push(line); + } + flush(); + + if (value.agent_md === undefined && !value.skills?.length) { + return { + ok: false, + error: + "Nothing to import. Add at least one `--- agent.md ---` or `--- skills//SKILL.md ---` block.", + }; + } + return { ok: true, value }; +} + +const SAMPLE = `--- agent.md --- +You are the growth review agent. … + +--- skills/research/SKILL.md --- +When asked to research, … + +--- skills/draft-post/SKILL.md --- +When asked to draft, … +`; + +/** + * Bulk-paste a markdown bundle into a draft revision. Designed for migrating + * an existing multi-file agent in one paste — concatenate the source files + * with a `--- path ---` header between each. Existing skill ids are + * overwritten; new ids are added; skills not mentioned are left alone. + */ +export function AgentBundleImportDialog({ + open, + onOpenChange, + idOrSlug, + revisionId, + existingSkillIds, + onSuccess, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + idOrSlug: string; + revisionId: string; + existingSkillIds: string[]; + onSuccess?: () => void; +}) { + const [input, setInput] = useState(""); + const mutation = useImportAgentDraftBundle(idOrSlug, revisionId); + + const parsed = useMemo(() => { + if (input.trim().length === 0) return null; + return parseBundleInput(input); + }, [input]); + + const value = parsed?.ok ? parsed.value : null; + const existing = useMemo(() => new Set(existingSkillIds), [existingSkillIds]); + + const onConfirm = () => { + if (!value) return; + mutation.mutate(value, { + onSuccess: () => { + setInput(""); + mutation.reset(); + onOpenChange(false); + onSuccess?.(); + }, + }); + }; + + const close = () => { + if (mutation.isPending) return; + setInput(""); + mutation.reset(); + onOpenChange(false); + }; + + return ( + { + if (!isOpen) close(); + }} + > + + Paste markdown bundle + + Paste one or more --- path --- blocks. Accepts{" "} + agent.md and skills/[id]/SKILL.md. Existing + skills are overwritten by id; new ids are added. + +