Skip to content

Commit c18972c

Browse files
committed
let loops run a skill instead of instructions
1 parent 883dc3f commit c18972c

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
@@ -50,6 +50,7 @@ export namespace LoopSchemas {
5050
| "failed"
5151
| "cancelled";
5252
export type LoopRunEnvironmentEnum = "local" | "cloud";
53+
export type LoopSkillSourceEnum = "user" | "repo" | "marketplace" | "codex";
5354

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

210235
/** Request body for create (all required fields present) and partial_update
@@ -392,6 +417,8 @@ const loopRunsPath = (projectId: string, loopId: string): string =>
392417
`/api/projects/${projectId}/loops/${loopId}/runs/`;
393418
const loopPreviewPath = (projectId: string, loopId: string): string =>
394419
`/api/projects/${projectId}/loops/${loopId}/preview/`;
420+
const loopSkillBundlesPath = (projectId: string, loopId: string): string =>
421+
`/api/projects/${projectId}/loops/${loopId}/skill_bundles/`;
395422

396423
function idempotencyHeader(
397424
idempotencyKey: string | undefined,
@@ -582,6 +609,17 @@ export async function listLoopRuns(
582609
});
583610
}
584611

612+
export async function replaceLoopSkillBundles(
613+
client: ApiClient,
614+
projectId: string,
615+
loopId: string,
616+
bundles: Array<LoopSchemas.LoopSkillBundleUpload>,
617+
): Promise<LoopSchemas.Loop> {
618+
return loopsRequest(client, "put", loopSkillBundlesPath(projectId, loopId), {
619+
body: { bundles },
620+
});
621+
}
622+
585623
export async function previewLoop(
586624
client: ApiClient,
587625
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,
@@ -19,14 +21,17 @@ import { useUsageLimitStore } from "@posthog/ui/features/billing/usageLimitStore
1921
import { useOrgMembers } from "@posthog/ui/features/canvas/hooks/useOrgMembers";
2022
import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay";
2123
import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent";
24+
import { Button as ActionButton } from "@posthog/ui/primitives/Button";
2225
import { TimezoneTimestamp } from "@posthog/ui/primitives/TimezoneTimestamp";
2326
import { systemTimezone } from "@posthog/ui/primitives/timezone";
2427
import { toast } from "@posthog/ui/primitives/toast";
2528
import {
2629
navigateToEditLoop,
2730
navigateToLoops,
2831
} from "@posthog/ui/router/navigationBridge";
32+
import { useHostCapabilities } from "@posthog/ui/shell/useHostCapabilities";
2933
import { Flex, Text } from "@radix-ui/themes";
34+
import { useQuery } from "@tanstack/react-query";
3035
import { useRef, useState } from "react";
3136
import { useLoop } from "../hooks/useLoop";
3237
import { useLoopDisplayModel } from "../hooks/useLoopDisplayModel";
@@ -36,6 +41,7 @@ import {
3641
useUpdateLoop,
3742
} from "../hooks/useLoopMutations";
3843
import { RECENT_RUNS_LIMIT, useLoopRuns } from "../hooks/useLoopRuns";
44+
import { useSyncLoopSkillBundles } from "../hooks/useLoopSkillBundles";
3945
import {
4046
describeTrigger,
4147
loopFireBlockedMessage,
@@ -45,6 +51,7 @@ import {
4551
nextScheduleRun,
4652
summarizeNotificationDestinations,
4753
} from "../loopDisplay";
54+
import { primaryLoopSkillBundle } from "../loopSkill";
4855
import { LoopLoadError } from "./LoopFallbacks";
4956
import { LoopRunRow } from "./LoopRunRow";
5057

@@ -370,6 +377,12 @@ function ConfigSummarySection({ loop }: { loop: LoopSchemas.Loop }) {
370377
.join(" · ")}
371378
</SummaryRow>
372379

380+
{loop.skill_bundles.length > 0 ? (
381+
<SummaryRow label="Skill">
382+
<LoopSkillSummary loop={loop} />
383+
</SummaryRow>
384+
) : null}
385+
373386
<SummaryRow label="Repository">
374387
{loop.repositories.length > 0
375388
? loop.repositories.map((repo) => repo.full_name).join(", ")
@@ -405,6 +418,80 @@ function ConfigSummarySection({ loop }: { loop: LoopSchemas.Loop }) {
405418
);
406419
}
407420

421+
function LoopSkillSummary({ loop }: { loop: LoopSchemas.Loop }) {
422+
const { localWorkspaces } = useHostCapabilities();
423+
const trpc = useHostTRPC();
424+
const skillsQuery = useQuery({
425+
...trpc.skills.list.queryOptions(),
426+
enabled: localWorkspaces,
427+
});
428+
const syncSkillBundles = useSyncLoopSkillBundles();
429+
430+
const primary = primaryLoopSkillBundle(loop);
431+
if (!primary) return null;
432+
const dependencyCount = loop.skill_bundles.length - 1;
433+
434+
const localMatch = (skillsQuery.data ?? []).find(
435+
(skill) =>
436+
skill.name === primary.skill_name &&
437+
isUploadableSkillSource(skill.source),
438+
);
439+
const updateDisabledReason = !localWorkspaces
440+
? "updating the snapshot needs the desktop app"
441+
: localMatch
442+
? null
443+
: `no local skill named ${primary.skill_name} was found on this machine`;
444+
445+
const handleUpdate = () => {
446+
if (!localMatch || !isUploadableSkillSource(localMatch.source)) return;
447+
syncSkillBundles.mutate(
448+
{
449+
loopId: loop.id,
450+
skill: {
451+
name: localMatch.name,
452+
source: localMatch.source,
453+
path: localMatch.path,
454+
},
455+
},
456+
{
457+
onSuccess: () => toast.success("Skill snapshot updated"),
458+
onError: (error) =>
459+
toast.error("Failed to update the skill snapshot", {
460+
description: error.message,
461+
}),
462+
},
463+
);
464+
};
465+
466+
return (
467+
<Flex align="center" gap="2" wrap="wrap">
468+
<Text className="text-[12.5px] text-gray-12">
469+
{primary.skill_name}
470+
{dependencyCount > 0
471+
? ` (+${dependencyCount} ${dependencyCount === 1 ? "dependency" : "dependencies"})`
472+
: ""}
473+
</Text>
474+
<Text
475+
className="text-[11px] text-gray-10"
476+
title={new Date(primary.uploaded_at).toLocaleString()}
477+
>
478+
Snapshot {primary.content_sha256.slice(0, 8)}
479+
</Text>
480+
<ActionButton
481+
variant="soft"
482+
color="gray"
483+
size="1"
484+
loading={syncSkillBundles.isPending}
485+
disabled={syncSkillBundles.isPending || !!updateDisabledReason}
486+
disabledReason={updateDisabledReason}
487+
onClick={handleUpdate}
488+
>
489+
Update from local skill
490+
</ActionButton>
491+
</Flex>
492+
);
493+
}
494+
408495
function InstructionsSection({ loop }: { loop: LoopSchemas.Loop }) {
409496
const updateLoop = useUpdateLoop(loop.id);
410497
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

@@ -543,7 +561,11 @@ function ReviewList({
543561
/>
544562
<ReviewRow
545563
label="Prompt"
546-
value={values.instructions.trim() || "No prompt"}
564+
value={
565+
values.skill
566+
? buildSkillInstructions(values.skill.name, values.skillContext)
567+
: values.instructions.trim() || "No prompt"
568+
}
547569
multiline
548570
/>
549571
<ReviewRow

0 commit comments

Comments
 (0)