Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/hub/src/skills-mount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export function mountSkills(deps: {
const skill = byName.get(name);
if (skill === undefined) {
// Pinning a skill the pusher cannot see would advertise a
// skill `load_skill` will refuse to fetch at run time. Reject
// skill `skills_load` will refuse to fetch at run time. Reject
// the push instead of shipping an index that lies.
throw new SkillRegistryError(
"not_found",
Expand Down
4 changes: 2 additions & 2 deletions packages/agent-directory/src/agent-workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,11 @@ export const AGENT_DEFINITION_STEP_ID = "agent";
* The tool package that turns a name in the `<available_skills>` index
* into an actual skill body at run time. A definition that pins skills
* must pin this too, or its prompt would tell the model to call a
* `load_skill` tool that does not exist.
* `skills_load` tool that does not exist.
*/
export const SKILLS_TOOL_PACKAGE_PIN = {
name: "@corbits/tools-skills",
version: "0.0.1",
version: "0.0.2",
} as const;

/**
Expand Down
6 changes: 3 additions & 3 deletions packages/agent-directory/test/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1402,11 +1402,11 @@ test("a create request indexes its pinned skills into the stored system prompt",
const prompt = promptFrom(workflowJson);
expect(prompt.startsWith("You are a careful research assistant.")).toBe(true);
expect(prompt).toContain("- web-research: What web-research does.");
expect(prompt).toContain("load_skill");
// The prompt tells the model to call `load_skill`, so the bundle that
expect(prompt).toContain("skills_load");
// The prompt tells the model to call `skills_load`, so the bundle that
// provides it must be pinned on the same push.
expect(pinsFrom(workflowJson)).toEqual([
{ name: "@corbits/tools-skills", version: "0.0.1" },
{ name: "@corbits/tools-skills", version: "0.0.2" },
]);
});

Expand Down
2 changes: 1 addition & 1 deletion packages/mocks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ ollama.onChat(() => ollama.reply.malformedToolName());
| `.truncatedToolArgs(name, rawArgs)` | Arguments that are NOT valid JSON — a mid-stream cutoff, reaching the wire byte-for-byte. |
| `.refusal(text?)` | The model declines to answer. |
| `.oversized(approxChars?)` | A large enough text blob to exercise a truncation or blob-spill path. |
| `.hallucinatedToolName()` | A plausible-but-nonexistent tool name (`skills_load` instead of the real `load_skill`). |
| `.hallucinatedToolName()` | A model calling `load_skill` instead of the canonical `skills_load`. |

`sequence([...])` scripts one reply per turn — turn 1 malformed, turn 2
normal — so CL-6478's "does the room survive?" contract is directly
Expand Down
8 changes: 4 additions & 4 deletions packages/mocks/src/ollama/scenarios.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,20 +146,20 @@ describe("ollama.reply adversarial scenarios", () => {
expect(body.choices[0]?.finish_reason).toBe("length");
});

test("hallucinatedToolName calls a plausible but nonexistent tool", async () => {
test("hallucinatedToolName calls load_skill instead of skills_load", async () => {
const ollama = createOllamaMock();
ollama.onChat(() => ollama.reply.hallucinatedToolName());

const response = await chat(ollama.fetch, {
model: "qwen3.8:27b",
messages: [{ role: "user", content: "load the git skill" }],
tools: [{ type: "function", function: { name: "load_skill" } }],
tools: [{ type: "function", function: { name: "skills_load" } }],
});
const body = (await response.json()) as ChatCompletionBody;

const name = body.choices[0]?.message.tool_calls?.[0]?.function.name;
expect(name).toBe("skills_load");
expect(name).not.toBe("load_skill");
expect(name).toBe("load_skill");
expect(name).not.toBe("skills_load");
});
});

Expand Down
12 changes: 7 additions & 5 deletions packages/mocks/src/ollama/scenarios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ import type { OllamaChatReply } from "./types";
const CL_6478_MALFORMED_TOOL_NAME =
"@intx/tools-posix/sidecar-bundle:run_shell\n</parameter";

// Round 1: a model called this instead of the real tool, `load_skill`
// (packages/tools-skills/src/tool.ts). Plausible-sounding, never declared.
const HALLUCINATED_TOOL_NAME = "skills_load";
// A model calling the tool by its old, retired name instead of the
// canonical `skills_load` (packages/tools-skills/src/tool.ts). `load_skill`
// no longer exists as a tool; this fixture reproduces a model still
// guessing it.
const HALLUCINATED_TOOL_NAME = "load_skill";

function nameOfExactLength(length: number): string {
const prefix = "namespaced_tool_probe_";
Expand Down Expand Up @@ -51,8 +53,8 @@ export type AdversarialReplies = {
/** A large enough text blob to exercise a truncation or blob-spill path
* (`finishReason: "length"` -- the model hit its token cap). */
oversized(approxChars?: number): OllamaChatReply;
/** A plausible-but-nonexistent tool name -- round 1 saw a model call
* `skills_load` when the real tool is `load_skill`. */
/** A plausible-but-nonexistent tool name -- a model calling `load_skill`,
* the tool's retired name, when the canonical tool is `skills_load`. */
hallucinatedToolName(): OllamaChatReply;
};

Expand Down
2 changes: 1 addition & 1 deletion packages/skills/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export {
export {
AVAILABLE_SKILLS_CLOSE_TAG,
AVAILABLE_SKILLS_OPEN_TAG,
LOAD_SKILL_TOOL,
SKILLS_LOAD_TOOL,
buildAvailableSkillsStanza,
stripAvailableSkillsStanza,
withAvailableSkills,
Expand Down
4 changes: 2 additions & 2 deletions packages/skills/src/prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ describe("buildAvailableSkillsStanza", () => {
expect(stanza).toContain("- summarize: Condenses a long thread.");
});

test("names load_skill as the way to read a body", () => {
expect(buildAvailableSkillsStanza(PINNED)).toContain("load_skill");
test("names skills_load as the way to read a body", () => {
expect(buildAvailableSkillsStanza(PINNED)).toContain("skills_load");
});

test("is empty when nothing is pinned", () => {
Expand Down
6 changes: 3 additions & 3 deletions packages/skills/src/prompt.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// The pinned-skills prompt index. A definition that pins skills gets an
// `<available_skills>` stanza appended to its system prompt at push
// time — name and description only, plus the instruction to call
// `load_skill` for the body.
// `skills_load` for the body.
//
// Index-only is the whole point: a skill body is arbitrarily long and
// most turns need none of it, so inlining every pinned body would burn
Expand All @@ -10,7 +10,7 @@
export const AVAILABLE_SKILLS_OPEN_TAG = "<available_skills>";
export const AVAILABLE_SKILLS_CLOSE_TAG = "</available_skills>";

export const LOAD_SKILL_TOOL = "load_skill";
export const SKILLS_LOAD_TOOL = "skills_load";

export type PinnedSkillIndexEntry = {
readonly name: string;
Expand All @@ -31,7 +31,7 @@ export function buildAvailableSkillsStanza(
AVAILABLE_SKILLS_OPEN_TAG,
"These skills are available to you. Only their names and descriptions",
`are listed here. To read a skill's full instructions, call the`,
`\`${LOAD_SKILL_TOOL}\` tool with that skill's name — never assume its`,
`\`${SKILLS_LOAD_TOOL}\` tool with that skill's name — never assume its`,
"contents from the description alone.",
"",
...lines,
Expand Down
13 changes: 13 additions & 0 deletions packages/tool-registry-publish/src/describe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,17 @@ describe("describeCorbitsToolPackages", () => {
const second = await describeCorbitsToolPackages();
expect(second).toBe(first);
});

test("tools-skills grants exactly one qualifiedId for skills_load, not the old load_skill name", async () => {
const descriptions = await describeCorbitsToolPackages();
const skills = descriptions.find(
(description) => description.name === "@corbits/tools-skills",
);
expect(skills).toBeDefined();
const qualifiedIds = skills?.tools.map((tool) => tool.qualifiedId) ?? [];
expect(qualifiedIds).toContain("@corbits/tools-skills/skills:skills_load");
expect(qualifiedIds).not.toContain(
"@corbits/tools-skills/skills:load_skill",
);
});
});
4 changes: 2 additions & 2 deletions packages/tools-skills/README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
# @corbits/tools-skills

`skills_list`, `skills_search`, and `load_skill` as an `@intx/agent` tool
`skills_list`, `skills_search`, and `skills_load` as an `@intx/agent` tool
bundle — the agent-facing half of the workbench's skill registry
(`@corbits/skills`).

A definition's pinned skills already appear in its system prompt's
`<available_skills>` index, with names and descriptions only.
`load_skill` is how a model turns one of those names into the actual
`skills_load` is how a model turns one of those names into the actual
instructions, so the index stays cheap and only the skills a turn truly
needs are paid for in context.

Expand Down
4 changes: 2 additions & 2 deletions packages/tools-skills/package.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"name": "@corbits/tools-skills",
"private": true,
"description": "skills_list / skills_search / load_skill as an @intx/agent tool bundle: calls the sanctioned workflow-skills HTTP surface (@corbits/skills) with the sidecar's own bearer token and the run's own address, and fails closed rather than returning an empty registry",
"version": "0.0.1",
"description": "skills_list / skills_search / skills_load as an @intx/agent tool bundle: calls the sanctioned workflow-skills HTTP surface (@corbits/skills) with the sidecar's own bearer token and the run's own address, and fails closed rather than returning an empty registry",
"version": "0.0.2",
"license": "LGPL-2.1-or-later",
"type": "module",
"exports": {
Expand Down
2 changes: 1 addition & 1 deletion packages/tools-skills/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ export {
} from "./client";
export {
skillsTools,
LOAD_SKILL_TOOL,
SKILLS_LIST_TOOL,
SKILLS_LOAD_TOOL,
SKILLS_SEARCH_TOOL,
type WorkflowSkillsToolEnv,
} from "./tool";
27 changes: 18 additions & 9 deletions packages/tools-skills/src/tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { afterEach, expect, test } from "bun:test";
import type { ToolCall } from "@intx/types/runtime";

import {
LOAD_SKILL_TOOL,
SKILLS_LIST_TOOL,
SKILLS_LOAD_TOOL,
SKILLS_SEARCH_TOOL,
skillsTools,
} from "./tool";
Expand Down Expand Up @@ -31,12 +31,12 @@ function stubFetch(respond: () => Response | Promise<Response>): void {
globalThis.fetch = (async () => respond()) as unknown as typeof fetch;
}

test("declares skills_list, skills_search, and load_skill", () => {
test("declares skills_list, skills_search, and skills_load", () => {
const bundle = skillsTools(testEnv());
expect(bundle.definitions.map((d) => d.name)).toEqual([
SKILLS_LIST_TOOL,
SKILLS_SEARCH_TOOL,
LOAD_SKILL_TOOL,
SKILLS_LOAD_TOOL,
]);
});

Expand Down Expand Up @@ -100,7 +100,7 @@ test("skills_search requires a query", async () => {
expect(String(result.content)).toContain("requires a query");
});

test("load_skill returns the skill body", async () => {
test("skills_load returns the skill body", async () => {
stubFetch(
() =>
new Response(
Expand All @@ -114,32 +114,41 @@ test("load_skill returns the skill body", async () => {
),
);
const result = await skillsTools(testEnv()).run(
callFor(LOAD_SKILL_TOOL, { name: "triage" }),
callFor(SKILLS_LOAD_TOOL, { name: "triage" }),
new AbortController().signal,
);
expect(result.isError).toBeFalsy();
expect(String(result.content)).toContain("Pick one label.");
});

test("load_skill surfaces a missing skill as an error rather than inventing one", async () => {
test("skills_load surfaces a missing skill as an error rather than inventing one", async () => {
stubFetch(() => new Response("nope", { status: 404 }));
const result = await skillsTools(testEnv()).run(
callFor(LOAD_SKILL_TOOL, { name: "triage" }),
callFor(SKILLS_LOAD_TOOL, { name: "triage" }),
new AbortController().signal,
);
expect(result.isError).toBe(true);
expect(String(result.content)).toContain("404");
});

test("load_skill requires a name", async () => {
test("skills_load requires a name", async () => {
const result = await skillsTools(testEnv()).run(
callFor(LOAD_SKILL_TOOL),
callFor(SKILLS_LOAD_TOOL),
new AbortController().signal,
);
expect(result.isError).toBe(true);
expect(String(result.content)).toContain("requires a skill name");
});

test("load_skill is no longer a recognized tool name", async () => {
const result = await skillsTools(testEnv()).run(
callFor("load_skill", { name: "triage" }),
new AbortController().signal,
);
expect(result.isError).toBe(true);
expect(String(result.content)).toContain("unknown tool");
});

test("an unknown tool name is an error result, not a throw", async () => {
const result = await skillsTools(testEnv()).run(
callFor("skills_delete"),
Expand Down
18 changes: 9 additions & 9 deletions packages/tools-skills/src/tool.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
// The `@corbits/tools-skills` bundle: `skills_list`, `skills_search`,
// and `load_skill` — the agent-facing half of the skill registry. A
// and `skills_load` — the agent-facing half of the skill registry. A
// definition's pinned skills are already named in its system prompt's
// `<available_skills>` index (`@corbits/skills`' `withAvailableSkills`);
// `load_skill` is how the model turns one of those names into the actual
// `skills_load` is how the model turns one of those names into the actual
// instructions, so the index stays cheap and only the skills a turn
// truly needs are paid for in context.
//
Expand All @@ -19,7 +19,7 @@ import { listSkills, loadSkill, searchSkills } from "./client";

export const SKILLS_LIST_TOOL = "skills_list";
export const SKILLS_SEARCH_TOOL = "skills_search";
export const LOAD_SKILL_TOOL = "load_skill";
export const SKILLS_LOAD_TOOL = "skills_load";

/** Env this bundle needs beyond `BaseEnv`: the run's hub-reach credential. */
export interface WorkflowSkillsToolEnv extends BaseEnv {
Expand Down Expand Up @@ -80,13 +80,13 @@ async function runSkillsSearch(
}
}

async function runLoadSkill(
async function runSkillsLoad(
env: WorkflowSkillsToolEnv,
call: ToolCall,
): Promise<ToolResult> {
const name = call.arguments["name"];
if (typeof name !== "string" || name === "") {
return errorResult(call.id, new Error("load_skill requires a skill name"));
return errorResult(call.id, new Error("skills_load requires a skill name"));
}
try {
const skill = await loadSkill(clientConfig(env), name);
Expand All @@ -102,7 +102,7 @@ export const skillsTools = defineTool<WorkflowSkillsToolEnv>({
definitions: [
{ name: SKILLS_LIST_TOOL },
{ name: SKILLS_SEARCH_TOOL },
{ name: LOAD_SKILL_TOOL },
{ name: SKILLS_LOAD_TOOL },
],
factory: (env) => ({
definitions: [
Expand Down Expand Up @@ -130,7 +130,7 @@ export const skillsTools = defineTool<WorkflowSkillsToolEnv>({
},
},
{
name: LOAD_SKILL_TOOL,
name: SKILLS_LOAD_TOOL,
description:
"Reads one skill's full instructions by name. Call this " +
"before following a skill listed in <available_skills> — the " +
Expand All @@ -154,8 +154,8 @@ export const skillsTools = defineTool<WorkflowSkillsToolEnv>({
return runSkillsList(env, call);
case SKILLS_SEARCH_TOOL:
return runSkillsSearch(env, call);
case LOAD_SKILL_TOOL:
return runLoadSkill(env, call);
case SKILLS_LOAD_TOOL:
return runSkillsLoad(env, call);
default:
return Promise.resolve(
errorResult(
Expand Down
Loading