diff --git a/docs/development.md b/docs/development.md index b5a84edd6..0a6674460 100644 --- a/docs/development.md +++ b/docs/development.md @@ -173,6 +173,19 @@ The supervisor rotates `daemon.log`. Persisted `log.file.rotate` settings in `PASEO_LOG_ROTATE_SIZE` and `PASEO_LOG_ROTATE_COUNT` env vars override the defaults. The default rotation is `10m` x `3` files everywhere. +### Agent Tool Catalog Measurement + +Measure the MCP `tools/list` payload that Paseo injects into agents with: + +```bash +npm run measure:agent-tools --workspace=@getpaseo/server +``` + +The command reports compact JSON bytes, estimated tokens, field totals, largest +tools, and the browser-tools delta. It defaults to the agent-scoped catalog; use +`-- --scope=top-level` for the unaffiliated `/mcp/agents` shape and `-- --json` +for machine-readable output. + ## paseo.json service scripts `worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array diff --git a/packages/server/package.json b/packages/server/package.json index f3cdea62a..e03e4f350 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -44,6 +44,7 @@ "start": "node dist/scripts/supervisor-entrypoint.js", "typecheck": "tsgo -p tsconfig.server.typecheck.json --noEmit", "generate:config-schema": "tsx scripts/generate-config-schema.ts", + "measure:agent-tools": "tsx scripts/measure-agent-tools-context.ts", "speech:models": "tsx scripts/list-speech-models.ts", "speech:download": "tsx scripts/download-speech-models.ts", "speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts", diff --git a/packages/server/scripts/measure-agent-tools-context.ts b/packages/server/scripts/measure-agent-tools-context.ts new file mode 100644 index 000000000..e9fcbd6d5 --- /dev/null +++ b/packages/server/scripts/measure-agent-tools-context.ts @@ -0,0 +1,384 @@ +import { Buffer } from "node:buffer"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; +import pino from "pino"; + +import { createAgentMcpServer } from "../src/server/agent/mcp-server.js"; +import type { AgentManager, ManagedAgent } from "../src/server/agent/agent-manager.js"; +import type { AgentStorage, StoredAgentRecord } from "../src/server/agent/agent-storage.js"; +import type { AgentProvider, ProviderSnapshotEntry } from "../src/server/agent/agent-sdk-types.js"; +import type { ProviderSnapshotManager } from "../src/server/agent/provider-snapshot-manager.js"; +import type { BrowserToolsBroker } from "../src/server/browser-tools/broker.js"; +import type { BrowserToolsResponsePayload } from "../src/server/browser-tools/errors.js"; + +type CatalogScope = "agent" | "top-level"; + +interface CliOptions { + format: "markdown" | "json"; + scope: CatalogScope; + top: number; +} + +interface FieldSize { + field: string; + bytes: number; + estimatedTokens: number; +} + +interface ToolSize { + name: string; + bytes: number; + estimatedTokens: number; + fields: FieldSize[]; +} + +interface CatalogMeasurement { + label: string; + browserToolsEnabled: boolean; + toolCount: number; + bytes: number; + estimatedTokens: number; + withoutOutputSchemas: { + bytes: number; + estimatedTokens: number; + savedBytes: number; + savedEstimatedTokens: number; + }; + fields: FieldSize[]; + tools: ToolSize[]; +} + +const DEFAULT_TOP_COUNT = 15; +const MEASUREMENT_AGENT_ID = "agent-tools-measurement"; +const MEASUREMENT_CWD = "/tmp/paseo-agent-tools-measurement"; +const MEASUREMENT_WORKSPACE_ID = "workspace_agent_tools_measurement"; +const FIELD_NAMES = [ + "name", + "title", + "description", + "inputSchema", + "outputSchema", + "annotations", + "execution", + "_meta", +] as const; + +function parseCliOptions(args: string[]): CliOptions { + let format: CliOptions["format"] = "markdown"; + let scope: CatalogScope = "agent"; + let top = DEFAULT_TOP_COUNT; + + for (const arg of args) { + if (arg === "--json") { + format = "json"; + continue; + } + if (arg === "--markdown") { + format = "markdown"; + continue; + } + if (arg.startsWith("--scope=")) { + const value = arg.slice("--scope=".length); + if (value !== "agent" && value !== "top-level") { + throw new Error("--scope must be agent or top-level"); + } + scope = value; + continue; + } + if (arg.startsWith("--top=")) { + const value = Number.parseInt(arg.slice("--top=".length), 10); + if (!Number.isInteger(value) || value < 1) { + throw new Error("--top must be a positive integer"); + } + top = value; + continue; + } + if (arg === "--help") { + printHelp(); + process.exit(0); + } + throw new Error(`Unknown argument: ${arg}`); + } + + return { format, scope, top }; +} + +function printHelp(): void { + process.stdout.write(`Usage: npm run measure:agent-tools -- [options] + +Measures the MCP tools/list payload for Paseo agent tools. + +Options: + --scope=agent|top-level Catalog shape to measure. Defaults to agent. + --top=N Number of largest tools to show. Defaults to ${DEFAULT_TOP_COUNT}. + --json Emit machine-readable JSON. + --markdown Emit Markdown. This is the default. +`); +} + +function estimateTokens(text: string): number { + return Math.ceil(Buffer.byteLength(text, "utf8") / 4); +} + +function measureJson(value: unknown): { bytes: number; estimatedTokens: number } { + const text = JSON.stringify(value); + return { + bytes: Buffer.byteLength(text, "utf8"), + estimatedTokens: estimateTokens(text), + }; +} + +function measureField(field: string, value: unknown): FieldSize { + return { + field, + ...measureJson({ [field]: value }), + }; +} + +function stripOutputSchemas(tools: Tool[]): Tool[] { + return tools.map((tool) => { + const { outputSchema: _outputSchema, ...rest } = tool; + return rest; + }); +} + +function measureTool(tool: Tool): ToolSize { + const fields = FIELD_NAMES.flatMap((field) => + Object.hasOwn(tool, field) ? [measureField(field, tool[field])] : [], + ); + return { + name: tool.name, + ...measureJson(tool), + fields, + }; +} + +function sumFieldSizes(tools: ToolSize[]): FieldSize[] { + const totals = new Map(); + for (const tool of tools) { + for (const field of tool.fields) { + const current = totals.get(field.field) ?? { bytes: 0, estimatedTokens: 0 }; + totals.set(field.field, { + bytes: current.bytes + field.bytes, + estimatedTokens: current.estimatedTokens + field.estimatedTokens, + }); + } + } + return [...totals.entries()] + .map(([field, size]) => ({ + field, + bytes: size.bytes, + estimatedTokens: size.estimatedTokens, + })) + .sort((a, b) => b.bytes - a.bytes); +} + +async function measureCatalog(params: { + label: string; + browserToolsEnabled: boolean; + scope: CatalogScope; +}): Promise { + const server = await createAgentMcpServer({ + agentManager: new MeasurementAgentManager() as unknown as AgentManager, + agentStorage: new MeasurementAgentStorage() as unknown as AgentStorage, + providerSnapshotManager: + new MeasurementProviderSnapshotManager() as unknown as ProviderSnapshotManager, + browserToolsEnabled: params.browserToolsEnabled, + browserToolsBroker: new MeasurementBrowserToolsBroker() as unknown as BrowserToolsBroker, + callerAgentId: params.scope === "agent" ? MEASUREMENT_AGENT_ID : undefined, + logger: pino({ level: "silent" }), + }); + const client = new Client({ name: "paseo-agent-tools-measurement", version: "0.0.0" }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await server.connect(serverTransport); + await client.connect(clientTransport); + try { + const result = await client.listTools(); + const size = measureJson(result.tools); + const strippedSize = measureJson(stripOutputSchemas(result.tools)); + const tools = result.tools.map(measureTool).sort((a, b) => b.bytes - a.bytes); + return { + label: params.label, + browserToolsEnabled: params.browserToolsEnabled, + toolCount: result.tools.length, + ...size, + withoutOutputSchemas: { + ...strippedSize, + savedBytes: size.bytes - strippedSize.bytes, + savedEstimatedTokens: size.estimatedTokens - strippedSize.estimatedTokens, + }, + fields: sumFieldSizes(tools), + tools, + }; + } finally { + await client.close(); + await server.close(); + } +} + +function formatInteger(value: number): string { + return value.toLocaleString("en-US"); +} + +function formatMarkdown(measurements: CatalogMeasurement[], top: number): string { + const [withoutBrowser, withBrowser] = measurements; + const delta = + withoutBrowser && withBrowser + ? { + tools: withBrowser.toolCount - withoutBrowser.toolCount, + bytes: withBrowser.bytes - withoutBrowser.bytes, + estimatedTokens: withBrowser.estimatedTokens - withoutBrowser.estimatedTokens, + } + : null; + const lines: string[] = []; + lines.push("# Paseo Agent Tool Catalog Context"); + lines.push(""); + lines.push("Token counts are estimates from compact JSON bytes / 4."); + lines.push(""); + lines.push("| Catalog | Tools | Bytes | Estimated tokens | Output-schema savings |"); + lines.push("| --- | ---: | ---: | ---: | ---: |"); + for (const measurement of measurements) { + lines.push( + `| ${measurement.label} | ${formatInteger(measurement.toolCount)} | ${formatInteger( + measurement.bytes, + )} | ${formatInteger(measurement.estimatedTokens)} | ${formatInteger( + measurement.withoutOutputSchemas.savedEstimatedTokens, + )} est. tokens |`, + ); + } + if (delta) { + lines.push( + `| Browser-tool delta | +${formatInteger(delta.tools)} | +${formatInteger( + delta.bytes, + )} | +${formatInteger(delta.estimatedTokens)} | |`, + ); + } + + for (const measurement of measurements) { + lines.push(""); + lines.push(`## ${measurement.label}`); + lines.push(""); + lines.push("### Field totals"); + lines.push(""); + lines.push("| Field | Bytes | Estimated tokens |"); + lines.push("| --- | ---: | ---: |"); + for (const field of measurement.fields) { + lines.push( + `| ${field.field} | ${formatInteger(field.bytes)} | ${formatInteger( + field.estimatedTokens, + )} |`, + ); + } + lines.push(""); + lines.push(`### Largest ${Math.min(top, measurement.tools.length)} tools`); + lines.push(""); + lines.push("| Tool | Bytes | Estimated tokens | Description | Input schema | Output schema |"); + lines.push("| --- | ---: | ---: | ---: | ---: | ---: |"); + for (const tool of measurement.tools.slice(0, top)) { + const fields = new Map(tool.fields.map((field) => [field.field, field.estimatedTokens])); + lines.push( + `| ${tool.name} | ${formatInteger(tool.bytes)} | ${formatInteger( + tool.estimatedTokens, + )} | ${formatInteger(fields.get("description") ?? 0)} | ${formatInteger( + fields.get("inputSchema") ?? 0, + )} | ${formatInteger(fields.get("outputSchema") ?? 0)} |`, + ); + } + } + lines.push(""); + return `${lines.join("\n")}\n`; +} + +class MeasurementAgentManager { + private readonly agent = { + id: MEASUREMENT_AGENT_ID, + cwd: MEASUREMENT_CWD, + workspaceId: MEASUREMENT_WORKSPACE_ID, + provider: "codex", + currentModeId: "default", + config: { + provider: "codex", + cwd: MEASUREMENT_CWD, + }, + }; + + public getAgent(agentId: string): ManagedAgent | null { + return agentId === MEASUREMENT_AGENT_ID ? (this.agent as unknown as ManagedAgent) : null; + } + + public listAgents(): ManagedAgent[] { + return []; + } +} + +class MeasurementAgentStorage { + public async list(): Promise { + return []; + } +} + +class MeasurementProviderSnapshotManager { + public listRegisteredProviderIds(): AgentProvider[] { + return []; + } + + public hasProvider(): boolean { + return false; + } + + public getProviderLabel(provider: AgentProvider): string { + return provider; + } + + public async listProviders(): Promise { + return []; + } + + public async getProvider(): Promise { + throw new Error("Provider catalog is not used by the measurement script"); + } + + public async listModels(): Promise<[]> { + return []; + } + + public async listModes(): Promise<[]> { + return []; + } +} + +class MeasurementBrowserToolsBroker { + public async execute(): Promise { + throw new Error("Browser tools are not executed by the measurement script"); + } +} + +async function main(): Promise { + const options = parseCliOptions(process.argv.slice(2)); + const measurements = await Promise.all([ + measureCatalog({ + label: "Core tools", + browserToolsEnabled: false, + scope: options.scope, + }), + measureCatalog({ + label: "Core + browser tools", + browserToolsEnabled: true, + scope: options.scope, + }), + ]); + + if (options.format === "json") { + process.stdout.write(`${JSON.stringify({ scope: options.scope, measurements }, null, 2)}\n`); + return; + } + process.stdout.write(formatMarkdown(measurements, options.top)); +} + +main().catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`${message}\n`); + process.exitCode = 1; +}); diff --git a/packages/server/src/server/agent/agent-mcp.e2e.test.ts b/packages/server/src/server/agent/agent-mcp.e2e.test.ts index b8ffaefe9..0374c4eaf 100644 --- a/packages/server/src/server/agent/agent-mcp.e2e.test.ts +++ b/packages/server/src/server/agent/agent-mcp.e2e.test.ts @@ -146,24 +146,21 @@ function createMcpRecordingAgentClients(recorder: LaunchRecorder) { }; } -async function waitForAgentCompletion(options: { +async function assertAgentNotRunning(options: { client: McpClient; agentId: string; }): Promise { - const waitResult = await options.client.callTool({ - name: "wait_for_agent", + const statusResult = await options.client.callTool({ + name: "get_agent_status", args: { agentId: options.agentId }, }); - const payload = getStructuredContent(waitResult); + const payload = getStructuredContent(statusResult); if (!payload) { - throw new Error("wait_for_agent returned no structured payload"); - } - if (payload.permission) { - throw new Error(`Unexpected permission while waiting: ${JSON.stringify(payload.permission)}`); + throw new Error("get_agent_status returned no structured payload"); } const status = payload.status; if (status === "running" || status === "initializing") { - throw new Error(`Agent still running after wait_for_agent (status=${status})`); + throw new Error(`Agent still running after blocking create_agent (status=${status})`); } } @@ -218,7 +215,7 @@ describe("agent MCP end-to-end (offline)", () => { agentId = typeof payload?.agentId === "string" ? payload.agentId : null; expect(agentId).toBeTruthy(); - await waitForAgentCompletion({ client, agentId: agentId! }); + await assertAgentNotRunning({ client, agentId: agentId! }); if (existsSync(filePath)) { const contents = await readFile(filePath, "utf8"); @@ -703,7 +700,7 @@ describe("agent MCP end-to-end (offline)", () => { agentId = typeof payload?.agentId === "string" ? payload.agentId : null; expect(agentId).toBeTruthy(); - await waitForAgentCompletion({ client, agentId: agentId! }); + await assertAgentNotRunning({ client, agentId: agentId! }); const statusResult = await client.callTool({ name: "get_agent_status", args: { agentId }, diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 7a322fbdf..b2515f154 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -6,12 +6,10 @@ import { realpathSync, rmSync } from "node:fs"; import { access, mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; import { join, resolve as resolvePath } from "node:path"; import { tmpdir } from "node:os"; -import Ajv from "ajv"; import { z } from "zod"; import { createTestLogger } from "../../test-utils/test-logger.js"; import { createAgentMcpServer } from "./mcp-server.js"; -import { createPaseoToolCatalog } from "./tools/paseo-tools.js"; import { AgentManager, type ManagedAgent } from "./agent-manager.js"; import { AgentStorage, type StoredAgentRecord } from "./agent-storage.js"; import { createTestAgentClients } from "../test-utils/fake-agent-client.js"; @@ -83,7 +81,6 @@ interface LooseContentBlock { interface RegisteredMcpTool { inputSchema: LooseInputSchema; - outputSchema?: unknown; callback?: ( input: unknown, extra?: unknown, @@ -136,24 +133,20 @@ async function invokeToolWithParsedInput( return tool.handler(parsed.data); } -function expectOutputSchemaAccepts(tool: RegisteredMcpTool, data: unknown): void { - expect(tool.outputSchema).toBeDefined(); - const jsonSchema = z.toJSONSchema(tool.outputSchema as z.ZodType, { - target: "draft-07", - unrepresentable: "any", - io: "input", - }); - const ajv = new Ajv({ allErrors: true, strict: false }); - const validate = ajv.compile(jsonSchema); - expect(validate(data), JSON.stringify(validate.errors, null, 2)).toBe(true); -} - function agentsOf(response: { structuredContent: LooseStructuredContent; }): Array> { return z.array(z.record(z.string(), z.unknown())).parse(response.structuredContent.agents); } +function expectSingleTextContent(response: { content?: LooseContentBlock[] }): string { + const content = response.content ?? []; + expect(content).toHaveLength(1); + const block = content[0]; + expect(block?.type).toBe("text"); + return z.string().min(1).parse(block?.text); +} + async function waitForWorkspaceTitle( workspaceRecords: Map, workspaceId: string, @@ -753,7 +746,7 @@ function createPaseoWorktreeForMcpTest(options: { describe("browser MCP tools", () => { const logger = createTestLogger(); - it("calls registered tools through the MCP SDK with listed output schemas", async () => { + it("omits output schemas from tools/list and keeps tool call content model-visible", async () => { const agentManager = new BoundaryAgentManagerFake(); const agentStorage = new BoundaryAgentStorageFake(); const broker = new FakeBrowserToolsBroker({ @@ -802,17 +795,15 @@ describe("browser MCP tools", () => { expect(listAgentsResult.structuredContent).toEqual({ agents: [], }); + expectSingleTextContent(browserResult); + expect(expectSingleTextContent(listAgentsResult)).toContain('"agents": []'); const listedTools = await client.listTools(); - const toolsByName = new Map(listedTools.tools.map((tool) => [tool.name, tool])); - const catalog = createPaseoToolCatalog(serverOptions); - - for (const tool of catalog.tools.values()) { - if (tool.outputSchema !== undefined) { - expect(toolsByName.get(tool.name)?.outputSchema, `${tool.name} outputSchema`).toEqual( - expect.objectContaining({ type: "object" }), - ); - } + expect(listedTools.tools.map((tool) => tool.name)).toEqual( + expect.arrayContaining(["browser_list_tabs", "list_agents"]), + ); + for (const tool of listedTools.tools) { + expect(tool, `${tool.name} outputSchema`).not.toHaveProperty("outputSchema"); } } finally { await client.close(); @@ -1308,7 +1299,7 @@ describe("create_agent MCP tool", () => { ); }); - it("advertises create_agent output schema that accepts full provider modes", async () => { + it("returns create_agent structured content with full provider modes", async () => { const { agentManager, agentStorage, spies } = createTestDeps(); spies.agentManager.createAgent.mockResolvedValue({ id: "mode-agent", @@ -1344,7 +1335,24 @@ describe("create_agent MCP tool", () => { background: true, }); - expectOutputSchemaAccepts(tool, response.structuredContent); + expect(response.structuredContent).toEqual( + expect.objectContaining({ + agentId: "mode-agent", + type: "codex", + status: "idle", + cwd: REPO_CWD, + currentModeId: "build", + availableModes: [ + { + id: "build", + label: "Build", + description: null, + icon: "hammer", + colorTier: "dangerous", + }, + ], + }), + ); }); it("requires provider as provider/model and rejects the old model field", async () => { @@ -2904,7 +2912,7 @@ describe("create_agent MCP tool", () => { }); expect(response.structuredContent.guidance).toBe( - "You will get notified when the created agent finishes, errors, or needs permission. Do not call wait_for_agent or poll for status; continue with other work until the notification arrives.", + "You will get notified when the created agent finishes, errors, or needs permission. Do not poll for status; continue with other work until the notification arrives.", ); }); @@ -3364,7 +3372,7 @@ describe("send_agent_prompt MCP tool", () => { expect(spies.agentManager.subscribe).toHaveBeenCalledTimes(1); expect(spies.agentManager.waitForAgentEvent).not.toHaveBeenCalled(); expect(response.structuredContent.guidance).toBe( - "You will get notified when the prompted agent finishes, errors, or needs permission. Do not call wait_for_agent or poll for status; continue with other work until the notification arrives.", + "You will get notified when the prompted agent finishes, errors, or needs permission. Do not poll for status; continue with other work until the notification arrives.", ); }); @@ -3816,7 +3824,7 @@ describe("create_schedule MCP tool", () => { ); }); - it("advertises create_schedule output schema that accepts inherited feature values", async () => { + it("returns create_schedule structured content with inherited feature values", async () => { const { agentManager, agentStorage, spies } = createTestDeps(); spies.agentManager.getAgent.mockReturnValue({ id: "parent-agent", @@ -3854,7 +3862,6 @@ describe("create_schedule MCP tool", () => { type: "new-agent", config: { featureValues: { auto_accept: true } }, }); - expectOutputSchemaAccepts(tool, response.structuredContent); }); it("passes timezone through cron create_schedule input", async () => { @@ -4872,7 +4879,6 @@ describe("agent snapshot MCP serialization", () => { `get_agent_status response failed AgentSnapshotPayloadSchema: ${JSON.stringify(parsed.error.issues, null, 2)}`, ); } - expectOutputSchemaAccepts(tool, response.structuredContent); expect(response.structuredContent.status).toBe("idle"); expect(snapshot).toEqual( expect.objectContaining({ @@ -5200,7 +5206,7 @@ describe("agent snapshot MCP serialization", () => { ]); }); - it("emits list_agents payloads that satisfy the declared output schema", async () => { + it("emits list_agents payloads that satisfy the agent list schema", async () => { const { agentManager, agentStorage, spies } = createTestDeps(); const now = new Date().toISOString(); spies.agentManager.listAgents.mockReturnValue([createManagedAgent()]); @@ -5231,7 +5237,7 @@ describe("agent snapshot MCP serialization", () => { } }); - it("emits list_pending_permissions payloads that satisfy the declared output schema", async () => { + it("emits list_pending_permissions payloads that satisfy the permission schema", async () => { const { agentManager, agentStorage, spies } = createTestDeps(); spies.agentManager.listAgents.mockReturnValue([ createManagedAgent({ @@ -5288,7 +5294,6 @@ describe("agent snapshot MCP serialization", () => { }, }, ]); - expectOutputSchemaAccepts(tool, response.structuredContent); }); it("loads archived agents before reading get_agent_activity", async () => { diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index 684cd4414..f209775a7 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -85,7 +85,6 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom title: tool.title, description: tool.description, inputSchema: tool.inputSchema, - outputSchema: tool.outputSchema, }, async (args: unknown, context?: McpToolContext) => toMcpToolResult(await catalog.executeTool(tool.name, args, { signal: context?.signal })), diff --git a/packages/server/src/server/agent/mcp-shared.ts b/packages/server/src/server/agent/mcp-shared.ts index f3c6e9fc8..60a127385 100644 --- a/packages/server/src/server/agent/mcp-shared.ts +++ b/packages/server/src/server/agent/mcp-shared.ts @@ -145,7 +145,7 @@ export async function waitForAgentWithTimeout( }); const recentActivity = curateAgentActivity(recent.items); const waitedSeconds = Math.round(AGENT_WAIT_TIMEOUT_MS / 1000); - const message = `Awaiting the agent timed out after ${waitedSeconds}s. This does not mean the agent failed - call wait_for_agent again to continue waiting.\n\nRecent activity:\n${recentActivity}`; + const message = `Awaiting the agent timed out after ${waitedSeconds}s. This does not mean the agent failed - it is still running. Call get_agent_status to check on it, or continue with other work if you will receive a finish notification.\n\nRecent activity:\n${recentActivity}`; return { status: snapshot?.lifecycle ?? "idle", permission: null, diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index 1ff0ec9ac..b091013a2 100644 --- a/packages/server/src/server/agent/tools/paseo-tools.ts +++ b/packages/server/src/server/agent/tools/paseo-tools.ts @@ -3,7 +3,7 @@ import { ensureValidJson } from "../../json-utils.js"; import type { Logger } from "pino"; import type { AgentMode, AgentProvider } from "../agent-sdk-types.js"; -import type { AgentManager, WaitForAgentResult } from "../agent-manager.js"; +import type { AgentManager } from "../agent-manager.js"; import { AgentFeatureSchema, AgentPermissionRequestPayloadSchema, @@ -26,7 +26,6 @@ import { killTerminalsForWorkspace, type ArchiveDependencies, } from "../../workspace-archive-service.js"; -import { WaitForAgentTracker } from "../wait-for-agent-tracker.js"; import { createAgentCommand, type CreateAgentFromMcpInput } from "../create-agent/create.js"; import type { VoiceCallerContext, VoiceSpeakHandler } from "../../voice-types.js"; import type { FirstAgentContext } from "../../messages.js"; @@ -436,7 +435,6 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase logger, } = options; const childLogger = logger.child({ module: "agent", component: "paseo-tool-catalog" }); - const waitTracker = new WaitForAgentTracker(logger); const callerContext = callerAgentId ? (resolveCallerContext?.(callerAgentId) ?? null) : null; const parseToolInput = async (tool: PaseoToolDefinition, input: unknown): Promise => { @@ -1145,7 +1143,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase const currentSnapshot = agentManager.getAgent(snapshot.id) ?? snapshot; const guidance = callerAgentId && notifyOnFinish && initialPromptStarted - ? "You will get notified when the created agent finishes, errors, or needs permission. Do not call wait_for_agent or poll for status; continue with other work until the notification arrives." + ? "You will get notified when the created agent finishes, errors, or needs permission. Do not poll for status; continue with other work until the notification arrives." : undefined; const response = { content: [], @@ -1409,83 +1407,6 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase } } - registerTool( - "wait_for_agent", - { - title: "Wait for agent", - description: - "Block until the agent requests permission or the current run completes. Returns the pending permission (if any) and recent activity summary.", - inputSchema: { - agentId: z.string().describe("Agent identifier returned by the create_agent tool"), - }, - outputSchema: { - agentId: z.string(), - status: AgentStatusEnum, - permission: AgentPermissionRequestPayloadSchema.nullable(), - lastMessage: z.string().nullable(), - }, - }, - async ({ agentId }, { signal }) => { - const abortController = new AbortController(); - const cleanupFns: Array<() => void> = []; - - const cleanup = () => { - while (cleanupFns.length) { - const fn = cleanupFns.pop(); - try { - fn?.(); - } catch { - // ignore cleanup errors - } - } - }; - - const forwardExternalAbort = () => { - if (!abortController.signal.aborted) { - const reason = signal?.reason ?? new Error("wait_for_agent aborted"); - abortController.abort(reason); - } - }; - - if (signal) { - if (signal.aborted) { - forwardExternalAbort(); - } else { - signal.addEventListener("abort", forwardExternalAbort, { once: true }); - cleanupFns.push(() => signal.removeEventListener("abort", forwardExternalAbort)); - } - } - - const unregister = waitTracker.register(agentId, (reason) => { - if (!abortController.signal.aborted) { - abortController.abort(new Error(reason ?? "wait_for_agent cancelled")); - } - }); - cleanupFns.push(unregister); - - try { - const result: WaitForAgentResult = await waitForAgentWithTimeout(agentManager, agentId, { - signal: abortController.signal, - }); - - const validJson = ensureValidJson({ - agentId, - status: result.status, - permission: sanitizePermissionRequest(result.permission), - lastMessage: result.lastMessage, - }); - - const response = { - content: [], - structuredContent: validJson, - }; - return response; - } finally { - cleanup(); - } - }, - ); - registerTool( "send_agent_prompt", { @@ -1508,9 +1429,6 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase background = Boolean(callerAgentId), notifyOnFinish = Boolean(callerAgentId), }) => { - if (agentManager.hasInFlightRun(agentId)) { - waitTracker.cancel(agentId, "Agent run interrupted by new prompt"); - } const shouldNotifyOnFinish = Boolean(callerAgentId && notifyOnFinish && background); await sendPromptToAgent({ @@ -1565,7 +1483,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase ...(shouldNotifyOnFinish ? { guidance: - "You will get notified when the prompted agent finishes, errors, or needs permission. Do not call wait_for_agent or poll for status; continue with other work until the notification arrives.", + "You will get notified when the prompted agent finishes, errors, or needs permission. Do not poll for status; continue with other work until the notification arrives.", } : {}), }; @@ -1705,9 +1623,6 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase { agentManager, logger: childLogger }, agentId, ); - if (cancelled) { - waitTracker.cancel(agentId, "Agent run cancelled"); - } return { content: [], structuredContent: ensureValidJson({ success: cancelled }), @@ -1737,7 +1652,6 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase }, agentId, ); - waitTracker.cancel(agentId, "Agent archived"); return { content: [], structuredContent: ensureValidJson({ success: true }), @@ -1759,7 +1673,6 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase }, async ({ agentId }) => { await closeAgentCommand({ agentManager }, agentId); - waitTracker.cancel(agentId, "Agent terminated"); return { content: [], structuredContent: ensureValidJson({ success: true }), diff --git a/packages/server/src/server/agent/wait-for-agent-tracker.test.ts b/packages/server/src/server/agent/wait-for-agent-tracker.test.ts deleted file mode 100644 index 53c2f401f..000000000 --- a/packages/server/src/server/agent/wait-for-agent-tracker.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { createTestLogger } from "../../test-utils/test-logger.js"; -import { WaitForAgentTracker } from "./wait-for-agent-tracker.js"; - -describe("WaitForAgentTracker", () => { - const logger = createTestLogger(); - - it("registers and cancels waiters per agent", () => { - const tracker = new WaitForAgentTracker(logger); - const cancelA = vi.fn(); - const cancelB = vi.fn(); - - tracker.register("agent-a", cancelA); - tracker.register("agent-a", cancelB); - - expect(tracker.cancel("agent-a", "test")).toBe(true); - expect(cancelA).toHaveBeenCalledWith("test"); - expect(cancelB).toHaveBeenCalledWith("test"); - - // Subsequent cancels should no-op once cleared - expect(tracker.cancel("agent-a")).toBe(false); - - // Unregister removes callback without triggering it - const cancelC = vi.fn(); - const unregisterC = tracker.register("agent-b", cancelC); - unregisterC(); - expect(tracker.cancel("agent-b")).toBe(false); - expect(cancelC).not.toHaveBeenCalled(); - }); - - it("supports cancelling all waiters", () => { - const tracker = new WaitForAgentTracker(logger); - const cancelA = vi.fn(); - const cancelB = vi.fn(); - - tracker.register("agent-a", cancelA); - tracker.register("agent-b", cancelB); - - expect(tracker.cancelAll()).toBe(2); - expect(cancelA).toHaveBeenCalled(); - expect(cancelB).toHaveBeenCalled(); - }); -}); diff --git a/packages/server/src/server/agent/wait-for-agent-tracker.ts b/packages/server/src/server/agent/wait-for-agent-tracker.ts deleted file mode 100644 index f9d355a8f..000000000 --- a/packages/server/src/server/agent/wait-for-agent-tracker.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { Logger } from "pino"; - -export type WaitForAgentCanceler = (agentId: string, reason?: string) => boolean; - -/** - * Tracks long-running wait_for_agent tool calls so they can be cancelled - * explicitly (e.g., when a voice barge-in aborts the current turn). - */ -export class WaitForAgentTracker { - private waiters = new Map void>>(); - private logger: Logger; - - constructor(logger: Logger) { - this.logger = logger.child({ module: "agent", component: "wait-for-agent-tracker" }); - } - - register(agentId: string, cancel: (reason?: string) => void): () => void { - if (!this.waiters.has(agentId)) { - this.waiters.set(agentId, new Set()); - } - const waitersForAgent = this.waiters.get(agentId)!; - waitersForAgent.add(cancel); - - return () => { - const current = this.waiters.get(agentId); - if (!current) { - return; - } - current.delete(cancel); - if (current.size === 0) { - this.waiters.delete(agentId); - } - }; - } - - cancel(agentId: string, reason?: string): boolean { - const waitersForAgent = this.waiters.get(agentId); - if (!waitersForAgent || waitersForAgent.size === 0) { - return false; - } - - this.waiters.delete(agentId); - for (const cancel of waitersForAgent) { - try { - cancel(reason); - } catch (error) { - this.logger.warn({ err: error, agentId }, "Cancel callback failed"); - } - } - return true; - } - - cancelAll(reason?: string): number { - let cancelled = 0; - for (const agentId of Array.from(this.waiters.keys())) { - if (this.cancel(agentId, reason)) { - cancelled += 1; - } - } - return cancelled; - } -} diff --git a/packages/server/src/server/browser-tools/tools.ts b/packages/server/src/server/browser-tools/tools.ts index cb9bf92cb..cf5ce8fe8 100644 --- a/packages/server/src/server/browser-tools/tools.ts +++ b/packages/server/src/server/browser-tools/tools.ts @@ -35,38 +35,6 @@ const WORKSPACE_CONTEXT_MESSAGE = const URL_WHITESPACE_PATTERN = /\s/; const NON_HTTP_EXPLICIT_SCHEME_PATTERN = /^(?!https?:\/\/)[a-z][a-z0-9+.-]*:\/\//i; -const BrowserToolOutputSchema = { - ok: z.boolean(), - result: z.unknown().optional(), - error: z - .object({ - code: z.string(), - message: z.string(), - retryable: z.boolean(), - }) - .optional(), - dialogs: z - .array( - z.object({ - type: z.enum(["alert", "confirm", "prompt", "beforeunload"]), - message: z.string(), - defaultValue: z.string().optional(), - action: z.enum(["accepted", "dismissed"]), - promptText: z.string().optional(), - timestamp: z.number(), - }), - ) - .optional(), - context: z - .object({ - agentId: z.string().optional(), - cwd: z.string().optional(), - workspaceId: z.string().optional(), - browserId: z.string().optional(), - }) - .optional(), -}; - const BrowserHttpUrlInputSchema = z .string() .trim() @@ -103,7 +71,6 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void description: "List open Paseo browser tabs for this agent's workspace across connected browser automation hosts. Use returned browserId values with tab-scoped tools.", inputSchema: {}, - outputSchema: BrowserToolOutputSchema, }, async () => { const context = resolveBrowserToolContext(options); @@ -133,7 +100,6 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void inputSchema: { url: BrowserHttpUrlInputSchema.optional(), }, - outputSchema: BrowserToolOutputSchema, }, async ({ url }) => { const context = resolveBrowserToolContext(options); @@ -163,7 +129,6 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void inputSchema: { browserId: BrowserAutomationBrowserIdSchema, }, - outputSchema: BrowserToolOutputSchema, }, async ({ browserId }) => { const context = resolveBrowserToolContext(options); @@ -196,7 +161,6 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void doubleClick: z.boolean().optional(), modifiers: z.array(BrowserClickModifierInputSchema).optional(), }, - outputSchema: BrowserToolOutputSchema, }, async ({ ref, browserId, button, doubleClick, modifiers }) => { const context = resolveBrowserToolContext(options); @@ -231,7 +195,6 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void value: z.string(), browserId: BrowserAutomationBrowserIdSchema, }, - outputSchema: BrowserToolOutputSchema, }, async ({ ref, value, browserId }) => { const context = resolveBrowserToolContext(options); @@ -260,7 +223,6 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void description: "Wait until a Paseo browser tab contains text or reaches a URL fragment. Use browserId from browser_new_tab or browser_list_tabs; waits up to 5s by default on the browser host.", inputSchema: BrowserWaitInputSchema, - outputSchema: BrowserToolOutputSchema, }, async ({ text, url, timeoutMs, browserId }) => { const context = resolveBrowserToolContext(options); @@ -295,7 +257,6 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void ref: BrowserRefInputSchema.optional(), browserId: BrowserAutomationBrowserIdSchema, }, - outputSchema: BrowserToolOutputSchema, }, async ({ text, ref, browserId }) => { const context = resolveBrowserToolContext(options); @@ -328,7 +289,6 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void ref: BrowserRefInputSchema.optional(), browserId: BrowserAutomationBrowserIdSchema, }, - outputSchema: BrowserToolOutputSchema, }, async ({ key, ref, browserId }) => { const context = resolveBrowserToolContext(options); @@ -357,7 +317,6 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void description: "Navigate a Paseo browser tab to a URL. Use browserId from browser_new_tab or browser_list_tabs; pass an http(s) URL or a scheme-less host URL, which is treated as http.", inputSchema: { url: BrowserHttpUrlInputSchema, browserId: BrowserAutomationBrowserIdSchema }, - outputSchema: BrowserToolOutputSchema, }, async ({ url, browserId }) => { const context = resolveBrowserToolContext(options); @@ -407,7 +366,6 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void title: toolConfig.title, description: toolConfig.description, inputSchema: { browserId: BrowserAutomationBrowserIdSchema }, - outputSchema: BrowserToolOutputSchema, }, async ({ browserId }) => { const context = resolveBrowserToolContext(options); @@ -438,7 +396,6 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void browserId: BrowserAutomationBrowserIdSchema, fullPage: z.boolean().default(false), }, - outputSchema: BrowserToolOutputSchema, }, async ({ browserId, fullPage }) => { const context = resolveBrowserToolContext(options); @@ -470,7 +427,6 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void filePaths: z.array(z.string().min(1)).min(1), browserId: BrowserAutomationBrowserIdSchema, }, - outputSchema: BrowserToolOutputSchema, }, async ({ ref, filePaths, browserId }) => { const context = resolveBrowserToolContext(options); @@ -507,7 +463,6 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void title: toolConfig.title, description: toolConfig.description, inputSchema: { ref: BrowserRefInputSchema, browserId: BrowserAutomationBrowserIdSchema }, - outputSchema: BrowserToolOutputSchema, }, async ({ ref, browserId }) => { const context = resolveBrowserToolContext(options); @@ -540,7 +495,6 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void value: z.string(), browserId: BrowserAutomationBrowserIdSchema, }, - outputSchema: BrowserToolOutputSchema, }, async ({ ref, value, browserId }) => { const context = resolveBrowserToolContext(options); @@ -573,7 +527,6 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void targetRef: BrowserRefInputSchema, browserId: BrowserAutomationBrowserIdSchema, }, - outputSchema: BrowserToolOutputSchema, }, async ({ sourceRef, targetRef, browserId }) => { const context = resolveBrowserToolContext(options); @@ -605,7 +558,6 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void maxEntries: z.number().int().positive().max(200).optional(), browserId: BrowserAutomationBrowserIdSchema, }, - outputSchema: BrowserToolOutputSchema, }, async ({ maxEntries, browserId }) => { const context = resolveBrowserToolContext(options); @@ -637,7 +589,6 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void ref: BrowserRefInputSchema.optional(), browserId: BrowserAutomationBrowserIdSchema, }, - outputSchema: BrowserToolOutputSchema, }, async ({ function: functionSource, ref, browserId }) => { const context = resolveBrowserToolContext(options); @@ -671,7 +622,6 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void deltaX: z.number(), deltaY: z.number(), }, - outputSchema: BrowserToolOutputSchema, }, async ({ browserId, ref, deltaX, deltaY }) => { const context = resolveBrowserToolContext(options); @@ -705,7 +655,6 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void width: z.number().int().positive(), height: z.number().int().positive(), }, - outputSchema: BrowserToolOutputSchema, }, async ({ browserId, width, height }) => { const context = resolveBrowserToolContext(options); @@ -736,7 +685,6 @@ export function registerBrowserTools(options: RegisterBrowserToolsOptions): void inputSchema: { browserId: BrowserAutomationBrowserIdSchema, }, - outputSchema: BrowserToolOutputSchema, }, async ({ browserId }) => { const context = resolveBrowserToolContext(options); diff --git a/public-docs/mcp.md b/public-docs/mcp.md index 4cd5e8ca4..df8a5987a 100644 --- a/public-docs/mcp.md +++ b/public-docs/mcp.md @@ -19,7 +19,6 @@ The MCP server itself is controlled by `daemon.mcp.enabled`. Existing agents may | Tool | Function | | -------------------- | ---------------------------------------------------------------------------------------------------- | | `create_agent` | Create an agent tied to a working directory, optionally with initial settings or a new git worktree. | -| `wait_for_agent` | Block until an agent requests permission or finishes its current run. | | `send_agent_prompt` | Send a task to a running agent. | | `get_agent_status` | Return the latest snapshot for an agent. | | `list_agents` | List recent agents as compact metadata. | diff --git a/skills/paseo/SKILL.md b/skills/paseo/SKILL.md index dbf7f1c24..198b0a0fb 100644 --- a/skills/paseo/SKILL.md +++ b/skills/paseo/SKILL.md @@ -106,7 +106,7 @@ If the file is missing, use sensible defaults and tell the user once. Agents take time — 10–30+ minutes is routine. Favor asynchronous workflows. -For agent-scoped `create_agent` and background `send_agent_prompt`, leave `notifyOnFinish` omitted or set it to `true` unless the work is truly fire-and-forget. You will get notified when the target agent finishes, errors, or needs permission. **You must not call `wait_for_agent` on a notify-on-finish agent.** Move on to other work. The notification arrives on its own. +For agent-scoped `create_agent` and background `send_agent_prompt`, leave `notifyOnFinish` omitted or set it to `true` unless the work is truly fire-and-forget. You will get notified when the target agent finishes, errors, or needs permission. Move on to other work. The notification arrives on its own. Don't poll `list_agents` or `get_agent_status` to "check on" a running agent. The notification will tell you.