Add daemon-wide system prompts (#1100)
* Add daemon-wide system prompt setting * Align settings textarea rendering * Polish daemon settings layout * Remove orchestrator prompt injection
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<TextStyle>;
|
||||
}
|
||||
|
||||
export function SettingsTextArea({
|
||||
accessibilityLabel,
|
||||
value,
|
||||
onChangeText,
|
||||
placeholder,
|
||||
testID,
|
||||
style,
|
||||
}: SettingsTextAreaProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const inputStyle = useMemo(() => [styles.input, style], [style]);
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
testID={testID}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
multiline
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={inputStyle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsTextAreaCard(props: SettingsTextAreaProps) {
|
||||
return (
|
||||
<View style={settingsStyles.card}>
|
||||
<SettingsTextArea {...props} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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",
|
||||
},
|
||||
}));
|
||||
@@ -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"
|
||||
>
|
||||
<SettingsSection title="Setup" testID="worktree-setup-section" trailing={setupDocsLink}>
|
||||
<View style={settingsStyles.card}>
|
||||
<TextInput
|
||||
testID="worktree-setup-input"
|
||||
accessibilityLabel="Worktree setup commands"
|
||||
multiline
|
||||
value={draft.setupText}
|
||||
onChangeText={handleSetupChange}
|
||||
placeholder="npm install"
|
||||
placeholderTextColor={styles.placeholderColor.color}
|
||||
style={styles.lifecycleInput}
|
||||
/>
|
||||
</View>
|
||||
<SettingsTextAreaCard
|
||||
testID="worktree-setup-input"
|
||||
accessibilityLabel="Worktree setup commands"
|
||||
value={draft.setupText}
|
||||
onChangeText={handleSetupChange}
|
||||
placeholder="npm install"
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
@@ -641,18 +637,13 @@ function ProjectConfigForm({
|
||||
trailing={teardownDocsLink}
|
||||
flush
|
||||
>
|
||||
<View style={settingsStyles.card}>
|
||||
<TextInput
|
||||
testID="worktree-teardown-input"
|
||||
accessibilityLabel="Worktree teardown commands"
|
||||
multiline
|
||||
value={draft.teardownText}
|
||||
onChangeText={handleTeardownChange}
|
||||
placeholder="docker compose down"
|
||||
placeholderTextColor={styles.placeholderColor.color}
|
||||
style={styles.lifecycleInput}
|
||||
/>
|
||||
</View>
|
||||
<SettingsTextAreaCard
|
||||
testID="worktree-teardown-input"
|
||||
accessibilityLabel="Worktree teardown commands"
|
||||
value={draft.teardownText}
|
||||
onChangeText={handleTeardownChange}
|
||||
placeholder="docker compose down"
|
||||
/>
|
||||
</SettingsSection>
|
||||
</SettingsGroup>
|
||||
|
||||
@@ -1008,18 +999,13 @@ function MetadataPromptSection({ promptKey, value, onChange, flush }: MetadataPr
|
||||
);
|
||||
return (
|
||||
<SettingsSection title={meta.title} testID={meta.sectionTestID} flush={flush}>
|
||||
<View style={settingsStyles.card}>
|
||||
<TextInput
|
||||
testID={meta.inputTestID}
|
||||
accessibilityLabel={meta.title}
|
||||
multiline
|
||||
value={value}
|
||||
onChangeText={handleChange}
|
||||
placeholder={meta.placeholder}
|
||||
placeholderTextColor={styles.placeholderColor.color}
|
||||
style={styles.lifecycleInput}
|
||||
/>
|
||||
</View>
|
||||
<SettingsTextAreaCard
|
||||
testID={meta.inputTestID}
|
||||
accessibilityLabel={meta.title}
|
||||
value={value}
|
||||
onChangeText={handleChange}
|
||||
placeholder={meta.placeholder}
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<SettingsSection title="Operations">
|
||||
<RestartDaemonCard host={host} />
|
||||
<SettingsSection title="Daemon settings">
|
||||
<InjectPaseoToolsCard serverId={host.serverId} />
|
||||
<AppendSystemPromptCard serverId={host.serverId} />
|
||||
</SettingsSection>
|
||||
{isLocalDaemon ? (
|
||||
<SettingsSection title="Pair devices">
|
||||
@@ -544,9 +545,9 @@ function InjectPaseoToolsCard({ serverId }: { serverId: string }) {
|
||||
<View style={settingsStyles.card} testID="host-page-inject-mcp-card">
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Inject Paseo tools</Text>
|
||||
<Text style={settingsStyles.rowTitle}>Enable Paseo tools</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
Automatically inject Paseo MCP tools into new agents
|
||||
Agents will be able to manage worktrees, agents and schedules
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
@@ -559,6 +560,111 @@ function InjectPaseoToolsCard({ serverId }: { serverId: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function AppendSystemPromptCard({ serverId }: { serverId: string }) {
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const { config, patchConfig } = useDaemonConfig(serverId);
|
||||
const persistedPrompt = config?.appendSystemPrompt ?? "";
|
||||
const [draft, setDraft] = useState(persistedPrompt);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const header = useMemo<SheetHeader>(() => ({ 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 (
|
||||
<>
|
||||
<View style={settingsStyles.card} testID="host-page-append-system-prompt-card">
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>System prompt</Text>
|
||||
<Text style={settingsStyles.rowHint}>Added a system prompt to all agents</Text>
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onPress={handleOpen}
|
||||
testID="host-page-append-system-prompt-edit"
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{isEditing ? (
|
||||
<AdaptiveModalSheet
|
||||
header={header}
|
||||
visible
|
||||
onClose={handleClose}
|
||||
testID="host-page-append-system-prompt-sheet"
|
||||
desktopMaxWidth={560}
|
||||
>
|
||||
<SettingsTextAreaCard
|
||||
testID="host-page-append-system-prompt-input"
|
||||
accessibilityLabel="Append system prompt"
|
||||
value={draft}
|
||||
onChangeText={setDraft}
|
||||
placeholder="Always keep replies concise."
|
||||
/>
|
||||
<View style={styles.appendPromptActions}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onPress={handleReset}
|
||||
disabled={!hasChanges || isSaving}
|
||||
testID="host-page-append-system-prompt-reset"
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onPress={handleSave}
|
||||
disabled={!hasChanges || isSaving}
|
||||
testID="host-page-append-system-prompt-save"
|
||||
>
|
||||
{isSaving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PairDeviceRow() {
|
||||
const { theme } = useUnistyles();
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
@@ -631,6 +737,8 @@ function RemoveHostSection({ host, onRemoved }: { host: HostProfile; onRemoved?:
|
||||
|
||||
return (
|
||||
<SettingsSection title="Danger zone" testID="host-page-remove-host-card">
|
||||
<RestartDaemonCard host={host} />
|
||||
|
||||
<View style={settingsStyles.card}>
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
@@ -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",
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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<Partial<AgentSessionConfig> | undefined> = [];
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
this.createdConfigs.push(config);
|
||||
return new TestAgentSession(config);
|
||||
}
|
||||
|
||||
@@ -135,9 +138,11 @@ class TestAgentClient implements AgentClient {
|
||||
config?: Partial<AgentSessionConfig>,
|
||||
_launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
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");
|
||||
|
||||
@@ -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<Promise<void>>();
|
||||
private readonly agentStreamCoalescer: AgentStreamCoalescer;
|
||||
private mcpBaseUrl: string | null;
|
||||
private appendSystemPrompt: string;
|
||||
private onAgentAttention?: AgentAttentionCallback;
|
||||
private logger: Logger;
|
||||
private readonly rescueTimeouts: Required<AgentManagerRescueTimeouts>;
|
||||
@@ -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<string, number> = {};
|
||||
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<AgentSessionConfig> = { ...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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 `
|
||||
<orchestrator-mode>
|
||||
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.
|
||||
</orchestrator-mode>
|
||||
`;
|
||||
}
|
||||
@@ -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<ClaudeOptions["settingSources"]> = [
|
||||
@@ -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<ClaudeOptions> | 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",
|
||||
|
||||
@@ -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<string, unknown> = {};
|
||||
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<string, unknown> = { 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<string, unknown> = {
|
||||
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 } : {}),
|
||||
};
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export function composeSystemPromptParts(
|
||||
...parts: Array<string | null | undefined>
|
||||
): 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -256,6 +256,10 @@ function resolveAuthConfig(
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function resolveAppendSystemPrompt(persisted: ReturnType<typeof loadPersistedConfig>): 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"),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -186,6 +186,7 @@ function mergeMutableConfigIntoPersistedConfig(params: {
|
||||
injectIntoAgents: mutable.mcp.injectIntoAgents,
|
||||
},
|
||||
autoArchiveAfterMerge: mutable.autoArchiveAfterMerge,
|
||||
appendSystemPrompt: mutable.appendSystemPrompt,
|
||||
},
|
||||
agents:
|
||||
providerOverrides && Object.keys(providerOverrides).length > 0
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user