mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.",
|
||||
"",
|
||||
];
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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-");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user