Skip to content

Commit 557785e

Browse files
committed
let loops run a skill instead of instructions
1 parent ac15077 commit 557785e

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

@@ -323,6 +330,12 @@ function ConfigSummarySection({ loop }: { loop: LoopSchemas.Loop }) {
323330
.join(" · ")}
324331
</SummaryRow>
325332

333+
{loop.skill_bundles.length > 0 ? (
334+
<SummaryRow label="Skill">
335+
<LoopSkillSummary loop={loop} />
336+
</SummaryRow>
337+
) : null}
338+
326339
<SummaryRow label="Repository">
327340
{loop.repositories.length > 0
328341
? loop.repositories.map((repo) => repo.full_name).join(", ")
@@ -358,6 +371,80 @@ function ConfigSummarySection({ loop }: { loop: LoopSchemas.Loop }) {
358371
);
359372
}
360373

374+
function LoopSkillSummary({ loop }: { loop: LoopSchemas.Loop }) {
375+
const { localWorkspaces } = useHostCapabilities();
376+
const trpc = useHostTRPC();
377+
const skillsQuery = useQuery({
378+
...trpc.skills.list.queryOptions(),
379+
enabled: localWorkspaces,
380+
});
381+
const syncSkillBundles = useSyncLoopSkillBundles();
382+
383+
const primary = primaryLoopSkillBundle(loop);
384+
if (!primary) return null;
385+
const dependencyCount = loop.skill_bundles.length - 1;
386+
387+
const localMatch = (skillsQuery.data ?? []).find(
388+
(skill) =>
389+
skill.name === primary.skill_name &&
390+
isUploadableSkillSource(skill.source),
391+
);
392+
const updateDisabledReason = !localWorkspaces
393+
? "updating the snapshot needs the desktop app"
394+
: localMatch
395+
? null
396+
: `no local skill named ${primary.skill_name} was found on this machine`;
397+
398+
const handleUpdate = () => {
399+
if (!localMatch || !isUploadableSkillSource(localMatch.source)) return;
400+
syncSkillBundles.mutate(
401+
{
402+
loopId: loop.id,
403+
skill: {
404+
name: localMatch.name,
405+
source: localMatch.source,
406+
path: localMatch.path,
407+
},
408+
},
409+
{
410+
onSuccess: () => toast.success("Skill snapshot updated"),
411+
onError: (error) =>
412+
toast.error("Failed to update the skill snapshot", {
413+
description: error.message,
414+
}),
415+
},
416+
);
417+
};
418+
419+
return (
420+
<Flex align="center" gap="2" wrap="wrap">
421+
<Text className="text-[12.5px] text-gray-12">
422+
{primary.skill_name}
423+
{dependencyCount > 0
424+
? ` (+${dependencyCount} ${dependencyCount === 1 ? "dependency" : "dependencies"})`
425+
: ""}
426+
</Text>
427+
<Text
428+
className="text-[11px] text-gray-10"
429+
title={new Date(primary.uploaded_at).toLocaleString()}
430+
>
431+
Snapshot {primary.content_sha256.slice(0, 8)}
432+
</Text>
433+
<ActionButton
434+
variant="soft"
435+
color="gray"
436+
size="1"
437+
loading={syncSkillBundles.isPending}
438+
disabled={syncSkillBundles.isPending || !!updateDisabledReason}
439+
disabledReason={updateDisabledReason}
440+
onClick={handleUpdate}
441+
>
442+
Update from local skill
443+
</ActionButton>
444+
</Flex>
445+
);
446+
}
447+
361448
function InstructionsSection({ loop }: { loop: LoopSchemas.Loop }) {
362449
const updateLoop = useUpdateLoop(loop.id);
363450
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
@@ -16,10 +16,11 @@ import {
1616
navigateToLoopDetail,
1717
navigateToLoops,
1818
} from "@posthog/ui/router/navigationBridge";
19-
import { Box, Flex, Text, TextArea, TextField } from "@radix-ui/themes";
19+
import { Box, Flex, Text, TextField } from "@radix-ui/themes";
2020
import { type ReactNode, useEffect, useState } from "react";
2121
import { useAuthStateValue } from "../../auth/store";
2222
import { useCreateLoop, useUpdateLoop } from "../hooks/useLoopMutations";
23+
import { useSyncLoopSkillBundles } from "../hooks/useLoopSkillBundles";
2324
import { summarizeTrigger } from "../loopDisplay";
2425
import { useLoopDraftStore } from "../loopDraftStore";
2526
import {
@@ -33,12 +34,14 @@ import {
3334
loopToFormValues,
3435
normalizeLoopFormValues,
3536
} from "../loopFormTypes";
37+
import { buildSkillInstructions } from "../loopSkill";
3638
import { LoopBehaviorFields } from "./LoopBehaviorFields";
3739
import { LoopContextFields } from "./LoopContextFields";
3840
import { Field } from "./LoopFormPrimitives";
3941
import { LoopModelFields } from "./LoopModelFields";
4042
import { LoopNotificationsFields } from "./LoopNotificationsFields";
4143
import { LoopRepositoryPicker } from "./LoopRepositoryPicker";
44+
import { LoopInstructionsFields } from "./LoopSkillFields";
4245
import { LoopTriggerEditor } from "./LoopTriggerEditor";
4346

4447
const VISIBILITY_OPTIONS: {
@@ -99,13 +102,17 @@ export function LoopForm({ loop }: LoopFormProps) {
99102

100103
const createLoop = useCreateLoop();
101104
const updateLoop = useUpdateLoop(loop?.id ?? "");
102-
const isSubmitting = isEdit ? updateLoop.isPending : createLoop.isPending;
105+
const syncSkillBundles = useSyncLoopSkillBundles();
106+
const isSubmitting =
107+
(isEdit ? updateLoop.isPending : createLoop.isPending) ||
108+
syncSkillBundles.isPending;
103109
const canSubmit = isLoopFormValid(values) && !isSubmitting;
104110

105111
// Per-step gate for the Next button. The final Create button is gated on the
106112
// whole form being valid, so jumping between steps can't submit a bad loop.
107113
const stepComplete = [
108-
!!values.name.trim() && !!values.instructions.trim(),
114+
!!values.name.trim() &&
115+
(values.skill !== null || !!values.instructions.trim()),
109116
values.triggers.every(isTriggerDraftValid),
110117
true,
111118
isLoopFormValid(values),
@@ -138,13 +145,28 @@ export function LoopForm({ loop }: LoopFormProps) {
138145
if (!canSubmit) return;
139146
const body = formValuesToLoopWrite(values);
140147
try {
141-
if (isEdit) {
142-
const updated = await updateLoop.mutateAsync(body);
143-
navigateToLoopDetail(updated.id);
144-
} else {
145-
const created = await createLoop.mutateAsync(body);
146-
navigateToLoopDetail(created.id);
148+
const saved = isEdit
149+
? await updateLoop.mutateAsync(body)
150+
: await createLoop.mutateAsync(body);
151+
// The skill snapshot rides separately from the loop write: a locally
152+
// picked skill is bundled and uploaded, removing the skill detaches the
153+
// stored bundles, and an unchanged attached snapshot is left alone.
154+
const skillSync =
155+
values.skill?.kind === "local"
156+
? { loopId: saved.id, skill: values.skill }
157+
: values.skill === null && saved.skill_bundles.length > 0
158+
? { loopId: saved.id, skill: null }
159+
: null;
160+
if (skillSync) {
161+
try {
162+
await syncSkillBundles.mutateAsync(skillSync);
163+
} catch (error) {
164+
toast.error("Loop saved, but updating its skill failed", {
165+
description: error instanceof Error ? error.message : undefined,
166+
});
167+
}
147168
}
169+
navigateToLoopDetail(saved.id);
148170
} catch (error) {
149171
const safetyLimit =
150172
error instanceof LoopsApiError ? error.safetyLimit : null;
@@ -202,15 +224,11 @@ export function LoopForm({ loop }: LoopFormProps) {
202224
onChange={(e) => patch({ description: e.target.value })}
203225
/>
204226
</Field>
205-
<Field label="Instructions" required>
206-
<TextArea
207-
value={values.instructions}
208-
placeholder="Summarize failing CI runs from the last 24 hours and post the summary to #eng-standup."
209-
disabled={isSubmitting}
210-
className="min-h-[220px] text-[13px] leading-relaxed"
211-
onChange={(e) => patch({ instructions: e.target.value })}
212-
/>
213-
</Field>
227+
<LoopInstructionsFields
228+
values={values}
229+
disabled={isSubmitting}
230+
onPatch={patch}
231+
/>
214232
</Step>
215233
) : null}
216234

@@ -540,7 +558,11 @@ function ReviewList({
540558
/>
541559
<ReviewRow
542560
label="Prompt"
543-
value={values.instructions.trim() || "No prompt"}
561+
value={
562+
values.skill
563+
? buildSkillInstructions(values.skill.name, values.skillContext)
564+
: values.instructions.trim() || "No prompt"
565+
}
544566
multiline
545567
/>
546568
<ReviewRow

0 commit comments

Comments
 (0)