diff --git a/docs/data-model.md b/docs/data-model.md index b92acfa94..c11d7a65f 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -139,6 +139,7 @@ Single file, validated with `PersistedConfigSchema`. listen: "127.0.0.1:6767", hostnames: true | string[], // legacy alias `allowedHosts` is migrated on load mcp: { enabled: boolean, injectIntoAgents: boolean }, + appendSystemPrompt: string, // appended to supported provider system/developer prompts cors: { allowedOrigins: string[] }, relay: { enabled: boolean, endpoint: string, publicEndpoint: string, useTls: boolean, publicUseTls: boolean }, auth: { password: string } // bcrypt hash, optional diff --git a/packages/app/src/components/settings-textarea.tsx b/packages/app/src/components/settings-textarea.tsx new file mode 100644 index 000000000..1e19d35cf --- /dev/null +++ b/packages/app/src/components/settings-textarea.tsx @@ -0,0 +1,58 @@ +import type { StyleProp, TextStyle } from "react-native"; +import { useMemo } from "react"; +import { TextInput, View } from "react-native"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { settingsStyles } from "@/styles/settings"; + +interface SettingsTextAreaProps { + accessibilityLabel: string; + value: string; + onChangeText: (text: string) => void; + placeholder?: string; + testID?: string; + style?: StyleProp; +} + +export function SettingsTextArea({ + accessibilityLabel, + value, + onChangeText, + placeholder, + testID, + style, +}: SettingsTextAreaProps) { + const { theme } = useUnistyles(); + const inputStyle = useMemo(() => [styles.input, style], [style]); + + return ( + + ); +} + +export function SettingsTextAreaCard(props: SettingsTextAreaProps) { + return ( + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + input: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + paddingVertical: theme.spacing[3], + paddingHorizontal: theme.spacing[4], + minHeight: 96, + textAlignVertical: "top", + }, +})); diff --git a/packages/app/src/screens/project-settings-screen.tsx b/packages/app/src/screens/project-settings-screen.tsx index 26068a03f..f1dd8e3e9 100644 --- a/packages/app/src/screens/project-settings-screen.tsx +++ b/packages/app/src/screens/project-settings-screen.tsx @@ -23,6 +23,7 @@ import { ExternalLink } from "@/components/ui/external-link"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { Switch } from "@/components/ui/switch"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; +import { SettingsTextAreaCard } from "@/components/settings-textarea"; import { SettingsGroup } from "@/screens/settings/settings-group"; import { SettingsSection } from "@/screens/settings/settings-section"; import { settingsStyles } from "@/styles/settings"; @@ -621,18 +622,13 @@ function ProjectConfigForm({ testID="worktree-group" > - - - + - - - + @@ -1008,18 +999,13 @@ function MetadataPromptSection({ promptKey, value, onChange, flush }: MetadataPr ); return ( - - - + ); } @@ -1344,14 +1330,6 @@ const styles = StyleSheet.create((theme) => ({ errorBlock: { marginTop: theme.spacing[2], }, - lifecycleInput: { - color: theme.colors.foreground, - fontSize: theme.fontSize.sm, - paddingVertical: theme.spacing[3], - paddingHorizontal: theme.spacing[4], - minHeight: 96, - textAlignVertical: "top", - }, emptyScripts: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, diff --git a/packages/app/src/screens/settings/host-page.tsx b/packages/app/src/screens/settings/host-page.tsx index f7894079d..6cd2d2c3e 100644 --- a/packages/app/src/screens/settings/host-page.tsx +++ b/packages/app/src/screens/settings/host-page.tsx @@ -4,6 +4,7 @@ import { Alert, Pressable, Text, View } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { AdaptiveRenameModal } from "@/components/rename-modal"; +import { SettingsTextAreaCard } from "@/components/settings-textarea"; import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section"; @@ -379,9 +380,9 @@ function ConnectionRow({ function DaemonSection({ host, isLocalDaemon }: { host: HostProfile; isLocalDaemon: boolean }) { return ( <> - - + + {isLocalDaemon ? ( @@ -544,9 +545,9 @@ function InjectPaseoToolsCard({ serverId }: { serverId: string }) { - Inject Paseo tools + Enable Paseo tools - Automatically inject Paseo MCP tools into new agents + Agents will be able to manage worktrees, agents and schedules (() => ({ title: "Append system prompt" }), []); + + useEffect(() => { + setDraft(persistedPrompt); + }, [persistedPrompt]); + + const hasChanges = draft !== persistedPrompt; + + const handleOpen = useCallback(() => { + setDraft(persistedPrompt); + setIsEditing(true); + }, [persistedPrompt]); + + const handleClose = useCallback(() => { + if (isSaving) return; + setDraft(persistedPrompt); + setIsEditing(false); + }, [isSaving, persistedPrompt]); + + const handleSave = useCallback(() => { + setIsSaving(true); + void patchConfig({ appendSystemPrompt: draft }) + .then(() => { + setIsEditing(false); + return; + }) + .catch((error) => { + console.error("[HostPage] Failed to save append system prompt", error); + }) + .finally(() => setIsSaving(false)); + }, [draft, patchConfig]); + + const handleReset = useCallback(() => { + setDraft(persistedPrompt); + }, [persistedPrompt]); + + if (!isConnected) return null; + + return ( + <> + + + + System prompt + Added a system prompt to all agents + + + + + + {isEditing ? ( + + + + + + + + ) : null} + + ); +} + function PairDeviceRow() { const { theme } = useUnistyles(); const [isModalOpen, setIsModalOpen] = useState(false); @@ -631,6 +737,8 @@ function RemoveHostSection({ host, onRemoved }: { host: HostProfile; onRemoved?: return ( + + @@ -755,6 +863,11 @@ const styles = StyleSheet.create((theme) => ({ gap: theme.spacing[2], marginTop: theme.spacing[4], }, + appendPromptActions: { + flexDirection: "row", + justifyContent: "flex-end", + gap: theme.spacing[2], + }, emptyCard: { padding: theme.spacing[4], alignItems: "center", diff --git a/packages/app/src/screens/settings/providers-section.test.tsx b/packages/app/src/screens/settings/providers-section.test.tsx index 61095e834..560df9f70 100644 --- a/packages/app/src/screens/settings/providers-section.test.tsx +++ b/packages/app/src/screens/settings/providers-section.test.tsx @@ -202,7 +202,12 @@ const disabledCodexEntry: ProviderSnapshotEntry = { }; function makeConfig(providers: MutableDaemonConfig["providers"] = {}): MutableDaemonConfig { - return { mcp: { injectIntoAgents: false }, providers, autoArchiveAfterMerge: false }; + return { + mcp: { injectIntoAgents: false }, + providers, + autoArchiveAfterMerge: false, + appendSystemPrompt: "", + }; } function descendants(el: HTMLElement): HTMLElement[] { diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 2c5a2aae8..384b81c0a 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -100,12 +100,15 @@ function expectArchivedAgentRecord( class TestAgentClient implements AgentClient { readonly provider = "codex" as const; readonly capabilities = TEST_CAPABILITIES; + readonly createdConfigs: AgentSessionConfig[] = []; + readonly resumeOverrides: Array | undefined> = []; async isAvailable(): Promise { return true; } async createSession(config: AgentSessionConfig): Promise { + this.createdConfigs.push(config); return new TestAgentSession(config); } @@ -135,9 +138,11 @@ class TestAgentClient implements AgentClient { config?: Partial, _launchContext?: AgentLaunchContext, ): Promise { + this.resumeOverrides.push(config); return new TestAgentSession({ provider: "codex", cwd: config?.cwd ?? process.cwd(), + daemonAppendSystemPrompt: config?.daemonAppendSystemPrompt, }); } } @@ -391,6 +396,62 @@ test("normalizeConfig strips legacy 'default' model id", async () => { expect(snapshot.config.modeId).toBe("auto"); }); +test("createAgent injects daemon append system prompt at runtime only", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const client = new TestAgentClient(); + const manager = new AgentManager({ + clients: { + codex: client, + }, + registry: storage, + logger, + appendSystemPrompt: " Daemon instructions. ", + idFactory: () => "00000000-0000-4000-8000-000000000103", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + systemPrompt: "Agent instructions.", + }); + const record = await storage.get(snapshot.id); + + expect(client.createdConfigs[0]?.systemPrompt).toBe("Agent instructions."); + expect(client.createdConfigs[0]?.daemonAppendSystemPrompt).toBe("Daemon instructions."); + expect(snapshot.config.daemonAppendSystemPrompt).toBe("Daemon instructions."); + expect(record?.config?.systemPrompt).toBe("Agent instructions."); + expect(record?.config).not.toHaveProperty("daemonAppendSystemPrompt"); +}); + +test("daemon append system prompt is not injected into Pi", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const client = new TestAgentClient(); + const manager = new AgentManager({ + clients: { + pi: client as unknown as AgentClient, + }, + providerDefinitions: { + pi: { enabled: true }, + }, + registry: storage, + logger, + appendSystemPrompt: "Daemon instructions.", + idFactory: () => "00000000-0000-4000-8000-000000000104", + }); + + await manager.createAgent({ + provider: "pi", + cwd: workdir, + systemPrompt: "Agent instructions.", + }); + + expect(client.createdConfigs[0]?.daemonAppendSystemPrompt).toBeUndefined(); +}); + test("setAgentMode persists the selected mode across session reload", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); const storagePath = join(workdir, "agents"); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 570ecbbab..d13ac0e72 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -173,6 +173,7 @@ export interface AgentManagerOptions { durableTimelineStore?: AgentTimelineStore; terminalManager?: TerminalManager | null; mcpBaseUrl?: string; + appendSystemPrompt?: string; agentStreamCoalesceWindowMs?: number; rescueTimeouts?: AgentManagerRescueTimeouts; logger: Logger; @@ -438,6 +439,7 @@ export class AgentManager { private readonly backgroundTasks = new Set>(); private readonly agentStreamCoalescer: AgentStreamCoalescer; private mcpBaseUrl: string | null; + private appendSystemPrompt: string; private onAgentAttention?: AgentAttentionCallback; private logger: Logger; private readonly rescueTimeouts: Required; @@ -448,6 +450,7 @@ export class AgentManager { this.durableTimelineStore = options?.durableTimelineStore; this.onAgentAttention = options?.onAgentAttention; this.mcpBaseUrl = options?.mcpBaseUrl ?? null; + this.appendSystemPrompt = options.appendSystemPrompt ?? ""; this.logger = options.logger.child({ module: "agent", component: "agent-manager" }); this.rescueTimeouts = { reloadSessionCloseMs: @@ -502,6 +505,10 @@ export class AgentManager { this.mcpBaseUrl = url; } + setAppendSystemPrompt(prompt: string | null | undefined): void { + this.appendSystemPrompt = prompt ?? ""; + } + public getMetricsSnapshot(): AgentMetricsSnapshot { const byLifecycle: Record = {}; let withActiveForegroundTurn = 0; @@ -816,7 +823,9 @@ export class AgentManager { }, }; this.requireEnabledProvider(injectedConfig.provider); - const normalizedConfig = await this.normalizeConfig(injectedConfig); + const normalizedConfig = this.applyDaemonAppendSystemPrompt( + await this.normalizeConfig(injectedConfig), + ); const launchContext = this.buildLaunchContext(resolvedAgentId); const client = await this.requireAvailableClient({ provider: normalizedConfig.provider, @@ -860,7 +869,9 @@ export class AgentManager { ...overrides, provider: handle.provider, } as AgentSessionConfig; - const normalizedConfig = await this.normalizeConfig(mergedConfig); + const normalizedConfig = this.applyDaemonAppendSystemPrompt( + await this.normalizeConfig(mergedConfig), + ); const resumeOverrides: Partial = { ...overrides }; let hasResumeOverrides = overrides !== undefined; @@ -874,6 +885,11 @@ export class AgentManager { hasResumeOverrides = true; } + if (metadata.daemonAppendSystemPrompt !== normalizedConfig.daemonAppendSystemPrompt) { + resumeOverrides.daemonAppendSystemPrompt = normalizedConfig.daemonAppendSystemPrompt; + hasResumeOverrides = true; + } + const launchContext = this.buildLaunchContext(resolvedAgentId); const client = this.requireClient(handle.provider); const available = await client.isAvailable(); @@ -919,7 +935,9 @@ export class AgentManager { ...overrides, provider, } as AgentSessionConfig; - const normalizedConfig = await this.normalizeConfig(refreshConfig); + const normalizedConfig = this.applyDaemonAppendSystemPrompt( + await this.normalizeConfig(refreshConfig), + ); const launchContext = this.buildLaunchContext(agentId); const session = handle @@ -3437,6 +3455,26 @@ export class AgentManager { return normalized; } + private applyDaemonAppendSystemPrompt(config: AgentSessionConfig): AgentSessionConfig { + if (config.provider === "pi") { + const next = { ...config }; + delete next.daemonAppendSystemPrompt; + return next; + } + + const trimmed = this.appendSystemPrompt.trim(); + if (!trimmed) { + const next = { ...config }; + delete next.daemonAppendSystemPrompt; + return next; + } + + return { + ...config, + daemonAppendSystemPrompt: trimmed, + }; + } + private buildLaunchContext(agentId: string): AgentLaunchContext { return { agentId, diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 768a1a059..cdeee51bf 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -473,6 +473,11 @@ export interface AgentSessionConfig { * Mapped by each provider to its native instruction field. */ systemPrompt?: string; + /** + * Daemon-level instructions appended at runtime. This is deliberately not + * persisted into agent config so daemon setting changes apply cleanly. + */ + daemonAppendSystemPrompt?: string; modeId?: string; model?: string; thinkingOptionId?: string; diff --git a/packages/server/src/server/agent/orchestrator-instructions.ts b/packages/server/src/server/agent/orchestrator-instructions.ts deleted file mode 100644 index 659beb0a3..000000000 --- a/packages/server/src/server/agent/orchestrator-instructions.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Returns orchestrator mode instructions to append to the system prompt. - * These instructions are from CLAUDE.md and guide agents on how to work - * effectively in this repository. - */ -export function getOrchestratorModeInstructions(): string { - return ` - -Activation: -- Only activate if the user explicitly says "go into orchestrator mode" (or similar). -- Otherwise, do work directly yourself; do not spawn agents. - -Core rules: -- In orchestrator mode, you accomplish tasks only by managing agents; do not perform the work yourself. -- Always prefix agent titles (e.g., "🎭 Feature Implementation", "🎭 Design Discussion"). -- Set cwd to the repository root and choose the most permissive mode available. -- If an agent control tool call fails, list agents before launching another; it may just be a wait timeout. - -Context management: -- Reuse an existing agent when the next step needs the same context (same files/module/folder or immediate follow-up like investigate → fix in the same area). -- Start a new agent when switching to a different area/module, or when an agent has run long and its context feels stale. -- Prefer sending follow-up prompts to an existing agent to avoid reloading context. -- Use multiple agents when roles diverge (e.g., one for refactor, one for external validation), but default to reuse when context overlaps. - -Conversation with agents: -- Engage actively: ask pointed questions, probe risks, and request clarifications before accepting proposals. -- Encourage agents to validate assumptions, consider edge cases, and describe how they will test/verify. - -Agent selection guidance: -- Codex: methodical and slower; great for deep debugging, tracing code paths, refactoring, complex features, and design discussions. -- Claude: fast; strong at tool use, agentic control, and managing other agents; may jump to conclusions—ask it to verify. - -Clarifying ambiguous requests: -- Research first to understand the current state. -- Ask clarifying questions about what the user wants. -- Present options with trade-offs. -- Get explicit confirmation; never assume. - -Investigation vs Implementation: -- Investigate only unless explicitly asked to implement. -- Report findings clearly. -- After investigation, ask for direction before implementing. - -Tool usage discipline: -- Do not ask users to run commands—run them yourself. -- Do not repeat the user’s instructions verbatim—summarize them in your own words. -- Be explicit about results—tell the user what happened after every command. - -`; -} diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 8d88d7929..27bcabf6d 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -81,7 +81,7 @@ import { import { findExecutable, isCommandAvailable } from "../../../../utils/executable.js"; import { withTimeout } from "../../../../utils/promise-timeout.js"; import { execCommand } from "../../../../utils/spawn.js"; -import { getOrchestratorModeInstructions } from "../../orchestrator-instructions.js"; +import { composeSystemPromptParts } from "../../system-prompt.js"; const fsPromises = promises; const CLAUDE_SETTING_SOURCES: NonNullable = [ @@ -2314,9 +2314,9 @@ class ClaudeAgentSession implements AgentSession { } private buildAppendedSystemPrompt(): string { - return [getOrchestratorModeInstructions(), this.config.systemPrompt?.trim()] - .filter((entry): entry is string => typeof entry === "string" && entry.length > 0) - .join("\n\n"); + return ( + composeSystemPromptParts(this.config.systemPrompt, this.config.daemonAppendSystemPrompt) ?? "" + ); } private buildSdkEnv(extraClaudeOptions: Partial | undefined): NodeJS.ProcessEnv { @@ -2365,7 +2365,7 @@ class ClaudeAgentSession implements AgentSession { canUseTool: this.handlePermissionRequest, pathToClaudeCodeExecutable: claudeBinary, // Use Claude Code preset system prompt and load CLAUDE.md files - // Append provider-agnostic system prompt and orchestrator instructions for agents. + // Append provider-agnostic system prompts for agents. systemPrompt: { type: "preset", preset: "claude_code", diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index ffdb13c81..8bf5364d6 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -41,6 +41,7 @@ import os from "node:os"; import path from "node:path"; import { z } from "zod"; import { renderPromptAttachmentAsText } from "../prompt-attachments.js"; +import { composeSystemPromptParts } from "../system-prompt.js"; import { curateAgentActivity } from "../activity-curator.js"; import { mapCodexRolloutToolCall, @@ -3026,12 +3027,11 @@ class CodexAppServerAgentSession implements AgentSession { const settings: Record = {}; if (match.model) settings.model = match.model; if (match.reasoning_effort) settings.reasoning_effort = match.reasoning_effort; - const developerInstructions = [ - match.developer_instructions?.trim(), - this.config.systemPrompt?.trim(), - ] - .filter((entry): entry is string => typeof entry === "string" && entry.length > 0) - .join("\n\n"); + const developerInstructions = composeSystemPromptParts( + match.developer_instructions, + this.config.systemPrompt, + this.config.daemonAppendSystemPrompt, + ); if (developerInstructions) settings.developer_instructions = developerInstructions; if (this.config.model) settings.model = this.config.model; const thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId); @@ -3161,8 +3161,12 @@ class CodexAppServerAgentSession implements AgentSession { return; } const params: Record = { threadId: this.currentThreadId }; - if (this.config.systemPrompt?.trim()) { - params.developerInstructions = this.config.systemPrompt.trim(); + const developerInstructions = composeSystemPromptParts( + this.config.systemPrompt, + this.config.daemonAppendSystemPrompt, + ); + if (developerInstructions) { + params.developerInstructions = developerInstructions; } const codexConfig = this.buildCodexInnerConfig(); if (codexConfig) { @@ -3257,6 +3261,7 @@ class CodexAppServerAgentSession implements AgentSession { approvalPolicy: string; sandboxPolicyType: string; hasOutputSchema: boolean; + hasDeveloperInstructions: boolean; hasCodexConfig: boolean; }> { const input = await this.buildUserInput(prompt); @@ -3299,8 +3304,12 @@ class CodexAppServerAgentSession implements AgentSession { if (options?.outputSchema) { params.outputSchema = normalizeCodexOutputSchema(options.outputSchema); } - if (this.config.systemPrompt?.trim()) { - params.developerInstructions = this.config.systemPrompt.trim(); + const developerInstructions = composeSystemPromptParts( + this.config.systemPrompt, + this.config.daemonAppendSystemPrompt, + ); + if (developerInstructions) { + params.developerInstructions = developerInstructions; } const codexConfig = this.buildCodexInnerConfig(); if (codexConfig) { @@ -3313,6 +3322,7 @@ class CodexAppServerAgentSession implements AgentSession { approvalPolicy, sandboxPolicyType, hasOutputSchema: Boolean(options?.outputSchema), + hasDeveloperInstructions: Boolean(developerInstructions), hasCodexConfig: Boolean(codexConfig), }; } @@ -3323,6 +3333,7 @@ class CodexAppServerAgentSession implements AgentSession { approvalPolicy, sandboxPolicyType, hasOutputSchema, + hasDeveloperInstructions, hasCodexConfig, }: { turnId: string; @@ -3330,6 +3341,7 @@ class CodexAppServerAgentSession implements AgentSession { approvalPolicy: string; sandboxPolicyType: string; hasOutputSchema: boolean; + hasDeveloperInstructions: boolean; hasCodexConfig: boolean; }): void { this.logger.info( @@ -3345,7 +3357,7 @@ class CodexAppServerAgentSession implements AgentSession { sandboxPolicyType, hasCollaborationMode: Boolean(this.resolvedCollaborationMode), hasOutputSchema, - hasDeveloperInstructions: Boolean(this.config.systemPrompt?.trim()), + hasDeveloperInstructions, hasCodexConfig, }, "Starting Codex app-server turn", @@ -3407,6 +3419,7 @@ class CodexAppServerAgentSession implements AgentSession { approvalPolicy: turnStart.approvalPolicy, sandboxPolicyType: turnStart.sandboxPolicyType, hasOutputSchema: turnStart.hasOutputSchema, + hasDeveloperInstructions: turnStart.hasDeveloperInstructions, hasCodexConfig: turnStart.hasCodexConfig, }); await this.client.request("turn/start", turnStart.params, TURN_START_TIMEOUT_MS); @@ -3952,14 +3965,16 @@ class CodexAppServerAgentSession implements AgentSession { const approvalPolicy = this.config.approvalPolicy ?? preset.approvalPolicy; const sandbox = this.config.sandboxMode ?? preset.sandbox; const innerConfig = this.buildCodexInnerConfig(); + const developerInstructions = composeSystemPromptParts( + this.config.systemPrompt, + this.config.daemonAppendSystemPrompt, + ); const params: Record = { model, cwd: this.config.cwd ?? null, approvalPolicy, sandbox, - ...(this.config.systemPrompt?.trim() - ? { developerInstructions: this.config.systemPrompt.trim() } - : {}), + ...(developerInstructions ? { developerInstructions } : {}), ...(innerConfig ? { config: innerConfig } : {}), ...(this.ephemeral ? { ephemeral: true } : {}), }; diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index d7b491399..9e28b4b73 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -60,6 +60,7 @@ import { } from "./diagnostic-utils.js"; import { runProviderTurn } from "./provider-runner.js"; import { renderPromptAttachmentAsText } from "../prompt-attachments.js"; +import { composeSystemPromptParts } from "../system-prompt.js"; import { createSdkOpenCodeClient, type OpenCodeRuntime, @@ -2589,6 +2590,10 @@ class OpenCodeAgentSession implements AgentSession { partTypes: parts.map((p) => p.type), }); try { + const systemPrompt = composeSystemPromptParts( + this.config.systemPrompt, + this.config.daemonAppendSystemPrompt, + ); const promptResponse = await this.client.session.promptAsync({ sessionID: this.sessionId, directory: this.config.cwd, @@ -2601,7 +2606,7 @@ class OpenCodeAgentSession implements AgentSession { }, } : {}), - ...(this.config.systemPrompt ? { system: this.config.systemPrompt } : {}), + ...(systemPrompt ? { system: systemPrompt } : {}), ...(model ? { model } : {}), ...(effectiveMode ? { agent: effectiveMode } : {}), ...(effectiveVariant ? { variant: effectiveVariant } : {}), diff --git a/packages/server/src/server/agent/system-prompt.ts b/packages/server/src/server/agent/system-prompt.ts new file mode 100644 index 000000000..2aafc3759 --- /dev/null +++ b/packages/server/src/server/agent/system-prompt.ts @@ -0,0 +1,10 @@ +export function composeSystemPromptParts( + ...parts: Array +): string | undefined { + const prompt = parts + .map((part) => part?.trim()) + .filter((part): part is string => typeof part === "string" && part.length > 0) + .join("\n\n"); + + return prompt.length > 0 ? prompt : undefined; +} diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 4f509e40a..95e0d4d00 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -230,6 +230,7 @@ export interface PaseoDaemonConfig { mcpEnabled?: boolean; mcpInjectIntoAgents?: boolean; autoArchiveAfterMerge?: boolean; + appendSystemPrompt?: string; staticDir: string; mcpDebug: boolean; isDev?: boolean; @@ -290,6 +291,7 @@ export async function createPaseoDaemon( ]), ), autoArchiveAfterMerge: config.autoArchiveAfterMerge ?? false, + appendSystemPrompt: config.appendSystemPrompt ?? "", }, logger, ); @@ -516,6 +518,7 @@ export async function createPaseoDaemon( }, providerDefinitions: providerRegistry, registry: agentStorage, + appendSystemPrompt: config.appendSystemPrompt, logger, }); @@ -862,6 +865,9 @@ export async function createPaseoDaemon( daemonConfigStore.onFieldChange("mcp.injectIntoAgents", (value) => { agentManager.setMcpBaseUrl(value ? mcpBaseUrl : null); }); + daemonConfigStore.onFieldChange("appendSystemPrompt", (value) => { + agentManager.setAppendSystemPrompt(typeof value === "string" ? value : ""); + }); const relayEnabled = config.relayEnabled ?? true; const relayEndpoint = config.relayEndpoint ?? "relay.paseo.sh:443"; const relayPublicEndpoint = config.relayPublicEndpoint ?? relayEndpoint; diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index f5b2fe09a..3a6f2ed28 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -256,6 +256,10 @@ function resolveAuthConfig( : undefined; } +function resolveAppendSystemPrompt(persisted: ReturnType): string { + return persisted.daemon?.appendSystemPrompt ?? ""; +} + function resolveStaticLoadConfigSettings( env: NodeJS.ProcessEnv, cli: CliConfigOverrides | undefined, @@ -266,6 +270,7 @@ function resolveStaticLoadConfigSettings( mcpInjectIntoAgents: cli?.mcpInjectIntoAgents ?? persisted.daemon?.mcp?.injectIntoAgents ?? false, autoArchiveAfterMerge: persisted.daemon?.autoArchiveAfterMerge ?? false, + appendSystemPrompt: resolveAppendSystemPrompt(persisted), hostnames: mergeHostnames([ persisted.daemon?.hostnames, parseHostnamesEnv(env.PASEO_HOSTNAMES ?? env.PASEO_ALLOWED_HOSTS), @@ -286,8 +291,14 @@ export function loadConfig( const persisted = loadPersistedConfig(paseoHome); const listen = resolveListenAddress(env, options?.cli, persisted); - const { mcpEnabled, mcpInjectIntoAgents, autoArchiveAfterMerge, hostnames, appBaseUrl } = - resolveStaticLoadConfigSettings(env, options?.cli, persisted); + const { + mcpEnabled, + mcpInjectIntoAgents, + autoArchiveAfterMerge, + appendSystemPrompt, + hostnames, + appBaseUrl, + } = resolveStaticLoadConfigSettings(env, options?.cli, persisted); const relay = resolveRelayConfig({ env, @@ -315,6 +326,7 @@ export function loadConfig( mcpEnabled, mcpInjectIntoAgents, autoArchiveAfterMerge, + appendSystemPrompt, mcpDebug: env.MCP_DEBUG === "1", isDev: resolvePaseoNodeEnv(env) === "development", agentStoragePath: path.join(paseoHome, "agents"), diff --git a/packages/server/src/server/daemon-config-store.test.ts b/packages/server/src/server/daemon-config-store.test.ts index fe44969ae..2ac5f65e3 100644 --- a/packages/server/src/server/daemon-config-store.test.ts +++ b/packages/server/src/server/daemon-config-store.test.ts @@ -107,6 +107,28 @@ describe("DaemonConfigStore", () => { }); }); + test("patch persists append system prompt into config.json", () => { + const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); + tempDirs.push(paseoHome); + + const store = new DaemonConfigStore( + paseoHome, + { + mcp: { injectIntoAgents: false }, + providers: {}, + appendSystemPrompt: "", + }, + undefined, + ); + + store.patch({ + appendSystemPrompt: "Prefer terse replies.", + }); + + const persisted = loadPersistedConfig(paseoHome); + expect(persisted.daemon?.appendSystemPrompt).toBe("Prefer terse replies."); + }); + test("patch persists provider additional models into config.json", () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); diff --git a/packages/server/src/server/daemon-config-store.ts b/packages/server/src/server/daemon-config-store.ts index a12b8571c..b7852eb05 100644 --- a/packages/server/src/server/daemon-config-store.ts +++ b/packages/server/src/server/daemon-config-store.ts @@ -186,6 +186,7 @@ function mergeMutableConfigIntoPersistedConfig(params: { injectIntoAgents: mutable.mcp.injectIntoAgents, }, autoArchiveAfterMerge: mutable.autoArchiveAfterMerge, + appendSystemPrompt: mutable.appendSystemPrompt, }, agents: providerOverrides && Object.keys(providerOverrides).length > 0 diff --git a/packages/server/src/server/persisted-config.test.ts b/packages/server/src/server/persisted-config.test.ts index a17d5129f..23756ceb2 100644 --- a/packages/server/src/server/persisted-config.test.ts +++ b/packages/server/src/server/persisted-config.test.ts @@ -34,6 +34,18 @@ describe("PersistedConfigSchema daemon auth config", () => { }); }); +describe("PersistedConfigSchema daemon append system prompt config", () => { + test("accepts optional append system prompt", () => { + const parsed = PersistedConfigSchema.parse({ + daemon: { + appendSystemPrompt: "Prefer terse replies.", + }, + }); + + expect(parsed.daemon?.appendSystemPrompt).toBe("Prefer terse replies."); + }); +}); + describe("PersistedConfigSchema daemon relay config", () => { test("accepts optional relay TLS setting", () => { const parsed = PersistedConfigSchema.parse({ diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index dc5dfcec6..011721416 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -251,6 +251,7 @@ export const PersistedConfigSchema = z .passthrough() .optional(), autoArchiveAfterMerge: z.boolean().optional(), + appendSystemPrompt: z.string().optional(), cors: z .object({ allowedOrigins: z.array(z.string()).optional(), diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 76207e2f4..574a1eb05 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -114,6 +114,7 @@ export const MutableDaemonConfigSchema = z .passthrough(), providers: z.record(z.string(), MutableDaemonProviderConfigSchema).default({}), autoArchiveAfterMerge: z.boolean().default(false), + appendSystemPrompt: z.string().default(""), }) .passthrough(); @@ -124,6 +125,7 @@ export const MutableDaemonConfigPatchSchema = z .record(z.string(), MutableDaemonProviderConfigSchema.partial().passthrough()) .optional(), autoArchiveAfterMerge: z.boolean().optional(), + appendSystemPrompt: z.string().optional(), }) .partial() .passthrough(); diff --git a/packages/website/public/schemas/paseo.config.v1.json b/packages/website/public/schemas/paseo.config.v1.json index 7f56eb511..6757654b4 100644 --- a/packages/website/public/schemas/paseo.config.v1.json +++ b/packages/website/public/schemas/paseo.config.v1.json @@ -54,6 +54,12 @@ }, "additionalProperties": true }, + "autoArchiveAfterMerge": { + "type": "boolean" + }, + "appendSystemPrompt": { + "type": "string" + }, "cors": { "type": "object", "properties": { @@ -77,6 +83,22 @@ }, "publicEndpoint": { "type": "string" + }, + "useTls": { + "type": "boolean" + }, + "publicUseTls": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "auth": { + "type": "object", + "properties": { + "password": { + "type": "string", + "pattern": "^\\$2[aby]\\$\\d{2}\\$[./A-Za-z0-9]{53}$" } }, "additionalProperties": false @@ -248,6 +270,10 @@ "type": "string", "minLength": 1 }, + "language": { + "type": "string", + "minLength": 1 + }, "confidenceThreshold": { "type": "number" } @@ -285,6 +311,10 @@ "model": { "type": "string", "minLength": 1 + }, + "language": { + "type": "string", + "minLength": 1 } }, "additionalProperties": false