mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-22 17:22:47 +00:00
Return actionable errors to OpenCode agents for invalid tool calls (#7183)
* fix(server): return actionable OpenCode bridge errors * fix(server): surface OpenCode tool input errors * fix(server): surface OpenCode v2 tool errors * test(server): cover OpenCode v2 bridge errors * fix(server): classify missing schedules as tool input * fix(server): surface schedule state conflicts * fix(server): validate schedule tool inputs * fix(server): keep tool contracts while reporting OpenCode input errors Preserve the removal of create_agent.background and keep legacy schedule fields hidden. Verify client errors through the real bridge, catalog, and schedule service rather than mocked schedule failures. Co-authored-by: Felipe Fidelix <felipe@fdx.is> * fix(server): distinguish caller errors from stored data failures Tag validation failures where tool arguments are parsed, so schema errors from persisted state remain internal failures. Preserve the missing-agent error at its producer and translate it for prompt delivery. Real HTTP regressions fail for both misclassifications before this change. * fix(server): preserve messages for unclassified tool failures Keep existing error messages on HTTP 500 responses so agents can correct failed tool calls. Exercise missing workspaces through the real script service and bridge route. * fix(server): preserve clearing schedule names with blank strings --------- Co-authored-by: Felipe Fidelix <felipe@fdx.is> Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Felipe Fidelix
parent
6e1ec21c6a
commit
30799fda19
@@ -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<ManagedAgent>;
|
||||
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();
|
||||
|
||||
@@ -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<never> => {
|
||||
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 });
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<unknown> {
|
||||
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 {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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 <provider> or <provider>/<model>");
|
||||
throw new PaseoToolInputError("provider must be <provider> or <provider>/<model>");
|
||||
}
|
||||
|
||||
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 <provider> or <provider>/<model>");
|
||||
throw new PaseoToolInputError("provider must be <provider> or <provider>/<model>");
|
||||
}
|
||||
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<T>(schema: z.ZodType<T>, 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<string, PaseoToolDefinition>();
|
||||
@@ -627,7 +693,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
|
||||
): Promise<PaseoToolResult> {
|
||||
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<ReturnType<ScheduleService["inspect"]>>;
|
||||
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<T>(action: () => Promise<T>): Promise<T> {
|
||||
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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<StoredSchedule> {
|
||||
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<ScheduleRun[]> {
|
||||
@@ -406,7 +417,7 @@ export class ScheduleService {
|
||||
async pause(id: string): Promise<StoredSchedule> {
|
||||
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<StoredSchedule> {
|
||||
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<StoredSchedule> {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user