diff --git a/docs/tools/skills.md b/docs/tools/skills.md index 10913863f577..47ea262e9da8 100644 --- a/docs/tools/skills.md +++ b/docs/tools/skills.md @@ -55,8 +55,11 @@ snapshot refresh and sandbox synchronization. Sandboxed runs read the materialized copies, not the original host paths. Managed worktree sessions keep their recorded canonical workspace as the skill -source. A selected nested workspace stays nested: discovery does not walk up to -its parent repository. Installing OpenClaw from a repository does not make that +source. The configured agent workspace remains the primary skill source even when +the session executes in a worktree; only selecting that worktree as the agent's +workspace gives its skills primary precedence. A selected nested workspace stays +nested: discovery does not walk up to its parent repository. Installing OpenClaw +from a repository does not make that repository's `.agents/skills/` a global bundled skill source. Each discovery pass reports one summary per winning/losing discovery root and diff --git a/src/agents/agent-command.ts b/src/agents/agent-command.ts index fbb9045758b8..1207473110c3 100644 --- a/src/agents/agent-command.ts +++ b/src/agents/agent-command.ts @@ -445,7 +445,6 @@ async function agentCommandInternal( sessionAgentId, lifecycleGeneration, runId, - workspaceDir, executionWorkspaceDir: sessionEntry?.worktree?.canonicalWorkspaceDir ?? cwd ?? workspaceDir, watchSkills, diff --git a/src/agents/cli-runner.test-helpers.ts b/src/agents/cli-runner.test-helpers.ts index 7a320624d1f3..e6b81f66c367 100644 --- a/src/agents/cli-runner.test-helpers.ts +++ b/src/agents/cli-runner.test-helpers.ts @@ -44,6 +44,27 @@ export type TestCliBackendParams = { systemPromptWhen?: "first" | "always" | "never"; }; +export function createCliRepositorySkillFixture(dir: string, taskDir: string, managed: boolean) { + const canonicalDir = path.join(dir, "canonical", "packages", "app"); + const skillDir = path.join(managed ? canonicalDir : taskDir, ".agents", "skills", "task-proof"); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync( + path.join(skillDir, "SKILL.md"), + "---\nname: task-proof\ndescription: Task-local proof\n---\n# Proof instructions\n", + ); + if (managed) { + for (const source of [".agents/skills", "skills"]) { + const worktreeSkillDir = path.join(taskDir, source, "task-proof"); + fs.mkdirSync(worktreeSkillDir, { recursive: true }); + fs.writeFileSync( + path.join(worktreeSkillDir, "SKILL.md"), + "---\nname: task-proof\ndescription: Worktree copy\n---\n# Changed instructions\n", + ); + } + } + return { canonicalDir, skillDir }; +} + export function wrappedPluginSystemContext(text: string) { return `---\n\nOpenClaw plugin-injected system context. This block is not workspace file content.\n\n${text}\n\n---`; } diff --git a/src/agents/cli-runner/prepare.test.ts b/src/agents/cli-runner/prepare.test.ts index 56715d465bb0..bfb77efa5ca6 100644 --- a/src/agents/cli-runner/prepare.test.ts +++ b/src/agents/cli-runner/prepare.test.ts @@ -102,6 +102,7 @@ import { testing as cliBackendsTesting } from "../cli-backends.test-support.js"; import { buildDefaultTestCliBackend, createCliRunnerPrepareFixture, + createCliRepositorySkillFixture, createTestMcpLoopbackClientGrant, createTestMcpLoopbackServer, createTestMcpLoopbackServerConfig, @@ -3152,15 +3153,11 @@ describe("prepareCliRunContext", () => { it.each([false, true])("uses admitted CLI repository skills (managed=%s)", async (managed) => { const { dir } = fixture.session; const taskDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-task-")); - const canonicalDir = path.join(dir, "canonical", "packages", "app"); - const skillDir = path.join(managed ? canonicalDir : taskDir, ".agents", "skills", "task-proof"); - fs.mkdirSync(skillDir, { recursive: true }); - fs.writeFileSync( - path.join(skillDir, "SKILL.md"), - "---\nname: task-proof\ndescription: Task-local proof\n---\n# Proof instructions\n", - ); + const { canonicalDir, skillDir } = createCliRepositorySkillFixture(dir, taskDir, managed); try { const context = await fixture.prepare({ + config: { agents: { defaults: { workspace: dir } } }, + workspaceDir: taskDir, cwd: taskDir, skillsSnapshot: undefined, ...(managed @@ -3184,6 +3181,7 @@ describe("prepareCliRunContext", () => { expect(context.systemPrompt).not.toContain(`Working directory: ${dir}`); expect(context.systemPrompt).toContain("task-proof"); expect(context.systemPrompt).toContain(path.join(skillDir, "SKILL.md")); + expect(context.systemPrompt).not.toContain("Worktree copy"); } finally { fs.rmSync(taskDir, { recursive: true, force: true }); } diff --git a/src/agents/cli-runner/prepare.ts b/src/agents/cli-runner/prepare.ts index 69a737787dda..ad8f2a0b5785 100644 --- a/src/agents/cli-runner/prepare.ts +++ b/src/agents/cli-runner/prepare.ts @@ -50,10 +50,6 @@ import { } from "../../routing/session-key.js"; import { annotateInterSessionPromptText } from "../../sessions/input-provenance.js"; import { captureAsyncWorkTracker } from "../../shared/async-work-scope.js"; -import { resolveSkillsPrompt } from "../../skills/loading/workspace-skill-prompt.js"; -import { resolveEmbeddedRunSkillEntries } from "../../skills/runtime/embedded-run-entries.js"; -import { resolveReusableWorkspaceSkillSnapshot } from "../../skills/runtime/session-snapshot.js"; -import type { SkillUsagePath } from "../../skills/types.js"; import { resolveUserPath } from "../../utils.js"; import { normalizeMessageChannel } from "../../utils/message-channel.js"; import { resolveAdmittedRunActiveAssertion } from "../admitted-run-context.js"; @@ -107,11 +103,7 @@ import { mergeForcedEmbeddedAttemptToolsAllow, } from "../embedded-agent-runner/run/attempt-tool-construction-plan.js"; import { buildCurrentInboundPrompt } from "../embedded-agent-runner/run/runtime-context-prompt.js"; -import { - mapSandboxSkillEntriesForPrompt, - remapSkillReferencePaths, - resolveSandboxSkillRuntimeInputs, -} from "../embedded-agent-runner/sandbox-skills.js"; +import { remapSkillReferencePaths } from "../embedded-agent-runner/sandbox-skills.js"; import { selectContextEngineForTranscriptHost } from "../harness/context-engine-logical-turn.js"; import { drainPendingContextEngineTurnsBeforeRun } from "../harness/context-engine-turn-attempt.js"; import { createAgentQuestionAnswerAuthority } from "../harness/host-private-capabilities.js"; @@ -125,7 +117,6 @@ import { type PreparedRootedExecutionCapability, } from "../rooted-run-params.js"; import { collectRuntimeChannelCapabilities } from "../runtime-capabilities.js"; -import { ensureSandboxWorkspaceForSession } from "../sandbox.js"; import { resolveSandboxRuntimeStatus } from "../sandbox/runtime-status.js"; import { buildSystemPromptReport } from "../system-prompt-report.js"; import { appendModelIdentitySystemPrompt, buildModelIdentityPromptLine } from "../system-prompt.js"; @@ -179,6 +170,7 @@ import { loadCliSessionPromptContext, resolveAutoCliSessionReseedHistoryChars, } from "./session-history.js"; +import { resolveCliSkillsPrompt } from "./skills-prompt.js"; import { prepareCliReplyToolAuthority } from "./tool-authority.js"; import { captureCliRunStartTime, @@ -257,81 +249,6 @@ function prependCliSessionDriftUserContext( }; } -async function resolveCliSkillsPrompt(params: { - assertCurrent: () => void; - agentId: string; - config: RunCliAgentParams["config"]; - sessionKey: string; - skillsSnapshot: RunCliAgentParams["skillsSnapshot"]; - workspaceDir: string; - executionWorkspaceDir: string; -}): Promise<{ prompt: string; usagePaths?: SkillUsagePath[] }> { - params.assertCurrent(); - const skillsSnapshot = - params.skillsSnapshot ?? - ( - await resolveReusableWorkspaceSkillSnapshot({ - assertCurrent: params.assertCurrent, - workspaceDir: params.workspaceDir, - executionWorkspaceDir: params.executionWorkspaceDir, - config: params.config ?? {}, - agentId: params.agentId, - watch: false, - }) - ).snapshot; - params.assertCurrent(); - const sandboxWorkspace = await ensureSandboxWorkspaceForSession({ - skillsSnapshot, - config: params.config, - agentId: params.agentId, - sessionKey: params.sessionKey, - workspaceDir: params.workspaceDir, - }); - params.assertCurrent(); - const { - skillsEligibility, - skillUsagePaths, - skillsPromptWorkspaceDir, - skillsSnapshot: skillsSnapshotForRun, - skillsWorkspaceDir, - workspaceOnly, - } = resolveSandboxSkillRuntimeInputs({ - sandbox: sandboxWorkspace ? { ...sandboxWorkspace, enabled: true } : undefined, - skillsAnchorWorkspace: sandboxWorkspace?.workspaceDir ?? params.workspaceDir, - skillsSnapshot, - }); - const { shouldLoadSkillEntries, skillEntries, loadSkillEntries, preserveEntryOrder } = - await resolveEmbeddedRunSkillEntries({ - assertCurrent: params.assertCurrent, - workspaceDir: skillsWorkspaceDir, - ...(sandboxWorkspace ? {} : { executionWorkspaceDir: params.executionWorkspaceDir }), - config: params.config, - agentId: params.agentId, - eligibility: skillsEligibility, - skillsSnapshot: skillsSnapshotForRun, - workspaceOnly, - }); - const promptSkillEntries = mapSandboxSkillEntriesForPrompt({ - entries: shouldLoadSkillEntries ? skillEntries : undefined, - skillsWorkspaceDir, - skillsPromptWorkspaceDir, - }); - return { - ...(sandboxWorkspace ? { usagePaths: skillUsagePaths } : {}), - prompt: await resolveSkillsPrompt({ - assertCurrent: params.assertCurrent, - skillsSnapshot: skillsSnapshotForRun, - entries: promptSkillEntries, - ...(sandboxWorkspace ? {} : { loadEntries: loadSkillEntries }), - workspaceDir: skillsPromptWorkspaceDir, - config: params.config, - agentId: params.agentId, - eligibility: skillsEligibility, - preserveEntryOrder, - }), - }; -} - /** Overrides preparation dependencies for CLI runner tests. */ function setCliRunnerPrepareTestDeps(overrides: Partial): void { Object.assign(prepareDeps, overrides); diff --git a/src/agents/cli-runner/skills-prompt.ts b/src/agents/cli-runner/skills-prompt.ts new file mode 100644 index 000000000000..88fbbb1e8383 --- /dev/null +++ b/src/agents/cli-runner/skills-prompt.ts @@ -0,0 +1,87 @@ +import { resolveSkillsPrompt } from "../../skills/loading/workspace-skill-prompt.js"; +import { resolveEmbeddedRunSkillEntries } from "../../skills/runtime/embedded-run-entries.js"; +import { resolveReusableWorkspaceSkillSnapshot } from "../../skills/runtime/session-snapshot.js"; +import type { SkillUsagePath } from "../../skills/types.js"; +import { resolveAgentWorkspaceDir } from "../agent-scope-config.js"; +import { + mapSandboxSkillEntriesForPrompt, + resolveSandboxSkillRuntimeInputs, +} from "../embedded-agent-runner/sandbox-skills.js"; +import { ensureSandboxWorkspaceForSession } from "../sandbox.js"; +import type { RunCliAgentParams } from "./types.js"; + +export async function resolveCliSkillsPrompt(params: { + assertCurrent: () => void; + agentId: string; + config: RunCliAgentParams["config"]; + sessionKey: string; + skillsSnapshot: RunCliAgentParams["skillsSnapshot"]; + workspaceDir: string; + executionWorkspaceDir: string; +}): Promise<{ prompt: string; usagePaths?: SkillUsagePath[] }> { + params.assertCurrent(); + const agentWorkspaceDir = resolveAgentWorkspaceDir(params.config ?? {}, params.agentId); + const skillsSnapshot = + params.skillsSnapshot ?? + ( + await resolveReusableWorkspaceSkillSnapshot({ + assertCurrent: params.assertCurrent, + workspaceDir: agentWorkspaceDir, + executionWorkspaceDir: params.executionWorkspaceDir, + config: params.config ?? {}, + agentId: params.agentId, + watch: false, + }) + ).snapshot; + params.assertCurrent(); + const sandboxWorkspace = await ensureSandboxWorkspaceForSession({ + skillsSnapshot, + config: params.config, + agentId: params.agentId, + sessionKey: params.sessionKey, + workspaceDir: params.workspaceDir, + }); + params.assertCurrent(); + const { + skillsEligibility, + skillUsagePaths, + skillsPromptWorkspaceDir, + skillsSnapshot: skillsSnapshotForRun, + skillsWorkspaceDir, + workspaceOnly, + } = resolveSandboxSkillRuntimeInputs({ + sandbox: sandboxWorkspace ? { ...sandboxWorkspace, enabled: true } : undefined, + skillsAnchorWorkspace: sandboxWorkspace?.workspaceDir ?? agentWorkspaceDir, + skillsSnapshot, + }); + const { shouldLoadSkillEntries, skillEntries, loadSkillEntries, preserveEntryOrder } = + await resolveEmbeddedRunSkillEntries({ + assertCurrent: params.assertCurrent, + workspaceDir: skillsWorkspaceDir, + ...(sandboxWorkspace ? {} : { executionWorkspaceDir: params.executionWorkspaceDir }), + config: params.config, + agentId: params.agentId, + eligibility: skillsEligibility, + skillsSnapshot: skillsSnapshotForRun, + workspaceOnly, + }); + const promptSkillEntries = mapSandboxSkillEntriesForPrompt({ + entries: shouldLoadSkillEntries ? skillEntries : undefined, + skillsWorkspaceDir, + skillsPromptWorkspaceDir, + }); + return { + ...(sandboxWorkspace ? { usagePaths: skillUsagePaths } : {}), + prompt: await resolveSkillsPrompt({ + assertCurrent: params.assertCurrent, + skillsSnapshot: skillsSnapshotForRun, + entries: promptSkillEntries, + ...(sandboxWorkspace ? {} : { loadEntries: loadSkillEntries }), + workspaceDir: skillsPromptWorkspaceDir, + config: params.config, + agentId: params.agentId, + eligibility: skillsEligibility, + preserveEntryOrder, + }), + }; +} diff --git a/src/agents/command/session-preparation.test.ts b/src/agents/command/session-preparation.test.ts index 3b9fb8a7e342..a35dc5522b1f 100644 --- a/src/agents/command/session-preparation.test.ts +++ b/src/agents/command/session-preparation.test.ts @@ -73,7 +73,6 @@ it.each([ sessionAgentId: "main", lifecycleGeneration, runId, - workspaceDir: "/workspace", executionWorkspaceDir: "/workspace", watchSkills: false, isNewSession: false, diff --git a/src/agents/command/session-preparation.ts b/src/agents/command/session-preparation.ts index 9ac9c4fb2365..9474e7591a33 100644 --- a/src/agents/command/session-preparation.ts +++ b/src/agents/command/session-preparation.ts @@ -18,6 +18,7 @@ import { buildCurrentRunRestartRecoveryClaim, prepareCommandHarnessCompletionRecovery, } from "../agent-command-restart-recovery.js"; +import { resolveAgentWorkspaceDir } from "../agent-scope-config.js"; import { persistAgentSession } from "./attempt-execution.shared.js"; import { resolveAgentRunContext } from "./run-context.js"; import { loadExecDefaultsRuntime, loadSkillsRuntime } from "./runtime-loaders.js"; @@ -98,7 +99,6 @@ export async function prepareEmbeddedSessionState(params: { sessionAgentId: string; lifecycleGeneration: string; runId: string; - workspaceDir: string; executionWorkspaceDir: string; watchSkills: boolean; isNewSession: boolean; @@ -146,7 +146,7 @@ export async function prepareEmbeddedSessionState(params: { agentId: params.sessionAgentId, }); const skillSnapshotState = await resolveReusableWorkspaceSkillSnapshot({ - workspaceDir: params.workspaceDir, + workspaceDir: resolveAgentWorkspaceDir(params.cfg, params.sessionAgentId), executionWorkspaceDir: params.executionWorkspaceDir, config: params.cfg, agentId: params.sessionAgentId, diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index ead70b4137d8..dfa64ee0bc95 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -845,16 +845,15 @@ describe("agentCommand", () => { { message: "inspect this repo", sessionKey, + workspaceDir: worktree.path, allowModelOverride: false, }, runtime, ); - expect(resolveReusableWorkspaceSkillSnapshot).toHaveBeenCalledWith( - expect.objectContaining({ - executionWorkspaceDir: canonicalWorkspace, - }), - ); + const skillRoots = vi.mocked(resolveReusableWorkspaceSkillSnapshot).mock.calls.at(-1)?.[0]; + expect(skillRoots?.workspaceDir).toBe(path.join(home, "openclaw")); + expect(skillRoots?.executionWorkspaceDir).toBe(canonicalWorkspace); }); });