diff --git a/packages/server/src/server/agent/agent-loading.ts b/packages/server/src/server/agent/agent-loading.ts index 248e8d2b1b..761f1008bd 100644 --- a/packages/server/src/server/agent/agent-loading.ts +++ b/packages/server/src/server/agent/agent-loading.ts @@ -12,6 +12,13 @@ import { toAgentPersistenceHandle, } from "../persistence-hooks.js"; +export class AgentNotFoundError extends Error { + constructor(agentId: string) { + super(`Agent not found: ${agentId}`); + this.name = "AgentNotFoundError"; + } +} + interface PendingAgentInitialization { promise: Promise; options: { broadcastTimeline: boolean }; @@ -94,7 +101,7 @@ export async function ensureAgentLoaded( const initPromise = (async () => { const record = await deps.agentStorage.get(agentId); if (!record) { - throw new Error(`Agent not found: ${agentId}`); + throw new AgentNotFoundError(agentId); } const validProviders = deps.validProviders ?? deps.agentManager.getRegisteredProviderIds(); diff --git a/packages/server/src/server/agent/providers/opencode/bridge-input-errors.test.ts b/packages/server/src/server/agent/providers/opencode/bridge-input-errors.test.ts new file mode 100644 index 0000000000..f9a776d168 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/bridge-input-errors.test.ts @@ -0,0 +1,202 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { expect, test } from "vitest"; +import { createTestLogger } from "../../../../test-utils/test-logger.js"; +import { createPaseoToolCatalog } from "../../tools/paseo-tools.js"; +import { AgentManager } from "../../agent-manager.js"; +import { AgentStorage } from "../../agent-storage.js"; +import { ProviderSnapshotManager } from "../../provider-snapshot-manager.js"; +import { OpenCodeBridge } from "./bridge.js"; +import { ScheduleService } from "../../../schedule/service.js"; +import { createWorkspaceScriptsService } from "../../../session/workspace-scripts/workspace-scripts-service.js"; +import { + FileBackedProjectRegistry, + FileBackedWorkspaceRegistry, +} from "../../../workspace-registry.js"; +import { createServiceProxySubsystem } from "../../../service-proxy.js"; +import { WorkspaceScriptRuntimeStore } from "../../../workspace-script-runtime-store.js"; +import { createTerminalManager } from "../../../../terminal/terminal-manager.js"; + +test("bridge preserves tool failure messages and classifies invalid requests as client errors", async () => { + const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-7045-")); + const logger = createTestLogger(); + const providerSnapshotManager = new ProviderSnapshotManager({ logger }); + const agentManager = new AgentManager({ logger }); + const agentStorage = new AgentStorage(paseoHome, logger); + const unused = async (): Promise => { + throw new Error("Unexpected workspace or agent creation"); + }; + const scheduleService = new ScheduleService({ + paseoHome, + logger, + agentManager, + agentStorage, + createAgent: unused, + createDirectoryWorkspace: unused, + createPaseoWorktreeWorkspace: unused, + archiveWorkspace: unused, + runner: async () => ({ agentId: null, output: "done" }), + }); + const workspaceScripts = createWorkspaceScriptsService({ + serviceProxy: createServiceProxySubsystem({ logger }), + scriptRuntimeStore: new WorkspaceScriptRuntimeStore(), + terminalManager: createTerminalManager(), + workspaceRegistry: new FileBackedWorkspaceRegistry( + path.join(paseoHome, "workspaces.json"), + logger, + ), + projectRegistry: new FileBackedProjectRegistry(path.join(paseoHome, "projects.json"), logger), + workspaceGitService: { + peekSnapshot: () => { + throw new Error("Unexpected git lookup"); + }, + }, + getDaemonTcpPort: null, + getDaemonTcpHost: null, + serviceProxyPublicBaseUrl: null, + resolveScriptHealth: null, + logger, + emit: () => {}, + publishStatusUpdate: () => {}, + spawnWorkspaceScript: unused, + assertAutomationAllowed: unused, + }); + const catalog = createPaseoToolCatalog({ + workspaceScripts, + agentManager, + agentStorage, + scheduleService, + providerSnapshotManager, + logger, + }); + const bridge = new OpenCodeBridge({ paseoHome, logger }); + try { + await bridge.start(); + bridge.setManifestCatalog(catalog); + bridge.bindSession({ sessionId: "diagnosis", env: {}, tools: catalog }); + const config = JSON.parse(bridge.decorateServerEnv({}).OPENCODE_CONFIG_CONTENT); + const { baseUrl, token } = config.plugin[0][1]; + const completed = await scheduleService.create({ + prompt: "Run once", + name: "Daily check", + cadence: { type: "cron", expression: "0 0 * * *" }, + target: { type: "new-agent", config: { provider: "codex", cwd: paseoHome } }, + maxRuns: 1, + runOnCreate: true, + }); + await scheduleService.tick(); + expect((await scheduleService.inspect(completed.id)).status).toBe("completed"); + const manifest = await fetch(`${baseUrl}/_internal/opencode/tools`, { + headers: { Authorization: `Bearer ${token}` }, + }); + const { tools } = await manifest.json(); + expect( + tools.find((tool: { name: string }) => tool.name === "create_agent").inputSchema.properties, + ).not.toHaveProperty("background"); + expect( + tools.find((tool: { name: string }) => tool.name === "update_schedule").inputSchema + .properties, + ).not.toHaveProperty("every"); + await writeFile( + path.join(paseoHome, "schedules", "broken.json"), + JSON.stringify({ id: "broken", prompt: 17 }), + ); + const observed = []; + for (const [tool, body, expected] of [ + [ + "create_agent", + JSON.stringify({ title: "Probe", provider: "opencode", initialPrompt: "Reply OK" }), + 400, + ], + ["create_agent", "{", 400], + ["nonexistent_tool", "{}", 404], + ["inspect_schedule", JSON.stringify({ id: "missing" }), 400], + ...["pause_schedule", "resume_schedule", "run_schedule_once"].map( + (action) => [action, JSON.stringify({ id: completed.id }), 400] as const, + ), + [ + "create_schedule", + JSON.stringify({ prompt: "Probe", provider: "codex", cron: "not-a-cron" }), + 400, + ], + [ + "create_schedule", + JSON.stringify({ + prompt: "Probe", + provider: "codex", + cron: "0 0 * * *", + timezone: "Mars/Phobos", + }), + 400, + ], + ["update_schedule", JSON.stringify({ id: completed.id, name: "" }), 200], + ["update_schedule", JSON.stringify({ id: completed.id, cron: "0 0 30 2 *" }), 400], + ["update_schedule", JSON.stringify({ id: completed.id, expiresIn: "not-a-duration" }), 400], + ["send_agent_prompt", JSON.stringify({ agentId: "missing", prompt: "Hello" }), 400], + ["inspect_schedule", JSON.stringify({ id: "broken" }), 500], + ["list_workspace_scripts", JSON.stringify({ workspaceId: "wks_typo" }), 500], + [ + "start_workspace_script", + JSON.stringify({ workspaceId: "wks_typo", scriptName: "web" }), + 500, + ], + ] as const) { + const response = await fetch( + `${baseUrl}/_internal/opencode/sessions/diagnosis/tools/${tool}`, + { + method: "POST", + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, + body, + }, + ); + observed.push({ + tool, + body, + expected, + status: response.status, + payload: await response.json(), + }); + } + + for (const result of observed) expect.soft(result.status, result.body).toBe(result.expected); + expect((await scheduleService.inspect(completed.id)).name).toBeNull(); + expect( + tools.find((tool: { name: string }) => tool.name === "update_schedule").inputSchema.properties + .name, + ).not.toHaveProperty("minLength"); + for (const result of observed.slice(14)) { + expect.soft(result.payload).toEqual({ error: "Workspace not found: wks_typo" }); + } + expect.soft(observed[12].payload).toEqual({ error: "Agent not found: missing" }); + expect.soft(observed[13].payload).toEqual({ error: expect.stringContaining('"prompt"') }); + expect.soft(observed[13].payload).not.toHaveProperty("issues"); + expect(observed[3].payload).toEqual({ error: "Schedule not found: missing" }); + for (const result of observed.slice(4, 7)) + expect(result.payload).toEqual({ error: `Schedule ${completed.id} is already completed` }); + for (const [index, field] of [ + [7, "cron"], + [8, "timezone"], + [10, "cron"], + [11, "expiresIn"], + ] as const) { + expect(observed[index].payload).toMatchObject({ + error: "Invalid tool input", + issues: expect.arrayContaining([expect.objectContaining({ path: [field] })]), + }); + } + expect(observed[0].payload).toMatchObject({ + error: "Invalid tool input", + issues: expect.arrayContaining([ + expect.objectContaining({ + path: ["provider"], + message: expect.stringContaining("provider/model"), + }), + ]), + }); + } finally { + await bridge.close(); + await providerSnapshotManager.shutdown(); + await rm(paseoHome, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/agent/providers/opencode/bridge-plugin.mjs b/packages/server/src/server/agent/providers/opencode/bridge-plugin.mjs index 82e4166492..6b3a38da9c 100644 --- a/packages/server/src/server/agent/providers/opencode/bridge-plugin.mjs +++ b/packages/server/src/server/agent/providers/opencode/bridge-plugin.mjs @@ -13,8 +13,11 @@ export default async function paseoPlugin(input, options) { }); const payload = await response.json().catch(() => ({})); if (!response.ok) { - const error = new Error(payload.error ?? `Paseo OpenCode bridge failed: ${response.status}`); + const detail = formatErrorDetail(payload.issues ?? payload.details); + const message = payload.error ?? `Paseo OpenCode bridge failed: ${response.status}`; + const error = new Error(detail ? `${message}: ${detail}` : message); error.status = response.status; + if (detail) error.details = detail; throw error; } return payload; @@ -79,6 +82,17 @@ export default async function paseoPlugin(input, options) { }; } +function formatErrorDetail(details) { + if (!Array.isArray(details)) return typeof details === "string" ? details : ""; + return details + .map((issue) => { + const path = + Array.isArray(issue?.path) && issue.path.length > 0 ? issue.path.join(".") : "input"; + return `${path}: ${issue?.message ?? "Invalid value"}`; + }) + .join("; "); +} + function logPluginError(stage, context, error) { console.error(`[paseo-opencode-plugin] ${stage} failed`, { ...context, diff --git a/packages/server/src/server/agent/providers/opencode/bridge.test.ts b/packages/server/src/server/agent/providers/opencode/bridge.test.ts index 076cbe94bd..4b42410845 100644 --- a/packages/server/src/server/agent/providers/opencode/bridge.test.ts +++ b/packages/server/src/server/agent/providers/opencode/bridge.test.ts @@ -9,6 +9,7 @@ import { afterEach, describe, expect, test, vi } from "vitest"; import { createTestLogger } from "../../../../test-utils/test-logger.js"; import type { PaseoToolCatalog } from "../../tools/types.js"; +import { PaseoToolInputError } from "../../tools/types.js"; import { createPaseoToolCatalog } from "../../tools/paseo-tools.js"; import { AgentManager } from "../../agent-manager.js"; import { AgentStorage } from "../../agent-storage.js"; @@ -36,8 +37,12 @@ function createCatalog(): PaseoToolCatalog { description: "Returns the supplied value.", inputSchema: { value: z.string() }, async handler(input: unknown) { - const parsed = z.object({ value: z.string() }).parse(input); - return { content: [{ type: "text", text: parsed.value }] }; + const parsed = z + .object({ value: z.string().min(2, "value must be at least 2 characters") }) + .strict() + .safeParse(input); + if (!parsed.success) throw new PaseoToolInputError(parsed.error.message, parsed.error.issues); + return { content: [{ type: "text", text: parsed.data.value }] }; }, }; const tools = new Map([[tool.name, tool]]); @@ -193,6 +198,13 @@ describe("OpenCodeBridge", () => { expect(asked).toEqual([ { permission: "paseo_echo_context", patterns: ["*"], always: ["*"], metadata: {} }, ]); + asked.length = 0; + await expect( + hooks.tool.paseo_echo_context.execute( + { value: "x" }, + { sessionID: "ses_one", ask: async () => undefined }, + ), + ).rejects.toThrow("value: value must be at least 2 characters"); executedInputs.length = 0; await expect( @@ -357,12 +369,17 @@ describe("OpenCodeBridge", () => { body: JSON.stringify(input), }, ); - expect(response.status).toBe(500); + expect(response.status).toBe(400); const payload = await response.json(); - expect(payload.error).toContain("provider must be provider/model"); + expect(payload).toMatchObject({ error: "Invalid tool input", issues: expect.any(Array) }); await expect( tools.get("paseo_create_agent")!.execute(input, { sessionID: "caller" }), - ).rejects.toMatchObject({ message: payload.error, status: 500 }); + ).rejects.toMatchObject({ + status: 400, + message: expect.stringContaining( + "Invalid tool input: provider: provider must be provider/model, for example codex/gpt-5.4", + ), + }); } finally { await dispose(); } @@ -550,6 +567,104 @@ describe("OpenCodeBridge", () => { } }); + test("returns actionable 4xx responses for invalid bridge requests", async () => { + const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-opencode-bridge-errors-")); + temporaryDirectories.push(paseoHome); + const bridge = new OpenCodeBridge({ paseoHome, logger: createTestLogger() }); + await bridge.start(); + const catalog = createCatalog(); + bridge.bindSession({ sessionId: "ses_errors", env: {}, tools: catalog }); + const expectedErrorCatalog: PaseoToolCatalog = { + tools: new Map([ + [ + "expected_error", + { + name: "expected_error", + description: "Throws an expected tool error.", + inputSchema: {}, + async handler() { + throw new PaseoToolInputError("Agent not found: missing-agent"); + }, + }, + ], + [ + "internal_error", + { + name: "internal_error", + description: "Throws an internal error.", + inputSchema: {}, + async handler() { + throw new Error("Provider unavailable"); + }, + }, + ], + ]), + getTool(name) { + return this.tools.get(name); + }, + async executeTool(name, input, context) { + const tool = this.tools.get(name); + if (!tool) throw new Error(`Unknown tool: ${name}`); + return tool.handler(input, context ?? {}); + }, + }; + bridge.bindSession({ sessionId: "ses_expected_error", env: {}, tools: expectedErrorCatalog }); + + try { + const plugin = readPluginOptions(bridge.decorateServerEnv({})); + const headers = { + Authorization: `Bearer ${plugin.token}`, + "Content-Type": "application/json", + }; + const invalidInput = await fetch( + `${plugin.baseUrl}/_internal/opencode/sessions/ses_errors/tools/echo_context`, + { method: "POST", headers, body: JSON.stringify({ value: "ok", extra: true }) }, + ); + expect(invalidInput.status).toBe(400); + expect(await invalidInput.json()).toEqual({ + error: "Invalid tool input", + issues: [ + { + code: "unrecognized_keys", + keys: ["extra"], + path: [], + message: 'Unrecognized key: "extra"', + }, + ], + }); + + const unknownTool = await fetch( + `${plugin.baseUrl}/_internal/opencode/sessions/ses_errors/tools/missing`, + { method: "POST", headers, body: "{}" }, + ); + expect(unknownTool.status).toBe(404); + expect(await unknownTool.json()).toEqual({ error: "Paseo tool not found: missing" }); + + const malformedBody = await fetch( + `${plugin.baseUrl}/_internal/opencode/sessions/ses_errors/tools/echo_context`, + { method: "POST", headers, body: "{" }, + ); + expect(malformedBody.status).toBe(400); + expect(await malformedBody.json()).toEqual({ error: "Malformed JSON request body" }); + + const expectedError = await fetch( + `${plugin.baseUrl}/_internal/opencode/sessions/ses_expected_error/tools/expected_error`, + { method: "POST", headers, body: "{}" }, + ); + expect(expectedError.status).toBe(400); + expect(await expectedError.json()).toEqual({ error: "Agent not found: missing-agent" }); + + const internalError = await fetch( + `${plugin.baseUrl}/_internal/opencode/sessions/ses_expected_error/tools/internal_error`, + { method: "POST", headers, body: "{}" }, + ); + expect(internalError.status).toBe(500); + expect(await internalError.json()).toEqual({ error: "Provider unavailable" }); + } finally { + await bridge.close(); + } + }); + test("preserves user OpenCode config while installing one content-addressed plugin", async () => { const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-opencode-bridge-config-")); temporaryDirectories.push(paseoHome); diff --git a/packages/server/src/server/agent/providers/opencode/bridge.ts b/packages/server/src/server/agent/providers/opencode/bridge.ts index 52a2cd3612..4e9902bd7a 100644 --- a/packages/server/src/server/agent/providers/opencode/bridge.ts +++ b/packages/server/src/server/agent/providers/opencode/bridge.ts @@ -11,11 +11,22 @@ import { addModelVisibleStructuredContent, serializePaseoToolInputParameters, } from "../../tools/paseo-tool-serialization.js"; -import type { PaseoToolCatalog } from "../../tools/types.js"; +import { PaseoToolInputError, type PaseoToolCatalog } from "../../tools/types.js"; const INTERNAL_PREFIX = "/_internal/opencode"; const MAX_REQUEST_BYTES = 1024 * 1024; +class OpenCodeBridgeClientError extends Error { + constructor( + readonly status: number, + message: string, + readonly details?: unknown, + ) { + super(message); + this.name = "OpenCodeBridgeClientError"; + } +} + interface OpenCodeBridgeOptions { paseoHome: string; logger: Logger; @@ -187,10 +198,26 @@ export class OpenCodeBridge { sendJson(response, 404, { error: "Not found" }); } catch (error) { this.logger.warn({ err: error }, "OpenCode bridge request failed"); + const failure = error instanceof Error ? error : new Error(String(error)); if (!response.headersSent) { - sendJson(response, 500, { error: error instanceof Error ? error.message : String(error) }); + if (error instanceof OpenCodeBridgeClientError) { + sendJson(response, error.status, { + error: error.message, + ...(error.details === undefined ? {} : { details: error.details }), + }); + } else if (error instanceof PaseoToolInputError) { + sendJson( + response, + error.status, + error.issues + ? { error: "Invalid tool input", issues: error.issues } + : { error: error.message }, + ); + } else { + sendJson(response, 500, { error: failure.message }); + } } else { - response.destroy(error instanceof Error ? error : new Error(String(error))); + response.destroy(failure); } } } @@ -224,6 +251,9 @@ export class OpenCodeBridge { sendJson(input.response, 403, { error: "Paseo tools are disabled for this session" }); return; } + if (!binding.tools.getTool(input.toolName)) { + throw new OpenCodeBridgeClientError(404, `Paseo tool not found: ${input.toolName}`); + } const body = await readJsonBody(input.request); const controller = new AbortController(); input.request.once("aborted", () => @@ -349,11 +379,17 @@ async function readJsonBody(request: IncomingMessage): Promise { for await (const chunk of request) { const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); size += buffer.byteLength; - if (size > MAX_REQUEST_BYTES) throw new Error("OpenCode bridge request body is too large"); + if (size > MAX_REQUEST_BYTES) { + throw new OpenCodeBridgeClientError(413, "OpenCode bridge request body is too large"); + } chunks.push(buffer); } if (chunks.length === 0) return {}; - return JSON.parse(Buffer.concat(chunks).toString("utf8")) as unknown; + try { + return JSON.parse(Buffer.concat(chunks).toString("utf8")) as unknown; + } catch { + throw new OpenCodeBridgeClientError(400, "Malformed JSON request body"); + } } function sendJson(response: ServerResponse, status: number, body: unknown): void { diff --git a/packages/server/src/server/agent/providers/opencode/v2/bridge-plugin.mjs b/packages/server/src/server/agent/providers/opencode/v2/bridge-plugin.mjs index 6b845d19ed..1664c0b59f 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/bridge-plugin.mjs +++ b/packages/server/src/server/agent/providers/opencode/v2/bridge-plugin.mjs @@ -15,12 +15,14 @@ export default { if (allowMissing && response.status === 404) return null; if (!response.ok) { const payload = await response.json().catch(() => null); + const detail = formatErrorDetail(payload?.issues ?? payload?.details); const message = typeof payload?.error === "string" && payload.error ? payload.error : `Paseo tool bridge returned HTTP ${response.status}`; - const error = new Error(message); + const error = new Error(detail ? `${message}: ${detail}` : message); error.status = response.status; + if (detail) error.details = detail; throw error; } return response.json(); @@ -78,6 +80,17 @@ export default { }, }; +function formatErrorDetail(details) { + if (!Array.isArray(details)) return typeof details === "string" ? details : ""; + return details + .map((issue) => { + const path = + Array.isArray(issue?.path) && issue.path.length > 0 ? issue.path.join(".") : "input"; + return `${path}: ${issue?.message ?? "Invalid value"}`; + }) + .join("; "); +} + // OpenCode tool content is text or a file; MCP images arrive as base64 data. function toOpenCodeContent(part) { if (part.type === "image") { diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index dae9c16f4f..68e9d99a40 100644 --- a/packages/server/src/server/agent/tools/paseo-tools.ts +++ b/packages/server/src/server/agent/tools/paseo-tools.ts @@ -25,7 +25,7 @@ import { import { curateAgentActivity } from "../activity-curator.js"; import { selectItemsByProjectedLimit } from "../timeline-projection.js"; import type { AgentStorage } from "../agent-storage.js"; -import { ensureAgentLoaded } from "../agent-loading.js"; +import { AgentNotFoundError, ensureAgentLoaded } from "../agent-loading.js"; import { isStoredAgentProviderAvailable } from "../../persistence-hooks.js"; import { archiveByScope, @@ -40,7 +40,12 @@ import { everyMsToFiveFieldCron } from "@getpaseo/protocol/schedule/cadence"; import { expandUserPath, isSameOrDescendantPath, resolvePathFromBase } from "../../path-utils.js"; import type { TerminalManager } from "../../../terminal/terminal-manager.js"; import type { CreatePaseoWorktreeWorkflowFn } from "../../worktree-session.js"; -import type { ScheduleService } from "../../schedule/service.js"; +import { computeNextRunAt, validateScheduleCadence } from "../../schedule/cron.js"; +import { + ScheduleNotFoundError, + ScheduleStateConflictError, + type ScheduleService, +} from "../../schedule/service.js"; import { ScheduleRunSchema, ScheduleSummarySchema, @@ -91,12 +96,13 @@ import { } from "../../worktree/commands.js"; import { registerBrowserTools } from "../../browser-tools/tools.js"; import type { BrowserToolsBroker } from "../../browser-tools/broker.js"; -import type { - PaseoToolCatalog, - PaseoToolConfig, - PaseoToolDefinition, - PaseoToolExecutionContext, - PaseoToolResult, +import { + PaseoToolInputError, + type PaseoToolCatalog, + type PaseoToolConfig, + type PaseoToolDefinition, + type PaseoToolExecutionContext, + type PaseoToolResult, } from "./types.js"; import type { ProviderPaseoToolsPolicy } from "@getpaseo/protocol/provider-config"; import { isPaseoToolEnabled } from "../paseo-tool-policy.js"; @@ -229,7 +235,7 @@ function assertOptionsAbsent( message: string, ): void { if (options.some(([, value]) => value !== undefined)) { - throw new Error(message); + throw new PaseoToolInputError(message); } } @@ -265,7 +271,7 @@ function resolveWorkspaceWorktreeTarget(input: WorkspaceWorktreeOptions): Worksp }; case "checkout-branch": if (!input.branch) { - throw new Error("branch is required for checkout-branch mode"); + throw new PaseoToolInputError("branch is required for checkout-branch mode"); } assertOptionsAbsent( [ @@ -279,7 +285,7 @@ function resolveWorkspaceWorktreeTarget(input: WorkspaceWorktreeOptions): Worksp return { action: "checkout", refName: input.branch }; case "checkout-pr": if (input.prNumber === undefined) { - throw new Error("prNumber is required for checkout-pr mode"); + throw new PaseoToolInputError("prNumber is required for checkout-pr mode"); } assertOptionsAbsent( [ @@ -355,7 +361,7 @@ function resolveScheduleProviderAndModel(params: { const provider = providerInput.slice(0, slashIndex).trim(); const model = providerInput.slice(slashIndex + 1).trim(); if (!provider || !model) { - throw new Error("provider must be or /"); + throw new PaseoToolInputError("provider must be or /"); } return { @@ -372,7 +378,7 @@ function resolveScheduleUpdateProviderAndModel(params: { const modelInput = typeof params.model === "string" ? params.model.trim() : params.model; if (params.model !== undefined && modelInput === "") { - throw new Error("model cannot be empty"); + throw new PaseoToolInputError("model cannot be empty"); } if (!providerInput) { @@ -390,13 +396,13 @@ function resolveScheduleUpdateProviderAndModel(params: { const provider = providerInput.slice(0, slashIndex).trim(); const modelFromProvider = providerInput.slice(slashIndex + 1).trim(); if (!provider || !modelFromProvider) { - throw new Error("provider must be or /"); + throw new PaseoToolInputError("provider must be or /"); } if (params.model === null) { - throw new Error("provider specifies a model but model is null"); + throw new PaseoToolInputError("provider specifies a model but model is null"); } if (typeof modelInput === "string" && modelInput !== modelFromProvider) { - throw new Error("Conflicting model values provided"); + throw new PaseoToolInputError("Conflicting model values provided"); } return { @@ -438,26 +444,82 @@ function normalizeScheduleTimeZoneArg(value: string | undefined): string | undef return normalizeScheduleCadenceArg(value); } +function addScheduleCadenceIssue( + input: { cron?: string; timezone?: string }, + context: z.RefinementCtx, +): void { + const expression = input.cron?.trim(); + if (!expression) { + return; + } + const cadence: ScheduleCadence = { + type: "cron", + expression, + ...(input.timezone !== undefined ? { timezone: input.timezone.trim() } : {}), + }; + try { + validateScheduleCadence(cadence); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + context.addIssue({ + code: "custom", + path: [message.startsWith("Invalid cron time zone:") ? "timezone" : "cron"], + message, + }); + return; + } + try { + computeNextRunAt(cadence, new Date()); + } catch (error) { + context.addIssue({ + code: "custom", + path: ["cron"], + message: error instanceof Error ? error.message : String(error), + }); + } +} + +function parseToolArguments(schema: z.ZodType, input: unknown): T { + const result = schema.safeParse(input); + if (!result.success) { + throw new PaseoToolInputError(result.error.message, result.error.issues); + } + return result.data; +} + +function addScheduleDurationIssue(value: string, context: z.RefinementCtx): void { + try { + parseDurationString(value); + } catch (error) { + context.addIssue({ + code: "custom", + message: error instanceof Error ? error.message : String(error), + }); + } +} + +const scheduleDurationInputSchema = z.string().superRefine(addScheduleDurationIssue); + function resolveScheduleUpdateCadence(input: ScheduleUpdateToolInput): ScheduleCadence | undefined { const every = normalizeScheduleCadenceArg(input.every); const cron = normalizeScheduleCadenceArg(input.cron); const timeZone = normalizeScheduleTimeZoneArg(input.timezone); if (every !== undefined && cron !== undefined) { - throw new Error("Specify at most one of every or cron"); + throw new PaseoToolInputError("Specify at most one of every or cron"); } if (timeZone !== undefined && cron === undefined) { - throw new Error("timezone can only be used with cron"); + throw new PaseoToolInputError("timezone can only be used with cron"); } if (every !== undefined) { // COMPAT(scheduleEveryInput): accept the old hidden field and canonicalize it before write. // Added in v0.2.0; remove after 2027-01-17. - const everyMs = parseDurationString(every); + const everyMs = parseDurationString(parseToolArguments(scheduleDurationInputSchema, every)); const expression = everyMsToFiveFieldCron(everyMs); if (expression) { return { type: "cron", expression }; } - throw new Error(`${every} cannot be represented faithfully by five-field cron`); + throw new PaseoToolInputError(`${every} cannot be represented faithfully by five-field cron`); } if (cron !== undefined) { return { @@ -471,7 +533,7 @@ function resolveScheduleUpdateCadence(input: ScheduleUpdateToolInput): ScheduleC function resolveScheduleUpdateExpiresAt(input: ScheduleUpdateToolInput): string | null | undefined { if (input.expiresIn !== undefined && input.clearExpires) { - throw new Error("Specify at most one of expiresIn or clearExpires"); + throw new PaseoToolInputError("Specify at most one of expiresIn or clearExpires"); } if (input.expiresIn !== undefined) { return new Date(Date.now() + parseDurationString(input.expiresIn)).toISOString(); @@ -593,7 +655,11 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase typeof (inputSchema as { safeParseAsync?: unknown }).safeParseAsync === "function" ? (inputSchema as z.ZodType) : z.object(inputSchema as z.ZodRawShape).passthrough(); - return schema.parseAsync(input); + const result = await schema.safeParseAsync(input); + if (!result.success) { + throw new PaseoToolInputError(result.error.message, result.error.issues); + } + return result.data; }; const tools = new Map(); @@ -627,7 +693,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase ): Promise { const tool = tools.get(name); if (!tool) { - throw new Error(`Paseo tool not found: ${name}`); + throw new PaseoToolInputError(`Paseo tool not found: ${name}`); } return tool.handler(await parseToolInput(tool, input), context); }, @@ -639,7 +705,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase }): ScheduleCadence => { const expression = input.cron?.trim() ?? ""; if (!expression) { - throw new Error("cron is required"); + throw new PaseoToolInputError("cron is required"); } const timezone = normalizeScheduleTimeZoneArg(input.timezone); return { @@ -661,7 +727,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase } const parentAgent = agentManager.getAgent(callerAgentId); if (!parentAgent) { - throw new Error(`Parent agent ${callerAgentId} not found`); + throw new PaseoToolInputError(`Parent agent ${callerAgentId} not found`); } return parentAgent; }; @@ -690,9 +756,9 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase const trimmedCwd = requestedCwd?.trim(); if (!trimmedCwd) { if (opts?.required) { - throw new Error("cwd is required"); + throw new PaseoToolInputError("cwd is required"); } - throw new Error("cwd is required outside an agent-scoped session"); + throw new PaseoToolInputError("cwd is required outside an agent-scoped session"); } return expandUserPath(trimmedCwd); @@ -707,11 +773,12 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase } if (!options.ensureWorkspaceForCreate) { - throw new Error( - callerAgentId - ? `Caller agent ${callerAgentId} has no workspace and workspace minting is not configured` - : "workspaceId is required outside an agent-scoped session", - ); + if (callerAgentId) { + throw new Error( + `Caller agent ${callerAgentId} has no workspace and workspace minting is not configured`, + ); + } + throw new PaseoToolInputError("workspaceId is required outside an agent-scoped session"); } return options.ensureWorkspaceForCreate(resolvedCwd); @@ -726,11 +793,11 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase if (callerAgentId) { const callerAgent = resolveCallerAgent(); if (!callerAgent?.workspaceId) { - throw new Error(`Caller agent ${callerAgentId} has no current workspace`); + throw new PaseoToolInputError(`Caller agent ${callerAgentId} has no current workspace`); } return callerAgent.workspaceId; } - throw new Error("workspaceId is required outside an agent-scoped session"); + throw new PaseoToolInputError("workspaceId is required outside an agent-scoped session"); } const buildCallerAgentScheduleConfigExtras = ( @@ -800,7 +867,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase } if (!params?.provider?.trim()) { - throw new Error("provider is required when target is new-agent"); + throw new PaseoToolInputError("provider is required when target is new-agent"); } const resolvedProviderModel = resolveScheduleProviderAndModel({ @@ -822,22 +889,41 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase if (!scheduleService) { throw new Error("Schedule service is not configured"); } - const schedule = await scheduleService.inspect(id); + let schedule: Awaited>; + try { + schedule = await scheduleService.inspect(id); + } catch (error) { + if (error instanceof ScheduleNotFoundError) { + throw new PaseoToolInputError(error.message); + } + throw error; + } if (schedule.target.type !== type) { - throw new Error( + throw new PaseoToolInputError( type === "agent" ? `Heartbeat not found: ${id}` : `Schedule not found: ${id}`, ); } return schedule; } + async function runScheduleAction(action: () => Promise): Promise { + try { + return await action(); + } catch (error) { + if (error instanceof ScheduleStateConflictError) { + throw new PaseoToolInputError(error.message); + } + throw error; + } + } + async function requireCallerHeartbeat(id: string) { if (!callerAgentId) { - throw new Error("Heartbeat operations require an agent-scoped session"); + throw new PaseoToolInputError("Heartbeat operations require an agent-scoped session"); } const schedule = await requireScheduleTarget(id, "agent"); if (schedule.target.type !== "agent" || schedule.target.agentId !== callerAgentId) { - throw new Error(`Heartbeat ${id} does not belong to caller ${callerAgentId}`); + throw new PaseoToolInputError(`Heartbeat ${id} does not belong to caller ${callerAgentId}`); } return schedule; } @@ -847,6 +933,9 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase }) .refine( (value) => { + if (!value.includes("/")) { + return true; + } try { resolveRequiredProviderModel(value); return true; @@ -1184,11 +1273,13 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase }, async (args, context) => { if (!callerAgentId) { - throw new Error("speak is only available to agent-scoped tool sessions"); + throw new PaseoToolInputError("speak is only available to agent-scoped tool sessions"); } const handler = resolveSpeakHandler?.(callerAgentId) ?? null; if (!handler) { - throw new Error(`No speak handler registered for your session '${callerAgentId}'`); + throw new PaseoToolInputError( + `No speak handler registered for your session '${callerAgentId}'`, + ); } await handler({ text: args.text, @@ -1289,7 +1380,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase if (isolation === "local") { const cwd = resolveScopedCwd(path, { required: true }); if (!(await isExistingDirectory(cwd))) { - throw new Error(`Directory not found: ${cwd}`); + throw new PaseoToolInputError(`Directory not found: ${cwd}`); } assertOptionsAbsent( [ @@ -1534,7 +1625,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase if (hasLegacyCreateAgentPlacement(args)) { // COMPAT(nestedCreateAgentPlacement): accept the old relationship/workspace shape without // advertising it to models. Added in v0.2.0; remove after 2027-01-17. - const parsed = legacyAgentToAgentCreateAgentArgsSchema.parse(args); + const parsed = parseToolArguments(legacyAgentToAgentCreateAgentArgsSchema, args); const { cwd, workspaceId, worktree } = await resolveCreateAgentWorkspace(parsed.workspace, { prompt: parsed.initialPrompt, }); @@ -1547,7 +1638,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase worktree, }; } - const parsed = agentToAgentCreateAgentArgsSchema.parse(args); + const parsed = parseToolArguments(agentToAgentCreateAgentArgsSchema, args); const { cwd, workspaceId } = await resolveCanonicalCreateAgentWorkspace(parsed.workspaceId, { prompt: parsed.initialPrompt, }); @@ -1563,13 +1654,15 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase if (hasLegacyCreateAgentPlacement(args)) { // COMPAT(nestedCreateAgentPlacement): see the agent-scoped branch above. const parsedArgs = normalizeTopLevelCreateAgentArgs( - legacyTopLevelCreateAgentArgsSchema.parse(args), + parseToolArguments(legacyTopLevelCreateAgentArgsSchema, args), ); if (parsedArgs.relationship?.kind === "subagent") { - throw new Error("relationship subagent requires an agent-scoped tool session"); + throw new PaseoToolInputError( + "relationship subagent requires an agent-scoped tool session", + ); } if (!parsedArgs.workspace) { - throw new Error("Legacy create_agent placement could not be resolved"); + throw new PaseoToolInputError("Legacy create_agent placement could not be resolved"); } const { cwd, workspaceId, worktree } = await resolveCreateAgentWorkspace( parsedArgs.workspace, @@ -1584,7 +1677,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase worktree, }; } - const parsedArgs = canonicalTopLevelCreateAgentArgsSchema.parse(args); + const parsedArgs = parseToolArguments(canonicalTopLevelCreateAgentArgsSchema, args); const { cwd, workspaceId } = await resolveCanonicalCreateAgentWorkspace( parsedArgs.workspaceId, { prompt: parsedArgs.initialPrompt }, @@ -1644,7 +1737,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase } const caller = resolveCallerAgent(); if (!caller?.workspaceId) { - throw new Error(`Caller agent ${callerAgentId} has no current workspace`); + throw new PaseoToolInputError(`Caller agent ${callerAgentId} has no current workspace`); } return { cwd: undefined, workspaceId: caller.workspaceId }; } @@ -1672,18 +1765,18 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase }; if (canonicalCandidate.relationship && canonicalCandidate.workspace) { - return legacyTopLevelCreateAgentArgsSchema.parse({ + return parseToolArguments(legacyTopLevelCreateAgentArgsSchema, { ...canonicalCandidate, ...(Object.keys(settings).length > 0 ? { settings } : {}), }); } if (canonicalCandidate.relationship || canonicalCandidate.workspace) { - throw new Error("relationship and workspace must be provided together"); + throw new PaseoToolInputError("relationship and workspace must be provided together"); } if (!cwd?.trim()) { - throw new Error("cwd is required for legacy top-level create_agent calls"); + throw new PaseoToolInputError("cwd is required for legacy top-level create_agent calls"); } const legacyWorktreeTarget = resolveLegacyCreateAgentWorktreeTarget({ @@ -1710,7 +1803,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase }, }; - return legacyTopLevelCreateAgentArgsSchema.parse({ + return parseToolArguments(legacyTopLevelCreateAgentArgsSchema, { ...canonicalCandidate, relationship: { kind: "detached" }, workspace, @@ -1763,11 +1856,11 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase }> { if (workspace.kind === "current") { if (!callerAgentId) { - throw new Error("workspace current requires an agent-scoped tool session"); + throw new PaseoToolInputError("workspace current requires an agent-scoped tool session"); } const callerAgent = resolveCallerAgent(); if (!callerAgent?.workspaceId) { - throw new Error(`Caller agent ${callerAgentId} has no current workspace`); + throw new PaseoToolInputError(`Caller agent ${callerAgentId} has no current workspace`); } return { cwd: workspace.cwd, @@ -1784,14 +1877,16 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase (candidate) => candidate.workspaceId === workspace.workspaceId, ); if (!existingWorkspace) { - throw new Error(`Workspace ${workspace.workspaceId} not found`); + throw new PaseoToolInputError(`Workspace ${workspace.workspaceId} not found`); } const cwd = workspace.cwd ? resolveScopedCwd(workspace.cwd, { required: true }) : existingWorkspace.cwd; const lockedCwd = callerContext?.lockedCwd?.trim(); if (lockedCwd && !isSameOrDescendantPath(expandUserPath(lockedCwd), cwd)) { - throw new Error(`Workspace ${workspace.workspaceId} is outside the allowed cwd`); + throw new PaseoToolInputError( + `Workspace ${workspace.workspaceId} is outside the allowed cwd`, + ); } return { cwd, @@ -1893,6 +1988,9 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase logger: childLogger, }).catch((error) => { stopNotification?.(); + if (error instanceof AgentNotFoundError) { + throw new PaseoToolInputError(error.message); + } throw error; }); @@ -1956,7 +2054,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase const record = await agentStorage.get(agentId); if (!record || record.internal) { - throw new Error(`Agent ${agentId} not found`); + throw new PaseoToolInputError(`Agent ${agentId} not found`); } const structuredSnapshot = buildStoredAgentPayload( @@ -2194,10 +2292,10 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase const workspaceId = resolveWorkspaceIdForRename(requestedWorkspaceId); const existing = await options.workspaceRegistry.get(workspaceId); if (!existing) { - throw new Error(`Workspace ${workspaceId} not found`); + throw new PaseoToolInputError(`Workspace ${workspaceId} not found`); } if (existing.archivedAt) { - throw new Error(`Workspace ${workspaceId} is archived`); + throw new PaseoToolInputError(`Workspace ${workspaceId} is archived`); } await options.workspaceRegistry.upsert({ @@ -2401,7 +2499,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase const terminal = terminalManager.getTerminal(terminalId); if (!terminal) { - throw new Error(`Terminal ${terminalId} not found`); + throw new PaseoToolInputError(`Terminal ${terminalId} not found`); } terminal.kill(); @@ -2437,7 +2535,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase } if (!terminalManager.getTerminal(terminalId)) { - throw new Error(`Terminal ${terminalId} not found`); + throw new PaseoToolInputError(`Terminal ${terminalId} not found`); } const capture = await terminalManager.captureTerminal(terminalId, { @@ -2478,7 +2576,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase const terminal = terminalManager.getTerminal(terminalId); if (!terminal) { - throw new Error(`Terminal ${terminalId} not found`); + throw new PaseoToolInputError(`Terminal ${terminalId} not found`); } terminal.send({ @@ -2498,24 +2596,26 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase { title: "Create schedule", description: "Create a recurring schedule that starts a new agent on a cron cadence.", - inputSchema: { - prompt: z.string().trim().min(1, "prompt is required"), - cron: z.string().trim().min(1, "cron is required"), - timezone: z - .string() - .trim() - .min(1) - .optional() - .describe("IANA time zone for the cron cadence. For example: America/New_York."), - name: z.string().optional(), - provider: (callerAgentId ? AgentProviderEnum.optional() : AgentProviderEnum).describe( - "Provider, or provider/model (for example: codex or codex/gpt-5.4). Defaults to the caller's provider in an agent-scoped session.", - ), - cwd: z.string().optional(), - isolation: z.enum(["local", "worktree"]).optional(), - maxRuns: z.number().int().positive().optional(), - expiresIn: z.string().optional(), - }, + inputSchema: z + .object({ + prompt: z.string().trim().min(1, "prompt is required"), + cron: z.string().trim().min(1, "cron is required"), + timezone: z + .string() + .trim() + .min(1) + .optional() + .describe("IANA time zone for the cron cadence. For example: America/New_York."), + name: z.string().optional(), + provider: (callerAgentId ? AgentProviderEnum.optional() : AgentProviderEnum).describe( + "Provider, or provider/model (for example: codex or codex/gpt-5.4). Defaults to the caller's provider in an agent-scoped session.", + ), + cwd: z.string().optional(), + isolation: z.enum(["local", "worktree"]).optional(), + maxRuns: z.number().int().positive().optional(), + expiresIn: scheduleDurationInputSchema.optional(), + }) + .superRefine(addScheduleCadenceIssue), outputSchema: ScheduleSummarySchema.shape, }, async ({ prompt, cron, timezone, name, provider, cwd, isolation, maxRuns, expiresIn }) => { @@ -2548,19 +2648,21 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase { title: "Create heartbeat", description: "Create a recurring heartbeat that sends you a prompt on a cron cadence.", - inputSchema: { - prompt: z.string().trim().min(1, "prompt is required"), - cron: z.string().trim().min(1, "cron is required"), - timezone: z - .string() - .trim() - .min(1) - .optional() - .describe("IANA time zone for the cron cadence. For example: America/New_York."), - name: z.string().optional(), - maxRuns: z.number().int().positive().optional(), - expiresIn: z.string().optional(), - }, + inputSchema: z + .object({ + prompt: z.string().trim().min(1, "prompt is required"), + cron: z.string().trim().min(1, "cron is required"), + timezone: z + .string() + .trim() + .min(1) + .optional() + .describe("IANA time zone for the cron cadence. For example: America/New_York."), + name: z.string().optional(), + maxRuns: z.number().int().positive().optional(), + expiresIn: scheduleDurationInputSchema.optional(), + }) + .superRefine(addScheduleCadenceIssue), outputSchema: ScheduleSummarySchema.shape, }, async ({ prompt, cron, timezone, name, maxRuns, expiresIn }) => { @@ -2568,7 +2670,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase throw new Error("Schedule service is not configured"); } if (!callerAgentId) { - throw new Error("create_heartbeat requires an agent-scoped session"); + throw new PaseoToolInputError("create_heartbeat requires an agent-scoped session"); } resolveCallerAgent(); @@ -2679,7 +2781,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase } await requireScheduleTarget(id, "new-agent"); - await scheduleService.pause(id); + await runScheduleAction(() => scheduleService.pause(id)); return { content: [], structuredContent: ensureValidJson({ success: true }), @@ -2705,7 +2807,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase } await requireScheduleTarget(id, "new-agent"); - await scheduleService.resume(id); + await runScheduleAction(() => scheduleService.resume(id)); return { content: [], structuredContent: ensureValidJson({ success: true }), @@ -2787,13 +2889,13 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase .optional() .describe("New mode for new-agent target (null to clear)."), cwd: z.string().trim().min(1).optional().describe("New cwd for new-agent target."), - expiresIn: z - .string() + expiresIn: scheduleDurationInputSchema .optional() .describe("New relative expiry duration (for example: 1h, 2d)."), clearExpires: z.boolean().optional().describe("Clear any schedule expiry."), }) - .passthrough(), + .passthrough() + .superRefine(addScheduleCadenceIssue), outputSchema: StoredScheduleSchema.shape, }, async (input) => { @@ -2850,7 +2952,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase throw new Error("Schedule service is not configured"); } await requireScheduleTarget(id, "new-agent"); - const schedule = await scheduleService.runOnce(id); + const schedule = await runScheduleAction(() => scheduleService.runOnce(id)); return { content: [], structuredContent: ensureValidJson(schedule), @@ -2963,10 +3065,10 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase }); const summary = toProviderSummary(entry); if (!entry.enabled) { - throw new Error(`Provider '${providerId}' is disabled`); + throw new PaseoToolInputError(`Provider '${providerId}' is disabled`); } if (entry.status !== "ready") { - throw new Error(entry.error ?? `Provider '${providerId}' is unavailable`); + throw new PaseoToolInputError(entry.error ?? `Provider '${providerId}' is unavailable`); } const selectedModel = settings?.model ?? resolvedProviderModel.model; const features = await agentManager.listDraftFeatures({ diff --git a/packages/server/src/server/agent/tools/types.ts b/packages/server/src/server/agent/tools/types.ts index 66a4a23da2..f82ad7549a 100644 --- a/packages/server/src/server/agent/tools/types.ts +++ b/packages/server/src/server/agent/tools/types.ts @@ -1,6 +1,18 @@ import type { z } from "zod"; import type { ProviderPaseoToolsPolicy } from "@getpaseo/protocol/provider-config"; +export class PaseoToolInputError extends Error { + readonly status = 400; + + constructor( + message: string, + readonly issues?: z.ZodError["issues"], + ) { + super(message); + this.name = "PaseoToolInputError"; + } +} + export interface PaseoToolExecutionContext { signal?: AbortSignal; sendUpdate?: (update: PaseoToolResult) => void; diff --git a/packages/server/src/server/schedule/service.ts b/packages/server/src/server/schedule/service.ts index 7b5487c3f8..3b462cc5b1 100644 --- a/packages/server/src/server/schedule/service.ts +++ b/packages/server/src/server/schedule/service.ts @@ -38,6 +38,20 @@ export class ScheduleTargetGoneError extends Error { } } +export class ScheduleNotFoundError extends Error { + constructor(id: string) { + super(`Schedule not found: ${id}`); + this.name = "ScheduleNotFoundError"; + } +} + +export class ScheduleStateConflictError extends Error { + constructor(message: string) { + super(message); + this.name = "ScheduleStateConflictError"; + } +} + function advanceNextRunAtPast( cadence: StoredSchedule["cadence"], nextRunAt: Date, @@ -158,7 +172,7 @@ function shouldCompleteSchedule(schedule: StoredSchedule, now: Date): boolean { function requireSchedule(schedule: StoredSchedule | null, id: string): StoredSchedule { if (!schedule) { - throw new Error(`Schedule not found: ${id}`); + throw new ScheduleNotFoundError(id); } return schedule; } @@ -392,10 +406,7 @@ export class ScheduleService { async inspect(id: string): Promise { const schedule = await this.store.get(id); - if (!schedule) { - throw new Error(`Schedule not found: ${id}`); - } - return schedule; + return requireSchedule(schedule, id); } async logs(id: string): Promise { @@ -406,7 +417,7 @@ export class ScheduleService { async pause(id: string): Promise { const paused = await this.store.update(id, (schedule) => { if (schedule.status === "completed") { - throw new Error(`Schedule ${id} is already completed`); + throw new ScheduleStateConflictError(`Schedule ${id} is already completed`); } if (schedule.status === "paused") { return schedule; @@ -426,7 +437,7 @@ export class ScheduleService { async resume(id: string): Promise { const resumed = await this.store.update(id, (schedule) => { if (schedule.status === "completed") { - throw new Error(`Schedule ${id} is already completed`); + throw new ScheduleStateConflictError(`Schedule ${id} is already completed`); } if (schedule.status === "active") { return schedule; @@ -546,10 +557,10 @@ export class ScheduleService { async runOnce(id: string): Promise { const schedule = await this.inspect(id); if (schedule.status === "completed") { - throw new Error(`Schedule ${id} is already completed`); + throw new ScheduleStateConflictError(`Schedule ${id} is already completed`); } if (this.runningScheduleIds.has(id)) { - throw new Error(`Schedule ${id} is already running`); + throw new ScheduleStateConflictError(`Schedule ${id} is already running`); } await this.runSchedule(schedule, this.now(), { manual: true }); return this.inspect(id);