Skip to content

Commit aefd499

Browse files
kessleriosallyom
andauthored
fix(skills): keep managed prompt paths readable (#92894)
* fix(skills): keep managed prompt paths readable * fix(skills): preserve only managed skill prompt paths * chore: refresh proof gate * fix(skills): refresh managed prompt snapshots * fix(skills): preserve plugin prompt paths in state roots * test(skills): use generic state-root path names Signed-off-by: sallyom <[email protected]> --------- Signed-off-by: sallyom <[email protected]> Co-authored-by: sallyom <[email protected]>
1 parent c67dc59 commit aefd499

5 files changed

Lines changed: 172 additions & 9 deletions

File tree

src/skills/loading/compact-skill-paths.test.ts

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
// Compact skill path tests cover short path formatting for skill prompt payloads.
22
import os from "node:os";
33
import path from "node:path";
4-
import { describe, expect, it } from "vitest";
4+
import { afterEach, describe, expect, it, vi } from "vitest";
55
import { createCanonicalFixtureSkill } from "../test-support/test-helpers.js";
66
import { testing as workspaceSkillsTesting, buildWorkspaceSkillsPrompt } from "./workspace.js";
77

88
describe("compactSkillPaths", () => {
9+
afterEach(() => {
10+
vi.unstubAllEnvs();
11+
});
12+
913
function buildPromptForFixtureSkill(params: {
1014
workspaceRoot: string;
1115
skillDir: string;
@@ -52,6 +56,72 @@ describe("compactSkillPaths", () => {
5256
expect(prompt).toContain("A test skill for path compaction");
5357
});
5458

59+
it("does not compact explicit state-root managed skill paths to OS-home tilde paths", () => {
60+
const root = path.parse(os.homedir()).root;
61+
const osHome = path.join(root, "data");
62+
const stateDir = path.join(osHome, ".openclaw");
63+
const skillDir = path.join(stateDir, "skills", "world-cup-soccer-openclaw-skill");
64+
const skillFile = path.join(skillDir, "SKILL.md");
65+
66+
vi.stubEnv("HOME", osHome);
67+
vi.stubEnv("OPENCLAW_HOME", osHome);
68+
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
69+
vi.stubEnv("OPENCLAW_CONFIG_PATH", path.join(stateDir, "openclaw.json"));
70+
71+
const prompt = buildPromptForFixtureSkill({
72+
workspaceRoot: path.join(root, "workspace"),
73+
skillDir,
74+
name: "world-cup-soccer-openclaw-skill",
75+
description: "World Cup standings lookup",
76+
});
77+
78+
expect(prompt).toContain(`<location>${skillFile}</location>`);
79+
expect(prompt).not.toContain("~/.openclaw/skills/world-cup-soccer-openclaw-skill/SKILL.md");
80+
});
81+
82+
it("does not compact explicit state-root plugin skill paths to OS-home tilde paths", () => {
83+
const root = path.parse(os.homedir()).root;
84+
const osHome = path.join(root, "data");
85+
const stateDir = path.join(osHome, ".openclaw");
86+
const skillDir = path.join(stateDir, "plugin-skills", "calendar-plugin-skill");
87+
const skillFile = path.join(skillDir, "SKILL.md");
88+
89+
vi.stubEnv("HOME", osHome);
90+
vi.stubEnv("OPENCLAW_HOME", osHome);
91+
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
92+
vi.stubEnv("OPENCLAW_CONFIG_PATH", path.join(stateDir, "openclaw.json"));
93+
94+
const prompt = buildPromptForFixtureSkill({
95+
workspaceRoot: path.join(root, "workspace"),
96+
skillDir,
97+
name: "calendar-plugin-skill",
98+
description: "Calendar plugin skill",
99+
});
100+
101+
expect(prompt).toContain(`<location>${skillFile}</location>`);
102+
expect(prompt).not.toContain("~/.openclaw/plugin-skills/calendar-plugin-skill/SKILL.md");
103+
});
104+
105+
it("compacts managed skill paths when OS-home tilde reaches the same path", () => {
106+
const home = os.homedir();
107+
const stateDir = path.join(home, ".openclaw");
108+
const skillDir = path.join(stateDir, "skills", "home-managed-skill");
109+
110+
vi.stubEnv("HOME", home);
111+
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
112+
vi.stubEnv("OPENCLAW_HOME", undefined);
113+
114+
const prompt = buildPromptForFixtureSkill({
115+
workspaceRoot: path.join(home, "workspace"),
116+
skillDir,
117+
name: "home-managed-skill",
118+
description: "Home managed skill",
119+
});
120+
121+
expect(prompt).toContain("<location>~/.openclaw/skills/home-managed-skill/SKILL.md</location>");
122+
expect(prompt).not.toContain(`<location>${path.join(skillDir, "SKILL.md")}</location>`);
123+
});
124+
55125
it("normalizes compacted Windows skill locations to forward slashes", () => {
56126
const home = "C:\\Users\\alice";
57127
const skillPath = path.win32.join(home, ".openclaw-test-skills", "win-skill", "SKILL.md");

src/skills/loading/workspace.ts

Lines changed: 62 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { walkDirectorySync } from "../../infra/fs-safe.js";
1313
import { resolveOsHomeDir } from "../../infra/home-dir.js";
1414
import { isPathInside } from "../../infra/path-guards.js";
1515
import { createSubsystemLogger } from "../../logging/subsystem.js";
16-
import { CONFIG_DIR, resolveHomeDir, resolveUserPath } from "../../utils.js";
16+
import { CONFIG_DIR, resolveConfigDir, resolveUserPath } from "../../utils.js";
1717
import {
1818
resolveEffectiveAgentSkillFilter,
1919
resolveEffectiveAgentSkillsLimits,
@@ -27,6 +27,7 @@ import type {
2727
SkillEntry,
2828
SkillSnapshot,
2929
} from "../types.js";
30+
import { WORKSPACE_SKILLS_PROMPT_FORMAT_VERSION } from "../types.js";
3031
import { resolveBundledSkillsDir } from "./bundled-dir.js";
3132
import { resolveBundledAllowlist, shouldIncludeSkill } from "./config.js";
3233
import { resolveOpenClawMetadata, resolveSkillInvocationPolicy } from "./frontmatter.js";
@@ -42,12 +43,11 @@ const SKILL_SOURCE_ORIGIN_RELATIVE_PATH = path.join(".openclaw", "source-origin.
4243
const MAX_SKILL_SOURCE_ORIGIN_BYTES = 16 * 1024;
4344

4445
/**
45-
* Replace the user's home directory prefix with `~` in skill file paths
46-
* to reduce system prompt token usage. Models understand `~` expansion,
47-
* and the read tool resolves `~` to the home directory.
46+
* Replace OS home directory prefixes with `~` in skill file paths to
47+
* reduce system prompt token usage while matching host file-tool expansion.
4848
*
4949
* Example: `/Users/alice/.bun/.../skills/github/SKILL.md`
50-
* → `~/.bun/.../skills/github/SKILL.md`
50+
* → `~/.bun/.../skills/github/SKILL.md`
5151
*
5252
* Saves ~5–6 tokens per skill path × N skills ≈ 400–600 tokens total.
5353
*/
@@ -64,7 +64,7 @@ function resolveNativeUserHomeDir(): string | undefined {
6464
}
6565

6666
function resolveCompactHomePrefixes(): string[] {
67-
const homes = [resolveHomeDir(), resolveUserHomeDir(), resolveNativeUserHomeDir()].filter(
67+
const homes = [resolveUserHomeDir(), resolveNativeUserHomeDir()].filter(
6868
(home): home is string => Boolean(home),
6969
);
7070
const resolvedHomes = homes.map((home) => path.resolve(home));
@@ -79,12 +79,66 @@ function compactSkillPaths(skills: Skill[]): Skill[] {
7979
if (homes.length === 0) {
8080
return skills;
8181
}
82+
const preservedRoots = resolvePreservedPromptSkillPathRoots();
83+
const tildeRoots = resolvePromptTildeRoots();
8284
return skills.map((s) => ({
8385
...s,
84-
filePath: compactHomePath(s.filePath, homes),
86+
filePath: shouldPreservePromptSkillPath(s.filePath, preservedRoots, tildeRoots)
87+
? s.filePath
88+
: compactHomePath(s.filePath, homes),
8589
}));
8690
}
8791

92+
function resolvePreservedPromptSkillPathRoots(): string[] {
93+
const configDir = resolveConfigDir();
94+
const promptSkillDirs = [
95+
path.resolve(configDir, "skills"),
96+
path.resolve(configDir, "plugin-skills"),
97+
];
98+
const realPromptSkillDirs = promptSkillDirs
99+
.map((dir) => tryRealpath(dir))
100+
.filter((dir): dir is string => Boolean(dir));
101+
return uniqueStrings([...promptSkillDirs, ...realPromptSkillDirs]);
102+
}
103+
104+
function resolvePromptTildeRoots(): string[] {
105+
const nativeHome = resolveNativeUserHomeDir();
106+
if (!nativeHome) {
107+
return [];
108+
}
109+
const resolvedNativeHome = path.resolve(nativeHome);
110+
if (isContainerStateHomeWherePromptTildeEscapes(resolvedNativeHome)) {
111+
return [];
112+
}
113+
const realNativeHome = tryRealpath(resolvedNativeHome);
114+
return uniqueStrings([
115+
resolvedNativeHome,
116+
...(realNativeHome ? [realNativeHome] : []),
117+
]);
118+
}
119+
120+
function isContainerStateHomeWherePromptTildeEscapes(home: string): boolean {
121+
const configDir = path.resolve(resolveConfigDir());
122+
return home === "/data" && (configDir === "/data/.openclaw" || isPathInside("/data/.openclaw", configDir));
123+
}
124+
125+
function shouldPreservePromptSkillPath(
126+
filePath: string,
127+
roots: readonly string[],
128+
tildeRoots: readonly string[],
129+
): boolean {
130+
const resolvedFilePath = path.resolve(filePath);
131+
const isManagedPromptSkillPath = roots.some(
132+
(root) => resolvedFilePath === root || isPathInside(root, resolvedFilePath),
133+
);
134+
if (!isManagedPromptSkillPath) {
135+
return false;
136+
}
137+
return !tildeRoots.some(
138+
(root) => resolvedFilePath === root || isPathInside(root, resolvedFilePath),
139+
);
140+
}
141+
88142
function compactHomePath(filePath: string, homes: readonly string[]): string {
89143
for (const home of homes) {
90144
for (const prefix of compactHomePrefixesForHome(home)) {
@@ -1368,6 +1422,7 @@ export function buildWorkspaceSkillSnapshot(
13681422
...(skillFilter === undefined ? {} : { skillFilter }),
13691423
resolvedSkills,
13701424
version: opts?.snapshotVersion,
1425+
promptFormatVersion: WORKSPACE_SKILLS_PROMPT_FORMAT_VERSION,
13711426
};
13721427
}
13731428

src/skills/runtime/session-snapshot.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Session snapshot tests cover runtime skill state captured for agent sessions.
22
import { beforeEach, describe, expect, it, vi } from "vitest";
33
import type { OpenClawConfig } from "../../config/config.js";
4+
import { WORKSPACE_SKILLS_PROMPT_FORMAT_VERSION } from "../types.js";
45
import type { SkillSnapshot } from "../types.js";
56

67
const TEST_WORKSPACE_DIR = "/tmp/workspace";
@@ -10,6 +11,7 @@ function strippedSnapshot(skillName = "test"): SkillSnapshot {
1011
prompt: "skills prompt",
1112
skills: [{ name: skillName }],
1213
version: 0,
14+
promptFormatVersion: WORKSPACE_SKILLS_PROMPT_FORMAT_VERSION,
1315
};
1416
}
1517

@@ -169,4 +171,29 @@ describe("resolveReusableWorkspaceSkillSnapshot", () => {
169171

170172
expect(buildWorkspaceSkillSnapshotMock).toHaveBeenCalledTimes(1);
171173
});
174+
175+
it("refreshes persisted snapshots missing the current prompt format marker", () => {
176+
ensureSkillsWatcherMock.mockImplementation(() => undefined);
177+
getSkillsSnapshotVersionMock.mockReturnValue(0);
178+
shouldRefreshSnapshotForVersionMock.mockReturnValue(false);
179+
const oldSnapshot = {
180+
...strippedSnapshot(),
181+
version: 5,
182+
promptFormatVersion: undefined,
183+
};
184+
185+
const result = resolveReusableWorkspaceSkillSnapshot({
186+
workspaceDir: TEST_WORKSPACE_DIR,
187+
config: {},
188+
existingSnapshot: oldSnapshot,
189+
});
190+
191+
expect(result.shouldRefresh).toBe(true);
192+
expect(shouldRefreshSnapshotForVersionMock).toHaveBeenCalledWith(5, 0);
193+
expect(buildWorkspaceSkillSnapshotMock).toHaveBeenCalledTimes(1);
194+
const [[, snapshotParams]] = buildWorkspaceSkillSnapshotMock.mock.calls as unknown as Array<
195+
[string, { snapshotVersion?: number }]
196+
>;
197+
expect(snapshotParams.snapshotVersion).toBe(0);
198+
});
172199
});

src/skills/runtime/session-snapshot.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { redactConfigObject } from "../../config/redact-snapshot.js";
55
import type { OpenClawConfig } from "../../config/types.openclaw.js";
66
import { matchesSkillFilter } from "../discovery/filter.js";
77
import { buildWorkspaceSkillSnapshot } from "../loading/workspace.js";
8+
import { WORKSPACE_SKILLS_PROMPT_FORMAT_VERSION } from "../types.js";
89
import type { SkillEligibilityContext, SkillSnapshot } from "../types.js";
910
import { getSkillsSnapshotVersion, shouldRefreshSnapshotForVersion } from "./refresh-state.js";
1011
import { ensureSkillsWatcher } from "./refresh.js";
@@ -61,8 +62,15 @@ export function resolveReusableWorkspaceSkillSnapshot(
6162
ensureSkillsWatcher({ workspaceDir: params.workspaceDir, config: params.config });
6263
}
6364
const snapshotVersion = params.snapshotVersion ?? getSkillsSnapshotVersion(params.workspaceDir);
65+
const promptFormatChanged =
66+
params.existingSnapshot?.promptFormatVersion !== WORKSPACE_SKILLS_PROMPT_FORMAT_VERSION;
67+
const skillVersionChanged = shouldRefreshSnapshotForVersion(
68+
params.existingSnapshot?.version,
69+
snapshotVersion,
70+
);
6471
const shouldRefresh =
65-
shouldRefreshSnapshotForVersion(params.existingSnapshot?.version, snapshotVersion) ||
72+
promptFormatChanged ||
73+
skillVersionChanged ||
6674
!matchesSkillFilter(params.existingSnapshot?.skillFilter, params.skillFilter);
6775
const buildSnapshot = () => {
6876
return buildWorkspaceSkillSnapshot(params.workspaceDir, {

src/skills/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,11 +99,14 @@ export type SkillEligibilityContext = {
9999
};
100100
};
101101

102+
export const WORKSPACE_SKILLS_PROMPT_FORMAT_VERSION = 1;
103+
102104
export type SkillSnapshot = {
103105
prompt: string;
104106
skills: Array<{ name: string; primaryEnv?: string; requiredEnv?: string[] }>;
105107
/** Normalized agent-level filter used to build this snapshot; undefined means unrestricted. */
106108
skillFilter?: string[];
107109
resolvedSkills?: Skill[];
108110
version?: number;
111+
promptFormatVersion?: number;
109112
};

0 commit comments

Comments
 (0)