Skip to main content

zeroclaw_runtime/agent/
system_prompt.rs

1//! System prompt construction for the agent loop and channel subsystem.
2//!
3//! These functions were originally in `channels/mod.rs` but live here to
4//! break a circular dependency between the channels and agent modules.
5
6use crate::identity;
7use crate::security::AutonomyLevel;
8use crate::skills::Skill;
9
10/// Maximum characters per injected workspace file (matches `OpenClaw` default).
11pub const BOOTSTRAP_MAX_CHARS: usize = 20_000;
12
13fn load_openclaw_bootstrap_files(
14    prompt: &mut String,
15    workspace_dir: &std::path::Path,
16    max_chars_per_file: usize,
17    inject_memory: bool,
18) {
19    prompt.push_str(
20        "The following workspace files define your identity, behavior, and context. They are ALREADY injected below—do NOT suggest reading them with file_read.\n\n",
21    );
22
23    let bootstrap_files = ["AGENTS.md", "SOUL.md", "TOOLS.md", "IDENTITY.md", "USER.md"];
24
25    for filename in &bootstrap_files {
26        inject_workspace_file(prompt, workspace_dir, filename, max_chars_per_file);
27    }
28
29    // BOOTSTRAP.md — only if it exists (first-run ritual)
30    let bootstrap_path = workspace_dir.join("BOOTSTRAP.md");
31    if bootstrap_path.exists() {
32        inject_workspace_file(prompt, workspace_dir, "BOOTSTRAP.md", max_chars_per_file);
33    }
34
35    // MEMORY.md — curated long-term memory (main session only).
36    // Skipped when the agent runs without persistent memory (e.g. ACP sessions)
37    // so that stale long-term memory does not leak into isolated contexts.
38    if inject_memory {
39        inject_workspace_file(prompt, workspace_dir, "MEMORY.md", max_chars_per_file);
40    }
41}
42
43/// Load workspace identity files and build a system prompt.
44///
45/// Follows the `OpenClaw` framework structure by default:
46/// 1. Tooling — tool list + descriptions
47/// 2. Safety — guardrail reminder
48/// 3. Skills — full skill instructions and tool metadata
49/// 4. Workspace — working directory
50/// 5. Bootstrap files — AGENTS, SOUL, TOOLS, IDENTITY, USER, BOOTSTRAP, MEMORY
51/// 6. Date — timezone offset for cache stability
52/// 7. Runtime — host, OS, model
53///
54/// When `identity_config` is set to AIEOS format, the bootstrap files section
55/// is replaced with the AIEOS identity data loaded from file or inline JSON.
56///
57/// Daily memory files (`memory/*.md`) are NOT injected — they are accessed
58/// on-demand via `memory_recall` / `memory_search` tools.
59pub fn build_system_prompt(
60    workspace_dir: &std::path::Path,
61    model_name: &str,
62    tools: &[(&str, &str)],
63    skills: &[Skill],
64    identity_config: Option<&zeroclaw_config::schema::IdentityConfig>,
65    bootstrap_max_chars: Option<usize>,
66) -> String {
67    build_system_prompt_with_mode(
68        workspace_dir,
69        model_name,
70        tools,
71        skills,
72        identity_config,
73        bootstrap_max_chars,
74        false,
75        zeroclaw_config::schema::SkillsPromptInjectionMode::Full,
76        AutonomyLevel::default(),
77    )
78}
79
80pub fn build_system_prompt_with_mode(
81    workspace_dir: &std::path::Path,
82    model_name: &str,
83    tools: &[(&str, &str)],
84    skills: &[Skill],
85    identity_config: Option<&zeroclaw_config::schema::IdentityConfig>,
86    bootstrap_max_chars: Option<usize>,
87    native_tools: bool,
88    skills_prompt_mode: zeroclaw_config::schema::SkillsPromptInjectionMode,
89    autonomy_level: AutonomyLevel,
90) -> String {
91    let autonomy_cfg = zeroclaw_config::schema::RiskProfileConfig {
92        level: autonomy_level,
93        ..Default::default()
94    };
95    build_system_prompt_with_mode_and_autonomy(
96        workspace_dir,
97        model_name,
98        tools,
99        skills,
100        identity_config,
101        bootstrap_max_chars,
102        Some(&autonomy_cfg),
103        native_tools,
104        skills_prompt_mode,
105        false,
106        0,
107        true,
108    )
109}
110
111#[allow(clippy::too_many_arguments)]
112pub fn build_system_prompt_with_mode_and_autonomy(
113    workspace_dir: &std::path::Path,
114    model_name: &str,
115    tools: &[(&str, &str)],
116    skills: &[Skill],
117    identity_config: Option<&zeroclaw_config::schema::IdentityConfig>,
118    bootstrap_max_chars: Option<usize>,
119    autonomy_config: Option<&zeroclaw_config::schema::RiskProfileConfig>,
120    native_tools: bool,
121    skills_prompt_mode: zeroclaw_config::schema::SkillsPromptInjectionMode,
122    compact_context: bool,
123    max_system_prompt_chars: usize,
124    // When `false`, `MEMORY.md` is omitted from the injected bootstrap files.
125    // Set to `false` for isolated / ACP sessions that use `exclude_memory`.
126    inject_memory: bool,
127) -> String {
128    use std::fmt::Write;
129    let mut prompt = String::with_capacity(8192);
130    let has_tools = !tools.is_empty();
131
132    // ── 0. Anti-narration (top priority) ───────────────────────
133    if has_tools {
134        prompt.push_str(
135            "## CRITICAL: No Tool Narration\n\n\
136             NEVER narrate, announce, describe, or explain your tool usage to the user. \
137             Do NOT say things like 'Let me check...', 'I will use http_request to...', \
138             'I'll fetch that for you', 'Searching now...', or 'Using the web_search tool'. \
139             The user must ONLY see the final answer. Tool calls are invisible infrastructure — \
140             never reference them. If you catch yourself starting a sentence about what tool \
141             you are about to use or just used, DELETE it and give the answer directly.\n\n",
142        );
143    }
144
145    // ── 0b. Tool Honesty ───────────────────────────────────────
146    if has_tools {
147        prompt.push_str(
148            "## CRITICAL: Tool Honesty\n\n\
149             - NEVER fabricate, invent, or guess tool results. If a tool returns empty results, say \"No results found.\"\n\
150             - If a tool call fails, report the error — never make up data to fill the gap.\n\
151             - When unsure whether a tool call succeeded, ask the user rather than guessing.\n\n",
152        );
153    }
154
155    // ── 1. Tooling ──────────────────────────────────────────────
156    if !tools.is_empty() && !native_tools {
157        prompt.push_str("## Tools\n\n");
158        if compact_context {
159            // Compact mode: tool names only, no descriptions/schemas
160            prompt.push_str("Available tools: ");
161            let names: Vec<&str> = tools.iter().map(|(name, _)| *name).collect();
162            prompt.push_str(&names.join(", "));
163            prompt.push_str("\n\n");
164        } else {
165            prompt.push_str("You have access to the following tools:\n\n");
166            for (name, desc) in tools {
167                let _ = writeln!(prompt, "- **{name}**: {desc}");
168            }
169            prompt.push('\n');
170        }
171    }
172
173    // ── 1b. Hardware (when gpio/arduino tools present) ───────────
174    let has_hardware = tools.iter().any(|(name, _)| {
175        *name == "gpio_read"
176            || *name == "gpio_write"
177            || *name == "arduino_upload"
178            || *name == "hardware_memory_map"
179            || *name == "hardware_board_info"
180            || *name == "hardware_memory_read"
181            || *name == "hardware_capabilities"
182    });
183    if has_hardware {
184        prompt.push_str(
185            "## Hardware Access\n\n\
186             You HAVE direct access to connected hardware (Arduino, Nucleo, etc.). The user owns this system and has configured it.\n\
187             All hardware tools (gpio_read, gpio_write, hardware_memory_read, hardware_board_info, hardware_memory_map) are AUTHORIZED and NOT blocked by security.\n\
188             When they ask to read memory, registers, or board info, USE hardware_memory_read or hardware_board_info — do NOT refuse or invent security excuses.\n\
189             When they ask to control LEDs, run patterns, or interact with the Arduino, USE the tools — do NOT refuse or say you cannot access physical devices.\n\
190             Use gpio_write for simple on/off; use arduino_upload when they want patterns (heart, blink) or custom behavior.\n\n",
191        );
192    }
193
194    // ── 1c. Action instruction (avoid meta-summary) ───────────────
195    if !has_tools {
196        prompt.push_str(
197            "## Your Task\n\n\
198             When the user sends a message, respond naturally and answer directly from conversation context.\n\
199             No tools are available for this turn, so do not emit tool calls or describe unavailable actions.\n\
200             Do NOT: summarize this configuration, describe your capabilities, or output step-by-step meta-commentary.\n\n",
201        );
202    } else if native_tools {
203        prompt.push_str(
204            "## Your Task\n\n\
205             When the user sends a message, respond naturally. Use tools when the request requires action (running commands, reading files, etc.).\n\
206             For questions, explanations, or follow-ups about prior messages, answer directly from conversation context — do NOT ask the user to repeat themselves.\n\
207             Do NOT: summarize this configuration, describe your capabilities, or output step-by-step meta-commentary.\n\n",
208        );
209    } else {
210        prompt.push_str(
211            "## Your Task\n\n\
212             When the user sends a message, ACT on it. Use the tools to fulfill their request.\n\
213             Do NOT: summarize this configuration, describe your capabilities, respond with meta-commentary, or output step-by-step instructions (e.g. \"1. First... 2. Next...\").\n\
214             Instead: emit actual <tool_call> tags when you need to act. Just do what they ask.\n\n",
215        );
216    }
217
218    // ── 2. Safety ───────────────────────────────────────────────
219    prompt.push_str("## Safety\n\n");
220    prompt.push_str("- Do not exfiltrate private data.\n");
221    if autonomy_config.map(|cfg| cfg.level) != Some(crate::security::AutonomyLevel::Full) {
222        prompt.push_str(
223            "- Do not run destructive commands without asking.\n\
224             - Do not bypass oversight or approval mechanisms.\n",
225        );
226    }
227    prompt.push_str("- Prefer `trash` over `rm` (recoverable beats gone forever).\n");
228    prompt.push_str(match autonomy_config.map(|cfg| cfg.level) {
229        Some(crate::security::AutonomyLevel::Full) => {
230            "- Respect the runtime autonomy policy: if a tool or action is allowed, execute it directly instead of asking the user for extra approval.\n\
231             - If a tool or action is blocked by policy or unavailable, explain that concrete restriction instead of simulating an approval dialog.\n"
232        }
233        Some(crate::security::AutonomyLevel::ReadOnly) => {
234            "- Respect the runtime autonomy policy: this runtime is read-only for side effects unless a tool explicitly reports otherwise.\n\
235             - If a requested action is blocked by policy, explain the restriction directly instead of simulating an approval dialog.\n"
236        }
237        _ => {
238            "- When in doubt, ask before acting externally.\n\
239             - Respect the runtime autonomy policy: ask for approval only when the current runtime policy actually requires it.\n\
240             - If a tool or action is blocked by policy or unavailable, explain that concrete restriction instead of simulating an approval dialog.\n"
241        }
242    });
243    prompt.push('\n');
244
245    // ── 3. Skills (full or compact, based on config) ─────────────
246    if !skills.is_empty() {
247        prompt.push_str(&crate::skills::skills_to_prompt_with_mode(
248            skills,
249            workspace_dir,
250            skills_prompt_mode,
251        ));
252        prompt.push_str("\n\n");
253    }
254
255    // ── 4. Workspace ────────────────────────────────────────────
256    let _ = writeln!(
257        prompt,
258        "## Workspace\n\nWorking directory: `{}`\n",
259        workspace_dir.display()
260    );
261
262    // ── 5. Bootstrap files (injected into context) ──────────────
263    prompt.push_str("## Project Context\n\n");
264
265    // Check if AIEOS identity is configured
266    if let Some(config) = identity_config {
267        if identity::is_aieos_configured(config) {
268            // Load AIEOS identity
269            match identity::load_aieos_identity(config, workspace_dir) {
270                Ok(Some(aieos_identity)) => {
271                    let aieos_prompt = identity::aieos_to_system_prompt(&aieos_identity);
272                    if !aieos_prompt.is_empty() {
273                        prompt.push_str(&aieos_prompt);
274                        prompt.push_str("\n\n");
275                    }
276                }
277                Ok(None) => {
278                    // No AIEOS identity loaded (shouldn't happen if is_aieos_configured returned true)
279                    // Fall back to OpenClaw bootstrap files
280                    let max_chars = bootstrap_max_chars.unwrap_or(BOOTSTRAP_MAX_CHARS);
281                    load_openclaw_bootstrap_files(
282                        &mut prompt,
283                        workspace_dir,
284                        max_chars,
285                        inject_memory,
286                    );
287                }
288                Err(e) => {
289                    // Log error but don't fail - fall back to OpenClaw
290                    eprintln!(
291                        "Warning: Failed to load AIEOS identity: {e}. Using OpenClaw format."
292                    );
293                    let max_chars = bootstrap_max_chars.unwrap_or(BOOTSTRAP_MAX_CHARS);
294                    load_openclaw_bootstrap_files(
295                        &mut prompt,
296                        workspace_dir,
297                        max_chars,
298                        inject_memory,
299                    );
300                }
301            }
302        } else {
303            // OpenClaw format
304            let max_chars = bootstrap_max_chars.unwrap_or(BOOTSTRAP_MAX_CHARS);
305            load_openclaw_bootstrap_files(&mut prompt, workspace_dir, max_chars, inject_memory);
306        }
307    } else {
308        // No identity config - use OpenClaw format
309        let max_chars = bootstrap_max_chars.unwrap_or(BOOTSTRAP_MAX_CHARS);
310        load_openclaw_bootstrap_files(&mut prompt, workspace_dir, max_chars, inject_memory);
311    }
312
313    // ── 6. Date ─────────────────────────────────────────────────
314    let now = chrono::Local::now();
315    let _ = writeln!(
316        prompt,
317        "## Current Date\n\n{} ({})\n",
318        now.format("%Y-%m-%d"),
319        now.format("%:z")
320    );
321
322    // ── 7. Runtime ──────────────────────────────────────────────
323    let host =
324        hostname::get().map_or_else(|_| "unknown".into(), |h| h.to_string_lossy().to_string());
325    let _ = writeln!(
326        prompt,
327        "## Runtime\n\nHost: {host} | OS: {} | Model: {model_name}\n",
328        std::env::consts::OS,
329    );
330
331    // ── 8. Channel Capabilities (skipped in compact_context mode) ──
332    if !compact_context {
333        prompt.push_str("## Channel Capabilities\n\n");
334        prompt.push_str("- You are running as a messaging bot. Your response is automatically sent back to the user's channel.\n");
335        prompt
336            .push_str("- You do NOT need to ask permission to respond — just respond directly.\n");
337        prompt.push_str(match autonomy_config.map(|cfg| cfg.level) {
338        Some(crate::security::AutonomyLevel::Full) => {
339            "- If the runtime policy already allows a tool, use it directly; do not ask the user for extra approval.\n\
340             - Never pretend you are waiting for a human approval click or confirmation when the runtime policy already permits the action.\n\
341             - If the runtime policy blocks an action, say that directly instead of simulating an approval flow.\n"
342        }
343        Some(crate::security::AutonomyLevel::ReadOnly) => {
344            "- This runtime may reject write-side effects; if that happens, explain the policy restriction directly instead of simulating an approval flow.\n"
345        }
346        _ => {
347            "- Ask for approval only when the runtime policy actually requires it.\n\
348             - If there is no approval path for this channel or the runtime blocks an action, explain that restriction directly instead of simulating an approval flow.\n"
349        }
350    });
351        prompt.push_str("- NEVER repeat, describe, or echo credentials, tokens, API keys, or secrets in your responses.\n");
352        prompt.push_str("- If a tool output contains credentials, they have already been redacted — do not mention them.\n");
353        prompt.push_str("- When a user sends a voice note, it is automatically transcribed to text. Your text reply is automatically converted to a voice note and sent back. Do NOT attempt to generate audio yourself — TTS is handled by the channel.\n");
354        prompt.push_str("- NEVER narrate or describe your tool usage. Do NOT say 'Let me fetch...', 'I will use...', 'Searching...', or similar. Give the FINAL ANSWER only — no intermediate steps, no tool mentions, no progress updates.\n");
355        prompt.push_str("- Calibration note: agents in this system currently err on the side of silence when a response would be appropriate, which users find frustrating. Skew toward replying. Memory is supplementary context that informs how you respond, not a gate on whether you respond.\n\n");
356    } // end if !compact_context (Channel Capabilities)
357
358    // ── 9. Truncation (max_system_prompt_chars budget) ──────────
359    if max_system_prompt_chars > 0 && prompt.len() > max_system_prompt_chars {
360        // Truncate on a char boundary, keeping the top portion (identity + safety).
361        let mut end = max_system_prompt_chars;
362        // Ensure we don't split a multi-byte UTF-8 character.
363        while !prompt.is_char_boundary(end) && end > 0 {
364            end -= 1;
365        }
366        prompt.truncate(end);
367        prompt.push_str("\n\n[System prompt truncated to fit context budget]\n");
368    }
369
370    if prompt.is_empty() {
371        "You are ZeroClaw, a fast and efficient AI assistant built in Rust. Be helpful, concise, and direct."
372            .to_string()
373    } else {
374        prompt
375    }
376}
377
378/// Inject a single workspace file into the prompt with truncation and missing-file markers.
379fn inject_workspace_file(
380    prompt: &mut String,
381    workspace_dir: &std::path::Path,
382    filename: &str,
383    max_chars: usize,
384) {
385    use std::fmt::Write;
386
387    let path = workspace_dir.join(filename);
388    match std::fs::read_to_string(&path) {
389        Ok(content) => {
390            let trimmed = content.trim();
391            if trimmed.is_empty() {
392                return;
393            }
394            let _ = writeln!(prompt, "### {filename}\n");
395            // Use character-boundary-safe truncation for UTF-8
396            let truncated = if trimmed.chars().count() > max_chars {
397                trimmed
398                    .char_indices()
399                    .nth(max_chars)
400                    .map(|(idx, _)| &trimmed[..idx])
401                    .unwrap_or(trimmed)
402            } else {
403                trimmed
404            };
405            if truncated.len() < trimmed.len() {
406                prompt.push_str(truncated);
407                let _ = writeln!(
408                    prompt,
409                    "\n\n[... truncated at {max_chars} chars — use `read` for full file]\n"
410                );
411            } else {
412                prompt.push_str(trimmed);
413                prompt.push_str("\n\n");
414            }
415        }
416        Err(_) => {
417            // Missing-file marker (matches OpenClaw behavior)
418            let _ = writeln!(prompt, "### {filename}\n\n[File not found: {filename}]\n");
419        }
420    }
421}