mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
feat(skills): search bounded installed skill instructions (#160538)
* fix(build): keep pinned pnpm probes from dirtying the lockfile * feat(skills): search bounded installed instruction bodies Index bounded instruction text lazily through the admitted catalog reader, retaining metadata-only search for unavailable or over-budget bodies. Revalidate reader authority on cache hits and preserve whole-body skill reads. Report index coverage in search results. Validation: 33 focused tests across four files passed on the native proof checkout in 117.62s; fresh scoped autoreview clean through P2; all eight changed files pass oxfmt. The coding worktree is deliberately code-only, so formatting ran through the qualified independent tooling checkout before this commit. * fix(skills): require current native read authority for body search * test(skills): prove disk-backed search authority through dispatch * test(skills): tie authority gates to test cancellation Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
parent
eb36d28541
commit
94f5a8d586
8 changed files with 722 additions and 119 deletions
|
|
@ -836,10 +836,11 @@ The prompt contains a bounded skill directory. Skills omitted by the prompt
|
|||
budget remain discoverable through `skills_search` when that tool is enabled.
|
||||
Small catalogs continue to appear in full.
|
||||
|
||||
- `skills_search({ query, limit? })` searches eligible installed names and
|
||||
descriptions. The default limit is 5; the maximum is 20. Queries must contain
|
||||
1-1,000 characters. Results contain names, locations, and shortened descriptions, not
|
||||
instructions. `hasMore` indicates that additional matches exist.
|
||||
- `skills_search({ query, limit? })` searches eligible installed names,
|
||||
descriptions, and bounded instruction text. The default limit is 5; the maximum
|
||||
is 20. Queries must contain 1-1,000 characters. Results contain names, locations,
|
||||
and shortened descriptions, not instructions. `hasMore` indicates that additional
|
||||
matches exist.
|
||||
- `skills_read({ name })` loads the complete `SKILL.md` for an exact name.
|
||||
Search is not required when the name is already known. Instructions omitted
|
||||
from the prompt directory are limited to 256 KiB and rejected if larger, not
|
||||
|
|
@ -851,6 +852,11 @@ ineligible, and model-hidden skills are not added by search. Existing explicit
|
|||
user references remain separate. Search does not query ClawHub, install a
|
||||
skill, or grant permission to execute its commands.
|
||||
|
||||
Instruction-body indexing requires the effective native `skills_read` tool.
|
||||
When reads are denied or shadowed, search uses metadata only and performs no
|
||||
instruction-body reads. Revocation also excludes cached body matches and rejects
|
||||
in-flight indexing started under the previous grant.
|
||||
|
||||
In OpenClaw Code Mode, use `await skills.search(query, limit)` and
|
||||
`await skills.read(name)`. These calls dispatch through the same tools and
|
||||
policies. `await skills.list(offset)` returns up to 20 directory entries;
|
||||
|
|
@ -864,6 +870,24 @@ An existing `read` policy grant also permits `skills_read`. An explicit
|
|||
Search uses an in-memory lexical index of the prepared catalog. It follows the
|
||||
existing [snapshot and refresh rules](/tools/skills#snapshots-and-refresh), with
|
||||
no embedding service or persistent search index.
|
||||
The first search reads bodies through the admitted filesystem owner.
|
||||
Concurrent first searches share one build; cancelling a waiter does not cancel
|
||||
its owner. Later searches reuse the completed index for that prepared catalog
|
||||
and still check current run authority. Names and descriptions have twice the lexical
|
||||
weight of body text; exact names rank first.
|
||||
|
||||
Body indexing reads at most 1,024 skills in name order, four at a time. Each body
|
||||
contributes at most 16 KiB, reduced equally across the selected skills
|
||||
to keep their total at most 4 MiB. File readers enforce this budget before reading;
|
||||
oversized files and owners without bounded search reads retain metadata only.
|
||||
Remote workspace owners with whole-skill reads only do not use their document
|
||||
bridge for indexing. Already-delivered inline bodies can contribute a bounded
|
||||
prefix. Metadata remains searchable for the full eligible catalog.
|
||||
If bodies are unreadable, omitted, or shortened,
|
||||
`coverage` reports `bodyIndexed`, `metadataOnly`, and `truncatedBodies`.
|
||||
An empty result with partial coverage does not prove that no applicable skill exists.
|
||||
Indexing never executes skill content, and index limits do not truncate `skills_read`.
|
||||
|
||||
Sandbox search includes only readable, delivered skills. Discovery does not
|
||||
expand the existing worker transfer selection or its 8 MiB total resource limit.
|
||||
Dedicated remote workers retain their existing tool protocol; the new search
|
||||
|
|
|
|||
|
|
@ -1,9 +1,17 @@
|
|||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { Type } from "typebox";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createDeferred } from "../../test/helpers/promise.js";
|
||||
import {
|
||||
awaitGateBeforeSettlement,
|
||||
createDeferred,
|
||||
withinTest,
|
||||
} from "../../test/helpers/promise.js";
|
||||
import { readLocalFileSafely } from "../infra/fs-safe.js";
|
||||
import { setPluginToolMeta } from "../plugins/tool-metadata.js";
|
||||
import { resolveSkillsPrompt } from "../skills/loading/workspace-skill-prompt.js";
|
||||
import { createFixtureSkillEntry } from "../skills/test-support/test-helpers.js";
|
||||
import { withTempDir } from "../test-utils/temp-dir.js";
|
||||
import { createOpenClawReadTool } from "./agent-tools.read.js";
|
||||
import { resolveCodeModeSkills } from "./code-mode-skills.js";
|
||||
import {
|
||||
|
|
@ -323,6 +331,7 @@ it("searches and reads eligible skills through the worker bridge and normal tool
|
|||
{ name: "demo", description: demo.skill.description, location: "/skills/demo/SKILL.md" },
|
||||
],
|
||||
hasMore: false,
|
||||
coverage: { bodyIndexed: 0, metadataOnly: 1, truncatedBodies: 0 },
|
||||
},
|
||||
body,
|
||||
unknown: 'Unknown installed skill "missing".',
|
||||
|
|
@ -334,72 +343,147 @@ it("searches and reads eligible skills through the worker bridge and normal tool
|
|||
});
|
||||
});
|
||||
|
||||
it.each(["skills_read", "skills_search"])(
|
||||
"keeps skill guidance and execution consistent when the harness removes %s",
|
||||
async (denied) => {
|
||||
const skills = [
|
||||
{
|
||||
name: "guide",
|
||||
description: "Guide",
|
||||
location: "/skills/guide/SKILL.md",
|
||||
source: { filePath: "/skills/guide/SKILL.md", readContent: "Private instructions" },
|
||||
},
|
||||
];
|
||||
const skillTools = createInstalledSkillTools(skills);
|
||||
const runtime = createAgentHarnessToolSurfaceRuntimeCore({
|
||||
config: {
|
||||
agents: { defaults: { experimental: { localModelLean: false } } },
|
||||
tools: { codeMode: true, toolSearch: false },
|
||||
},
|
||||
modelToolsEnabled: true,
|
||||
executeTool: async ({ toolName, toolCallId, input, signal, onUpdate }) => {
|
||||
const tool = skillTools.find((candidate) => candidate.name === toolName);
|
||||
if (!tool) {
|
||||
throw new Error(`Unknown native skill tool: ${toolName}`);
|
||||
}
|
||||
return tool.execute(toolCallId, input, signal, onUpdate);
|
||||
},
|
||||
});
|
||||
try {
|
||||
const surface = runtime.compactTools(
|
||||
skillTools.filter((tool) => tool.name !== denied),
|
||||
{ prepared: { codeModeSkills: skills, preserveToolNames: [] } },
|
||||
);
|
||||
const exec = surface.tools.find((tool) => tool.name === "exec")!;
|
||||
const wait = surface.tools.find((tool) => tool.name === "wait")!;
|
||||
expect(exec.description.includes("skills.search(")).toBe(denied !== "skills_search");
|
||||
expect(exec.description.includes("skills.list(")).toBe(denied !== "skills_search");
|
||||
expect(exec.description.includes("skills.read(")).toBe(denied !== "skills_read");
|
||||
const result = await runUntilCompleted({
|
||||
execTool: exec,
|
||||
waitTool: wait,
|
||||
code: `
|
||||
it.for([undefined, "skills_read", "skills_search", "shadowed", "revoked"] as const)(
|
||||
"keeps disk-backed skill discovery within the harness read authority: %s",
|
||||
async (denied, { signal: testSignal }) =>
|
||||
withTempDir("code-mode-skill-authority-", async (dir) => {
|
||||
const filePath = path.join(dir, "SKILL.md");
|
||||
await writeFile(filePath, "Private instructions");
|
||||
const readStarted = createDeferred();
|
||||
const releaseRead = createDeferred();
|
||||
let reads = 0;
|
||||
const skills = [
|
||||
{
|
||||
name: "guide",
|
||||
description: "Guide",
|
||||
location: filePath,
|
||||
source: { filePath },
|
||||
readSearchContent: async (maxBytes: number) => {
|
||||
reads += 1;
|
||||
const content = (await readLocalFileSafely({ filePath, maxBytes })).buffer.toString(
|
||||
"utf8",
|
||||
);
|
||||
readStarted.resolve();
|
||||
if (denied === "revoked") {
|
||||
await releaseRead.promise;
|
||||
}
|
||||
return content;
|
||||
},
|
||||
},
|
||||
];
|
||||
const skillTools = createInstalledSkillTools(skills);
|
||||
let effectiveTools = skillTools.filter((tool) => tool.name !== denied);
|
||||
if (denied === "shadowed") {
|
||||
effectiveTools = effectiveTools.map((tool) =>
|
||||
tool.name === "skills_read"
|
||||
? {
|
||||
...tool,
|
||||
execute: async () => {
|
||||
throw new Error("Shadowed reader");
|
||||
},
|
||||
}
|
||||
: tool,
|
||||
);
|
||||
}
|
||||
const runtime = createAgentHarnessToolSurfaceRuntimeCore({
|
||||
config: {
|
||||
agents: { defaults: { experimental: { localModelLean: false } } },
|
||||
tools: { codeMode: true, toolSearch: false },
|
||||
},
|
||||
modelToolsEnabled: true,
|
||||
executeTool: async ({ toolName, toolCallId, input, signal, onUpdate }) => {
|
||||
const tool = effectiveTools.find((candidate) => candidate.name === toolName);
|
||||
if (!tool) {
|
||||
throw new Error(`Unknown native skill tool: ${toolName}`);
|
||||
}
|
||||
return tool.execute(toolCallId, input, signal, onUpdate);
|
||||
},
|
||||
});
|
||||
try {
|
||||
const surface = runtime.compactTools(effectiveTools, {
|
||||
prepared: { codeModeSkills: skills, preserveToolNames: [] },
|
||||
});
|
||||
const exec = surface.tools.find((tool) => tool.name === "exec")!;
|
||||
const wait = surface.tools.find((tool) => tool.name === "wait")!;
|
||||
expect(exec.description.includes("skills.search(")).toBe(denied !== "skills_search");
|
||||
expect(exec.description.includes("skills.list(")).toBe(denied !== "skills_search");
|
||||
expect(exec.description.includes("skills.read(")).toBe(denied !== "skills_read");
|
||||
const pending = runUntilCompleted({
|
||||
execTool: exec,
|
||||
waitTool: wait,
|
||||
code: `
|
||||
async function outcome(call) {
|
||||
try { return await call(); } catch (error) { return error.message; }
|
||||
}
|
||||
return {
|
||||
listed: await outcome(() => skills.list()),
|
||||
bodyMatch: await outcome(() => skills.search("private")),
|
||||
found: await outcome(() => skills.search("guide")),
|
||||
body: await outcome(() => skills.read("guide")),
|
||||
};
|
||||
`,
|
||||
});
|
||||
const unavailable = `${denied} is not available in this run.`;
|
||||
expect(result).toMatchObject({
|
||||
status: "completed",
|
||||
value: {
|
||||
listed:
|
||||
denied === "skills_search"
|
||||
? unavailable
|
||||
: [{ name: "guide", description: "Guide", location: "/skills/guide/SKILL.md" }],
|
||||
found: denied === "skills_search" ? unavailable : { skills: [{ name: "guide" }] },
|
||||
body: denied === "skills_read" ? unavailable : "Private instructions",
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
runtime.cleanup();
|
||||
}
|
||||
},
|
||||
});
|
||||
if (denied === "revoked") {
|
||||
await withinTest(
|
||||
awaitGateBeforeSettlement(
|
||||
readStarted.promise,
|
||||
pending,
|
||||
"Skill search completed before reading its instruction file",
|
||||
),
|
||||
testSignal,
|
||||
);
|
||||
effectiveTools = skillTools.filter((tool) => tool.name !== "skills_read");
|
||||
runtime.compactTools(effectiveTools, {
|
||||
prepared: { codeModeSkills: skills, preserveToolNames: [] },
|
||||
});
|
||||
releaseRead.resolve();
|
||||
}
|
||||
const result = await pending;
|
||||
const unavailable = `${denied} is not available in this run.`;
|
||||
expect(result).toMatchObject({
|
||||
status: "completed",
|
||||
value: {
|
||||
listed:
|
||||
denied === "skills_search"
|
||||
? unavailable
|
||||
: [{ name: "guide", description: "Guide", location: filePath }],
|
||||
found: denied === "skills_search" ? unavailable : { skills: [{ name: "guide" }] },
|
||||
bodyMatch:
|
||||
denied === "skills_search"
|
||||
? unavailable
|
||||
: denied === "revoked"
|
||||
? expect.stringContaining("permission changed")
|
||||
: { skills: denied === undefined ? [{ name: "guide" }] : [] },
|
||||
body:
|
||||
denied === "skills_read"
|
||||
? unavailable
|
||||
: denied === "revoked"
|
||||
? expect.stringContaining("Unknown tool id: skills_read.")
|
||||
: denied === "shadowed"
|
||||
? expect.stringContaining("Shadowed reader")
|
||||
: "Private instructions",
|
||||
},
|
||||
});
|
||||
expect(reads).toBe(denied === undefined || denied === "revoked" ? 1 : 0);
|
||||
if (denied === undefined) {
|
||||
effectiveTools = skillTools.filter((tool) => tool.name !== "skills_read");
|
||||
const revoked = runtime.compactTools(effectiveTools, {
|
||||
prepared: { codeModeSkills: skills, preserveToolNames: [] },
|
||||
});
|
||||
expect(
|
||||
await runUntilCompleted({
|
||||
execTool: revoked.tools.find((tool) => tool.name === "exec")!,
|
||||
waitTool: revoked.tools.find((tool) => tool.name === "wait")!,
|
||||
code: 'return await skills.search("private");',
|
||||
}),
|
||||
).toMatchObject({ status: "completed", value: { skills: [] } });
|
||||
expect(reads).toBe(1);
|
||||
}
|
||||
} finally {
|
||||
releaseRead.resolve();
|
||||
runtime.cleanup();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
it.each([undefined, "read", "skills_read"])(
|
||||
|
|
|
|||
|
|
@ -82,6 +82,9 @@ it("binds skill reads to a late sandbox and refuses reads after host closure", a
|
|||
skills: [],
|
||||
hasMore: false,
|
||||
});
|
||||
expect(readFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ filePath: runtimePath, maxBytes: 16 * 1024 }),
|
||||
);
|
||||
const result = await read.execute("read-guide", { name: "guide" });
|
||||
expect(result.content).toEqual([{ type: "text", text: "Complete sandbox instructions" }]);
|
||||
expect(readFile).toHaveBeenCalledWith(
|
||||
|
|
@ -89,7 +92,8 @@ it("binds skill reads to a late sandbox and refuses reads after host closure", a
|
|||
);
|
||||
host.closeHost();
|
||||
await expect(read.execute("closed", { name: "guide" })).rejects.toThrow();
|
||||
expect(readFile).toHaveBeenCalledOnce();
|
||||
await expect(search.execute("closed-search", { query: "sandbox" })).rejects.toThrow();
|
||||
expect(readFile).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
host.closeHost();
|
||||
host.closeAdmission();
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { racePromiseWithAbortSignal } from "../infra/abort-signal.js";
|
||||
import { readLocalFileSafely } from "../infra/fs-safe.js";
|
||||
import { readCodeModeSkill, type CodeModeSkill } from "./code-mode-skills.js";
|
||||
import { buildLexicalIndex, scoreLexical, tokenizeDocument } from "./tool-search-ranking.js";
|
||||
|
|
@ -6,6 +7,9 @@ import { ToolInputError } from "./tools/common.js";
|
|||
export type InstalledSkill = CodeModeSkill & {
|
||||
/** Prompt-listed instructions retain the shipped Code Mode whole-read contract. */
|
||||
promptListed?: boolean;
|
||||
assertCurrent?: () => void;
|
||||
/** The owner must bound I/O before allocating the returned content. */
|
||||
readSearchContent?: (maxBytes: number, signal?: AbortSignal) => Promise<string>;
|
||||
};
|
||||
|
||||
const MAX_QUERY_CHARS = 1_000;
|
||||
|
|
@ -13,8 +17,12 @@ const MAX_RESULTS = 20;
|
|||
const MAX_DESCRIPTION_CHARS = 512;
|
||||
const MAX_RESULT_CHARS = 16_000;
|
||||
export const MAX_SKILL_INSTRUCTION_BYTES = 256 * 1024;
|
||||
const MAX_BODY_SKILLS = 1_024;
|
||||
const MAX_BODY_BYTES = 16 * 1024;
|
||||
const MAX_INDEX_BODY_BYTES = 4 * 1024 * 1024;
|
||||
const READ_CONCURRENCY = 4;
|
||||
|
||||
function buildIndex(skills: readonly InstalledSkill[]) {
|
||||
function buildMetadataIndex(skills: readonly InstalledSkill[]) {
|
||||
return buildLexicalIndex(
|
||||
skills.map((skill) => ({
|
||||
value: skill,
|
||||
|
|
@ -23,14 +31,125 @@ function buildIndex(skills: readonly InstalledSkill[]) {
|
|||
);
|
||||
}
|
||||
|
||||
const indexes = new WeakMap<readonly InstalledSkill[], ReturnType<typeof buildIndex>>();
|
||||
function assertCatalogCurrent(skills: readonly InstalledSkill[], signal?: AbortSignal) {
|
||||
signal?.throwIfAborted();
|
||||
for (const skill of skills) {
|
||||
skill.assertCurrent?.();
|
||||
}
|
||||
}
|
||||
|
||||
function assertBodyReadable(canReadInstructions: () => boolean) {
|
||||
if (!canReadInstructions()) {
|
||||
throw new ToolInputError("Skill instruction-read permission changed during search.");
|
||||
}
|
||||
}
|
||||
|
||||
async function buildIndex(
|
||||
skills: readonly InstalledSkill[],
|
||||
canReadInstructions: () => boolean,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const selected = skills
|
||||
.toSorted((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
||||
.slice(0, MAX_BODY_SKILLS);
|
||||
const bodyBytes = Math.min(
|
||||
MAX_BODY_BYTES,
|
||||
Math.floor(MAX_INDEX_BODY_BYTES / Math.max(1, selected.length)),
|
||||
);
|
||||
const documents: Array<{ value: InstalledSkill; terms: string[] }> = [];
|
||||
let truncatedBodies = 0;
|
||||
for (let offset = 0; offset < selected.length; offset += READ_CONCURRENCY) {
|
||||
const batch = await Promise.allSettled(
|
||||
selected.slice(offset, offset + READ_CONCURRENCY).map(async (skill) => {
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
skill.assertCurrent?.();
|
||||
assertBodyReadable(canReadInstructions);
|
||||
const body = await readSearchBody(skill, bodyBytes, signal);
|
||||
signal?.throwIfAborted();
|
||||
skill.assertCurrent?.();
|
||||
assertBodyReadable(canReadInstructions);
|
||||
if (!body) {
|
||||
return undefined;
|
||||
}
|
||||
if (body.truncated) {
|
||||
truncatedBodies += 1;
|
||||
}
|
||||
return { value: skill, terms: tokenizeDocument(body.content) };
|
||||
} catch {
|
||||
// Unreadable bodies retain metadata. Revocation and cancellation must
|
||||
// propagate instead of becoming partial search coverage.
|
||||
signal?.throwIfAborted();
|
||||
skill.assertCurrent?.();
|
||||
assertBodyReadable(canReadInstructions);
|
||||
return undefined;
|
||||
}
|
||||
}),
|
||||
);
|
||||
// Join all owned reads before surfacing cancellation or authority loss.
|
||||
for (const result of batch) {
|
||||
if (result.status === "rejected") {
|
||||
throw result.reason;
|
||||
}
|
||||
if (result.value) {
|
||||
documents.push(result.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
assertCatalogCurrent(skills, signal);
|
||||
assertBodyReadable(canReadInstructions);
|
||||
return {
|
||||
metadata: buildMetadataIndex(skills),
|
||||
bodies: buildLexicalIndex(documents),
|
||||
coverage: {
|
||||
bodyIndexed: documents.length,
|
||||
metadataOnly: skills.length - documents.length,
|
||||
truncatedBodies,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function readSearchBody(skill: InstalledSkill, maxBytes: number, signal?: AbortSignal) {
|
||||
const inline = skill.source.readContent;
|
||||
if (typeof inline === "string") {
|
||||
const prefix = inline.slice(0, maxBytes);
|
||||
const bytes = Buffer.from(prefix);
|
||||
return {
|
||||
content: bytes.subarray(0, maxBytes).toString("utf8"),
|
||||
truncated: prefix.length < inline.length || bytes.length > maxBytes,
|
||||
};
|
||||
}
|
||||
if (skill.readSearchContent) {
|
||||
const content = await skill.readSearchContent(maxBytes, signal);
|
||||
if (Buffer.byteLength(content) > maxBytes) {
|
||||
throw new Error("Skill search reader exceeded its byte budget.");
|
||||
}
|
||||
return { content, truncated: false };
|
||||
}
|
||||
// An opaque reader is not permission to read an ambient host path, nor a
|
||||
// guarantee that a whole-resource read is bounded enough for catalog search.
|
||||
if (skill.reader) {
|
||||
return undefined;
|
||||
}
|
||||
const { buffer } = await readLocalFileSafely({ filePath: skill.source.filePath, maxBytes });
|
||||
return { content: buffer.toString("utf8"), truncated: false };
|
||||
}
|
||||
|
||||
const indexes = new WeakMap<readonly InstalledSkill[], Awaited<ReturnType<typeof buildIndex>>>();
|
||||
const pendingIndexes = new WeakMap<readonly InstalledSkill[], Promise<void>>();
|
||||
|
||||
/** Search only the prepared, eligible catalog. No filesystem or marketplace discovery. */
|
||||
export function searchInstalledSkills(
|
||||
export async function searchInstalledSkills(
|
||||
skills: readonly InstalledSkill[],
|
||||
query: string,
|
||||
limit = 5,
|
||||
): { skills: Array<{ name: string; description: string; location: string }>; hasMore: boolean } {
|
||||
signal?: AbortSignal,
|
||||
canReadInstructions: () => boolean = () => false,
|
||||
): Promise<{
|
||||
skills: Array<{ name: string; description: string; location: string }>;
|
||||
hasMore: boolean;
|
||||
coverage?: { bodyIndexed: number; metadataOnly: number; truncatedBodies: number };
|
||||
}> {
|
||||
const needle = query.trim();
|
||||
if (!needle || needle.length > MAX_QUERY_CHARS) {
|
||||
throw new ToolInputError(`query must contain 1-${MAX_QUERY_CHARS} characters.`);
|
||||
|
|
@ -38,17 +157,57 @@ export function searchInstalledSkills(
|
|||
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_RESULTS) {
|
||||
throw new ToolInputError(`limit must be an integer between 1 and ${MAX_RESULTS}.`);
|
||||
}
|
||||
assertCatalogCurrent(skills, signal);
|
||||
const includeBodies = canReadInstructions();
|
||||
let index = indexes.get(skills);
|
||||
if (!index) {
|
||||
index = buildIndex(skills);
|
||||
indexes.set(skills, index);
|
||||
if (includeBodies) {
|
||||
while (!index) {
|
||||
const pending = pendingIndexes.get(skills);
|
||||
if (pending) {
|
||||
await racePromiseWithAbortSignal(pending, signal);
|
||||
assertCatalogCurrent(skills, signal);
|
||||
assertBodyReadable(canReadInstructions);
|
||||
index = indexes.get(skills);
|
||||
continue;
|
||||
}
|
||||
// One caller owns the bounded reads. Other callers can cancel their wait
|
||||
// independently or build afresh after a cancelled owner's reads have joined.
|
||||
const build = buildIndex(skills, canReadInstructions, signal);
|
||||
pendingIndexes.set(
|
||||
skills,
|
||||
build.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
),
|
||||
);
|
||||
try {
|
||||
index = await build;
|
||||
assertBodyReadable(canReadInstructions);
|
||||
indexes.set(skills, index);
|
||||
} finally {
|
||||
pendingIndexes.delete(skills);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Tool-intent expansions (web, cron, etc.) do not belong to skill matching.
|
||||
const terms = [...new Set(tokenizeDocument(needle))].map((term) => ({ term, weight: 1 }));
|
||||
const ranked = scoreLexical(index, terms);
|
||||
const scores = new Map<InstalledSkill, number>();
|
||||
for (const { value, score } of scoreLexical(
|
||||
index?.metadata ?? buildMetadataIndex(skills),
|
||||
terms,
|
||||
)) {
|
||||
scores.set(value, score * 2);
|
||||
}
|
||||
if (includeBodies && index) {
|
||||
assertBodyReadable(canReadInstructions);
|
||||
for (const { value, score } of scoreLexical(index.bodies, terms)) {
|
||||
scores.set(value, (scores.get(value) ?? 0) + score);
|
||||
}
|
||||
}
|
||||
const ranked = [...scores].map(([value, score]) => ({ value, score }));
|
||||
const exact = skills.find((skill) => skill.name.toLowerCase() === needle.toLowerCase());
|
||||
if (exact && !ranked.some(({ value }) => value === exact)) {
|
||||
ranked.push({ value: exact, score: 0, matchedLiteral: true });
|
||||
ranked.push({ value: exact, score: 0 });
|
||||
}
|
||||
ranked.sort(
|
||||
(a, b) =>
|
||||
|
|
@ -70,7 +229,19 @@ export function searchInstalledSkills(
|
|||
}
|
||||
results.push(result);
|
||||
}
|
||||
return { skills: results, hasMore: ranked.length > results.length };
|
||||
assertCatalogCurrent(skills, signal);
|
||||
if (includeBodies) {
|
||||
assertBodyReadable(canReadInstructions);
|
||||
}
|
||||
const coverage =
|
||||
includeBodies && index
|
||||
? index.coverage
|
||||
: { bodyIndexed: 0, metadataOnly: skills.length, truncatedBodies: 0 };
|
||||
return {
|
||||
skills: results,
|
||||
hasMore: ranked.length > results.length,
|
||||
...(coverage.metadataOnly || coverage.truncatedBodies ? { coverage } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Instructions are delivered whole or rejected, never silently truncated. */
|
||||
|
|
@ -84,6 +255,7 @@ export async function readInstalledSkill(
|
|||
if (!skill) {
|
||||
throw new ToolInputError(`Unknown installed skill ${JSON.stringify(name)}.`);
|
||||
}
|
||||
skill.assertCurrent?.();
|
||||
const content =
|
||||
!skill.promptListed && typeof skill.source.readContent !== "string" && !skill.reader
|
||||
? (
|
||||
|
|
@ -94,6 +266,7 @@ export async function readInstalledSkill(
|
|||
).buffer.toString("utf8")
|
||||
: await readCodeModeSkill(skill, signal);
|
||||
signal?.throwIfAborted();
|
||||
skill.assertCurrent?.();
|
||||
if (!skill.promptListed && Buffer.byteLength(content, "utf8") > MAX_SKILL_INSTRUCTION_BYTES) {
|
||||
throw new ToolInputError(
|
||||
`Skill ${JSON.stringify(name)} exceeds the ${MAX_SKILL_INSTRUCTION_BYTES}-byte instruction limit.`,
|
||||
|
|
|
|||
|
|
@ -40,21 +40,28 @@ export function prepareInstalledSkillCatalog(params: {
|
|||
)
|
||||
.map((skill) => {
|
||||
let reader: CodeModeSkillReader | undefined;
|
||||
let readSearchContent: InstalledSkill["readSearchContent"];
|
||||
if (sandbox?.enabled) {
|
||||
reader = async ({ location, signal }) => {
|
||||
const readInstructions = async (maxBytes: number | undefined, signal?: AbortSignal) => {
|
||||
params.assertCurrent?.();
|
||||
if (!sandbox.fsBridge) {
|
||||
throw new Error("Sandbox filesystem bridge is unavailable for skill reads.");
|
||||
}
|
||||
const content = await sandbox.fsBridge.readFile({
|
||||
filePath: location,
|
||||
filePath: skill.filePath,
|
||||
cwd: sandbox.containerWorkdir,
|
||||
signal,
|
||||
maxBytes: promptListed.has(skill.name) ? undefined : MAX_SKILL_INSTRUCTION_BYTES,
|
||||
maxBytes,
|
||||
});
|
||||
params.assertCurrent?.();
|
||||
return content.toString("utf8");
|
||||
};
|
||||
reader = ({ signal }) =>
|
||||
readInstructions(
|
||||
promptListed.has(skill.name) ? undefined : MAX_SKILL_INSTRUCTION_BYTES,
|
||||
signal,
|
||||
);
|
||||
readSearchContent = readInstructions;
|
||||
} else if (
|
||||
workspace?.loadSkills &&
|
||||
(skill.fileHost === "workspace" ||
|
||||
|
|
@ -72,6 +79,8 @@ export function prepareInstalledSkillCatalog(params: {
|
|||
params.assertCurrent?.();
|
||||
return content;
|
||||
};
|
||||
// This resource owner supports whole reads only. Its document bridge
|
||||
// is not a substitute for bounded skill-resource authority.
|
||||
}
|
||||
return {
|
||||
name: skill.name,
|
||||
|
|
@ -83,6 +92,8 @@ export function prepareInstalledSkillCatalog(params: {
|
|||
readContent: sandbox?.enabled ? undefined : skill.readContent,
|
||||
},
|
||||
reader,
|
||||
assertCurrent: params.assertCurrent,
|
||||
readSearchContent,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,32 +20,38 @@ function skill(name: string, description: string, content = "Whole instructions"
|
|||
}
|
||||
|
||||
describe("installed skill catalog", () => {
|
||||
it("ranks an exact identity first and searches the entire prepared catalog", () => {
|
||||
it("ranks an exact identity first and searches the entire prepared catalog", async () => {
|
||||
const skills = [
|
||||
skill("alpha", "Release checks"),
|
||||
skill("releases", "Prepare a software release"),
|
||||
skill("zulu", "Audit database migrations"),
|
||||
];
|
||||
expect(searchInstalledSkills(skills, "releases", 1).skills[0]?.name).toBe("releases");
|
||||
expect(searchInstalledSkills(skills, "database migration").skills).toEqual([
|
||||
expect((await searchInstalledSkills(skills, "releases", 1)).skills[0]?.name).toBe("releases");
|
||||
expect((await searchInstalledSkills(skills, "database migration")).skills).toEqual([
|
||||
{ name: "zulu", description: "Audit database migrations", location: "/skills/zulu/SKILL.md" },
|
||||
]);
|
||||
expect(searchInstalledSkills(skills, "unrelated")).toEqual({ skills: [], hasMore: false });
|
||||
expect(await searchInstalledSkills(skills, "unrelated")).toEqual({
|
||||
skills: [],
|
||||
hasMore: false,
|
||||
coverage: { bodyIndexed: 0, metadataOnly: 3, truncatedBodies: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it("bounds metadata and uses deterministic ties without tool-specific expansions", () => {
|
||||
it("bounds metadata and uses deterministic ties without tool-specific expansions", async () => {
|
||||
const skills = Array.from({ length: 25 }, (_, i) =>
|
||||
skill(`guide-${String(i).padStart(2, "0")}`, `Deploy ${"x".repeat(1_000)}`),
|
||||
).toReversed();
|
||||
const result = searchInstalledSkills(skills, "deploy", 20);
|
||||
const result = await searchInstalledSkills(skills, "deploy", 20);
|
||||
expect(result.skills).toHaveLength(20);
|
||||
expect(result.hasMore).toBe(true);
|
||||
expect(result.skills[0]?.name).toBe("guide-00");
|
||||
expect(result.skills.every((entry) => entry.description.length <= 512)).toBe(true);
|
||||
expect(searchInstalledSkills([skill("web", "Search the web")], "today").skills).toEqual([]);
|
||||
expect(() => searchInstalledSkills(skills, " ")).toThrow("query");
|
||||
expect(() => searchInstalledSkills(skills, "x".repeat(1_001))).toThrow("query");
|
||||
expect(() => searchInstalledSkills(skills, "deploy", 21)).toThrow("limit");
|
||||
expect((await searchInstalledSkills([skill("web", "Search the web")], "today")).skills).toEqual(
|
||||
[],
|
||||
);
|
||||
await expect(searchInstalledSkills(skills, " ")).rejects.toThrow("query");
|
||||
await expect(searchInstalledSkills(skills, "x".repeat(1_001))).rejects.toThrow("query");
|
||||
await expect(searchInstalledSkills(skills, "deploy", 21)).rejects.toThrow("limit");
|
||||
});
|
||||
|
||||
it("reads only an exact eligible identity through its owner and preserves the whole body", async () => {
|
||||
|
|
@ -85,6 +91,12 @@ describe("installed skill catalog", () => {
|
|||
const guide = skill("guide", "Guide");
|
||||
guide.source = { filePath };
|
||||
expect(await readInstalledSkill([guide], "guide")).toBe("Complete instructions");
|
||||
await fs.writeFile(filePath, `Canary ${"x".repeat(16 * 1024)}`);
|
||||
expect(await searchInstalledSkills([guide], "canary", 5, undefined, () => true)).toMatchObject({
|
||||
skills: [],
|
||||
coverage: { bodyIndexed: 0, metadataOnly: 1 },
|
||||
});
|
||||
expect(await readInstalledSkill([guide], "guide")).toContain("Canary");
|
||||
await fs.truncate(filePath, 256 * 1024 + 1);
|
||||
await expect(readInstalledSkill([guide], "guide")).rejects.toThrow(/large|size|limit/i);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,92 @@
|
|||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { expect, it } from "vitest";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { readLocalFileSafely } from "../../infra/fs-safe.js";
|
||||
import { createDeferredCore } from "../../shared/deferred.js";
|
||||
import { withTempDir } from "../../test-utils/temp-dir.js";
|
||||
import {
|
||||
finalizeAgentToolAvailability,
|
||||
markAgentToolExecutionUnavailable,
|
||||
} from "../agent-tool-availability.js";
|
||||
import { createInstalledSkillTools } from "./installed-skill-tools.js";
|
||||
|
||||
it("requires the effective native read tool for body I/O and cached body matches", async () => {
|
||||
await withTempDir("installed-skill-authority-", async (dir) => {
|
||||
const filePath = path.join(dir, "SKILL.md");
|
||||
await writeFile(filePath, "Private canary deployment instructions");
|
||||
let reads = 0;
|
||||
const tools = createInstalledSkillTools([
|
||||
{
|
||||
name: "guide",
|
||||
description: "Operations",
|
||||
location: filePath,
|
||||
source: { filePath },
|
||||
readSearchContent: async (maxBytes) => {
|
||||
reads += 1;
|
||||
return (await readLocalFileSafely({ filePath, maxBytes })).buffer.toString("utf8");
|
||||
},
|
||||
},
|
||||
]);
|
||||
const search = expectDefined(tools[0], "search");
|
||||
const read = expectDefined(tools[1], "read");
|
||||
finalizeAgentToolAvailability([search]);
|
||||
expect((await search.execute("denied", { query: "canary" })).details).toMatchObject({
|
||||
skills: [],
|
||||
coverage: { bodyIndexed: 0, metadataOnly: 1 },
|
||||
});
|
||||
expect((await search.execute("metadata", { query: "operations" })).details).toMatchObject({
|
||||
skills: [{ name: "guide" }],
|
||||
});
|
||||
expect(reads).toBe(0);
|
||||
finalizeAgentToolAvailability(tools);
|
||||
expect((await search.execute("allowed", { query: "canary" })).details).toMatchObject({
|
||||
skills: [{ name: "guide" }],
|
||||
});
|
||||
expect(reads).toBe(1);
|
||||
finalizeAgentToolAvailability(tools, { toolExecutionAllow: ["skills_search"] });
|
||||
expect((await search.execute("revoked", { query: "canary" })).details).toMatchObject({
|
||||
skills: [],
|
||||
});
|
||||
finalizeAgentToolAvailability([search, { ...read }]);
|
||||
expect((await search.execute("shadowed", { query: "canary" })).details).toMatchObject({
|
||||
skills: [],
|
||||
});
|
||||
markAgentToolExecutionUnavailable(read);
|
||||
finalizeAgentToolAvailability(tools);
|
||||
expect((await search.execute("execution-denied", { query: "canary" })).details).toMatchObject({
|
||||
skills: [],
|
||||
});
|
||||
expect(reads).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not publish an in-flight body index across read revocation and regrant", async () => {
|
||||
const body = createDeferredCore<string>();
|
||||
const reader = vi.fn(() => body.promise);
|
||||
const tools = createInstalledSkillTools([
|
||||
{
|
||||
name: "guide",
|
||||
description: "Operations",
|
||||
location: "/skills/guide/SKILL.md",
|
||||
source: { filePath: "/skills/guide/SKILL.md" },
|
||||
readSearchContent: reader,
|
||||
},
|
||||
]);
|
||||
const search = expectDefined(tools[0], "search");
|
||||
finalizeAgentToolAvailability(tools);
|
||||
const pending = search.execute("in-flight", { query: "canary" });
|
||||
const rejected = expect(pending).rejects.toThrow("permission changed");
|
||||
finalizeAgentToolAvailability([search]);
|
||||
finalizeAgentToolAvailability(tools);
|
||||
body.resolve("Canary deployment");
|
||||
await rejected;
|
||||
expect((await search.execute("regranted", { query: "canary" })).details).toMatchObject({
|
||||
skills: [{ name: "guide" }],
|
||||
});
|
||||
expect(reader).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("searches and reads through the model-facing tool contract without reading other paths", async () => {
|
||||
const tools = createInstalledSkillTools([
|
||||
{
|
||||
|
|
@ -14,6 +99,7 @@ it("searches and reads through the model-facing tool contract without reading ot
|
|||
},
|
||||
},
|
||||
]);
|
||||
finalizeAgentToolAvailability(tools);
|
||||
const search = expectDefined(tools[0], "installed skill search tool");
|
||||
const read = expectDefined(tools[1], "installed skill read tool");
|
||||
expect((await search.execute("find", { query: "publish release" })).details).toEqual({
|
||||
|
|
@ -34,3 +120,186 @@ it("searches and reads through the model-facing tool contract without reading ot
|
|||
);
|
||||
expect(createInstalledSkillTools([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("discovers a body-only capability through its reader without exposing instructions", async () => {
|
||||
const content =
|
||||
"# Release\n\nUse a canary deployment.\nPreserve the complete rollback procedure.";
|
||||
const reader = vi.fn(async () => content);
|
||||
const tools = createInstalledSkillTools([
|
||||
{
|
||||
name: "release-guide",
|
||||
description: "Publish a software release",
|
||||
location: "/skills/release/SKILL.md",
|
||||
source: { filePath: "/skills/release/SKILL.md" },
|
||||
reader,
|
||||
readSearchContent: reader,
|
||||
},
|
||||
]);
|
||||
finalizeAgentToolAvailability(tools);
|
||||
const search = expectDefined(tools[0], "search tool");
|
||||
const read = expectDefined(tools[1], "read tool");
|
||||
const first = await search.execute("body-search", { query: "canary" });
|
||||
expect(first.details).toEqual({
|
||||
skills: [
|
||||
{
|
||||
name: "release-guide",
|
||||
description: "Publish a software release",
|
||||
location: "/skills/release/SKILL.md",
|
||||
},
|
||||
],
|
||||
hasMore: false,
|
||||
});
|
||||
expect(JSON.stringify(first)).not.toContain("rollback");
|
||||
await search.execute("cached-search", { query: "rollback" });
|
||||
expect(reader).toHaveBeenCalledTimes(1);
|
||||
expect((await read.execute("read", { name: "release-guide" })).content).toEqual([
|
||||
{ type: "text", text: content },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not serve a cached body index after its owner loses authority", async () => {
|
||||
let current = true;
|
||||
const tools = createInstalledSkillTools([
|
||||
{
|
||||
name: "guide",
|
||||
description: "Operations",
|
||||
location: "/skills/guide/SKILL.md",
|
||||
source: { filePath: "/skills/guide/SKILL.md", readContent: "Canary deployment" },
|
||||
assertCurrent: () => {
|
||||
if (!current) {
|
||||
throw new Error("Run is no longer current");
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
finalizeAgentToolAvailability(tools);
|
||||
const search = expectDefined(tools[0], "search tool");
|
||||
await search.execute("first", { query: "canary" });
|
||||
current = false;
|
||||
await expect(search.execute("retained", { query: "canary" })).rejects.toThrow(
|
||||
"no longer current",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports unreadable and bounded bodies while preserving metadata search and whole reads", async () => {
|
||||
const large = `${"x".repeat(16 * 1024)}\nUnabridged ending`;
|
||||
const tools = createInstalledSkillTools([
|
||||
{
|
||||
name: "large",
|
||||
description: "Deployment guide",
|
||||
location: "/skills/large/SKILL.md",
|
||||
source: { filePath: "/skills/large/SKILL.md", readContent: large },
|
||||
},
|
||||
{
|
||||
name: "unavailable",
|
||||
description: "Deployment guide",
|
||||
location: "/skills/unavailable/SKILL.md",
|
||||
source: { filePath: "/skills/unavailable/SKILL.md" },
|
||||
reader: async () => {
|
||||
throw new Error("Unavailable");
|
||||
},
|
||||
},
|
||||
]);
|
||||
finalizeAgentToolAvailability(tools);
|
||||
const search = expectDefined(tools[0], "search tool");
|
||||
const read = expectDefined(tools[1], "read tool");
|
||||
expect((await search.execute("partial", { query: "deployment" })).details).toMatchObject({
|
||||
skills: [{ name: "large" }, { name: "unavailable" }],
|
||||
coverage: { bodyIndexed: 1, metadataOnly: 1, truncatedBodies: 1 },
|
||||
});
|
||||
expect((await read.execute("whole", { name: "large" })).content).toEqual([
|
||||
{ type: "text", text: large },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not cache a cancelled read as an empty body", async () => {
|
||||
const controller = new AbortController();
|
||||
const reader = vi.fn(async ({ signal }: { signal?: AbortSignal }) => {
|
||||
if (signal === controller.signal) {
|
||||
controller.abort();
|
||||
}
|
||||
return "Canary deployment";
|
||||
});
|
||||
const tools = createInstalledSkillTools([
|
||||
{
|
||||
name: "guide",
|
||||
description: "Operations",
|
||||
location: "/skills/guide/SKILL.md",
|
||||
source: { filePath: "/skills/guide/SKILL.md" },
|
||||
reader,
|
||||
readSearchContent: (_maxBytes, signal) => reader({ signal }),
|
||||
},
|
||||
]);
|
||||
finalizeAgentToolAvailability(tools);
|
||||
const search = expectDefined(tools[0], "search tool");
|
||||
const cancelled = search.execute("cancelled", { query: "canary" }, controller.signal);
|
||||
const healthy = search.execute("healthy", { query: "canary" });
|
||||
await expect(cancelled).rejects.toThrow();
|
||||
expect((await healthy).details).toMatchObject({
|
||||
skills: [{ name: "guide" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("lets a waiting caller cancel without cancelling the cold index owner", async () => {
|
||||
const body = createDeferredCore<string>();
|
||||
const reader = vi.fn(() => body.promise);
|
||||
const tools = createInstalledSkillTools([
|
||||
{
|
||||
name: "guide",
|
||||
description: "Operations",
|
||||
location: "/skills/guide/SKILL.md",
|
||||
source: { filePath: "/skills/guide/SKILL.md" },
|
||||
reader,
|
||||
readSearchContent: reader,
|
||||
},
|
||||
]);
|
||||
finalizeAgentToolAvailability(tools);
|
||||
const search = expectDefined(tools[0], "search tool");
|
||||
const controller = new AbortController();
|
||||
const owner = search.execute("owner", { query: "canary" });
|
||||
const waiter = search.execute("waiter", { query: "canary" }, controller.signal);
|
||||
const cancelled = expect(waiter).rejects.toThrow();
|
||||
controller.abort();
|
||||
try {
|
||||
await cancelled;
|
||||
} finally {
|
||||
body.resolve("Canary deployment");
|
||||
await owner;
|
||||
}
|
||||
expect((await owner).details).toMatchObject({ skills: [{ name: "guide" }] });
|
||||
expect(reader).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("bounds concurrent cold searches and retains metadata outside the body budget", async () => {
|
||||
let active = 0;
|
||||
let peak = 0;
|
||||
const reader = vi.fn(async () => {
|
||||
active += 1;
|
||||
peak = Math.max(peak, active);
|
||||
await Promise.resolve();
|
||||
active -= 1;
|
||||
return "Canary";
|
||||
});
|
||||
const tools = createInstalledSkillTools(
|
||||
Array.from({ length: 1_025 }, (_, index) => ({
|
||||
name: `guide-${String(index).padStart(4, "0")}`,
|
||||
description: "Deployment",
|
||||
location: `/skills/guide-${index}/SKILL.md`,
|
||||
source: { filePath: `/skills/guide-${index}/SKILL.md` },
|
||||
reader,
|
||||
readSearchContent: reader,
|
||||
})).toReversed(),
|
||||
);
|
||||
finalizeAgentToolAvailability(tools);
|
||||
const search = expectDefined(tools[0], "search tool");
|
||||
const [result] = await Promise.all([
|
||||
search.execute("budget", { query: "guide-1024", limit: 1 }),
|
||||
search.execute("concurrent", { query: "canary" }),
|
||||
]);
|
||||
expect(result.details).toMatchObject({
|
||||
skills: [{ name: "guide-1024" }],
|
||||
coverage: { bodyIndexed: 1_024, metadataOnly: 1, truncatedBodies: 0 },
|
||||
});
|
||||
expect(peak).toBeLessThanOrEqual(4);
|
||||
expect(reader).toHaveBeenCalledTimes(1_024);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
import { Type } from "typebox";
|
||||
import {
|
||||
bindAgentToolAvailability,
|
||||
getAgentToolAvailabilityBinding,
|
||||
type AgentToolAvailabilityBinding,
|
||||
} from "../agent-tool-availability.js";
|
||||
import {
|
||||
readInstalledSkill,
|
||||
searchInstalledSkills,
|
||||
|
|
@ -16,40 +21,61 @@ export function createInstalledSkillTools(skills: readonly InstalledSkill[]): An
|
|||
if (skills.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const readerBinding: AgentToolAvailabilityBinding = { prepare() {} };
|
||||
let readAuthority: object | undefined;
|
||||
return [
|
||||
{
|
||||
name: "skills_search",
|
||||
label: "Search Installed Skills",
|
||||
description:
|
||||
"Find relevant installed, eligible skills by task or exact name, including skills omitted from the prompt directory. Returns metadata only. Does not search ClawHub or install anything.",
|
||||
parameters: Type.Object({
|
||||
query: Type.String({ minLength: 1, maxLength: 1000 }),
|
||||
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 20 })),
|
||||
}),
|
||||
execute: async (_id, args, signal) => {
|
||||
signal?.throwIfAborted();
|
||||
const params = asToolParamsRecord(args);
|
||||
return jsonResult(
|
||||
searchInstalledSkills(
|
||||
skills,
|
||||
readToolStringParam(params, "query", { required: true }),
|
||||
readNumberParam(params, "limit"),
|
||||
),
|
||||
);
|
||||
bindAgentToolAvailability<AnyAgentTool>(
|
||||
{
|
||||
name: "skills_search",
|
||||
label: "Search Installed Skills",
|
||||
description:
|
||||
"Find relevant installed, eligible skills by task or exact name, including skills omitted from the prompt directory. Searches names and descriptions, plus bounded instruction text when skill reads are allowed. Returns metadata only; coverage reports any incomplete body indexing. Read the selected skill's whole instructions before applying it. Does not search ClawHub or install anything.",
|
||||
parameters: Type.Object({
|
||||
query: Type.String({ minLength: 1, maxLength: 1000 }),
|
||||
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 20 })),
|
||||
}),
|
||||
execute: async (_id, args, signal) => {
|
||||
signal?.throwIfAborted();
|
||||
const params = asToolParamsRecord(args);
|
||||
const authority = readAuthority;
|
||||
return jsonResult(
|
||||
await searchInstalledSkills(
|
||||
skills,
|
||||
readToolStringParam(params, "query", { required: true }),
|
||||
readNumberParam(params, "limit"),
|
||||
signal,
|
||||
() => authority !== undefined && readAuthority === authority,
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "skills_read",
|
||||
label: "Read Installed Skill",
|
||||
description:
|
||||
"Load complete SKILL.md instructions for an exact installed skill name. Use a known name directly; search is not required first. Does not execute the skill or grant additional tool permissions.",
|
||||
parameters: Type.Object({ name: Type.String({ minLength: 1 }) }),
|
||||
execute: async (_id, args, signal) => {
|
||||
const params = asToolParamsRecord(args);
|
||||
const name = readToolStringParam(params, "name", { required: true });
|
||||
const content = await readInstalledSkill(skills, name, signal);
|
||||
return { content: [{ type: "text", text: content }], details: { name, content } };
|
||||
{
|
||||
prepare(_tool, callableTools) {
|
||||
const reader = callableTools.get("skills_read");
|
||||
if (reader && getAgentToolAvailabilityBinding(reader) === readerBinding) {
|
||||
readAuthority ??= {};
|
||||
} else {
|
||||
// A regrant gets a new identity; work started under a revoked grant stays revoked.
|
||||
readAuthority = undefined;
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
bindAgentToolAvailability<AnyAgentTool>(
|
||||
{
|
||||
name: "skills_read",
|
||||
label: "Read Installed Skill",
|
||||
description:
|
||||
"Load complete SKILL.md instructions for an exact installed skill name. Use a known name directly; search is not required first. Does not execute the skill or grant additional tool permissions.",
|
||||
parameters: Type.Object({ name: Type.String({ minLength: 1 }) }),
|
||||
execute: async (_id, args, signal) => {
|
||||
const params = asToolParamsRecord(args);
|
||||
const name = readToolStringParam(params, "name", { required: true });
|
||||
const content = await readInstalledSkill(skills, name, signal);
|
||||
return { content: [{ type: "text", text: content }], details: { name, content } };
|
||||
},
|
||||
},
|
||||
readerBinding,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue