From c40e35a537de08a0fd5d64dba914c896b8196234 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Fri, 24 Jul 2026 00:19:36 -0700 Subject: [PATCH] let loops run a skill instead of instructions --- packages/api-client/src/loops.ts | 40 +++ .../loops/components/LoopDetailView.tsx | 103 +++++++ .../features/loops/components/LoopForm.tsx | 118 ++++++-- .../loops/components/LoopSkillFields.tsx | 278 ++++++++++++++++++ .../loops/hooks/useLoopSkillBundles.ts | 130 ++++++++ .../src/features/loops/loopFormTypes.test.ts | 129 ++++++++ .../ui/src/features/loops/loopFormTypes.ts | 34 ++- .../ui/src/features/loops/loopSkill.test.ts | 112 +++++++ packages/ui/src/features/loops/loopSkill.ts | 87 ++++++ .../src/services/skills/skill-bundler.ts | 14 +- .../src/services/skills/skills.test.ts | 54 ++++ .../src/services/skills/skills.ts | 28 ++ 12 files changed, 1102 insertions(+), 25 deletions(-) create mode 100644 packages/ui/src/features/loops/components/LoopSkillFields.tsx create mode 100644 packages/ui/src/features/loops/hooks/useLoopSkillBundles.ts create mode 100644 packages/ui/src/features/loops/loopSkill.test.ts create mode 100644 packages/ui/src/features/loops/loopSkill.ts diff --git a/packages/api-client/src/loops.ts b/packages/api-client/src/loops.ts index 0acfdf3ff3..c67aad7ed2 100644 --- a/packages/api-client/src/loops.ts +++ b/packages/api-client/src/loops.ts @@ -50,6 +50,7 @@ export namespace LoopSchemas { | "failed" | "cancelled"; export type LoopRunEnvironmentEnum = "local" | "cloud"; + export type LoopSkillSourceEnum = "user" | "repo" | "marketplace" | "codex"; export type LoopRepositoryEntry = { github_integration_id: number; @@ -205,6 +206,32 @@ export namespace LoopSchemas { created_at: string; updated_at: string; triggers: Array; + /** Skill bundles attached to this loop, seeded into every fired run's sandbox. + * Replaced wholesale via `replaceLoopSkillBundles`, never through the loop write. + * Optional because a backend that predates skill bundles omits the field; treat + * absence as an empty list. */ + skill_bundles?: Array; + }; + + /** A skill bundle attached to a loop. `content_sha256` is the stored snapshot's + * digest, so a client can detect drift from the local copy of the skill. */ + export type LoopSkillBundle = { + id: string; + skill_name: string; + skill_source: LoopSkillSourceEnum; + size: number; + content_sha256: string; + uploaded_at: string; + }; + + /** One zipped local skill in a skill-bundle replace request. */ + export type LoopSkillBundleUpload = { + file_name: string; + skill_name: string; + skill_source: LoopSkillSourceEnum; + content_sha256: string; + bundle_format: "zip"; + content_base64: string; }; /** Request body for create (all required fields present) and partial_update @@ -392,6 +419,8 @@ const loopRunsPath = (projectId: string, loopId: string): string => `/api/projects/${projectId}/loops/${loopId}/runs/`; const loopPreviewPath = (projectId: string, loopId: string): string => `/api/projects/${projectId}/loops/${loopId}/preview/`; +const loopSkillBundlesPath = (projectId: string, loopId: string): string => + `/api/projects/${projectId}/loops/${loopId}/skill_bundles/`; function idempotencyHeader( idempotencyKey: string | undefined, @@ -582,6 +611,17 @@ export async function listLoopRuns( }); } +export async function replaceLoopSkillBundles( + client: ApiClient, + projectId: string, + loopId: string, + bundles: Array, +): Promise { + return loopsRequest(client, "put", loopSkillBundlesPath(projectId, loopId), { + body: { bundles }, + }); +} + export async function previewLoop( client: ApiClient, projectId: string, diff --git a/packages/ui/src/features/loops/components/LoopDetailView.tsx b/packages/ui/src/features/loops/components/LoopDetailView.tsx index 39c8a1a48c..ff8685713f 100644 --- a/packages/ui/src/features/loops/components/LoopDetailView.tsx +++ b/packages/ui/src/features/loops/components/LoopDetailView.tsx @@ -1,5 +1,7 @@ import { ArrowLeftIcon, RepeatIcon } from "@phosphor-icons/react"; import type { LoopSchemas } from "@posthog/api-client/loops"; +import { isUploadableSkillSource } from "@posthog/core/message-editor/skillTags"; +import { useHostTRPC } from "@posthog/host-router/react"; import { AlertDialog, AlertDialogClose, @@ -20,6 +22,7 @@ import { useUsageLimitStore } from "@posthog/ui/features/billing/usageLimitStore import { useOrgMembers } from "@posthog/ui/features/canvas/hooks/useOrgMembers"; import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; +import { Button as ActionButton } from "@posthog/ui/primitives/Button"; import { TimezoneTimestamp } from "@posthog/ui/primitives/TimezoneTimestamp"; import { systemTimezone } from "@posthog/ui/primitives/timezone"; import { toast } from "@posthog/ui/primitives/toast"; @@ -28,7 +31,9 @@ import { navigateToLoops, } from "@posthog/ui/router/navigationBridge"; import { track } from "@posthog/ui/shell/analytics"; +import { useHostCapabilities } from "@posthog/ui/shell/useHostCapabilities"; import { Flex, Text } from "@radix-ui/themes"; +import { useQuery } from "@tanstack/react-query"; import { useEffect, useRef, useState } from "react"; import { useLoop } from "../hooks/useLoop"; import { @@ -37,6 +42,7 @@ import { useUpdateLoop, } from "../hooks/useLoopMutations"; import { RECENT_RUNS_LIMIT, useLoopRuns } from "../hooks/useLoopRuns"; +import { useSyncLoopSkillBundles } from "../hooks/useLoopSkillBundles"; import { buildLoopEnabledToggledProps, buildLoopViewedProps, @@ -51,6 +57,7 @@ import { summarizeNotificationDestinations, } from "../loopDisplay"; import { formatLoopModel } from "../loopModels"; +import { loopSkillBundles, primaryLoopSkillBundle } from "../loopSkill"; import { LoopLoadError } from "./LoopFallbacks"; import { LoopRunRow } from "./LoopRunRow"; @@ -430,6 +437,12 @@ function ConfigSummarySection({ loop }: { loop: LoopSchemas.Loop }) { .join(" · ")} + {loopSkillBundles(loop).length > 0 ? ( + + + + ) : null} + {loop.repositories.length > 0 ? loop.repositories.map((repo) => repo.full_name).join(", ") @@ -465,8 +478,91 @@ function ConfigSummarySection({ loop }: { loop: LoopSchemas.Loop }) { ); } +function LoopSkillSummary({ loop }: { loop: LoopSchemas.Loop }) { + const { localWorkspaces } = useHostCapabilities(); + const trpc = useHostTRPC(); + const { data: localSkillData } = useQuery({ + ...trpc.skills.list.queryOptions(), + enabled: localWorkspaces, + }); + const syncSkillBundles = useSyncLoopSkillBundles(); + + const primary = primaryLoopSkillBundle(loop); + if (!primary) return null; + const dependencyCount = loopSkillBundles(loop).length - 1; + + // The one-click refresh must be unambiguous about which skill it snapshots: it + // requires exactly one local skill matching the stored name AND source, so a + // same-named skill from another source (say, an opened repo) can never silently + // replace the loop's snapshot. Ambiguous cases go through the edit form, where + // the picker shows each candidate. + const candidates = (localSkillData ?? []).filter( + (skill) => + skill.name === primary.skill_name && + skill.source === primary.skill_source, + ); + const localMatch = candidates.length === 1 ? candidates[0] : undefined; + const updateDisabledReason = !localWorkspaces + ? "updating the snapshot needs the desktop app" + : localMatch + ? null + : candidates.length > 1 + ? `several local skills are named ${primary.skill_name}; pick the right one from the edit form` + : `no local ${primary.skill_source} skill named ${primary.skill_name} was found on this machine`; + + const handleUpdate = () => { + if (!localMatch || !isUploadableSkillSource(localMatch.source)) return; + syncSkillBundles.mutate( + { + loopId: loop.id, + skill: { + name: localMatch.name, + source: localMatch.source, + path: localMatch.path, + }, + }, + { + onSuccess: () => toast.success("Skill snapshot updated"), + onError: (error) => + toast.error("Failed to update the skill snapshot", { + description: error.message, + }), + }, + ); + }; + + return ( + + + {primary.skill_name} + {dependencyCount > 0 + ? ` (+${dependencyCount} ${dependencyCount === 1 ? "dependency" : "dependencies"})` + : ""} + + + Snapshot {primary.content_sha256.slice(0, 8)} + + + Update from local skill + + + ); +} + function InstructionsSection({ loop }: { loop: LoopSchemas.Loop }) { const updateLoop = useUpdateLoop(loop.id); + const primarySkill = primaryLoopSkillBundle(loop); const [draft, setDraft] = useState(null); // Escape reverts and blurs; skip the resulting onBlur save. const skipCommit = useRef(false); @@ -528,6 +624,13 @@ function InstructionsSection({ loop }: { loop: LoopSchemas.Loop }) { } }} /> + {primarySkill ? ( + + This loop runs the {primarySkill.skill_name} skill: the leading / + {primarySkill.skill_name} line invokes its attached snapshot. Editing + here changes only the text; use Edit to change or detach the skill. + + ) : null} ); } diff --git a/packages/ui/src/features/loops/components/LoopForm.tsx b/packages/ui/src/features/loops/components/LoopForm.tsx index 300fa6790e..e68cd60a99 100644 --- a/packages/ui/src/features/loops/components/LoopForm.tsx +++ b/packages/ui/src/features/loops/components/LoopForm.tsx @@ -17,10 +17,18 @@ import { navigateToLoops, } from "@posthog/ui/router/navigationBridge"; import { track } from "@posthog/ui/shell/analytics"; -import { Box, Flex, Text, TextArea, TextField } from "@radix-ui/themes"; +import { Box, Flex, Text, TextField } from "@radix-ui/themes"; import { type ReactNode, useEffect, useState } from "react"; import { useAuthStateValue } from "../../auth/store"; -import { useCreateLoop, useUpdateLoop } from "../hooks/useLoopMutations"; +import { + useCreateLoop, + useDeleteLoop, + useUpdateLoop, +} from "../hooks/useLoopMutations"; +import { + useBundleLocalSkill, + useReplaceLoopSkillBundles, +} from "../hooks/useLoopSkillBundles"; import { buildLoopSavedProps } from "../loopAnalytics"; import { summarizeTrigger } from "../loopDisplay"; import { useLoopDraftStore } from "../loopDraftStore"; @@ -36,12 +44,14 @@ import { normalizeLoopFormValues, } from "../loopFormTypes"; import { formatLoopModel } from "../loopModels"; +import { buildSkillInstructions, loopSkillBundles } from "../loopSkill"; import { LoopBehaviorFields } from "./LoopBehaviorFields"; import { LoopContextFields } from "./LoopContextFields"; import { Field } from "./LoopFormPrimitives"; import { LoopModelFields } from "./LoopModelFields"; import { LoopNotificationsFields } from "./LoopNotificationsFields"; import { LoopRepositoryPicker } from "./LoopRepositoryPicker"; +import { LoopInstructionsFields } from "./LoopSkillFields"; import { LoopTriggerEditor } from "./LoopTriggerEditor"; const VISIBILITY_OPTIONS: { @@ -102,13 +112,21 @@ export function LoopForm({ loop }: LoopFormProps) { const createLoop = useCreateLoop(); const updateLoop = useUpdateLoop(loop?.id ?? ""); - const isSubmitting = isEdit ? updateLoop.isPending : createLoop.isPending; + const deleteLoop = useDeleteLoop(); + const bundleSkill = useBundleLocalSkill(); + const replaceSkillBundles = useReplaceLoopSkillBundles(); + const isSubmitting = + (isEdit ? updateLoop.isPending : createLoop.isPending) || + bundleSkill.isPending || + replaceSkillBundles.isPending || + deleteLoop.isPending; const canSubmit = isLoopFormValid(values) && !isSubmitting; // Per-step gate for the Next button. The final Create button is gated on the // whole form being valid, so jumping between steps can't submit a bad loop. const stepComplete = [ - !!values.name.trim() && !!values.instructions.trim(), + !!values.name.trim() && + (values.skill !== null || !!values.instructions.trim()), values.triggers.every(isTriggerDraftValid), true, isLoopFormValid(values), @@ -140,16 +158,72 @@ export function LoopForm({ loop }: LoopFormProps) { const handleSubmit = async () => { if (!canSubmit) return; const body = formValuesToLoopWrite(values); + + // Bundling runs before anything is persisted: a missing or broken local + // skill fails here with no partial state, instead of leaving a saved loop + // whose `/skill-name` instructions have no matching bundle. + let uploads: LoopSchemas.LoopSkillBundleUpload[] | null = null; + if (values.skill?.kind === "local") { + try { + uploads = await bundleSkill.mutateAsync(values.skill); + } catch (error) { + toast.error("Failed to bundle the skill", { + description: error instanceof Error ? error.message : undefined, + }); + return; + } + } + try { - if (isEdit) { - const updated = await updateLoop.mutateAsync(body); - track(ANALYTICS_EVENTS.LOOP_UPDATED, buildLoopSavedProps(updated)); - navigateToLoopDetail(updated.id); - } else { - const created = await createLoop.mutateAsync(body); - track(ANALYTICS_EVENTS.LOOP_CREATED, buildLoopSavedProps(created)); - navigateToLoopDetail(created.id); + const saved = isEdit + ? await updateLoop.mutateAsync(body) + : await createLoop.mutateAsync(body); + track( + isEdit ? ANALYTICS_EVENTS.LOOP_UPDATED : ANALYTICS_EVENTS.LOOP_CREATED, + buildLoopSavedProps(saved), + ); + const needsDetach = + values.skill === null && loopSkillBundles(saved).length > 0; + if (uploads || needsDetach) { + try { + await replaceSkillBundles.mutateAsync({ + loopId: saved.id, + uploads: uploads ?? [], + }); + } catch (error) { + const description = + error instanceof Error ? error.message : undefined; + if (!isEdit) { + // Roll the just-created loop back rather than leaving one that + // fires `/skill-name` with no bundle behind it. If the rollback + // itself fails, an orphaned loop exists — say so instead of + // pretending nothing was created. + try { + await deleteLoop.mutateAsync(saved.id); + toast.error("Failed to create loop", { description }); + } catch { + toast.error("Loop created, but attaching its skill failed", { + description: [ + description, + `Delete "${saved.name}" or re-save it from Edit.`, + ] + .filter(Boolean) + .join(" "), + }); + } + return; + } + // Keep the form open with its state intact: saving again retries + // both the loop write and the skill upload. + toast.error("Loop saved, but updating its skill failed", { + description: [description, "Save again to retry."] + .filter(Boolean) + .join(" "), + }); + return; + } } + navigateToLoopDetail(saved.id); } catch (error) { const safetyLimit = error instanceof LoopsApiError ? error.safetyLimit : null; @@ -207,15 +281,11 @@ export function LoopForm({ loop }: LoopFormProps) { onChange={(e) => patch({ description: e.target.value })} /> - -