diff --git a/docs/tools/self-learning.md b/docs/tools/self-learning.md index 5d64dfeb71b5..5f82c65fae33 100644 --- a/docs/tools/self-learning.md +++ b/docs/tools/self-learning.md @@ -19,6 +19,21 @@ them through the normal scanner-gated Workshop service without asking for approval. Choose `propose` to review every capture before it becomes active, or `off` to disable autonomous capture. +## Immediate repair + +When the foreground agent discovers that a skill it used is wrong or incomplete, +it reads the current live skill and drafts a targeted patch through Skill +Workshop in the same turn. A runtime usage receipt prevents foreground repair of +skills that the run did not use. Autonomous mode controls the outcome: `off` +disables the repair, `propose` leaves it pending for explicit review and apply, +and `auto` scans and applies it immediately. The repair still goes through +proposal storage, hash binding, the security scanner, and rollback capture. + +Immediate repair changes the live skill for new sessions. It does not rewrite the +skill snapshot already loaded into the running session. The delayed experience +review remains a fallback for durable learning that the foreground agent did not +repair itself. + ## Experience review Every autonomous capture is authored by a model reviewing real evidence. There diff --git a/docs/tools/skill-workshop.md b/docs/tools/skill-workshop.md index bda658ff6b81..773f3c6b29bb 100644 --- a/docs/tools/skill-workshop.md +++ b/docs/tools/skill-workshop.md @@ -110,6 +110,13 @@ Update an existing workspace skill: Update trip-planning to also check seat maps before booking. ``` +If a skill used in the current turn proves wrong or incomplete, the agent reads +the live skill and creates a targeted patch proposal. A runtime receipt limits +this flow to skills used in that run. Autonomous mode `off` disables repair, +`propose` leaves the patch pending until explicitly applied, and `auto` scans and +applies it immediately. The repaired skill is loaded by new sessions; the +running session keeps its original skill snapshot. + Iterate on a pending proposal: ```text @@ -235,14 +242,15 @@ and paths outside the standard support folders. ## Agent tool The model uses `skill_workshop` with one required `action`: -`create | update | revise | list | inspect | evaluate | apply | reject | quarantine`. +`create | read | patch | update | revise | list | inspect | evaluate | apply | reject | quarantine`. Other parameters apply depending on the action: | Parameter | Used by | Notes | | -------------------------- | ---------------------------------------------------------------- | -------------------------------------------------------------------- | | `name` | `create`, `inspect`, `revise` | Required for `create`; resolves a pending proposal by name otherwise | | `description` | `create`, `update`, `revise` | Max 160 bytes | -| `skill_name` | `update` | Existing skill name or key | +| `skill_name` | `read`, `patch`, `update` | Existing skill name or key | +| `old_string`, `new_string` | `patch` | Exact current text and its replacement; read the skill first | | `proposal_content` | `create`, `update`, `revise` | Required for create/update; omit on revise to preserve the body | | `support_files` | `create`, `update`, `revise` | Array of `{ path, content }` | | `goal`, `evidence` | `create`, `update`, `revise` | Free-text context | diff --git a/src/agents/agent-tools.before-tool-call.e2e.test.ts b/src/agents/agent-tools.before-tool-call.e2e.test.ts index dc69c0406ce4..fac200c4342c 100644 --- a/src/agents/agent-tools.before-tool-call.e2e.test.ts +++ b/src/agents/agent-tools.before-tool-call.e2e.test.ts @@ -1497,7 +1497,12 @@ describe("before_tool_call loop detection behavior", () => { expect(JSON.stringify(emitted)).not.toContain(skillBaseDir); expect(privateData[0]?.skillUsage?.skillFile).toBe(skillFilePath); expect(consumeRunSkillUsage("run-1")).toEqual([ - { name: "demo-skill", source: "workspace", activation: "read" }, + { + name: "demo-skill", + source: "workspace", + activation: "read", + skillFile: skillFilePath, + }, ]); expect(consumeRunSkillUsage("run-1")).toEqual([]); }); diff --git a/src/agents/agent-tools.before-tool-call.wrapper.ts b/src/agents/agent-tools.before-tool-call.wrapper.ts index ea8509e88c7e..102b2a4ee401 100644 --- a/src/agents/agent-tools.before-tool-call.wrapper.ts +++ b/src/agents/agent-tools.before-tool-call.wrapper.ts @@ -552,6 +552,7 @@ export function wrapToolWithBeforeToolCallHook( name: skillMatch.skillName, source: skillMatch.skillSource, activation: skillMatch.activation, + ...(skillMatch.skillFile ? { skillFile: skillMatch.skillFile } : {}), }); } if (hookOptions.emitDiagnostics) { diff --git a/src/agents/skill-workshop-prompt.ts b/src/agents/skill-workshop-prompt.ts index b296cb6c5d96..2cf899b0f864 100644 --- a/src/agents/skill-workshop-prompt.ts +++ b/src/agents/skill-workshop-prompt.ts @@ -9,7 +9,8 @@ export function buildSkillWorkshopPromptSection(): string[] { return [ "## Skill Workshop", "Durable reusable skill/playbook/workflow work: `skill_workshop`; never write proposal/skill files directly.", - "Generated = pending proposal. Apply/reject/quarantine only explicit user ask.", + "Used skill proved wrong or incomplete: call `skill_workshop` read, then patch it now; the configured autonomous mode disables repair, leaves it pending, or applies it immediately. Capture only durable, evidenced procedure changes—never task artifacts, transient failures, or unresolved guesses.", + "Other generated work = pending proposal. Apply/reject/quarantine only explicit user ask.", "proposal_content = complete final skill body, never plan/diff; update/revise preserves unchanged content.", "", ]; diff --git a/src/agents/system-prompt.test.ts b/src/agents/system-prompt.test.ts index 3c8af675668f..5f7895577391 100644 --- a/src/agents/system-prompt.test.ts +++ b/src/agents/system-prompt.test.ts @@ -1116,7 +1116,8 @@ describe("buildAgentSystemPrompt", () => { expect(section).toEqual([ "## Skill Workshop", "Durable reusable skill/playbook/workflow work: `skill_workshop`; never write proposal/skill files directly.", - "Generated = pending proposal. Apply/reject/quarantine only explicit user ask.", + "Used skill proved wrong or incomplete: call `skill_workshop` read, then patch it now; the configured autonomous mode disables repair, leaves it pending, or applies it immediately. Capture only durable, evidenced procedure changes—never task artifacts, transient failures, or unresolved guesses.", + "Other generated work = pending proposal. Apply/reject/quarantine only explicit user ask.", "proposal_content = complete final skill body, never plan/diff; update/revise preserves unchanged content.", "", ]); @@ -1135,7 +1136,8 @@ describe("buildAgentSystemPrompt", () => { expect(withTool).toContain("- skill_workshop: Manage reusable-skill proposals"); expect(withTool).toContain("## Skill Workshop"); expect(withTool).toContain("Durable reusable skill/playbook/workflow work"); - expect(withTool).toContain("Generated = pending proposal"); + expect(withTool).toContain("Used skill proved wrong or incomplete"); + expect(withTool).toContain("Other generated work = pending proposal"); }); it("appends available skills when provided", () => { diff --git a/src/agents/tool-mutation.test.ts b/src/agents/tool-mutation.test.ts index a087f49156a5..fc3875b1a350 100644 --- a/src/agents/tool-mutation.test.ts +++ b/src/agents/tool-mutation.test.ts @@ -292,6 +292,7 @@ describe("tool mutation helpers", () => { ); expect(isReplaySafeToolCall("skill_workshop", { action: "list" })).toBe(true); expect(isReplaySafeToolCall("skill_workshop", { action: "inspect" })).toBe(true); + expect(isReplaySafeToolCall("skill_workshop", { action: "read" })).toBe(true); expect(isReplaySafeToolCall("skill_workshop", { action: "create" })).toBe(false); expect(isReplaySafeToolCall("transcripts", { action: "status" })).toBe(true); expect(isReplaySafeToolCall("transcripts", { action: "import" })).toBe(false); diff --git a/src/agents/tool-mutation.ts b/src/agents/tool-mutation.ts index 1f6231576287..4ef5e5780feb 100644 --- a/src/agents/tool-mutation.ts +++ b/src/agents/tool-mutation.ts @@ -408,7 +408,7 @@ export function isReplaySafeToolCall(toolName: string, args: unknown): boolean { case "mobile_ui": return action != null && MOBILE_UI_REPLAY_SAFE_ACTIONS.has(action); case "skill_workshop": - return action === "list" || action === "inspect"; + return action === "list" || action === "inspect" || action === "read"; case "transcripts": return action === "status"; case "gateway": diff --git a/src/agents/tools/skill-workshop-tool.review.test.ts b/src/agents/tools/skill-workshop-tool.review.test.ts index a37281fd5c7f..f6fc5a069168 100644 --- a/src/agents/tools/skill-workshop-tool.review.test.ts +++ b/src/agents/tools/skill-workshop-tool.review.test.ts @@ -324,7 +324,7 @@ describe("skill_workshop review mode", () => { const text = (read.content[0] as { text: string }).text; expect(read.details).toMatchObject({ skillKey: "big-skill", truncated: true }); expect(text.length).toBeLessThanOrEqual(20_000 + 100); - expect(text).toContain("[truncated: skill exceeds the reviewer read budget]"); + expect(text).toContain("[truncated: skill exceeds the Workshop read budget]"); await expect( reviewTool.execute("oversized-patch", { diff --git a/src/agents/tools/skill-workshop-tool.test.ts b/src/agents/tools/skill-workshop-tool.test.ts index 34669bd344cb..da239447bbe5 100644 --- a/src/agents/tools/skill-workshop-tool.test.ts +++ b/src/agents/tools/skill-workshop-tool.test.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { consumeRunSkillUsage, recordRunSkillUsage } from "../../skills/runtime/run-usage.js"; import { listSkillProposalEvents } from "../../skills/workshop/service.js"; import { SKILL_AUTHORING_STANDARDS_PROMPT } from "../../skills/workshop/skill-authoring-standards.js"; import { readSkillProposalRecord } from "../../skills/workshop/store.js"; @@ -38,7 +39,9 @@ describe("skill_workshop tool", () => { const schema = JSON.stringify(tool.parameters); expect(schema).toContain("create = new skill"); - expect(schema).toContain("update = existing live skill"); + expect(schema).toContain("patch = targeted"); + expect(schema).toContain("read = existing live skill"); + expect(schema).toContain("update = full-body rewrite"); expect(schema).toContain("revise = existing pending proposal"); expect(schema).toContain("evaluate runs plugin evaluators"); expect(schema).toContain("not filesystem search"); @@ -140,7 +143,7 @@ describe("skill_workshop tool", () => { expect(tools.some((tool) => tool.name === "skill_workshop")).toBe(true); }); - it("does not nudge the foreground model when autonomy is enabled", () => { + it("describes the configured foreground repair outcome", () => { const disabled = createSkillWorkshopTool({ workspaceDir: "/tmp/openclaw", config: { skills: { workshop: { autonomous: { mode: "off" } } } }, @@ -150,7 +153,8 @@ describe("skill_workshop tool", () => { config: { skills: { workshop: { autonomous: { mode: "propose" } } } }, }); - expect(enabled.description).toBe(disabled.description); + expect(disabled.description).toContain("Foreground repair is disabled."); + expect(enabled.description).toContain("stays pending for review"); expect(enabled.description).not.toContain("Experience capture"); }); @@ -775,6 +779,125 @@ describe("skill_workshop tool", () => { ).rejects.toThrow(); }); + it.each(["off", "propose", "auto"] as const)( + "enforces foreground repair receipts in autonomous mode %s", + async (mode) => { + const workspaceDir = await tempDirs.make(`openclaw-skill-workshop-repair-${mode}-`); + const runId = `repair-${mode}`; + const skillName = `weather-planner-${mode}`; + const tool = createSkillWorkshopTool({ + workspaceDir, + config: { skills: { workshop: { autonomous: { mode } } } }, + agentId: "main", + origin: { agentId: "main", runId }, + }); + const created = await tool.execute("repair-create", { + action: "create", + name: skillName, + description: "Plan around current weather", + proposal_content: "# Weather Planner\n\nCheck weather before outdoor recommendations.\n", + }); + await tool.execute("repair-create-apply", { + action: "apply", + proposal_id: (created.details as { id: string }).id, + }); + + await tool.execute("repair-read", { action: "read", skill_name: skillName }); + + const patchArgs = { + action: "patch", + skill_name: skillName, + old_string: "Check weather before outdoor recommendations.", + new_string: "Check weather and alerts before outdoor recommendations.", + }; + if (mode === "off") { + await expect(tool.execute("repair-disabled", patchArgs)).rejects.toThrow( + "disabled by autonomous mode off", + ); + return; + } + + await expect(tool.execute("repair-unused", patchArgs)).rejects.toThrow( + "was not used in this run", + ); + recordRunSkillUsage({ + runId, + name: skillName, + source: "workspace", + activation: "read", + skillFile: path.join(workspaceDir, "skills", skillName, "SKILL.md"), + }); + const patch = await tool.execute("repair-patch", patchArgs); + expect(patch.details).toMatchObject({ + status: mode === "auto" ? "applied" : "pending", + kind: "update", + }); + + const skillFile = path.join(workspaceDir, "skills", skillName, "SKILL.md"); + if (mode === "propose") { + await expect(fs.readFile(skillFile, "utf8")).resolves.toContain( + "Check weather before outdoor recommendations.", + ); + await expect( + tool.execute("repair-apply", { + action: "apply", + proposal_id: (patch.details as { id: string }).id, + }), + ).resolves.toMatchObject({ details: { status: "applied" } }); + await expect(fs.readFile(skillFile, "utf8")).resolves.toContain( + "Check weather and alerts before outdoor recommendations.", + ); + } else { + await expect(fs.readFile(skillFile, "utf8")).resolves.toContain( + "Check weather and alerts before outdoor recommendations.", + ); + } + consumeRunSkillUsage(runId); + }, + ); + + it("matches an aliased used-skill receipt by canonical file", async () => { + const workspaceDir = await tempDirs.make("openclaw-skill-workshop-repair-alias-"); + const runId = "repair-alias"; + const skillName = "canonical-skill-key"; + const skillFile = path.join(workspaceDir, "skills", skillName, "SKILL.md"); + const tool = createSkillWorkshopTool({ + workspaceDir, + config: { skills: { workshop: { autonomous: { mode: "auto" } } } }, + agentId: "main", + origin: { agentId: "main", runId }, + }); + const created = await tool.execute("alias-create", { + action: "create", + name: skillName, + description: "Exercise canonical receipt identity", + proposal_content: "# Aliased Skill\n\nUse OLD_TOKEN.\n", + }); + await tool.execute("alias-create-apply", { + action: "apply", + proposal_id: (created.details as { id: string }).id, + }); + await tool.execute("alias-read", { action: "read", skill_name: skillName }); + recordRunSkillUsage({ + runId, + name: "frontmatter-skill-name", + source: "workspace", + activation: "read", + skillFile, + }); + + await expect( + tool.execute("alias-patch", { + action: "patch", + skill_name: skillName, + old_string: "Use OLD_TOKEN.", + new_string: "Use NEW_TOKEN.", + }), + ).resolves.toMatchObject({ details: { status: "applied" } }); + await expect(fs.readFile(skillFile, "utf8")).resolves.toContain("Use NEW_TOKEN."); + consumeRunSkillUsage(runId); + }); + it("keeps proposal discovery scoped to the tool agent across workspace changes", async () => { const firstWorkspaceDir = await tempDirs.make("openclaw-skill-workshop-tool-first-"); const secondWorkspaceDir = await tempDirs.make("openclaw-skill-workshop-tool-second-"); diff --git a/src/agents/tools/skill-workshop-tool.ts b/src/agents/tools/skill-workshop-tool.ts index 0ab703b91a79..16d063e39285 100644 --- a/src/agents/tools/skill-workshop-tool.ts +++ b/src/agents/tools/skill-workshop-tool.ts @@ -7,6 +7,8 @@ import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { Type } from "typebox"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { sha256Hex } from "../../infra/crypto-digest.js"; +import { hasRunWorkspaceSkillUsage } from "../../skills/runtime/run-usage.js"; +import { resolveSkillWorkshopConfig } from "../../skills/workshop/config.js"; import { stripProposalFrontmatterForSkill } from "../../skills/workshop/frontmatter.js"; import { applySkillProposal, @@ -58,7 +60,9 @@ import { const SKILL_WORKSHOP_ACTIONS = [ "create", + "patch", "update", + "read", "revise", "list", "inspect", @@ -78,10 +82,9 @@ function resolveProposalOnlyActions(updateProposals: boolean, supportsCompletion ]; } const SKILL_WORKSHOP_MUTATION_ACTIONS = new Set(["create", "patch", "update", "revise"]); -// Reviewer reads give the model the text it must quote to patch; the composition -// itself happens on the service side, so a bounded excerpt keeps large operator -// skills out of the provider payload. -const REVIEWER_SKILL_READ_MAX_CHARS = 20_000; +// Reads give the model the text it must quote to patch. Composition still uses +// the authoritative live body, while the cap bounds provider payloads. +const SKILL_WORKSHOP_READ_MAX_CHARS = 20_000; const SKILL_PROPOSAL_STATUSES = [ "pending", "applied", @@ -115,7 +118,7 @@ function buildSkillWorkshopToolSchema( action: stringEnum(proposalOnly ? proposalActions : [...SKILL_WORKSHOP_ACTIONS], { description: proposalOnly ? `create = new skill;${updateProposals ? " patch = targeted find-and-replace on an existing live skill (quote the exact current text in old_string, replacement in new_string; empty old_string appends new_string at the end); read = bounded excerpt of an existing live skill (required before patch or update); update = full-body rewrite of an existing live skill after reading it;" : ""} revise = existing pending proposal; list/inspect discover pending proposals (not filesystem search).${supportsCompletion ? " complete = durably finish this review after all proposal work." : ""} Nothing writes a live skill directly; lifecycle actions are unavailable.` - : "create = new skill; update = existing live skill; revise = existing pending proposal; list/inspect discover pending proposals (not filesystem search); evaluate runs plugin evaluators for the exact draft; apply/reject/quarantine are explicit lifecycle actions.", + : "create = new skill; read = existing live skill; patch = targeted find-and-replace after reading; update = full-body rewrite; revise = existing pending proposal; list/inspect discover pending proposals (not filesystem search); evaluate runs plugin evaluators for the exact draft; apply/reject/quarantine are explicit lifecycle actions.", }), proposal_id: Type.Optional( Type.String({ @@ -234,9 +237,16 @@ function buildSkillWorkshopToolDescription( proposalOnly: boolean, supportsCompletion: boolean, updateProposals: boolean, + autonomousMode: "off" | "propose" | "auto", ): string { if (!proposalOnly) { - return `Create/update/revise/list/inspect/evaluate/apply/reject/quarantine reusable-procedure skill proposals.\n\n${SKILL_AUTHORING_STANDARDS_PROMPT}`; + const repairPolicy = + autonomousMode === "off" + ? "Foreground repair is disabled." + : autonomousMode === "propose" + ? "A foreground patch to a skill used in this run stays pending for review." + : "A foreground patch to a skill used in this run is scanned and applied immediately."; + return `Read, patch, create, update, revise, inspect, evaluate, and apply reusable-procedure skill proposals. ${repairPolicy}\n\n${SKILL_AUTHORING_STANDARDS_PROMPT}`; } const completion = supportsCompletion ? " complete = durably finish this review." : ""; const draftKinds = updateProposals ? "create, update, or revise" : "create or revise"; @@ -245,14 +255,21 @@ function buildSkillWorkshopToolDescription( /** Create the Skill Workshop tool for proposal discovery and lifecycle actions. */ export function createSkillWorkshopTool(options: SkillWorkshopToolOptions): AnyAgentTool { + const workshopConfig = resolveSkillWorkshopConfig(options.config); + const readSkillHashes = + options.proposalMutationBudget?.readSkillHashes ?? new Map(); + if (options.proposalMutationBudget) { + options.proposalMutationBudget.readSkillHashes = readSkillHashes; + } return { label: "Skill Workshop", name: "skill_workshop", - displaySummary: "Propose a reusable skill", + displaySummary: "Propose or improve a reusable skill", description: buildSkillWorkshopToolDescription( options.proposalOnly === true, options.proposalReviewCompletion !== undefined, options.updateProposals === true, + workshopConfig.autonomous.mode, ), parameters: buildSkillWorkshopToolSchema( options.proposalOnly === true, @@ -285,7 +302,7 @@ export function createSkillWorkshopTool(options: SkillWorkshopToolOptions): AnyA } if (action === "read") { - if (options.updateProposals !== true) { + if (options.proposalOnly === true && options.updateProposals !== true) { throw new ToolInputError("this Skill Workshop session cannot read live skills"); } const skill = await readWritableWorkspaceSkill( @@ -293,18 +310,17 @@ export function createSkillWorkshopTool(options: SkillWorkshopToolOptions): AnyA readStringParam(params, "skill_name", { required: true, label: "skill_name" }), { config: options.config, agentId: options.agentId }, ); - const truncated = skill.content.length > REVIEWER_SKILL_READ_MAX_CHARS; + const truncated = skill.content.length > SKILL_WORKSHOP_READ_MAX_CHARS; // A truncated read is context, not sight of the whole skill: it earns no // receipt, so oversized skills cannot be patched by a reviewer that never // saw their later content. - if (options.proposalMutationBudget && !truncated) { - const readSkillHashes = - options.proposalMutationBudget.readSkillHashes ?? new Map(); + if (truncated) { + readSkillHashes.delete(skill.skillKey); + } else { readSkillHashes.set(skill.skillKey, sha256Hex(skill.content)); - options.proposalMutationBudget.readSkillHashes = readSkillHashes; } const text = truncated - ? `${truncateUtf16Safe(skill.content, REVIEWER_SKILL_READ_MAX_CHARS)}\n[truncated: skill exceeds the reviewer read budget]` + ? `${truncateUtf16Safe(skill.content, SKILL_WORKSHOP_READ_MAX_CHARS)}\n[truncated: skill exceeds the Workshop read budget]` : skill.content; return { content: [{ type: "text", text }], @@ -437,34 +453,51 @@ export function createSkillWorkshopTool(options: SkillWorkshopToolOptions): AnyA const goal = readStringParam(params, "goal"); const evidence = readStringParam(params, "evidence"); - if (action === "patch" && options.updateProposals !== true) { + if (action === "patch" && options.proposalOnly === true && options.updateProposals !== true) { throw new ToolInputError("this Skill Workshop session cannot patch live skills"); } - let reviewerUpdateContentHash: string | undefined; - if (options.updateProposals === true && (action === "patch" || action === "update")) { - // The reviewer must see the entire current skill before either a targeted - // patch or a rewrite. The service binds the resulting proposal to that read. + const foregroundRepair = action === "patch" && options.proposalOnly !== true; + if (foregroundRepair && workshopConfig.autonomous.mode === "off") { + throw new ToolInputError("foreground skill repair is disabled by autonomous mode off"); + } + let expectedCurrentContentHash: string | undefined; + const requiresRead = action === "patch" || (action === "update" && options.updateProposals); + if (requiresRead) { + // The model must see the entire current skill before a targeted patch or + // autonomous rewrite. The service binds the proposal to that read. const target = await readWritableWorkspaceSkill( options.workspaceDir, readStringParam(params, "skill_name", { required: true, label: "skill_name" }), { config: options.config, agentId: options.agentId }, ); - const readHash = options.proposalMutationBudget?.readSkillHashes?.get(target.skillKey); + const readHash = readSkillHashes.get(target.skillKey); if (!readHash) { throw new ToolInputError( - target.content.length > REVIEWER_SKILL_READ_MAX_CHARS + target.content.length > SKILL_WORKSHOP_READ_MAX_CHARS ? `skill "${target.skillKey}" exceeds the reviewer read budget and cannot be updated autonomously` : `read the live skill first: call action=read with skill_name "${target.skillKey}", then ${action === "patch" ? "quote its current text in the patch" : "rewrite it from the returned content"}`, ); } if (readHash !== sha256Hex(target.content)) { - options.proposalMutationBudget?.readSkillHashes?.delete(target.skillKey); + readSkillHashes.delete(target.skillKey); throw new ToolInputError( `skill "${target.skillKey}" changed since it was read: call action=read again and redraft the ${action} from the current content`, ); } - reviewerUpdateContentHash = readHash; + expectedCurrentContentHash = readHash; if (action === "patch") { + if ( + foregroundRepair && + !hasRunWorkspaceSkillUsage({ + runId: options.origin?.runId, + name: target.skillKey, + skillFile: target.skillFile, + }) + ) { + throw new ToolInputError( + `skill "${target.skillKey}" was not used in this run and cannot be repaired autonomously`, + ); + } try { composeSkillBodyPatch(stripProposalFrontmatterForSkill(target.content), { oldString: @@ -530,7 +563,7 @@ export function createSkillWorkshopTool(options: SkillWorkshopToolOptions): AnyA required: true, label: "skill_name", }), - expectedCurrentContentHash: reviewerUpdateContentHash, + expectedCurrentContentHash, description: readStringParam(params, "description"), content: requireProposalContent(proposalContent), supportFiles, @@ -554,7 +587,7 @@ export function createSkillWorkshopTool(options: SkillWorkshopToolOptions): AnyA required: true, label: "skill_name", }), - expectedCurrentContentHash: reviewerUpdateContentHash, + expectedCurrentContentHash, composePatch: { oldString: readStringParam(params, "old_string", { label: "old_string", trim: false }) ?? "", @@ -565,12 +598,16 @@ export function createSkillWorkshopTool(options: SkillWorkshopToolOptions): AnyA }), }, createdBy: "skill-workshop", - ...(options.autonomousCapture ? { autonomousCapture: true } : {}), + ...(options.autonomousCapture || foregroundRepair ? { autonomousCapture: true } : {}), ...(options.origin ? { origin: options.origin } : {}), goal, evidence, }); - contentText = proposalMutationText("Created skill patch proposal", proposal.record); + contentText = foregroundRepair + ? workshopConfig.autonomous.mode === "propose" + ? `Created skill patch proposal ${proposal.record.id} (pending) for ${proposal.record.target.skillKey}; autonomous mode propose requires operator review.` + : proposalMutationText("Created skill patch proposal", proposal.record) + : proposalMutationText("Created skill patch proposal", proposal.record); } else if (action === "revise") { const pendingProposal = await resolvePendingSkillProposal({ proposalId: readStringParam(params, "proposal_id", { @@ -618,6 +655,22 @@ export function createSkillWorkshopTool(options: SkillWorkshopToolOptions): AnyA }); } + if (foregroundRepair && workshopConfig.autonomous.mode === "auto") { + const applied = await applySkillProposal({ + workspaceDir: options.workspaceDir, + agentId: options.agentId, + eventActor: skillWorkshopAgentEventActor(options.agentId), + config: options.config, + env: options.env, + proposalId: proposal.record.id, + expectedRevisionHash: proposal.revisionHash, + reason: "Foreground repair of a used skill", + }); + return actionResult(applied.record, { + contentText: `Repaired used skill ${applied.record.target.skillKey} through proposal ${applied.record.id}.`, + targetSkillFile: applied.targetSkillFile, + }); + } return proposalResult(proposal, { contentText }); } catch (error) { if (reservesMutation && options.proposalMutationBudget) { diff --git a/src/skills/runtime/run-usage.ts b/src/skills/runtime/run-usage.ts index 6c99a057984f..38c5d722d91d 100644 --- a/src/skills/runtime/run-usage.ts +++ b/src/skills/runtime/run-usage.ts @@ -7,6 +7,7 @@ export type RunSkillUsage = Readonly<{ name: string; source: SkillTelemetrySource; activation: "command" | "read"; + skillFile?: string; }>; const skillUsageByRun = new Map>(); @@ -18,12 +19,37 @@ export function recordRunSkillUsage(params: RunSkillUsage & { runId?: string }): return; } const usage = skillUsageByRun.get(runId) ?? new Map(); - const record = { name: params.name, source: params.source, activation: params.activation }; + const record = { + name: params.name, + source: params.source, + activation: params.activation, + ...(params.skillFile ? { skillFile: params.skillFile } : {}), + }; usage.set(`${record.source}\u0000${record.name}\u0000${record.activation}`, record); skillUsageByRun.set(runId, usage); pruneMapToMaxSize(skillUsageByRun, MAX_TRACKED_SKILL_USAGE_RUNS); } +/** Checks whether this run demonstrably used one writable workspace skill. */ +export function hasRunWorkspaceSkillUsage(params: { + runId: string | undefined; + name: string; + skillFile: string; +}): boolean { + if (!params.runId) { + return false; + } + for (const usage of skillUsageByRun.get(params.runId)?.values() ?? []) { + if ( + usage.source === "workspace" && + (usage.skillFile === params.skillFile || (!usage.skillFile && usage.name === params.name)) + ) { + return true; + } + } + return false; +} + /** Transfers one completed run's usage receipt to its terminal side effects. */ export function consumeRunSkillUsage(runId: string | undefined): RunSkillUsage[] { if (!runId) { diff --git a/src/skills/workshop/workspace-skill-read.ts b/src/skills/workshop/workspace-skill-read.ts index 098170b2d1e8..c7ae1f3c9ce1 100644 --- a/src/skills/workshop/workspace-skill-read.ts +++ b/src/skills/workshop/workspace-skill-read.ts @@ -62,7 +62,7 @@ export async function readWritableWorkspaceSkill( workspaceDir: string, skillName: string, opts?: { config?: OpenClawConfig; agentId?: string }, -): Promise<{ skillKey: string; content: string }> { +): Promise<{ skillKey: string; skillFile: string; content: string }> { const name = normalizeOptionalString(skillName); if (!name) { throw new Error("Skill name is required."); @@ -80,5 +80,5 @@ export async function readWritableWorkspaceSkill( if (content === null) { throw new Error(`Skill file is missing: ${targetSkill.filePath}`); } - return { skillKey: targetSkill.skillKey, content }; + return { skillKey: targetSkill.skillKey, skillFile: targetSkill.filePath, content }; }