Skip to content

Commit 6eec4ca

Browse files
committed
let loops run a skill instead of instructions
1 parent 636a8d6 commit 6eec4ca

9 files changed

Lines changed: 640 additions & 21 deletions

File tree

packages/api-client/src/loops.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export namespace LoopSchemas {
4848
| "failed"
4949
| "cancelled";
5050
export type LoopRunEnvironmentEnum = "local" | "cloud";
51+
export type LoopSkillSourceEnum = "user" | "repo" | "marketplace" | "codex";
5152

5253
export type LoopRepositoryEntry = {
5354
github_integration_id: number;
@@ -202,6 +203,30 @@ export namespace LoopSchemas {
202203
created_at: string;
203204
updated_at: string;
204205
triggers: Array<LoopTrigger>;
206+
/** Skill bundles attached to this loop, seeded into every fired run's sandbox.
207+
* Replaced wholesale via `replaceLoopSkillBundles`, never through the loop write. */
208+
skill_bundles: Array<LoopSkillBundle>;
209+
};
210+
211+
/** A skill bundle attached to a loop. `content_sha256` is the stored snapshot's
212+
* digest, so a client can detect drift from the local copy of the skill. */
213+
export type LoopSkillBundle = {
214+
id: string;
215+
skill_name: string;
216+
skill_source: LoopSkillSourceEnum;
217+
size: number;
218+
content_sha256: string;
219+
uploaded_at: string;
220+
};
221+
222+
/** One zipped local skill in a skill-bundle replace request. */
223+
export type LoopSkillBundleUpload = {
224+
file_name: string;
225+
skill_name: string;
226+
skill_source: LoopSkillSourceEnum;
227+
content_sha256: string;
228+
bundle_format: "zip";
229+
content_base64: string;
205230
};
206231

207232
/** Request body for create (all required fields present) and partial_update
@@ -389,6 +414,8 @@ const loopRunsPath = (projectId: string, loopId: string): string =>
389414
`/api/projects/${projectId}/loops/${loopId}/runs/`;
390415
const loopPreviewPath = (projectId: string, loopId: string): string =>
391416
`/api/projects/${projectId}/loops/${loopId}/preview/`;
417+
const loopSkillBundlesPath = (projectId: string, loopId: string): string =>
418+
`/api/projects/${projectId}/loops/${loopId}/skill_bundles/`;
392419

393420
function idempotencyHeader(
394421
idempotencyKey: string | undefined,
@@ -579,6 +606,17 @@ export async function listLoopRuns(
579606
});
580607
}
581608

609+
export async function replaceLoopSkillBundles(
610+
client: ApiClient,
611+
projectId: string,
612+
loopId: string,
613+
bundles: Array<LoopSchemas.LoopSkillBundleUpload>,
614+
): Promise<LoopSchemas.Loop> {
615+
return loopsRequest(client, "put", loopSkillBundlesPath(projectId, loopId), {
616+
body: { bundles },
617+
});
618+
}
619+
582620
export async function previewLoop(
583621
client: ApiClient,
584622
projectId: string,

packages/ui/src/features/loops/components/LoopDetailView.tsx

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { ArrowLeftIcon, RepeatIcon } from "@phosphor-icons/react";
22
import type { LoopSchemas } from "@posthog/api-client/loops";
3+
import { isUploadableSkillSource } from "@posthog/core/message-editor/skillTags";
4+
import { useHostTRPC } from "@posthog/host-router/react";
35
import {
46
AlertDialog,
57
AlertDialogClose,
@@ -17,14 +19,17 @@ import { UserAvatar } from "@posthog/ui/features/auth/UserAvatar";
1719
import { useOrgMembers } from "@posthog/ui/features/canvas/hooks/useOrgMembers";
1820
import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay";
1921
import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent";
22+
import { Button as ActionButton } from "@posthog/ui/primitives/Button";
2023
import { TimezoneTimestamp } from "@posthog/ui/primitives/TimezoneTimestamp";
2124
import { systemTimezone } from "@posthog/ui/primitives/timezone";
2225
import { toast } from "@posthog/ui/primitives/toast";
2326
import {
2427
navigateToEditLoop,
2528
navigateToLoops,
2629
} from "@posthog/ui/router/navigationBridge";
30+
import { useHostCapabilities } from "@posthog/ui/shell/useHostCapabilities";
2731
import { Flex, Text } from "@radix-ui/themes";
32+
import { useQuery } from "@tanstack/react-query";
2833
import { useRef, useState } from "react";
2934
import { useLoop } from "../hooks/useLoop";
3035
import { useLoopDisplayModel } from "../hooks/useLoopDisplayModel";
@@ -34,13 +39,15 @@ import {
3439
useUpdateLoop,
3540
} from "../hooks/useLoopMutations";
3641
import { RECENT_RUNS_LIMIT, useLoopRuns } from "../hooks/useLoopRuns";
42+
import { useSyncLoopSkillBundles } from "../hooks/useLoopSkillBundles";
3743
import {
3844
describeTrigger,
3945
loopStatusColor,
4046
loopStatusLabel,
4147
nextScheduleRun,
4248
summarizeNotificationDestinations,
4349
} from "../loopDisplay";
50+
import { primaryLoopSkillBundle } from "../loopSkill";
4451
import { LoopLoadError } from "./LoopFallbacks";
4552
import { LoopRunRow } from "./LoopRunRow";
4653

@@ -315,6 +322,12 @@ function ConfigSummarySection({ loop }: { loop: LoopSchemas.Loop }) {
315322
.join(" · ")}
316323
</SummaryRow>
317324

325+
{loop.skill_bundles.length > 0 ? (
326+
<SummaryRow label="Skill">
327+
<LoopSkillSummary loop={loop} />
328+
</SummaryRow>
329+
) : null}
330+
318331
<SummaryRow label="Repository">
319332
{loop.repositories.length > 0
320333
? loop.repositories.map((repo) => repo.full_name).join(", ")
@@ -350,6 +363,80 @@ function ConfigSummarySection({ loop }: { loop: LoopSchemas.Loop }) {
350363
);
351364
}
352365

366+
function LoopSkillSummary({ loop }: { loop: LoopSchemas.Loop }) {
367+
const { localWorkspaces } = useHostCapabilities();
368+
const trpc = useHostTRPC();
369+
const skillsQuery = useQuery({
370+
...trpc.skills.list.queryOptions(),
371+
enabled: localWorkspaces,
372+
});
373+
const syncSkillBundles = useSyncLoopSkillBundles();
374+
375+
const primary = primaryLoopSkillBundle(loop);
376+
if (!primary) return null;
377+
const dependencyCount = loop.skill_bundles.length - 1;
378+
379+
const localMatch = (skillsQuery.data ?? []).find(
380+
(skill) =>
381+
skill.name === primary.skill_name &&
382+
isUploadableSkillSource(skill.source),
383+
);
384+
const updateDisabledReason = !localWorkspaces
385+
? "updating the snapshot needs the desktop app"
386+
: localMatch
387+
? null
388+
: `no local skill named ${primary.skill_name} was found on this machine`;
389+
390+
const handleUpdate = () => {
391+
if (!localMatch || !isUploadableSkillSource(localMatch.source)) return;
392+
syncSkillBundles.mutate(
393+
{
394+
loopId: loop.id,
395+
skill: {
396+
name: localMatch.name,
397+
source: localMatch.source,
398+
path: localMatch.path,
399+
},
400+
},
401+
{
402+
onSuccess: () => toast.success("Skill snapshot updated"),
403+
onError: (error) =>
404+
toast.error("Failed to update the skill snapshot", {
405+
description: error.message,
406+
}),
407+
},
408+
);
409+
};
410+
411+
return (
412+
<Flex align="center" gap="2" wrap="wrap">
413+
<Text className="text-[12.5px] text-gray-12">
414+
{primary.skill_name}
415+
{dependencyCount > 0
416+
? ` (+${dependencyCount} ${dependencyCount === 1 ? "dependency" : "dependencies"})`
417+
: ""}
418+
</Text>
419+
<Text
420+
className="text-[11px] text-gray-10"
421+
title={new Date(primary.uploaded_at).toLocaleString()}
422+
>
423+
Snapshot {primary.content_sha256.slice(0, 8)}
424+
</Text>
425+
<ActionButton
426+
variant="soft"
427+
color="gray"
428+
size="1"
429+
loading={syncSkillBundles.isPending}
430+
disabled={syncSkillBundles.isPending || !!updateDisabledReason}
431+
disabledReason={updateDisabledReason}
432+
onClick={handleUpdate}
433+
>
434+
Update from local skill
435+
</ActionButton>
436+
</Flex>
437+
);
438+
}
439+
353440
function InstructionsSection({ loop }: { loop: LoopSchemas.Loop }) {
354441
const updateLoop = useUpdateLoop(loop.id);
355442
const [draft, setDraft] = useState<string | null>(null);

packages/ui/src/features/loops/components/LoopForm.tsx

Lines changed: 41 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,11 @@ import {
1313
navigateToLoopDetail,
1414
navigateToLoops,
1515
} from "@posthog/ui/router/navigationBridge";
16-
import { Box, Flex, Text, TextArea, TextField } from "@radix-ui/themes";
16+
import { Box, Flex, Text, TextField } from "@radix-ui/themes";
1717
import { type ReactNode, useEffect, useState } from "react";
1818
import { useAuthStateValue } from "../../auth/store";
1919
import { useCreateLoop, useUpdateLoop } from "../hooks/useLoopMutations";
20+
import { useSyncLoopSkillBundles } from "../hooks/useLoopSkillBundles";
2021
import { summarizeTrigger } from "../loopDisplay";
2122
import { useLoopDraftStore } from "../loopDraftStore";
2223
import {
@@ -30,12 +31,14 @@ import {
3031
loopToFormValues,
3132
normalizeLoopFormValues,
3233
} from "../loopFormTypes";
34+
import { buildSkillInstructions } from "../loopSkill";
3335
import { LoopBehaviorFields } from "./LoopBehaviorFields";
3436
import { LoopContextFields } from "./LoopContextFields";
3537
import { Field } from "./LoopFormPrimitives";
3638
import { LoopModelFields } from "./LoopModelFields";
3739
import { LoopNotificationsFields } from "./LoopNotificationsFields";
3840
import { LoopRepositoryPicker } from "./LoopRepositoryPicker";
41+
import { LoopInstructionsFields } from "./LoopSkillFields";
3942
import { LoopTriggerEditor } from "./LoopTriggerEditor";
4043

4144
const VISIBILITY_OPTIONS: {
@@ -85,13 +88,17 @@ export function LoopForm({ loop }: LoopFormProps) {
8588

8689
const createLoop = useCreateLoop();
8790
const updateLoop = useUpdateLoop(loop?.id ?? "");
88-
const isSubmitting = isEdit ? updateLoop.isPending : createLoop.isPending;
91+
const syncSkillBundles = useSyncLoopSkillBundles();
92+
const isSubmitting =
93+
(isEdit ? updateLoop.isPending : createLoop.isPending) ||
94+
syncSkillBundles.isPending;
8995
const canSubmit = isLoopFormValid(values) && !isSubmitting;
9096

9197
// Per-step gate for the Next button. The final Create button is gated on the
9298
// whole form being valid, so jumping between steps can't submit a bad loop.
9399
const stepComplete = [
94-
!!values.name.trim() && !!values.instructions.trim(),
100+
!!values.name.trim() &&
101+
(values.skill !== null || !!values.instructions.trim()),
95102
values.triggers.every(isTriggerDraftValid),
96103
true,
97104
isLoopFormValid(values),
@@ -124,13 +131,28 @@ export function LoopForm({ loop }: LoopFormProps) {
124131
if (!canSubmit) return;
125132
const body = formValuesToLoopWrite(values);
126133
try {
127-
if (isEdit) {
128-
const updated = await updateLoop.mutateAsync(body);
129-
navigateToLoopDetail(updated.id);
130-
} else {
131-
const created = await createLoop.mutateAsync(body);
132-
navigateToLoopDetail(created.id);
134+
const saved = isEdit
135+
? await updateLoop.mutateAsync(body)
136+
: await createLoop.mutateAsync(body);
137+
// The skill snapshot rides separately from the loop write: a locally
138+
// picked skill is bundled and uploaded, removing the skill detaches the
139+
// stored bundles, and an unchanged attached snapshot is left alone.
140+
const skillSync =
141+
values.skill?.kind === "local"
142+
? { loopId: saved.id, skill: values.skill }
143+
: values.skill === null && saved.skill_bundles.length > 0
144+
? { loopId: saved.id, skill: null }
145+
: null;
146+
if (skillSync) {
147+
try {
148+
await syncSkillBundles.mutateAsync(skillSync);
149+
} catch (error) {
150+
toast.error("Loop saved, but updating its skill failed", {
151+
description: error instanceof Error ? error.message : undefined,
152+
});
153+
}
133154
}
155+
navigateToLoopDetail(saved.id);
134156
} catch (error) {
135157
const safetyLimit =
136158
error instanceof LoopsApiError ? error.safetyLimit : null;
@@ -188,15 +210,11 @@ export function LoopForm({ loop }: LoopFormProps) {
188210
onChange={(e) => patch({ description: e.target.value })}
189211
/>
190212
</Field>
191-
<Field label="Instructions" required>
192-
<TextArea
193-
value={values.instructions}
194-
placeholder="Summarize failing CI runs from the last 24 hours and post the summary to #eng-standup."
195-
disabled={isSubmitting}
196-
className="min-h-[220px] text-[13px] leading-relaxed"
197-
onChange={(e) => patch({ instructions: e.target.value })}
198-
/>
199-
</Field>
213+
<LoopInstructionsFields
214+
values={values}
215+
disabled={isSubmitting}
216+
onPatch={patch}
217+
/>
200218
</Step>
201219
) : null}
202220

@@ -515,7 +533,11 @@ function ReviewList({ values }: { values: LoopFormValues }) {
515533
/>
516534
<ReviewRow
517535
label="Prompt"
518-
value={values.instructions.trim() || "No prompt"}
536+
value={
537+
values.skill
538+
? buildSkillInstructions(values.skill.name, values.skillContext)
539+
: values.instructions.trim() || "No prompt"
540+
}
519541
multiline
520542
/>
521543
<ReviewRow

0 commit comments

Comments
 (0)