From 4063671e3f01fbcab357c1b3392ac5099deb82b2 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 11 Jun 2026 12:38:13 +0700 Subject: [PATCH] Add first-class Paseo Agent definitions --- docs/paseo-agent.md | 62 ++-- .../src/hooks/use-paseo-agent-providers.ts | 2 +- packages/cli/src/commands/login/index.test.ts | 26 +- packages/cli/src/commands/login/index.ts | 89 +++-- packages/cli/src/utils/open-browser.test.ts | 81 ++++ packages/cli/src/utils/open-browser.ts | 34 +- packages/protocol/src/messages.ts | 24 -- .../paseo-agent/agent-permissions.test.ts | 31 ++ .../paseo-agent/agent-permissions.ts | 51 +++ .../agent/providers/paseo-agent/agent.test.ts | 306 +++++++++++++++- .../agent/providers/paseo-agent/agent.ts | 345 +++++++++++------- .../paseo-agent/config-service.test.ts | 22 +- .../providers/paseo-agent/config-service.ts | 10 - .../providers/paseo-agent/config.test.ts | 8 +- .../agent/providers/paseo-agent/config.ts | 8 +- .../providers/paseo-agent/pi-services.test.ts | 80 ++++ .../providers/paseo-agent/pi-services.ts | 40 +- .../paseo-agent/prompt-profiles.test.ts | 148 ++++---- .../providers/paseo-agent/prompt-profiles.ts | 164 +++++---- .../src/server/daemon-config-store.test.ts | 12 +- .../server/src/server/daemon-config-store.ts | 18 +- .../src/server/persisted-config.test.ts | 4 + packages/server/src/server/session.ts | 31 -- .../public/schemas/paseo.config.v1.json | 4 + 24 files changed, 1159 insertions(+), 441 deletions(-) create mode 100644 packages/cli/src/utils/open-browser.test.ts create mode 100644 packages/server/src/server/agent/providers/paseo-agent/agent-permissions.test.ts create mode 100644 packages/server/src/server/agent/providers/paseo-agent/agent-permissions.ts diff --git a/docs/paseo-agent.md b/docs/paseo-agent.md index 0d2ab40915..fc78538e0a 100644 --- a/docs/paseo-agent.md +++ b/docs/paseo-agent.md @@ -27,18 +27,18 @@ Transports: HTTP (streamable) is the primary path (the injected `paseo` server i SSE and stdio transports are also wired via the MCP SDK. No extra config is needed — MCP servers come from Paseo's normal injection/config, not from `agents.paseo`. -## Prompt profiles +## Agent definitions -Paseo Agent can load a Paseo-owned prompt profile from `$PASEO_HOME/agents/*.md`. -Configure the default profile in `agents.paseo.defaultProfile`; `orchestrator` resolves -to `$PASEO_HOME/agents/orchestrator.md`. Only top-level markdown files are profiles. -Reusable fragments live under `$PASEO_HOME/agents/fragments/*.md`. +Paseo Agent can load a Paseo-owned agent definition from `$PASEO_HOME/agents/*.md`. +Configure the default agent in `agents.paseo.defaultAgent`; `orchestrator` resolves +to `$PASEO_HOME/agents/orchestrator.md`. Only top-level markdown files are selectable +agents. Reusable partials can live anywhere under `$PASEO_HOME/agents`. ```jsonc { "agents": { "paseo": { - "defaultProfile": "orchestrator", + "defaultAgent": "orchestrator", "defaultModel": "openrouter-main/anthropic/claude-3.7-sonnet", "providers": {}, }, @@ -52,37 +52,48 @@ Example `$PASEO_HOME/agents/orchestrator.md`: --- name: Orchestrator description: Coordinates work through Paseo-managed agents -mode: extend -include: - - fragments/collaboration.md +prompt: extend mcp: [paseo] model: openrouter-main/anthropic/claude-3.7-sonnet +tools: [read, grep, paseo__list_agents, paseo__create_agent] +permissions: + - tool: paseo__archive_* + action: deny --- +!{{./partials/collaboration.md}} + Use the Paseo MCP tools to inspect active agents, create focused helper agents, and summarize handoffs clearly. -{{include: fragments/review-rules.md}} +!{{./partials/review-rules.md}} ``` -`mode: extend` keeps Pi's default base prompt and prepends the composed profile body to -the append list. `mode: override` uses the profile body as the custom base prompt, so -Pi's default base prompt is skipped. In both modes, per-session `systemPrompt` is appended -after the profile, and the daemon-level append prompt is appended last. +`prompt: extend` keeps Pi's default base prompt and prepends the composed agent body to +the append list. `prompt: override` uses the agent body as the custom base prompt, so +Pi's default base prompt is skipped. In both prompt modes, per-session `systemPrompt` is +appended after the agent, and the daemon-level append prompt is appended last. -Frontmatter supports `name`, `description`, `mode`, `include`, `mcp`, `model`, and -`projectContext`. `projectContext` is parsed for a future explicit project-context model, -but it does not activate implicit `AGENTS.md`/`CLAUDE.md` discovery; Paseo Agent still -keeps Pi context discovery off. `model` is only a lowest-precedence default: an explicit -session model wins, then `agents.paseo.defaultModel`, then the profile model. +Frontmatter supports `name`, `description`, `prompt`, `mcp`, `model`, `tools`, +`permissions`, and `projectContext`. `projectContext` is parsed for a future explicit +project-context model, but it does not activate implicit `AGENTS.md`/`CLAUDE.md` +discovery; Paseo Agent still keeps Pi context discovery off. `model` is an agent default: +an explicit session model wins, then the selected agent model, then +`agents.paseo.defaultModel`, then Pi's first available model. -Includes are deliberately confined to `$PASEO_HOME/agents`: absolute paths, `..` escapes, -cycles, overly deep include chains, and oversized profiles are rejected. Frontmatter -`include` entries are prepended in order; inline `{{include: fragments/foo.md}}` entries -are expanded in place. +Partials use bang braces and expand exactly where they appear: `!{{./partials/base.md}}`. +Paths are relative to the file containing the directive and are confined to +`$PASEO_HOME/agents`: absolute paths, directory escapes, cycles, overly deep partial +chains, oversized definitions, and frontmatter inside partials are rejected. + +`tools` is the Pi tool allowlist for the agent: it controls what the model sees and can +call. Omit it to use Pi's default built-in tools plus bridged MCP tools. `permissions` is +an ordered first-match policy for active tool calls. The first matching `tool` pattern +wins; unmatched tools are allowed. Denied calls are blocked before execution through Pi's +tool preflight hook, so the policy applies to built-in, custom, and bridged MCP tools. `mcp: [paseo]` is an expectation check, not a new injection mechanism. The normal daemon -MCP injection still supplies the actual server; if a profile declares an MCP server that +MCP injection still supplies the actual server; if an agent declares an MCP server that is not present in the session's `mcpServers`, Paseo Agent logs a warning and continues. ## Config shape @@ -97,6 +108,9 @@ is not present in the session's `mcpServers`, Paseo Agent logs a warning and con // Optional. Loads $PASEO_HOME/agents/orchestrator.md by default for new // Paseo Agent sessions. + "defaultAgent": "orchestrator", + + // Legacy alias for defaultAgent. Still accepted for old configs. "defaultProfile": "orchestrator", // Inference providers, keyed by instance name. Names are free-form; you may diff --git a/packages/app/src/hooks/use-paseo-agent-providers.ts b/packages/app/src/hooks/use-paseo-agent-providers.ts index c2f335b840..730585bc8a 100644 --- a/packages/app/src/hooks/use-paseo-agent-providers.ts +++ b/packages/app/src/hooks/use-paseo-agent-providers.ts @@ -36,7 +36,7 @@ export function usePaseoAgentProviders(serverId: string | null): UsePaseoAgentPr const queryClient = useQueryClient(); const client = useHostRuntimeClient(serverId ?? ""); const isConnected = useHostRuntimeIsConnected(serverId ?? ""); - // COMPAT(paseoAgentConfig): added in v0.1.X, drop the gate when floor >= v0.1.X. + // COMPAT(paseoAgentConfig): added in v0.1.85, remove gate after 2026-11-30. const supported = useSessionStore( (state) => state.sessions[serverId ?? ""]?.serverInfo?.features?.paseoAgentConfig === true, ); diff --git a/packages/cli/src/commands/login/index.test.ts b/packages/cli/src/commands/login/index.test.ts index 9bb6807c21..7da40fd8a9 100644 --- a/packages/cli/src/commands/login/index.test.ts +++ b/packages/cli/src/commands/login/index.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { createCli } from "../../cli.js"; import { createLoginCommand } from "./index.js"; @@ -152,6 +152,11 @@ describe("paseo login command", () => { it("rejects --device-code with --host instead of writing local auth for a remote host", async () => { const output: string[] = []; + const stderr: string[] = []; + const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + stderr.push(String(chunk)); + return true; + }); const login = createLoginCommand({ write: (message) => output.push(message), writeError: (message) => output.push(message), @@ -172,14 +177,22 @@ describe("paseo login command", () => { }, }); - await login.parseAsync(["node", "login", "chatgpt", "--device-code", "--host", "remote:7777"]); + await expect( + login.parseAsync(["node", "login", "chatgpt", "--device-code", "--host", "remote:7777"]), + ).rejects.toThrow("process.exit unexpectedly called"); + stderrSpy.mockRestore(); - expect(output.join("\n")).toContain("--device-code cannot be combined with --host"); + expect(stderr.join("\n")).toContain("--device-code cannot be combined with --host"); }); it("asks for a host update instead of sending credentials to an old daemon", async () => { const stored: unknown[] = []; const output: string[] = []; + const stderr: string[] = []; + const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + stderr.push(String(chunk)); + return true; + }); const login = createLoginCommand({ write: (message) => output.push(message), writeError: (message) => output.push(message), @@ -209,10 +222,13 @@ describe("paseo login command", () => { }, }); - await login.parseAsync(["node", "login", "chatgpt", "--host", "remote:7777"]); + await expect( + login.parseAsync(["node", "login", "chatgpt", "--host", "remote:7777"]), + ).rejects.toThrow("process.exit unexpectedly called"); + stderrSpy.mockRestore(); expect(stored).toEqual([]); - expect(output.join("\n")).toContain("Update the host to configure Paseo Agent providers."); + expect(stderr.join("\n")).toContain("Update the host to configure Paseo Agent providers."); }); it("does not echo password-bearing host URIs after remote login", async () => { diff --git a/packages/cli/src/commands/login/index.ts b/packages/cli/src/commands/login/index.ts index 7046d60eae..5b26e30238 100644 --- a/packages/cli/src/commands/login/index.ts +++ b/packages/cli/src/commands/login/index.ts @@ -8,9 +8,16 @@ import { } from "@getpaseo/server"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { addDaemonHostOption } from "../../utils/command-options.js"; +import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js"; import { connectToDaemon } from "../../utils/client.js"; import { openBrowserUrl } from "../../utils/open-browser.js"; +import { + type CommandError, + type CommandOptions, + type OutputSchema, + type SingleResult, + withOutput, +} from "../../output/index.js"; // First-class auth UX: `paseo login chatgpt`. // Default flow is browser OAuth (PKCE + local callback on 127.0.0.1:1455) via Pi's @@ -19,13 +26,15 @@ import { openBrowserUrl } from "../../utils/open-browser.js"; const PROVIDER_INSTANCE = "chatgpt"; -interface LoginChatgptOptions { +interface LoginChatgptOptions extends CommandOptions { deviceCode?: boolean; home?: string; - host?: string; } interface LoginResult { + provider: string; + mode: "browser" | "device-code"; + target: string; path: string; } @@ -49,10 +58,24 @@ const defaultDependencies: LoginCommandDependencies = { connectDaemon: connectToDaemon, openBrowser: openBrowserUrl, promptForCode, - write: (message) => console.log(message), + write: (message) => console.error(message), writeError: (message) => console.error(message), }; +const loginResultSchema: OutputSchema = { + idField: "provider", + columns: [ + { header: "PROVIDER", field: "provider", width: 12 }, + { header: "MODE", field: "mode", width: 12 }, + { header: "TARGET", field: "target", width: 44 }, + ], + renderHuman: (result) => { + const data = result.data as LoginResult; + return `Logged in. Credential stored ${data.mode === "device-code" ? `at ${data.path}` : `on ${data.target}`} in Paseo-owned auth storage.`; + }, + serialize: (data) => data, +}; + function resolveEnv(home: string | undefined): NodeJS.ProcessEnv { return home ? { ...process.env, PASEO_HOME: home } : process.env; } @@ -61,7 +84,10 @@ function requirePaseoAgentConfigFeature(client: Pick void, info: CodexDeviceCode async function runChatgptLogin( options: LoginChatgptOptions, dependencies: LoginCommandDependencies, -): Promise { +): Promise> { const env = resolveEnv(options.home); const { write } = dependencies; if (options.deviceCode && options.host) { - throw new Error( - "--device-code cannot be combined with --host yet. Use browser login for remote hosts.", - ); + throw { + code: "INVALID_LOGIN_OPTIONS", + message: + "--device-code cannot be combined with --host yet. Use browser login for remote hosts.", + } satisfies CommandError; } if (options.deviceCode) { @@ -116,10 +144,19 @@ async function runChatgptLogin( env, onDeviceCode: (info) => printDeviceCode(write, info), }); - write(`\nāœ“ Logged in. Credential stored at ${path} (Paseo-owned, mode 0600).`); - return { path }; + return { + type: "single", + data: { + provider: "chatgpt", + mode: "device-code", + target: path, + path, + }, + schema: loginResultSchema, + }; } + const target = formatDaemonTarget(options.host); const client = await dependencies.connectDaemon({ host: options.host }); try { requirePaseoAgentConfigFeature(client); @@ -144,35 +181,39 @@ async function runChatgptLogin( credential, }); if (!result.success || result.error) { - throw new Error(result.error ?? "Daemon rejected the ChatGPT credential"); + throw { + code: "LOGIN_REJECTED", + message: result.error ?? "Daemon rejected the ChatGPT credential", + } satisfies CommandError; } + write(`Credential accepted by ${target}.`); } finally { await client.close().catch(() => {}); } - const target = formatDaemonTarget(options.host); - write(`\nāœ“ Logged in. Credential stored on ${target} in its Paseo-owned auth store.`); - return { path: target }; + return { + type: "single", + data: { + provider: "chatgpt", + mode: "browser", + target, + path: target, + }, + schema: loginResultSchema, + }; } export function createLoginCommand(dependencies: Partial = {}): Command { const deps = { ...defaultDependencies, ...dependencies }; const login = new Command("login").description("Authenticate Paseo providers"); - addDaemonHostOption( + addJsonAndDaemonHostOptions( login .command("chatgpt") .description("Log in to ChatGPT/OpenAI (Codex subscription) for the Paseo Agent provider") .option("--device-code", "Use the headless device-code flow instead of the browser flow") .option("--home ", "Paseo home directory for local --device-code only"), - ).action(async (options: LoginChatgptOptions) => { - try { - await runChatgptLogin(options, deps); - } catch (error) { - deps.writeError(`Login failed: ${error instanceof Error ? error.message : String(error)}`); - process.exitCode = 1; - } - }); + ).action(withOutput((options, _command) => runChatgptLogin(options, deps))); return login; } diff --git a/packages/cli/src/utils/open-browser.test.ts b/packages/cli/src/utils/open-browser.test.ts new file mode 100644 index 0000000000..a5d0f5a664 --- /dev/null +++ b/packages/cli/src/utils/open-browser.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; + +import { browserOpenCommand, openBrowserUrl } from "./open-browser.js"; + +describe("browserOpenCommand", () => { + it("opens Windows URLs without cmd.exe shell parsing", () => { + const url = + "https://auth.openai.com/oauth/authorize?client_id=paseo&state=abc%20123&redirect_uri=http%3A%2F%2F127.0.0.1%3A49152%2Fcallback"; + + expect(browserOpenCommand(url, "win32")).toEqual({ + command: "rundll32.exe", + args: ["url.dll,FileProtocolHandler", url], + }); + }); + + it("keeps macOS opener behavior unchanged", () => { + const url = "https://auth.openai.com/oauth/authorize?client_id=paseo&state=abc"; + + expect(browserOpenCommand(url, "darwin")).toEqual({ + command: "open", + args: [url], + }); + }); + + it("keeps Linux opener behavior unchanged", () => { + const url = "https://auth.openai.com/oauth/authorize?client_id=paseo&state=abc"; + + expect(browserOpenCommand(url, "linux")).toEqual({ + command: "xdg-open", + args: [url], + }); + }); +}); + +describe("openBrowserUrl", () => { + it("spawns the resolved opener without launching a real browser", () => { + const spawned: Array<{ + command: string; + args: string[]; + options: { stdio: "ignore"; detached: true }; + }> = []; + const child = { + on: () => child, + unref: () => {}, + }; + + const opened = openBrowserUrl( + "https://auth.openai.com/oauth/authorize?client_id=paseo&state=abc", + { + platform: "win32", + spawn: (command, args, options) => { + spawned.push({ command, args, options }); + return child; + }, + }, + ); + + expect(opened).toBe(true); + expect(spawned).toEqual([ + { + command: "rundll32.exe", + args: [ + "url.dll,FileProtocolHandler", + "https://auth.openai.com/oauth/authorize?client_id=paseo&state=abc", + ], + options: { stdio: "ignore", detached: true }, + }, + ]); + }); + + it("returns false when spawning the opener throws", () => { + const opened = openBrowserUrl("https://auth.openai.com/oauth/authorize", { + platform: "linux", + spawn: () => { + throw new Error("spawn failed"); + }, + }); + + expect(opened).toBe(false); + }); +}); diff --git a/packages/cli/src/utils/open-browser.ts b/packages/cli/src/utils/open-browser.ts index b0f2432a6b..fb617fb19d 100644 --- a/packages/cli/src/utils/open-browser.ts +++ b/packages/cli/src/utils/open-browser.ts @@ -1,26 +1,46 @@ -import { spawn } from "node:child_process"; +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; + +interface BrowserOpenCommand { + command: string; + args: string[]; +} + +type BrowserOpenSpawn = ( + command: string, + args: string[], + options: { stdio: "ignore"; detached: true }, +) => Pick; + +interface BrowserOpenDependencies { + platform?: NodeJS.Platform; + spawn?: BrowserOpenSpawn; +} /** * Best-effort cross-platform browser opener for CLI OAuth flows. Returns true if the * opener process was spawned, false otherwise. Callers must always print the URL too, * so a failed/headless open still lets the user copy it. */ -function browserOpenCommand(url: string): { command: string; args: string[] } { - switch (process.platform) { +export function browserOpenCommand( + url: string, + platform: NodeJS.Platform = process.platform, +): BrowserOpenCommand { + switch (platform) { case "darwin": return { command: "open", args: [url] }; case "win32": - return { command: "cmd", args: ["/c", "start", "", url] }; + return { command: "rundll32.exe", args: ["url.dll,FileProtocolHandler", url] }; default: return { command: "xdg-open", args: [url] }; } } -export function openBrowserUrl(url: string): boolean { - const { command, args } = browserOpenCommand(url); +export function openBrowserUrl(url: string, dependencies: BrowserOpenDependencies = {}): boolean { + const spawnBrowser = dependencies.spawn ?? spawn; + const { command, args } = browserOpenCommand(url, dependencies.platform); try { - const child = spawn(command, args, { stdio: "ignore", detached: true }); + const child = spawnBrowser(command, args, { stdio: "ignore", detached: true }); child.on("error", () => {}); child.unref(); return true; diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index e79698adf4..37f0af5730 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -2015,12 +2015,6 @@ export const PaseoAgentRemoveProviderRequestSchema = z.object({ name: z.string().trim().min(1), }); -export const PaseoAgentSetDefaultModelRequestSchema = z.object({ - type: z.literal("config.paseo_agent.set_default_model.request"), - requestId: z.string(), - model: z.string().trim().min(1).nullable(), -}); - export const PaseoAgentStoreChatGptCredentialRequestSchema = z.object({ type: z.literal("config.paseo_agent.store_chatgpt_credential.request"), requestId: z.string(), @@ -2182,7 +2176,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ PaseoAgentGetProvidersRequestSchema, PaseoAgentSetProviderRequestSchema, PaseoAgentRemoveProviderRequestSchema, - PaseoAgentSetDefaultModelRequestSchema, PaseoAgentStoreChatGptCredentialRequestSchema, ListAvailableProvidersRequestMessageSchema, GetProvidersSnapshotRequestMessageSchema, @@ -4013,16 +4006,6 @@ export const PaseoAgentRemoveProviderResponseSchema = z.object({ }), }); -export const PaseoAgentSetDefaultModelResponseSchema = z.object({ - type: z.literal("config.paseo_agent.set_default_model.response"), - payload: z.object({ - requestId: z.string(), - success: z.boolean(), - defaultModel: z.string().nullable(), - error: z.string().nullable(), - }), -}); - export const PaseoAgentStoreChatGptCredentialResponseSchema = z.object({ type: z.literal("config.paseo_agent.store_chatgpt_credential.response"), payload: z.object({ @@ -4432,7 +4415,6 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ PaseoAgentGetProvidersResponseSchema, PaseoAgentSetProviderResponseSchema, PaseoAgentRemoveProviderResponseSchema, - PaseoAgentSetDefaultModelResponseSchema, PaseoAgentStoreChatGptCredentialResponseSchema, ListAvailableProvidersResponseSchema, GetProvidersSnapshotResponseMessageSchema, @@ -4576,9 +4558,6 @@ export type PaseoAgentSetProviderResponse = z.infer; -export type PaseoAgentSetDefaultModelResponse = z.infer< - typeof PaseoAgentSetDefaultModelResponseSchema ->; export type PaseoAgentStoreChatGptCredentialResponse = z.infer< typeof PaseoAgentStoreChatGptCredentialResponseSchema >; @@ -4661,9 +4640,6 @@ export type ListProviderFeaturesRequestMessage = z.infer< export type PaseoAgentGetProvidersRequest = z.infer; export type PaseoAgentSetProviderRequest = z.infer; export type PaseoAgentRemoveProviderRequest = z.infer; -export type PaseoAgentSetDefaultModelRequest = z.infer< - typeof PaseoAgentSetDefaultModelRequestSchema ->; export type PaseoAgentStoreChatGptCredentialRequest = z.infer< typeof PaseoAgentStoreChatGptCredentialRequestSchema >; diff --git a/packages/server/src/server/agent/providers/paseo-agent/agent-permissions.test.ts b/packages/server/src/server/agent/providers/paseo-agent/agent-permissions.test.ts new file mode 100644 index 0000000000..726dbeb9a8 --- /dev/null +++ b/packages/server/src/server/agent/providers/paseo-agent/agent-permissions.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { createToolPermissionPolicy, evaluateToolPermission } from "./agent-permissions.js"; + +describe("Paseo Agent tool permissions", () => { + it("uses the first matching rule", () => { + const allowFirst = createToolPermissionPolicy([ + { tool: "bash", action: "allow" }, + { tool: "bash", action: "deny" }, + ]); + const denyFirst = createToolPermissionPolicy([ + { tool: "bash", action: "deny" }, + { tool: "bash", action: "allow" }, + ]); + + expect(evaluateToolPermission(allowFirst, "bash")).toBe("allow"); + expect(evaluateToolPermission(denyFirst, "bash")).toBe("deny"); + }); + + it("matches wildcard tool names", () => { + const policy = createToolPermissionPolicy([{ tool: "paseo__archive_*", action: "deny" }]); + + expect(evaluateToolPermission(policy, "paseo__archive_agent")).toBe("deny"); + expect(evaluateToolPermission(policy, "paseo__list_agents")).toBe("allow"); + }); + + it("allows tools when no rule matches", () => { + const policy = createToolPermissionPolicy([{ tool: "read", action: "deny" }]); + + expect(evaluateToolPermission(policy, "bash")).toBe("allow"); + }); +}); diff --git a/packages/server/src/server/agent/providers/paseo-agent/agent-permissions.ts b/packages/server/src/server/agent/providers/paseo-agent/agent-permissions.ts new file mode 100644 index 0000000000..3172442851 --- /dev/null +++ b/packages/server/src/server/agent/providers/paseo-agent/agent-permissions.ts @@ -0,0 +1,51 @@ +import { z } from "zod"; + +const ToolPermissionActionSchema = z.enum(["allow", "deny"]); + +export const ToolPermissionRuleSchema = z + .object({ + tool: z.string().min(1), + action: ToolPermissionActionSchema, + }) + .strict(); + +export type ToolPermissionAction = z.infer; +export type ToolPermissionRule = z.infer; + +export interface ToolPermissionPolicy { + rules: ToolPermissionRule[]; +} + +export function createToolPermissionPolicy( + rules: ToolPermissionRule[] | undefined, +): ToolPermissionPolicy { + return { rules: rules ?? [] }; +} + +export function evaluateToolPermission( + policy: ToolPermissionPolicy | undefined, + toolName: string, +): ToolPermissionAction { + for (const rule of policy?.rules ?? []) { + if (matchesToolPattern(rule.tool, toolName)) { + return rule.action; + } + } + return "allow"; +} + +function matchesToolPattern(pattern: string, toolName: string): boolean { + if (pattern === toolName || pattern === "*") { + return true; + } + const matcher = new RegExp(`^${wildcardPatternToRegExp(pattern)}$`); + return matcher.test(toolName); +} + +function wildcardPatternToRegExp(pattern: string): string { + return pattern.split("*").map(escapeRegExp).join(".*"); +} + +function escapeRegExp(value: string): string { + return value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&"); +} diff --git a/packages/server/src/server/agent/providers/paseo-agent/agent.test.ts b/packages/server/src/server/agent/providers/paseo-agent/agent.test.ts index 58b7e4ec7d..bfbf28391e 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/agent.test.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/agent.test.ts @@ -1,14 +1,16 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent"; import type { Logger } from "pino"; import { afterEach, describe, expect, it } from "vitest"; import { createTestLogger } from "../../../../test-utils/test-logger.js"; -import type { AgentSessionConfig } from "../../agent-sdk-types.js"; -import { PaseoAgentClient } from "./agent.js"; +import type { AgentSessionConfig, AgentStreamEvent } from "../../agent-sdk-types.js"; +import { PaseoAgentClient, PaseoAgentSession } from "./agent.js"; import { PaseoAgentConfigSchema, type PaseoAgentConfig } from "./config.js"; import { storeCodexOAuthCredential } from "./oauth-store.js"; +import type { PaseoAgentSessionHandle } from "./pi-services.js"; function makeConfig(): PaseoAgentConfig { return PaseoAgentConfigSchema.parse({ @@ -46,6 +48,112 @@ function createRecordingLogger(): Logger & { warnings: Array<{ data: unknown; me return logger; } +function deferred(): { + promise: Promise; + resolve: (value: T | PromiseLike) => void; + reject: (error: unknown) => void; +} { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((innerResolve, innerReject) => { + resolve = innerResolve; + reject = innerReject; + }); + return { promise, resolve, reject }; +} + +class FakeInProcessPiSession { + readonly sessionId = "pi-session-1"; + readonly thinkingLevel = "medium"; + readonly model = { provider: "openrouter-main", id: "test-model" }; + readonly messages: Array<{ role: string; content: unknown }> = []; + readonly agent = { state: { errorMessage: "" } }; + abortCalls = 0; + disposeCalls = 0; + promptCalls: Array<{ text: string; options: unknown }> = []; + promptDeferred = deferred(); + private readonly subscribers = new Set<(event: AgentSessionEvent) => void>(); + + subscribe(callback: (event: AgentSessionEvent) => void): () => void { + this.subscribers.add(callback); + return () => { + this.subscribers.delete(callback); + }; + } + + async prompt(text: string, options?: unknown): Promise { + this.promptCalls.push({ text, options }); + await this.promptDeferred.promise; + } + + async abort(): Promise { + this.abortCalls += 1; + const error = new Error("Request was aborted"); + error.name = "AbortError"; + this.promptDeferred.reject(error); + } + + dispose(): void { + this.disposeCalls += 1; + } + + getSessionStats() { + return { + sessionFile: undefined, + sessionId: this.sessionId, + userMessages: 1, + assistantMessages: 1, + toolCalls: 0, + toolResults: 0, + totalMessages: 2, + tokens: { input: 3, output: 5, cacheRead: 2, cacheWrite: 0, total: 10 }, + cost: 0.01, + }; + } + + setThinkingLevel(): void {} + + async setModel(): Promise {} + + emit(event: AgentSessionEvent): void { + for (const subscriber of this.subscribers) { + subscriber(event); + } + } +} + +function createPaseoProviderSession(): { + fakePi: FakeInProcessPiSession; + session: PaseoAgentSession; + events: AgentStreamEvent[]; + mcpBridge: { closeCalls: number }; +} { + const fakePi = new FakeInProcessPiSession(); + const handle = { + session: fakePi, + modelRegistry: { find: () => fakePi.model }, + resourceLoader: {}, + sessionManager: {}, + } as unknown as PaseoAgentSessionHandle; + const mcpBridge = { + tools: [], + closeCalls: 0, + async close() { + this.closeCalls += 1; + }, + }; + const session = new PaseoAgentSession( + handle, + sessionConfig(), + mcpBridge as unknown as ConstructorParameters[2], + null, + [], + ); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + return { fakePi, session, events, mcpBridge }; +} + describe("PaseoAgentClient", () => { const tempDirs: string[] = []; @@ -152,7 +260,7 @@ describe("PaseoAgentClient", () => { } }); - it("uses the configured default prompt profile as a lowest-precedence model default", async () => { + it("uses the selected agent as a model default", async () => { const paseoHome = mkdtempSync(join(tmpdir(), "paseo-agent-client-")); tempDirs.push(paseoHome); mkdirSync(join(paseoHome, "agents"), { recursive: true }); @@ -165,7 +273,7 @@ Profile prompt. `, ); const config = PaseoAgentConfigSchema.parse({ - defaultProfile: "orchestrator", + defaultAgent: "orchestrator", providers: { "openrouter-main": { type: "openrouter", @@ -188,7 +296,7 @@ Profile prompt. } }); - it("prefers the configured default model over the profile default model", async () => { + it("prefers the selected agent model over the configured default model", async () => { const paseoHome = mkdtempSync(join(tmpdir(), "paseo-agent-client-")); tempDirs.push(paseoHome); mkdirSync(join(paseoHome, "agents"), { recursive: true }); @@ -201,7 +309,7 @@ Profile prompt. `, ); const config = PaseoAgentConfigSchema.parse({ - defaultProfile: "orchestrator", + defaultAgent: "orchestrator", defaultModel: "openrouter-main/a", providers: { "openrouter-main": { @@ -219,7 +327,48 @@ Profile prompt. const session = await client.createSession(sessionConfig()); try { const info = await session.getRuntimeInfo(); - expect(info.model).toBe("openrouter-main/a"); + expect(info.model).toBe("openrouter-main/b"); + } finally { + await session.close(); + } + }); + + it("uses the requested mode as the selected agent definition", async () => { + const paseoHome = mkdtempSync(join(tmpdir(), "paseo-agent-client-")); + tempDirs.push(paseoHome); + mkdirSync(join(paseoHome, "agents"), { recursive: true }); + writeFileSync(join(paseoHome, "agents", "builder.md"), "---\nname: Builder\n---\nBuild."); + writeFileSync( + join(paseoHome, "agents", "reviewer.md"), + "---\nmodel: openrouter-main/b\n---\nReview.", + ); + const config = PaseoAgentConfigSchema.parse({ + defaultAgent: "builder", + providers: { + "openrouter-main": { + type: "openrouter", + options: { + baseUrl: "https://openrouter.ai/api/v1", + apiKey: "sk-test", + api: "openai-completions", + models: [{ id: "a" }, { id: "b" }], + }, + }, + }, + }); + const client = new PaseoAgentClient({ logger: createTestLogger(), config, paseoHome }); + + await expect(client.listModes({ cwd: process.cwd(), force: false })).resolves.toEqual([ + { id: "builder", label: "Builder" }, + { id: "reviewer", label: "reviewer" }, + ]); + + const session = await client.createSession(sessionConfig({ modeId: "reviewer" })); + try { + const info = await session.getRuntimeInfo(); + expect(info.modeId).toBe("reviewer"); + expect(info.model).toBe("openrouter-main/b"); + await expect(session.getCurrentMode()).resolves.toBe("reviewer"); } finally { await session.close(); } @@ -240,7 +389,7 @@ Profile prompt. const logger = createRecordingLogger(); const client = new PaseoAgentClient({ logger, - config: PaseoAgentConfigSchema.parse({ ...makeConfig(), defaultProfile: "orchestrator" }), + config: PaseoAgentConfigSchema.parse({ ...makeConfig(), defaultAgent: "orchestrator" }), paseoHome, }); const session = await client.createSession(sessionConfig()); @@ -256,3 +405,144 @@ Profile prompt. } }); }); + +describe("PaseoAgentSession runtime events", () => { + it("runs through a representative Pi event sequence", async () => { + const { fakePi, session, events } = createPaseoProviderSession(); + const resultPromise = session.run("hello"); + + await Promise.resolve(); + fakePi.emit({ type: "agent_start" }); + fakePi.emit({ type: "turn_start" }); + fakePi.emit({ + type: "message_update", + message: { role: "assistant", content: [] }, + assistantMessageEvent: { type: "thinking_delta", delta: "thinking" }, + }); + fakePi.emit({ + type: "tool_execution_start", + toolCallId: "tool-1", + toolName: "bash", + args: { command: "pwd" }, + }); + fakePi.emit({ + type: "tool_execution_end", + toolCallId: "tool-1", + toolName: "bash", + result: { output: "/tmp" }, + isError: false, + }); + fakePi.emit({ + type: "message_update", + message: { role: "assistant", content: [] }, + assistantMessageEvent: { type: "text_delta", delta: "done" }, + }); + fakePi.emit({ type: "agent_end", messages: [], willRetry: false }); + fakePi.promptDeferred.resolve(); + + await expect(resultPromise).resolves.toEqual({ + sessionId: "pi-session-1", + finalText: "done", + usage: { inputTokens: 3, cachedInputTokens: 2, outputTokens: 5, totalCostUsd: 0.01 }, + timeline: [ + { type: "reasoning", text: "thinking" }, + { + type: "tool_call", + callId: "tool-1", + name: "bash", + status: "running", + detail: { type: "shell", command: "pwd", output: undefined, exitCode: undefined }, + error: null, + }, + { + type: "tool_call", + callId: "tool-1", + name: "bash", + status: "completed", + detail: { type: "shell", command: "pwd", output: "/tmp", exitCode: null }, + error: null, + }, + { type: "assistant_message", text: "done" }, + ], + }); + expect(events.map((event) => event.type)).toEqual([ + "thread_started", + "turn_started", + "timeline", + "timeline", + "timeline", + "timeline", + "turn_completed", + ]); + }); + + it("keeps the active turn open when Pi agent_end says it will retry", async () => { + const { fakePi, session, events } = createPaseoProviderSession(); + const resultPromise = session.run("retry please"); + + await Promise.resolve(); + fakePi.emit({ + type: "message_update", + message: { role: "assistant", content: [] }, + assistantMessageEvent: { type: "text_delta", delta: "first attempt " }, + }); + fakePi.agent.state.errorMessage = "transient overflow"; + fakePi.emit({ type: "agent_end", messages: [], willRetry: true }); + expect(events.some((event) => event.type === "turn_failed")).toBe(false); + expect(events.some((event) => event.type === "turn_completed")).toBe(false); + + fakePi.agent.state.errorMessage = ""; + fakePi.emit({ + type: "message_update", + message: { role: "assistant", content: [] }, + assistantMessageEvent: { type: "text_delta", delta: "retry success" }, + }); + fakePi.emit({ type: "agent_end", messages: [], willRetry: false }); + fakePi.promptDeferred.resolve(); + + await expect(resultPromise).resolves.toMatchObject({ + finalText: "first attempt retry success", + }); + expect(events.filter((event) => event.type === "turn_completed")).toHaveLength(1); + }); + + it("maps interrupt aborts to clean turn cancellation", async () => { + const { fakePi, session, events } = createPaseoProviderSession(); + const resultPromise = session.run("cancel me"); + + await Promise.resolve(); + await session.interrupt(); + + await expect(resultPromise).resolves.toMatchObject({ + sessionId: "pi-session-1", + finalText: "", + timeline: [], + }); + expect(fakePi.abortCalls).toBe(1); + expect(events).toContainEqual({ + type: "turn_canceled", + provider: "paseo", + turnId: expect.any(String), + reason: "interrupted", + }); + expect(events.some((event) => event.type === "turn_failed")).toBe(false); + }); + + it("aborts an active turn before close and close is idempotent", async () => { + const { fakePi, session, events, mcpBridge } = createPaseoProviderSession(); + await session.startTurn("close me"); + + await Promise.all([session.close(), session.close()]); + + expect(fakePi.abortCalls).toBe(1); + expect(fakePi.disposeCalls).toBe(1); + expect(mcpBridge.closeCalls).toBe(1); + expect(events).toContainEqual({ + type: "turn_canceled", + provider: "paseo", + turnId: expect.any(String), + reason: "interrupted", + }); + expect(events.some((event) => event.type === "turn_failed")).toBe(false); + }); +}); diff --git a/packages/server/src/server/agent/providers/paseo-agent/agent.ts b/packages/server/src/server/agent/providers/paseo-agent/agent.ts index da29acc3f6..abb0d150a2 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/agent.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/agent.ts @@ -6,7 +6,6 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent"; import { - getAgentStreamEventTurnId, type AgentCapabilityFlags, type AgentClient, type AgentLaunchContext, @@ -23,10 +22,10 @@ import { type AgentSessionConfig, type AgentSlashCommand, type AgentStreamEvent, - type AgentTimelineItem, - type AgentUsage, + type ListModesOptions, type ListModelsOptions, } from "../../agent-sdk-types.js"; +import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "../provider-runner.js"; import { PASEO_AGENT_PROVIDER, type PaseoAgentConfig, @@ -48,7 +47,13 @@ import { import { createMcpToolBridge, type McpToolBridge } from "./mcp-bridge.js"; import { createPaseoAgentAuthStorage, hasStoredOAuthCredential } from "./oauth-store.js"; import { createPaseoAgentSession, type PaseoAgentSessionHandle } from "./pi-services.js"; -import { composePromptParts, loadPromptProfile } from "./prompt-profiles.js"; +import { createToolPermissionPolicy } from "./agent-permissions.js"; +import { + composePromptParts, + listAgentDefinitionIds, + loadAgentDefinition, + type ResolvedAgentDefinition, +} from "./prompt-profiles.js"; const DEFAULT_THINKING_LEVEL: ThinkingLevel = "medium"; @@ -87,6 +92,17 @@ function envForPaseoHome(paseoHome: string | undefined): NodeJS.ProcessEnv { return paseoHome ? { ...process.env, PASEO_HOME: paseoHome } : process.env; } +function errorToMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isAbortError(error: unknown): boolean { + if (error instanceof Error && error.name === "AbortError") { + return true; + } + return /\brequest was aborted\b|\babort(ed)?\b/i.test(errorToMessage(error)); +} + interface PaseoAgentClientOptions { logger: Logger; config: PaseoAgentConfig; @@ -101,11 +117,14 @@ export class PaseoAgentSession implements AgentSession { private readonly activeToolCalls = new Map(); private activeTurnId: string | null = null; private lastThinkingOptionId: string | null; + private closePromise: Promise | null = null; constructor( private readonly handle: PaseoAgentSessionHandle, private readonly config: AgentSessionConfig, private readonly mcpBridge: McpToolBridge, + private readonly agentId: string | null, + private readonly availableAgents: AgentMode[], ) { this.lastThinkingOptionId = normalizeThinkingLevel(config.thinkingOptionId) ?? this.piSession.thinkingLevel ?? null; @@ -126,6 +145,27 @@ export class PaseoAgentSession implements AgentSession { } } + private clearActiveTurn(): string | null { + const turnId = this.activeTurnId; + this.activeTurnId = null; + this.activeToolCalls.clear(); + return turnId; + } + + private emitActiveTurnCanceled(reason: string): boolean { + const turnId = this.clearActiveTurn(); + if (!turnId) { + return false; + } + this.emit({ + type: "turn_canceled", + provider: PASEO_AGENT_PROVIDER, + turnId, + reason, + }); + return true; + } + private emitToolCall( toolCallId: string, toolCall: PiTrackedToolCall, @@ -158,29 +198,9 @@ export class PaseoAgentSession implements AgentSession { case "turn_start": this.emit({ type: "turn_started", provider: PASEO_AGENT_PROVIDER, turnId }); return; - case "message_update": { - if (event.message.role !== "assistant") { - return; - } - if (event.assistantMessageEvent.type === "text_delta") { - this.emit({ - type: "timeline", - provider: PASEO_AGENT_PROVIDER, - turnId, - item: { type: "assistant_message", text: event.assistantMessageEvent.delta ?? "" }, - }); - return; - } - if (event.assistantMessageEvent.type === "thinking_delta") { - this.emit({ - type: "timeline", - provider: PASEO_AGENT_PROVIDER, - turnId, - item: { type: "reasoning", text: event.assistantMessageEvent.delta ?? "" }, - }); - } + case "message_update": + this.handleMessageUpdate(event, turnId); return; - } case "tool_execution_start": { const toolCall = parseToolArgs(event.toolName, event.args); this.activeToolCalls.set(event.toolCallId, toolCall); @@ -216,96 +236,76 @@ export class PaseoAgentSession implements AgentSession { ); return; } - case "agent_end": { - const usage = toAgentUsage(this.piSession.getSessionStats()); - const currentTurnId = turnId; - this.activeTurnId = null; - const errorMessage = this.piSession.agent.state.errorMessage; - if (errorMessage) { - this.emit({ - type: "turn_failed", - provider: PASEO_AGENT_PROVIDER, - turnId: currentTurnId, - error: errorMessage, - }); - return; - } - this.emit({ - type: "turn_completed", - provider: PASEO_AGENT_PROVIDER, - turnId: currentTurnId, - usage, - }); + case "agent_end": + this.handleAgentEnd(event); return; - } default: return; } } + private handleMessageUpdate( + event: Extract, + turnId: string | undefined, + ): void { + if (event.message.role !== "assistant") { + return; + } + if (event.assistantMessageEvent.type === "text_delta") { + this.emit({ + type: "timeline", + provider: PASEO_AGENT_PROVIDER, + turnId, + item: { type: "assistant_message", text: event.assistantMessageEvent.delta ?? "" }, + }); + return; + } + if (event.assistantMessageEvent.type === "thinking_delta") { + this.emit({ + type: "timeline", + provider: PASEO_AGENT_PROVIDER, + turnId, + item: { type: "reasoning", text: event.assistantMessageEvent.delta ?? "" }, + }); + } + } + + private handleAgentEnd(event: Extract): void { + if (event.willRetry) { + return; + } + const usage = toAgentUsage(this.piSession.getSessionStats()); + const currentTurnId = this.clearActiveTurn() ?? undefined; + if (!currentTurnId) { + return; + } + const errorMessage = this.piSession.agent.state.errorMessage; + if (errorMessage) { + this.emit({ + type: "turn_failed", + provider: PASEO_AGENT_PROVIDER, + turnId: currentTurnId, + error: errorMessage, + }); + return; + } + this.emit({ + type: "turn_completed", + provider: PASEO_AGENT_PROVIDER, + turnId: currentTurnId, + usage, + }); + } + async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise { - const timeline: AgentTimelineItem[] = []; - let finalText = ""; - let usage: AgentUsage | undefined; - let turnId: string | null = null; - const bufferedEvents: AgentStreamEvent[] = []; - let settled = false; - let resolveCompletion!: () => void; - let rejectCompletion!: (error: Error) => void; - - function processEvent(event: AgentStreamEvent): void { - if (settled) { - return; - } - const eventTurnId = getAgentStreamEventTurnId(event); - if (turnId && eventTurnId && eventTurnId !== turnId) { - return; - } - if (event.type === "timeline") { - timeline.push(event.item); - if (event.item.type === "assistant_message") { - finalText += event.item.text; - } - return; - } - if (event.type === "turn_completed") { - usage = event.usage; - settled = true; - resolveCompletion(); - return; - } - if (event.type === "turn_failed") { - settled = true; - rejectCompletion(new Error(event.error)); - } - } - - const completion = new Promise((resolve, reject) => { - resolveCompletion = resolve; - rejectCompletion = reject; + return runProviderTurn({ + prompt, + runOptions: options, + startTurn: (p, o) => this.startTurn(p, o), + subscribe: (callback) => this.subscribe(callback), + getSessionId: () => this.piSession.sessionId, + reduceFinalText: appendOrReplaceGrowingAssistantMessage, }); - const unsubscribe = this.subscribe((event) => { - if (!turnId) { - bufferedEvents.push(event); - return; - } - processEvent(event); - }); - - try { - const result = await this.startTurn(prompt, options); - turnId = result.turnId; - for (const event of bufferedEvents) { - processEvent(event); - } - if (!settled) { - await completion; - } - } finally { - unsubscribe(); - } - - return { sessionId: this.piSession.sessionId, finalText, usage, timeline }; } async startTurn( @@ -322,13 +322,19 @@ export class PaseoAgentSession implements AgentSession { void this.piSession .prompt(payload.text, payload.images ? { images: payload.images } : undefined) .catch((error: unknown) => { - const failedTurnId = this.activeTurnId ?? turnId; - this.activeTurnId = null; + if (this.activeTurnId !== turnId) { + return; + } + if (isAbortError(error)) { + this.emitActiveTurnCanceled(errorToMessage(error)); + return; + } + const failedTurnId = this.clearActiveTurn() ?? turnId; this.emit({ type: "turn_failed", provider: PASEO_AGENT_PROVIDER, turnId: failedTurnId, - error: error instanceof Error ? error.message : String(error), + error: errorToMessage(error), }); }); @@ -426,20 +432,23 @@ export class PaseoAgentSession implements AgentSession { model: model ? `${model.provider}/${model.id}` : null, thinkingOptionId: normalizeThinkingLevel(this.lastThinkingOptionId) ?? this.piSession.thinkingLevel ?? null, - modeId: null, + modeId: this.agentId, }; } async getAvailableModes(): Promise { - return []; + return this.availableAgents; } async getCurrentMode(): Promise { - return null; + return this.agentId; } - async setMode(_modeId: string): Promise { - throw new Error("Paseo Agent does not expose selectable modes"); + async setMode(modeId: string): Promise { + if (modeId === this.agentId) { + return; + } + throw new Error("Paseo Agent selection is fixed when the session starts"); } getPendingPermissions(): AgentPermissionRequest[] { @@ -456,12 +465,28 @@ export class PaseoAgentSession implements AgentSession { } async interrupt(): Promise { - await this.piSession.abort(); + const canceled = this.emitActiveTurnCanceled("interrupted"); + try { + await this.piSession.abort(); + } catch (error) { + if (!canceled || !isAbortError(error)) { + throw error; + } + } } async close(): Promise { - this.piSession.dispose(); - await this.mcpBridge.close(); + this.closePromise ??= (async () => { + try { + if (this.activeTurnId) { + await this.interrupt(); + } + } finally { + this.piSession.dispose(); + await this.mcpBridge.close(); + } + })(); + await this.closePromise; } async listCommands(): Promise { @@ -517,17 +542,18 @@ export class PaseoAgentClient implements AgentClient { ); } - const profile = this.loadDefaultProfile(); - this.verifyExpectedMcpServers(profile?.expectedMcpServers ?? [], config.mcpServers); + const availableAgents = this.loadAvailableAgentModes(); + const agent = this.loadSelectedAgent(config.modeId); + this.verifyExpectedMcpServers(agent, config.mcpServers); const model = resolvePaseoAgentModel( this.config, config.model, inferenceProviders, - profile?.model, + agent?.model, ); const thinkingLevel = normalizeThinkingLevel(config.thinkingOptionId) ?? undefined; const composedPrompt = composePromptParts({ - profile, + agent, systemPrompt: config.systemPrompt, daemonAppendSystemPrompt: config.daemonAppendSystemPrompt, }); @@ -535,7 +561,7 @@ export class PaseoAgentClient implements AgentClient { { provider: PASEO_AGENT_PROVIDER, model: model ? `${model.provider}/${model.id}` : null, - promptProfile: profile?.id ?? null, + agent: agent?.id ?? null, }, "Creating Paseo Agent session", ); @@ -548,6 +574,7 @@ export class PaseoAgentClient implements AgentClient { : undefined; // Bridge Paseo-injected MCP servers (e.g. the `paseo` HTTP server) into Pi tools. + const permissionPolicy = createToolPermissionPolicy(agent?.permissions); const mcpBridge = await createMcpToolBridge({ mcpServers: config.mcpServers, logger: this.logger, @@ -562,9 +589,11 @@ export class PaseoAgentClient implements AgentClient { ...(thinkingLevel ? { thinkingLevel } : {}), ...(authStorage ? { authStorage } : {}), ...(mcpBridge.tools.length > 0 ? { customTools: mcpBridge.tools } : {}), + ...(agent?.tools ? { tools: agent.tools } : {}), + permissionPolicy, ...(composedPrompt ? { composedPrompt } : {}), }); - return new PaseoAgentSession(handle, config, mcpBridge); + return new PaseoAgentSession(handle, config, mcpBridge, agent?.id ?? null, availableAgents); } catch (error) { await mcpBridge.close(); throw error; @@ -579,6 +608,10 @@ export class PaseoAgentClient implements AgentClient { return listPaseoAgentModels(this.config); } + async listModes(_options: ListModesOptions): Promise { + return this.loadAvailableAgentModes(); + } + async isAvailable(): Promise { const env = envForPaseoHome(this.paseoHome); return paseoAgentHasUsableModel(this.config, env, (providerInstance) => @@ -586,45 +619,81 @@ export class PaseoAgentClient implements AgentClient { ); } - private loadDefaultProfile() { - if (!this.paseoHome || !this.config.defaultProfile) { + private loadSelectedAgent(requestedAgentId: string | undefined): ResolvedAgentDefinition | null { + const selectedAgentId = + requestedAgentId ?? this.config.defaultAgent ?? this.config.defaultProfile; + if (!this.paseoHome || !selectedAgentId) { return null; } try { - const profile = loadPromptProfile(this.paseoHome, this.config.defaultProfile); - if (!profile) { + const agent = loadAgentDefinition(this.paseoHome, selectedAgentId); + if (!agent) { this.logger.warn( - { provider: PASEO_AGENT_PROVIDER, promptProfile: this.config.defaultProfile }, - "Configured Paseo Agent prompt profile was not found", + { provider: PASEO_AGENT_PROVIDER, agent: selectedAgentId }, + "Configured Paseo Agent definition was not found", ); } - return profile; + return agent; } catch (error) { this.logger.warn( { provider: PASEO_AGENT_PROVIDER, - promptProfile: this.config.defaultProfile, + agent: selectedAgentId, error: error instanceof Error ? error.message : String(error), }, - "Configured Paseo Agent prompt profile could not be loaded", + "Configured Paseo Agent definition could not be loaded", ); return null; } } + private loadAvailableAgentModes(): AgentMode[] { + const paseoHome = this.paseoHome; + if (!paseoHome) { + return []; + } + return listAgentDefinitionIds(paseoHome).flatMap((agentId): AgentMode[] => { + try { + const agent = loadAgentDefinition(paseoHome, agentId); + if (!agent) { + return []; + } + return [ + { + id: agent.id, + label: agent.frontmatter.name ?? agent.id, + ...(agent.frontmatter.description + ? { description: agent.frontmatter.description } + : {}), + }, + ]; + } catch (error) { + this.logger.warn( + { + provider: PASEO_AGENT_PROVIDER, + agent: agentId, + error: error instanceof Error ? error.message : String(error), + }, + "Paseo Agent definition could not be listed", + ); + return []; + } + }); + } + private verifyExpectedMcpServers( - expectedServers: string[], + agent: ResolvedAgentDefinition | null, configuredServers: AgentSessionConfig["mcpServers"], ): void { - for (const serverName of new Set(expectedServers)) { + for (const serverName of new Set(agent?.expectedMcpServers ?? [])) { if (!configuredServers?.[serverName]) { this.logger.warn( { provider: PASEO_AGENT_PROVIDER, - promptProfile: this.config.defaultProfile, + agent: agent?.id ?? null, mcpServer: serverName, }, - "Paseo Agent prompt profile expects an MCP server that is not configured for this session", + "Paseo Agent definition expects an MCP server that is not configured for this session", ); } } diff --git a/packages/server/src/server/agent/providers/paseo-agent/config-service.test.ts b/packages/server/src/server/agent/providers/paseo-agent/config-service.test.ts index e160e65524..6132dca0d0 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/config-service.test.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/config-service.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; @@ -171,7 +171,25 @@ describe("PaseoAgentConfigService", () => { models: [{ id: "anthropic/claude-3.7-sonnet" }], }, }); - service.setDefaultModel("openrouter-main/anthropic/claude-3.7-sonnet"); + writeFileSync( + join(home, "config.json"), + JSON.stringify({ + agents: { + paseo: { + defaultModel: "openrouter-main/anthropic/claude-3.7-sonnet", + providers: { + "openrouter-main": { + type: "openrouter", + options: { + apiKey: "sk-secret-openrouter", + models: [{ id: "anthropic/claude-3.7-sonnet" }], + }, + }, + }, + }, + }, + }), + ); expect(service.removeProvider("openrouter-main")).toBe(true); expect(service.getProviders()).toEqual({ defaultModel: null, providers: [] }); diff --git a/packages/server/src/server/agent/providers/paseo-agent/config-service.ts b/packages/server/src/server/agent/providers/paseo-agent/config-service.ts index d79735df61..acd3d41a12 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/config-service.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/config-service.ts @@ -227,16 +227,6 @@ export class PaseoAgentConfigService { return removed; } - setDefaultModel(model: string | null): string | null { - const next = this.updateConfig((current) => - PaseoAgentConfigSchema.parse({ - ...current, - ...(model ? { defaultModel: model } : { defaultModel: undefined }), - }), - ); - return next.defaultModel ?? null; - } - storeChatGptCredential(providerName: string, credential: PaseoAgentOAuthCredential): void { storeCodexOAuthCredential({ providerInstance: providerName, diff --git a/packages/server/src/server/agent/providers/paseo-agent/config.test.ts b/packages/server/src/server/agent/providers/paseo-agent/config.test.ts index 6b580900a6..deee44070c 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/config.test.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/config.test.ts @@ -216,12 +216,18 @@ describe("paseoAgentHasUsableModel (env-aware auth)", () => { }); describe("resolvePaseoAgentModel", () => { - it("prefers the explicit request, then default, then first configured", () => { + it("prefers the explicit request, then agent model, then default, then first configured", () => { const config = configWith({ defaultModel: "openrouter-main/openai/gpt" }); expect(resolvePaseoAgentModel(config, "openrouter-main/anthropic/claude")).toEqual({ provider: "openrouter-main", id: "anthropic/claude", }); + expect( + resolvePaseoAgentModel(config, null, undefined, "openrouter-main/anthropic/claude"), + ).toEqual({ + provider: "openrouter-main", + id: "anthropic/claude", + }); expect(resolvePaseoAgentModel(config, null)).toEqual({ provider: "openrouter-main", id: "openai/gpt", diff --git a/packages/server/src/server/agent/providers/paseo-agent/config.ts b/packages/server/src/server/agent/providers/paseo-agent/config.ts index a6b6da310f..d6b7f69320 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/config.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/config.ts @@ -163,7 +163,9 @@ export const PaseoAgentConfigSchema = z .object({ // Optional default model as "/". defaultModel: z.string().min(1).optional(), - // Optional default prompt profile from $PASEO_HOME/agents/.md. + // Optional default agent definition from $PASEO_HOME/agents/.md. + defaultAgent: z.string().min(1).optional(), + // Legacy alias for defaultAgent. defaultProfile: z.string().min(1).optional(), // Inference providers keyed by instance name. Multiple entries may share a // type while pointing at different APIs/base URLs/models. @@ -357,13 +359,13 @@ export function resolvePaseoAgentModel( config: PaseoAgentConfig, requestedModelId: string | null | undefined, registeredProviders: PaseoAgentInferenceProvider[] = paseoAgentInferenceProviders(config), - profileDefaultModelId?: string | null, + agentDefaultModelId?: string | null, ): PaseoAgentModelReference | undefined { if (requestedModelId) { return parsePaseoAgentModelId(requestedModelId) ?? undefined; } - for (const candidate of [config.defaultModel, profileDefaultModelId, firstModelId(config)]) { + for (const candidate of [agentDefaultModelId, config.defaultModel, firstModelId(config)]) { if (!candidate) { continue; } diff --git a/packages/server/src/server/agent/providers/paseo-agent/pi-services.test.ts b/packages/server/src/server/agent/providers/paseo-agent/pi-services.test.ts index 628c98ad66..6d3bdefb32 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/pi-services.test.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/pi-services.test.ts @@ -2,7 +2,9 @@ import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { AuthStorage } from "@earendil-works/pi-coding-agent"; +import type { BeforeToolCallContext } from "@earendil-works/pi-agent-core"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createToolPermissionPolicy } from "./agent-permissions.js"; import { type CreatePaseoAgentSessionOptions, @@ -35,6 +37,15 @@ function codexInferenceProvider(): PaseoAgentInferenceProvider { const FAKE_PROVIDER = "paseo-test-openrouter"; const FAKE_MODEL_ID = "test-model"; +function toolCallContext(toolName: string): BeforeToolCallContext { + return { + assistantMessage: { role: "assistant", content: [] }, + toolCall: { type: "toolCall", id: "call-1", name: toolName, arguments: {} }, + args: {}, + context: {}, + } as BeforeToolCallContext; +} + function fakeInferenceProvider(): PaseoAgentInferenceProvider { return { name: FAKE_PROVIDER, @@ -180,6 +191,75 @@ describe("createPaseoAgentSession (no-discovery spike)", () => { expect(active).toContain("bash"); }); + it("honors an explicit agent tool allowlist", async () => { + const { session } = await createPaseoAgentSession({ + ...baseOptions(), + tools: ["read", "paseo__demo"], + customTools: [ + { + name: "paseo__demo", + label: "demo", + description: "demo tool", + parameters: { type: "object" } as never, + async execute() { + return { content: [{ type: "text", text: "ok" }], details: null }; + }, + }, + ], + }); + + expect(session.getActiveToolNames().sort()).toEqual(["paseo__demo", "read"]); + }); + + it("blocks a denied built-in tool through Pi's preflight hook", async () => { + const { session } = await createPaseoAgentSession({ + ...baseOptions(), + tools: ["bash"], + permissionPolicy: createToolPermissionPolicy([{ tool: "bash", action: "deny" }]), + }); + + expect(session.getActiveToolNames()).toEqual(["bash"]); + await expect(session.agent.beforeToolCall?.(toolCallContext("bash"))).resolves.toEqual({ + block: true, + reason: 'Paseo Agent denied tool "bash" by agent permissions.', + }); + }); + + it("allows unmatched built-in tools to fall through the existing Pi hook", async () => { + const { session } = await createPaseoAgentSession({ + ...baseOptions(), + tools: ["bash"], + permissionPolicy: createToolPermissionPolicy([{ tool: "read", action: "deny" }]), + }); + + await expect(session.agent.beforeToolCall?.(toolCallContext("bash"))).resolves.toBeUndefined(); + }); + + it("blocks a denied custom tool through the same Pi preflight hook", async () => { + const { session } = await createPaseoAgentSession({ + ...baseOptions(), + tools: ["paseo__demo"], + customTools: [ + { + name: "paseo__demo", + label: "demo", + description: "demo tool", + parameters: { type: "object" } as never, + async execute() { + return { content: [{ type: "text", text: "ok" }], details: null }; + }, + }, + ], + permissionPolicy: createToolPermissionPolicy([{ tool: "paseo__*", action: "deny" }]), + }); + + expect(session.getActiveToolNames()).toEqual(["paseo__demo"]); + await expect(session.agent.beforeToolCall?.(toolCallContext("paseo__demo"))).resolves.toEqual({ + block: true, + reason: 'Paseo Agent denied tool "paseo__demo" by agent permissions.', + }); + }); + it("registers a codex provider and seeds the advanced refresh-token override", async () => { const codex = codexInferenceProvider(); const { session, modelRegistry } = await createPaseoAgentSession({ diff --git a/packages/server/src/server/agent/providers/paseo-agent/pi-services.ts b/packages/server/src/server/agent/providers/paseo-agent/pi-services.ts index 2bb96eeac3..0f55835fd7 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/pi-services.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/pi-services.ts @@ -9,9 +9,10 @@ import { type ToolDefinition, createAgentSession, } from "@earendil-works/pi-coding-agent"; -import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; +import type { BeforeToolCallResult, ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { ImageContent, TextContent } from "@earendil-works/pi-ai"; import { openaiCodexOAuthProvider } from "@earendil-works/pi-ai/oauth"; +import { evaluateToolPermission, type ToolPermissionPolicy } from "./agent-permissions.js"; import type { PaseoComposedPrompt } from "./prompt-profiles.js"; // Re-export the Pi tool contract so the MCP bridge can build custom tools without @@ -87,7 +88,11 @@ export interface CreatePaseoAgentSessionOptions { settings?: PiSettings; /** Paseo-bridged tools (e.g. MCP) to register alongside built-in tools. */ customTools?: ToolDefinition[]; - /** Paseo-composed prompt profile/session/daemon instructions. */ + /** Optional allowlist of active Pi tool names for this agent definition. */ + tools?: string[]; + /** Runtime allow/deny policy for every Pi tool call. */ + permissionPolicy?: ToolPermissionPolicy; + /** Paseo-composed agent/session/daemon instructions. */ composedPrompt?: PaseoComposedPrompt; } @@ -144,6 +149,30 @@ function wrapPromptResourceLoader( }; } +function installPermissionPolicy( + session: PiAgentSession, + permissionPolicy: ToolPermissionPolicy | undefined, +): void { + if (!permissionPolicy || permissionPolicy.rules.length === 0) { + return; + } + + const previousBeforeToolCall = session.agent.beforeToolCall; + session.agent.beforeToolCall = async ( + context, + signal, + ): Promise => { + const toolName = context.toolCall.name; + if (evaluateToolPermission(permissionPolicy, toolName) === "deny") { + return { + block: true, + reason: `Paseo Agent denied tool "${toolName}" by agent permissions.`, + }; + } + return previousBeforeToolCall?.(context, signal); + }; +} + /** * Create a Pi agent session through the high-level `createAgentSession` API with * every service supplied in-memory and no Pi config discovery. @@ -208,14 +237,17 @@ export async function createPaseoAgentSession( ...(model ? { model } : {}), ...(options.thinkingLevel ? { thinkingLevel: options.thinkingLevel } : {}), ...(options.customTools ? { customTools: options.customTools } : {}), + ...(options.tools ? { tools: options.tools } : {}), }); // Custom (MCP) tools are registered but not active by default — only the built-in - // tool set is. Activate them so the model can actually call them. - if (options.customTools && options.customTools.length > 0) { + // tool set is. Activate them unless an agent definition supplied an explicit tool allowlist. + if (!options.tools && options.customTools && options.customTools.length > 0) { const customToolNames = options.customTools.map((tool) => tool.name); session.setActiveToolsByName([...session.getActiveToolNames(), ...customToolNames]); } + installPermissionPolicy(session, options.permissionPolicy); + return { session, modelRegistry, resourceLoader, sessionManager }; } diff --git a/packages/server/src/server/agent/providers/paseo-agent/prompt-profiles.test.ts b/packages/server/src/server/agent/providers/paseo-agent/prompt-profiles.test.ts index 47106e136d..1b042010a5 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/prompt-profiles.test.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/prompt-profiles.test.ts @@ -3,9 +3,13 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { composePromptParts, listPromptProfileIds, loadPromptProfile } from "./prompt-profiles.js"; +import { + composePromptParts, + listAgentDefinitionIds, + loadAgentDefinition, +} from "./prompt-profiles.js"; -describe("Paseo Agent prompt profiles", () => { +describe("Paseo Agent definitions", () => { let paseoHome: string; let agentsDir: string; const tempDirs: string[] = []; @@ -23,145 +27,149 @@ describe("Paseo Agent prompt profiles", () => { } }); - function writeProfile(name: string, content: string): void { + function writeAgent(name: string, content: string): void { writeFileSync(join(agentsDir, name), content); } - it("parses frontmatter and lists only top-level markdown profiles", () => { - writeProfile( + it("parses frontmatter and lists only top-level markdown agents", () => { + writeAgent( "orchestrator.md", `--- name: Orchestrator description: Routes work -mode: override +prompt: override mcp: [paseo] model: openrouter-main/test-model +tools: [read, paseo__list_agents] +permissions: + - tool: paseo__archive_agent + action: deny projectContext: true --- -Profile body. +Agent body. `, ); - writeProfile("notes.txt", "ignored"); + writeAgent("notes.txt", "ignored"); writeFileSync(join(agentsDir, "fragments", "piece.md"), "fragment"); - const profile = loadPromptProfile(paseoHome, "orchestrator"); + const agent = loadAgentDefinition(paseoHome, "orchestrator"); - expect(listPromptProfileIds(paseoHome)).toEqual(["orchestrator"]); - expect(profile?.frontmatter).toMatchObject({ + expect(listAgentDefinitionIds(paseoHome)).toEqual(["orchestrator"]); + expect(agent?.frontmatter).toMatchObject({ name: "Orchestrator", description: "Routes work", - mode: "override", + prompt: "override", mcp: ["paseo"], model: "openrouter-main/test-model", + tools: ["read", "paseo__list_agents"], + permissions: [{ tool: "paseo__archive_agent", action: "deny" }], projectContext: true, }); - expect(profile?.composedPrompt.customPrompt).toBe("Profile body."); + expect(agent?.composedPrompt.customPrompt).toBe("Agent body."); }); - it("defaults to extend mode and prepends frontmatter includes in order", () => { - writeFileSync(join(agentsDir, "fragments", "a.md"), "Fragment A"); - writeFileSync(join(agentsDir, "fragments", "b.md"), "Fragment B"); - writeProfile( - "worker.md", - `--- -include: - - fragments/a.md - - fragments/b.md ---- -Body. -`, + it("defaults to extend prompt mode", () => { + writeAgent("worker.md", "Body."); + + const agent = loadAgentDefinition(paseoHome, "worker.md"); + + expect(agent?.frontmatter.prompt).toBe("extend"); + expect(agent?.body).toBe("Body."); + expect(agent?.composedPrompt.appendSystemPrompt).toEqual(["Body."]); + }); + + it("resolves bang-brace partials in place relative to the current file", () => { + mkdirSync(join(agentsDir, "team", "partials"), { recursive: true }); + writeFileSync(join(agentsDir, "team", "partials", "style.md"), "Use short answers."); + writeFileSync(join(agentsDir, "team", "nested.md"), "nested !{{./partials/style.md}}"); + writeAgent("inline.md", "Before\n!{{./team/nested.md}}\nAfter"); + + expect(loadAgentDefinition(paseoHome, "inline")?.body).toBe( + "Before\nnested Use short answers.\nAfter", ); - - const profile = loadPromptProfile(paseoHome, "worker.md"); - - expect(profile?.frontmatter.mode).toBe("extend"); - expect(profile?.body).toBe("Fragment A\n\nFragment B\n\nBody."); - expect(profile?.composedPrompt.appendSystemPrompt).toEqual([ - "Fragment A\n\nFragment B\n\nBody.", - ]); }); - it("resolves inline includes in place", () => { - writeFileSync(join(agentsDir, "fragments", "style.md"), "Use short answers."); - writeProfile("inline.md", "Before\n{{include: fragments/style.md}}\nAfter"); + it("rejects frontmatter inside partials", () => { + writeFileSync(join(agentsDir, "fragments", "bad.md"), "---\nname: Nope\n---\nfragment"); + writeAgent("agent.md", "!{{./fragments/bad.md}}"); - expect(loadPromptProfile(paseoHome, "inline")?.body).toBe("Before\nUse short answers.\nAfter"); + expect(() => loadAgentDefinition(paseoHome, "agent")).toThrow(/partials cannot declare/i); }); - it("detects include cycles", () => { - writeFileSync(join(agentsDir, "fragments", "a.md"), "{{include: fragments/b.md}}"); - writeFileSync(join(agentsDir, "fragments", "b.md"), "{{include: fragments/a.md}}"); - writeProfile("cycle.md", "{{include: fragments/a.md}}"); + it("detects partial cycles", () => { + writeFileSync(join(agentsDir, "fragments", "a.md"), "!{{./b.md}}"); + writeFileSync(join(agentsDir, "fragments", "b.md"), "!{{./a.md}}"); + writeAgent("cycle.md", "!{{./fragments/a.md}}"); - expect(() => loadPromptProfile(paseoHome, "cycle")).toThrow(/cycle/i); + expect(() => loadAgentDefinition(paseoHome, "cycle")).toThrow(/cycle/i); }); - it("rejects missing fragments and path escapes", () => { - writeProfile("missing.md", "{{include: fragments/nope.md}}"); - writeProfile("escape.md", "{{include: ../secret.md}}"); + it("rejects missing partials and path escapes", () => { + writeAgent("missing.md", "!{{./fragments/nope.md}}"); + writeAgent("escape.md", "!{{../secret.md}}"); - expect(() => loadPromptProfile(paseoHome, "missing")).toThrow(/not found/i); - expect(() => loadPromptProfile(paseoHome, "escape")).toThrow(/escape|invalid/i); - expect(() => loadPromptProfile(paseoHome, "../escape")).toThrow(/invalid/i); + expect(() => loadAgentDefinition(paseoHome, "missing")).toThrow(/not found/i); + expect(() => loadAgentDefinition(paseoHome, "escape")).toThrow(/escape|invalid/i); + expect(() => loadAgentDefinition(paseoHome, "../escape")).toThrow(/invalid/i); }); - it("rejects symlink escapes for profiles and includes", () => { + it("rejects symlink escapes for agents and partials", () => { const outsideDir = mkdtempSync(join(tmpdir(), "paseo-agent-profile-outside-")); tempDirs.push(outsideDir); writeFileSync(join(outsideDir, "secret.md"), "outside secret"); symlinkSync(join(outsideDir, "secret.md"), join(agentsDir, "linked-profile.md")); symlinkSync(join(outsideDir, "secret.md"), join(agentsDir, "fragments", "linked.md")); - writeProfile("include-link.md", "{{include: fragments/linked.md}}"); + writeAgent("include-link.md", "!{{./fragments/linked.md}}"); - expect(() => loadPromptProfile(paseoHome, "linked-profile")).toThrow(/escapes/i); - expect(() => loadPromptProfile(paseoHome, "include-link")).toThrow(/escapes/i); + expect(() => loadAgentDefinition(paseoHome, "linked-profile")).toThrow(/escapes/i); + expect(() => loadAgentDefinition(paseoHome, "include-link")).toThrow(/escapes/i); }); it("enforces depth and total size caps", () => { - writeFileSync(join(agentsDir, "fragments", "deep.md"), "{{include: fragments/deeper.md}}"); + writeFileSync(join(agentsDir, "fragments", "deep.md"), "!{{./deeper.md}}"); writeFileSync(join(agentsDir, "fragments", "deeper.md"), "done"); - writeProfile("depth.md", "{{include: fragments/deep.md}}"); - writeProfile("large.md", "0123456789"); + writeAgent("depth.md", "!{{./fragments/deep.md}}"); + writeAgent("large.md", "0123456789"); - expect(() => loadPromptProfile(paseoHome, "depth", { maxDepth: 1 })).toThrow(/depth/i); - expect(() => loadPromptProfile(paseoHome, "large", { maxTotalBytes: 4 })).toThrow(/bytes/i); + expect(() => loadAgentDefinition(paseoHome, "depth", { maxDepth: 1 })).toThrow(/depth/i); + expect(() => loadAgentDefinition(paseoHome, "large", { maxTotalBytes: 4 })).toThrow(/bytes/i); }); - it("orders profile append, session prompt, and daemon append with daemon last", () => { - writeProfile("extend.md", "Profile prompt."); - const profile = loadPromptProfile(paseoHome, "extend"); + it("orders agent append, session prompt, and daemon append with daemon last", () => { + writeAgent("extend.md", "Agent prompt."); + const agent = loadAgentDefinition(paseoHome, "extend"); expect( composePromptParts({ - profile, - systemPrompt: " Agent prompt. ", + agent, + systemPrompt: " Session prompt. ", daemonAppendSystemPrompt: "Daemon prompt.", }), ).toEqual({ - appendSystemPrompt: ["Profile prompt.", "Agent prompt.", "Daemon prompt."], + appendSystemPrompt: ["Agent prompt.", "Session prompt.", "Daemon prompt."], }); }); - it("uses override profile body as custom prompt while appending session and daemon prompts", () => { - writeProfile( + it("uses override agent body as custom prompt while appending session and daemon prompts", () => { + writeAgent( "override.md", `--- -mode: override +prompt: override --- Replacement base. `, ); - const profile = loadPromptProfile(paseoHome, "override"); + const agent = loadAgentDefinition(paseoHome, "override"); expect( composePromptParts({ - profile, - systemPrompt: "Agent prompt.", + agent, + systemPrompt: "Session prompt.", daemonAppendSystemPrompt: "Daemon prompt.", }), ).toEqual({ customPrompt: "Replacement base.", - appendSystemPrompt: ["Agent prompt.", "Daemon prompt."], + appendSystemPrompt: ["Session prompt.", "Daemon prompt."], }); }); }); diff --git a/packages/server/src/server/agent/providers/paseo-agent/prompt-profiles.ts b/packages/server/src/server/agent/providers/paseo-agent/prompt-profiles.ts index 49963a0f9e..63c1ce5302 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/prompt-profiles.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/prompt-profiles.ts @@ -1,44 +1,51 @@ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs"; -import { basename, extname, isAbsolute, relative, resolve } from "node:path"; +import { basename, dirname, extname, isAbsolute, relative, resolve } from "node:path"; import { parse as parseYaml } from "yaml"; import { z } from "zod"; +import { ToolPermissionRuleSchema, type ToolPermissionRule } from "./agent-permissions.js"; const DEFAULT_MAX_DEPTH = 8; const DEFAULT_MAX_TOTAL_BYTES = 256 * 1024; -const INLINE_INCLUDE_PATTERN = /\{\{\s*include:\s*([^}]+?)\s*\}\}/g; +const PARTIAL_PATTERN = /!\{\{\s*([^}]+?)\s*\}\}/g; -const PromptProfileFrontmatterSchema = z +const AgentDefinitionFrontmatterSchema = z .object({ name: z.string().min(1).optional(), description: z.string().min(1).optional(), - mode: z.enum(["extend", "override"]).default("extend"), - include: z.array(z.string().min(1)).optional(), + prompt: z.enum(["extend", "override"]).default("extend"), mcp: z.array(z.string().min(1)).optional(), model: z.string().min(1).optional(), + tools: z.array(z.string().min(1)).optional(), + permissions: z.array(ToolPermissionRuleSchema).optional(), // Parsed for the future explicit project-context model. It is intentionally // inactive here; Paseo Agent still keeps implicit AGENTS.md discovery off. projectContext: z.boolean().optional(), }) - .strict(); + .passthrough(); -export type PromptProfileFrontmatter = z.infer; +export type AgentDefinitionFrontmatter = z.infer; +export type PromptProfileFrontmatter = AgentDefinitionFrontmatter; export interface PaseoComposedPrompt { customPrompt?: string; appendSystemPrompt: string[]; } -export interface ResolvedPromptProfile { +export interface ResolvedAgentDefinition { id: string; path: string; - frontmatter: PromptProfileFrontmatter; + frontmatter: AgentDefinitionFrontmatter; body: string; composedPrompt: PaseoComposedPrompt; expectedMcpServers: string[]; model?: string; + tools?: string[]; + permissions: ToolPermissionRule[]; } -interface LoadPromptProfileOptions { +export type ResolvedPromptProfile = ResolvedAgentDefinition; + +interface LoadAgentDefinitionOptions { maxDepth?: number; maxTotalBytes?: number; } @@ -48,53 +55,64 @@ interface LoadState { } interface ParsedMarkdown { - frontmatter: PromptProfileFrontmatter; + frontmatter: AgentDefinitionFrontmatter; body: string; } -export function loadPromptProfile( +export function loadAgentDefinition( paseoHome: string, - profileName: string | undefined, - options: LoadPromptProfileOptions = {}, -): ResolvedPromptProfile | null { - if (!profileName) { + agentName: string | undefined, + options: LoadAgentDefinitionOptions = {}, +): ResolvedAgentDefinition | null { + if (!agentName) { return null; } const agentsDir = resolve(paseoHome, "agents"); - const profilePath = resolveProfilePath(agentsDir, profileName); - if (!existsSync(profilePath)) { + const agentPath = resolveAgentPath(agentsDir, agentName); + if (!existsSync(agentPath)) { return null; } const state: LoadState = { totalBytes: 0 }; - const parsed = loadMarkdownWithIncludes({ + const parsed = loadMarkdownWithPartials({ agentsDir, - path: profilePath, + path: agentPath, depth: 0, stack: [], state, options, + allowFrontmatter: true, }); const body = trimPrompt(parsed.body); - const mode = parsed.frontmatter.mode; + const promptMode = parsed.frontmatter.prompt; const composedPrompt = - mode === "override" + promptMode === "override" ? { customPrompt: body, appendSystemPrompt: [] } : { appendSystemPrompt: body ? [body] : [] }; return { - id: basename(profilePath, ".md"), - path: profilePath, + id: basename(agentPath, ".md"), + path: agentPath, frontmatter: parsed.frontmatter, body, composedPrompt, expectedMcpServers: parsed.frontmatter.mcp ?? [], ...(parsed.frontmatter.model ? { model: parsed.frontmatter.model } : {}), + ...(parsed.frontmatter.tools ? { tools: parsed.frontmatter.tools } : {}), + permissions: parsed.frontmatter.permissions ?? [], }; } -export function listPromptProfileIds(paseoHome: string): string[] { +export function loadPromptProfile( + paseoHome: string, + profileName: string | undefined, + options: LoadAgentDefinitionOptions = {}, +): ResolvedPromptProfile | null { + return loadAgentDefinition(paseoHome, profileName, options); +} + +export function listAgentDefinitionIds(paseoHome: string): string[] { const agentsDir = resolve(paseoHome, "agents"); if (!existsSync(agentsDir)) { return []; @@ -105,19 +123,23 @@ export function listPromptProfileIds(paseoHome: string): string[] { .sort(); } -function resolveProfilePath(agentsDir: string, profileName: string): string { - if (!isSafeRelativePath(profileName) || profileName.includes("/") || profileName.includes("\\")) { - throw new Error(`Invalid Paseo Agent prompt profile path: ${profileName}`); +export function listPromptProfileIds(paseoHome: string): string[] { + return listAgentDefinitionIds(paseoHome); +} + +function resolveAgentPath(agentsDir: string, agentName: string): string { + if (!isSafeRelativePath(agentName) || agentName.includes("/") || agentName.includes("\\")) { + throw new Error(`Invalid Paseo Agent definition path: ${agentName}`); } - const filename = profileName.endsWith(".md") ? profileName : `${profileName}.md`; + const filename = agentName.endsWith(".md") ? agentName : `${agentName}.md`; return resolveConfinedPath(agentsDir, filename); } -function resolveIncludePath(agentsDir: string, includePath: string): string { - if (!isSafeRelativePath(includePath)) { - throw new Error(`Invalid Paseo Agent prompt include path: ${includePath}`); +function resolvePartialPath(agentsDir: string, currentPath: string, partialPath: string): string { + if (!isSafeRelativePath(partialPath)) { + throw new Error(`Invalid Paseo Agent partial path: ${partialPath}`); } - return resolveConfinedPath(agentsDir, includePath); + return resolveConfinedPath(agentsDir, resolve(dirname(currentPath), partialPath)); } function isSafeRelativePath(input: string): boolean { @@ -125,71 +147,63 @@ function isSafeRelativePath(input: string): boolean { } function resolveConfinedPath(agentsDir: string, input: string): string { - const resolved = resolve(agentsDir, input); - const rel = relative(agentsDir, resolved); + const realAgentsDir = existsSync(agentsDir) ? realpathSync(agentsDir) : agentsDir; + const resolved = isAbsolute(input) ? input : resolve(agentsDir, input); + const comparablePath = existsSync(resolved) ? realpathSync(resolved) : resolved; + const rel = relative(realAgentsDir, comparablePath); if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) { - throw new Error(`Paseo Agent prompt path escapes agents directory: ${input}`); + throw new Error(`Paseo Agent path escapes agents directory: ${input}`); } return resolved; } -function loadMarkdownWithIncludes(input: { +function loadMarkdownWithPartials(input: { agentsDir: string; path: string; depth: number; stack: string[]; state: LoadState; - options: LoadPromptProfileOptions; + options: LoadAgentDefinitionOptions; + allowFrontmatter: boolean; }): ParsedMarkdown { const maxDepth = input.options.maxDepth ?? DEFAULT_MAX_DEPTH; if (input.depth > maxDepth) { throw new Error(`Paseo Agent prompt include depth exceeds ${maxDepth}`); } if (!existsSync(input.path) || !statSync(input.path).isFile()) { - throw new Error( - `Paseo Agent prompt include not found: ${relative(input.agentsDir, input.path)}`, - ); + throw new Error(`Paseo Agent partial not found: ${relative(input.agentsDir, input.path)}`); } const path = realpathConfined(input.agentsDir, input.path); if (input.stack.includes(path)) { const cycle = [...input.stack, path].map((entry) => relative(input.agentsDir, entry)); - throw new Error(`Paseo Agent prompt include cycle: ${cycle.join(" -> ")}`); + throw new Error(`Paseo Agent partial cycle: ${cycle.join(" -> ")}`); } const raw = readFileSync(path, "utf8"); input.state.totalBytes += Buffer.byteLength(raw, "utf8"); const maxTotalBytes = input.options.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES; if (input.state.totalBytes > maxTotalBytes) { - throw new Error(`Paseo Agent prompt profile exceeds ${maxTotalBytes} bytes`); + throw new Error(`Paseo Agent definition exceeds ${maxTotalBytes} bytes`); } - const parsed = parseMarkdown(raw); + const parsed = parseMarkdown(raw, input.allowFrontmatter); const stack = [...input.stack, path]; - const frontmatterIncludes = parsed.frontmatter.include ?? []; - const prepended = frontmatterIncludes.map( - (includePath) => - loadMarkdownWithIncludes({ + const bodyWithPartials = parsed.body.replace( + PARTIAL_PATTERN, + (_match, partialPath: string) => + loadMarkdownWithPartials({ ...input, - path: resolveIncludePath(input.agentsDir, includePath), - depth: input.depth + 1, - stack, - }).body, - ); - const bodyWithInlineIncludes = parsed.body.replace( - INLINE_INCLUDE_PATTERN, - (_match, includePath: string) => - loadMarkdownWithIncludes({ - ...input, - path: resolveIncludePath(input.agentsDir, includePath.trim()), + path: resolvePartialPath(input.agentsDir, path, partialPath.trim()), depth: input.depth + 1, stack, + allowFrontmatter: false, }).body, ); return { frontmatter: parsed.frontmatter, - body: joinPromptParts([...prepended, bodyWithInlineIncludes]), + body: bodyWithPartials, }; } @@ -198,45 +212,47 @@ function realpathConfined(agentsDir: string, path: string): string { const realPath = realpathSync(path); const rel = relative(realAgentsDir, realPath); if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) { - throw new Error( - `Paseo Agent prompt path escapes agents directory: ${relative(agentsDir, path)}`, - ); + throw new Error(`Paseo Agent path escapes agents directory: ${relative(agentsDir, path)}`); } return realPath; } -function parseMarkdown(raw: string): ParsedMarkdown { +function parseMarkdown(raw: string, allowFrontmatter: boolean): ParsedMarkdown { if (!raw.startsWith("---\n") && !raw.startsWith("---\r\n")) { return { - frontmatter: PromptProfileFrontmatterSchema.parse({}), + frontmatter: AgentDefinitionFrontmatterSchema.parse({}), body: raw, }; } + if (!allowFrontmatter) { + throw new Error("Paseo Agent partials cannot declare frontmatter"); + } + const newline = raw.startsWith("---\r\n") ? "\r\n" : "\n"; const closeMarker = `${newline}---${newline}`; const closeIndex = raw.indexOf(closeMarker, 4); if (closeIndex === -1) { - throw new Error("Paseo Agent prompt profile has unterminated frontmatter"); + throw new Error("Paseo Agent definition has unterminated frontmatter"); } const yaml = raw.slice(4, closeIndex); const body = raw.slice(closeIndex + closeMarker.length); const value = yaml.trim() ? parseYaml(yaml) : {}; return { - frontmatter: PromptProfileFrontmatterSchema.parse(value ?? {}), + frontmatter: AgentDefinitionFrontmatterSchema.parse(value ?? {}), body, }; } export function composePromptParts(input: { - profile?: ResolvedPromptProfile | null; + agent?: ResolvedAgentDefinition | null; systemPrompt?: string; daemonAppendSystemPrompt?: string; }): PaseoComposedPrompt | undefined { - const profilePrompt = input.profile?.composedPrompt; + const agentPrompt = input.agent?.composedPrompt; const appendSystemPrompt = [ - ...(profilePrompt?.appendSystemPrompt ?? []), + ...(agentPrompt?.appendSystemPrompt ?? []), input.systemPrompt, input.daemonAppendSystemPrompt, ].flatMap((part) => { @@ -244,9 +260,9 @@ export function composePromptParts(input: { return trimmed ? [trimmed] : []; }); const hasCustomPrompt = Boolean( - profilePrompt && Object.prototype.hasOwnProperty.call(profilePrompt, "customPrompt"), + agentPrompt && Object.prototype.hasOwnProperty.call(agentPrompt, "customPrompt"), ); - const customPrompt = trimPrompt(profilePrompt?.customPrompt); + const customPrompt = trimPrompt(agentPrompt?.customPrompt); if (!hasCustomPrompt && appendSystemPrompt.length === 0) { return undefined; @@ -261,7 +277,3 @@ export function composePromptParts(input: { function trimPrompt(value: string | undefined): string { return value?.trim() ?? ""; } - -function joinPromptParts(parts: string[]): string { - return parts.map(trimPrompt).filter(Boolean).join("\n\n"); -} diff --git a/packages/server/src/server/daemon-config-store.test.ts b/packages/server/src/server/daemon-config-store.test.ts index 07c172903e..1bd422bea1 100644 --- a/packages/server/src/server/daemon-config-store.test.ts +++ b/packages/server/src/server/daemon-config-store.test.ts @@ -123,7 +123,7 @@ describe("DaemonConfigStore", () => { }); }); - test("generic mutable config strips dedicated Paseo Agent config instead of echoing secrets", () => { + test("generic mutable config strips agent provider config instead of echoing secrets", () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); @@ -140,6 +140,13 @@ describe("DaemonConfigStore", () => { const secretBearingPatch: MutableDaemonConfigPatch & { agents: unknown } = { agents: { + providers: { + custom: { + env: { + API_KEY: "shared-provider-secret", + }, + }, + }, paseo: { providers: { "openrouter-main": { @@ -160,8 +167,11 @@ describe("DaemonConfigStore", () => { const broadcastJson = JSON.stringify(broadcasts); expect(returnedJson).not.toContain("sk-secret-openrouter"); expect(returnedJson).not.toContain("header-secret"); + expect(returnedJson).not.toContain("shared-provider-secret"); expect(broadcastJson).not.toContain("sk-secret-openrouter"); expect(broadcastJson).not.toContain("header-secret"); + expect(broadcastJson).not.toContain("shared-provider-secret"); + expect(loadPersistedConfig(paseoHome).agents?.providers).toBeUndefined(); expect(loadPersistedConfig(paseoHome).agents?.paseo).toBeUndefined(); }); diff --git a/packages/server/src/server/daemon-config-store.ts b/packages/server/src/server/daemon-config-store.ts index 11e28ff7f0..a0a6318517 100644 --- a/packages/server/src/server/daemon-config-store.ts +++ b/packages/server/src/server/daemon-config-store.ts @@ -62,19 +62,13 @@ function isEqualValue(a: unknown, b: unknown): boolean { return JSON.stringify(a) === JSON.stringify(b); } -function stripDedicatedPaseoAgentConfig>(config: T): T { - const agents = config.agents; - if (!isRecord(agents) || !Object.prototype.hasOwnProperty.call(agents, "paseo")) { +function stripAgentProviderSecretConfig>(config: T): T { + if (!Object.prototype.hasOwnProperty.call(config, "agents")) { return config; } - const { paseo: _paseo, ...remainingAgents } = agents; const next: Record = { ...config }; - if (Object.keys(remainingAgents).length > 0) { - next.agents = remainingAgents; - } else { - delete next.agents; - } + delete next.agents; return next as T; } @@ -107,7 +101,7 @@ export class DaemonConfigStore { constructor(paseoHome: string, initial: MutableDaemonConfig, logger?: LoggerLike) { this.paseoHome = paseoHome; this.logger = getLogger(logger); - this.current = stripDedicatedPaseoAgentConfig(MutableDaemonConfigSchema.parse(initial)); + this.current = stripAgentProviderSecretConfig(MutableDaemonConfigSchema.parse(initial)); } public get(): MutableDaemonConfig { @@ -115,10 +109,10 @@ export class DaemonConfigStore { } public patch(partial: MutableDaemonConfigPatch): MutableDaemonConfig { - const parsedPatch = stripDedicatedPaseoAgentConfig( + const parsedPatch = stripAgentProviderSecretConfig( MutableDaemonConfigPatchSchema.parse(partial), ); - const next = stripDedicatedPaseoAgentConfig( + const next = stripAgentProviderSecretConfig( MutableDaemonConfigSchema.parse(deepMerge(this.current, parsedPatch)), ); diff --git a/packages/server/src/server/persisted-config.test.ts b/packages/server/src/server/persisted-config.test.ts index 714322fd59..a6a018a4f1 100644 --- a/packages/server/src/server/persisted-config.test.ts +++ b/packages/server/src/server/persisted-config.test.ts @@ -247,6 +247,8 @@ describe("PersistedConfigSchema agent provider runtime settings", () => { agents: { paseo: { defaultModel: "openrouter-main/anthropic/claude", + defaultAgent: "builder", + defaultProfile: "orchestrator", providers: { "openrouter-main": { type: "openrouter", @@ -263,6 +265,8 @@ describe("PersistedConfigSchema agent provider runtime settings", () => { }); expect(parsed.agents?.paseo?.defaultModel).toBe("openrouter-main/anthropic/claude"); + expect(parsed.agents?.paseo?.defaultAgent).toBe("builder"); + expect(parsed.agents?.paseo?.defaultProfile).toBe("orchestrator"); expect(parsed.agents?.paseo?.providers?.["openrouter-main"]?.type).toBe("openrouter"); }); }); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 4462fb22f0..0e12b2bf65 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1659,8 +1659,6 @@ export class Session { return this.handlePaseoAgentSetProviderRequest(msg); case "config.paseo_agent.remove_provider.request": return this.handlePaseoAgentRemoveProviderRequest(msg); - case "config.paseo_agent.set_default_model.request": - return this.handlePaseoAgentSetDefaultModelRequest(msg); case "config.paseo_agent.store_chatgpt_credential.request": return this.handlePaseoAgentStoreChatGptCredentialRequest(msg); case "list_available_providers_request": @@ -1789,35 +1787,6 @@ export class Session { } } - private async handlePaseoAgentSetDefaultModelRequest( - msg: Extract, - ): Promise { - try { - const defaultModel = this.createPaseoAgentConfigService().setDefaultModel(msg.model); - await this.refreshPaseoAgentRuntimeSnapshot(); - this.emit({ - type: "config.paseo_agent.set_default_model.response", - payload: { - requestId: msg.requestId, - success: true, - defaultModel, - error: null, - }, - }); - } catch (error) { - this.sessionLogger.error({ err: error }, "Failed to set Paseo Agent default model"); - this.emit({ - type: "config.paseo_agent.set_default_model.response", - payload: { - requestId: msg.requestId, - success: false, - defaultModel: null, - error: getErrorMessage(error), - }, - }); - } - } - private async handlePaseoAgentStoreChatGptCredentialRequest( msg: Extract< SessionInboundMessage, diff --git a/packages/website/public/schemas/paseo.config.v1.json b/packages/website/public/schemas/paseo.config.v1.json index 8e9e3e5994..0c65119263 100644 --- a/packages/website/public/schemas/paseo.config.v1.json +++ b/packages/website/public/schemas/paseo.config.v1.json @@ -329,6 +329,10 @@ "type": "string", "minLength": 1 }, + "defaultProfile": { + "type": "string", + "minLength": 1 + }, "providers": { "type": "object", "additionalProperties": {