Add first-class Paseo Agent definitions

This commit is contained in:
Mohamed Boudra
2026-07-02 08:38:27 +02:00
parent 9665ba07a3
commit 4063671e3f
24 changed files with 1159 additions and 441 deletions
+38 -24
View File
@@ -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
@@ -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,
);
+21 -5
View File
@@ -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 () => {
+65 -24
View File
@@ -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<LoginResult> = {
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<DaemonClient, "getLastServe
if (client.getLastServerInfoMessage()?.features?.paseoAgentConfig === true) {
return;
}
throw new Error("Update the host to configure Paseo Agent providers.");
throw {
code: "HOST_UPDATE_REQUIRED",
message: "Update the host to configure Paseo Agent providers.",
} satisfies CommandError;
}
function formatDaemonTarget(host: string | undefined): string {
@@ -99,14 +125,16 @@ function printDeviceCode(write: (message: string) => void, info: CodexDeviceCode
async function runChatgptLogin(
options: LoginChatgptOptions,
dependencies: LoginCommandDependencies,
): Promise<LoginResult> {
): Promise<SingleResult<LoginResult>> {
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<LoginCommandDependencies> = {}): 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 <path>", "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<LoginResult, []>((options, _command) => runChatgptLogin(options, deps)));
return login;
}
@@ -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);
});
});
+27 -7
View File
@@ -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<ChildProcessWithoutNullStreams, "on" | "unref">;
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;
-24
View File
@@ -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<typeof PaseoAgentSetProvider
export type PaseoAgentRemoveProviderResponse = z.infer<
typeof PaseoAgentRemoveProviderResponseSchema
>;
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<typeof PaseoAgentGetProvidersRequestSchema>;
export type PaseoAgentSetProviderRequest = z.infer<typeof PaseoAgentSetProviderRequestSchema>;
export type PaseoAgentRemoveProviderRequest = z.infer<typeof PaseoAgentRemoveProviderRequestSchema>;
export type PaseoAgentSetDefaultModelRequest = z.infer<
typeof PaseoAgentSetDefaultModelRequestSchema
>;
export type PaseoAgentStoreChatGptCredentialRequest = z.infer<
typeof PaseoAgentStoreChatGptCredentialRequestSchema
>;
@@ -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");
});
});
@@ -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<typeof ToolPermissionActionSchema>;
export type ToolPermissionRule = z.infer<typeof ToolPermissionRuleSchema>;
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, "\\$&");
}
@@ -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<T = void>(): {
promise: Promise<T>;
resolve: (value: T | PromiseLike<T>) => void;
reject: (error: unknown) => void;
} {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((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<void> {
this.promptCalls.push({ text, options });
await this.promptDeferred.promise;
}
async abort(): Promise<void> {
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<void> {}
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<typeof PaseoAgentSession>[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);
});
});
@@ -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<string, PiTrackedToolCall>();
private activeTurnId: string | null = null;
private lastThinkingOptionId: string | null;
private closePromise: Promise<void> | 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<AgentSessionEvent, { type: "message_update" }>,
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<AgentSessionEvent, { type: "agent_end" }>): 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<AgentRunResult> {
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<void>((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<AgentMode[]> {
return [];
return this.availableAgents;
}
async getCurrentMode(): Promise<string | null> {
return null;
return this.agentId;
}
async setMode(_modeId: string): Promise<void> {
throw new Error("Paseo Agent does not expose selectable modes");
async setMode(modeId: string): Promise<void> {
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<void> {
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<void> {
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<AgentSlashCommand[]> {
@@ -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<AgentMode[]> {
return this.loadAvailableAgentModes();
}
async isAvailable(): Promise<boolean> {
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",
);
}
}
@@ -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: [] });
@@ -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,
@@ -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",
@@ -163,7 +163,9 @@ export const PaseoAgentConfigSchema = z
.object({
// Optional default model as "<inferenceProviderName>/<modelId>".
defaultModel: z.string().min(1).optional(),
// Optional default prompt profile from $PASEO_HOME/agents/<name>.md.
// Optional default agent definition from $PASEO_HOME/agents/<name>.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;
}
@@ -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({
@@ -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<BeforeToolCallResult | undefined> => {
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 };
}
@@ -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."],
});
});
});
@@ -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<typeof PromptProfileFrontmatterSchema>;
export type AgentDefinitionFrontmatter = z.infer<typeof AgentDefinitionFrontmatterSchema>;
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");
}
@@ -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();
});
@@ -62,19 +62,13 @@ function isEqualValue(a: unknown, b: unknown): boolean {
return JSON.stringify(a) === JSON.stringify(b);
}
function stripDedicatedPaseoAgentConfig<T extends Record<string, unknown>>(config: T): T {
const agents = config.agents;
if (!isRecord(agents) || !Object.prototype.hasOwnProperty.call(agents, "paseo")) {
function stripAgentProviderSecretConfig<T extends Record<string, unknown>>(config: T): T {
if (!Object.prototype.hasOwnProperty.call(config, "agents")) {
return config;
}
const { paseo: _paseo, ...remainingAgents } = agents;
const next: Record<string, unknown> = { ...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)),
);
@@ -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");
});
});
-31
View File
@@ -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<SessionInboundMessage, { type: "config.paseo_agent.set_default_model.request" }>,
): Promise<void> {
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,
@@ -329,6 +329,10 @@
"type": "string",
"minLength": 1
},
"defaultProfile": {
"type": "string",
"minLength": 1
},
"providers": {
"type": "object",
"additionalProperties": {