feat(skills): repair used skills in turn (#121522)

* feat(skills): repair used skills in turn
* fix(skills): bind repair to used skill receipt
* fix(skills): preserve explicit repair review

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
Ayaan Zaidi
2026-08-10 14:42:41 +05:30
committed by GitHub
parent 6f917dfabb
commit 2d627a0c25
13 changed files with 276 additions and 41 deletions
+15
View File
@@ -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
+10 -2
View File
@@ -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 |
@@ -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([]);
});
@@ -552,6 +552,7 @@ export function wrapToolWithBeforeToolCallHook(
name: skillMatch.skillName,
source: skillMatch.skillSource,
activation: skillMatch.activation,
...(skillMatch.skillFile ? { skillFile: skillMatch.skillFile } : {}),
});
}
if (hookOptions.emitDiagnostics) {
+2 -1
View File
@@ -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.",
"",
];
+4 -2
View File
@@ -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", () => {
+1
View File
@@ -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);
+1 -1
View File
@@ -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":
@@ -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", {
+126 -3
View File
@@ -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-");
+80 -27
View File
@@ -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<string, string>();
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<string, string>();
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) {
+27 -1
View File
@@ -7,6 +7,7 @@ export type RunSkillUsage = Readonly<{
name: string;
source: SkillTelemetrySource;
activation: "command" | "read";
skillFile?: string;
}>;
const skillUsageByRun = new Map<string, Map<string, RunSkillUsage>>();
@@ -18,12 +19,37 @@ export function recordRunSkillUsage(params: RunSkillUsage & { runId?: string }):
return;
}
const usage = skillUsageByRun.get(runId) ?? new Map<string, RunSkillUsage>();
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) {
+2 -2
View File
@@ -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 };
}