Files
curriculum-project-hub/hub/src/agent/roleTools.ts
T
hongjr03 2f79b7743f feat(hub): live TodoWrite checklist on Feishu agent cards
Always expose Claude Agent SDK TodoWrite (todoFeatureEnabled) so multi-step
runs can plan in the open. Parse TodoWrite payloads into a progress panel on
the streaming Feishu card (completed/in_progress/pending) and filter raw
TodoWrite noise out of the tool-use list.
2026-07-23 22:42:02 +08:00

149 lines
4.8 KiB
TypeScript

export const DEFAULT_CLAUDE_BUILT_IN_TOOLS = [
"Read",
"Write",
"Bash",
"Glob",
"Grep",
"WebFetch",
"WebSearch",
"TodoWrite",
] as const;
export const CPH_HUB_MCP_SERVER_NAME = "cph_hub";
export const CPH_HUB_MCP_TOOL_IDS = [
"send_file",
"feishu_read_context",
"feishu_download_resource",
"request_approval",
"convert_pdf_to_md",
"pbank_search_problems",
"pbank_get_problem",
"pbank_get_many_problems",
] as const;
export type CphHubMcpToolId = (typeof CPH_HUB_MCP_TOOL_IDS)[number];
export interface ClaudeSdkToolConfig {
readonly tools: readonly string[];
readonly allowedTools: readonly string[];
}
const ROLE_TOOL_TO_CLAUDE_BUILT_INS: Readonly<Record<string, readonly string[]>> = {
read_file: ["Read"],
write_file: ["Write"],
list_files: ["Glob"],
search_files: ["Grep"],
bash: ["Bash"],
// ADR-0017 replaced cph custom tools with Bash commands. Granting either
// cph role tool therefore exposes the SDK Bash tool; cph-only Bash narrowing
// would need a separate command-policy layer.
cph_check: ["Bash"],
cph_build: ["Bash"],
web_fetch: ["WebFetch"],
web_search: ["WebSearch"],
todo: ["TodoWrite"],
TodoWrite: ["TodoWrite"],
Read: ["Read"],
Write: ["Write"],
Bash: ["Bash"],
Glob: ["Glob"],
Grep: ["Grep"],
WebFetch: ["WebFetch"],
WebSearch: ["WebSearch"],
};
const ROLE_TOOL_TO_CPH_HUB_MCP_TOOLS: Readonly<Record<string, readonly CphHubMcpToolId[]>> = {
send_file: ["send_file"],
feishu_read_context: ["feishu_read_context"],
feishu_download_resource: ["feishu_download_resource"],
request_approval: ["request_approval"],
convert_pdf_to_md: ["convert_pdf_to_md"],
pbank: ["pbank_search_problems", "pbank_get_problem", "pbank_get_many_problems"],
pbank_search_problems: ["pbank_search_problems"],
pbank_get_problem: ["pbank_get_problem"],
pbank_get_many_problems: ["pbank_get_many_problems"],
"mcp__cph_hub__send_file": ["send_file"],
"mcp__cph_hub__feishu_read_context": ["feishu_read_context"],
"mcp__cph_hub__feishu_download_resource": ["feishu_download_resource"],
"mcp__cph_hub__request_approval": ["request_approval"],
"mcp__cph_hub__convert_pdf_to_md": ["convert_pdf_to_md"],
"mcp__cph_hub__pbank_search_problems": ["pbank_search_problems"],
"mcp__cph_hub__pbank_get_problem": ["pbank_get_problem"],
"mcp__cph_hub__pbank_get_many_problems": ["pbank_get_many_problems"],
};
const SUPPORTED_ROLE_TOOLS = new Set([
...Object.keys(ROLE_TOOL_TO_CLAUDE_BUILT_INS),
...Object.keys(ROLE_TOOL_TO_CPH_HUB_MCP_TOOLS),
]);
export function claudeSdkToolConfigForRole(roleTools: readonly string[] | undefined): ClaudeSdkToolConfig {
if (roleTools === undefined) {
const mcpTools = CPH_HUB_MCP_TOOL_IDS.map(claudeMcpToolName);
return {
tools: [...DEFAULT_CLAUDE_BUILT_IN_TOOLS],
allowedTools: [...DEFAULT_CLAUDE_BUILT_IN_TOOLS, ...mcpTools],
};
}
const builtIns: string[] = [];
const allowedTools: string[] = [];
for (const roleTool of roleTools) {
assertSupportedRoleTool(roleTool);
for (const tool of ROLE_TOOL_TO_CLAUDE_BUILT_INS[roleTool] ?? []) {
pushUnique(builtIns, tool);
pushUnique(allowedTools, tool);
}
for (const mcpTool of ROLE_TOOL_TO_CPH_HUB_MCP_TOOLS[roleTool] ?? []) {
pushUnique(allowedTools, claudeMcpToolName(mcpTool));
}
}
return { tools: builtIns, allowedTools };
}
export function cphHubMcpToolsForRole(roleTools: readonly string[] | undefined): readonly CphHubMcpToolId[] {
if (roleTools === undefined) return [...CPH_HUB_MCP_TOOL_IDS];
const tools: CphHubMcpToolId[] = [];
for (const roleTool of roleTools) {
assertSupportedRoleTool(roleTool);
for (const mcpTool of ROLE_TOOL_TO_CPH_HUB_MCP_TOOLS[roleTool] ?? []) {
pushUnique(tools, mcpTool);
}
}
return tools;
}
export function roleToolsAllow(roleTools: readonly string[] | undefined, roleTool: string): boolean {
if (roleTools === undefined) return true;
for (const configured of roleTools) {
assertSupportedRoleTool(configured);
if (configured === roleTool) return true;
const mapped = ROLE_TOOL_TO_CPH_HUB_MCP_TOOLS[configured];
if (mapped !== undefined && mapped.includes(roleTool as CphHubMcpToolId)) return true;
// Umbrella: role tool "pbank" allows any pbank_* MCP or role tool.
if (configured === "pbank" && roleTool.startsWith("pbank")) return true;
}
return false;
}
export function assertSupportedRoleTools(roleTools: readonly string[]): void {
for (const roleTool of roleTools) assertSupportedRoleTool(roleTool);
}
function claudeMcpToolName(tool: CphHubMcpToolId): string {
return `mcp__${CPH_HUB_MCP_SERVER_NAME}__${tool}`;
}
function assertSupportedRoleTool(roleTool: string): void {
if (!SUPPORTED_ROLE_TOOLS.has(roleTool)) {
throw new Error(`unknown role tool id: ${roleTool}`);
}
}
function pushUnique<T>(items: T[], item: T): void {
if (!items.includes(item)) items.push(item);
}