mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 18:22:38 +00:00
Add Paseo Agent prompt profiles
This commit is contained in:
@@ -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": {
|
||||
|
||||
Generated
+1
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -80,6 +80,7 @@ export interface BuildProviderRegistryOptions {
|
||||
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
|
||||
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<string, ProviderClientFactory> = {
|
||||
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<string, ResolvedProvider> {
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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<Record<AgentProvider, AgentClient>>;
|
||||
private readonly paseoHome: string | undefined;
|
||||
private runtimeSettings: AgentProviderRuntimeSettingsMap | undefined;
|
||||
private providerOverrides: Record<string, ProviderOverride> | undefined;
|
||||
private readonly baseProviderOverrides: Record<string, ProviderOverride> | 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<
|
||||
|
||||
@@ -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<AgentSessionConfig>): 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,6 +163,8 @@ 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.
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<typeof PromptProfileFrontmatterSchema>;
|
||||
|
||||
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");
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user