From f37ebf10749e2d54c591ebd08cdc00f1877cc852 Mon Sep 17 00:00:00 2001 From: iamjr15 Date: Sun, 19 Jul 2026 16:49:12 +0530 Subject: [PATCH] fix(skills): persist creator output directly --- .../agent-run-mastra-stream.ts | 3 +- .../durable-objects/agent-run-user-skills.ts | 79 ++++- .../durable-objects/mastra-stream-chunks.ts | 32 +- apps/agent-worker/src/index.ts | 4 +- .../src/skill-proposal-http-routes.ts | 327 ------------------ .../src/skill-runtime-managed-routes.ts | 26 -- .../src/user-skill-http-routes.ts | 133 +++++++ apps/agent-worker/src/user-skill-packages.ts | 5 +- apps/gateway-worker/src/agent-http-routes.ts | 4 - .../src/openapi-skill-routes.ts | 18 +- apps/web/src/components/chat/chat-panel.tsx | 1 - .../src/components/chat/message-list-view.tsx | 22 -- apps/web/src/components/chat/message-list.tsx | 19 - .../web/src/components/chat/message-parts.tsx | 110 +----- .../src/components/chat/message-timeline.ts | 12 +- .../chat/use-chat-panel-controller.ts | 14 +- apps/web/src/lib/api/project-thread.ts | 2 +- apps/web/src/lib/api/skills.ts | 26 -- .../post/0076_remove_skill_proposals.sql | 11 + infra/supabase/migrations/raw-phases.json | 3 +- packages/agent-core/src/index.ts | 3 + .../agent-core/src/mastra/system-prompt.ts | 40 ++- .../src/mastra/tools/request-context.ts | 4 + .../src/mastra/tools/skill-tools.ts | 32 +- .../src/mastra/tools/tool-schemas.ts | 15 +- packages/db/src/index.ts | 3 - packages/db/src/thread-messages.ts | 70 ---- packages/types/src/api.ts | 8 - packages/types/src/index.ts | 2 - packages/types/src/ui-message.ts | 16 - skills/manage-skills/_shared.ts | 2 +- skills/skill-authoring/SKILL.md | 6 +- skills/skill-authoring/create.ts | 118 ------- .../references/skill-registration.md | 2 +- 34 files changed, 322 insertions(+), 850 deletions(-) delete mode 100644 apps/agent-worker/src/skill-proposal-http-routes.ts create mode 100644 apps/agent-worker/src/user-skill-http-routes.ts create mode 100644 infra/supabase/migrations/post/0076_remove_skill_proposals.sql delete mode 100644 skills/skill-authoring/create.ts diff --git a/apps/agent-worker/src/durable-objects/agent-run-mastra-stream.ts b/apps/agent-worker/src/durable-objects/agent-run-mastra-stream.ts index c9ecfe75..1c3c85b2 100644 --- a/apps/agent-worker/src/durable-objects/agent-run-mastra-stream.ts +++ b/apps/agent-worker/src/durable-objects/agent-run-mastra-stream.ts @@ -170,7 +170,7 @@ function agentRequestContext( prepared: PreparedMastraContext, ): ReturnType { const { credential, input } = options; - const { toolCredentials, userSkillLoader, userSkills } = prepared; + const { toolCredentials, userSkillCreator, userSkillLoader, userSkills } = prepared; const isSkillCreator = input.runIntent === "skill-creator"; const codeRuntime: CodeRuntimeContext = { artifacts: options.artifactRuntime, @@ -209,6 +209,7 @@ function agentRequestContext( runIntent: input.runIntent, runId: input.runId, taskMessage: input.messageText, + ...(isSkillCreator ? { userSkillCreator } : {}), userSkillLoader, userSkills, }); diff --git a/apps/agent-worker/src/durable-objects/agent-run-user-skills.ts b/apps/agent-worker/src/durable-objects/agent-run-user-skills.ts index 1ab9b09c..5da9190f 100644 --- a/apps/agent-worker/src/durable-objects/agent-run-user-skills.ts +++ b/apps/agent-worker/src/durable-objects/agent-run-user-skills.ts @@ -1,4 +1,11 @@ -import type { UserSkillDefinition, UserSkillLoader, UserSkillRuntime } from "@cheatcode/agent-core"; +import type { + UserSkillCreateInput, + UserSkillCreateResult, + UserSkillCreator, + UserSkillDefinition, + UserSkillLoader, + UserSkillRuntime, +} from "@cheatcode/agent-core"; import { createDb, getUserSkillByName, @@ -9,11 +16,22 @@ import { } from "@cheatcode/db"; import type { SandboxLike } from "@cheatcode/sandbox-contracts"; import { UserId } from "@cheatcode/types"; -import { resolveUserSkillMirror, userSkillSlug, writeUserSkillMirror } from "../user-skill-files"; -import { readUserSkillPackage, writeUserSkillPackageMirror } from "../user-skill-packages"; +import { + resolveUserSkillMirror, + serializeUserSkillMarkdown, + userSkillSlug, + writeUserSkillMirror, +} from "../user-skill-files"; +import { + collectUserSkillPackageFromSandbox, + persistUserSkillPackage, + readUserSkillPackage, + writeUserSkillPackageMirror, +} from "../user-skill-packages"; import type { AgentRunEnv } from "./agent-run-env"; export interface ResolvedUserSkillContext { + userSkillCreator: UserSkillCreator; userSkills: UserSkillRuntime[]; userSkillLoader: UserSkillLoader; } @@ -34,7 +52,60 @@ export async function resolveUserSkillContext( const userSkillLoader: UserSkillLoader = { load: async (name) => loadUserSkill(env, userId, sandbox, name), }; - return { userSkills, userSkillLoader }; + const userSkillCreator: UserSkillCreator = { + create: async (input) => persistCreatedUserSkill(env, userId, sandbox, input), + }; + return { userSkillCreator, userSkills, userSkillLoader }; +} + +async function persistCreatedUserSkill( + env: AgentRunEnv, + userId: UserId, + sandbox: SandboxLike, + input: UserSkillCreateInput, +): Promise { + const skill = await saveUserSkillRecord(env, userId, input); + const collected = await collectUserSkillPackageFromSandbox(sandbox, skill, input.sourceSlug); + const canonicalMarkdown = await serializeUserSkillMarkdown(skill); + const files = collected.some((file) => file.path === "SKILL.md") + ? collected.map((file) => + file.path === "SKILL.md" ? { content: canonicalMarkdown, path: file.path } : file, + ) + : [{ content: canonicalMarkdown, path: "SKILL.md" }, ...collected]; + const packageValue = await persistUserSkillPackage(env.R2_OUTPUTS, userId, skill.id, files); + const filePath = await writeUserSkillPackageMirror(sandbox, skill, packageValue); + return { + description: skill.description, + filePath, + id: skill.id, + name: skill.name, + slug: userSkillSlug(skill.name), + }; +} + +async function saveUserSkillRecord( + env: AgentRunEnv, + userId: UserId, + input: UserSkillCreateInput, +): Promise { + const { db, close } = createDb(env.HYPERDRIVE, { + audience: "app_agent", + signingSecret: env.DATABASE_CONTEXT_SIGNING_SECRET_AGENT, + }); + try { + return await withUserContext(db, userId, (tx) => + upsertUserSkill(tx, { + body: input.body, + category: input.category, + description: input.description, + name: input.name, + tags: input.tags, + userId, + }), + ); + } finally { + await close(); + } } async function loadUserSkill( diff --git a/apps/agent-worker/src/durable-objects/mastra-stream-chunks.ts b/apps/agent-worker/src/durable-objects/mastra-stream-chunks.ts index 71cd3674..90c1831a 100644 --- a/apps/agent-worker/src/durable-objects/mastra-stream-chunks.ts +++ b/apps/agent-worker/src/durable-objects/mastra-stream-chunks.ts @@ -105,7 +105,7 @@ function controlMastraChunk(chunk: ControlAgentChunk): UIMessageChunk[] { function toolResultChunks(payload: ToolResultPayload): UIMessageChunk[] { if (payload.toolName === "skill_create") { - const skill = skillProposedChunkFromResult(payload.result); + const skill = skillCreatedChunkFromResult(payload.result); return skill ? [skill] : []; } if (isSandboxTool(payload)) { @@ -125,40 +125,22 @@ function toolResultChunks(payload: ToolResultPayload): UIMessageChunk[] { return []; } -function skillProposedChunkFromResult(result: unknown): UIMessageChunk | undefined { +function skillCreatedChunkFromResult(result: unknown): UIMessageChunk | undefined { const record = asRecord(result); const name = stringField(record, "name"); const description = stringField(record, "description"); - const body = stringField(record, "body"); - const category = stringField(record, "category"); - const proposalId = stringField(record, "proposalId"); + const filePath = stringField(record, "filePath"); + const id = stringField(record, "id"); const slug = stringField(record, "slug"); - const tags = stringArrayField(record, "tags"); - if ( - record["proposed"] !== true || - !name || - !description || - !body || - !category || - !proposalId || - !slug || - !tags - ) { + if (record["created"] !== true || !name || !description || !filePath || !id || !slug) { return undefined; } return { - type: "data-skill-proposed", - data: { body, category, description, name, proposalId, slug, tags, v: 1 }, + type: "data-skill-created", + data: { description, filePath, id, name, slug, v: 1 }, }; } -function stringArrayField(record: Record, key: string): string[] | undefined { - const value = record[key]; - return Array.isArray(value) && value.every((item) => typeof item === "string") - ? value - : undefined; -} - export function mastraChunkError(chunk: AgentChunkType): unknown | null { if (chunk.type !== "error") { return null; diff --git a/apps/agent-worker/src/index.ts b/apps/agent-worker/src/index.ts index 4c248b0a..5f41bb7e 100644 --- a/apps/agent-worker/src/index.ts +++ b/apps/agent-worker/src/index.ts @@ -25,9 +25,9 @@ import { AgentRunWorkflow } from "./durable-objects/agent-run-workflow"; import { ProjectSandbox } from "./durable-objects/project-sandbox"; import { formatAgentRouteError } from "./error-handling"; import { registerSandboxHttpRoutes } from "./sandbox-http-routes"; -import { registerSkillProposalHttpRoutes } from "./skill-proposal-http-routes"; import { registerSkillRuntimeExecutionRoutes } from "./skill-runtime-execution-routes"; import { registerSkillRuntimeManagedRoutes } from "./skill-runtime-managed-routes"; +import { registerUserSkillHttpRoutes } from "./user-skill-http-routes"; export { AgentRun, AgentRunWorkflow, ProjectSandbox }; @@ -80,7 +80,7 @@ registerAgentDurableObjectStorageRoute(agentApp); registerAgentSystemHttpRoutes(agentApp); registerAgentRunHttpRoutes(agentApp); registerSandboxHttpRoutes(agentApp); -registerSkillProposalHttpRoutes(agentApp); +registerUserSkillHttpRoutes(agentApp); registerSkillRuntimeManagedRoutes(agentApp); registerSkillRuntimeExecutionRoutes(agentApp); diff --git a/apps/agent-worker/src/skill-proposal-http-routes.ts b/apps/agent-worker/src/skill-proposal-http-routes.ts deleted file mode 100644 index 7652dfbe..00000000 --- a/apps/agent-worker/src/skill-proposal-http-routes.ts +++ /dev/null @@ -1,327 +0,0 @@ -import { - createDb, - createThreadMessage, - deleteUserSkill, - findSkillConfirmationMessage, - getThreadAgentRunMessage, - getUserSkillById, - getUserSkillByName, - lockSkillProposal, - type MessageRecord, - type UserSkillRecord, - upsertUserSkill, - withUserContext, -} from "@cheatcode/db"; -import { APIError } from "@cheatcode/observability"; -import { - AgentRunId, - CHEATCODE_DATA_SCHEMAS, - SandboxIdeSessionSchema, - SkillProposalConfirmResponseSchema, - ThreadId, - type UIMessagePart, - UserId, - UserSkillSchema, -} from "@cheatcode/types"; -import type { Context, Hono } from "hono"; -import { z } from "zod"; -import type { AgentEnv } from "./agent-env"; -import { sandboxForUser } from "./agent-routing"; -import { terminalDisplayCwd } from "./sandbox-route-helpers"; -import { parseRunRouteParam, parseThreadRouteParam, readGatewayUserId } from "./tenancy"; -import { - serializeUserSkillMarkdown, - userSkillDirectoryPath, - userSkillFilePath, - writeUserSkillMirror, -} from "./user-skill-files"; -import { - collectUserSkillPackageFromSandbox, - deleteUserSkillPackage, - persistUserSkillPackage, - readUserSkillPackage, - writeUserSkillPackageMirror, -} from "./user-skill-packages"; - -const IdSchema = z.string().uuid(); -type AgentContext = Context<{ Bindings: AgentEnv }>; -type SkillProposal = z.infer<(typeof CHEATCODE_DATA_SCHEMAS)["skill-proposed"]>; - -export function registerSkillProposalHttpRoutes(app: Hono<{ Bindings: AgentEnv }>): void { - app.post( - "/v1/threads/:threadId/skill-proposals/:runId/:proposalId/confirm", - confirmSkillProposal, - ); - app.post("/v1/skills/:skillId/open", openUserSkill); - app.delete("/v1/skills/:skillId", deleteSavedUserSkill); -} - -async function deleteSavedUserSkill(c: AgentContext): Promise { - const userId = UserId(readGatewayUserId(c.req.raw.headers)); - const skillId = parsedId(c.req.param("skillId"), "skill"); - const skill = await readSkill(c.env, userId, skillId); - if (!skill) { - throw new APIError(404, "not_found_skill", "Skill not found", { retriable: false }); - } - await removeSkillPackageFiles(c.env, userId, skill); - await deleteSkillRecord(c.env, userId, skillId); - return new Response(null, { status: 204 }); -} - -async function confirmSkillProposal(c: AgentContext): Promise { - const userId = UserId(readGatewayUserId(c.req.raw.headers)); - const threadId = ThreadId(parseThreadRouteParam(c.req.param("threadId") ?? "")); - const runId = AgentRunId(parseRunRouteParam(c.req.param("runId") ?? "")); - const proposalId = parsedId(c.req.param("proposalId"), "proposal"); - const confirmed = await persistProposal(c.env, { proposalId, runId, threadId, userId }); - await persistAndMirrorSkillPackage(c.env, userId, confirmed.skill); - return c.json( - SkillProposalConfirmResponseSchema.parse({ - message: messageResponse(confirmed.message), - skill: skillResponse(confirmed.skill), - }), - ); -} - -async function openUserSkill(c: AgentContext): Promise { - const userId = UserId(readGatewayUserId(c.req.raw.headers)); - const skillId = parsedId(c.req.param("skillId"), "skill"); - const skill = await readSkill(c.env, userId, skillId); - if (!skill) { - throw new APIError(404, "not_found_skill", "Skill not found", { retriable: false }); - } - const filePath = await mirrorSkillFile(c.env, userId, skill); - const sandbox = await sandboxForUser(c.env, userId); - const session = await sandbox.exposeCodeServer({ - initialFilePath: filePath, - workspacePath: userSkillDirectoryPath(skill.name), - }); - return c.json( - SandboxIdeSessionSchema.parse({ - ...session, - displayWorkspacePath: terminalDisplayCwd(session.workspacePath), - }), - ); -} - -async function persistProposal( - env: AgentEnv, - input: { proposalId: string; runId: AgentRunId; threadId: ThreadId; userId: UserId }, -): Promise<{ message: MessageRecord; skill: UserSkillRecord }> { - const { db, close } = createDb(env.HYPERDRIVE, { - audience: "app_agent", - signingSecret: env.DATABASE_CONTEXT_SIGNING_SECRET_AGENT, - }); - try { - return await withUserContext(db, input.userId, async (tx) => { - await lockSkillProposal(tx, input.proposalId); - const proposalMessage = await getThreadAgentRunMessage(tx, input); - const proposal = proposalFromMessage(proposalMessage, input.proposalId); - const existing = await findSkillConfirmationMessage(tx, input); - if (existing) { - const skill = await skillForExistingConfirmation(tx, input.userId, existing, proposal.name); - if (!skill) { - throw new APIError( - 409, - "conflict_state_invalid", - "This skill proposal was already created and later removed.", - { retriable: false }, - ); - } - return { message: existing, skill }; - } - const skill = await upsertUserSkill(tx, { - body: proposal.body, - category: proposal.category, - description: proposal.description, - name: proposal.name, - tags: proposal.tags, - userId: input.userId, - }); - const message = await createThreadMessage(tx, { - parts: confirmationParts(proposal, skill), - role: "assistant", - threadId: input.threadId, - userId: input.userId, - }); - return { message, skill }; - }); - } finally { - await close(); - } -} - -function proposalFromMessage(message: MessageRecord | null, proposalId: string): SkillProposal { - if (!message) { - throw new APIError(404, "not_found_skill", "Skill proposal not found", { - retriable: false, - }); - } - for (const part of message.parts) { - if (part.type !== "data-skill-proposed" || part.data.proposalId !== proposalId) { - continue; - } - return CHEATCODE_DATA_SCHEMAS["skill-proposed"].parse(part.data); - } - throw new APIError(404, "not_found_skill", "Skill proposal not found", { - retriable: false, - }); -} - -async function skillForExistingConfirmation( - db: Parameters[0], - userId: UserId, - message: MessageRecord, - proposalName: string, -): Promise { - const created = message.parts.find((part) => part.type === "data-skill-created"); - return created?.type === "data-skill-created" && created.data.id - ? getUserSkillById(db, userId, created.data.id) - : getUserSkillByName(db, userId, proposalName); -} - -function confirmationParts(proposal: SkillProposal, skill: UserSkillRecord): UIMessagePart[] { - const filePath = userSkillFilePath(skill.name); - return [ - { - state: "done", - text: [ - `Created and saved the new custom Cheatcode skill: **${proposal.name}**.`, - "", - "### What It Does", - proposal.description, - "", - "### Validation", - "- Confirmed the skill instructions are valid markdown.", - "- Persisted it to your custom skill registry.", - "- Mirrored it to the Cheatcode computer as `SKILL.md` for review and editing.", - ].join("\n"), - type: "text", - }, - { - data: { - v: 1, - description: proposal.description, - filePath, - id: skill.id, - name: proposal.name, - proposalId: proposal.proposalId, - slug: proposal.slug, - }, - type: "data-skill-created", - }, - ]; -} - -async function mirrorSkillFile( - env: AgentEnv, - userId: UserId, - skill: UserSkillRecord, -): Promise { - const sandbox = await sandboxForUser(env, userId); - const packageValue = await readUserSkillPackage(env.R2_OUTPUTS, userId, skill.id); - return packageValue - ? writeUserSkillPackageMirror(sandbox, skill, packageValue) - : writeUserSkillMirror(sandbox, skill); -} - -async function persistAndMirrorSkillPackage( - env: AgentEnv, - userId: UserId, - skill: UserSkillRecord, -): Promise { - const sandbox = await sandboxForUser(env, userId); - const collected = await collectUserSkillPackageFromSandbox(sandbox, skill); - const skillMarkdown = await serializeUserSkillMarkdown(skill); - const files = collected.some((file) => file.path === "SKILL.md") - ? collected.map((file) => - file.path === "SKILL.md" ? { content: skillMarkdown, path: file.path } : file, - ) - : [{ content: skillMarkdown, path: "SKILL.md" }, ...collected]; - const packageValue = await persistUserSkillPackage(env.R2_OUTPUTS, userId, skill.id, files); - return writeUserSkillPackageMirror(sandbox, skill, packageValue); -} - -async function readSkill( - env: AgentEnv, - userId: UserId, - skillId: string, -): Promise { - const { db, close } = createDb(env.HYPERDRIVE, { - audience: "app_agent", - signingSecret: env.DATABASE_CONTEXT_SIGNING_SECRET_AGENT, - }); - try { - return await withUserContext(db, userId, (tx) => getUserSkillById(tx, userId, skillId)); - } finally { - await close(); - } -} - -async function removeSkillPackageFiles( - env: AgentEnv, - userId: UserId, - skill: UserSkillRecord, -): Promise { - const sandbox = await sandboxForUser(env, userId); - if (!sandbox.deleteFile) { - throw new APIError( - 503, - "unavailable_maintenance", - "The skill workspace cannot be cleaned up right now", - { retriable: true }, - ); - } - await Promise.all([ - deleteUserSkillPackage(env.R2_OUTPUTS, userId, skill.id), - sandbox.deleteFile({ path: userSkillDirectoryPath(skill.name), recursive: true }), - ]); -} - -async function deleteSkillRecord(env: AgentEnv, userId: UserId, skillId: string): Promise { - const { db, close } = createDb(env.HYPERDRIVE, { - audience: "app_agent", - signingSecret: env.DATABASE_CONTEXT_SIGNING_SECRET_AGENT, - }); - try { - const deleted = await withUserContext(db, userId, (tx) => deleteUserSkill(tx, userId, skillId)); - if (!deleted) { - throw new APIError(404, "not_found_skill", "Skill not found", { retriable: false }); - } - } finally { - await close(); - } -} - -function parsedId(value: string | undefined, label: string): string { - const parsed = IdSchema.safeParse(value); - if (!parsed.success) { - throw new APIError(400, "invalid_path_param", `Invalid ${label} id`, { retriable: false }); - } - return parsed.data; -} - -function skillResponse(skill: UserSkillRecord): unknown { - return UserSkillSchema.parse({ - category: skill.category, - createdAt: skill.createdAt.toISOString(), - description: skill.description, - id: skill.id, - name: skill.name, - tags: skill.tags, - updatedAt: skill.updatedAt.toISOString(), - }); -} - -function messageResponse(message: MessageRecord): unknown { - return { - agentRunId: message.agentRunId, - agentRunSegment: message.agentRunSegment, - agentRunSegmentFinal: message.agentRunSegmentFinal, - createdAt: message.createdAt.toISOString(), - id: message.id, - parts: message.parts, - role: message.role, - threadId: message.threadId, - }; -} diff --git a/apps/agent-worker/src/skill-runtime-managed-routes.ts b/apps/agent-worker/src/skill-runtime-managed-routes.ts index ce5c262f..84bc8e38 100644 --- a/apps/agent-worker/src/skill-runtime-managed-routes.ts +++ b/apps/agent-worker/src/skill-runtime-managed-routes.ts @@ -47,14 +47,6 @@ const SkillSelectionSchema = z.object({ skillSlug: SkillSlugSchema }).strict(); const DefaultAccountSchema = z .object({ connectedAccountId: z.string().trim().min(1).max(256) }) .strict(); -const CreateRequestSchema = z - .object({ - requestSummary: z.string().trim().min(1).max(500), - skillName: z.string().trim().min(1).max(80), - skillSlug: SkillSlugSchema.optional(), - }) - .strict(); - const KNOWN_INTEGRATIONS = [ "gmail", "github", @@ -85,7 +77,6 @@ interface ManagedSkillItem { export function registerSkillRuntimeManagedRoutes(app: Hono<{ Bindings: AgentEnv }>): void { app.get("/skill-runtime/managed-skills", listManagedSkills); app.post("/skill-runtime/managed-skills/custom/save", saveCustomSkill); - app.post("/skill-runtime/managed-skills/custom/prepare-create-request", prepareCreateRequest); app.post("/skill-runtime/managed-skills/prepare-change", prepareSkillChange); app.post("/skill-runtime/managed-skills/prepare-connect-account", prepareConnectAccount); app.post("/skill-runtime/managed-skills/connect-link", connectLinkFallback); @@ -143,23 +134,6 @@ async function persistCustomSkill( }); } -async function prepareCreateRequest(c: AgentContext): Promise { - await requireSkillRuntimePrincipal(c.env, c.req.raw.headers, "skills:write"); - const input = CreateRequestSchema.parse( - await readJsonRequest(c.req.raw, 8 * 1024, "Custom skill creation request"), - ); - const skillSlug = input.skillSlug ?? userSkillSlug(input.skillName); - return c.json({ - requiresConfirmation: true, - uiAction: { - kind: "create_skill" as const, - requestSummary: input.requestSummary, - skillName: input.skillName, - skillSlug, - }, - }); -} - async function prepareSkillChange(c: AgentContext): Promise { const principal = await requireSkillRuntimePrincipal(c.env, c.req.raw.headers, "skills:write"); const input = SkillActionSchema.parse( diff --git a/apps/agent-worker/src/user-skill-http-routes.ts b/apps/agent-worker/src/user-skill-http-routes.ts new file mode 100644 index 00000000..e02a8b1c --- /dev/null +++ b/apps/agent-worker/src/user-skill-http-routes.ts @@ -0,0 +1,133 @@ +import { + createDb, + deleteUserSkill, + getUserSkillById, + type UserSkillRecord, + withUserContext, +} from "@cheatcode/db"; +import { APIError } from "@cheatcode/observability"; +import { SandboxIdeSessionSchema, UserId } from "@cheatcode/types"; +import type { Context, Hono } from "hono"; +import { z } from "zod"; +import type { AgentEnv } from "./agent-env"; +import { sandboxForUser } from "./agent-routing"; +import { terminalDisplayCwd } from "./sandbox-route-helpers"; +import { readGatewayUserId } from "./tenancy"; +import { userSkillDirectoryPath, writeUserSkillMirror } from "./user-skill-files"; +import { + deleteUserSkillPackage, + readUserSkillPackage, + writeUserSkillPackageMirror, +} from "./user-skill-packages"; + +const IdSchema = z.string().uuid(); +type AgentContext = Context<{ Bindings: AgentEnv }>; + +export function registerUserSkillHttpRoutes(app: Hono<{ Bindings: AgentEnv }>): void { + app.post("/v1/skills/:skillId/open", openUserSkill); + app.delete("/v1/skills/:skillId", deleteSavedUserSkill); +} + +async function deleteSavedUserSkill(c: AgentContext): Promise { + const userId = UserId(readGatewayUserId(c.req.raw.headers)); + const skillId = parsedId(c.req.param("skillId"), "skill"); + const skill = await readSkill(c.env, userId, skillId); + if (!skill) { + throw new APIError(404, "not_found_skill", "Skill not found", { retriable: false }); + } + await removeSkillPackageFiles(c.env, userId, skill); + await deleteSkillRecord(c.env, userId, skillId); + return new Response(null, { status: 204 }); +} + +async function openUserSkill(c: AgentContext): Promise { + const userId = UserId(readGatewayUserId(c.req.raw.headers)); + const skillId = parsedId(c.req.param("skillId"), "skill"); + const skill = await readSkill(c.env, userId, skillId); + if (!skill) { + throw new APIError(404, "not_found_skill", "Skill not found", { retriable: false }); + } + const filePath = await mirrorSkillPackage(c.env, userId, skill); + const sandbox = await sandboxForUser(c.env, userId); + const session = await sandbox.exposeCodeServer({ + initialFilePath: filePath, + workspacePath: userSkillDirectoryPath(skill.name), + }); + return c.json( + SandboxIdeSessionSchema.parse({ + ...session, + displayWorkspacePath: terminalDisplayCwd(session.workspacePath), + }), + ); +} + +async function mirrorSkillPackage( + env: AgentEnv, + userId: UserId, + skill: UserSkillRecord, +): Promise { + const packageValue = await readUserSkillPackage(env.R2_OUTPUTS, userId, skill.id); + const sandbox = await sandboxForUser(env, userId); + return packageValue + ? writeUserSkillPackageMirror(sandbox, skill, packageValue) + : writeUserSkillMirror(sandbox, skill); +} + +async function readSkill( + env: AgentEnv, + userId: UserId, + skillId: string, +): Promise { + const { db, close } = createDb(env.HYPERDRIVE, { + audience: "app_agent", + signingSecret: env.DATABASE_CONTEXT_SIGNING_SECRET_AGENT, + }); + try { + return await withUserContext(db, userId, (tx) => getUserSkillById(tx, userId, skillId)); + } finally { + await close(); + } +} + +async function removeSkillPackageFiles( + env: AgentEnv, + userId: UserId, + skill: UserSkillRecord, +): Promise { + const sandbox = await sandboxForUser(env, userId); + if (!sandbox.deleteFile) { + throw new APIError( + 503, + "unavailable_maintenance", + "The skill workspace cannot be cleaned up right now", + { retriable: true }, + ); + } + await Promise.all([ + deleteUserSkillPackage(env.R2_OUTPUTS, userId, skill.id), + sandbox.deleteFile({ path: userSkillDirectoryPath(skill.name), recursive: true }), + ]); +} + +async function deleteSkillRecord(env: AgentEnv, userId: UserId, skillId: string): Promise { + const { db, close } = createDb(env.HYPERDRIVE, { + audience: "app_agent", + signingSecret: env.DATABASE_CONTEXT_SIGNING_SECRET_AGENT, + }); + try { + const deleted = await withUserContext(db, userId, (tx) => deleteUserSkill(tx, userId, skillId)); + if (!deleted) { + throw new APIError(404, "not_found_skill", "Skill not found", { retriable: false }); + } + } finally { + await close(); + } +} + +function parsedId(value: string | undefined, label: string): string { + const parsed = IdSchema.safeParse(value); + if (!parsed.success) { + throw new APIError(400, "invalid_path_param", `Invalid ${label} id`, { retriable: false }); + } + return parsed.data; +} diff --git a/apps/agent-worker/src/user-skill-packages.ts b/apps/agent-worker/src/user-skill-packages.ts index 678b22f7..b4207a5c 100644 --- a/apps/agent-worker/src/user-skill-packages.ts +++ b/apps/agent-worker/src/user-skill-packages.ts @@ -105,12 +105,15 @@ export async function deleteUserSkillPackage( export async function collectUserSkillPackageFromSandbox( sandbox: SandboxLike, skill: UserSkillRecord, + sourceSlug?: string, ): Promise { const fallback = [{ content: await serializeUserSkillMarkdown(skill), path: "SKILL.md" }]; if (!sandbox.listFiles || !sandbox.readFile) { return fallback; } - const directory = userSkillDirectoryPath(skill.name); + const directory = sourceSlug + ? `/workspace/.cheatcode/skills/${sourceSlug}` + : userSkillDirectoryPath(skill.name); const listing = await sandbox .listFiles({ includeHidden: true, path: directory, recursive: true }) .catch(() => null); diff --git a/apps/gateway-worker/src/agent-http-routes.ts b/apps/gateway-worker/src/agent-http-routes.ts index 020c76ef..ed3b70a5 100644 --- a/apps/gateway-worker/src/agent-http-routes.ts +++ b/apps/gateway-worker/src/agent-http-routes.ts @@ -46,10 +46,6 @@ const GET_AGENT_ROUTES = [ const POST_AGENT_ROUTES = [ ["/v1/runs/:runId/cancel", "POST /v1/runs/:runId/cancel"], - [ - "/v1/threads/:threadId/skill-proposals/:runId/:proposalId/confirm", - "POST /v1/threads/:threadId/skill-proposals/:runId/:proposalId/confirm", - ], ["/v1/skills/:skillId/open", "POST /v1/skills/:skillId/open"], [ "/v1/threads/:threadId/browser-takeover/start", diff --git a/apps/gateway-worker/src/openapi-skill-routes.ts b/apps/gateway-worker/src/openapi-skill-routes.ts index 2fc17881..d51c40fe 100644 --- a/apps/gateway-worker/src/openapi-skill-routes.ts +++ b/apps/gateway-worker/src/openapi-skill-routes.ts @@ -1,8 +1,4 @@ -import { - SkillProposalConfirmResponseSchema, - UserSkillSchema, - UserSkillsResponseSchema, -} from "@cheatcode/types"; +import { UserSkillSchema, UserSkillsResponseSchema } from "@cheatcode/types"; import { emptyResponse, type JsonValue, @@ -13,7 +9,6 @@ import { import { zodJsonSchema } from "./openapi-zod"; export const skillSchemas: Record = { - SkillProposalConfirmResponse: zodJsonSchema(SkillProposalConfirmResponseSchema), UserSkill: zodJsonSchema(UserSkillSchema), UserSkillsResponse: zodJsonSchema(UserSkillsResponseSchema), }; @@ -28,17 +23,6 @@ export const skillRoutes: OpenApiRoute[] = [ summary: "List the current user's skills", tags: ["skills"], }, - { - method: "post", - operationId: "confirmSkillProposal", - path: "/v1/threads/{threadId}/skill-proposals/{runId}/{proposalId}/confirm", - responses: { - "200": jsonResponse("Confirmed skill proposal", schemaRef("SkillProposalConfirmResponse")), - }, - security: [{ bearerAuth: [] }], - summary: "Create a skill from a persisted agent proposal", - tags: ["skills"], - }, { method: "post", operationId: "openUserSkill", diff --git a/apps/web/src/components/chat/chat-panel.tsx b/apps/web/src/components/chat/chat-panel.tsx index 5bb428fd..1b0b6ce2 100644 --- a/apps/web/src/components/chat/chat-panel.tsx +++ b/apps/web/src/components/chat/chat-panel.tsx @@ -27,7 +27,6 @@ export function ChatPanel(props: ChatPanelProps) { messages={controller.state.messages} onContinue={controller.actions.continueRun} onLoadOlderMessages={controller.actions.loadOlderMessages} - onMessageAppend={controller.actions.appendMessage} threadId={props.threadId} /> ; computerOpen: boolean; hasOlderMessages: boolean; isLoadingOlderMessages: boolean; @@ -65,7 +60,6 @@ interface MessageListViewProps { listTopPadding: number; loadOlderMessages: () => Promise; onContinue: () => void; - onMessageAppend: (message: CheatcodeUIMessage) => void; scroll: MessageScrollController; scrollState: MessageScrollState; totalHeight: number; @@ -88,14 +82,12 @@ function MessageViewport(props: Omit) { } function VirtualMessageContent({ - completedSkillProposalIds, hasOlderMessages, isLoadingOlderMessages, isStreaming, listTopPadding, loadOlderMessages, onContinue, - onMessageAppend, scrollState, totalHeight, turns, @@ -126,11 +118,9 @@ function VirtualMessageContent({ style={{ transform: `translateY(${virtualItem.start + listTopPadding}px)` }} > @@ -142,19 +132,15 @@ function VirtualMessageContent({ } function MessageTurnContent({ - completedSkillProposalIds, isLastTurn, isStreaming, onContinue, - onMessageAppend, threadId, turn, }: { - completedSkillProposalIds: ReadonlySet; isLastTurn: boolean; isStreaming: boolean; onContinue: () => void; - onMessageAppend: (message: CheatcodeUIMessage) => void; threadId: string; turn: MessageTurn; }) { @@ -169,11 +155,9 @@ function MessageTurnContent({ const isLastMessage = index === turn.messages.length - 1; return ( @@ -227,17 +211,13 @@ function ScrollToBottomButton({ } function MessageBubble({ - completedSkillProposalIds, message, onContinue, - onMessageAppend, streaming, threadId, }: { - completedSkillProposalIds: ReadonlySet; message: CheatcodeUIMessage; onContinue?: (() => void) | undefined; - onMessageAppend: (message: CheatcodeUIMessage) => void; streaming: boolean; threadId: string; }) { @@ -254,10 +234,8 @@ function MessageBubble({ > {isUser ? null : } diff --git a/apps/web/src/components/chat/message-list.tsx b/apps/web/src/components/chat/message-list.tsx index b402ec01..27a9a880 100644 --- a/apps/web/src/components/chat/message-list.tsx +++ b/apps/web/src/components/chat/message-list.tsx @@ -23,7 +23,6 @@ interface MessageListProps { messages: readonly CheatcodeUIMessage[]; onContinue: () => void; onLoadOlderMessages: () => Promise; - onMessageAppend: (message: CheatcodeUIMessage) => void; threadId: string; } @@ -34,12 +33,10 @@ export function MessageList({ messages, onContinue, onLoadOlderMessages, - onMessageAppend, threadId, }: MessageListProps) { const scrollState = useMessageScrollState(); const turns = groupMessagesIntoTurns(messages); - const completedSkillProposalIds = collectCompletedSkillProposalIds(messages); const virtualizer = useVirtualizer({ count: turns.length, estimateSize: () => scrollState.parentRef.current?.clientHeight ?? ESTIMATED_TURN_HEIGHT, @@ -63,7 +60,6 @@ export function MessageList({ } return ( ); } - -function collectCompletedSkillProposalIds( - messages: readonly CheatcodeUIMessage[], -): ReadonlySet { - const proposalIds = new Set(); - for (const message of messages) { - for (const part of message.parts) { - if (part.type === "data-skill-created" && part.data.proposalId) { - proposalIds.add(part.data.proposalId); - } - } - } - return proposalIds; -} diff --git a/apps/web/src/components/chat/message-parts.tsx b/apps/web/src/components/chat/message-parts.tsx index 814eeae4..e1f8204f 100644 --- a/apps/web/src/components/chat/message-parts.tsx +++ b/apps/web/src/components/chat/message-parts.tsx @@ -5,7 +5,7 @@ import { type ModelFallbackData, reconstructedTranscriptUIMessage, } from "@cheatcode/types"; -import { Check, FileText, Loader2, Puzzle } from "@cheatcode/ui"; +import { FileText, Loader2, Puzzle } from "@cheatcode/ui"; import { useAuth } from "@clerk/nextjs"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import Link from "next/link"; @@ -30,21 +30,17 @@ import { formatUnknown, isHiddenTranscriptPart, } from "@/components/chat/message-timeline"; -import { confirmSkillProposal, openUserSkill, USER_SKILLS_QUERY } from "@/lib/api/skills"; +import { openUserSkill } from "@/lib/api/skills"; import { useAppStore } from "@/lib/store/app-store"; export function MessageParts({ - completedSkillProposalIds, message, onContinue, - onMessageAppend, streaming, threadId, }: { - completedSkillProposalIds: ReadonlySet; message: CheatcodeUIMessage; onContinue?: (() => void) | undefined; - onMessageAppend: (message: CheatcodeUIMessage) => void; streaming: boolean; threadId: string; }) { @@ -61,11 +57,8 @@ export function MessageParts({ ) : ( @@ -98,22 +91,12 @@ function UserMessageParts({ message }: { message: CheatcodeUIMessage }) { } interface MessagePartViewProps { - completedSkillProposalIds: ReadonlySet; - message: CheatcodeUIMessage; onContinue?: (() => void) | undefined; - onMessageAppend: (message: CheatcodeUIMessage) => void; part: MessagePart; threadId: string; } -function MessagePartView({ - completedSkillProposalIds, - message, - onContinue, - onMessageAppend, - part, - threadId, -}: MessagePartViewProps) { +function MessagePartView({ onContinue, part, threadId }: MessagePartViewProps) { if (part.type === "text") { return (
@@ -130,40 +113,12 @@ function MessagePartView({ ); } if (isHiddenTranscriptPart(part)) return null; - return ( - - ); + return ; } -function MessagePartFallback({ - completedSkillProposalIds, - message, - onMessageAppend, - part, - threadId, -}: Pick< - MessagePartViewProps, - "completedSkillProposalIds" | "message" | "onMessageAppend" | "part" | "threadId" ->) { +function MessagePartFallback({ part, threadId }: Pick) { if (part.type === "data-model-fallback") return ; if (part.type === "data-project-created") return ; - if (part.type === "data-skill-proposed") { - if (completedSkillProposalIds.has(part.data.proposalId)) return null; - return ( - - ); - } if (part.type === "data-skill-created") { return ; } @@ -171,63 +126,8 @@ function MessagePartFallback({ return ; } -type SkillProposedData = Extract["data"]; type SkillCreatedData = Extract["data"]; -function SkillProposalBlock({ - data, - message, - onMessageAppend, - threadId, -}: { - data: SkillProposedData; - message: CheatcodeUIMessage; - onMessageAppend: (message: CheatcodeUIMessage) => void; - threadId: string; -}) { - const { getToken } = useAuth(); - const queryClient = useQueryClient(); - const runId = - message.metadata?.runId ?? message.metadata?.transcriptSegment?.agentRunId ?? message.id; - const mutation = useMutation({ - mutationFn: () => confirmSkillProposal(getToken, threadId, runId, data.proposalId), - onError: (error) => - toast.error(error instanceof Error ? error.message : "That skill could not be created."), - onSuccess: ({ message: confirmation }) => { - onMessageAppend(confirmation); - void queryClient.invalidateQueries({ queryKey: ["threads", threadId, "messages"] }); - void queryClient.invalidateQueries({ queryKey: USER_SKILLS_QUERY }); - }, - }); - return ( -
-
-
-
- Create {data.slug} skill -
-
{data.description}
-
-
- -
-
-
- ); -} - function SkillCreatedBlock({ data, threadId }: { data: SkillCreatedData; threadId: string }) { const { getToken } = useAuth(); const queryClient = useQueryClient(); diff --git a/apps/web/src/components/chat/message-timeline.ts b/apps/web/src/components/chat/message-timeline.ts index dfbcbefa..708d5ace 100644 --- a/apps/web/src/components/chat/message-timeline.ts +++ b/apps/web/src/components/chat/message-timeline.ts @@ -14,7 +14,6 @@ export function buildMessageTimeline( ): TimelineItem[] { const finalAnswerIndex = streaming ? -1 : lastAnswerTextIndex(parts); const items: TimelineItem[] = []; - const deferredSkillProposals: TimelineItem[] = []; let activity: PendingActivity | null = null; const flushActivity = () => { if (!activity) return; @@ -27,15 +26,6 @@ export function buildMessageTimeline( }; parts.forEach((part, index) => { if (isHiddenTranscriptPart(part)) return; - if (part.type === "data-skill-proposed") { - flushActivity(); - deferredSkillProposals.push({ - key: partKey(messageId, part, index), - kind: "part", - part, - }); - return; - } if (index === finalAnswerIndex || !isStepPart(part)) { flushActivity(); items.push({ key: partKey(messageId, part, index), kind: "part", part }); @@ -45,7 +35,7 @@ export function buildMessageTimeline( activity.parts.push(part); }); flushActivity(); - return [...items, ...deferredSkillProposals]; + return items; } export function isHiddenTranscriptPart(part: MessagePart): boolean { diff --git a/apps/web/src/components/chat/use-chat-panel-controller.ts b/apps/web/src/components/chat/use-chat-panel-controller.ts index 90721095..c4ac029e 100644 --- a/apps/web/src/components/chat/use-chat-panel-controller.ts +++ b/apps/web/src/components/chat/use-chat-panel-controller.ts @@ -211,17 +211,8 @@ function useChatPanelActions( (value: string) => runtime.store.setDraft(input.threadId, value), [input.threadId, runtime.store.setDraft], ); - const appendMessage = useCallback( - (message: CheatcodeUIMessage) => { - runtime.chat.setMessages((messages) => - messages.some((current) => current.id === message.id) ? messages : [...messages, message], - ); - }, - [runtime.chat.setMessages], - ); return { ...submission, - appendMessage, loadOlderMessages: runtime.loadOlderMessages, setDraft, stopRun, @@ -338,7 +329,7 @@ function handleStreamData( handleProjectCreatedData(part.data, input); } if (part.type === "data-skill-created") { - handleSkillCreatedData(part.data, input.queryClient); + handleSkillCreatedData(part.data, input.queryClient, input.sandboxActions); } } @@ -377,10 +368,13 @@ function handleProjectCreatedData( function handleSkillCreatedData( data: unknown, queryClient: ReturnType, + actions: SandboxStatusActions, ): void { const parsed = CHEATCODE_DATA_SCHEMAS["skill-created"].safeParse(data); if (parsed.success) { void queryClient.invalidateQueries({ queryKey: USER_SKILLS_QUERY }); + actions.setActivePreviewTab("files"); + actions.setPreviewPanelOpen(true); } } diff --git a/apps/web/src/lib/api/project-thread.ts b/apps/web/src/lib/api/project-thread.ts index 3e2b511b..050b1c61 100644 --- a/apps/web/src/lib/api/project-thread.ts +++ b/apps/web/src/lib/api/project-thread.ts @@ -430,7 +430,7 @@ function paginatedPath(path: string, limit: number, cursor: string | null): stri return `${path}?${query.toString()}`; } -export async function messageRecordToUiMessage( +async function messageRecordToUiMessage( record: UIMessageRecord, ): Promise { const role = uiMessageRole(record.role); diff --git a/apps/web/src/lib/api/skills.ts b/apps/web/src/lib/api/skills.ts index 18b503e4..e5cbd288 100644 --- a/apps/web/src/lib/api/skills.ts +++ b/apps/web/src/lib/api/skills.ts @@ -1,10 +1,8 @@ "use client"; import { - type CheatcodeUIMessage, type SandboxIdeSession, SandboxIdeSessionSchema, - SkillProposalConfirmResponseSchema, type UserSkill, UserSkillsResponseSchema, } from "@cheatcode/types"; @@ -14,7 +12,6 @@ import { authorizedFetch, readBoundedJsonResponse, } from "@/lib/api/authorized-fetch"; -import { messageRecordToUiMessage } from "@/lib/api/project-thread"; export const USER_SKILLS_QUERY = ["user-skills"] as const; @@ -37,29 +34,6 @@ export async function deleteUserSkill( await authorizedFetch(getToken, `/v1/skills/${encodeURIComponent(id)}`, { method: "DELETE" }); } -/** Commit a trusted, persisted Skill Creator proposal. */ -export async function confirmSkillProposal( - getToken: () => Promise, - threadId: string, - runId: string, - proposalId: string, -): Promise<{ message: CheatcodeUIMessage; skill: UserSkill }> { - const response = await authorizedFetch( - getToken, - `/v1/threads/${encodeURIComponent(threadId)}/skill-proposals/${encodeURIComponent(runId)}/${encodeURIComponent(proposalId)}/confirm`, - { method: "POST" }, - { timeoutMs: API_REQUEST_TIMEOUT_MS.provisioning }, - ); - const parsed = SkillProposalConfirmResponseSchema.parse( - await readBoundedJsonResponse(response, API_RESPONSE_LIMIT_BYTES.messages), - ); - const message = await messageRecordToUiMessage(parsed.message); - if (!message) { - throw new Error("The saved skill confirmation could not be displayed."); - } - return { message, skill: parsed.skill }; -} - /** Mirror and open a custom skill's `SKILL.md` in the Computer. */ export async function openUserSkill( getToken: () => Promise, diff --git a/infra/supabase/migrations/post/0076_remove_skill_proposals.sql b/infra/supabase/migrations/post/0076_remove_skill_proposals.sql new file mode 100644 index 00000000..1ebd4337 --- /dev/null +++ b/infra/supabase/migrations/post/0076_remove_skill_proposals.sql @@ -0,0 +1,11 @@ +update public.v2_messages as message +set parts = ( + select coalesce(jsonb_agg(part.value order by part.ordinality), '[]'::jsonb) + from jsonb_array_elements(message.parts) with ordinality as part(value, ordinality) + where part.value ->> 'type' <> 'data-skill-proposed' +) +where exists ( + select 1 + from jsonb_array_elements(message.parts) as part(value) + where part.value ->> 'type' = 'data-skill-proposed' +); diff --git a/infra/supabase/migrations/raw-phases.json b/infra/supabase/migrations/raw-phases.json index d086426d..1a6720e8 100644 --- a/infra/supabase/migrations/raw-phases.json +++ b/infra/supabase/migrations/raw-phases.json @@ -61,5 +61,6 @@ "infra/supabase/migrations/post/0072_remove_obsolete_run_gates.sql": "pre-deploy", "infra/supabase/migrations/post/0073_finalize_direct_run_statuses.sql": "post-deploy", "infra/supabase/migrations/post/0074_remove_product_policy_residue.sql": "post-deploy", - "infra/supabase/migrations/post/0075_attribute_thread_models.sql": "pre-deploy" + "infra/supabase/migrations/post/0075_attribute_thread_models.sql": "pre-deploy", + "infra/supabase/migrations/post/0076_remove_skill_proposals.sql": "pre-deploy" } diff --git a/packages/agent-core/src/index.ts b/packages/agent-core/src/index.ts index 3a96c537..3fb59a41 100644 --- a/packages/agent-core/src/index.ts +++ b/packages/agent-core/src/index.ts @@ -12,6 +12,9 @@ export type { ComposioQuotaResult, } from "./mastra/composio-context"; export type { + UserSkillCreateInput, + UserSkillCreateResult, + UserSkillCreator, UserSkillDefinition, UserSkillLoader, UserSkillRuntime, diff --git a/packages/agent-core/src/mastra/system-prompt.ts b/packages/agent-core/src/mastra/system-prompt.ts index 1e3a04ae..fa2e76e4 100644 --- a/packages/agent-core/src/mastra/system-prompt.ts +++ b/packages/agent-core/src/mastra/system-prompt.ts @@ -15,6 +15,8 @@ export const PROMPT_WORKSPACE_DIR_CONTEXT_KEY = "promptWorkspaceDir"; export const USER_SKILLS_CONTEXT_KEY = "userSkills"; /** Request-scoped capability that loads one custom skill body on demand. */ export const USER_SKILL_LOADER_CONTEXT_KEY = "userSkillLoader"; +/** Request-scoped capability that atomically persists a Skill Creator package. */ +export const USER_SKILL_CREATOR_CONTEXT_KEY = "userSkillCreator"; /** Body-less custom-skill metadata carried on every run. */ export interface UserSkillRuntime { @@ -34,6 +36,30 @@ export interface UserSkillLoader { load(name: string): Promise; } +/** Validated metadata passed to the user-scoped Skill Creator persistence boundary. */ +export interface UserSkillCreateInput { + body: string; + category: string; + description: string; + name: string; + sourceSlug: string; + tags: string[]; +} + +/** Client-safe identity returned only after the complete skill package is durable. */ +export interface UserSkillCreateResult { + description: string; + filePath: string; + id: string; + name: string; + slug: string; +} + +/** Persists one authored skill package within the active user's run context. */ +export interface UserSkillCreator { + create(input: UserSkillCreateInput): Promise; +} + export function userSkillLoaderFromRequestContext( requestContext: { get(key: string): unknown } | undefined, ): UserSkillLoader | null { @@ -44,6 +70,16 @@ export function userSkillLoaderFromRequestContext( return null; } +export function userSkillCreatorFromRequestContext( + requestContext: { get(key: string): unknown } | undefined, +): UserSkillCreator | null { + const value = requestContext?.get(USER_SKILL_CREATOR_CONTEXT_KEY); + if (value && typeof (value as UserSkillCreator).create === "function") { + return value as UserSkillCreator; + } + return null; +} + interface RequestContextReader { get(key: string): unknown; } @@ -172,8 +208,8 @@ function buildSkillCreatorPrompt(runtimeContext: PromptRuntimeContext): string { "## Skill Creator execution contract", "Work only inside `/workspace/.cheatcode/skills//`; do not create or attach a normal project.", "Build the complete reusable package the request needs, including `SKILL.md`, references, TypeScript entrypoints, schemas, or assets when they materially contribute to the behavior. Do not manufacture scripts when instructions alone are sufficient.", - "Inspect and validate the authored files before proposing the skill. You may use only the bounded file and shell tools available in this mode.", - "Finish by calling `skill_create` exactly once with metadata and markdown body that match the authored `SKILL.md`. The native confirmation card is the save boundary; do not claim the skill is persisted before the user confirms it.", + "Inspect and validate the authored files before saving the skill. You may use only the bounded file and shell tools available in this mode.", + "Finish by calling `skill_create` exactly once with metadata, markdown body, and the exact folder slug authored under `/workspace/.cheatcode/skills/`. That call is the durable save boundary. Do not claim the skill is persisted until the call succeeds.", "Do not research, browse, build an app, deploy anything, or modify files outside `.cheatcode/skills` in this mode.", ].join("\n"), requiredBundledSkillInstructions("skill-authoring"), diff --git a/packages/agent-core/src/mastra/tools/request-context.ts b/packages/agent-core/src/mastra/tools/request-context.ts index 02400957..65a40a19 100644 --- a/packages/agent-core/src/mastra/tools/request-context.ts +++ b/packages/agent-core/src/mastra/tools/request-context.ts @@ -27,8 +27,10 @@ import { PROMPT_TASK_MESSAGE_CONTEXT_KEY, PROMPT_WORKSPACE_DIR_CONTEXT_KEY, RUN_INTENT_CONTEXT_KEY, + USER_SKILL_CREATOR_CONTEXT_KEY, USER_SKILL_LOADER_CONTEXT_KEY, USER_SKILLS_CONTEXT_KEY, + type UserSkillCreator, type UserSkillLoader, type UserSkillRuntime, } from "../system-prompt"; @@ -55,6 +57,7 @@ export interface CodeRequestContextOptions { runId?: string | undefined; taskMessage?: string | undefined; userSkills?: UserSkillRuntime[] | undefined; + userSkillCreator?: UserSkillCreator | undefined; userSkillLoader?: UserSkillLoader | undefined; } @@ -99,6 +102,7 @@ function contextEntries( [FIRECRAWL_API_KEY_CONTEXT_KEY, options.firecrawlApiKey], [BROWSER_RUN_ID_CONTEXT_KEY, options.runId], [USER_SKILLS_CONTEXT_KEY, options.userSkills], + [USER_SKILL_CREATOR_CONTEXT_KEY, options.userSkillCreator], [USER_SKILL_LOADER_CONTEXT_KEY, options.userSkillLoader], ]; } diff --git a/packages/agent-core/src/mastra/tools/skill-tools.ts b/packages/agent-core/src/mastra/tools/skill-tools.ts index e9c15001..04b12df9 100644 --- a/packages/agent-core/src/mastra/tools/skill-tools.ts +++ b/packages/agent-core/src/mastra/tools/skill-tools.ts @@ -1,6 +1,9 @@ import { type BundledSkill, getSkillByName } from "@cheatcode/skills"; import { createTool } from "@mastra/core/tools"; -import { userSkillLoaderFromRequestContext } from "../system-prompt"; +import { + userSkillCreatorFromRequestContext, + userSkillLoaderFromRequestContext, +} from "../system-prompt"; import { requestContextFromToolContext } from "./tool-runtime-context"; import { skillCreateInputSchema, @@ -63,34 +66,27 @@ export const mastraSkillInvoke = createTool({ export const mastraSkillCreate = createTool({ id: "skill_create", description: - "Prepare a reusable custom skill for the user's review. Use once in Skill Creator mode when the name, description, and markdown instructions are ready. The user must explicitly create the proposed skill before it is saved.", + "Persist a reusable custom skill. Use exactly once in Skill Creator mode after the complete package has been authored and validated.", inputSchema: skillCreateInputSchema, outputSchema: skillCreateOutputSchema, - execute: async (input) => { + execute: async (input, context) => { const parsed = skillCreateInputSchema.parse(input); - return { + const creator = userSkillCreatorFromRequestContext(requestContextFromToolContext(context)); + if (!creator) { + throw new Error("Skill creation is available only in Skill Creator mode."); + } + const result = await creator.create({ body: parsed.body, category: parsed.category ?? "Builder & Apps", description: parsed.description, name: parsed.name, - proposalId: crypto.randomUUID(), - proposed: true as const, - slug: skillSlug(parsed.name), + sourceSlug: parsed.slug, tags: parsed.tags ?? [], - }; + }); + return { created: true as const, ...result }; }, }); -function skillSlug(name: string): string { - const slug = name - .normalize("NFKD") - .toLowerCase() - .replaceAll(/[^a-z0-9]+/gu, "-") - .replaceAll(/^-+|-+$/gu, "") - .slice(0, 80); - return slug || "custom-skill"; -} - export const mastraSkillReadReference = createTool({ id: "skill_read_reference", description: diff --git a/packages/agent-core/src/mastra/tools/tool-schemas.ts b/packages/agent-core/src/mastra/tools/tool-schemas.ts index 93f1203b..1b509318 100644 --- a/packages/agent-core/src/mastra/tools/tool-schemas.ts +++ b/packages/agent-core/src/mastra/tools/tool-schemas.ts @@ -85,20 +85,25 @@ export const skillCreateInputSchema = z .max(400) .describe("One line: what the skill does and when to use it."), name: z.string().trim().min(1).max(80).describe("Short skill name."), + slug: z + .string() + .trim() + .min(1) + .max(80) + .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/u) + .describe("Exact folder name authored under /workspace/.cheatcode/skills/."), tags: z.array(z.string().trim().min(1).max(40)).max(12).optional(), }) .strict(); export const skillCreateOutputSchema = z .object({ - body: z.string(), - category: z.string(), + created: z.literal(true), description: z.string(), + filePath: z.string(), + id: z.string().uuid(), name: z.string(), - proposalId: z.string().uuid(), - proposed: z.literal(true), slug: z.string(), - tags: z.array(z.string()), }) .strict(); diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 662d79ca..4e10a3dd 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -233,11 +233,8 @@ export { } from "./skills"; export { createThreadMessage, - findSkillConfirmationMessage, - getThreadAgentRunMessage, listRecentThreadContextMessages, listThreadMessages, - lockSkillProposal, } from "./thread-messages"; export type { ClaimedUserDeletionJob, diff --git a/packages/db/src/thread-messages.ts b/packages/db/src/thread-messages.ts index 9c119840..933dddc2 100644 --- a/packages/db/src/thread-messages.ts +++ b/packages/db/src/thread-messages.ts @@ -1,5 +1,4 @@ import { - type AgentRunId, coalesceTranscriptSegmentParts, ThreadId, UIMessageRecordSchema, @@ -20,13 +19,6 @@ import { messages, threads } from "./schema"; const THREAD_CONTEXT_MAX_MESSAGES = 64; const THREAD_CONTEXT_MAX_SERIALIZED_BYTES = 1024 * 1024; -/** Serializes retries of one user-confirmed Skill Creator proposal. */ -export async function lockSkillProposal(db: Database, proposalId: string): Promise { - await db.execute( - sql`select pg_advisory_xact_lock(hashtextextended(${`skill-proposal:${proposalId}`}, 0))`, - ); -} - interface ThreadContextQueryInput { maxMessages: number; maxSerializedBytes: number; @@ -60,68 +52,6 @@ export async function listThreadMessages( return rows.map((row) => ({ ...messageFromRow(row), pageCursorAt: row.pageCursorAt })); } -/** Reassembles the complete persisted assistant transcript for one run. */ -export async function getThreadAgentRunMessage( - db: Database, - input: { runId: AgentRunId; threadId: ThreadId; userId: UserId }, -): Promise { - const rows = await db - .select(messageReturningColumns()) - .from(messages) - .where( - and( - eq(messages.agentRunId, input.runId), - eq(messages.threadId, input.threadId), - eq(messages.userId, input.userId), - eq(messages.role, "assistant"), - ), - ) - .orderBy(messages.agentRunSegment, messages.id); - const first = rows[0]; - if (!first) { - return null; - } - const parts = coalesceTranscriptSegmentParts( - rows.map((row) => ({ - index: row.agentRunSegment, - isFinal: row.agentRunSegmentFinal, - parts: row.parts, - })), - ); - if (!parts) { - return null; - } - return { - ...messageFromRow(first), - agentRunSegment: 0, - agentRunSegmentFinal: true, - parts, - }; -} - -/** Finds the deterministic confirmation message for a previously committed proposal. */ -export async function findSkillConfirmationMessage( - db: Database, - input: { proposalId: string; threadId: ThreadId; userId: UserId }, -): Promise { - const [row] = await db - .select(messageReturningColumns()) - .from(messages) - .where( - and( - eq(messages.threadId, input.threadId), - eq(messages.userId, input.userId), - eq(messages.role, "assistant"), - sql`${messages.parts} @> ${JSON.stringify([ - { data: { proposalId: input.proposalId }, type: "data-skill-created" }, - ])}::jsonb`, - ), - ) - .orderBy(desc(messages.createdAt), desc(messages.id)) - .limit(1); - return row ? messageFromRow(row) : null; -} - /** * Returns the newest complete suffix that fits both limits. PostgreSQL applies * the cumulative byte bound before rows cross the Hyperdrive/Worker boundary. diff --git a/packages/types/src/api.ts b/packages/types/src/api.ts index 94d541c1..f789769b 100644 --- a/packages/types/src/api.ts +++ b/packages/types/src/api.ts @@ -634,13 +634,6 @@ export const UserSkillsResponseSchema = z .object({ skills: z.array(UserSkillSchema).max(MAX_USER_SKILLS) }) .strict(); -export const SkillProposalConfirmResponseSchema = z - .object({ - message: UIMessageRecordSchema, - skill: UserSkillSchema, - }) - .strict(); - export type SandboxHourPoint = z.infer; export type CreateRun = z.infer; export type CreateThread = z.infer; @@ -683,5 +676,4 @@ export type SandboxTerminalResult = z.infer; export type ActivityHistoryResponse = z.infer; export type ActivityRunPoint = z.infer; export type UserSkill = z.infer; -export type SkillProposalConfirmResponse = z.infer; export type ToolDomain = z.infer; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 2b2fb3d8..923d75f9 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -31,7 +31,6 @@ export type { SearchResponse, SearchResult, SearchResultThread, - SkillProposalConfirmResponse, Thread, ToolDomain, ToolkitAction, @@ -91,7 +90,6 @@ export { SandboxTerminalResultSchema, SearchQuerySchema, SearchResponseSchema, - SkillProposalConfirmResponseSchema, ThreadSchema, ToolDomainSchema, ToolkitActionsResponseSchema, diff --git a/packages/types/src/ui-message.ts b/packages/types/src/ui-message.ts index d6f82b64..f2907705 100644 --- a/packages/types/src/ui-message.ts +++ b/packages/types/src/ui-message.ts @@ -67,24 +67,10 @@ const SkillCreatedDataSchema = z filePath: z.string().min(1).max(1_000).optional(), id: z.string().uuid().optional(), name: z.string().min(1).max(80), - proposalId: z.string().uuid().optional(), slug: z.string().min(1).max(80).optional(), }) .strict(); -const SkillProposedDataSchema = z - .object({ - v: z.literal(1), - body: z.string().min(1).max(40_000), - category: z.string().min(1).max(80), - description: z.string().min(1).max(400), - name: z.string().min(1).max(80), - proposalId: z.string().uuid(), - slug: z.string().min(1).max(80), - tags: z.array(z.string().min(1).max(40)).max(12), - }) - .strict(); - const RunIntentDataSchema = z .object({ v: z.literal(1), @@ -150,7 +136,6 @@ export const CHEATCODE_DATA_SCHEMAS = { "run-intent": RunIntentDataSchema, "sandbox-status": SandboxStatusDataSchema, "skill-created": SkillCreatedDataSchema, - "skill-proposed": SkillProposedDataSchema, seq: SeqDataSchema, "task-status": TaskStatusDataSchema, tool: ToolDataSchema, @@ -185,7 +170,6 @@ export const MessagePartSchema = z.discriminatedUnion("type", [ dataMessagePartSchema("run-intent"), dataMessagePartSchema("sandbox-status"), dataMessagePartSchema("skill-created"), - dataMessagePartSchema("skill-proposed"), dataMessagePartSchema("task-status"), dataMessagePartSchema("tool"), dataMessagePartSchema("transcript-fragment"), diff --git a/skills/manage-skills/_shared.ts b/skills/manage-skills/_shared.ts index a17f0901..681c81be 100644 --- a/skills/manage-skills/_shared.ts +++ b/skills/manage-skills/_shared.ts @@ -316,7 +316,7 @@ export function renderManagedConnectedAccountsOutput( } const MANAGED_SKILLS_CREATE_HINT = - 'If the capability you need is not listed above, and the user wants Cheatcode to do it on demand rather than build it into the project, consider creating a reusable custom skill via `cheatcode-skills skill-authoring/create --name "" --goal "" [--skill ]` and then follow the Cheatcode skill-authoring flow.'; + "If the capability you need is not listed above and the user wants a reusable custom capability, use Skill Creator mode. Author the complete package there and call the native `skill_create` tool exactly once to persist it atomically."; export function renderManagedSkillsListOutput( skills: readonly ManagedSkillListItem[], diff --git a/skills/skill-authoring/SKILL.md b/skills/skill-authoring/SKILL.md index e100cc00..075a520d 100644 --- a/skills/skill-authoring/SKILL.md +++ b/skills/skill-authoring/SKILL.md @@ -21,7 +21,7 @@ Default contract: - Before authoring a new custom skill, inspect existing Cheatcode tools with `cheatcode-skills manage-skills/manage/list`. - If a suitable built-in Cheatcode skill already exists for the requested capability, do not create a new custom skill unless the user explicitly says to ignore the built-in option and make a custom one anyway. - If a suitable built-in skill exists and is disabled, prefer enabling it instead of creating a duplicate custom skill. -- In Skill Creator mode, author and validate the complete package first, then call the native `skill_create` tool exactly once. That tool presents Cheatcode's confirmation card and is the only save boundary for a new skill. +- In Skill Creator mode, author and validate the complete package first, then call the native `skill_create` tool exactly once with the exact authored folder slug. That call persists the package immediately and is the only save boundary for a new skill. - Create or update a skill under `/workspace/.cheatcode/skills//`. - Keep the implementation agent-first. These tools are mainly for Cheatcode itself to run in the project sandbox. - Default to a prompt-only skill when the reusable behavior can live in `SKILL.md` as instructions, workflows, templates, or guidance for commands and CLI tools that are already available in the sandbox. @@ -33,7 +33,7 @@ Default contract: - `@cheatcode/sandbox-skills-runtime` is provided by the Cheatcode skill runtime. Import it in authored files, but do not add `@cheatcode/sandbox-skills-runtime` to a skill-local `package.json` and do not try to install it from npm. - For custom Cheatcode skills that need secrets, store them in the skill root `.env` file and read them from `process.env` instead of hardcoding them. - Keep the skill lean: one capability per skill, one action per tool file when possible. -- Use `cheatcode-skills skill-authoring/persist/save --skill ` when an existing saved custom skill is edited from the computer and needs to be persisted again. New Skill Creator runs use the native `skill_create` confirmation card instead. +- Use `cheatcode-skills skill-authoring/persist/save --skill ` when an existing saved custom skill is edited from the computer and needs to be persisted again. New Skill Creator runs persist through the native `skill_create` tool instead. - If the custom skill has a root `.env`, keep that file up to date and persist it with the rest of the skill so the saved skill still works after reload. - In project sandboxes, if you need to update an existing saved custom skill that is not loaded yet, enable it first so it is provisioned into `/workspace/.cheatcode/skills//`, then edit and persist that custom skill. Do not use this path for built-in or integration skills. - Persisted custom skill payloads include `SKILL.md`, other `.md` files, `.ts` files, a root `package.json`, and a root `.env`. Do not persist lockfiles such as `package-lock.json`, `bun.lock`, or `pnpm-lock.yaml`, and do not expect `node_modules`, build output, caches, or other generated artifacts to be saved. @@ -93,7 +93,7 @@ Default implementation model: 20. If dependency installation for that specific skill fails, say so explicitly and stop rather than pretending the skill is ready. 21. If a tool-based skill cannot be exercised safely end to end, say exactly what blocked validation and what remains unverified. 22. Prompt-only skills do not need executable validation beyond making sure the prompt and file structure are correct. -23. For a new Skill Creator package, call `skill_create` exactly once after validation so Cheatcode can present the native confirmation card. For edits to an already-saved skill, persist with `cheatcode-skills skill-authoring/persist/save --skill `. +23. For a new Skill Creator package, call `skill_create` exactly once after validation and pass the exact folder slug under `/workspace/.cheatcode/skills/` so Cheatcode can persist the complete package atomically. For edits to an already-saved skill, persist with `cheatcode-skills skill-authoring/persist/save --skill `. 24. If the new skill likely requires credentials and they are not already available, explain the missing auth requirement clearly at the end, including where the user can obtain it and that the custom skill should keep those secrets in its root `.env` file so they persist with the skill. 25. After creation or persistence, explain the new skill in user-facing terms: what the user can ask Cheatcode to do with it, and that Cheatcode can use it automatically when a future request clearly matches it. Do not default to CLI commands, code snippets, tool paths, or `cheatcode-skills ...` usage examples unless the user explicitly asks for technical usage or debugging details. 26. If the user's original goal was to perform a task with the new capability, continue immediately after validation and persistence by using the new skill unless the user asked to stop at implementation only. diff --git a/skills/skill-authoring/create.ts b/skills/skill-authoring/create.ts deleted file mode 100644 index f4ef9151..00000000 --- a/skills/skill-authoring/create.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { - createSkillTool, - emitCheatcodeSkillFrontendEvent, - readProjectSkillRuntimeConfig, - requestCheatcodeSkillJson, - stringOption, -} from "@cheatcode/sandbox-skills-runtime"; - -type RequestCreateCustomSkillResponse = { - requiresConfirmation: boolean; - uiAction?: { - kind: "create_skill"; - skillName: string; - skillSlug: string; - requestSummary: string; - logoUrl?: string | null; - }; -}; - -type SkillLogger = { - log(message: string): void; -}; - -type CreateSkillOptions = { - name: string; - skill?: string; - goal: string; -}; - -const CREATE_SKILL_OPTIONS = { - name: stringOption({ - description: "Display name for the new custom skill.", - short: "n", - required: true, - }), - skill: stringOption({ - description: - "Optional kebab-case slug for the new custom skill. If omitted, Cheatcode derives one from --name.", - short: "s", - }), - goal: stringOption({ - description: "Concise summary of what the new skill should do and why it should be created.", - short: "g", - required: true, - }), -}; - -async function startSkillCreation(params: { logger: SkillLogger; options: CreateSkillOptions }) { - const { logger, options } = params; - const config = await readProjectSkillRuntimeConfig(); - const response = await requestCheatcodeSkillJson({ - config, - path: "/managed-skills/custom/prepare-create-request", - method: "POST", - body: { - skillName: options.name, - ...(options.skill ? { skillSlug: options.skill } : {}), - requestSummary: options.goal, - }, - }); - - if (response.uiAction && config.sandboxContext === "message") { - logger.log( - [ - `Skill creation confirmed for ${response.uiAction.skillName} (${response.uiAction.skillSlug}) in messaging.`, - `Proceed to implement the skill under /workspace/.cheatcode/skills/${response.uiAction.skillSlug}/ now.`, - `When implementation is complete, persist it with cheatcode-skills skill-authoring/persist/save --skill ${response.uiAction.skillSlug}.`, - ].join(" "), - ); - return; - } - - if (response.uiAction && config.runId) { - const result = await emitCheatcodeSkillFrontendEvent({ - config, - event: { - type: "coding_agent.request_create_skill", - data: { - toolCallId: `skill-create:${response.uiAction.skillSlug}:${Date.now()}`, - ...response.uiAction, - }, - }, - }); - - if (result.delivered) { - logger.log( - [ - `Skill Creator confirmation UI has been presented for ${response.uiAction.skillName} (${response.uiAction.skillSlug}).`, - "Stop here and wait for the user's decision in the UI.", - "Do not start authoring the new skill in this turn unless Cheatcode later sends a hidden follow-up instruction after confirmation.", - ].join(" "), - ); - return; - } - } - - logger.log(JSON.stringify(response, null, 2)); -} - -async function main() { - await createSkillTool({ - name: "create", - description: - "Start creation of a new custom Cheatcode skill with user confirmation when required.", - help: "Use this first whenever the agent wants to create a brand-new custom Cheatcode skill. In project chat it presents the Skill Creator confirmation card before authoring begins. Messaging contexts can proceed after server-side validation.", - options: CREATE_SKILL_OPTIONS, - action: startSkillCreation, - }).run(); -} - -void main().catch((error: unknown) => { - const message = - error instanceof Error - ? error.message - : "Failed to start creation of the new custom Cheatcode skill."; - console.error(message); - process.exitCode = 1; -}); diff --git a/skills/skill-authoring/references/skill-registration.md b/skills/skill-authoring/references/skill-registration.md index 51a34f49..6fa1f43b 100644 --- a/skills/skill-authoring/references/skill-registration.md +++ b/skills/skill-authoring/references/skill-registration.md @@ -11,7 +11,7 @@ What this stage covers: - saving edits to an already saved custom skill - registering the skill so it appears in the managed tools list - refreshing any managed-skill or creator UI state after save -- handling enablement or follow-up confirmation after persistence +- refreshing enablement and creator UI state after persistence What this stage does not change: - the authored tool files under `/workspace/.cheatcode/skills/`