Skip to content
Closed
8 changes: 7 additions & 1 deletion packages/core/src/sessions/cloudArtifactService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,13 @@ describe("CloudArtifactService", () => {
"task-1",
"run-1",
[],
[{ name: "local-skill", source: "user", path: "/tmp/local-skill" }],
[
{
name: "local-skill",
source: "user",
path: "/tmp/local-skill",
},
],
);

expect(ids).toEqual(["skill-artifact-1"]);
Expand Down
2 changes: 0 additions & 2 deletions packages/core/src/sessions/cloudArtifactService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,8 +232,6 @@ export class CloudArtifactService {
if (skillBundleRefs.length === 0) {
return [];
}
// Pull in dependency skills the tagged ones declare, so a skill that needs
// another arrives in the sandbox together with it.
const expandedRefs =
await this.resolveSkillBundleDependencies(skillBundleRefs);
return Promise.all(
Expand Down
10 changes: 9 additions & 1 deletion packages/core/src/task-detail/taskCreationHost.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import type { ContentBlock } from "@agentclientprotocol/sdk";
import type { CloudSkillBundleRef } from "@posthog/core/sessions/cloudArtifactIdentifiers";
import type { Workspace, WorkspaceInfo, WorkspaceMode } from "@posthog/shared";
import type {
AlwaysOnSkillRef,
Workspace,
WorkspaceInfo,
WorkspaceMode,
} from "@posthog/shared";
import type { TaskCreationApiClient } from "./taskCreationApiClient";

export interface CloudPromptTransport {
Expand Down Expand Up @@ -103,6 +108,9 @@ export interface ITaskCreationHost {
* too, or a typed `/my-skill` reaches the sandbox with no bundle attached.
*/
resolveLocalSkillCommandPrompt(prompt: string): Promise<string>;
renderAlwaysOnSkillInstructions(
skills: AlwaysOnSkillRef[],
): Promise<string | undefined>;
/**
* Return-and-clear the pre-warmed sandbox lease matching the composer
* selection, if one was provisioned while the user typed. The saga uploads
Expand Down
76 changes: 76 additions & 0 deletions packages/core/src/task-detail/taskCreationSaga.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const mockHost = vi.hoisted(() => ({
detectRepo: vi.fn(),
getCloudPromptTransport: vi.fn(),
resolveLocalSkillCommandPrompt: vi.fn(async (prompt: string) => prompt),
renderAlwaysOnSkillInstructions: vi.fn(),
takeWarmTaskLease: vi.fn(
(): { taskId: string; runId: string } | null => null,
),
Expand Down Expand Up @@ -118,6 +119,7 @@ describe("TaskCreationSaga", () => {
mockHost.getWorkspace.mockResolvedValue(null);
mockHost.getFolders.mockResolvedValue([]);
mockHost.uploadRunAttachments.mockResolvedValue([]);
mockHost.renderAlwaysOnSkillInstructions.mockResolvedValue(undefined);
mockHost.linkTaskBranch.mockResolvedValue(undefined);
mockHost.recordClaudeCliImport.mockResolvedValue(undefined);
mockHost.deleteClaudeCliImport.mockResolvedValue(undefined);
Expand Down Expand Up @@ -243,6 +245,49 @@ describe("TaskCreationSaga", () => {
);
});

it("folds always-on skill instructions into the first cloud message", async () => {
const startedTask = createTask({ latest_run: createRun() });
const startTaskRun = vi.fn().mockResolvedValue(startedTask);
mockHost.renderAlwaysOnSkillInstructions.mockResolvedValue(
"<always_on_skills>Be concise.</always_on_skills>",
);

const saga = makeSaga({
createTask: vi.fn().mockResolvedValue(createTask()),
createTaskRun: vi.fn().mockResolvedValue(createRun()),
startTaskRun,
});
const skill = {
name: "concise",
source: "user" as const,
path: "/skills/concise",
};

const result = await saga.run({
content: "Ship the fix",
repository: "posthog/posthog",
workspaceMode: "cloud",
alwaysOnSkills: [skill],
});

expect(result.success).toBe(true);
expect(mockHost.renderAlwaysOnSkillInstructions).toHaveBeenCalledWith([
skill,
]);
expect(startTaskRun).toHaveBeenCalledWith(
"task-123",
"run-123",
expect.objectContaining({
pendingUserMessage:
"Ship the fix\n\n<always_on_skills>Be concise.</always_on_skills>",
}),
);
expect(mockHost.getCloudPromptTransport).toHaveBeenCalledWith(
"Ship the fix",
undefined,
);
});

it("folds custom personalization into the cloud prompt and stashes it for the optimistic placeholder", async () => {
const createdTask = createTask();
const startedTask = createTask({ latest_run: createRun() });
Expand Down Expand Up @@ -353,6 +398,37 @@ describe("TaskCreationSaga", () => {
);
});

it("adds always-on skill instructions to the initial local prompt", async () => {
mockHost.renderAlwaysOnSkillInstructions.mockResolvedValue(
"<always_on_skills>Be concise.</always_on_skills>",
);
const saga = makeSaga({
createTask: vi.fn().mockResolvedValue(createTask()),
});

const result = await saga.run({
content: "Ship the fix",
workspaceMode: "local",
allowNoRepo: true,
alwaysOnSkills: [
{ name: "concise", source: "user", path: "/skills/concise" },
],
});

expect(result.success).toBe(true);
const connectParams = vi.mocked(sessionService.connectToTask).mock
.calls[0][0];
expect(connectParams.initialPrompt).toEqual(
expect.arrayContaining([
{
type: "text",
text: "<always_on_skills>Be concise.</always_on_skills>",
},
]),
);
expect(connectParams).not.toHaveProperty("alwaysOnSkills");
});

it("starts a Pi session without creating an ACP session", async () => {
const createdTask = createTask({ repository: undefined });
const createTaskRequest = vi.fn().mockResolvedValue(createdTask);
Expand Down
50 changes: 41 additions & 9 deletions packages/core/src/task-detail/taskCreationSaga.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { PI_THINKING_LEVELS } from "@posthog/agent/pi/types";
import {
buildChannelContextBlock,
buildChannelContextText,
buildCustomInstructionsText,
buildPromptBlocks,
Expand Down Expand Up @@ -54,6 +53,7 @@ interface WarmActivationPayload {
function buildCloudFirstMessage(
messageText: string | undefined,
input: TaskCreationInput,
alwaysOnSkillInstructions?: string,
): { pendingUserMessage?: string; augmented: boolean } {
const customInstructionsText = messageText
? buildCustomInstructionsText(input.customInstructions)
Expand All @@ -64,12 +64,21 @@ function buildCloudFirstMessage(
input.channelContextId,
);
const pendingUserMessage =
[messageText, customInstructionsText, channelContextText]
[
messageText,
customInstructionsText,
alwaysOnSkillInstructions,
channelContextText,
]
.filter((part): part is string => !!part)
.join("\n\n") || undefined;
return {
pendingUserMessage,
augmented: !!(customInstructionsText || channelContextText),
augmented: !!(
customInstructionsText ||
alwaysOnSkillInstructions ||
channelContextText
),
};
}

Expand Down Expand Up @@ -99,10 +108,16 @@ export class TaskCreationSaga extends Saga<
const importedClaude = isPiRuntime
? undefined
: await this.importClaudeSession(input);
const alwaysOnSkills = input.alwaysOnSkills;
const alwaysOnSkillInstructions = alwaysOnSkills?.length
? await this.readOnlyStep("render_always_on_skills", () =>
this.deps.host.renderAlwaysOnSkillInstructions(alwaysOnSkills),
)
: undefined;

const warmPayload =
!isPiRuntime && !taskId && input.workspaceMode === "cloud"
? await this.prepareWarmActivation(input)
? await this.prepareWarmActivation(input, alwaysOnSkillInstructions)
: null;

let task = taskId
Expand Down Expand Up @@ -387,7 +402,11 @@ export class TaskCreationSaga extends Saga<

const { pendingUserMessage, augmented } = warmPayload
? warmPayload
: buildCloudFirstMessage(transport?.messageText, input);
: buildCloudFirstMessage(
transport?.messageText,
input,
alwaysOnSkillInstructions,
);

// The sandbox echoes pendingUserMessage back once it boots; until then
// the optimistic placeholder would show the bare task description with
Expand Down Expand Up @@ -511,13 +530,22 @@ export class TaskCreationSaga extends Saga<
// Append the channel's CONTEXT.md as optional background, so tasks made
// in a channel start with the shared context the agent would otherwise
// have to rediscover. Kept after the user's prompt so the request leads.
const channelContextBlock = buildChannelContextBlock(
const channelContextText = buildChannelContextText(
input.channelContext,
input.channelName,
input.channelContextId,
);
if (initialPrompt && channelContextBlock) {
initialPrompt.push(channelContextBlock);
const supplementaryContext = [
alwaysOnSkillInstructions,
channelContextText,
].filter((text): text is string => !!text);
if (initialPrompt) {
initialPrompt.push(
...supplementaryContext.map((text) => ({
type: "text" as const,
text,
})),
);
}

await this.step({
Expand All @@ -531,7 +559,9 @@ export class TaskCreationSaga extends Saga<
await this.deps.piRunner.create({
taskId: task.id,
cwd: agentCwd ?? "",
prompt: input.content ?? "",
prompt: [input.content, ...supplementaryContext]
.filter((text): text is string => !!text)
.join("\n\n"),
model: input.model,
thinkingLevel,
});
Expand Down Expand Up @@ -708,6 +738,7 @@ export class TaskCreationSaga extends Saga<
// deliver the first message without its attachments.
private async prepareWarmActivation(
input: TaskCreationInput,
alwaysOnSkillInstructions?: string,
): Promise<WarmActivationPayload | null> {
if (!input.content && !input.filePaths?.length) {
return null;
Expand All @@ -723,6 +754,7 @@ export class TaskCreationSaga extends Saga<
const { pendingUserMessage, augmented } = buildCloudFirstMessage(
transport.messageText,
input,
alwaysOnSkillInstructions,
);
const base: WarmActivationPayload = {
transport,
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/task-detail/taskInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export interface PrepareTaskInputOptions {
channelId?: string;
channelContextId?: string;
customInstructions?: string;
alwaysOnSkills?: TaskCreationInput["alwaysOnSkills"];
Comment thread
adboio marked this conversation as resolved.
autoPublishCloudRuns?: boolean;
rtkEnabledCloud?: boolean;
allowNoRepo?: boolean;
Expand Down Expand Up @@ -85,6 +86,7 @@ export function prepareTaskInput(
channelId: options.channelId,
channelContextId: options.channelContextId,
customInstructions: isCloud ? options.customInstructions : undefined,
alwaysOnSkills: options.alwaysOnSkills,
allowNoRepo: options.allowNoRepo,
importedMcpServers: isCloud ? options.importedMcpServers : undefined,
relayedMcpServers: isCloud ? options.relayedMcpServers : undefined,
Expand Down
9 changes: 9 additions & 0 deletions packages/host-router/src/routers/skills.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
readSkillFileInput,
readSkillFileOutput,
renameSkillFileInput,
renderAlwaysOnSkillsOutput,
resolveSkillDependenciesInput,
resolveSkillDependenciesOutput,
saveSkillFileInput,
Expand Down Expand Up @@ -54,6 +55,14 @@ export const skillsRouter = router({
.get<SkillsService>(SKILLS_SERVICE)
.resolveSkillBundleDependencies(input),
),
renderAlwaysOn: publicProcedure
.input(resolveSkillDependenciesInput)
.output(renderAlwaysOnSkillsOutput)
.query(({ ctx, input }) =>
ctx.container
.get<SkillsService>(SKILLS_SERVICE)
.renderAlwaysOnSkillInstructions(input),
),
contents: publicProcedure
.input(skillContentsInput)
.output(skillContentsOutput)
Expand Down
3 changes: 3 additions & 0 deletions packages/shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,7 @@ export type {
SignalReportStatus,
} from "./signal-types";
export type {
AlwaysOnSkillTarget,
ExportedSkill,
ExportedSkillFile,
SkillFileEntry,
Expand All @@ -336,6 +337,7 @@ export type {
UploadableSkillSource,
} from "./skills";
export {
getApplicableAlwaysOnSkills,
SKILL_EXISTS_MARKER,
serializeSkillMarkdown,
stripFrontmatter,
Expand All @@ -354,6 +356,7 @@ export {
updateTaskAutomationSchema,
} from "./task-automation";
export type {
AlwaysOnSkillRef,
TaskCreationInput,
TaskCreationOutput,
} from "./task-creation-domain";
Expand Down
Loading
Loading