diff --git a/docs/.generated/config-baseline.counts.json b/docs/.generated/config-baseline.counts.json index 7879d3ac0bfe..0ee505cfc912 100644 --- a/docs/.generated/config-baseline.counts.json +++ b/docs/.generated/config-baseline.counts.json @@ -1,5 +1,5 @@ { - "core": 2306, - "channel": 3646, - "plugin": 3580 + "core": 2305, + "channel": 3648, + "plugin": 3583 } diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 141d52bfdf64..0a5b358ba74f 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -2b982f19d640b237cc8a4677eba922901fbf98aca78af2f001cebf0366334ded config-baseline.json -88610e88d2f41cee512e386bd929396bfbf30b7ff95099dcf3dbaa385f18b568 config-baseline.core.json -5feb809e645891b6bc9736163dedf3b6b90a3f8154147846c0404f1dc0783d2f config-baseline.channel.json -598a029cc19415dac4ae3f04474b3c724c3a355e93fbea3516e5e2dd8410f7d5 config-baseline.plugin.json +1702c4a729d952844880aa6f45dfbab1ff4a8408bc8338d5c41354a043869f7f config-baseline.json +82dbf6abbd417071aa545c3d398afbab297591b117627ff5766c1fad689b8f40 config-baseline.core.json +b3d7fdc2662b49b2e6567573852545a36a8eab13bb2111011510aa96a80a3b8b config-baseline.channel.json +c7c25cc8e151d9db96fb6497e9f2e69870b4106f88e6c0f6cd481100217a556d config-baseline.plugin.json diff --git a/docs/docs_map.md b/docs/docs_map.md index 95cfbca5fc5b..469e0119e209 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -10487,19 +10487,20 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - Route: /tools/self-learning - Headings: - - H2: Enable self-learning - - H2: Review past sessions manually - - H2: What OpenClaw can learn - - H2: When experience review runs + - H2: Capture paths + - H3: Deterministic correction capture + - H3: Experience review + - H2: Mode policy + - H2: Why auto is safe to default - H2: Runtime support - - H2: What the reviewer receives - - H2: Proposal safety - - H2: Review learned proposals - - H2: Configuration + - H2: Cost and privacy + - H2: Review and revert learning + - H2: Configuration reference - H2: Troubleshooting - - H3: No proposal appears after a long turn - - H3: Doctor reports that the Workshop tool is hidden - - H3: Too many low-value proposals appear + - H3: No capture appears + - H3: Doctor reports that Workshop is hidden + - H3: A proposal remains pending in auto mode + - H3: Too many low-value captures appear - H2: Related ## tools/show-widget.md diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 2f1a56d106f8..a1081c10d055 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -235,7 +235,7 @@ See [MCP](/cli/mcp#openclaw-as-an-mcp-client-registry) and - `entries..enabled: false` disables a skill even if bundled/installed. - `entries..apiKey`: convenience for skills declaring a primary env var (plaintext string or SecretRef object). - `limits.maxCandidatesPerRoot`, `limits.maxSkillsLoadedPerSource`, `limits.maxSkillsInPrompt`, `limits.maxSkillsPromptChars`, `limits.maxSkillFileBytes`: bound skill discovery and the model-facing skills prompt. -- Skill Workshop autonomy/approval settings (`workshop.autonomous.enabled`, `workshop.approvalPolicy`, `workshop.maxPending`, `workshop.maxSkillBytes`) are documented in [Skills configuration](/tools/skills-config). +- Skill Workshop autonomy/approval settings (`workshop.autonomous.mode`, `workshop.approvalPolicy`, `workshop.maxPending`, `workshop.maxSkillBytes`) are documented in [Skills configuration](/tools/skills-config). --- diff --git a/docs/tools/self-learning.md b/docs/tools/self-learning.md index 90c10cbef157..f5edb6b3bcd9 100644 --- a/docs/tools/self-learning.md +++ b/docs/tools/self-learning.md @@ -1,32 +1,100 @@ --- -summary: "Let OpenClaw propose reusable skills from corrections and substantial completed work" +summary: "Turn corrections and successful work into reusable skills through Skill Workshop" read_when: - You want OpenClaw to learn reusable procedures from completed conversations - - You are deciding whether to enable autonomous skill proposals - - You need to understand self-learning safety, cost, eligibility, or troubleshooting + - You are choosing between off, propose, and auto self-learning modes + - You need to understand self-learning safety, cost, privacy, or troubleshooting title: "Self-learning" sidebarTitle: "Self-learning" --- -Self-learning lets OpenClaw turn useful evidence from conversations into pending -[Skill Workshop](/tools/skill-workshop) proposals. It does not train model -weights, edit active skills, or silently change agent behavior. Every learned -procedure stays pending until an operator reviews and applies it. +Self-learning turns corrections and successful work into reusable skills. Skills +are the durable unit: they hold procedures that future sessions can discover and +follow. Every learned skill flows through [Skill Workshop](/tools/skill-workshop), +the same governed proposal, scan, apply, and lifecycle path used for explicit +skill authoring. -Self-learning is **disabled by default**. Enable it only when an additional -background model run and transcript review are appropriate for your workspace. +The default mode is `auto`. OpenClaw captures strong learning signals and applies +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 keep only the suggestion nudge. -## Enable self-learning +## Capture paths -In Control UI, open **Plugins → Workshop** and switch on **Self-learning**. The -change takes effect immediately; when another config writer has updated the -file, Control UI refreshes the config snapshot and retries the toggle without a -page or Gateway reload. +OpenClaw uses two complementary capture paths. -Use the CLI: +### Deterministic correction capture + +When an interactive turn ends, OpenClaw looks for durable instructions such as +"from now on," "next time," and direct corrections to a failed approach. This +path is deterministic and does not start another model run. It can: + +- group related instructions into up to three focused skills; +- route a correction to a matching writable workspace skill; +- revise its own related pending proposal; and +- capture after a failed turn because the user instruction remains useful even + when the work did not complete. + +Detection is intentionally heuristic. A durable phrase can occasionally produce +an overly broad or low-value capture. That is an accepted tradeoff because +miscaptures are cheap to inspect and remove, while Workshop governance keeps the +write bounded and recoverable. + +### Experience review + +After substantial successful work, OpenClaw can run one isolated background +review to find a reusable recovery technique or a stable procedure that would +remove at least two future model or tool round trips. + +Experience review starts only when all of these conditions hold: + +- the foreground turn completed successfully; +- the current turn used at least 10 model iterations; +- the run was an eligible foreground conversation, not cron, heartbeat, memory, + overflow, hook, subagent, or review work; +- the runtime reported the resolved provider, model, and actual availability of + `skill_workshop`; +- the system has been quiet for 30 seconds; and +- no agent or reply run is still active. + +A later foreground completion in the same session restarts the quiet period. +Only one experience review runs at a time. The foreground answer is never delayed. + +The reviewer is isolated and conservative. It can list or inspect proposals and +create or revise at most one pending proposal. Its one-mutation budget is shared +across retries. It cannot apply, reject, quarantine, message, update a live skill, +or use general agent tools. The reviewed trajectory is evidence, not instructions. + +Good candidates include: + +- a reliable recovery after repeated tool or model failures; +- a non-obvious ordering constraint that prevented a recurring error; +- a stable multi-step workflow that required repeated discovery; or +- a reusable preflight that would avoid several future calls. + +The reviewer should abstain for: + +- routine successful work or a one-time request; +- personal facts and simple preferences; +- transient environment or service failures; +- generic advice without concrete supporting evidence; +- unsupported negative claims; or +- secrets and credential material. + +## Mode policy + +| Mode | Capture behavior | Suggestion nudge | +| --------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | +| `off` | Does not create deterministic or experience-review captures. | Enabled. OpenClaw can offer to save a detected durable instruction. | +| `propose` | Creates or revises pending proposals through both capture paths. Nothing applies automatically. | Suppressed. | +| `auto` | Creates or revises proposals, then immediately calls the normal Workshop apply path. This is the default. | Suppressed. | + +Set the mode with the CLI: ```bash -openclaw config set skills.workshop.autonomous.enabled true --strict-json +openclaw config set skills.workshop.autonomous.mode auto +openclaw config set skills.workshop.autonomous.mode propose +openclaw config set skills.workshop.autonomous.mode off ``` Or edit `~/.openclaw/openclaw.json`: @@ -36,251 +104,203 @@ Or edit `~/.openclaw/openclaw.json`: skills: { workshop: { autonomous: { - enabled: true, + mode: "auto", }, }, }, } ``` -Disable it again with: +Changing the mode does not alter existing proposals or applied skills. Manual +history review, `/learn`, and explicit Workshop requests remain available in all +three modes. + +## Why auto is safe to default + +Automatic learning uses the same apply path as an operator-approved Workshop +proposal. It does not give the isolated reviewer new tools or a way to bypass +lifecycle checks. + +Every learned skill receives these controls: + +- **Security scan at apply:** Workshop reruns the scanner immediately before the + live write. A critical finding quarantines the proposal instead of applying it. +- **Workspace-only writes:** creates and updates can target only writable skills + in the selected workspace. Bundled, plugin, managed, personal-agent, system, + and extra-root skills remain outside the write boundary. +- **Hash binding:** update proposals bind to the current live skill and go stale + if that target changes before apply. +- **Rollback metadata:** apply records the prior skill and support-file contents + before the live write. +- **Curator lifecycle:** learned skills unused for 30 days become stale and after + 90 days become archived. Pin keeps a skill active; restore returns an archived + skill to new session snapshots. +- **Authoring standards:** learned skills use class-level names, trigger-first + descriptions, evidence-backed steps, and token-efficient language. +- **Bounded failure:** an automatic apply is attempted once. A normal apply + failure leaves the proposal pending, while a scanner-critical proposal is + quarantined. OpenClaw does not retry in a loop. + +Reject a pending miscapture with one command: ```bash -openclaw config set skills.workshop.autonomous.enabled false --strict-json +openclaw skills workshop reject --reason "Not reusable" ``` -User-requested skill creation, `/learn`, and manual Skill Workshop operations -continue to work while self-learning is disabled. +Applied captures remain visible in `openclaw skills workshop list`, retain their +rollback metadata, and enter curator lifecycle management. This makes +approval-free learning reversible and observable rather than silent. -## Review past sessions manually - -Manual history review is the conservative alternative to autonomous capture. -Open **Plugins → Workshop** in the Control UI and select **Find skill ideas**. -This does not change `skills.workshop.autonomous.enabled`. - -Each scan: - -- starts with the newest unreviewed sessions and moves backward; -- reviews up to 20 substantial sessions with at least six model turns; -- skips cron, heartbeat, hook, subagent, ACP, plugin-owned, and internal review - sessions; -- redacts recognized secrets and bounds the transcript bundle before sending it - to the selected agent's configured model; -- uses the same high bar as autonomous experience review; and -- can create or revise at most three pending proposals, never live skills. - -The Workshop reports cumulative session count, date coverage, and ideas found. -Select **Scan earlier work** for the next older window. When the cursor reaches -the beginning of eligible history, the action changes to **Scan new work**. -OpenClaw persists only cursor and coverage metadata in the shared state database; -it does not create a second transcript archive. - -Sessions are scanned only when OpenClaw can prove their ownership and exclude -external-hook content. After an upgrade, the current pre-upgrade transcript can -be classified locally, but rotated pre-upgrade transcripts without per-run -provenance are skipped. New transcripts retain this provenance across rotation. - -Manual scans still incur model-provider cost and send eligible conversation -content to the configured provider. Use them only when that review matches the -workspace's privacy and data-handling requirements. - -## What OpenClaw can learn - -Self-learning has two conservative paths: - -1. **Direct instructions and corrections.** OpenClaw detects durable language - such as “from now on,” “next time,” and corrections to a failed approach. - With self-learning enabled, it can turn those signals into pending proposals - without waiting for another prompt. This deterministic path can group related - instructions into up to three proposals, target a writable workspace skill, - or revise its own related pending proposal. It also runs after failed turns - because it captures the user's instructions rather than judging completion. -2. **Experience review.** After a successful, substantial foreground turn, - OpenClaw can review the completed work for a reusable recovery technique or - a stable procedure that would remove at least two future model or tool round - trips. - -Generated proposals follow shared authoring standards: class-level names, -one-sentence descriptions that lead with the task or trigger, and compact -evidence-backed imperative steps. They retain supported pitfalls and -verification checks, capture working fixes, and do not invent commands, paths, -flags, or APIs. - -Good candidates include: - -- a reliable recovery after repeated tool or model failures; -- a non-obvious ordering constraint that prevented a recurring error; -- a stable multi-step workflow that required repeated discovery; or -- a reusable preflight that would avoid multiple future calls. - -The reviewer should abstain for routine successful work, one-off requests, -personal facts, simple preferences, transient environment failures, generic -advice, unsupported negative claims, and secrets. - -## When experience review runs - -Experience review is deliberately delayed and bounded: - -- The foreground turn must finish successfully. -- The current turn must contain at least ten model iterations. -- Cron, heartbeat, memory, overflow, hook, subagent, and review sessions are - excluded. -- The foreground run must have resolved a provider and model and must actually - have had access to `skill_workshop`. -- OpenClaw waits 30 seconds after completion. A later foreground completion in - the same session restarts that quiet period. -- If any agent or reply run is still active, review waits another 30 seconds. -- Only one experience review runs at a time. -- Delayed review is process-local Gateway work. The Gateway must remain running - through the idle window. CLI-backed harnesses can schedule review only when - they report the resolved model, exact model-iteration count, and actual - `skill_workshop` availability. The Codex app-server harness reports those - facts for its `openai/*` sessions; runtimes with missing facts still fail - closed. - -The foreground answer is never delayed for learning. A failed or ineligible -turn does not start experience review, although direct user corrections can -still be offered as a suggestion when autonomy is disabled. +Residual risk remains: learned content comes from conversation and tool output, +and the scanner blocks recognized dangerous patterns, not every possible piece +of bad advice. Review `openclaw skills workshop list` when in doubt. ## Runtime support Delayed experience review requires the runtime to report its resolved model and -actual `skill_workshop` availability. The embedded runner and the Codex -app-server harness provide those facts; Codex also reports its exact model -iteration count. Other CLI-backed runtimes fail closed until they provide the -same runtime facts. Deterministic correction capture and `/learn` still work on -those runtimes. +actual `skill_workshop` availability. The embedded runner and Codex app-server +harness report those facts; Codex also reports its exact model-iteration count. +Other CLI-backed runtimes fail closed until they provide the same runtime facts. -## What the reviewer receives +Deterministic correction capture and `/learn` do not depend on delayed review and +continue to work on those runtimes. In `auto` mode, a runtime that does not report +actual Workshop availability leaves deterministic captures pending instead of +applying them. -The background reviewer receives only the current turn, starting at its most -recent user message. The rendered trajectory is capped at 60,000 characters; -when necessary, OpenClaw keeps the first message and the newest evidence and +## Cost and privacy + +Deterministic correction capture does not make an extra model call. Experience +review adds one bounded model run on the configured provider only after a +substantial successful turn, not after every message. The review can make more +than one provider request while it inspects or drafts its single proposal. + +The reviewer receives only the current turn beginning with its most recent user +message. The rendered trajectory is limited to 60,000 characters. When the +bundle is too large, OpenClaw keeps the first message and newest evidence and marks the omitted middle. -For Codex app-server sessions, OpenClaw counts unique upstream response -completions as model iterations and uses the harness's frozen current-turn -projection. The harness also attempts to mirror that projection into the agent's -SQLite transcript before delayed review is scheduled; the reviewer does not -need to reread the database. +The reviewer reuses the foreground provider, model, and available auth identity, +with model fallbacks disabled. Provider pricing and data-handling terms apply to +the additional run. -The reviewer reuses the resolved provider and model. It reuses the foreground -auth profile when that identity is available and disables model fallbacks. The -review therefore starts an additional model run on the configured provider. -That run can make more than one provider request when it inspects or drafts a -proposal. Provider pricing and data-handling terms apply just as they do to the -foreground turn. - -Before starting, OpenClaw reloads current runtime configuration and rechecks the -effective sandbox and tool policy for the original conversation. If the run is -sandboxed, policy no longer permits `skill_workshop`, or required runtime facts -are missing, review fails closed and creates nothing. +Manual history scan uses a separate bounded path. It reviews up to 20 substantial +sessions with at least six model turns, redacts recognized secrets, bounds the +transcript bundle, and can create or revise at most three pending proposals. It +stores cursor and coverage metadata in the shared state database without copying +transcript content into scan state. - Enabling self-learning permits eligible conversation content, including tool - inputs and results from the current turn, to be sent to the selected model - provider for one additional review. Do not enable it in a workspace where - that review would violate data-handling requirements. + Experience review and manual history scan can send eligible conversation + content, including tool inputs and results, to the configured model provider. + Choose a provider and mode that match the workspace privacy and data-handling + requirements. -## Proposal safety +## Review and revert learning -The reviewer runs in an isolated session with a deliberately narrow tool -surface: - -- It can only list or inspect Workshop proposals and create or revise one - pending proposal. -- It cannot update a live skill, apply a proposal, reject a proposal, quarantine - a proposal, send a message, or use general agent tools. -- One mutation budget is shared across model retries, so a review can create or - revise at most one proposal. -- The reviewed trajectory is treated as untrusted evidence, not as instructions - for the background agent. -- Skill Workshop scans proposal content and rejects recognized literal - credentials before proposal state is written. - -Normal Workshop limits still apply, including `maxPending`, `maxSkillBytes`, -support-file restrictions, scanner checks, and workspace-only writes. The -`approvalPolicy: "auto"` setting does not grant the background reviewer access -to lifecycle actions. - -## Review learned proposals - -Self-learning produces the same pending proposals as manual Workshop use. -Inspect them before applying: +List and inspect every pending, applied, rejected, quarantined, or stale capture: ```bash openclaw skills workshop list openclaw skills workshop inspect -openclaw skills workshop apply ``` -Revise, reject, or quarantine proposals that are useful but not ready: +Stop a pending capture from becoming active or quarantine it for safety review: ```bash -openclaw skills workshop revise --proposal ./PROPOSAL.md openclaw skills workshop reject --reason "Too specific" openclaw skills workshop quarantine --reason "Needs security review" ``` -Applying is the only operation that writes an active `SKILL.md`. See -[Skill Workshop](/tools/skill-workshop) for the complete lifecycle and storage -model. +Inspect and manage applied learned skills through the curator: -## Configuration +```bash +openclaw skills curator status +openclaw skills curator pin +openclaw skills curator unpin +openclaw skills curator restore +``` -| Setting | Default | Self-learning effect | -| ------------------------------------------ | -------- | --------------------------------------------------------------------------------------------------------------------------------- | -| `skills.workshop.autonomous.enabled` | `false` | Enables direct correction capture and delayed experience review. | -| `skills.workshop.approvalPolicy` | `"auto"` | Controls approval prompts for normal agent-initiated lifecycle actions; it does not expand the background reviewer's permissions. | -| `skills.workshop.maxPending` | `50` | Caps pending and quarantined proposals per workspace. | -| `skills.workshop.maxSkillBytes` | `40000` | Caps proposal body size in bytes. | -| `skills.workshop.allowSymlinkTargetWrites` | `false` | Affects apply behavior only; self-learning itself writes proposal state, not live skill targets. | +Use `/learn` when you want an explicit proposal from the current conversation or +named sources: -For the exhaustive schema, ranges, and related skill settings, see -[Skills config](/tools/skills-config#workshop-skills-workshop). +```text +/learn +/learn docs/runbook.md; focus on recovery +``` + +`/learn` always creates a pending proposal and never auto-applies it. + +To review older work manually, open **Plugins -> Workshop** in Control UI and +select **Find skill ideas**. Each click reviews one bounded window and leaves any +result pending regardless of autonomous mode. + +## Configuration reference + +| Setting | Default | Effect | +| ------------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------ | +| `skills.workshop.autonomous.mode` | `"auto"` | Chooses `off`, `propose`, or `auto` capture behavior. | +| `skills.workshop.approvalPolicy` | `"auto"` | Controls prompts for normal agent-initiated lifecycle calls. It never expands the isolated reviewer tool surface. | +| `skills.workshop.maxPending` | `50` | Caps pending and quarantined proposals per workspace. | +| `skills.workshop.maxSkillBytes` | `40000` | Caps proposal body size in bytes. | +| `skills.workshop.allowSymlinkTargetWrites` | `false` | Allows apply through explicitly trusted workspace skill symlinks. Capture itself does not widen the trusted target list. | + +See [Skills config](/tools/skills-config#workshop-skills-workshop) for ranges and +the complete `skills.*` schema. ## Troubleshooting -### No proposal appears after a long turn +### No capture appears -Check all of the following: +Check the following: -1. `skills.workshop.autonomous.enabled` is `true` in the active Gateway config. -2. The turn succeeded and included at least ten model iterations after the most - recent user message. -3. The conversation was a normal foreground run, not a scheduled, memory, - hook, or subagent run. -4. The original run had access to `skill_workshop` and was not sandboxed. -5. The system remained idle long enough for the delayed review. -6. The long-running Gateway process stayed active through the idle window; a - one-shot local command does not wait for delayed review. +1. `skills.workshop.autonomous.mode` is `propose` or `auto` in the active Gateway + config. +2. The correction uses durable language, or the successful turn reached at least + 10 model iterations. +3. The conversation is eligible foreground work. +4. The runtime reported the resolved model and actual `skill_workshop` + availability. +5. The run was not sandboxed and tool policy still permits `skill_workshop`. +6. For experience review, the Gateway stayed running and idle through the + 30-second quiet period. -A qualifying review may still produce no proposal. Abstention is the expected +An eligible experience review can still abstain. No proposal is the expected result when the evidence does not clear the reusable-procedure bar. -### Doctor reports that the Workshop tool is hidden +### Doctor reports that Workshop is hidden -When self-learning is enabled, `openclaw doctor` checks whether the default -agent's effective tool policy permits `skill_workshop`. Follow the reported -`tools.allow` or `tools.alsoAllow` change, or disable self-learning. +In `propose` and `auto` modes, `openclaw doctor` checks whether the default agent +tool policy permits `skill_workshop`. Apply the reported `tools.allow` or +`tools.alsoAllow` change, or set the autonomous mode to `off`. -### Too many low-value proposals appear +### A proposal remains pending in auto mode -Disable self-learning and continue using `/learn` or explicit Workshop requests: +Automatic apply runs once. Inspect the proposal and its scanner state: ```bash -openclaw config set skills.workshop.autonomous.enabled false --strict-json +openclaw skills workshop inspect ``` -Pending proposals remain reviewable after the feature is disabled. Disabling -self-learning does not apply, reject, or delete them. +A normal write or target failure leaves it pending for manual review. A critical +scanner result moves it to quarantine. Fix the cause and apply manually; do not +build a retry loop around automatic capture. + +### Too many low-value captures appear + +Switch to `propose` to review every capture, or `off` to keep only the suggestion +nudge: + +```bash +openclaw config set skills.workshop.autonomous.mode propose +openclaw config set skills.workshop.autonomous.mode off +``` + +Existing proposals and applied skills remain visible after the mode changes. ## Related -- [Skill Workshop](/tools/skill-workshop) for proposal review, approval, and - storage -- [Creating skills](/tools/creating-skills) for hand-authored skills and - `SKILL.md` structure -- [Skills config](/tools/skills-config) for all `skills.*` settings +- [Skill Workshop](/tools/skill-workshop) for proposal lifecycle and storage +- [Creating skills](/tools/creating-skills) for hand-authored skills +- [Skills config](/tools/skills-config) for every `skills.*` setting - [Skills CLI](/cli/skills) for Workshop and curator commands diff --git a/docs/tools/skill-workshop.md b/docs/tools/skill-workshop.md index c21d8721278a..840d8999e7e4 100644 --- a/docs/tools/skill-workshop.md +++ b/docs/tools/skill-workshop.md @@ -235,10 +235,10 @@ agent session or the CLI. OpenClaw detects durable instructions such as “next time,” “remember to,” and reactive corrections when an interactive turn ends, including failed turns. On the next turn, the agent offers to save the most recent detected workflow through `skill_workshop`; the user decides whether to create a -proposal. This built-in suggestion does not create or change a skill by itself. Enable -`skills.workshop.autonomous.enabled` to create pending proposals directly instead. In the Control -UI, the Workshop tab offers the same setting as a **Self-learning** toggle in the page header, and -as an enable button on the empty proposal board. +proposal. This built-in suggestion does not create or change a skill by itself. Set +`skills.workshop.autonomous.mode` to `propose` to create pending proposals directly, or to `auto` +to apply scanner-approved captures through the normal Workshop service. The Control UI Workshop +tab shows whether self-learning is on; use the config setting to choose all three modes. ### Scan past sessions @@ -261,15 +261,16 @@ for example **20 sessions reviewed · Jun 18–today · 2 ideas found**. Select the available history is exhausted, the action becomes **Scan new work**. Historical review is manual even when -`skills.workshop.autonomous.enabled` is `false`. Each click starts a model run, +`skills.workshop.autonomous.mode` is `off`. Each click starts a model run, so provider pricing and data-handling terms apply. The cursor and coverage counts are stored in the shared OpenClaw state database; transcript content is not copied into scan state. -With autonomous capture enabled, OpenClaw can also perform a conservative review after successful, +In `propose` and `auto` modes, OpenClaw can also perform a conservative review after successful, substantial work and after the whole agent system becomes idle. That isolated review can create or revise at most one pending proposal. It cannot update a live skill or apply, reject, or quarantine a -proposal, even when `approvalPolicy` is `"auto"`. +proposal. In `auto` mode, the orchestrating capture pipeline applies the result afterward through +the normal scanner-gated service. See [Self-learning](/tools/self-learning) for enablement, eligibility, privacy and cost details, the proposal threshold, and troubleshooting. @@ -281,7 +282,7 @@ the proposal threshold, and troubleshooting. skills: { workshop: { autonomous: { - enabled: false, + mode: "auto", }, allowSymlinkTargetWrites: false, approvalPolicy: "auto", @@ -294,13 +295,13 @@ the proposal threshold, and troubleshooting. | Setting | Default | Effect | | -------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `autonomous.enabled` | `false` | Creates pending proposals from explicit corrections and, after an idle delay, substantial completed work with reusable recovery or meaningful round-trip savings. | +| `autonomous.mode` | `"auto"` | `"off"` keeps the suggestion nudge, `"propose"` creates pending captures, and `"auto"` applies captures through the normal Workshop scanner and apply path. | | `allowSymlinkTargetWrites` | `false` | Lets apply write through workspace skill symlinks whose real target is listed in `skills.load.allowSymlinkTargets`. | | `approvalPolicy` | `"auto"` | `"auto"` skips an additional prompt for agent-initiated `apply`, `reject`, or `quarantine` (the agent still has to call the action). `"pending"` requires approval. | | `maxPending` | `50` | Caps pending and quarantined proposals per workspace (1-200). | | `maxSkillBytes` | `40000` | Caps proposal body size in bytes (1024-200000). | -Autonomous capture recognizes prospective rules (for example, “from now on”) and reactive +Autonomous capture in `propose` and `auto` modes recognizes prospective rules (for example, “from now on”) and reactive corrections (for example, “that’s not what I asked”). It groups new instructions by topic into up to three proposals per turn, routes vocabulary matches to existing writable workspace skills, and revises its own pending proposal when another correction targets the same skill. @@ -309,7 +310,8 @@ For successful substantial work without an explicit correction, an isolated run model decides whether the completed trajectory clears the conservative proposal bar. The foreground model is not prompted to learn before it replies. The background reviewer preserves the foreground run as proposal provenance, cannot access general agent tools, and cannot make lifecycle -decisions. The review starts only when the foreground runtime reports both its exact resolved model +decisions. In `auto` mode, the capture pipeline applies the resulting pending proposal only after +the isolated run completes. The review starts only when the foreground runtime reports its resolved model and that `skill_workshop` was actually available. Restrictive or unknown tool policy therefore fails closed and creates no proposal. @@ -398,7 +400,7 @@ proposals remain listed with a previous-workspace marker instead of disappearing ### Tool-policy diagnostic -When autonomous capture is enabled, `openclaw doctor` runs the +In `propose` and `auto` modes, `openclaw doctor` runs the `core/doctor/skill-workshop-tool-policy` check for the default agent. If policy hides `skill_workshop`, the warning names the first excluding config layer and the exact `allow` or `alsoAllow` change to make. Older runbooks may still use diff --git a/docs/tools/skills-config.md b/docs/tools/skills-config.md index 1b1582e64cb1..92dfd6dacd15 100644 --- a/docs/tools/skills-config.md +++ b/docs/tools/skills-config.md @@ -27,7 +27,7 @@ Most skills configuration lives under `skills` in allowUploadedArchives: false, }, workshop: { - autonomous: { enabled: false }, + autonomous: { mode: "auto" }, allowSymlinkTargetWrites: false, approvalPolicy: "auto", maxPending: 50, @@ -336,11 +336,12 @@ different visible skill set per agent. ## Workshop (`skills.workshop`) - - When `true`, OpenClaw can create pending proposals from durable corrections - and can review successful, substantial completed work after the system becomes - idle. This can add a background model run after eligible turns. User-prompted - skill creation and `/learn` continue to work when the setting is `false`. + + `off` disables autonomous capture while keeping the durable-instruction + suggestion nudge. `propose` creates pending proposals from corrections and + substantial completed work. `auto` sends the same captures through the normal + scanner-gated Workshop apply path. User-prompted skill creation, `/learn`, and + manual history scan continue to work in every mode. See [Self-learning](/tools/self-learning) for eligibility, privacy, cost, diff --git a/src/agents/cli-runner.reliability.test.ts b/src/agents/cli-runner.reliability.test.ts index 630f5dec3d4b..ec78b66eed8d 100644 --- a/src/agents/cli-runner.reliability.test.ts +++ b/src/agents/cli-runner.reliability.test.ts @@ -3296,7 +3296,7 @@ describe("runCliAgent reliability", () => { skills: { workshop: { autonomous: { - enabled: true, + mode: "propose", }, }, }, diff --git a/src/agents/embedded-agent-runner/run/attempt-tool-base-prepare.ts b/src/agents/embedded-agent-runner/run/attempt-tool-base-prepare.ts index 18efbde39e48..a92476ed024d 100644 --- a/src/agents/embedded-agent-runner/run/attempt-tool-base-prepare.ts +++ b/src/agents/embedded-agent-runner/run/attempt-tool-base-prepare.ts @@ -252,6 +252,7 @@ export function prepareEmbeddedAttemptToolBase(params: { skillWorkshop: { env: attempt.skillWorkshopProposalEnv, proposalOnly: attempt.skillWorkshopProposalOnly, + ...(attempt.skillWorkshopAutonomousCapture ? { autonomousCapture: true } : {}), origin: attempt.skillWorkshopOrigin, proposalMutationBudget: attempt.skillWorkshopProposalMutationBudget, proposalReviewCompletion: attempt.skillWorkshopProposalReviewCompletion, diff --git a/src/agents/embedded-agent-runner/run/params.ts b/src/agents/embedded-agent-runner/run/params.ts index 785b31a3cbf5..e592917877b8 100644 --- a/src/agents/embedded-agent-runner/run/params.ts +++ b/src/agents/embedded-agent-runner/run/params.ts @@ -155,6 +155,8 @@ export type RunEmbeddedAgentParams = { disableTrajectory?: boolean; /** Restrict Skill Workshop to a bounded pending-proposal budget for an internal review run. */ skillWorkshopProposalOnly?: boolean; + /** Mark proposals created by this internal review as autonomous captures. */ + skillWorkshopAutonomousCapture?: boolean; /** Preserve the foreground run as proposal provenance for an internal review run. */ skillWorkshopOrigin?: SkillProposalOrigin; /** Run-scoped mutation budget shared across internal runner attempts. */ diff --git a/src/agents/embedded-agent-runner/run/skill-workshop-attempt-params.ts b/src/agents/embedded-agent-runner/run/skill-workshop-attempt-params.ts index 389924388de2..1bd903bb32cb 100644 --- a/src/agents/embedded-agent-runner/run/skill-workshop-attempt-params.ts +++ b/src/agents/embedded-agent-runner/run/skill-workshop-attempt-params.ts @@ -3,6 +3,7 @@ import type { RunEmbeddedAgentParams } from "./params.js"; export function resolveSkillWorkshopAttemptParams( params: Pick< RunEmbeddedAgentParams, + | "skillWorkshopAutonomousCapture" | "skillWorkshopOrigin" | "skillWorkshopProposalEnv" | "skillWorkshopProposalMutationBudget" @@ -11,6 +12,7 @@ export function resolveSkillWorkshopAttemptParams( >, ) { return { + skillWorkshopAutonomousCapture: params.skillWorkshopAutonomousCapture, skillWorkshopProposalOnly: params.skillWorkshopProposalOnly, skillWorkshopProposalEnv: params.skillWorkshopProposalEnv, skillWorkshopOrigin: params.skillWorkshopOrigin, diff --git a/src/agents/harness/agent-end-side-effects.test.ts b/src/agents/harness/agent-end-side-effects.test.ts index 1998ec65142d..5106514e6101 100644 --- a/src/agents/harness/agent-end-side-effects.test.ts +++ b/src/agents/harness/agent-end-side-effects.test.ts @@ -58,7 +58,7 @@ describe("agent end side effects", () => { skills: { workshop: { autonomous: { - enabled: true, + mode: "propose", }, }, }, @@ -83,7 +83,7 @@ describe("agent end side effects", () => { skills: { workshop: { autonomous: { - enabled: true, + mode: "propose", }, }, }, @@ -93,7 +93,7 @@ describe("agent end side effects", () => { skills: { workshop: { autonomous: { - enabled: true, + mode: "propose", }, }, }, diff --git a/src/agents/tools/skill-workshop-tool-factory.ts b/src/agents/tools/skill-workshop-tool-factory.ts index 088868374c6e..33eb1aba0e23 100644 --- a/src/agents/tools/skill-workshop-tool-factory.ts +++ b/src/agents/tools/skill-workshop-tool-factory.ts @@ -31,6 +31,7 @@ export function createConfiguredSkillWorkshopTool(params: { ...(messageId ? { messageId } : {}), } satisfies SkillProposalOrigin), proposalOnly: params.run?.proposalOnly, + ...(params.run?.autonomousCapture ? { autonomousCapture: true } : {}), proposalMutationBudget: params.run?.proposalMutationBudget ?? (params.run?.proposalOnly ? { remaining: 1 } : undefined), diff --git a/src/agents/tools/skill-workshop-tool.test.ts b/src/agents/tools/skill-workshop-tool.test.ts index 3427adfb6dea..20934d522673 100644 --- a/src/agents/tools/skill-workshop-tool.test.ts +++ b/src/agents/tools/skill-workshop-tool.test.ts @@ -80,7 +80,7 @@ describe("skill_workshop tool", () => { skills: { workshop: { autonomous: { - enabled: false, + mode: "off", }, }, }, @@ -93,11 +93,11 @@ describe("skill_workshop tool", () => { it("does not nudge the foreground model when autonomy is enabled", () => { const disabled = createSkillWorkshopTool({ workspaceDir: "/tmp/openclaw", - config: { skills: { workshop: { autonomous: { enabled: false } } } }, + config: { skills: { workshop: { autonomous: { mode: "off" } } } }, }); const enabled = createSkillWorkshopTool({ workspaceDir: "/tmp/openclaw", - config: { skills: { workshop: { autonomous: { enabled: true } } } }, + config: { skills: { workshop: { autonomous: { mode: "propose" } } } }, }); expect(enabled.description).toBe(disabled.description); diff --git a/src/agents/tools/skill-workshop-tool.ts b/src/agents/tools/skill-workshop-tool.ts index d540ad58b111..a2134204d90e 100644 --- a/src/agents/tools/skill-workshop-tool.ts +++ b/src/agents/tools/skill-workshop-tool.ts @@ -161,6 +161,8 @@ type SkillWorkshopToolOptions = { origin?: SkillProposalOrigin; /** Internal reviewers may inspect and draft bounded pending proposals, never change lifecycle state. */ proposalOnly?: boolean; + /** Marks proposals created by an autonomous capture pipeline. */ + autonomousCapture?: boolean; /** Run-scoped budget shared by every tool instance created across retries. */ proposalMutationBudget?: SkillWorkshopProposalMutationBudget; /** Optional durable completion latch shared across runner retries. */ @@ -340,6 +342,7 @@ export function createSkillWorkshopTool(options: SkillWorkshopToolOptions): AnyA content: proposalContent, supportFiles, createdBy: "skill-workshop", + ...(options.autonomousCapture ? { autonomousCapture: true } : {}), ...(options.origin ? { origin: options.origin } : {}), goal, evidence, @@ -359,6 +362,7 @@ export function createSkillWorkshopTool(options: SkillWorkshopToolOptions): AnyA content: proposalContent, supportFiles, createdBy: "skill-workshop", + ...(options.autonomousCapture ? { autonomousCapture: true } : {}), ...(options.origin ? { origin: options.origin } : {}), goal, evidence, diff --git a/src/auto-reply/reply/get-reply-run-context.ts b/src/auto-reply/reply/get-reply-run-context.ts index 0d071962dc6c..098a206fbaeb 100644 --- a/src/auto-reply/reply/get-reply-run-context.ts +++ b/src/auto-reply/reply/get-reply-run-context.ts @@ -321,7 +321,7 @@ export async function prepareReplyRunContext(params: RunPreparedReplyParams) { } const envelopeOptions = resolveEnvelopeFormatOptions(cfg); - const skillSuggestionEnabled = !resolveSkillWorkshopConfig(cfg).autonomous.enabled; + const skillSuggestionEnabled = resolveSkillWorkshopConfig(cfg).autonomous.mode === "off"; const inboundUserContextSessionCtx = isNewSession ? { ...sessionCtx, diff --git a/src/auto-reply/reply/get-reply-run.media-only.test.ts b/src/auto-reply/reply/get-reply-run.media-only.test.ts index 662839687b25..a13e21ff3bd7 100644 --- a/src/auto-reply/reply/get-reply-run.media-only.test.ts +++ b/src/auto-reply/reply/get-reply-run.media-only.test.ts @@ -1645,6 +1645,12 @@ describe("runPreparedReply media-only handling", () => { const runPromise = runPreparedReply( baseParams({ + cfg: { + session: {}, + channels: {}, + agents: { defaults: {} }, + skills: { workshop: { autonomous: { mode: "off" } } }, + }, isNewSession: false, sessionId: "session-goal-interrupt", sessionEntry: activeEntry, @@ -1696,6 +1702,12 @@ describe("runPreparedReply media-only handling", () => { await runPreparedReply( baseParams({ + cfg: { + session: {}, + channels: {}, + agents: { defaults: {} }, + skills: { workshop: { autonomous: { mode: "off" } } }, + }, isNewSession: false, sessionEntry, sessionStore, @@ -1711,6 +1723,12 @@ describe("runPreparedReply media-only handling", () => { await runPreparedReply( baseParams({ + cfg: { + session: {}, + channels: {}, + agents: { defaults: {} }, + skills: { workshop: { autonomous: { mode: "off" } } }, + }, isNewSession: false, sessionEntry, sessionStore, @@ -1723,6 +1741,42 @@ describe("runPreparedReply media-only handling", () => { requireLastRunReplyAgentCall().followupRun.currentInboundContext?.text ?? "", ).not.toContain("A reusable workflow"); }); + + it.each(["propose", "auto"] as const)( + "suppresses the suggestion nudge in %s mode", + async (mode) => { + const suggestion = { skillName: "github-pr-workflow", detectedAt: 1 }; + const sessionEntry: SessionEntry = { + sessionId: `skill-suggestion-${mode}`, + updatedAt: 1, + pendingSkillSuggestion: suggestion, + }; + consumeSessionSkillSuggestionMock.mockResolvedValueOnce({ + entry: { ...sessionEntry, pendingSkillSuggestion: undefined }, + suggestion, + }); + + await runPreparedReply( + baseParams({ + cfg: { + session: {}, + channels: {}, + agents: { defaults: {} }, + skills: { workshop: { autonomous: { mode } } }, + }, + isNewSession: false, + sessionEntry, + sessionStore: { "session-key": sessionEntry }, + storePath: "/tmp/openclaw-session-store.json", + }), + ); + + expect(consumeSessionSkillSuggestionMock).toHaveBeenCalledOnce(); + expect( + requireLastRunReplyAgentCall().followupRun.currentInboundContext?.text ?? "", + ).not.toContain("A reusable workflow"); + }, + ); it("treats reset-triggered followup mode as interrupt when the session lane is empty", async () => { const queueSettings = await import("./queue/settings-runtime.js"); const embeddedAgentRuntime = await import("../../agents/embedded-agent.runtime.js"); diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.skills.test.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.skills.test.ts new file mode 100644 index 000000000000..0ebbfad4216f --- /dev/null +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.skills.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { LEGACY_CONFIG_MIGRATIONS_RUNTIME_SKILLS } from "./legacy-config-migrations.runtime.skills.js"; + +function migrate(raw: Record) { + const changes: string[] = []; + for (const migration of LEGACY_CONFIG_MIGRATIONS_RUNTIME_SKILLS) { + migration.apply(raw, changes); + } + return { raw, changes }; +} + +describe("Skill Workshop autonomy config migration", () => { + it.each([ + { enabled: true, mode: "propose" }, + { enabled: false, mode: "off" }, + ] as const)("maps enabled=$enabled to $mode", ({ enabled, mode }) => { + const result = migrate({ + skills: { workshop: { autonomous: { enabled } } }, + }); + + expect(result.raw).toEqual({ + skills: { workshop: { autonomous: { mode } } }, + }); + expect(result.changes).toEqual([ + `Mapped skills.workshop.autonomous.enabled to mode: "${mode}".`, + ]); + }); + + it("leaves an absent legacy key absent so the new auto default applies", () => { + const result = migrate({ skills: { workshop: { autonomous: {} } } }); + + expect(result.raw).toEqual({ skills: { workshop: { autonomous: {} } } }); + expect(result.changes).toEqual([]); + }); +}); diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.skills.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.skills.ts new file mode 100644 index 000000000000..b8c7d109cfda --- /dev/null +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.skills.ts @@ -0,0 +1,35 @@ +import { + defineLegacyConfigMigration, + getRecord, + type LegacyConfigMigrationSpec, +} from "../../../config/legacy.shared.js"; + +export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_SKILLS: LegacyConfigMigrationSpec[] = [ + defineLegacyConfigMigration({ + id: "skills.workshop.autonomous.enabled->mode", + describe: "Migrate Skill Workshop autonomy to its three-position mode.", + legacyRules: [ + { + path: ["skills", "workshop", "autonomous", "enabled"], + message: + 'skills.workshop.autonomous.enabled is retired; use skills.workshop.autonomous.mode. Run "openclaw doctor --fix".', + }, + ], + apply: (raw, changes) => { + const autonomous = getRecord(getRecord(getRecord(raw.skills)?.workshop)?.autonomous); + if (!autonomous || !Object.hasOwn(autonomous, "enabled")) { + return; + } + if (autonomous.mode === undefined) { + const mode = autonomous.enabled === false ? "off" : "propose"; + autonomous.mode = mode; + changes.push(`Mapped skills.workshop.autonomous.enabled to mode: "${mode}".`); + } else { + changes.push( + "Removed skills.workshop.autonomous.enabled because autonomous.mode is already set.", + ); + } + delete autonomous.enabled; + }, + }), +]; diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.ts index 45def6e6f22f..248ca19e5833 100644 --- a/src/commands/doctor/shared/legacy-config-migrations.runtime.ts +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.ts @@ -11,6 +11,7 @@ import { LEGACY_CONFIG_MIGRATIONS_RUNTIME_MODELS } from "./legacy-config-migrati import { LEGACY_CONFIG_MIGRATIONS_RUNTIME_PROVIDERS } from "./legacy-config-migrations.runtime.providers.js"; import { LEGACY_CONFIG_MIGRATIONS_RUNTIME_RETIRED } from "./legacy-config-migrations.runtime.retired.js"; import { LEGACY_CONFIG_MIGRATIONS_RUNTIME_SESSION } from "./legacy-config-migrations.runtime.session.js"; +import { LEGACY_CONFIG_MIGRATIONS_RUNTIME_SKILLS } from "./legacy-config-migrations.runtime.skills.js"; import { LEGACY_CONFIG_MIGRATIONS_RUNTIME_SYSTEM_AGENT } from "./legacy-config-migrations.runtime.system-agent.js"; import { LEGACY_CONFIG_MIGRATIONS_RUNTIME_TTS } from "./legacy-config-migrations.runtime.tts.js"; @@ -26,6 +27,7 @@ export const LEGACY_CONFIG_MIGRATIONS_RUNTIME: LegacyConfigMigrationSpec[] = [ ...LEGACY_CONFIG_MIGRATIONS_RUNTIME_PROVIDERS, ...LEGACY_CONFIG_MIGRATIONS_RUNTIME_RETIRED, ...LEGACY_CONFIG_MIGRATIONS_RUNTIME_SESSION, + ...LEGACY_CONFIG_MIGRATIONS_RUNTIME_SKILLS, ...LEGACY_CONFIG_MIGRATIONS_RUNTIME_SYSTEM_AGENT, ...LEGACY_CONFIG_MIGRATIONS_RUNTIME_TTS, ...LEGACY_CONFIG_MIGRATIONS_RUNTIME_ENTRIES, diff --git a/src/config/__snapshots__/schema.help.quality.test.ts.snap b/src/config/__snapshots__/schema.help.quality.test.ts.snap index 74699fbdf170..3cf35529e119 100644 --- a/src/config/__snapshots__/schema.help.quality.test.ts.snap +++ b/src/config/__snapshots__/schema.help.quality.test.ts.snap @@ -306,7 +306,7 @@ exports[`config tier coverage > keeps the curated common leaf set reviewable 1`] "skills.load.allowSymlinkTargets.*", "skills.load.extraDirs.*", "skills.workshop.approvalPolicy", - "skills.workshop.autonomous.enabled", + "skills.workshop.autonomous.mode", "talk.provider", "talk.providers.*.apiKey", "talk.providers.*.apiKey.id", diff --git a/src/config/schema.help.quality.test-fixtures.ts b/src/config/schema.help.quality.test-fixtures.ts index 0214e11e03ff..9f1533a9888a 100644 --- a/src/config/schema.help.quality.test-fixtures.ts +++ b/src/config/schema.help.quality.test-fixtures.ts @@ -348,6 +348,7 @@ export const TARGET_KEYS = [ ] as const; export const ENUM_EXPECTATIONS: Record = { + "skills.workshop.autonomous.mode": ['"off"', '"propose"', '"auto"'], "memory.citations": ['"auto"', '"on"', '"off"'], "memory.backend": ['"builtin"', '"qmd"'], "memory.qmd.searchMode": ['"query"', '"search"', '"vsearch"'], diff --git a/src/config/schema.help.runtime.ts b/src/config/schema.help.runtime.ts index d0534cc8bb35..f9a321382aae 100644 --- a/src/config/schema.help.runtime.ts +++ b/src/config/schema.help.runtime.ts @@ -444,6 +444,8 @@ export const RUNTIME_FIELD_HELP: Record = { "Trusted real target roots that skill symlinks may resolve into when they sit outside their configured source root. Keep this narrow, such as a sibling repo skills directory.", "skills.load.watch": "Enable filesystem watching for skill-definition changes so updates can be applied without full process restart. Keep enabled in development workflows and disable in immutable production images.", + "skills.workshop.autonomous.mode": + 'Controls background learning: "off" keeps only the suggestion nudge, "propose" creates pending proposals, and "auto" applies captured proposals through the normal scanner-gated Workshop path. Default: "auto".', "skills.workshop.allowSymlinkTargetWrites": "Allows Skill Workshop apply to write through symlinked workspace skill paths whose real target is already trusted by skills.load.allowSymlinkTargets. Keep disabled unless operators intentionally want generated proposal applies to mutate those shared skill roots.", approvals: diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 7b3935360537..092a8724ca01 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -407,6 +407,7 @@ export const FIELD_LABELS: Record = { "skills.load.extraDirs": "Extra Skill Directories", "skills.load.allowSymlinkTargets": "Allowed Skill Symlink Targets", "skills.load.watch": "Watch Skills", + "skills.workshop.autonomous.mode": "Skill Workshop Autonomous Mode", "skills.workshop.allowSymlinkTargetWrites": "Allow Skill Workshop Symlink Writes", "agents.defaults.skills": "Skills", "agents.defaults.subagents.delegationMode": "Sub-agent Delegation Mode", diff --git a/src/config/schema.tiers.ts b/src/config/schema.tiers.ts index 603e8e633ab0..3d78ecb9188f 100644 --- a/src/config/schema.tiers.ts +++ b/src/config/schema.tiers.ts @@ -158,7 +158,7 @@ secrets.providers.*.path secrets.providers.*.source skills.allowBundled skills.entries.*.apiKey skills.entries.*.config skills.entries.*.enabled skills.entries.*.env skills.install.allowUploadedArchives skills.install.nodeManager skills.load.allowSymlinkTargets skills.load.extraDirs skills.workshop.approvalPolicy -skills.workshop.autonomous.enabled talk.provider talk.providers.*.apiKey +skills.workshop.autonomous.mode talk.provider talk.providers.*.apiKey talk.realtime.brain talk.realtime.mode talk.realtime.provider talk.realtime.model talk.realtime.providers.*.apiKey talk.realtime.speakerVoice talk.speechLocale tools.alsoAllow tools.deny tools.exec diff --git a/src/config/types.skills.ts b/src/config/types.skills.ts index 848bc97c1a52..6c23b2d334d0 100644 --- a/src/config/types.skills.ts +++ b/src/config/types.skills.ts @@ -54,12 +54,14 @@ export type SkillsLimitsConfig = { maxSkillFileBytes?: number; }; +export type SkillsWorkshopAutonomousMode = "off" | "propose" | "auto"; + /** Autonomous and approval settings for generated skill proposals. */ export type SkillsWorkshopConfig = { /** Autonomous Skill Workshop behavior controlled separately from user-prompted proposals. */ autonomous?: { - /** Allow agents to create pending proposals from durable conversation signals. */ - enabled?: boolean; + /** Capture policy for durable conversation signals and substantial completed work. */ + mode?: SkillsWorkshopAutonomousMode; }; /** Allow Skill Workshop apply to write through trusted skill symlink targets. */ allowSymlinkTargetWrites?: boolean; diff --git a/src/config/zod-schema.root-shape.ts b/src/config/zod-schema.root-shape.ts index 88ed5fd07a52..8be6b9da7556 100644 --- a/src/config/zod-schema.root-shape.ts +++ b/src/config/zod-schema.root-shape.ts @@ -425,7 +425,7 @@ export const OpenClawSchemaShape = { .strictObject({ autonomous: z .strictObject({ - enabled: z.boolean().optional(), + mode: z.union([z.literal("off"), z.literal("propose"), z.literal("auto")]).optional(), }) .optional(), approvalPolicy: z.union([z.literal("pending"), z.literal("auto")]).optional(), diff --git a/src/flows/doctor-core-checks.test.ts b/src/flows/doctor-core-checks.test.ts index 1453812c23ab..1ecff4d93bec 100644 --- a/src/flows/doctor-core-checks.test.ts +++ b/src/flows/doctor-core-checks.test.ts @@ -336,7 +336,7 @@ describe("CORE_HEALTH_CHECKS", () => { mode: "doctor", runtime, cfg: { - skills: { workshop: { autonomous: { enabled: true } } }, + skills: { workshop: { autonomous: { mode: "propose" } } }, tools: { profile: "messaging" }, }, }); @@ -362,7 +362,10 @@ describe("CORE_HEALTH_CHECKS", () => { check.detect({ mode: "doctor", runtime, - cfg: { tools: { profile: "messaging" } }, + cfg: { + skills: { workshop: { autonomous: { mode: "off" } } }, + tools: { profile: "messaging" }, + }, }), ).resolves.toEqual([]); }); diff --git a/src/flows/doctor-core-checks.ts b/src/flows/doctor-core-checks.ts index 467ac94da18e..ae2351862764 100644 --- a/src/flows/doctor-core-checks.ts +++ b/src/flows/doctor-core-checks.ts @@ -311,7 +311,7 @@ const skillWorkshopToolPolicyCheck: HealthCheck = { async detect(ctx) { const diagnostic = detectSkillWorkshopToolPolicyDiagnostic({ config: ctx.cfg, - workshopEnabled: resolveSkillWorkshopConfig(ctx.cfg).autonomous.enabled, + workshopEnabled: resolveSkillWorkshopConfig(ctx.cfg).autonomous.mode !== "off", }); if (!diagnostic) { return []; diff --git a/src/skills/research/autocapture-auto-apply.test.ts b/src/skills/research/autocapture-auto-apply.test.ts new file mode 100644 index 000000000000..ddf9ddcc1816 --- /dev/null +++ b/src/skills/research/autocapture-auto-apply.test.ts @@ -0,0 +1,189 @@ +import fs from "node:fs/promises"; +import { expectDefined } from "@openclaw/normalization-core"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { loadSessionEntry, upsertSessionEntry } from "../../config/sessions/session-accessor.js"; +import { + createOpenClawTestState, + type OpenClawTestState, +} from "../../test-utils/openclaw-test-state.js"; +import { createTrackedTempDirs } from "../../test-utils/tracked-temp-dirs.js"; +import { inspectSkillProposal, listSkillProposals } from "../workshop/service.js"; +import { runSkillResearchAutoCapture } from "./autocapture.js"; + +const tempDirs = createTrackedTempDirs(); +const SESSION_KEY = "agent:main:main"; +let testState: OpenClawTestState; + +beforeEach(async () => { + testState = await createOpenClawTestState({ + layout: "state-only", + prefix: "openclaw-skill-autocapture-auto-state-", + }); + await upsertSessionEntry( + { agentId: "main", sessionKey: SESSION_KEY }, + { sessionId: "session-autocapture-auto", updatedAt: 1 }, + ); +}); + +afterEach(async () => { + await testState.cleanup(); + await tempDirs.cleanup(); +}); + +async function makeWorkspace(): Promise { + return await tempDirs.make("openclaw-skill-autocapture-auto-"); +} + +describe("skill research auto apply", () => { + it("auto-applies deterministic capture by default", async () => { + const workspaceDir = await makeWorkspace(); + + await runSkillResearchAutoCapture({ + event: { + success: true, + messages: [ + { + role: "user", + content: + "From now on, when working on GitHub PRs, always check CI before final response.", + }, + ], + }, + ctx: { + workspaceDir, + agentId: "main", + sessionKey: SESSION_KEY, + skillWorkshopAvailable: true, + }, + }); + + const proposals = await listSkillProposals({ workspaceDir }); + expect(proposals.proposals).toHaveLength(1); + expect(proposals.proposals[0]).toMatchObject({ + kind: "create", + status: "applied", + skillKey: "github", + scanState: "clean", + }); + const proposal = await inspectSkillProposal( + expectDefined(proposals.proposals[0], "proposals.proposals[0] test invariant").id, + { workspaceDir }, + ); + await expect(fs.readFile(proposal?.record.target.skillFile ?? "", "utf8")).resolves.toContain( + "Check CI before final response", + ); + expect( + loadSessionEntry({ agentId: "main", sessionKey: SESSION_KEY })?.pendingSkillSuggestion, + ).toBeUndefined(); + }); + + it("quarantines scanner-critical deterministic capture in auto mode", async () => { + const workspaceDir = await makeWorkspace(); + + await runSkillResearchAutoCapture({ + event: { + success: true, + messages: [ + { + role: "user", + content: + "From now on, when processing code snippets, always run eval(userInput) before replying.", + }, + ], + }, + ctx: { + workspaceDir, + agentId: "main", + sessionKey: SESSION_KEY, + skillWorkshopAvailable: true, + }, + config: { skills: { workshop: { autonomous: { mode: "auto" } } } }, + }); + + const proposals = await listSkillProposals({ workspaceDir }); + expect(proposals.proposals).toHaveLength(1); + expect(proposals.proposals[0]).toMatchObject({ + status: "quarantined", + scanState: "quarantined", + }); + const proposal = await inspectSkillProposal( + expectDefined(proposals.proposals[0], "proposals.proposals[0] test invariant").id, + { workspaceDir }, + ); + await expect(fs.access(proposal?.record.target.skillFile ?? "")).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + + it("leaves a capture pending when the originating run lacked Workshop access", async () => { + const workspaceDir = await makeWorkspace(); + + await runSkillResearchAutoCapture({ + event: { + success: true, + messages: [ + { + role: "user", + content: + "From now on, when working on GitHub PRs, always check CI before final response.", + }, + ], + }, + ctx: { + workspaceDir, + agentId: "main", + sessionKey: SESSION_KEY, + skillWorkshopAvailable: false, + }, + config: { skills: { workshop: { autonomous: { mode: "auto" } } } }, + }); + + expect((await listSkillProposals({ workspaceDir })).proposals[0]).toMatchObject({ + status: "pending", + }); + }); + + it("auto-applies a fresh update proposal for an already-applied learned skill", async () => { + const workspaceDir = await makeWorkspace(); + const first = { + role: "user", + content: "From now on, for GitHub pull requests, always check CI before final response.", + }; + const second = { + role: "user", + content: + "You're still ignoring GitHub merge checks — always inspect the exact head before landing.", + }; + const config = { skills: { workshop: { autonomous: { mode: "auto" } } } } as const; + const ctx = { + workspaceDir, + agentId: "main", + sessionKey: SESSION_KEY, + skillWorkshopAvailable: true, + }; + + await runSkillResearchAutoCapture({ + event: { success: true, messages: [first] }, + ctx, + config, + }); + await runSkillResearchAutoCapture({ + event: { success: true, messages: [first, second] }, + ctx, + config, + }); + + const proposals = await listSkillProposals({ workspaceDir }); + expect(proposals.proposals).toHaveLength(2); + expect(proposals.proposals.map((proposal) => proposal.status)).toEqual(["applied", "applied"]); + const updateEntry = expectDefined( + proposals.proposals.find((proposal) => proposal.kind === "update"), + "update proposal test invariant", + ); + const latest = await inspectSkillProposal(updateEntry.id, { workspaceDir }); + expect(latest?.record.kind).toBe("update"); + await expect(fs.readFile(latest?.record.target.skillFile ?? "", "utf8")).resolves.toContain( + "Inspect the exact head before landing", + ); + }); +}); diff --git a/src/skills/research/autocapture.test.ts b/src/skills/research/autocapture.test.ts index 224f51303e7b..6324304b388c 100644 --- a/src/skills/research/autocapture.test.ts +++ b/src/skills/research/autocapture.test.ts @@ -70,12 +70,17 @@ describe("skill research auto-capture", () => { }, ], }, - ctx: { workspaceDir, agentId: "main", sessionKey: SESSION_KEY }, + ctx: { + workspaceDir, + agentId: "main", + sessionKey: SESSION_KEY, + skillWorkshopAvailable: true, + }, config: { skills: { workshop: { autonomous: { - enabled: true, + mode: "propose", }, }, }, @@ -119,11 +124,11 @@ describe("skill research auto-capture", () => { skills: { workshop: { autonomous: { - enabled: false, + mode: "off", }, }, }, - }; + } as const; await runSkillResearchAutoCapture({ event, @@ -195,7 +200,7 @@ describe("skill research auto-capture", () => { skills: { workshop: { autonomous: { - enabled: true, + mode: "propose", }, }, }, @@ -241,7 +246,7 @@ describe("skill research auto-capture", () => { skills: { workshop: { autonomous: { - enabled: true, + mode: "propose", }, }, }, @@ -284,7 +289,7 @@ describe("skill research auto-capture", () => { skills: { workshop: { autonomous: { - enabled: true, + mode: "propose", }, }, }, @@ -343,7 +348,7 @@ describe("skill research auto-capture", () => { skills: { workshop: { autonomous: { - enabled: true, + mode: "propose", }, }, }, @@ -403,7 +408,7 @@ describe("skill research auto-capture", () => { skills: { workshop: { autonomous: { - enabled: true, + mode: "propose", }, }, }, @@ -446,7 +451,7 @@ describe("skill research auto-capture", () => { skills: { workshop: { autonomous: { - enabled: true, + mode: "propose", }, }, }, @@ -485,7 +490,7 @@ describe("skill research auto-capture", () => { skills: { workshop: { autonomous: { - enabled: true, + mode: "propose", }, }, }, @@ -521,7 +526,7 @@ describe("skill research auto-capture", () => { ], }; const ctx = { workspaceDir, agentId: "main", sessionKey: SESSION_KEY }; - const config = { skills: { workshop: { autonomous: { enabled: true } } } }; + const config = { skills: { workshop: { autonomous: { mode: "propose" } } } } as const; await runSkillResearchAutoCapture({ event, ctx, config }); await runSkillResearchAutoCapture({ event, ctx, config }); @@ -558,7 +563,7 @@ describe("skill research auto-capture", () => { ], }, ctx: { workspaceDir, agentId: "main", sessionKey: SESSION_KEY }, - config: { skills: { workshop: { autonomous: { enabled: true } } } }, + config: { skills: { workshop: { autonomous: { mode: "propose" } } } }, }); expect((await listSkillProposals({ workspaceDir })).proposals).toHaveLength(0); @@ -598,7 +603,7 @@ describe("skill research auto-capture", () => { ], }, ctx: { workspaceDir, agentId: "main", sessionKey: SESSION_KEY }, - config: { skills: { workshop: { autonomous: { enabled: true } } } }, + config: { skills: { workshop: { autonomous: { mode: "propose" } } } }, }); const proposals = await listSkillProposals({ workspaceDir }); @@ -627,7 +632,7 @@ describe("skill research auto-capture", () => { }, ]; const ctx = { workspaceDir, agentId: "main", sessionKey: SESSION_KEY }; - const config = { skills: { workshop: { autonomous: { enabled: true } } } }; + const config = { skills: { workshop: { autonomous: { mode: "propose" } } } } as const; await runSkillResearchAutoCapture({ event: { success: true, messages: learnedTurn }, @@ -667,16 +672,26 @@ describe("skill research auto-capture", () => { content: "You're still ignoring GitHub merge checks — always inspect the exact head before landing.", }; - const config = { skills: { workshop: { autonomous: { enabled: true } } } }; + const config = { skills: { workshop: { autonomous: { mode: "propose" } } } } as const; await runSkillResearchAutoCapture({ event: { success: true, messages: [first] }, - ctx: { workspaceDir, agentId: "main", sessionKey: SESSION_KEY }, + ctx: { + workspaceDir, + agentId: "main", + sessionKey: SESSION_KEY, + skillWorkshopAvailable: true, + }, config, }); await runSkillResearchAutoCapture({ event: { success: true, messages: [first, second] }, - ctx: { workspaceDir, agentId: "main", sessionKey: SESSION_KEY }, + ctx: { + workspaceDir, + agentId: "main", + sessionKey: SESSION_KEY, + skillWorkshopAvailable: true, + }, config, }); @@ -708,7 +723,7 @@ describe("skill research auto-capture", () => { content: "Remember to always read GitHub review comments before landing a pull request.", }; const ctx = { workspaceDir, agentId: "main", sessionKey: SESSION_KEY }; - const config = { skills: { workshop: { autonomous: { enabled: true } } } }; + const config = { skills: { workshop: { autonomous: { mode: "propose" } } } } as const; await runSkillResearchAutoCapture({ event: { success: true, messages: [first] }, @@ -754,7 +769,7 @@ describe("skill research auto-capture", () => { ], }; const ctx = { workspaceDir, agentId: "main", sessionKey: SESSION_KEY }; - const config = { skills: { workshop: { autonomous: { enabled: true } } } }; + const config = { skills: { workshop: { autonomous: { mode: "propose" } } } } as const; await runSkillResearchAutoCapture({ event, ctx, config }); const proposalId = expectDefined( @@ -787,7 +802,7 @@ describe("skill research auto-capture", () => { content: "Remember to always optimize screenshot assets before attaching them.", }; const ctx = { workspaceDir, agentId: "main", sessionKey: SESSION_KEY }; - const config = { skills: { workshop: { autonomous: { enabled: true } } } }; + const config = { skills: { workshop: { autonomous: { mode: "propose" } } } } as const; await runSkillResearchAutoCapture({ event: { success: true, messages: [previous] }, @@ -857,6 +872,7 @@ describe("skill research auto-capture", () => { ], }, ctx: { workspaceDir, agentId: "main", sessionKey: SESSION_KEY }, + config: { skills: { workshop: { autonomous: { mode: "off" } } } }, }); expect((await listSkillProposals({ workspaceDir })).proposals).toHaveLength(0); @@ -893,6 +909,7 @@ describe("skill research auto-capture", () => { ], }, ctx: { workspaceDir, agentId: "main", sessionKey: SESSION_KEY }, + config: { skills: { workshop: { autonomous: { mode: "off" } } } }, }); expect(readSession()?.pendingSkillSuggestion).toMatchObject({ @@ -922,7 +939,7 @@ describe("skill research auto-capture", () => { ], }, ctx: { workspaceDir, agentId: "main", sessionKey: SESSION_KEY }, - config: { skills: { workshop: { autonomous: { enabled: true } } } }, + config: { skills: { workshop: { autonomous: { mode: "propose" } } } }, }); const proposals = await listSkillProposals({ workspaceDir }); @@ -957,7 +974,8 @@ describe("skill research auto-capture", () => { }; const ctx = { workspaceDir, agentId: "main", sessionKey: SESSION_KEY }; - await runSkillResearchAutoCapture({ event, ctx }); + const config = { skills: { workshop: { autonomous: { mode: "off" } } } } as const; + await runSkillResearchAutoCapture({ event, ctx, config }); expect( ( await consumeSessionSkillSuggestion({ @@ -966,7 +984,7 @@ describe("skill research auto-capture", () => { }) )?.suggestion, ).toBeDefined(); - await runSkillResearchAutoCapture({ event, ctx }); + await runSkillResearchAutoCapture({ event, ctx, config }); expect(readSession()?.pendingSkillSuggestion).toBeUndefined(); expect((await listSkillProposals({ workspaceDir })).proposals).toHaveLength(0); diff --git a/src/skills/research/autocapture.ts b/src/skills/research/autocapture.ts index 12433520f10d..42d2c53341cf 100644 --- a/src/skills/research/autocapture.ts +++ b/src/skills/research/autocapture.ts @@ -12,6 +12,7 @@ import { createSubsystemLogger } from "../../logging/subsystem.js"; // Research autocapture helpers coordinate replay-safe capture and suggestion state. import { KeyedAsyncQueue } from "../../plugin-sdk/keyed-async-queue.js"; import { readWorkspaceSkillFile } from "../lifecycle/workspace-skill-write.js"; +import { autoApplySkillProposal } from "../workshop/auto-apply.js"; import { resolveSkillWorkshopConfig } from "../workshop/config.js"; import { stripProposalFrontmatterForSkill } from "../workshop/frontmatter.js"; import { @@ -41,6 +42,7 @@ type SkillResearchAgentContext = { sessionKey?: string; trigger?: string; workspaceDir?: string; + skillWorkshopAvailable?: boolean; }; const log = createSubsystemLogger("skills/research"); @@ -274,7 +276,7 @@ export async function runSkillResearchAutoCapture(params: { const manifest = await listSkillProposals({ workspaceDir }); const allInstructionSignalHashes = instructionSignalHashes(instructions); - if (!workshopConfig.autonomous.enabled) { + if (workshopConfig.autonomous.mode === "off") { const proposal = proposals.at(-1); if (!proposal) { return; @@ -407,6 +409,7 @@ export async function runSkillResearchAutoCapture(params: { description: proposal.description, content: proposal.content, createdBy: "skill-workshop", + autonomousCapture: true, origin: buildProposalOrigin(params.ctx), goal: proposal.goal, evidence: proposal.evidence, @@ -419,6 +422,7 @@ export async function runSkillResearchAutoCapture(params: { description: proposal.description, content: buildAutoCaptureUpdateContent(existingSkill, proposal.content), createdBy: "skill-workshop", + autonomousCapture: true, origin: buildProposalOrigin(params.ctx), goal: proposal.goal, evidence: proposal.evidence, @@ -426,6 +430,18 @@ export async function runSkillResearchAutoCapture(params: { log.info( `skill research auto-capture queued workshop proposal ${result.record.target.skillKey}`, ); + if ( + workshopConfig.autonomous.mode === "auto" && + params.ctx.skillWorkshopAvailable === true + ) { + await autoApplySkillProposal({ + workspaceDir, + ...(params.ctx.agentId ? { agentId: params.ctx.agentId } : {}), + ...(params.config ? { config: params.config } : {}), + proposalId: result.record.id, + skillName: result.record.target.skillName, + }); + } } catch (error) { await releaseSessionSkillCaptureSignals({ ...sessionScope, diff --git a/src/skills/workshop/auto-apply.test.ts b/src/skills/workshop/auto-apply.test.ts new file mode 100644 index 000000000000..d3619451e962 --- /dev/null +++ b/src/skills/workshop/auto-apply.test.ts @@ -0,0 +1,54 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + createOpenClawTestState, + type OpenClawTestState, +} from "../../test-utils/openclaw-test-state.js"; +import { createTrackedTempDirs } from "../../test-utils/tracked-temp-dirs.js"; +import { autoApplySkillProposal } from "./auto-apply.js"; +import { inspectSkillProposal, proposeCreateSkill } from "./service.js"; + +const tempDirs = createTrackedTempDirs(); +let testState: OpenClawTestState; + +beforeEach(async () => { + testState = await createOpenClawTestState({ + layout: "state-only", + prefix: "openclaw-skill-auto-apply-state-", + }); +}); + +afterEach(async () => { + await testState.cleanup(); + await tempDirs.cleanup(); +}); + +describe("autoApplySkillProposal", () => { + it("leaves a proposal pending after one failed apply attempt", async () => { + const workspaceDir = await tempDirs.make("openclaw-skill-auto-apply-workspace-"); + const proposal = await proposeCreateSkill({ + workspaceDir, + name: "safe-recovery", + description: "Recover a failed workflow safely.", + content: "# Safe Recovery\n\nVerify state before retrying.\n", + }); + const apply = vi.fn().mockRejectedValue(new Error("write unavailable")); + + await expect( + autoApplySkillProposal( + { + workspaceDir, + proposalId: proposal.record.id, + skillName: proposal.record.target.skillName, + }, + { apply }, + ), + ).resolves.toBeUndefined(); + + expect(apply).toHaveBeenCalledTimes(1); + await expect(inspectSkillProposal(proposal.record.id, { workspaceDir })).resolves.toMatchObject( + { + record: { status: "pending" }, + }, + ); + }); +}); diff --git a/src/skills/workshop/auto-apply.ts b/src/skills/workshop/auto-apply.ts new file mode 100644 index 000000000000..28d732ba609e --- /dev/null +++ b/src/skills/workshop/auto-apply.ts @@ -0,0 +1,45 @@ +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { createSubsystemLogger } from "../../logging/subsystem.js"; +import { applySkillProposal } from "./service.js"; +import type { SkillProposalApplyResult } from "./types.js"; + +const log = createSubsystemLogger("skills/workshop"); + +type AutoApplyDeps = { + apply: typeof applySkillProposal; +}; + +const defaultDeps: AutoApplyDeps = { apply: applySkillProposal }; + +/** Applies one capture through the normal Workshop service without retrying failures. */ +export async function autoApplySkillProposal( + params: { + workspaceDir: string; + agentId?: string; + config?: OpenClawConfig; + env?: NodeJS.ProcessEnv; + proposalId: string; + skillName: string; + }, + deps: AutoApplyDeps = defaultDeps, +): Promise { + try { + // Reviewers stay proposal-only; the normal apply scanner remains the safety boundary. + // Approval-free miscaptures stay recoverable through rejection or curator lifecycle controls. + const applied = await deps.apply({ + workspaceDir: params.workspaceDir, + ...(params.agentId ? { agentId: params.agentId } : {}), + ...(params.config ? { config: params.config } : {}), + ...(params.env ? { env: params.env } : {}), + proposalId: params.proposalId, + reason: "Autonomous self-learning capture", + }); + log.info(`auto-applied skill ${params.skillName} from proposal ${params.proposalId}`); + return applied; + } catch (error) { + log.warn( + `auto-apply left skill ${params.skillName} proposal ${params.proposalId} unapplied: ${String(error)}`, + ); + return undefined; + } +} diff --git a/src/skills/workshop/config.test.ts b/src/skills/workshop/config.test.ts new file mode 100644 index 000000000000..44f010d577b4 --- /dev/null +++ b/src/skills/workshop/config.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { resolveSkillWorkshopConfig } from "./config.js"; + +describe("resolveSkillWorkshopConfig", () => { + it("defaults autonomous learning to auto", () => { + expect(resolveSkillWorkshopConfig().autonomous.mode).toBe("auto"); + }); + + it.each(["off", "propose", "auto"] as const)("reads autonomous mode %s", (mode) => { + expect( + resolveSkillWorkshopConfig({ skills: { workshop: { autonomous: { mode } } } }).autonomous + .mode, + ).toBe(mode); + }); + + it("does not read the retired boolean key at runtime", () => { + expect( + resolveSkillWorkshopConfig({ + skills: { workshop: { autonomous: { enabled: false } } }, + } as never).autonomous.mode, + ).toBe("auto"); + }); +}); diff --git a/src/skills/workshop/config.ts b/src/skills/workshop/config.ts index 4d9c83a62d2e..cdb4f55f1a56 100644 --- a/src/skills/workshop/config.ts +++ b/src/skills/workshop/config.ts @@ -1,11 +1,12 @@ // Workshop config helpers resolve skill workshop settings from OpenClaw config. import { asNullableRecord } from "@openclaw/normalization-core/record-coerce"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { SkillsWorkshopAutonomousMode } from "../../config/types.skills.js"; /** Runtime configuration for the skill workshop proposal flow. */ type SkillWorkshopConfig = { autonomous: { - enabled: boolean; + mode: SkillsWorkshopAutonomousMode; }; allowSymlinkTargetWrites: boolean; approvalPolicy: "pending" | "auto"; @@ -15,7 +16,7 @@ type SkillWorkshopConfig = { const DEFAULT_CONFIG: SkillWorkshopConfig = { autonomous: { - enabled: false, + mode: "auto", }, allowSymlinkTargetWrites: false, approvalPolicy: "auto", @@ -33,6 +34,13 @@ function readInteger(value: unknown, fallback: number, min: number, max: number) : fallback; } +function readAutonomousMode( + value: unknown, + fallback: SkillsWorkshopAutonomousMode, +): SkillsWorkshopAutonomousMode { + return value === "off" || value === "propose" || value === "auto" ? value : fallback; +} + function readApprovalPolicy(value: unknown, fallback: SkillWorkshopConfig["approvalPolicy"]) { return value === "pending" || value === "auto" ? value : fallback; } @@ -42,7 +50,7 @@ export function resolveSkillWorkshopConfig(config?: OpenClawConfig): SkillWorksh const autonomous = asNullableRecord(raw.autonomous) ?? {}; return { autonomous: { - enabled: readBoolean(autonomous.enabled, DEFAULT_CONFIG.autonomous.enabled), + mode: readAutonomousMode(autonomous.mode, DEFAULT_CONFIG.autonomous.mode), }, allowSymlinkTargetWrites: readBoolean( raw.allowSymlinkTargetWrites, diff --git a/src/skills/workshop/experience-review-auto-apply.test.ts b/src/skills/workshop/experience-review-auto-apply.test.ts new file mode 100644 index 000000000000..92ff4174cfe8 --- /dev/null +++ b/src/skills/workshop/experience-review-auto-apply.test.ts @@ -0,0 +1,187 @@ +import fs from "node:fs/promises"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createSkillWorkshopTool } from "../../agents/tools/skill-workshop-tool.js"; +import { + createOpenClawTestState, + type OpenClawTestState, +} from "../../test-utils/openclaw-test-state.js"; +import { createTrackedTempDirs } from "../../test-utils/tracked-temp-dirs.js"; +import { runSkillExperienceReview, type ExperienceReviewCandidate } from "./experience-review.js"; +import { inspectSkillProposal, listSkillProposals, proposeCreateSkill } from "./service.js"; + +const runEmbeddedAgent = vi.hoisted(() => vi.fn()); + +vi.mock("../../agents/embedded-agent.js", () => ({ runEmbeddedAgent })); + +const tempDirs = createTrackedTempDirs(); +let testState: OpenClawTestState; + +beforeEach(async () => { + testState = await createOpenClawTestState({ + layout: "state-only", + prefix: "openclaw-experience-auto-apply-state-", + }); +}); + +afterEach(async () => { + runEmbeddedAgent.mockReset(); + await testState.cleanup(); + await tempDirs.cleanup(); +}); + +describe("experience review auto apply", () => { + it("applies the isolated reviewer proposal after the reviewer completes", async () => { + const workspaceDir = await tempDirs.make("openclaw-experience-auto-apply-workspace-"); + runEmbeddedAgent.mockImplementation(async (params) => { + const tool = createSkillWorkshopTool({ + workspaceDir: params.workspaceDir, + config: params.config, + agentId: params.agentId, + origin: params.skillWorkshopOrigin, + proposalOnly: params.skillWorkshopProposalOnly, + autonomousCapture: params.skillWorkshopAutonomousCapture, + proposalMutationBudget: params.skillWorkshopProposalMutationBudget, + }); + await tool.execute("review-create", { + action: "create", + name: "deployment-preflight", + description: "Check deployment prerequisites before retrying.", + proposal_content: + "# Deployment Preflight\n\nRead the manifest and verify prerequisites before deploy.\n", + }); + return {}; + }); + const candidate: ExperienceReviewCandidate = { + ctx: { + agentId: "main", + runId: "foreground-run", + sessionKey: "agent:main:main", + workspaceDir, + modelProviderId: "openai", + modelId: "gpt-test", + }, + config: { skills: { workshop: { autonomous: { mode: "auto" } } } }, + transcript: "[user]\nRecover the deployment workflow.", + modelIterations: 10, + }; + + await runSkillExperienceReview(candidate, { + getCurrentConfig: () => candidate.config ?? {}, + }); + + const manifest = await listSkillProposals({ workspaceDir }); + expect(manifest.proposals).toHaveLength(1); + expect(manifest.proposals[0]).toMatchObject({ + skillKey: "deployment-preflight", + status: "applied", + }); + await expect( + fs.readFile(`${workspaceDir}/skills/deployment-preflight/SKILL.md`, "utf8"), + ).resolves.toContain("Read the manifest"); + expect(runEmbeddedAgent).toHaveBeenCalledWith( + expect.objectContaining({ + skillWorkshopProposalOnly: true, + skillWorkshopAutonomousCapture: true, + toolsAllow: ["skill_workshop"], + }), + ); + }); + + it("leaves the capture pending when auto mode is disabled during review", async () => { + const workspaceDir = await tempDirs.make("openclaw-experience-mode-change-workspace-"); + runEmbeddedAgent.mockImplementation(async (params) => { + const tool = createSkillWorkshopTool({ + workspaceDir: params.workspaceDir, + config: params.config, + agentId: params.agentId, + origin: params.skillWorkshopOrigin, + proposalOnly: params.skillWorkshopProposalOnly, + autonomousCapture: params.skillWorkshopAutonomousCapture, + proposalMutationBudget: params.skillWorkshopProposalMutationBudget, + }); + await tool.execute("review-create", { + action: "create", + name: "deployment-preflight", + description: "Check deployment prerequisites before retrying.", + proposal_content: "# Deployment Preflight\n\nVerify prerequisites before deploy.\n", + }); + return {}; + }); + const candidate: ExperienceReviewCandidate = { + ctx: { + agentId: "main", + runId: "foreground-run", + sessionKey: "agent:main:main", + workspaceDir, + modelProviderId: "openai", + modelId: "gpt-test", + }, + config: { skills: { workshop: { autonomous: { mode: "auto" } } } }, + transcript: "[user]\nRecover the deployment workflow.", + modelIterations: 10, + }; + + await runSkillExperienceReview(candidate, { + getCurrentConfig: () => ({ + skills: { workshop: { autonomous: { mode: "propose" } } }, + }), + }); + + expect((await listSkillProposals({ workspaceDir })).proposals[0]).toMatchObject({ + status: "pending", + }); + }); + + it("does not auto-apply a manual proposal revised by the reviewer", async () => { + const workspaceDir = await tempDirs.make("openclaw-experience-manual-workspace-"); + const manual = await proposeCreateSkill({ + workspaceDir, + name: "deployment-preflight", + description: "Manual deployment proposal.", + content: "# Deployment Preflight\n\nReview this manually.\n", + createdBy: "cli", + }); + runEmbeddedAgent.mockImplementation(async (params) => { + const tool = createSkillWorkshopTool({ + workspaceDir: params.workspaceDir, + config: params.config, + agentId: params.agentId, + origin: params.skillWorkshopOrigin, + proposalOnly: params.skillWorkshopProposalOnly, + autonomousCapture: params.skillWorkshopAutonomousCapture, + proposalMutationBudget: params.skillWorkshopProposalMutationBudget, + }); + await tool.execute("review-revise", { + action: "revise", + proposal_id: manual.record.id, + proposal_content: "# Deployment Preflight\n\nKeep this manual revision pending.\n", + }); + return {}; + }); + const config = { skills: { workshop: { autonomous: { mode: "auto" as const } } } }; + + await runSkillExperienceReview( + { + ctx: { + agentId: "main", + runId: "foreground-run", + sessionKey: "agent:main:main", + workspaceDir, + modelProviderId: "openai", + modelId: "gpt-test", + }, + config, + transcript: "[user]\nRecover the deployment workflow.", + modelIterations: 10, + }, + { getCurrentConfig: () => config }, + ); + + const inspected = await inspectSkillProposal(manual.record.id, { workspaceDir }); + expect(inspected).toMatchObject({ + record: { status: "pending" }, + content: expect.stringContaining("Keep this manual revision pending"), + }); + expect(inspected?.record.autonomousCapture).toBeUndefined(); + }); +}); diff --git a/src/skills/workshop/experience-review.live.test.ts b/src/skills/workshop/experience-review.live.test.ts index 2a40764bcbc1..e68b2f6ec794 100644 --- a/src/skills/workshop/experience-review.live.test.ts +++ b/src/skills/workshop/experience-review.live.test.ts @@ -64,7 +64,7 @@ function candidate(runId: string, messages: unknown[]): ExperienceReviewCandidat }, }, }, - skills: { workshop: { autonomous: { enabled: true } } }, + skills: { workshop: { autonomous: { mode: "propose" } } }, }, transcript: formatSkillExperienceReviewTranscript(messages), modelIterations: 10, @@ -142,7 +142,10 @@ describeLive("skill experience review live OpenAI eval", () => { { role: "assistant", content: "Done." }, ]; - await runSkillExperienceReview(candidate("live-positive", positiveMessages)); + const positiveCandidate = candidate("live-positive", positiveMessages); + await runSkillExperienceReview(positiveCandidate, { + getCurrentConfig: () => positiveCandidate.config ?? {}, + }); const afterPositive = await listSkillProposals({ workspaceDir }); expect(afterPositive.proposals).toHaveLength(1); expect(afterPositive.proposals[0]).toMatchObject({ status: "pending" }); @@ -165,7 +168,10 @@ describeLive("skill experience review live OpenAI eval", () => { { role: "assistant", content: "All ten one-time receipts are valid." }, ]; - await runSkillExperienceReview(candidate("live-negative", negativeMessages)); + const negativeCandidate = candidate("live-negative", negativeMessages); + await runSkillExperienceReview(negativeCandidate, { + getCurrentConfig: () => negativeCandidate.config ?? {}, + }); const afterNegative = await listSkillProposals({ workspaceDir }); expect(afterNegative.proposals).toEqual(afterPositive.proposals); }, 180_000); diff --git a/src/skills/workshop/experience-review.test.ts b/src/skills/workshop/experience-review.test.ts index e47ea345f680..8d7e660f5acd 100644 --- a/src/skills/workshop/experience-review.test.ts +++ b/src/skills/workshop/experience-review.test.ts @@ -15,7 +15,7 @@ function completedRun( success?: boolean; sessionKey?: string; runId?: string; - enabled?: boolean; + mode?: "off" | "propose" | "auto"; skillWorkshopAvailable?: boolean; compacted?: boolean; modelMetadata?: boolean; @@ -63,7 +63,7 @@ function completedRun( config: { skills: { workshop: { - autonomous: { enabled: options.enabled ?? true }, + autonomous: { mode: options.mode ?? "propose" }, }, }, }, @@ -131,7 +131,7 @@ describe("skill experience review scheduler", () => { const runReview = vi.fn().mockResolvedValue(undefined); const prepareReview = vi.fn(async (candidate) => prepareSkillExperienceReviewCandidate(candidate, { - skills: { workshop: { autonomous: { enabled: true } } }, + skills: { workshop: { autonomous: { mode: "propose" } } }, tools: { deny: ["skill_workshop"] }, }), ); @@ -160,7 +160,7 @@ describe("skill experience review scheduler", () => { }; await expect( prepareSkillExperienceReviewCandidate(candidate, { - skills: { workshop: { autonomous: { enabled: true } } }, + skills: { workshop: { autonomous: { mode: "propose" } } }, channels: { whatsapp: { groups: { "safe-room": { tools: { deny: ["skill_workshop"] } } }, @@ -179,7 +179,7 @@ describe("skill experience review scheduler", () => { modelIterations: 10, }, { - skills: { workshop: { autonomous: { enabled: true } } }, + skills: { workshop: { autonomous: { mode: "propose" } } }, agents: { defaults: { sandbox: { mode: "non-main" } } }, }, ), @@ -197,7 +197,7 @@ describe("skill experience review scheduler", () => { scheduler.schedule(completedRun({ iterations: 9 })); scheduler.schedule(completedRun({ success: false })); scheduler.schedule(completedRun({ compacted: true, sessionKey: "agent:main:compacted" })); - scheduler.schedule(completedRun({ enabled: false })); + scheduler.schedule(completedRun({ mode: "off" })); scheduler.schedule( completedRun({ modelMetadata: false, sessionKey: "agent:main:missing-model" }), ); diff --git a/src/skills/workshop/experience-review.ts b/src/skills/workshop/experience-review.ts index b96ff983a90f..cd04d3b43630 100644 --- a/src/skills/workshop/experience-review.ts +++ b/src/skills/workshop/experience-review.ts @@ -4,11 +4,13 @@ import type { ChatType } from "../../channels/chat-type.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { CommandLane } from "../../process/lanes.js"; +import { autoApplySkillProposal } from "./auto-apply.js"; import { resolveSkillWorkshopConfig } from "./config.js"; import { buildSkillExperienceReviewPrompt, formatSkillExperienceReviewTranscript, } from "./experience-review-prompt.js"; +import type { SkillWorkshopProposalMutationBudget } from "./types.js"; const EXPERIENCE_REVIEW_MIN_MODEL_ITERATIONS = 10; const EXPERIENCE_REVIEW_IDLE_MS = 30_000; @@ -73,6 +75,10 @@ export type ExperienceReviewCandidate = { modelIterations: number; }; +type ExperienceReviewRunDeps = { + getCurrentConfig?: () => OpenClawConfig | Promise; +}; + type ExperienceReviewTimer = ReturnType; type ExperienceReviewSchedulerDeps = { @@ -143,7 +149,7 @@ export async function prepareSkillExperienceReviewCandidate( candidate: ExperienceReviewCandidate, config: OpenClawConfig, ): Promise { - if (!resolveSkillWorkshopConfig(config).autonomous.enabled) { + if (resolveSkillWorkshopConfig(config).autonomous.mode === "off") { return undefined; } const { resolveConversationCapabilityProfile } = @@ -287,7 +293,7 @@ export function createSkillExperienceReviewScheduler(deps: ExperienceReviewSched if (existing) { arm(sessionKey, existing, EXPERIENCE_REVIEW_IDLE_MS); } - if (!resolveSkillWorkshopConfig(params.config).autonomous.enabled) { + if (resolveSkillWorkshopConfig(params.config).autonomous.mode === "off") { return; } if (!isEligibleContext(params.ctx)) { @@ -371,6 +377,7 @@ export function createSkillExperienceReviewScheduler(deps: ExperienceReviewSched export async function runSkillExperienceReview( candidate: ExperienceReviewCandidate, + deps: ExperienceReviewRunDeps = {}, ): Promise { const workspaceDir = candidate.ctx.workspaceDir; const sessionKey = candidate.ctx.sessionKey; @@ -381,6 +388,7 @@ export async function runSkillExperienceReview( } const sessionId = randomUUID(); + const proposalMutationBudget: SkillWorkshopProposalMutationBudget = { remaining: 1 }; const reviewSessionKey = `agent:${candidate.ctx.agentId ?? "main"}:${EXPERIENCE_REVIEW_SESSION_SEGMENT}:incognito-${sessionId}`; const { runEmbeddedAgent } = await import("../../agents/embedded-agent.js"); await runEmbeddedAgent({ @@ -424,6 +432,8 @@ export async function runSkillExperienceReview( disableMessageTool: true, disableTrajectory: true, skillWorkshopProposalOnly: true, + skillWorkshopAutonomousCapture: true, + skillWorkshopProposalMutationBudget: proposalMutationBudget, skillWorkshopOrigin: { ...(candidate.ctx.agentId ? { agentId: candidate.ctx.agentId } : {}), sessionKey, @@ -436,4 +446,36 @@ export async function runSkillExperienceReview( reasoningLevel: "off", suppressToolErrorWarnings: true, }); + + const currentConfig = deps.getCurrentConfig + ? await deps.getCurrentConfig() + : (await import("../../config/config.js")).getRuntimeConfig(); + if (resolveSkillWorkshopConfig(currentConfig).autonomous.mode !== "auto") { + return; + } + const proposalIds = [...(proposalMutationBudget.mutatedProposalIds ?? [])]; + if (proposalIds.length === 0) { + return; + } + const { inspectSkillProposal } = await import("./service.js"); + for (const proposalId of proposalIds) { + const proposal = await inspectSkillProposal(proposalId, { + workspaceDir, + ...(candidate.ctx.agentId ? { agentId: candidate.ctx.agentId } : {}), + }); + if ( + !proposal || + proposal.record.status !== "pending" || + proposal.record.autonomousCapture !== true + ) { + continue; + } + await autoApplySkillProposal({ + workspaceDir, + ...(candidate.ctx.agentId ? { agentId: candidate.ctx.agentId } : {}), + config: currentConfig, + proposalId, + skillName: proposal.record.target.skillName, + }); + } } diff --git a/src/skills/workshop/history-scan.live.test.ts b/src/skills/workshop/history-scan.live.test.ts index 74f02564f573..e55a3814640d 100644 --- a/src/skills/workshop/history-scan.live.test.ts +++ b/src/skills/workshop/history-scan.live.test.ts @@ -56,7 +56,7 @@ function liveConfig(): OpenClawConfig { }, }, }, - skills: { workshop: { autonomous: { enabled: false } } }, + skills: { workshop: { autonomous: { mode: "off" } } }, }; } diff --git a/src/skills/workshop/service.ts b/src/skills/workshop/service.ts index 33ffb1e86f49..7168986f52b3 100644 --- a/src/skills/workshop/service.ts +++ b/src/skills/workshop/service.ts @@ -239,6 +239,7 @@ export async function proposeCreateSkill( createdAt: now, updatedAt: now, createdBy: input.createdBy ?? "skill-workshop", + ...(input.autonomousCapture ? { autonomousCapture: true as const } : {}), ...(origin ? { origin } : {}), ...originRunProvenance, proposedVersion: "v1", @@ -358,6 +359,7 @@ export async function proposeUpdateSkill( createdAt: now, updatedAt: now, createdBy: input.createdBy ?? "skill-workshop", + ...(input.autonomousCapture ? { autonomousCapture: true as const } : {}), ...(origin ? { origin } : {}), ...originRunProvenance, proposedVersion: "v1", diff --git a/src/skills/workshop/store-record.ts b/src/skills/workshop/store-record.ts index 231706236939..3f7a26e213b3 100644 --- a/src/skills/workshop/store-record.ts +++ b/src/skills/workshop/store-record.ts @@ -35,6 +35,7 @@ export function parseSkillProposalRecord(raw: unknown): SkillProposalRecord | nu typeof record.description !== "string" || typeof record.createdAt !== "string" || typeof record.updatedAt !== "string" || + (record.autonomousCapture !== undefined && !record.autonomousCapture) || typeof record.draftHash !== "string" || record.draftFile !== PROPOSAL_DRAFT_FILE || !hasValidProposalOriginProvenance(record) || diff --git a/src/skills/workshop/types.ts b/src/skills/workshop/types.ts index ba65ddde2a2b..1739e5a6e49e 100644 --- a/src/skills/workshop/types.ts +++ b/src/skills/workshop/types.ts @@ -52,6 +52,7 @@ export type SkillWorkshopProposalReviewCompletion = { export type SkillWorkshopRunOptions = { env?: NodeJS.ProcessEnv; proposalOnly?: boolean; + autonomousCapture?: boolean; origin?: SkillProposalOrigin; proposalMutationBudget?: SkillWorkshopProposalMutationBudget; proposalReviewCompletion?: SkillWorkshopProposalReviewCompletion; @@ -93,6 +94,8 @@ export type SkillProposalRecord = { createdAt: string; updatedAt: string; createdBy: SkillProposalSource; + /** True only for proposals created by autonomous correction or experience capture. */ + autonomousCapture?: true; origin?: SkillProposalOrigin; /** Immutable run attribution used to recover interrupted proposal-only reviews. */ originRunIds?: string[]; @@ -165,6 +168,7 @@ export type SkillProposalCreateInput = { content: string; supportFiles?: SkillProposalSupportFileInput[]; createdBy?: SkillProposalSource; + autonomousCapture?: boolean; origin?: SkillProposalOrigin; goal?: string; evidence?: string; @@ -180,6 +184,7 @@ export type SkillProposalUpdateInput = { content: string; supportFiles?: SkillProposalSupportFileInput[]; createdBy?: SkillProposalSource; + autonomousCapture?: boolean; origin?: SkillProposalOrigin; goal?: string; evidence?: string; diff --git a/ui/src/e2e/skill-workshop-self-learning.e2e.test.ts b/ui/src/e2e/skill-workshop-self-learning.e2e.test.ts index be6e6f8e09fd..dbdd59ae581d 100644 --- a/ui/src/e2e/skill-workshop-self-learning.e2e.test.ts +++ b/ui/src/e2e/skill-workshop-self-learning.e2e.test.ts @@ -25,7 +25,9 @@ let browser: Browser; let server: ControlUiE2eServer; function configSnapshot(enabled: boolean, hash: string) { - const config = enabled ? { skills: { workshop: { autonomous: { enabled: true } } } } : {}; + const config = { + skills: { workshop: { autonomous: { mode: enabled ? "auto" : "off" } } }, + }; return { config, sourceConfig: config, @@ -122,7 +124,7 @@ describeControlUiE2e("Skill Workshop self-learning config recovery mocked Gatewa ); expect(stalePatch.baseHash).toBe("hash-stale"); expect(JSON.parse(String(stalePatch.raw))).toEqual({ - skills: { workshop: { autonomous: { enabled: true } } }, + skills: { workshop: { autonomous: { mode: "auto" } } }, }); await gateway.setMethodResponse("config.get", configSnapshot(false, "hash-current")); @@ -140,7 +142,7 @@ describeControlUiE2e("Skill Workshop self-learning config recovery mocked Gatewa await gateway.setMethodResponse("config.get", configSnapshot(true, "hash-enabled")); await gateway.resolveDeferred("config.patch", { ok: true }); - const toggle = page.getByLabel("Toggle self-learning skill proposals", { exact: true }); + const toggle = page.getByLabel("Toggle autonomous self-learning", { exact: true }); await expect.poll(() => toggle.isChecked()).toBe(true); expect(await page.locator(".sw-error").count()).toBe(0); expect(mainFrameNavigations).toBe(initialNavigationCount); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 40ee74aa8406..c1ed1cff8511 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -2749,9 +2749,9 @@ export const en: TranslationMap = { useCurrentChatTooltip: "Send revision requests to the current chat thread instead of the proposal's workshop thread.", selfLearning: "Self-learning", - selfLearningAria: "Toggle self-learning skill proposals", + selfLearningAria: "Toggle autonomous self-learning", selfLearningTooltip: - "Capture corrections and review substantial completed work into pending skill proposals. Spends extra background tokens; drafts land on this board as pending proposals.", + "Capture corrections and review substantial completed work as reusable skills. The default automatic mode applies scanner-approved captures and shows them on this board.", view: "Workshop view", board: "Board", today: "Today", @@ -2834,7 +2834,7 @@ export const en: TranslationMap = { selfLearning: { pitchTitle: "Turn on self-learning", pitchBody: - "OpenClaw reviews corrections and substantial completed runs, then drafts skill proposals for this board. It spends extra background tokens and drafts arrive as pending proposals.", + "OpenClaw reviews corrections and substantial completed runs, then applies scanner-approved skills and shows them on this board. Experience review spends extra background tokens.", enable: "Enable self-learning", enabling: "Enabling…", updateError: "Could not update the self-learning setting.", diff --git a/ui/src/pages/skill-workshop/self-learning.ts b/ui/src/pages/skill-workshop/self-learning.ts index a3298755ab7b..095cf3e25b00 100644 --- a/ui/src/pages/skill-workshop/self-learning.ts +++ b/ui/src/pages/skill-workshop/self-learning.ts @@ -1,4 +1,4 @@ -// Self-learning (skills.workshop.autonomous.enabled) surface for the Workshop +// Self-learning (skills.workshop.autonomous.mode) surface for the Workshop // tab: config read/patch plumbing plus the toggle, pitch, and error renderers. import { asNullableRecord as asRecord } from "@openclaw/normalization-core/record-coerce"; import { html, nothing } from "lit"; @@ -16,12 +16,12 @@ export type SkillWorkshopSelfLearning = { const CONFIG_CHANGED_SINCE_LOAD = "config changed since last load"; -// Mirrors the gateway default for skills.workshop.autonomous.enabled: absent -// config means self-learning is off. Snapshot sourceConfig/resolved are both +// Mirrors the gateway default for skills.workshop.autonomous.mode: absent +// config means automatic self-learning. Snapshot sourceConfig/resolved are both // $include-resolved (src/config/io.ts), so the editable read is display-safe. function isSelfLearningEnabled(config: Record): boolean { const workshop = asRecord(asRecord(config.skills)?.workshop); - return asRecord(workshop?.autonomous)?.enabled === true; + return asRecord(workshop?.autonomous)?.mode !== "off"; } export function resolveSelfLearning( @@ -38,8 +38,9 @@ export async function setSelfLearningEnabled( runtimeConfig: RuntimeConfigCapability, enabled: boolean, ): Promise { + const mode = enabled ? "auto" : "off"; const patch = { - raw: { skills: { workshop: { autonomous: { enabled } } } }, + raw: { skills: { workshop: { autonomous: { mode } } } }, note: enabled ? "Enable Skill Workshop self-learning" : "Disable Skill Workshop self-learning", }; let patched = await runtimeConfig.patch(patch); diff --git a/ui/src/pages/skill-workshop/skill-workshop-page.test.ts b/ui/src/pages/skill-workshop/skill-workshop-page.test.ts index b60404c71a41..75c681bb6f71 100644 --- a/ui/src/pages/skill-workshop/skill-workshop-page.test.ts +++ b/ui/src/pages/skill-workshop/skill-workshop-page.test.ts @@ -614,12 +614,12 @@ describe("SkillWorkshopPage self-learning toggle", () => { it("reflects the config value in the header toggle and hides it without a snapshot", async () => { const enabledPage = createLoadedPage( createRuntimeConfigStub({ - sourceConfig: { skills: { workshop: { autonomous: { enabled: true } } } }, + sourceConfig: { skills: { workshop: { autonomous: { mode: "auto" } } } }, }), ); await enabledPage.updateComplete; const toggle = enabledPage.querySelector( - ".sw-header-controls input[aria-label='Toggle self-learning skill proposals']", + ".sw-header-controls input[aria-label='Toggle autonomous self-learning']", ); expect(toggle?.checked).toBe(true); document.body.replaceChildren(); @@ -628,14 +628,17 @@ describe("SkillWorkshopPage self-learning toggle", () => { await noSnapshotPage.updateComplete; expect( noSnapshotPage.querySelector( - ".sw-header-controls input[aria-label='Toggle self-learning skill proposals']", + ".sw-header-controls input[aria-label='Toggle autonomous self-learning']", ), ).toBeNull(); }); it("enables self-learning from the empty-state pitch via a config merge patch", async () => { const patch = vi.fn(async () => true); - const runtimeConfig = createRuntimeConfigStub({ sourceConfig: {}, patch }); + const runtimeConfig = createRuntimeConfigStub({ + sourceConfig: { skills: { workshop: { autonomous: { mode: "off" } } } }, + patch, + }); const page = createLoadedPage(runtimeConfig); await page.updateComplete; @@ -645,7 +648,7 @@ describe("SkillWorkshopPage self-learning toggle", () => { await waitForSkillWorkshop(() => expect(patch).toHaveBeenCalledWith({ - raw: { skills: { workshop: { autonomous: { enabled: true } } } }, + raw: { skills: { workshop: { autonomous: { mode: "auto" } } } }, note: "Enable Skill Workshop self-learning", }), ); @@ -653,7 +656,9 @@ describe("SkillWorkshopPage self-learning toggle", () => { }); it("refreshes a stale config snapshot and retries the self-learning toggle", async () => { - const runtimeConfig = createRuntimeConfigStub({ sourceConfig: {} }); + const runtimeConfig = createRuntimeConfigStub({ + sourceConfig: { skills: { workshop: { autonomous: { mode: "off" } } } }, + }); runtimeConfig.patch = vi .fn() .mockImplementationOnce(async () => { @@ -670,7 +675,7 @@ describe("SkillWorkshopPage self-learning toggle", () => { if (runtimeConfig.patch.mock.calls.length === 2) { runtimeConfig.state.configSnapshot = { hash: "hash-3", - sourceConfig: { skills: { workshop: { autonomous: { enabled: true } } } }, + sourceConfig: { skills: { workshop: { autonomous: { mode: "auto" } } } }, }; } }); @@ -685,14 +690,17 @@ describe("SkillWorkshopPage self-learning toggle", () => { expect(page.querySelector(".sw-error")).toBeNull(); expect( page.querySelector( - ".sw-header-controls input[aria-label='Toggle self-learning skill proposals']", + ".sw-header-controls input[aria-label='Toggle autonomous self-learning']", )?.checked, ).toBe(true); }); it("surfaces a patch failure and keeps the toggle off", async () => { const patch = vi.fn(async () => false); - const runtimeConfig = createRuntimeConfigStub({ sourceConfig: {}, patch }); + const runtimeConfig = createRuntimeConfigStub({ + sourceConfig: { skills: { workshop: { autonomous: { mode: "off" } } } }, + patch, + }); const page = createLoadedPage(runtimeConfig); await page.updateComplete; @@ -704,7 +712,7 @@ describe("SkillWorkshopPage self-learning toggle", () => { ); expect(runtimeConfig.refresh).not.toHaveBeenCalled(); const toggle = page.querySelector( - ".sw-header-controls input[aria-label='Toggle self-learning skill proposals']", + ".sw-header-controls input[aria-label='Toggle autonomous self-learning']", ); expect(toggle?.checked).toBe(false); });