diff --git a/docs/paseo-agent.md b/docs/paseo-agent.md index 2957452c74..0d2ab40915 100644 --- a/docs/paseo-agent.md +++ b/docs/paseo-agent.md @@ -27,6 +27,64 @@ 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 + +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`. + +```jsonc +{ + "agents": { + "paseo": { + "defaultProfile": "orchestrator", + "defaultModel": "openrouter-main/anthropic/claude-3.7-sonnet", + "providers": {}, + }, + }, +} +``` + +Example `$PASEO_HOME/agents/orchestrator.md`: + +```markdown +--- +name: Orchestrator +description: Coordinates work through Paseo-managed agents +mode: extend +include: + - fragments/collaboration.md +mcp: [paseo] +model: openrouter-main/anthropic/claude-3.7-sonnet +--- + +Use the Paseo MCP tools to inspect active agents, create focused helper agents, and +summarize handoffs clearly. + +{{include: fragments/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. + +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. + +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. + +`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 +is not present in the session's `mcpServers`, Paseo Agent logs a warning and continues. + ## Config shape ```jsonc @@ -37,6 +95,10 @@ servers come from Paseo's normal injection/config, not from `agents.paseo`. // started without an explicit model. "defaultModel": "openrouter-main/anthropic/claude-3.7-sonnet", + // Optional. Loads $PASEO_HOME/agents/orchestrator.md by default for new + // Paseo Agent sessions. + "defaultProfile": "orchestrator", + // Inference providers, keyed by instance name. Names are free-form; you may // run several entries of the same type against different APIs/keys/models. "providers": { diff --git a/package-lock.json b/package-lock.json index cf8e32b220..5435ac8811 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38051,6 +38051,7 @@ "uuid": "^9.0.1", "which": "^5.0.0", "ws": "^8.14.2", + "yaml": "^2.8.4", "zod": "^4.4.3" }, "devDependencies": { diff --git a/packages/server/package.json b/packages/server/package.json index 8447c3e044..b652db5835 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -98,6 +98,7 @@ "uuid": "^9.0.1", "which": "^5.0.0", "ws": "^8.14.2", + "yaml": "^2.8.4", "zod": "^4.4.3" }, "devDependencies": { diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index 923ac2109f..f7a18cbe19 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -80,6 +80,7 @@ export interface BuildProviderRegistryOptions { workspaceGitService?: Pick; managedProcesses?: ManagedProcessRegistry; isDev?: boolean; + paseoHome?: string; /** * Opaque Paseo Agent config blob. The registry only forwards it to the * paseo-agent client factory; it never reads the nested inference providers. @@ -89,7 +90,7 @@ export interface BuildProviderRegistryOptions { interface ProviderClientFactoryOptions extends Pick< BuildProviderRegistryOptions, - "workspaceGitService" | "managedProcesses" | "paseoAgentConfig" + "workspaceGitService" | "managedProcesses" | "paseoAgentConfig" | "paseoHome" > { providerParams?: unknown; customProvider?: { @@ -170,6 +171,7 @@ const PROVIDER_CLIENT_FACTORIES: Record = { new PaseoAgentClient({ logger, config: options?.paseoAgentConfig ?? {}, + paseoHome: options?.paseoHome, }), mock: (logger) => new MockLoadTestAgentClient(logger), "mock-slow": () => new MockSlowProviderClient(), @@ -581,7 +583,7 @@ function buildResolvedBuiltinProviders( runtimeSettings: AgentProviderRuntimeSettingsMap | undefined, options: Pick< BuildProviderRegistryOptions, - "workspaceGitService" | "managedProcesses" | "paseoAgentConfig" + "workspaceGitService" | "managedProcesses" | "paseoAgentConfig" | "paseoHome" >, isDev: boolean, ): Map { @@ -614,6 +616,7 @@ function buildResolvedBuiltinProviders( managedProcesses: options.managedProcesses, providerParams: override?.params, paseoAgentConfig: options.paseoAgentConfig, + paseoHome: options.paseoHome, }), }); } @@ -734,6 +737,7 @@ export function buildProviderRegistry( workspaceGitService: options?.workspaceGitService, managedProcesses: options?.managedProcesses, paseoAgentConfig: options?.paseoAgentConfig, + paseoHome: options?.paseoHome, }, options?.isDev === true, ); diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index e8f3ef23c1..b5e689559d 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -78,6 +78,7 @@ export interface ProviderSnapshotManagerOptions { refreshTimeoutMs?: number; diagnosticTimeoutMs?: number; paseoAgentConfig?: PaseoAgentConfig; + paseoHome?: string; } interface ProviderSnapshotRefreshOptions { @@ -163,6 +164,7 @@ export class ProviderSnapshotManager { private readonly managedProcesses?: ManagedProcessRegistry; private readonly isDev: boolean; private readonly extraClients: Partial>; + private readonly paseoHome: string | undefined; private runtimeSettings: AgentProviderRuntimeSettingsMap | undefined; private providerOverrides: Record | undefined; private readonly baseProviderOverrides: Record | undefined; @@ -176,6 +178,7 @@ export class ProviderSnapshotManager { this.managedProcesses = options.managedProcesses; this.isDev = options.isDev === true; this.extraClients = options.extraClients ?? {}; + this.paseoHome = options.paseoHome; this.runtimeSettings = options.runtimeSettings; this.providerOverrides = options.providerOverrides; this.baseProviderOverrides = options.providerOverrides; @@ -422,6 +425,7 @@ export class ProviderSnapshotManager { managedProcesses: this.managedProcesses, isDev: this.isDev, paseoAgentConfig: this.paseoAgentConfig, + paseoHome: this.paseoHome, }); for (const [provider, client] of Object.entries(this.extraClients) as Array< 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 55358deb1f..27df8b7c06 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,4 +1,8 @@ -import { describe, expect, it } from "vitest"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +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"; @@ -26,7 +30,30 @@ function sessionConfig(overrides?: Partial): AgentSessionCon return { provider: "paseo", cwd: process.cwd(), ...overrides }; } +function createRecordingLogger(): Logger & { warnings: Array<{ data: unknown; message: string }> } { + const warnings: Array<{ data: unknown; message: string }> = []; + const logger = { + warnings, + child: () => logger, + debug: () => {}, + warn: (data: unknown, message: string) => { + warnings.push({ data, message }); + }, + error: () => {}, + info: () => {}, + } as Logger & { warnings: Array<{ data: unknown; message: string }> }; + return logger; +} + describe("PaseoAgentClient", () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("is available only when config has a usable inference provider", async () => { const withConfig = new PaseoAgentClient({ logger: createTestLogger(), config: makeConfig() }); expect(await withConfig.isAvailable()).toBe(true); @@ -91,4 +118,108 @@ describe("PaseoAgentClient", () => { await session.close(); } }); + + it("uses the configured default prompt profile as a lowest-precedence model default", async () => { + const paseoHome = mkdtempSync(join(tmpdir(), "paseo-agent-client-")); + tempDirs.push(paseoHome); + mkdirSync(join(paseoHome, "agents"), { recursive: true }); + writeFileSync( + join(paseoHome, "agents", "orchestrator.md"), + `--- +model: openrouter-main/b +--- +Profile prompt. +`, + ); + const config = PaseoAgentConfigSchema.parse({ + defaultProfile: "orchestrator", + 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 }); + const session = await client.createSession(sessionConfig()); + try { + const info = await session.getRuntimeInfo(); + expect(info.model).toBe("openrouter-main/b"); + } finally { + await session.close(); + } + }); + + it("prefers the configured default model over the profile default model", async () => { + const paseoHome = mkdtempSync(join(tmpdir(), "paseo-agent-client-")); + tempDirs.push(paseoHome); + mkdirSync(join(paseoHome, "agents"), { recursive: true }); + writeFileSync( + join(paseoHome, "agents", "orchestrator.md"), + `--- +model: openrouter-main/b +--- +Profile prompt. +`, + ); + const config = PaseoAgentConfigSchema.parse({ + defaultProfile: "orchestrator", + defaultModel: "openrouter-main/a", + 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 }); + const session = await client.createSession(sessionConfig()); + try { + const info = await session.getRuntimeInfo(); + expect(info.model).toBe("openrouter-main/a"); + } finally { + await session.close(); + } + }); + + it("warns when the configured profile expects a missing MCP server", async () => { + const paseoHome = mkdtempSync(join(tmpdir(), "paseo-agent-client-")); + tempDirs.push(paseoHome); + mkdirSync(join(paseoHome, "agents"), { recursive: true }); + writeFileSync( + join(paseoHome, "agents", "orchestrator.md"), + `--- +mcp: [paseo, paseo] +--- +Profile prompt. +`, + ); + const logger = createRecordingLogger(); + const client = new PaseoAgentClient({ + logger, + config: PaseoAgentConfigSchema.parse({ ...makeConfig(), defaultProfile: "orchestrator" }), + paseoHome, + }); + const session = await client.createSession(sessionConfig()); + try { + expect(logger.warnings).toEqual([ + { + data: expect.objectContaining({ mcpServer: "paseo" }), + message: expect.stringMatching(/expects an MCP server/i), + }, + ]); + } finally { + await session.close(); + } + }); }); 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 0a39ed779f..d02e9644eb 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/agent.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/agent.ts @@ -48,6 +48,7 @@ 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"; const DEFAULT_THINKING_LEVEL: ThinkingLevel = "medium"; @@ -85,6 +86,7 @@ function resolveIsolatedAgentDir(): string { interface PaseoAgentClientOptions { logger: Logger; config: PaseoAgentConfig; + paseoHome?: string; } export class PaseoAgentSession implements AgentSession { @@ -492,10 +494,12 @@ export class PaseoAgentClient implements AgentClient { private readonly logger: Logger; private readonly config: PaseoAgentConfig; + private readonly paseoHome: string | undefined; constructor(options: PaseoAgentClientOptions) { this.logger = options.logger; this.config = options.config; + this.paseoHome = options.paseoHome; } async createSession( @@ -509,10 +513,26 @@ export class PaseoAgentClient implements AgentClient { ); } - const model = resolvePaseoAgentModel(this.config, config.model, inferenceProviders); + const profile = this.loadDefaultProfile(); + this.verifyExpectedMcpServers(profile?.expectedMcpServers ?? [], config.mcpServers); + const model = resolvePaseoAgentModel( + this.config, + config.model, + inferenceProviders, + profile?.model, + ); const thinkingLevel = normalizeThinkingLevel(config.thinkingOptionId) ?? undefined; + const composedPrompt = composePromptParts({ + profile, + systemPrompt: config.systemPrompt, + daemonAppendSystemPrompt: config.daemonAppendSystemPrompt, + }); this.logger.debug( - { provider: PASEO_AGENT_PROVIDER, model: model ? `${model.provider}/${model.id}` : null }, + { + provider: PASEO_AGENT_PROVIDER, + model: model ? `${model.provider}/${model.id}` : null, + promptProfile: profile?.id ?? null, + }, "Creating Paseo Agent session", ); @@ -536,6 +556,7 @@ export class PaseoAgentClient implements AgentClient { ...(thinkingLevel ? { thinkingLevel } : {}), ...(authStorage ? { authStorage } : {}), ...(mcpBridge.tools.length > 0 ? { customTools: mcpBridge.tools } : {}), + ...(composedPrompt ? { composedPrompt } : {}), }); return new PaseoAgentSession(handle, config, mcpBridge); } catch (error) { @@ -557,4 +578,48 @@ export class PaseoAgentClient implements AgentClient { hasStoredOAuthCredential(providerInstance), ); } + + private loadDefaultProfile() { + if (!this.paseoHome || !this.config.defaultProfile) { + return null; + } + try { + const profile = loadPromptProfile(this.paseoHome, this.config.defaultProfile); + if (!profile) { + this.logger.warn( + { provider: PASEO_AGENT_PROVIDER, promptProfile: this.config.defaultProfile }, + "Configured Paseo Agent prompt profile was not found", + ); + } + return profile; + } catch (error) { + this.logger.warn( + { + provider: PASEO_AGENT_PROVIDER, + promptProfile: this.config.defaultProfile, + error: error instanceof Error ? error.message : String(error), + }, + "Configured Paseo Agent prompt profile could not be loaded", + ); + return null; + } + } + + private verifyExpectedMcpServers( + expectedServers: string[], + configuredServers: AgentSessionConfig["mcpServers"], + ): void { + for (const serverName of new Set(expectedServers)) { + if (!configuredServers?.[serverName]) { + this.logger.warn( + { + provider: PASEO_AGENT_PROVIDER, + promptProfile: this.config.defaultProfile, + mcpServer: serverName, + }, + "Paseo Agent prompt profile expects an MCP server that is not configured for this session", + ); + } + } + } } 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 540f41dd36..a6b6da310f 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/config.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/config.ts @@ -163,6 +163,8 @@ export const PaseoAgentConfigSchema = z .object({ // Optional default model as "/". defaultModel: z.string().min(1).optional(), + // Optional default prompt profile from $PASEO_HOME/agents/.md. + 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. providers: z.record(PaseoAgentInferenceProviderSchema).optional(), @@ -355,12 +357,13 @@ export function resolvePaseoAgentModel( config: PaseoAgentConfig, requestedModelId: string | null | undefined, registeredProviders: PaseoAgentInferenceProvider[] = paseoAgentInferenceProviders(config), + profileDefaultModelId?: string | null, ): PaseoAgentModelReference | undefined { if (requestedModelId) { return parsePaseoAgentModelId(requestedModelId) ?? undefined; } - for (const candidate of [config.defaultModel, firstModelId(config)]) { + for (const candidate of [config.defaultModel, profileDefaultModelId, 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 4b396f8a5b..628c98ad66 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 @@ -116,6 +116,20 @@ describe("createPaseoAgentSession (no-discovery spike)", () => { expect(resourceLoader.getPrompts().prompts).toHaveLength(0); }); + it("exposes composed prompts through the resource loader without discovery", async () => { + const { resourceLoader } = await createPaseoAgentSession({ + ...baseOptions(), + composedPrompt: { + customPrompt: "Custom Paseo base prompt.", + appendSystemPrompt: ["Profile append.", "Daemon append."], + }, + }); + + expect(resourceLoader.getSystemPrompt()).toBe("Custom Paseo base prompt."); + expect(resourceLoader.getAppendSystemPrompt()).toEqual(["Profile append.", "Daemon append."]); + expect(resourceLoader.getAgentsFiles().agentsFiles).toHaveLength(0); + }); + it("uses an in-memory session manager with no on-disk session file", async () => { const { sessionManager } = await createPaseoAgentSession(baseOptions()); 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 a48d8f8379..2bb96eeac3 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 @@ -12,6 +12,7 @@ import { import type { 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 type { PaseoComposedPrompt } from "./prompt-profiles.js"; // Re-export the Pi tool contract so the MCP bridge can build custom tools without // importing the Pi SDK type names itself. @@ -86,6 +87,8 @@ 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. */ + composedPrompt?: PaseoComposedPrompt; } export interface PaseoAgentSessionHandle { @@ -106,8 +109,9 @@ function createNoDiscoveryResourceLoader(options: { cwd: string; agentDir: string; settingsManager: SettingsManager; + composedPrompt?: PaseoComposedPrompt; }): ResourceLoader { - return new DefaultResourceLoader({ + const base = new DefaultResourceLoader({ cwd: options.cwd, agentDir: options.agentDir, settingsManager: options.settingsManager, @@ -117,6 +121,27 @@ function createNoDiscoveryResourceLoader(options: { noThemes: true, noContextFiles: true, }); + return options.composedPrompt ? wrapPromptResourceLoader(base, options.composedPrompt) : base; +} + +function wrapPromptResourceLoader( + delegate: ResourceLoader, + composedPrompt: PaseoComposedPrompt, +): ResourceLoader { + return { + getExtensions: () => delegate.getExtensions(), + getSkills: () => delegate.getSkills(), + getPrompts: () => delegate.getPrompts(), + getThemes: () => delegate.getThemes(), + getAgentsFiles: () => delegate.getAgentsFiles(), + getSystemPrompt: () => composedPrompt.customPrompt ?? delegate.getSystemPrompt(), + getAppendSystemPrompt: () => [ + ...delegate.getAppendSystemPrompt(), + ...composedPrompt.appendSystemPrompt, + ], + extendResources: (paths) => delegate.extendResources(paths), + reload: () => delegate.reload(), + }; } /** @@ -160,6 +185,7 @@ export async function createPaseoAgentSession( cwd: options.cwd, agentDir: options.agentDir, settingsManager, + composedPrompt: options.composedPrompt, }); const model = options.model 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 new file mode 100644 index 0000000000..47106e136d --- /dev/null +++ b/packages/server/src/server/agent/providers/paseo-agent/prompt-profiles.test.ts @@ -0,0 +1,167 @@ +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +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"; + +describe("Paseo Agent prompt profiles", () => { + let paseoHome: string; + let agentsDir: string; + const tempDirs: string[] = []; + + beforeEach(() => { + paseoHome = mkdtempSync(join(tmpdir(), "paseo-agent-profiles-")); + agentsDir = join(paseoHome, "agents"); + mkdirSync(join(agentsDir, "fragments"), { recursive: true }); + }); + + afterEach(() => { + rmSync(paseoHome, { recursive: true, force: true }); + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + function writeProfile(name: string, content: string): void { + writeFileSync(join(agentsDir, name), content); + } + + it("parses frontmatter and lists only top-level markdown profiles", () => { + writeProfile( + "orchestrator.md", + `--- +name: Orchestrator +description: Routes work +mode: override +mcp: [paseo] +model: openrouter-main/test-model +projectContext: true +--- +Profile body. +`, + ); + writeProfile("notes.txt", "ignored"); + writeFileSync(join(agentsDir, "fragments", "piece.md"), "fragment"); + + const profile = loadPromptProfile(paseoHome, "orchestrator"); + + expect(listPromptProfileIds(paseoHome)).toEqual(["orchestrator"]); + expect(profile?.frontmatter).toMatchObject({ + name: "Orchestrator", + description: "Routes work", + mode: "override", + mcp: ["paseo"], + model: "openrouter-main/test-model", + projectContext: true, + }); + expect(profile?.composedPrompt.customPrompt).toBe("Profile 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. +`, + ); + + 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"); + + expect(loadPromptProfile(paseoHome, "inline")?.body).toBe("Before\nUse short answers.\nAfter"); + }); + + 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}}"); + + expect(() => loadPromptProfile(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}}"); + + expect(() => loadPromptProfile(paseoHome, "missing")).toThrow(/not found/i); + expect(() => loadPromptProfile(paseoHome, "escape")).toThrow(/escape|invalid/i); + expect(() => loadPromptProfile(paseoHome, "../escape")).toThrow(/invalid/i); + }); + + it("rejects symlink escapes for profiles and includes", () => { + 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}}"); + + expect(() => loadPromptProfile(paseoHome, "linked-profile")).toThrow(/escapes/i); + expect(() => loadPromptProfile(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", "deeper.md"), "done"); + writeProfile("depth.md", "{{include: fragments/deep.md}}"); + writeProfile("large.md", "0123456789"); + + expect(() => loadPromptProfile(paseoHome, "depth", { maxDepth: 1 })).toThrow(/depth/i); + expect(() => loadPromptProfile(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"); + + expect( + composePromptParts({ + profile, + systemPrompt: " Agent prompt. ", + daemonAppendSystemPrompt: "Daemon prompt.", + }), + ).toEqual({ + appendSystemPrompt: ["Profile prompt.", "Agent prompt.", "Daemon prompt."], + }); + }); + + it("uses override profile body as custom prompt while appending session and daemon prompts", () => { + writeProfile( + "override.md", + `--- +mode: override +--- +Replacement base. +`, + ); + const profile = loadPromptProfile(paseoHome, "override"); + + expect( + composePromptParts({ + profile, + systemPrompt: "Agent prompt.", + daemonAppendSystemPrompt: "Daemon prompt.", + }), + ).toEqual({ + customPrompt: "Replacement base.", + appendSystemPrompt: ["Agent 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 new file mode 100644 index 0000000000..49963a0f9e --- /dev/null +++ b/packages/server/src/server/agent/providers/paseo-agent/prompt-profiles.ts @@ -0,0 +1,267 @@ +import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs"; +import { basename, extname, isAbsolute, relative, resolve } from "node:path"; +import { parse as parseYaml } from "yaml"; +import { z } from "zod"; + +const DEFAULT_MAX_DEPTH = 8; +const DEFAULT_MAX_TOTAL_BYTES = 256 * 1024; +const INLINE_INCLUDE_PATTERN = /\{\{\s*include:\s*([^}]+?)\s*\}\}/g; + +const PromptProfileFrontmatterSchema = 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(), + mcp: z.array(z.string().min(1)).optional(), + model: z.string().min(1).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(); + +export type PromptProfileFrontmatter = z.infer; + +export interface PaseoComposedPrompt { + customPrompt?: string; + appendSystemPrompt: string[]; +} + +export interface ResolvedPromptProfile { + id: string; + path: string; + frontmatter: PromptProfileFrontmatter; + body: string; + composedPrompt: PaseoComposedPrompt; + expectedMcpServers: string[]; + model?: string; +} + +interface LoadPromptProfileOptions { + maxDepth?: number; + maxTotalBytes?: number; +} + +interface LoadState { + totalBytes: number; +} + +interface ParsedMarkdown { + frontmatter: PromptProfileFrontmatter; + body: string; +} + +export function loadPromptProfile( + paseoHome: string, + profileName: string | undefined, + options: LoadPromptProfileOptions = {}, +): ResolvedPromptProfile | null { + if (!profileName) { + return null; + } + + const agentsDir = resolve(paseoHome, "agents"); + const profilePath = resolveProfilePath(agentsDir, profileName); + if (!existsSync(profilePath)) { + return null; + } + + const state: LoadState = { totalBytes: 0 }; + const parsed = loadMarkdownWithIncludes({ + agentsDir, + path: profilePath, + depth: 0, + stack: [], + state, + options, + }); + const body = trimPrompt(parsed.body); + const mode = parsed.frontmatter.mode; + const composedPrompt = + mode === "override" + ? { customPrompt: body, appendSystemPrompt: [] } + : { appendSystemPrompt: body ? [body] : [] }; + + return { + id: basename(profilePath, ".md"), + path: profilePath, + frontmatter: parsed.frontmatter, + body, + composedPrompt, + expectedMcpServers: parsed.frontmatter.mcp ?? [], + ...(parsed.frontmatter.model ? { model: parsed.frontmatter.model } : {}), + }; +} + +export function listPromptProfileIds(paseoHome: string): string[] { + const agentsDir = resolve(paseoHome, "agents"); + if (!existsSync(agentsDir)) { + return []; + } + return readdirSync(agentsDir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && extname(entry.name) === ".md") + .map((entry) => basename(entry.name, ".md")) + .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}`); + } + const filename = profileName.endsWith(".md") ? profileName : `${profileName}.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}`); + } + return resolveConfinedPath(agentsDir, includePath); +} + +function isSafeRelativePath(input: string): boolean { + return input.trim() === input && input.length > 0 && !isAbsolute(input) && !input.includes("\0"); +} + +function resolveConfinedPath(agentsDir: string, input: string): string { + const resolved = resolve(agentsDir, input); + const rel = relative(agentsDir, resolved); + if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) { + throw new Error(`Paseo Agent prompt path escapes agents directory: ${input}`); + } + return resolved; +} + +function loadMarkdownWithIncludes(input: { + agentsDir: string; + path: string; + depth: number; + stack: string[]; + state: LoadState; + options: LoadPromptProfileOptions; +}): 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)}`, + ); + } + + 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(" -> ")}`); + } + + 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`); + } + + const parsed = parseMarkdown(raw); + const stack = [...input.stack, path]; + const frontmatterIncludes = parsed.frontmatter.include ?? []; + const prepended = frontmatterIncludes.map( + (includePath) => + loadMarkdownWithIncludes({ + ...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()), + depth: input.depth + 1, + stack, + }).body, + ); + + return { + frontmatter: parsed.frontmatter, + body: joinPromptParts([...prepended, bodyWithInlineIncludes]), + }; +} + +function realpathConfined(agentsDir: string, path: string): string { + const realAgentsDir = realpathSync(agentsDir); + 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)}`, + ); + } + return realPath; +} + +function parseMarkdown(raw: string): ParsedMarkdown { + if (!raw.startsWith("---\n") && !raw.startsWith("---\r\n")) { + return { + frontmatter: PromptProfileFrontmatterSchema.parse({}), + body: raw, + }; + } + + 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"); + } + + const yaml = raw.slice(4, closeIndex); + const body = raw.slice(closeIndex + closeMarker.length); + const value = yaml.trim() ? parseYaml(yaml) : {}; + return { + frontmatter: PromptProfileFrontmatterSchema.parse(value ?? {}), + body, + }; +} + +export function composePromptParts(input: { + profile?: ResolvedPromptProfile | null; + systemPrompt?: string; + daemonAppendSystemPrompt?: string; +}): PaseoComposedPrompt | undefined { + const profilePrompt = input.profile?.composedPrompt; + const appendSystemPrompt = [ + ...(profilePrompt?.appendSystemPrompt ?? []), + input.systemPrompt, + input.daemonAppendSystemPrompt, + ].flatMap((part) => { + const trimmed = trimPrompt(part); + return trimmed ? [trimmed] : []; + }); + const hasCustomPrompt = Boolean( + profilePrompt && Object.prototype.hasOwnProperty.call(profilePrompt, "customPrompt"), + ); + const customPrompt = trimPrompt(profilePrompt?.customPrompt); + + if (!hasCustomPrompt && appendSystemPrompt.length === 0) { + return undefined; + } + + return { + ...(hasCustomPrompt ? { customPrompt } : {}), + appendSystemPrompt, + }; +} + +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/bootstrap.ts b/packages/server/src/server/bootstrap.ts index f2eab3e0a8..ac60813e09 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -742,6 +742,7 @@ export async function createPaseoDaemon( runtimeSettings: config.agentProviderSettings, providerOverrides: config.providerOverrides, paseoAgentConfig: config.paseoAgentConfig, + paseoHome: config.paseoHome, workspaceGitService, managedProcesses, isDev: config.isDev === true,