Skip to content

Commit aaa203e

Browse files
dmarticusclaude
andauthored
feat(agents): editable agent.md and SKILL.md on draft revisions (#2889)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent aa7e5c5 commit aaa203e

8 files changed

Lines changed: 656 additions & 5 deletions

File tree

packages/api-client/src/posthog-client.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4988,6 +4988,60 @@ export class PostHogAPIClient {
49884988
return (await response.json()) as AgentRevision;
49894989
}
49904990

4991+
/**
4992+
* Write a single bundle file on a draft revision. The server accepts
4993+
* `agent.md` and `skills/<id>/SKILL.md` paths only — tool source / schema
4994+
* stay read-only this round. Ready / live / archived revisions return 409.
4995+
*/
4996+
async updateAgentDraftBundleFile(
4997+
idOrSlug: string,
4998+
revisionId: string,
4999+
filePath: string,
5000+
content: string,
5001+
): Promise<AgentRevision> {
5002+
const teamId = await this.getTeamId();
5003+
const path = `${this.agentApplicationsPath(teamId)}${encodeURIComponent(idOrSlug)}/revisions/${encodeURIComponent(revisionId)}/bundle/file/`;
5004+
const url = new URL(`${this.api.baseUrl}${path}`);
5005+
const response = await this.api.fetcher.fetch({
5006+
method: "put",
5007+
url,
5008+
path,
5009+
overrides: {
5010+
body: JSON.stringify({ path: filePath, content }),
5011+
},
5012+
});
5013+
return (await response.json()) as AgentRevision;
5014+
}
5015+
5016+
/**
5017+
* Bulk-import a set of `.md` files into a draft revision's bundle — the
5018+
* migration hatch for porting an existing multi-file agent in one paste.
5019+
* Sets `agent_md` if present and merges `skills[]` by id (adds new ids,
5020+
* overwrites bodies for existing ids; skills not mentioned are left alone).
5021+
* Draft-only; ready / live / archived return 409.
5022+
*/
5023+
async importAgentDraftBundle(
5024+
idOrSlug: string,
5025+
revisionId: string,
5026+
body: {
5027+
agent_md?: string;
5028+
skills?: { id: string; description?: string; body: string }[];
5029+
},
5030+
): Promise<AgentRevision> {
5031+
const teamId = await this.getTeamId();
5032+
const path = `${this.agentApplicationsPath(teamId)}${encodeURIComponent(idOrSlug)}/revisions/${encodeURIComponent(revisionId)}/bundle/import/`;
5033+
const url = new URL(`${this.api.baseUrl}${path}`);
5034+
const response = await this.api.fetcher.fetch({
5035+
method: "post",
5036+
url,
5037+
path,
5038+
overrides: {
5039+
body: JSON.stringify(body),
5040+
},
5041+
});
5042+
return (await response.json()) as AgentRevision;
5043+
}
5044+
49915045
/**
49925046
* A revision's bundle, flattened to per-file rows. The server returns a typed
49935047
* `{ bundle: { agent_md, skills[], tools[] } }`; we expand it to the canonical
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { describe, expect, it } from "vitest";
2+
import { type ParsedBundle, parseBundleInput } from "../utils/parseBundleInput";
3+
4+
type Expected =
5+
| { ok: true; value: ParsedBundle }
6+
| { ok: false; errorMatch?: RegExp };
7+
8+
const cases: Array<{ name: string; input: string; expected: Expected }> = [
9+
{
10+
name: "rejects empty input",
11+
input: "",
12+
expected: { ok: false },
13+
},
14+
{
15+
name: "parses a single agent.md block",
16+
input: "--- agent.md ---\nYou are the growth review agent.\n",
17+
expected: {
18+
ok: true,
19+
value: { agent_md: "You are the growth review agent." },
20+
},
21+
},
22+
{
23+
name: "parses multiple skill blocks",
24+
input: [
25+
"--- skills/research/SKILL.md ---",
26+
"Research body",
27+
"--- skills/draft-post/SKILL.md ---",
28+
"Draft body",
29+
].join("\n"),
30+
expected: {
31+
ok: true,
32+
value: {
33+
skills: [
34+
{ id: "research", body: "Research body" },
35+
{ id: "draft-post", body: "Draft body" },
36+
],
37+
},
38+
},
39+
},
40+
{
41+
name: "parses agent.md plus skills together",
42+
input: [
43+
"--- agent.md ---",
44+
"Main prompt",
45+
"",
46+
"--- skills/research/SKILL.md ---",
47+
"Research body",
48+
].join("\n"),
49+
expected: {
50+
ok: true,
51+
value: {
52+
agent_md: "Main prompt",
53+
skills: [{ id: "research", body: "Research body" }],
54+
},
55+
},
56+
},
57+
{
58+
name: "tolerates CRLF line endings",
59+
input: "--- agent.md ---\r\nMain prompt\r\n",
60+
expected: { ok: true, value: { agent_md: "Main prompt" } },
61+
},
62+
{
63+
name: "rejects an unsupported file path",
64+
input: "--- tools/foo/source.ts ---\nconsole.log('hi')\n",
65+
expected: { ok: false, errorMatch: /Unsupported file path/ },
66+
},
67+
{
68+
name: "rejects skill ids with spaces",
69+
input: "--- skills/Bad Id/SKILL.md ---\nbody\n",
70+
expected: { ok: false },
71+
},
72+
{
73+
name: "rejects skill ids with uppercase letters",
74+
input: "--- skills/MySkill/SKILL.md ---\nbody\n",
75+
expected: { ok: false },
76+
},
77+
{
78+
name: "ignores leading content before the first header",
79+
input: [
80+
"# notes for myself, not in any block",
81+
"--- agent.md ---",
82+
"Prompt",
83+
].join("\n"),
84+
expected: { ok: true, value: { agent_md: "Prompt" } },
85+
},
86+
];
87+
88+
describe("parseBundleInput", () => {
89+
it.each(cases)("$name", ({ input, expected }) => {
90+
const out = parseBundleInput(input);
91+
if (expected.ok) {
92+
expect(out).toEqual(expected);
93+
return;
94+
}
95+
expect(out.ok).toBe(false);
96+
if (!out.ok && expected.errorMatch) {
97+
expect(out.error).toMatch(expected.errorMatch);
98+
}
99+
});
100+
});
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { Badge } from "@posthog/ui/primitives/Badge";
2+
import { Button } from "@posthog/ui/primitives/Button";
3+
import { Dialog, Flex, Text } from "@radix-ui/themes";
4+
import { useMemo, useState } from "react";
5+
import { useImportAgentDraftBundle } from "../hooks/useImportAgentDraftBundle";
6+
import { parseBundleInput } from "../utils/parseBundleInput";
7+
8+
const SAMPLE = `--- agent.md ---
9+
You are the growth review agent. …
10+
11+
--- skills/research/SKILL.md ---
12+
When asked to research, …
13+
14+
--- skills/draft-post/SKILL.md ---
15+
When asked to draft, …
16+
`;
17+
18+
/**
19+
* Bulk-paste a markdown bundle into a draft revision. Designed for migrating
20+
* an existing multi-file agent in one paste — concatenate the source files
21+
* with a `--- path ---` header between each. Existing skill ids are
22+
* overwritten; new ids are added; skills not mentioned are left alone.
23+
*/
24+
export function AgentBundleImportDialog({
25+
open,
26+
onOpenChange,
27+
idOrSlug,
28+
revisionId,
29+
existingSkillIds,
30+
onSuccess,
31+
}: {
32+
open: boolean;
33+
onOpenChange: (open: boolean) => void;
34+
idOrSlug: string;
35+
revisionId: string;
36+
existingSkillIds: string[];
37+
onSuccess?: () => void;
38+
}) {
39+
const [input, setInput] = useState("");
40+
const mutation = useImportAgentDraftBundle(idOrSlug, revisionId);
41+
42+
const parsed = useMemo(() => {
43+
if (input.trim().length === 0) return null;
44+
return parseBundleInput(input);
45+
}, [input]);
46+
47+
const value = parsed?.ok ? parsed.value : null;
48+
const existing = useMemo(() => new Set(existingSkillIds), [existingSkillIds]);
49+
50+
const onConfirm = () => {
51+
if (!value) return;
52+
mutation.mutate(value, {
53+
onSuccess: () => {
54+
setInput("");
55+
mutation.reset();
56+
onOpenChange(false);
57+
onSuccess?.();
58+
},
59+
});
60+
};
61+
62+
const close = () => {
63+
if (mutation.isPending) return;
64+
setInput("");
65+
mutation.reset();
66+
onOpenChange(false);
67+
};
68+
69+
return (
70+
<Dialog.Root
71+
open={open}
72+
onOpenChange={(isOpen) => {
73+
if (!isOpen) close();
74+
}}
75+
>
76+
<Dialog.Content maxWidth="640px">
77+
<Dialog.Title className="text-base">Paste markdown bundle</Dialog.Title>
78+
<Dialog.Description size="2" className="text-gray-11">
79+
Paste one or more <code>--- path ---</code> blocks. Accepts{" "}
80+
<code>agent.md</code> and <code>skills/[id]/SKILL.md</code>. Existing
81+
skills are overwritten by id; new ids are added.
82+
</Dialog.Description>
83+
<textarea
84+
aria-label="Markdown bundle"
85+
value={input}
86+
onChange={(e) => setInput(e.currentTarget.value)}
87+
placeholder={SAMPLE}
88+
disabled={mutation.isPending}
89+
spellCheck={false}
90+
className="mt-3 min-h-[280px] w-full resize-y rounded-(--radius-2) border border-border bg-(--color-panel-solid) p-3 text-[12.5px] text-gray-12 [font-family:var(--font-mono)] focus:border-(--accent-7) focus:outline-none"
91+
/>
92+
{parsed && !parsed.ok ? (
93+
<Text className="mt-2 block text-(--red-11) text-[12px]">
94+
{parsed.error}
95+
</Text>
96+
) : null}
97+
{value ? (
98+
<div className="mt-3 rounded-(--radius-2) border border-border bg-(--gray-2) px-3 py-2">
99+
<Text className="block text-[11px] text-gray-10 uppercase tracking-wide">
100+
Will write
101+
</Text>
102+
<Flex direction="column" gap="1" className="mt-1.5">
103+
{value.agent_md !== undefined ? (
104+
<Flex align="center" gap="2">
105+
<code className="text-[12px] text-gray-12 [font-family:var(--font-mono)]">
106+
agent.md
107+
</code>
108+
<Badge color="blue">update</Badge>
109+
</Flex>
110+
) : null}
111+
{value.skills?.map((s) => (
112+
<Flex key={s.id} align="center" gap="2">
113+
<code className="text-[12px] text-gray-12 [font-family:var(--font-mono)]">
114+
skills/{s.id}/SKILL.md
115+
</code>
116+
<Badge color={existing.has(s.id) ? "blue" : "green"}>
117+
{existing.has(s.id) ? "update" : "new"}
118+
</Badge>
119+
</Flex>
120+
))}
121+
</Flex>
122+
</div>
123+
) : null}
124+
{mutation.isError ? (
125+
<Text className="mt-2 block text-(--red-11) text-[12px]">
126+
{mutation.error?.message ?? "Import failed"}
127+
</Text>
128+
) : null}
129+
<Flex justify="end" gap="2" mt="4">
130+
<Button
131+
size="1"
132+
variant="soft"
133+
color="gray"
134+
disabled={mutation.isPending}
135+
onClick={close}
136+
>
137+
Cancel
138+
</Button>
139+
<Button
140+
size="1"
141+
loading={mutation.isPending}
142+
disabled={!value}
143+
onClick={onConfirm}
144+
>
145+
Import
146+
</Button>
147+
</Flex>
148+
</Dialog.Content>
149+
</Dialog.Root>
150+
);
151+
}

0 commit comments

Comments
 (0)