From ba0f1d2fabf5b649f55908decb9260a27e3d0d94 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Fri, 21 Aug 2026 22:37:34 +0800 Subject: [PATCH] Reduce code redundancy --- docs/subagent.md | 4 +- docs/tools.md | 13 +- packages/codingcode/package.json | 1 - packages/codingcode/src/agent/agent.ts | 32 ++--- packages/codingcode/src/layer.ts | 3 - packages/codingcode/src/mcp/index.ts | 11 +- .../codingcode/src/runtime/project-runtime.ts | 2 - packages/codingcode/src/subagent/registry.ts | 4 +- .../codingcode/src/tools/builtin-tools.ts | 33 +++++ .../codingcode/src/tools/domains/bash/exec.ts | 4 +- .../codingcode/src/tools/domains/fs/edit.ts | 4 +- .../codingcode/src/tools/domains/fs/glob.ts | 4 +- .../codingcode/src/tools/domains/fs/grep.ts | 6 +- .../codingcode/src/tools/domains/fs/read.ts | 4 +- .../codingcode/src/tools/domains/fs/write.ts | 4 +- .../src/tools/domains/self/todo-write.ts | 3 +- .../src/tools/domains/self/tool-search.ts | 46 ------- .../src/tools/domains/subagent/dispatch.ts | 5 +- .../src/tools/domains/subagent/submit-plan.ts | 5 +- .../codingcode/src/tools/domains/web/fetch.ts | 4 +- .../src/tools/domains/web/search.ts | 4 +- packages/codingcode/src/tools/providers.ts | 128 ------------------ packages/codingcode/src/tools/registry.ts | 31 +++++ .../src/tools/tool-search-service.ts | 81 ----------- packages/codingcode/src/tools/types.ts | 6 - .../test/agent/agent-cache-stability.test.ts | 2 - .../test/agent/agent-concurrent.test.ts | 2 - .../test/agent/agent-todo-event.test.ts | 2 - packages/codingcode/test/agent/agent.test.ts | 45 ------ .../test/agent/hooks-deps-type.test.ts | 2 - .../test/agent/loop-options.test.ts | 2 - .../test/agent/memory-snapshot.test.ts | 2 - .../codingcode/test/agent/stop-hook.test.ts | 2 - .../test/agent/submit-plan-turn-end.test.ts | 4 - packages/codingcode/test/mcp/service.test.ts | 38 ++++++ packages/codingcode/test/orchestrate.test.ts | 4 - .../codingcode/test/subagent/dispatch.test.ts | 2 - .../codingcode/test/subagent/registry.test.ts | 4 +- .../test/tools/builtin-tools.test.ts | 26 ++++ .../test/tools/descriptions.test.ts | 8 +- .../test/tools/executor-context.test.ts | 61 +++++++++ .../codingcode/test/tools/registry.test.ts | 43 ++++++ packages/codingcode/test/tools/todo.test.ts | 4 +- .../codingcode/test/tools/tool-search.test.ts | 76 ----------- .../desktop/src/settings/SubagentsPanel.tsx | 1 - packages/desktop/test/settings-panels.test.ts | 6 +- 46 files changed, 288 insertions(+), 490 deletions(-) create mode 100644 packages/codingcode/src/tools/builtin-tools.ts delete mode 100644 packages/codingcode/src/tools/domains/self/tool-search.ts delete mode 100644 packages/codingcode/src/tools/providers.ts create mode 100644 packages/codingcode/src/tools/registry.ts delete mode 100644 packages/codingcode/src/tools/tool-search-service.ts create mode 100644 packages/codingcode/test/tools/builtin-tools.test.ts create mode 100644 packages/codingcode/test/tools/executor-context.test.ts create mode 100644 packages/codingcode/test/tools/registry.test.ts delete mode 100644 packages/codingcode/test/tools/tool-search.test.ts diff --git a/docs/subagent.md b/docs/subagent.md index 14e7f1e8..c88e28a8 100644 --- a/docs/subagent.md +++ b/docs/subagent.md @@ -89,7 +89,7 @@ You are a code search specialist. Your job is to find specific code patterns, fu ```yaml name: explore description: 只读代码探索 -tools: [read_file, search_files, search_code, fetch_url, tool_search] +tools: [read_file, search_files, search_code, fetch_url] readonly: true maxSteps: 180 ``` @@ -101,7 +101,7 @@ maxSteps: 180 ```yaml name: plan description: 只读代码研究和规划 -tools: [read_file, search_files, search_code, fetch_url, tool_search, submit_plan, dispatch_agent] +tools: [read_file, search_files, search_code, fetch_url, submit_plan, dispatch_agent] maxSteps: 180 ``` diff --git a/docs/tools.md b/docs/tools.md index c4dd601d..1c8a9d60 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -34,7 +34,6 @@ Coding Code 的工具系统是 Agent 与外部世界交互的核心机制。本 | 工具 | 功能 | 关键参数 | |---|---|---| | `todo_write` | 修改代理的任务列表 | `plan: Array<{ step: string, status: 'pending' \| 'in_progress' \| 'completed' }>`(最大条目数有限制) | -| `tool_search` | 发现和加载可用工具 | `query: string`(搜索关键词,至少 1 字符) | ### 子智能体 @@ -46,13 +45,12 @@ Coding Code 的工具系统是 Agent 与外部世界交互的核心机制。本 ## 工具加载机制 -工具按加载时机分为三类: +工具按加载时机分为两类: - **Core 工具**:始终可用,在启动时注册。包括上述所有内置工具。 -- **Deferred 工具**:按需加载,通过 `tool_search` 发现后动态加载。这类工具标记了 `deferred: true`,不会在初始工具列表中暴露给 LLM,只有当 LLM 主动调用 `tool_search` 查询后才会加载。 - **MCP 工具**:从 MCP 服务自动导入和注册。名称空间化为 `serverName:toolName` 格式,避免不同服务间的工具名冲突。 -工具解析流程:`createSessionToolResolver()` 合并 builtin + project MCP + tool_search + dispatch_agent,根据 `AgentProfile.tools` 和 `ToolVisibilityPolicy` 过滤后提供给 Agent。 +Agent 在一次运行开始时将内置工具、项目 MCP 工具和 `dispatch_agent` 注册到 `ToolRegistry`。每轮通过注册表按 `AgentProfile.tools` 和 `ToolVisibilityPolicy` 过滤,并生成 LLM 工具描述与执行查找结果。 --- @@ -64,10 +62,7 @@ Coding Code 的工具系统是 Agent 与外部世界交互的核心机制。本 interface ToolDefinition { name: string; description: string; - shortDescription?: string; // 简短描述,用于工具列表展示 - deferred?: boolean; // 是否延迟加载 parameters: z.ZodTypeAny; // Zod schema 定义参数 - jsonSchema?: Record; // 可选的 JSON Schema 覆盖 execute: (args: unknown, ctx?: ToolExecCtx) => Effect.Effect; } @@ -79,6 +74,8 @@ interface ToolExecCtx { } ``` +`execute` 保留在 `ToolDefinition` 中,因为执行器需要通过同一个定义完成参数校验、审批、取消和 hook,再调用工具的实际实现。`ToolExecCtx` 中,`signal` 用于取消;`sessionId` 用于会话级工具状态和子智能体关联,`projectPath` 用于限定工作目录,`turnId` 只用于执行 hook 的轮次追踪。 + 在 `cli.ts` 中向 `ToolService` 注册新工具,Agent 会自动将其暴露给 LLM。 ### 工具可见性策略 @@ -89,8 +86,6 @@ interface ToolExecCtx { interface ToolVisibilityPolicy { allowedTools?: Set; // 允许的工具白名单 allowedMcpServers?: Set; // 允许的 MCP 服务白名单 - allowToolSearch?: boolean; // 是否允许 tool_search - allowDeferredTools?: boolean; // 是否允许延迟工具 } ``` diff --git a/packages/codingcode/package.json b/packages/codingcode/package.json index d3ff8ff6..5759ca12 100644 --- a/packages/codingcode/package.json +++ b/packages/codingcode/package.json @@ -23,7 +23,6 @@ "./context/context": "./src/context/context.ts", "./hooks/registry": "./src/hooks/registry.ts", "./tools/executor": "./src/tools/executor.ts", - "./tools/tool-search-service": "./src/tools/tool-search-service.ts", "./mcp/client": "./src/mcp/client.ts", "./mcp/types": "./src/mcp/types.ts", "./skills/types": "./src/skills/types.ts", diff --git a/packages/codingcode/src/agent/agent.ts b/packages/codingcode/src/agent/agent.ts index 20ef1e0a..38813a80 100644 --- a/packages/codingcode/src/agent/agent.ts +++ b/packages/codingcode/src/agent/agent.ts @@ -1,9 +1,7 @@ import { Effect, Queue, Stream, Fiber } from 'effect'; -import { z } from 'zod'; import type { Message } from '../core/types.js'; import { AgentError } from '../core/error.js'; import { Result } from '../core/result.js'; -import type { ToolDescription, ToolDefinition } from '../tools/types.js'; import type { LLMClient } from '../llm/client.js'; import { ToolExecutorService, type ToolLookup } from '../tools/executor.js'; import { SessionService } from '../session/store.js'; @@ -24,9 +22,9 @@ import { resolveSubagentEnabled, resolveAgentDisabled } from '../subagent/regist import { ProjectRuntimeService, modeToProfile } from '../runtime/project-runtime.js'; import { createDispatchAgentTool } from '../tools/domains/subagent/dispatch.js'; import { LLMFactoryService } from '../llm/factory.js'; -import { getBuiltinTools } from '../tools/providers.js'; +import { registerBuiltinTools } from '../tools/builtin-tools.js'; +import { ToolRegistry } from '../tools/registry.js'; import { submitPlanTool } from '../tools/domains/subagent/submit-plan.js'; -import { canonicalizeSchema } from '../tools/utils/canonicalize-schema.js'; import { normalizePath } from '../core/path.js'; import { isPlanProfile } from '../plan/index.js'; import type { SessionMode } from '../session/types.js'; @@ -291,6 +289,12 @@ export function agentLoop( let stopContinuations = 0; const effectiveMaxStopContinuations = opts.maxStopContinuations ?? maxStopContinuations; + const registry = new ToolRegistry(); + yield* registerBuiltinTools(registry); + registry.register(...(opts.mcpTools ?? [])); + if (opts.dispatchTool && resolveSubagentEnabled(projectPath)) registry.register(opts.dispatchTool); + if (isPlanProfile(profile)) registry.register(submitPlanTool); + let messages: Message[] = []; let submittedPlanTitle: string | null = null; @@ -310,25 +314,9 @@ export function agentLoop( for (let step = 0; step < effectiveMaxSteps; step++) { yield* q.offer({ _tag: 'Step', step: step + 1, max: effectiveMaxSteps }); - const builtinTools = yield* getBuiltinTools(); - let allToolDefs: ToolDefinition[] = [...builtinTools, ...(opts.mcpTools ?? [])]; - if (opts.dispatchTool && resolveSubagentEnabled(projectPath)) - allToolDefs = [...allToolDefs, opts.dispatchTool]; - if (isPlanProfile(profile)) allToolDefs = [...allToolDefs, submitPlanTool]; - const allowedByPolicy = opts.toolPolicy?.allowedTools; - let filteredDefs = allToolDefs; - if (allowedByPolicy) filteredDefs = filteredDefs.filter((t) => allowedByPolicy.has(t.name)); - - const tools: ToolDescription[] = filteredDefs.map((t) => ({ - name: t.name, - description: t.description, - parameters: - t.jsonSchema ?? - (canonicalizeSchema(z.toJSONSchema(t.parameters)) as Record), - })); - - const toolLookup: ToolLookup = (name: string) => filteredDefs.find((t) => t.name === name); + const tools = registry.describe(allowedByPolicy); + const toolLookup: ToolLookup = (name: string) => registry.get(name, allowedByPolicy); const systemWithCatalog = system; const stepBeforePayload = { sessionId, step: step + 1 }; diff --git a/packages/codingcode/src/layer.ts b/packages/codingcode/src/layer.ts index 0e389aa6..ecf012be 100644 --- a/packages/codingcode/src/layer.ts +++ b/packages/codingcode/src/layer.ts @@ -12,7 +12,6 @@ import { ProjectRuntimeService } from './runtime/project-runtime.js'; import { LLMFactoryService } from './llm/factory.js'; import { WorkspaceService } from './core/workspace.js'; import { TodoService } from './agent/todo.js'; -import { ToolSearchService } from './tools/tool-search-service.js'; import { SubagentService } from './subagent/registry.js'; import { SubagentRunnerService } from './subagent/runner-service.js'; import { RulesService } from './rules/index.js'; @@ -23,7 +22,6 @@ import { planModeGateHook } from './plan/index.js'; export const WorkspaceLayer = WorkspaceService.Default; export const TodoLayer = TodoService.Default; -export const ToolSearchLayer = ToolSearchService.Default; export const SubagentLayer = SubagentService.Default; export const RulesLayer = RulesService.Default; export const SessionLayer = SessionService.Default; @@ -105,7 +103,6 @@ export const AppLayer = Layer.mergeAll( LLMFactoryLayer, WorkspaceLayer, TodoLayer, - ToolSearchLayer, SubagentLayer, RulesLayer, MemoryLayer, diff --git a/packages/codingcode/src/mcp/index.ts b/packages/codingcode/src/mcp/index.ts index 82651704..8ea59e54 100644 --- a/packages/codingcode/src/mcp/index.ts +++ b/packages/codingcode/src/mcp/index.ts @@ -1,9 +1,9 @@ import { Effect } from 'effect'; import { z } from 'zod'; import { resolveMcpConfig, resolveMcpDisabled } from './config.js'; -import { McpClient, McpError } from './client.js'; +import { McpClient } from './client.js'; import type { McpServerConfig, McpStatus } from './types.js'; -import type { ToolDefinition, ToolExecCtx } from '../tools/types.js'; +import type { ToolDefinition } from '../tools/types.js'; import { createLogger } from '@codingcode/infra/logger'; import { AgentError } from '../core/error.js'; @@ -31,7 +31,7 @@ type ProjectPath = string; type ServerName = string; export class McpService extends Effect.Service()('Mcp', { - effect: Effect.gen(function* () { + effect: Effect.sync(() => { const clientsByProject = new Map>(); const leasesBySession = new Map>(); const disabledMcpByProject = new Map>(); @@ -334,9 +334,8 @@ function mcpToolToDefinition( return { name: `${serverName}:${mcpTool.name}`, description: `[MCP:${serverName}] ${mcpTool.description || mcpTool.name}`, - parameters: z.object({}).passthrough(), - jsonSchema: mcpTool.inputSchema, - execute: (args: unknown, _ctx?: ToolExecCtx) => { + parameters: z.fromJSONSchema(mcpTool.inputSchema), + execute: (args) => { if (isDisabledFn()) return Effect.fail( new AgentError('TOOL_EXECUTION_FAILED', `MCP server '${serverName}' is disabled`) diff --git a/packages/codingcode/src/runtime/project-runtime.ts b/packages/codingcode/src/runtime/project-runtime.ts index 32874ac5..de674f2c 100644 --- a/packages/codingcode/src/runtime/project-runtime.ts +++ b/packages/codingcode/src/runtime/project-runtime.ts @@ -92,8 +92,6 @@ export class ProjectRuntimeService extends Effect.Service getToolPolicy: (profile: AgentProfile | undefined): ToolVisibilityPolicy => ({ allowedTools: profile?.tools ? new Set(profile.tools) : undefined, allowedMcpServers: profile?.mcpServers ? new Set(profile.mcpServers) : undefined, - allowToolSearch: true, - allowDeferredTools: false, }), setSessionProfile: ( diff --git a/packages/codingcode/src/subagent/registry.ts b/packages/codingcode/src/subagent/registry.ts index a48b0323..ef83253b 100644 --- a/packages/codingcode/src/subagent/registry.ts +++ b/packages/codingcode/src/subagent/registry.ts @@ -160,7 +160,7 @@ export const EXPLORE_PROFILE: AgentProfile = { - When referencing code, use the format \`file_path:line_number\`. - Be thorough but concise in your findings. Focus on what the user asked for — structure your answer around the question, not around the files you read. - If you cannot find the answer, say so clearly rather than guessing.`, - tools: ['read_file', 'search_files', 'search_code', 'fetch_url', 'tool_search'], + tools: ['read_file', 'search_files', 'search_code', 'fetch_url'], readonly: true, maxSteps: 180, }; @@ -208,7 +208,6 @@ Never re-call submit_plan on your own initiative. Never treat an implement messa 'search_files', 'search_code', 'fetch_url', - 'tool_search', 'submit_plan', 'dispatch_agent', ], @@ -230,7 +229,6 @@ export const BUILD_PROFILE: AgentProfile = { 'fetch_url', 'web_search', 'todo_write', - 'tool_search', 'dispatch_agent', ], maxSteps: 180, diff --git a/packages/codingcode/src/tools/builtin-tools.ts b/packages/codingcode/src/tools/builtin-tools.ts new file mode 100644 index 00000000..642983b8 --- /dev/null +++ b/packages/codingcode/src/tools/builtin-tools.ts @@ -0,0 +1,33 @@ +import { Effect } from 'effect'; +import type { ToolDefinition } from './types.js'; +import { ToolRegistry } from './registry.js'; +import { readFileTool } from './domains/fs/read.js'; +import { writeFileTool } from './domains/fs/write.js'; +import { editFileTool } from './domains/fs/edit.js'; +import { bashTool } from './domains/bash/exec.js'; +import { searchTool } from './domains/fs/grep.js'; +import { globTool } from './domains/fs/glob.js'; +import { webFetchTool } from './domains/web/fetch.js'; +import { webSearchTool } from './domains/web/search.js'; +import { createTodoWriteTool } from './domains/self/todo-write.js'; +import { TodoService } from '../agent/todo.js'; + +const STATELESS_BUILTIN_TOOLS: ToolDefinition[] = [ + readFileTool, + writeFileTool, + editFileTool, + bashTool, + searchTool, + globTool, + webFetchTool, + webSearchTool, +]; + +export function registerBuiltinTools( + registry: ToolRegistry +): Effect.Effect { + return Effect.gen(function* () { + const todoTool = yield* createTodoWriteTool(); + registry.register(...STATELESS_BUILTIN_TOOLS, todoTool); + }); +} diff --git a/packages/codingcode/src/tools/domains/bash/exec.ts b/packages/codingcode/src/tools/domains/bash/exec.ts index 9c06e36b..a3d82fd4 100644 --- a/packages/codingcode/src/tools/domains/bash/exec.ts +++ b/packages/codingcode/src/tools/domains/bash/exec.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; import { spawn } from 'child_process'; import { Effect } from 'effect'; -import type { ToolDefinition, ToolExecCtx } from '../../types.js'; +import type { ToolDefinition } from '../../types.js'; import { AgentError } from '../../../core/error.js'; @@ -14,7 +14,7 @@ export const bashTool: ToolDefinition = { cwd: z.string().optional().describe('Working directory (defaults to project root)'), timeout_ms: z.number().int().default(30000).describe('Timeout in milliseconds'), }), - execute: (args: unknown, ctx?: ToolExecCtx) => { + execute: (args, ctx) => { const { command, cwd, timeout_ms } = args as any; const workDir = cwd || ctx?.projectPath || process.cwd(); return Effect.async((resume) => { diff --git a/packages/codingcode/src/tools/domains/fs/edit.ts b/packages/codingcode/src/tools/domains/fs/edit.ts index 4f264c32..0f3c2ef3 100644 --- a/packages/codingcode/src/tools/domains/fs/edit.ts +++ b/packages/codingcode/src/tools/domains/fs/edit.ts @@ -3,7 +3,7 @@ import { readFile, writeFile } from 'fs/promises'; import { resolve } from 'path'; import { Effect } from 'effect'; import { AgentError } from '../../../core/error.js'; -import type { ToolDefinition, ToolExecCtx } from '../../types.js'; +import type { ToolDefinition } from '../../types.js'; export const editFileTool: ToolDefinition = { name: 'edit_file', @@ -17,7 +17,7 @@ export const editFileTool: ToolDefinition = { .describe('Exact text to replace — must match exactly one location in the file'), new_string: z.string().describe('Text to replace it with'), }), - execute: (args: unknown, ctx?: ToolExecCtx) => + execute: (args, ctx) => Effect.gen(function* () { const { path, old_string, new_string } = args as { path: string; diff --git a/packages/codingcode/src/tools/domains/fs/glob.ts b/packages/codingcode/src/tools/domains/fs/glob.ts index 21078d9b..1a3d41af 100644 --- a/packages/codingcode/src/tools/domains/fs/glob.ts +++ b/packages/codingcode/src/tools/domains/fs/glob.ts @@ -3,7 +3,7 @@ import { globby } from 'globby'; import { relative, resolve } from 'path'; import { Effect } from 'effect'; import { AgentError } from '../../../core/error.js'; -import type { ToolDefinition, ToolExecCtx } from '../../types.js'; +import type { ToolDefinition } from '../../types.js'; export const globTool: ToolDefinition = { name: 'search_files', @@ -23,7 +23,7 @@ export const globTool: ToolDefinition = { .default(50) .describe('Maximum number of file paths to return'), }), - execute: (args: unknown, ctx?: ToolExecCtx) => + execute: (args, ctx) => Effect.gen(function* () { const { pattern, path, max_results } = args as { pattern: string; diff --git a/packages/codingcode/src/tools/domains/fs/grep.ts b/packages/codingcode/src/tools/domains/fs/grep.ts index a2197fcb..1cc0efd8 100644 --- a/packages/codingcode/src/tools/domains/fs/grep.ts +++ b/packages/codingcode/src/tools/domains/fs/grep.ts @@ -1,10 +1,10 @@ import { z } from 'zod'; import { globby } from 'globby'; import { readFile } from 'fs/promises'; -import { relative, resolve } from 'path'; +import { relative } from 'path'; import { Effect } from 'effect'; import { AgentError } from '../../../core/error.js'; -import type { ToolDefinition, ToolExecCtx } from '../../types.js'; +import type { ToolDefinition } from '../../types.js'; export const searchTool: ToolDefinition = { name: 'search_code', @@ -24,7 +24,7 @@ export const searchTool: ToolDefinition = { .default(30) .describe('Maximum number of matches to return'), }), - execute: (args: unknown, ctx?: ToolExecCtx) => + execute: (args, ctx) => Effect.gen(function* () { const { pattern, glob, max_results } = args as any; const base = ctx?.projectPath ?? process.cwd(); diff --git a/packages/codingcode/src/tools/domains/fs/read.ts b/packages/codingcode/src/tools/domains/fs/read.ts index 3ee3fdc6..2e24d00f 100644 --- a/packages/codingcode/src/tools/domains/fs/read.ts +++ b/packages/codingcode/src/tools/domains/fs/read.ts @@ -3,7 +3,7 @@ import { readFile } from 'fs/promises'; import { resolve } from 'path'; import { Effect } from 'effect'; import { AgentError } from '../../../core/error.js'; -import type { ToolDefinition, ToolExecCtx } from '../../types.js'; +import type { ToolDefinition } from '../../types.js'; export const readFileTool: ToolDefinition = { name: 'read_file', @@ -19,7 +19,7 @@ export const readFileTool: ToolDefinition = { .default(200) .describe('Maximum number of lines to read'), }), - execute: (args: unknown, ctx?: ToolExecCtx) => + execute: (args, ctx) => Effect.gen(function* () { const { path, offset, limit } = args as any; const filePath = resolve(ctx?.projectPath ?? process.cwd(), path); diff --git a/packages/codingcode/src/tools/domains/fs/write.ts b/packages/codingcode/src/tools/domains/fs/write.ts index 00ad1952..073307d4 100644 --- a/packages/codingcode/src/tools/domains/fs/write.ts +++ b/packages/codingcode/src/tools/domains/fs/write.ts @@ -3,7 +3,7 @@ import { writeFile, mkdir } from 'fs/promises'; import { dirname, relative, resolve } from 'path'; import { Effect } from 'effect'; import { AgentError } from '../../../core/error.js'; -import type { ToolDefinition, ToolExecCtx } from '../../types.js'; +import type { ToolDefinition } from '../../types.js'; export const writeFileTool: ToolDefinition = { name: 'write_file', @@ -13,7 +13,7 @@ export const writeFileTool: ToolDefinition = { path: z.string().describe('Path to the file'), content: z.string().describe('Content to write'), }), - execute: (args: unknown, ctx?: ToolExecCtx) => + execute: (args, ctx) => Effect.gen(function* () { const { path, content } = args as any; const base = ctx?.projectPath ?? process.cwd(); diff --git a/packages/codingcode/src/tools/domains/self/todo-write.ts b/packages/codingcode/src/tools/domains/self/todo-write.ts index 55358eac..f1c7b3c7 100644 --- a/packages/codingcode/src/tools/domains/self/todo-write.ts +++ b/packages/codingcode/src/tools/domains/self/todo-write.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; import { Effect } from 'effect'; import { AgentError } from '../../../core/error.js'; -import type { ToolDefinition, ToolExecCtx } from '../../types.js'; +import type { ToolDefinition } from '../../types.js'; import { TodoService, countByStatus, @@ -29,7 +29,6 @@ export function createTodoWriteTool(): Effect.Effect { const sessionId = ctx?.sessionId; diff --git a/packages/codingcode/src/tools/domains/self/tool-search.ts b/packages/codingcode/src/tools/domains/self/tool-search.ts deleted file mode 100644 index d4f69693..00000000 --- a/packages/codingcode/src/tools/domains/self/tool-search.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { z } from 'zod'; -import { Effect } from 'effect'; -import { AgentError } from '../../../core/error.js'; -import type { ToolDefinition, ToolExecCtx } from '../../types.js'; -import type { ToolVisibilityPolicy } from '../../types.js'; -import { ToolSearchService } from '../../tool-search-service.js'; - -export function createToolSearchTool( - policy?: ToolVisibilityPolicy -): Effect.Effect { - return Effect.gen(function* () { - const searchSvc = yield* ToolSearchService; - - return { - name: 'tool_search', - description: - 'Load deferred tools by keyword search. Required before calling any deferred tool — match the tool name or description with relevant keywords.', - parameters: z.object({ - query: z - .string() - .min(1) - .describe('Keywords to match against deferred tool names and descriptions.'), - }), - execute: (args, ctx) => { - const sessionId = ctx?.sessionId; - if (!sessionId) - return Effect.fail( - new AgentError('TOOL_EXECUTION_FAILED', 'tool_search requires sessionId') - ); - const { query } = args as { query: string }; - const hits = searchSvc.search(sessionId, query, policy); - if (hits.length === 0) return Effect.succeed(`No deferred tools matched "${query}".`); - searchSvc.markLoaded( - sessionId, - hits.map((h) => h.name) - ); - return Effect.succeed( - [ - `Loaded ${hits.length} tool(s). Their full schemas are now available next turn:`, - ...hits.map((h) => `- ${h.name}: ${h.shortDescription ?? ''}`), - ].join('\n') - ); - }, - }; - }); -} diff --git a/packages/codingcode/src/tools/domains/subagent/dispatch.ts b/packages/codingcode/src/tools/domains/subagent/dispatch.ts index 24cda3b7..de6d1e9b 100644 --- a/packages/codingcode/src/tools/domains/subagent/dispatch.ts +++ b/packages/codingcode/src/tools/domains/subagent/dispatch.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; import { Effect } from 'effect'; import { AgentError } from '../../../core/error.js'; -import type { ToolDefinition, ToolExecCtx } from '../../types.js'; +import type { ToolDefinition } from '../../types.js'; import { SessionService } from '../../../session/store.js'; import { ApprovalService } from '../../../approval/index.js'; import { HookService } from '../../../hooks/registry.js'; @@ -47,12 +47,11 @@ export function createDispatchAgentTool(): Effect.Effect< name: 'dispatch_agent', description: 'Spawn an isolated subagent to handle specialized tasks. See "Available Subagents" in the system prompt for available profiles and their capabilities.', - shortDescription: 'Spawn isolated subagent', parameters: z.object({ agent: z.string().describe('subagent profile name'), prompt: z.string().min(1).describe('task description for the subagent'), }), - execute: (args: unknown, ctx?: ToolExecCtx): Effect.Effect => + execute: (args, ctx) => Effect.gen(function* () { const { agent: agentName, prompt } = args as { agent: string; prompt: string }; diff --git a/packages/codingcode/src/tools/domains/subagent/submit-plan.ts b/packages/codingcode/src/tools/domains/subagent/submit-plan.ts index 6193459a..f6e8621e 100644 --- a/packages/codingcode/src/tools/domains/subagent/submit-plan.ts +++ b/packages/codingcode/src/tools/domains/subagent/submit-plan.ts @@ -3,7 +3,7 @@ import { Effect } from 'effect'; import { join } from 'path'; import { writeFileSync, mkdirSync } from 'fs'; import { AgentError } from '../../../core/error.js'; -import type { ToolDefinition, ToolExecCtx } from '../../types.js'; +import type { ToolDefinition } from '../../types.js'; import { encodeProjectPath, getProjectBaseDir } from '../../../core/path.js'; import { createLogger } from '@codingcode/infra/logger'; @@ -44,7 +44,6 @@ export const submitPlanTool: ToolDefinition = { name: 'submit_plan', description: 'Submit (or update) the implementation plan for the current session. The only write operation allowed in plan mode. The file is written immediately and the tool returns synchronously; the user is then shown a plan approval modal in the UI. The user’s next message will contain their decision (implement / revised content / cancel).', - shortDescription: 'Submit plan', parameters: z.object({ title: z .string() @@ -60,7 +59,7 @@ export const submitPlanTool: ToolDefinition = { 'Full Markdown implementation plan. Must contain the sections: Goal, Current state, Out of scope, Approach, Key files, Dependencies and risks, Verification. Phases is optional.' ), }), - execute: (args: unknown, ctx?: ToolExecCtx): Effect.Effect => + execute: (args, ctx) => Effect.gen(function* () { const { title, plan_content: rawContent } = args as { title: string; diff --git a/packages/codingcode/src/tools/domains/web/fetch.ts b/packages/codingcode/src/tools/domains/web/fetch.ts index a3f0593d..d4f4bc0e 100644 --- a/packages/codingcode/src/tools/domains/web/fetch.ts +++ b/packages/codingcode/src/tools/domains/web/fetch.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; import { Effect } from 'effect'; import { AgentError } from '../../../core/error.js'; -import type { ToolDefinition, ToolExecCtx } from '../../types.js'; +import type { ToolDefinition } from '../../types.js'; export const webFetchTool: ToolDefinition = { name: 'fetch_url', @@ -16,7 +16,7 @@ export const webFetchTool: ToolDefinition = { .default(100_000) .describe('Maximum characters to return (default 100k, max 500k)'), }), - execute: (args: unknown, _ctx?: ToolExecCtx) => + execute: (args) => Effect.gen(function* () { const { url, max_length } = args as any; const controller = new AbortController(); diff --git a/packages/codingcode/src/tools/domains/web/search.ts b/packages/codingcode/src/tools/domains/web/search.ts index b203adc2..ef02bf75 100644 --- a/packages/codingcode/src/tools/domains/web/search.ts +++ b/packages/codingcode/src/tools/domains/web/search.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; import { Effect } from 'effect'; import { AgentError } from '../../../core/error.js'; -import type { ToolDefinition, ToolExecCtx } from '../../types.js'; +import type { ToolDefinition } from '../../types.js'; interface SearchResult { title: string; @@ -156,7 +156,7 @@ export const webSearchTool: ToolDefinition = { .default(8) .describe('Maximum number of results to return'), }), - execute: (args: unknown, _ctx?: ToolExecCtx) => + execute: (args) => Effect.gen(function* () { const { query, max_results } = args as { query: string; max_results: number }; diff --git a/packages/codingcode/src/tools/providers.ts b/packages/codingcode/src/tools/providers.ts deleted file mode 100644 index 16ff721f..00000000 --- a/packages/codingcode/src/tools/providers.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { z } from 'zod'; -import { Effect } from 'effect'; -import type { ToolDefinition, ToolDescription } from './types.js'; -import type { AgentProfile } from '../subagent/types.js'; -import type { ToolVisibilityPolicy } from './types.js'; -import { canonicalizeSchema } from './utils/canonicalize-schema.js'; -import { readFileTool } from './domains/fs/read.js'; -import { writeFileTool } from './domains/fs/write.js'; -import { editFileTool } from './domains/fs/edit.js'; -import { bashTool } from './domains/bash/exec.js'; -import { searchTool } from './domains/fs/grep.js'; -import { globTool } from './domains/fs/glob.js'; -import { webFetchTool } from './domains/web/fetch.js'; -import { webSearchTool } from './domains/web/search.js'; -import { createTodoWriteTool } from './domains/self/todo-write.js'; -import { TodoService } from '../agent/todo.js'; - -export interface ToolBuildContext { - projectPath: string; - sessionId: string; -} - -export interface BuiltinToolProvider { - listBuiltinTools(ctx: ToolBuildContext): ToolDefinition[]; -} - -export interface ProjectToolProvider { - listProjectTools(projectPath: string, ctx: ToolBuildContext): ToolDefinition[]; -} - -export interface SessionToolResolver { - resolveTools(input: { - projectPath: string; - sessionId: string; - profile: AgentProfile; - policy: ToolVisibilityPolicy; - }): ToolDescription[]; -} - -/** Tools that require no Effect services — safe to instantiate statically. */ -const STATELESS_BUILTIN_TOOLS: ToolDefinition[] = [ - readFileTool, - writeFileTool, - editFileTool, - bashTool, - searchTool, - globTool, - webFetchTool, - webSearchTool, -]; - -/** - * Build the full list of builtin tools, including those that depend on - * Effect services (e.g. TodoService). Must be called inside an Effect - * context that provides the required services. - */ -export function getBuiltinTools(): Effect.Effect { - return Effect.gen(function* () { - const todoTool = yield* createTodoWriteTool(); - return [...STATELESS_BUILTIN_TOOLS, todoTool]; - }); -} - -// ---- Implementation factories ---- - -export function createBuiltinToolProvider(): BuiltinToolProvider { - return { - listBuiltinTools(_ctx: ToolBuildContext): ToolDefinition[] { - return [...STATELESS_BUILTIN_TOOLS]; - }, - }; -} - -export function createProjectToolProvider( - listProjectMcpTools: (projectPath: string) => ToolDefinition[] -): ProjectToolProvider { - return { - listProjectTools(projectPath: string, _ctx: ToolBuildContext): ToolDefinition[] { - return listProjectMcpTools(projectPath); - }, - }; -} - -export function createSessionToolResolver( - builtinProvider: BuiltinToolProvider, - projectProvider: ProjectToolProvider, - createToolSearch: () => ToolDefinition, - createDispatchAgent: () => ToolDefinition -): SessionToolResolver { - return { - resolveTools(input: { - projectPath: string; - sessionId: string; - profile: AgentProfile; - policy: ToolVisibilityPolicy; - }): ToolDescription[] { - const ctx: ToolBuildContext = { - projectPath: input.projectPath, - sessionId: input.sessionId, - }; - - let tools: ToolDefinition[] = [ - ...builtinProvider.listBuiltinTools(ctx), - ...projectProvider.listProjectTools(input.projectPath, ctx), - ]; - - tools.push(createToolSearch()); - tools.push(createDispatchAgent()); - - if (input.profile.tools) { - const allowed = new Set(input.profile.tools); - tools = tools.filter((t) => allowed.has(t.name)); - } - - if (input.policy.allowedTools) { - tools = tools.filter((t) => input.policy.allowedTools!.has(t.name)); - } - - return tools.map((t) => ({ - name: t.name, - description: t.description, - parameters: - t.jsonSchema ?? - (canonicalizeSchema(z.toJSONSchema(t.parameters)) as Record), - })); - }, - }; -} diff --git a/packages/codingcode/src/tools/registry.ts b/packages/codingcode/src/tools/registry.ts new file mode 100644 index 00000000..b48a0fa3 --- /dev/null +++ b/packages/codingcode/src/tools/registry.ts @@ -0,0 +1,31 @@ +import { z } from 'zod'; +import type { ToolDefinition, ToolDescription } from './types.js'; +import { canonicalizeSchema } from './utils/canonicalize-schema.js'; + +export class ToolRegistry { + private readonly tools = new Map(); + + register(...definitions: ToolDefinition[]): void { + for (const definition of definitions) { + if (this.tools.has(definition.name)) { + throw new Error(`Tool already registered: ${definition.name}`); + } + this.tools.set(definition.name, definition); + } + } + + get(name: string, allowedTools?: ReadonlySet): ToolDefinition | undefined { + if (allowedTools && !allowedTools.has(name)) return undefined; + return this.tools.get(name); + } + + describe(allowedTools?: ReadonlySet): ToolDescription[] { + return Array.from(this.tools.values()) + .filter((tool) => !allowedTools || allowedTools.has(tool.name)) + .map((tool) => ({ + name: tool.name, + description: tool.description, + parameters: canonicalizeSchema(z.toJSONSchema(tool.parameters)) as Record, + })); + } +} diff --git a/packages/codingcode/src/tools/tool-search-service.ts b/packages/codingcode/src/tools/tool-search-service.ts deleted file mode 100644 index 3c5911da..00000000 --- a/packages/codingcode/src/tools/tool-search-service.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { Effect } from 'effect'; -import type { ToolDefinition, ToolVisibilityPolicy } from './types.js'; - -export interface ToolSearchHit { - name: string; - shortDescription?: string; -} - -export class ToolSearchService extends Effect.Service()('ToolSearch', { - sync: () => { - const loaded = new Map>(); - const deferredTools: ToolDefinition[] = []; - - function getSet(sessionId: string): Set { - let s = loaded.get(sessionId); - if (!s) { - s = new Set(); - loaded.set(sessionId, s); - } - return s; - } - - function filterByPolicy( - tools: ToolDefinition[], - policy?: ToolVisibilityPolicy - ): ToolDefinition[] { - if (!policy || !policy.allowedTools) return tools; - return tools.filter((t) => policy.allowedTools!.has(t.name)); - } - - return { - registerDeferred(tool: ToolDefinition): void { - deferredTools.push(tool); - }, - - isLoaded(sessionId: string, toolName: string, policy?: ToolVisibilityPolicy): boolean { - if (policy?.allowedTools && !policy.allowedTools.has(toolName)) return false; - return getSet(sessionId).has(toolName); - }, - - listLoaded(sessionId: string): string[] { - return Array.from(getSet(sessionId)); - }, - - listUnloadedDeferred(sessionId: string, policy?: ToolVisibilityPolicy): ToolDefinition[] { - const set = getSet(sessionId); - return filterByPolicy( - deferredTools.filter((t) => !set.has(t.name)), - policy - ); - }, - - search(sessionId: string, query: string, policy?: ToolVisibilityPolicy): ToolSearchHit[] { - const set = getSet(sessionId); - const tokens = query.toLowerCase().split(/\s+/).filter(Boolean); - if (tokens.length === 0) return []; - const candidates = filterByPolicy(deferredTools, policy); - const hits = candidates.filter((t) => { - if (set.has(t.name)) return false; - const haystack = `${t.name} ${t.shortDescription ?? ''} ${t.description}`.toLowerCase(); - return tokens.every((tok) => haystack.includes(tok)); - }); - return hits.map((t) => ({ name: t.name, shortDescription: t.shortDescription })); - }, - - markLoaded(sessionId: string, toolNames: string[]): void { - const set = getSet(sessionId); - for (const name of toolNames) set.add(name); - }, - - reset(): void { - loaded.clear(); - deferredTools.length = 0; - }, - - disposeSession(sessionId: string): void { - loaded.delete(sessionId); - }, - }; - }, -}) {} diff --git a/packages/codingcode/src/tools/types.ts b/packages/codingcode/src/tools/types.ts index df95427a..e07b1457 100644 --- a/packages/codingcode/src/tools/types.ts +++ b/packages/codingcode/src/tools/types.ts @@ -13,17 +13,11 @@ export interface ToolExecCtx { export interface ToolDefinition { name: string; description: string; - shortDescription?: string; - deferred?: boolean; parameters: z.ZodTypeAny; - /** Optional JSON Schema override. When absent, the schema is auto-generated from `parameters`. */ - jsonSchema?: Record; execute: (args: unknown, ctx?: ToolExecCtx) => Effect.Effect; } export interface ToolVisibilityPolicy { allowedTools?: Set; allowedMcpServers?: Set; - allowToolSearch?: boolean; - allowDeferredTools?: boolean; } diff --git a/packages/codingcode/test/agent/agent-cache-stability.test.ts b/packages/codingcode/test/agent/agent-cache-stability.test.ts index dd0e6138..ad50eedd 100644 --- a/packages/codingcode/test/agent/agent-cache-stability.test.ts +++ b/packages/codingcode/test/agent/agent-cache-stability.test.ts @@ -45,8 +45,6 @@ const AllMockLayer = Layer.mergeAll( getToolPolicy: () => ({ allowedTools: undefined, allowedMcpServers: undefined, - allowToolSearch: true, - allowDeferredTools: false, }), setSessionProfile: () => {}, getSessionProfile: () => undefined, diff --git a/packages/codingcode/test/agent/agent-concurrent.test.ts b/packages/codingcode/test/agent/agent-concurrent.test.ts index cf4a95de..e89ec502 100644 --- a/packages/codingcode/test/agent/agent-concurrent.test.ts +++ b/packages/codingcode/test/agent/agent-concurrent.test.ts @@ -45,8 +45,6 @@ const AllMockLayer = Layer.mergeAll( getToolPolicy: () => ({ allowedTools: undefined, allowedMcpServers: undefined, - allowToolSearch: true, - allowDeferredTools: false, }), setSessionProfile: () => {}, getSessionProfile: () => undefined, diff --git a/packages/codingcode/test/agent/agent-todo-event.test.ts b/packages/codingcode/test/agent/agent-todo-event.test.ts index fb542f57..6e2cdfd8 100644 --- a/packages/codingcode/test/agent/agent-todo-event.test.ts +++ b/packages/codingcode/test/agent/agent-todo-event.test.ts @@ -48,8 +48,6 @@ const AllMockLayer = Layer.mergeAll( getToolPolicy: () => ({ allowedTools: undefined, allowedMcpServers: undefined, - allowToolSearch: true, - allowDeferredTools: false, }), setSessionProfile: () => {}, getSessionProfile: () => undefined, diff --git a/packages/codingcode/test/agent/agent.test.ts b/packages/codingcode/test/agent/agent.test.ts index 7429d662..f488bb24 100644 --- a/packages/codingcode/test/agent/agent.test.ts +++ b/packages/codingcode/test/agent/agent.test.ts @@ -29,37 +29,12 @@ vi.mock('@codingcode/infra/config', () => ({ }), })); -const mockToolRegistry = { - describeAll: () => [], - filter: () => [], - get: () => null, - register: () => Effect.succeed(undefined), - allCore: () => [], - allDeferred: () => [], - getDef: () => undefined, -}; - -const mockToolSearch = { - isLoaded: () => false, - listLoaded: () => [], - listUnloadedDeferred: () => [], - search: () => [], - reset: () => {}, -}; - const mockAgentService = { runStream: () => { throw new Error('not implemented'); }, }; -const mockSession = { - recordAssistant: (_state: any, _content: string, _toolCalls: any) => Effect.succeed({}), - recordToolResult: (_state: any, _toolName: string, _toolCallId: string, _output: string) => - Effect.succeed({}), - recordUser: (_state: any, _content: string) => Effect.succeed({}), -}; - const mockState = { sessionId: 'test-sid', cwd: '/tmp', @@ -156,8 +131,6 @@ const AllMockLayer = Layer.mergeAll( getToolPolicy: () => ({ allowedTools: undefined, allowedMcpServers: undefined, - allowToolSearch: true, - allowDeferredTools: false, }), setSessionProfile: () => {}, getSessionProfile: () => undefined, @@ -262,17 +235,6 @@ describe('agentLoop', () => { }), }; - const toolRegistryWithBash = { - ...mockToolRegistry, - describeAll: () => [ - { - name: 'execute_command', - description: 'Run shell command', - parameters: { type: 'object' }, - }, - ], - }; - const mockExecutor = { execute: (_name: string, _args: Record, _opts?: any) => Effect.succeed('On branch main\nnothing to commit'), @@ -328,13 +290,6 @@ describe('agentLoop', () => { }), }; - const toolRegistryWithTool = { - ...mockToolRegistry, - describeAll: () => [ - { name: 'readFile', description: 'Read a file', parameters: { type: 'object' } }, - ], - }; - const mockExecutor = { execute: (_name: string, _args: Record, _opts?: any) => Effect.succeed('file content'), diff --git a/packages/codingcode/test/agent/hooks-deps-type.test.ts b/packages/codingcode/test/agent/hooks-deps-type.test.ts index 50550d37..d881ab5f 100644 --- a/packages/codingcode/test/agent/hooks-deps-type.test.ts +++ b/packages/codingcode/test/agent/hooks-deps-type.test.ts @@ -46,8 +46,6 @@ const AllMockLayer = Layer.mergeAll( getToolPolicy: () => ({ allowedTools: undefined, allowedMcpServers: undefined, - allowToolSearch: true, - allowDeferredTools: false, }), setSessionProfile: () => {}, getSessionProfile: () => undefined, diff --git a/packages/codingcode/test/agent/loop-options.test.ts b/packages/codingcode/test/agent/loop-options.test.ts index 4d82242e..50243700 100644 --- a/packages/codingcode/test/agent/loop-options.test.ts +++ b/packages/codingcode/test/agent/loop-options.test.ts @@ -46,8 +46,6 @@ const AllMockLayer = Layer.mergeAll( getToolPolicy: () => ({ allowedTools: undefined, allowedMcpServers: undefined, - allowToolSearch: true, - allowDeferredTools: false, }), setSessionProfile: () => {}, getSessionProfile: () => undefined, diff --git a/packages/codingcode/test/agent/memory-snapshot.test.ts b/packages/codingcode/test/agent/memory-snapshot.test.ts index 7e765623..7fb7187d 100644 --- a/packages/codingcode/test/agent/memory-snapshot.test.ts +++ b/packages/codingcode/test/agent/memory-snapshot.test.ts @@ -56,8 +56,6 @@ const BaseMockLayer = Layer.mergeAll( getToolPolicy: () => ({ allowedTools: undefined, allowedMcpServers: undefined, - allowToolSearch: true, - allowDeferredTools: false, }), setSessionProfile: () => {}, getSessionProfile: () => undefined, diff --git a/packages/codingcode/test/agent/stop-hook.test.ts b/packages/codingcode/test/agent/stop-hook.test.ts index c27a0b16..07bd6caa 100644 --- a/packages/codingcode/test/agent/stop-hook.test.ts +++ b/packages/codingcode/test/agent/stop-hook.test.ts @@ -46,8 +46,6 @@ const AllMockLayer = Layer.mergeAll( getToolPolicy: () => ({ allowedTools: undefined, allowedMcpServers: undefined, - allowToolSearch: true, - allowDeferredTools: false, }), setSessionProfile: () => {}, getSessionProfile: () => undefined, diff --git a/packages/codingcode/test/agent/submit-plan-turn-end.test.ts b/packages/codingcode/test/agent/submit-plan-turn-end.test.ts index 417047bc..73b69300 100644 --- a/packages/codingcode/test/agent/submit-plan-turn-end.test.ts +++ b/packages/codingcode/test/agent/submit-plan-turn-end.test.ts @@ -44,8 +44,6 @@ const AllMockLayer = Layer.mergeAll( getToolPolicy: () => ({ allowedTools: undefined, allowedMcpServers: undefined, - allowToolSearch: true, - allowDeferredTools: false, }), setSessionProfile: () => {}, getSessionProfile: () => undefined, @@ -247,8 +245,6 @@ describe('agentLoop plan.ready emission on turn-end', () => { getToolPolicy: () => ({ allowedTools: undefined, allowedMcpServers: undefined, - allowToolSearch: true, - allowDeferredTools: false, }), setSessionProfile: (...args: any[]) => { setProfileCalls.push(args); diff --git a/packages/codingcode/test/mcp/service.test.ts b/packages/codingcode/test/mcp/service.test.ts index 44bee26a..7da5d816 100644 --- a/packages/codingcode/test/mcp/service.test.ts +++ b/packages/codingcode/test/mcp/service.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { Effect, Layer } from 'effect'; +import { z } from 'zod'; import { McpService } from '../../src/mcp/index.js'; import { HookService } from '../../src/hooks/registry.js'; @@ -185,6 +186,43 @@ describe('McpService granular methods', () => { await run(program); }); + it('converts MCP inputSchema into the tool parameters schema', async () => { + mockConfigs = [ + { + name: 'typed', + command: 'echo', + _mockTools: [ + { + name: 'query', + description: 'Query', + inputSchema: { + type: 'object', + properties: { text: { type: 'string' } }, + required: ['text'], + }, + }, + ], + }, + ]; + + const program = Effect.gen(function* () { + const mcp = yield* McpService; + yield* mcp.connectServers(TEST_PROJECT, TEST_SESSION, ['typed']); + const [tool] = mcp.listProjectMcpTools(TEST_PROJECT); + + expect(tool).toBeDefined(); + expect(z.toJSONSchema(tool!.parameters)).toMatchObject({ + type: 'object', + properties: { text: { type: 'string' } }, + required: ['text'], + }); + expect(() => tool!.parameters.parse({})).toThrow(); + expect(tool!.parameters.parse({ text: 'hello' })).toEqual({ text: 'hello' }); + }); + + await run(program); + }); + it('connectServers warns and skips unknown server name', async () => { mockConfigs = [ { diff --git a/packages/codingcode/test/orchestrate.test.ts b/packages/codingcode/test/orchestrate.test.ts index ad049b62..d546f2ec 100644 --- a/packages/codingcode/test/orchestrate.test.ts +++ b/packages/codingcode/test/orchestrate.test.ts @@ -197,8 +197,6 @@ vi.mock('../src/runtime/project-runtime.js', () => ({ getToolPolicy: vi.fn(() => ({ allowedTools: undefined, allowedMcpServers: undefined, - allowToolSearch: true, - allowDeferredTools: false, })), setSessionProfile: vi.fn(() => Effect.void), restoreSessionProfile: vi.fn(() => Effect.void), @@ -251,8 +249,6 @@ const MockProjectRuntimeLayer = Layer.succeed(ProjectRuntimeService, { getToolPolicy: () => ({ allowedTools: undefined, allowedMcpServers: undefined, - allowToolSearch: true, - allowDeferredTools: false, }), setSessionProfile: () => {}, getSessionProfile: () => undefined, diff --git a/packages/codingcode/test/subagent/dispatch.test.ts b/packages/codingcode/test/subagent/dispatch.test.ts index 29e6dc72..6d14b002 100644 --- a/packages/codingcode/test/subagent/dispatch.test.ts +++ b/packages/codingcode/test/subagent/dispatch.test.ts @@ -135,8 +135,6 @@ const mockProjectRuntime = { getToolPolicy: () => ({ allowedTools: undefined, allowedMcpServers: undefined, - allowToolSearch: true, - allowDeferredTools: false, }), setSessionProfile: () => Effect.void, restoreSessionProfile: () => Effect.void, diff --git a/packages/codingcode/test/subagent/registry.test.ts b/packages/codingcode/test/subagent/registry.test.ts index a4ed2759..e422036b 100644 --- a/packages/codingcode/test/subagent/registry.test.ts +++ b/packages/codingcode/test/subagent/registry.test.ts @@ -104,7 +104,7 @@ describe('SubagentService', () => { expect(EXPLORE_PROFILE.tools).toContain('search_files'); expect(EXPLORE_PROFILE.tools).toContain('search_code'); expect(EXPLORE_PROFILE.tools).toContain('fetch_url'); - expect(EXPLORE_PROFILE.tools).toContain('tool_search'); + expect(EXPLORE_PROFILE.tools).not.toContain('tool_search'); }); it('explore profile systemPrompt includes guidelines', () => { @@ -125,7 +125,7 @@ describe('SubagentService', () => { expect(PLAN_PROFILE.tools).toContain('search_files'); expect(PLAN_PROFILE.tools).toContain('search_code'); expect(PLAN_PROFILE.tools).toContain('fetch_url'); - expect(PLAN_PROFILE.tools).toContain('tool_search'); + expect(PLAN_PROFILE.tools).not.toContain('tool_search'); expect(PLAN_PROFILE.tools).toContain('submit_plan'); expect(PLAN_PROFILE.tools).toContain('dispatch_agent'); // Write tools are intentionally absent — the plan-mode gate hook denies diff --git a/packages/codingcode/test/tools/builtin-tools.test.ts b/packages/codingcode/test/tools/builtin-tools.test.ts new file mode 100644 index 00000000..50dcf480 --- /dev/null +++ b/packages/codingcode/test/tools/builtin-tools.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { Effect } from 'effect'; +import { TodoService } from '../../src/agent/todo.js'; +import { registerBuiltinTools } from '../../src/tools/builtin-tools.js'; +import { ToolRegistry } from '../../src/tools/registry.js'; + +describe('registerBuiltinTools', () => { + it('registers stateless tools and the TodoService-backed todo tool', async () => { + const registry = new ToolRegistry(); + await Effect.runPromise( + registerBuiltinTools(registry).pipe(Effect.provide(TodoService.Default)) + ); + + expect(registry.describe().map((tool) => tool.name)).toEqual([ + 'read_file', + 'write_file', + 'edit_file', + 'execute_command', + 'search_code', + 'search_files', + 'fetch_url', + 'web_search', + 'todo_write', + ]); + }); +}); diff --git a/packages/codingcode/test/tools/descriptions.test.ts b/packages/codingcode/test/tools/descriptions.test.ts index 268833e5..bd12f43c 100644 --- a/packages/codingcode/test/tools/descriptions.test.ts +++ b/packages/codingcode/test/tools/descriptions.test.ts @@ -48,10 +48,12 @@ describe('tool JSON Schema auto-generation', () => { } }); -describe('tool jsonSchema override', () => { - it('should not have hand-written jsonSchema on built-in tools', () => { +describe('tool parameter schemas', () => { + it('should expose parameters through generated JSON Schema', () => { for (const tool of allTools) { - expect(tool.jsonSchema).toBeUndefined(); + const schema = z.toJSONSchema(tool.parameters) as Record; + expect(schema.type).toBe('object'); + expect(schema.properties).toBeDefined(); } }); }); diff --git a/packages/codingcode/test/tools/executor-context.test.ts b/packages/codingcode/test/tools/executor-context.test.ts new file mode 100644 index 00000000..21765f43 --- /dev/null +++ b/packages/codingcode/test/tools/executor-context.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { Effect, Layer } from 'effect'; +import { z } from 'zod'; +import { ApprovalService } from '../../src/approval/index.js'; +import { HookService } from '../../src/hooks/registry.js'; +import { ToolExecutorService } from '../../src/tools/executor.js'; +import type { ToolDefinition, ToolExecCtx } from '../../src/tools/types.js'; + +const hooks = { + emit: () => Effect.void, +}; + +const approval = { + evaluate: () => Effect.succeed({ type: 'allow' as const }), +}; + +const executorLayer = ToolExecutorService.Default.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(HookService, hooks as any), + Layer.succeed(ApprovalService, approval as any) + ) + ) +); + +describe('ToolExecutorService context', () => { + it('passes execution context to a tool without per-tool type annotations', async () => { + let received: ToolExecCtx | undefined; + const tool: ToolDefinition = { + name: 'capture_context', + description: 'Captures execution context for verification.', + parameters: z.object({}), + execute: (_args, ctx) => { + received = ctx; + return Effect.succeed('ok'); + }, + }; + const signal = new AbortController().signal; + + const result = await Effect.runPromise( + Effect.gen(function* () { + const executor = yield* ToolExecutorService; + return yield* executor.execute('capture_context', {}, { + signal, + sessionId: 'session-1', + turnId: 2, + projectPath: '/project', + toolLookup: (name) => (name === tool.name ? tool : undefined), + }); + }).pipe(Effect.provide(executorLayer) as any) + ); + + expect((result as { output: string }).output).toBe('ok'); + expect(received).toEqual({ + signal, + sessionId: 'session-1', + turnId: 2, + projectPath: '/project', + }); + }); +}); diff --git a/packages/codingcode/test/tools/registry.test.ts b/packages/codingcode/test/tools/registry.test.ts new file mode 100644 index 00000000..7df084c7 --- /dev/null +++ b/packages/codingcode/test/tools/registry.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { Effect } from 'effect'; +import { z } from 'zod'; +import { ToolRegistry } from '../../src/tools/registry.js'; +import type { ToolDefinition } from '../../src/tools/types.js'; + +const readTool: ToolDefinition = { + name: 'read', + description: 'Reads a value.', + parameters: z.object({ path: z.string() }), + execute: () => Effect.succeed('read'), +}; + +const writeTool: ToolDefinition = { + name: 'write', + description: 'Writes a value.', + parameters: z.object({ path: z.string(), content: z.string() }), + execute: () => Effect.succeed('write'), +}; + +describe('ToolRegistry', () => { + it('registers tools once and exposes filtered descriptions and lookups', () => { + const registry = new ToolRegistry(); + registry.register(readTool, writeTool); + + const allowed = new Set(['read']); + expect(registry.describe(allowed)).toMatchObject([ + { + name: 'read', + parameters: { type: 'object', properties: { path: { type: 'string' } } }, + }, + ]); + expect(registry.get('read', allowed)).toBe(readTool); + expect(registry.get('write', allowed)).toBeUndefined(); + }); + + it('rejects duplicate tool names', () => { + const registry = new ToolRegistry(); + registry.register(readTool); + + expect(() => registry.register(readTool)).toThrow('Tool already registered: read'); + }); +}); diff --git a/packages/codingcode/test/tools/todo.test.ts b/packages/codingcode/test/tools/todo.test.ts index c3187e02..4d8d0f28 100644 --- a/packages/codingcode/test/tools/todo.test.ts +++ b/packages/codingcode/test/tools/todo.test.ts @@ -8,9 +8,9 @@ async function makeTodoTool() { } describe('todo_write tool', () => { - it('is a core tool (not deferred)', async () => { + it('does not expose a deferred flag', async () => { const tool = await makeTodoTool(); - expect(tool.deferred).not.toBe(true); + expect('deferred' in tool).toBe(false); }); it('returns pending/in_progress/completed counts', async () => { diff --git a/packages/codingcode/test/tools/tool-search.test.ts b/packages/codingcode/test/tools/tool-search.test.ts deleted file mode 100644 index 8dcdd684..00000000 --- a/packages/codingcode/test/tools/tool-search.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { Effect } from 'effect'; -import { createToolSearchTool } from '../../src/tools/domains/self/tool-search.js'; -import { ToolSearchService } from '../../src/tools/tool-search-service.js'; -import type { ToolDefinition } from '../../src/tools/types.js'; - -describe('createToolSearchTool', () => { - it('returns loaded tool list when matches found', async () => { - const setupAndRun = Effect.gen(function* () { - const svc = yield* ToolSearchService; - svc.registerDeferred({ - name: 'todo_write', - shortDescription: 'Write tasks', - } as ToolDefinition); - const tool = yield* createToolSearchTool(); - return yield* tool.execute({ query: 'todo' }, { sessionId: 'test-agent' }); - }); - - const result = await Effect.runPromise( - setupAndRun.pipe(Effect.provide(ToolSearchService.Default)) - ); - expect(result).toContain('Loaded 1 tool(s)'); - expect(result).toContain('todo_write'); - }); - - it('returns no-match message when no hits', async () => { - const setupAndRun = Effect.gen(function* () { - const svc = yield* ToolSearchService; - svc.registerDeferred({ - name: 'unrelated_tool', - shortDescription: 'Something else', - } as ToolDefinition); - const tool = yield* createToolSearchTool(); - return yield* tool.execute({ query: 'zzznonexistent' }, { sessionId: 'test-agent' }); - }); - - const result = await Effect.runPromise( - setupAndRun.pipe(Effect.provide(ToolSearchService.Default)) - ); - expect(result).toBe('No deferred tools matched "zzznonexistent".'); - }); - - it('fails with AgentError if sessionId is missing', async () => { - const setupAndRun = Effect.gen(function* () { - const tool = yield* createToolSearchTool(); - return yield* Effect.flip(tool.execute({ query: 'anything' }, {})); - }); - - const error = await Effect.runPromise( - setupAndRun.pipe(Effect.provide(ToolSearchService.Default)) - ); - expect(error.name).toBe('AgentError'); - }); - - it('each tool instance uses the same service but different deferred registrations', async () => { - const setupAndRun = Effect.gen(function* () { - const svc = yield* ToolSearchService; - svc.registerDeferred({ name: 'tool_a', shortDescription: 'Tool A' } as ToolDefinition); - svc.registerDeferred({ name: 'tool_b', shortDescription: 'Tool B' } as ToolDefinition); - - const tool1 = yield* createToolSearchTool(); - const tool2 = yield* createToolSearchTool(); - - const r1 = yield* tool1.execute({ query: 'a' }, { sessionId: 'session-1' }); - const r2 = yield* tool2.execute({ query: 'b' }, { sessionId: 'session-2' }); - return { r1, r2 }; - }); - - const { r1, r2 } = await Effect.runPromise( - setupAndRun.pipe(Effect.provide(ToolSearchService.Default)) - ); - - expect(r1).toContain('tool_a'); - expect(r2).toContain('tool_b'); - }); -}); diff --git a/packages/desktop/src/settings/SubagentsPanel.tsx b/packages/desktop/src/settings/SubagentsPanel.tsx index 4cf704df..47a6c4bc 100644 --- a/packages/desktop/src/settings/SubagentsPanel.tsx +++ b/packages/desktop/src/settings/SubagentsPanel.tsx @@ -25,7 +25,6 @@ const AVAILABLE_TOOLS = [ 'fetch_url', 'web_search', 'todo_write', - 'tool_search', 'dispatch_agent', ]; diff --git a/packages/desktop/test/settings-panels.test.ts b/packages/desktop/test/settings-panels.test.ts index dc1109ff..6dc81481 100644 --- a/packages/desktop/test/settings-panels.test.ts +++ b/packages/desktop/test/settings-panels.test.ts @@ -200,7 +200,6 @@ const AVAILABLE_TOOLS = [ 'fetch_url', 'web_search', 'todo_write', - 'tool_search', 'dispatch_agent', ]; @@ -244,10 +243,11 @@ function toggleTool(selected: string[], tool: string): string[] { } describe('SubagentsPanel - tool multi-select form logic', () => { - it('AVAILABLE_TOOLS contains all 11 expected tools', () => { - expect(AVAILABLE_TOOLS).toHaveLength(11); + it('AVAILABLE_TOOLS contains all 10 expected tools', () => { + expect(AVAILABLE_TOOLS).toHaveLength(10); expect(AVAILABLE_TOOLS).toContain('read_file'); expect(AVAILABLE_TOOLS).toContain('dispatch_agent'); + expect(AVAILABLE_TOOLS).not.toContain('tool_search'); }); it('startEdit populates tools as array directly (no join)', () => {