Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
37 changes: 22 additions & 15 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,10 @@ import * as Stream from "effect/Stream";

import { resolveAttachmentPath } from "../../attachmentStore.ts";
import { resolveClaudeSdkExecutablePath } from "../../provider/Drivers/ClaudeExecutable.ts";
import { planClaudeSkillDispatch } from "../../provider/Drivers/ClaudeSkillDispatch.ts";
import {
planClaudeSkillDispatch,
type ClaudeSkillTarget,
} from "../../provider/Drivers/ClaudeSkillDispatch.ts";
import { discoverClaudeSkills } from "../../provider/Drivers/ClaudeSkills.ts";
import {
compileClaudeModelSelection,
Expand Down Expand Up @@ -1546,7 +1549,7 @@ function isSupportedClaudeImageMimeType(
export function makeClaudeUserMessage(input: {
readonly text: string;
readonly priority?: SDKUserMessage["priority"];
readonly skillNames?: ReadonlySet<string>;
readonly skills?: ReadonlyMap<string, ClaudeSkillTarget>;
// Claude echoes it as user_message_uuid on the turn that answers it.
readonly uuid?: SDKUserMessage["uuid"];
}): SDKUserMessage {
Expand All @@ -1555,9 +1558,7 @@ export function makeClaudeUserMessage(input: {
// therefore split into [leading text, "/name trailing text"] so the CLI
// runs it natively and the prose around it survives. See ClaudeSkillDispatch.
const dispatch =
input.skillNames === undefined
? undefined
: planClaudeSkillDispatch(input.text, input.skillNames);
input.skills === undefined ? undefined : planClaudeSkillDispatch(input.text, input.skills);
return {
type: "user",
message: {
Expand All @@ -1584,12 +1585,12 @@ const makeClaudeUserMessageWithAttachments = Effect.fnUntraced(function* (input:
readonly uuid?: SDKUserMessage["uuid"];
readonly attachmentsDir: string;
readonly fileSystem: FileSystem.FileSystem;
readonly skillNames?: ReadonlySet<string>;
readonly skills?: ReadonlyMap<string, ClaudeSkillTarget>;
}) {
if (input.attachments.length === 0) {
return makeClaudeUserMessage({
text: input.text,
...(input.skillNames === undefined ? {} : { skillNames: input.skillNames }),
...(input.skills === undefined ? {} : { skills: input.skills }),
...(input.priority === undefined ? {} : { priority: input.priority }),
...(input.uuid === undefined ? {} : { uuid: input.uuid }),
});
Expand All @@ -1606,9 +1607,9 @@ const makeClaudeUserMessageWithAttachments = Effect.fnUntraced(function* (input:
});

const dispatch =
input.skillNames === undefined
input.skills === undefined
? undefined
: planClaudeSkillDispatch(textWithAttachmentPaths, input.skillNames);
: planClaudeSkillDispatch(textWithAttachmentPaths, input.skills);
const content: Array<ClaudeUserContentBlock> = [];
if (dispatch?.leadingText !== undefined) {
content.push({ type: "text", text: dispatch.leadingText });
Expand Down Expand Up @@ -3460,18 +3461,24 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
// the scan is a few directory reads. A skill switched off via skillOverrides,
// or reserved for the agent with `user-invocable: false`, is left as prose:
// the CLI would answer `/name` with a notice instead of running it.
const userInvocableSkillNames = (cwd: string | null) =>
const userInvocableSkills = (cwd: string | null) =>
discoverClaudeSkills(
adapterOptions.settings,
cwd ?? undefined,
adapterOptions.environment,
).pipe(
Effect.map(
(skills) =>
new Set(
(skills): ReadonlyMap<string, ClaudeSkillTarget> =>
new Map(
skills
.filter((skill) => skill.enabled && skill.userInvocable !== false)
.map((skill) => skill.name),
.map((skill): [string, ClaudeSkillTarget] => [
skill.name,
{
path: skill.path,
...(skill.userInvocationOnly === true ? { userInvocationOnly: true } : {}),
},
]),
),
),
Effect.provideService(FileSystem.FileSystem, fileSystem),
Expand Down Expand Up @@ -8734,7 +8741,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
attachments: turnInput.message.attachments,
attachmentsDir,
fileSystem,
skillNames: yield* userInvocableSkillNames(turnInput.runtimePolicy.cwd),
skills: yield* userInvocableSkills(turnInput.runtimePolicy.cwd),
uuid: promptUuid,
});
const querySession = yield* openQuery(turnInput, nativeThreadId);
Expand Down Expand Up @@ -8986,7 +8993,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
priority: "now",
attachmentsDir,
fileSystem,
skillNames: yield* userInvocableSkillNames(currentTurn.input.runtimePolicy.cwd),
skills: yield* userInvocableSkills(currentTurn.input.runtimePolicy.cwd),
});
yield* Ref.update(steeredTurns, (current) => {
const next = new Set(current);
Expand Down
62 changes: 59 additions & 3 deletions apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,23 @@
import { describe, expect, it } from "vite-plus/test";

import { planClaudeSkillDispatch } from "./ClaudeSkillDispatch.ts";
import { planClaudeSkillDispatch, type ClaudeSkillTarget } from "./ClaudeSkillDispatch.ts";

const SKILLS = new Set(["2spec", "implement", "review", "re-release-version"]);
const skillPath = (name: string) => `/home/me/.claude/skills/${name}/SKILL.md`;

const INTERROGATE_NOTE = `[The user invoked this skill. Follow the instructions at: ${skillPath("interrogate")}]`;

const skillMap = (names: Iterable<string>, userInvocationOnly: ReadonlyArray<string> = []) =>
new Map<string, ClaudeSkillTarget>(
[...names].map((name): [string, ClaudeSkillTarget] => [
name,
{
path: skillPath(name),
...(userInvocationOnly.includes(name) ? { userInvocationOnly: true } : {}),
},
]),
);

const SKILLS = skillMap(["2spec", "implement", "review", "re-release-version"]);

describe("planClaudeSkillDispatch", () => {
it("leaves a prompt without a known skill untouched", () => {
Expand Down Expand Up @@ -69,7 +84,7 @@ describe("planClaudeSkillDispatch", () => {
});

it("ignores currency amounts and compact monetary expressions", () => {
const skillsWithCurrency = new Set([...SKILLS, "20", "20k", "100M", "1e6"]);
const skillsWithCurrency = skillMap([...SKILLS.keys(), "20", "20k", "100M", "1e6"]);
for (const symbol of ["$", "€", "£", "¥", "₹", "₩", "₿", "𑿝"]) {
expect(
planClaudeSkillDispatch(
Expand All @@ -79,4 +94,45 @@ describe("planClaudeSkillDispatch", () => {
).toBeUndefined();
}
});

describe("earlier skills the model may not start itself", () => {
const skills = skillMap(
["interrogate", "poteto-mode", "review"],
["interrogate", "poteto-mode"],
);

it("tells the model the user invoked an earlier user-only skill and where it lives", () => {
expect(planClaudeSkillDispatch("$interrogate and $poteto-mode pleas", skills)).toEqual({
leadingText: `/interrogate ${INTERROGATE_NOTE} and`,
commandText: "/poteto-mode pleas",
skillName: "poteto-mode",
});
});

it("leaves an earlier skill the model may start as a bare slash name", () => {
expect(planClaudeSkillDispatch("$review then $interrogate", skills)).toEqual({
leadingText: "/review then",
commandText: "/interrogate",
skillName: "interrogate",
});
});

it("does not annotate the dispatched skill when it is named twice", () => {
expect(planClaudeSkillDispatch("$interrogate again $interrogate", skills)).toEqual({
leadingText: "/interrogate again",
commandText: "/interrogate",
skillName: "interrogate",
});
});

it("annotates a repeated earlier user-only skill only once", () => {
expect(
planClaudeSkillDispatch("$interrogate then $interrogate then $review", skills),
).toEqual({
leadingText: `/interrogate ${INTERROGATE_NOTE} then /interrogate then`,
commandText: "/review",
skillName: "review",
});
});
});
});
36 changes: 32 additions & 4 deletions apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@
* So one mention anywhere in the prompt becomes a guaranteed invocation, and
* the user's text on either side is kept in order.
*
* The model's Skill tool refuses a skill that opts out of model invocation
* (`disable-model-invocation`), so an earlier mention of one would dead-end in
* "ask the user to run it". Those mentions also carry the skill's file path and
* say the user invoked it, which lets the model read and follow it.
*
* @module provider/Drivers/ClaudeSkillDispatch
*/

Expand All @@ -32,6 +37,15 @@
const SKILL_MENTION_PATTERN =
/(^|\s)\p{Sc}(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s|$)/gu;

/**
* What the dispatcher needs to know about one discovered skill.
* `userInvocationOnly` marks a skill the model may not start itself.
*/
export interface ClaudeSkillTarget {
readonly path: string;
readonly userInvocationOnly?: boolean;
}

export interface ClaudeSkillDispatch {
/** Text before the dispatched mention, or `undefined` when it opens the prompt. */
readonly leadingText: string | undefined;
Expand All @@ -48,11 +62,11 @@ export interface ClaudeSkillDispatch {
*/
export function planClaudeSkillDispatch(
prompt: string,
skillNames: ReadonlySet<string>,
skills: ReadonlyMap<string, ClaudeSkillTarget>,
): ClaudeSkillDispatch | undefined {
const mentions = [...prompt.matchAll(SKILL_MENTION_PATTERN)].flatMap((match) => {
const name = match[2] ?? "";
if (!skillNames.has(name)) return [];
if (!skills.has(name)) return [];
const start = (match.index ?? 0) + (match[1]?.length ?? 0);
return [{ name, start, end: (match.index ?? 0) + match[0].length }];
});
Expand All @@ -63,11 +77,25 @@ export function planClaudeSkillDispatch(

const leading = prompt.slice(0, last.start);
const trailing = prompt.slice(last.end);
// A skill named twice is annotated once, and the last skill needs no note
// because the CLI expands it itself.
const annotated = new Set([last.name]);
const inlineSlash = (name: string): string => {
const skill = skills.get(name);
if (skill?.userInvocationOnly !== true || annotated.has(name)) {
return `/${name}`;
}
annotated.add(name);
return `/${name} [The user invoked this skill. Follow the instructions at: ${skill.path}]`;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
};
// Built front to back so the note lands on a skill's first mention, then
// spliced back to front so earlier offsets stay valid.
const replacements = mentions.slice(0, -1).map((mention) => inlineSlash(mention.name));
const leadingWithInlineSlashes = mentions
.slice(0, -1)
.reduceRight(
(text, mention) =>
`${text.slice(0, mention.start)}/${mention.name}${text.slice(mention.end)}`,
(text, mention, index) =>
`${text.slice(0, mention.start)}${replacements[index]}${text.slice(mention.end)}`,
leading,
)
.trimEnd();
Expand Down
Loading