Some checks failed
build-prerelease / Package helexa-neuron-ada RPM (push) Blocked by required conditions
build-prerelease / Package helexa-neuron-ampere RPM (push) Blocked by required conditions
build-prerelease / Package helexa-neuron-blackwell RPM (push) Blocked by required conditions
build-prerelease / Resolve version stamps (push) Successful in 37s
CI / Format (push) Successful in 36s
CI / Clippy (push) Successful in 2m44s
CI / Test (push) Successful in 5m3s
build-prerelease / Build cortex binary (push) Successful in 4m36s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
build-prerelease / Package cortex RPM (push) Successful in 1m27s
build-prerelease / Build neuron-blackwell (push) Successful in 6m37s
build-prerelease / Build neuron-ampere (push) Successful in 8m12s
build-prerelease / Build neuron-ada (push) Successful in 5m32s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been cancelled
Plan mode is the most restrictive of the three session modes: bash is disabled outright, writes are confined to a per-project plan directory under $XDG_DATA_HOME/helexa-acp/plans/<basename>-<8hex>/, and reads / list_dir are unrestricted. The system prompt is rebuilt at the top of every round so a mid-turn switch into (or out of) plan mode takes effect on the next streaming round, and plan mode appends a 3-option menu instructing the model to stop and let the user pick how to proceed once the plan is complete. The project id is basename + FNV-1a-32 of the cwd so it stays stable across runs (SipHash's DefaultHasher reseeds per process), while still disambiguating multiple checkouts that share a final path component. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
275 lines
10 KiB
Rust
275 lines
10 KiB
Rust
//! System prompt assembly.
|
|
//!
|
|
//! The system message has two parts:
|
|
//!
|
|
//! 1. A short human-readable preamble (working directory, style
|
|
//! instructions). Either the built-in [`DEFAULT_PROMPT`] or a
|
|
//! user-supplied file at `HELEXA_ACP_SYSTEM_PROMPT_PATH` /
|
|
//! `system_prompt_path`. `{cwd}` is substituted in both.
|
|
//! 2. A `# Tools` block in Qwen3 Hermes format (see [`crate::qwen3`])
|
|
//! describing the available functions. This is what makes the
|
|
//! model actually call them — neuron/cortex don't honour the
|
|
//! OpenAI `tools` API field, so the tool list has to live in the
|
|
//! prompt itself.
|
|
|
|
use agent_client_protocol::schema::SessionModeId;
|
|
use anyhow::Context;
|
|
use std::path::Path;
|
|
|
|
use crate::provider::ToolSpec;
|
|
use crate::qwen3;
|
|
use crate::session::MODE_PLAN;
|
|
|
|
const DEFAULT_PROMPT: &str = "\
|
|
You are helexa-acp, a coding assistant working inside an editor.
|
|
|
|
Working directory: {cwd}
|
|
|
|
Use the tools described below whenever the user's request involves
|
|
looking at or modifying files, or running commands. Do not ask the
|
|
user to paste file contents you could read yourself. All file paths
|
|
must be absolute. Writes and shell commands may prompt the user for
|
|
permission depending on the session mode.
|
|
|
|
Be concise; the user is reading your output in an editor pane.";
|
|
|
|
/// Build the system prompt for a session.
|
|
///
|
|
/// - `cwd`: session working directory (substituted for `{cwd}` in
|
|
/// the preamble — both the default and any user-supplied template).
|
|
/// - `override_path`: path to a user-supplied template, already
|
|
/// resolved by [`crate::config::Config`]. The `# Tools` block is
|
|
/// appended *after* the user's template so a custom preamble
|
|
/// still gets the tool descriptions the model needs.
|
|
/// - `tools`: the tools to advertise. Empty list → no `# Tools`
|
|
/// block is appended at all.
|
|
/// - `mode`: current session mode. When the mode is [`MODE_PLAN`]
|
|
/// a plan-mode addendum describing the restrictions and the
|
|
/// completion menu is appended *after* the `# Tools` block so it
|
|
/// is the last thing the model reads before user input.
|
|
/// - `plan_dir`: resolved plan directory for the cwd. Only consulted
|
|
/// when `mode == MODE_PLAN`. `None` means the plan directory could
|
|
/// not be resolved (no `HOME` / `XDG_DATA_HOME`) — the addendum
|
|
/// still renders but with a placeholder so the model knows to
|
|
/// surface the error to the user rather than guess a path.
|
|
pub fn build_system_prompt(
|
|
cwd: &Path,
|
|
override_path: Option<&Path>,
|
|
tools: &[ToolSpec],
|
|
mode: &SessionModeId,
|
|
plan_dir: Option<&Path>,
|
|
) -> anyhow::Result<String> {
|
|
let template = match override_path {
|
|
Some(path) => std::fs::read_to_string(path)
|
|
.with_context(|| format!("read system prompt from {}", path.display()))?,
|
|
None => DEFAULT_PROMPT.to_string(),
|
|
};
|
|
let mut prompt = template.replace("{cwd}", &cwd.display().to_string());
|
|
prompt.push_str(&qwen3::render_tool_block(tools));
|
|
if mode.0.as_ref() == MODE_PLAN {
|
|
prompt.push_str(&render_plan_mode_block(plan_dir));
|
|
}
|
|
Ok(prompt)
|
|
}
|
|
|
|
/// Plan-mode instruction block. Tells the model:
|
|
///
|
|
/// 1. Where it may write — only inside `plan_dir`.
|
|
/// 2. What it may *not* do — bash is disabled; writes outside
|
|
/// `plan_dir` are refused by the runtime.
|
|
/// 3. How to finish — emit the 3-option menu so the user can
|
|
/// switch modes and either kick off implementation (with or
|
|
/// without permission prompts) or keep iterating on the plan.
|
|
fn render_plan_mode_block(plan_dir: Option<&Path>) -> String {
|
|
let plan_path = plan_dir
|
|
.map(|p| p.display().to_string())
|
|
.unwrap_or_else(|| "<plan directory could not be resolved — tell the user>".to_string());
|
|
format!(
|
|
"\n\n# Plan mode\n\
|
|
\n\
|
|
You are in **plan mode**. Your task is to draft a written\n\
|
|
implementation plan for the user; you must NOT modify any\n\
|
|
project files or run shell commands.\n\
|
|
\n\
|
|
Rules in plan mode:\n\
|
|
\n\
|
|
- `read_file` and `list_dir` are unrestricted — use them to\n\
|
|
explore the codebase as needed.\n\
|
|
- `write_file` and `edit_file` are allowed ONLY under the\n\
|
|
plan directory: `{plan_path}`. The runtime will refuse any\n\
|
|
write outside it.\n\
|
|
- `bash` is disabled. Do not call it.\n\
|
|
\n\
|
|
Write the plan as one or more Markdown files under\n\
|
|
`{plan_path}`. Use descriptive filenames\n\
|
|
(`01-overview.md`, `02-data-model.md`, etc.). It is fine to\n\
|
|
iterate — overwrite the file when you refine a section.\n\
|
|
\n\
|
|
When the plan is complete, do NOT begin implementation.\n\
|
|
Instead, end your turn with this menu, verbatim, so the\n\
|
|
user can choose how to proceed:\n\
|
|
\n\
|
|
---\n\
|
|
**Plan complete.** To proceed, switch the session mode in\n\
|
|
the agent dropdown and send a follow-up message:\n\
|
|
\n\
|
|
1. **Bypass Permissions** — implement the plan now, skipping\n\
|
|
per-tool permission prompts.\n\
|
|
2. **Default** — implement the plan now, prompting before\n\
|
|
each write or shell command.\n\
|
|
3. **Plan** (stay here) — refine the plan; reply with the\n\
|
|
change you want and I will revise it.\n\
|
|
---\n"
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::session::{MODE_DEFAULT, MODE_PLAN};
|
|
use std::io::Write;
|
|
|
|
fn default_mode() -> SessionModeId {
|
|
SessionModeId::new(MODE_DEFAULT)
|
|
}
|
|
fn plan_mode() -> SessionModeId {
|
|
SessionModeId::new(MODE_PLAN)
|
|
}
|
|
|
|
#[test]
|
|
fn default_prompt_substitutes_cwd() {
|
|
let prompt =
|
|
build_system_prompt(Path::new("/home/me/proj"), None, &[], &default_mode(), None)
|
|
.unwrap();
|
|
assert!(
|
|
prompt.contains("/home/me/proj"),
|
|
"cwd not interpolated: {prompt}"
|
|
);
|
|
assert!(prompt.contains("helexa-acp"));
|
|
assert!(
|
|
!prompt.contains("{cwd}"),
|
|
"left-over placeholder in default prompt"
|
|
);
|
|
// With no tools, the # Tools block is absent.
|
|
assert!(!prompt.contains("# Tools"));
|
|
// Default mode does not get the plan-mode addendum.
|
|
assert!(!prompt.contains("# Plan mode"));
|
|
}
|
|
|
|
#[test]
|
|
fn tools_are_appended_in_hermes_format() {
|
|
let spec = ToolSpec {
|
|
name: "read_file".into(),
|
|
description: "Read a file.".into(),
|
|
parameters: serde_json::json!({"type":"object","properties":{}, "required":[]}),
|
|
};
|
|
let prompt =
|
|
build_system_prompt(Path::new("/x"), None, &[spec], &default_mode(), None).unwrap();
|
|
assert!(prompt.contains("# Tools"));
|
|
assert!(prompt.contains("<tools>"));
|
|
assert!(prompt.contains("\"name\":\"read_file\""));
|
|
assert!(prompt.contains("<tool_call>"));
|
|
}
|
|
|
|
#[test]
|
|
fn override_path_is_read_and_templated() {
|
|
let mut tmp = tempfile_in_target("prompt.txt");
|
|
tmp.write_all(b"custom prompt for {cwd} only").unwrap();
|
|
tmp.flush().unwrap();
|
|
|
|
let path = tmp.path().to_path_buf();
|
|
drop(tmp);
|
|
|
|
let prompt = build_system_prompt(
|
|
Path::new("/etc"),
|
|
Some(path.as_path()),
|
|
&[],
|
|
&default_mode(),
|
|
None,
|
|
)
|
|
.expect("read override");
|
|
assert_eq!(prompt, "custom prompt for /etc only");
|
|
|
|
let _ = std::fs::remove_file(&path);
|
|
}
|
|
|
|
#[test]
|
|
fn missing_override_path_errors() {
|
|
let err = build_system_prompt(
|
|
Path::new("/tmp"),
|
|
Some(Path::new("/definitely/not/a/real/path")),
|
|
&[],
|
|
&default_mode(),
|
|
None,
|
|
)
|
|
.unwrap_err();
|
|
assert!(format!("{err:#}").contains("read system prompt"));
|
|
}
|
|
|
|
#[test]
|
|
fn plan_mode_addendum_includes_plan_dir_and_menu() {
|
|
let plan_dir = Path::new("/home/me/.local/share/helexa-acp/plans/proj-deadbeef");
|
|
let prompt = build_system_prompt(
|
|
Path::new("/home/me/proj"),
|
|
None,
|
|
&[],
|
|
&plan_mode(),
|
|
Some(plan_dir),
|
|
)
|
|
.unwrap();
|
|
assert!(prompt.contains("# Plan mode"));
|
|
assert!(
|
|
prompt.contains(plan_dir.to_str().unwrap()),
|
|
"plan dir not interpolated: {prompt}"
|
|
);
|
|
// The 3-option menu must be present so the model emits it verbatim.
|
|
assert!(prompt.contains("Bypass Permissions"));
|
|
assert!(prompt.contains("**Default**"));
|
|
assert!(prompt.contains("3. **Plan**"));
|
|
// Bash disabled instruction must be present.
|
|
assert!(prompt.contains("`bash` is disabled"));
|
|
}
|
|
|
|
#[test]
|
|
fn plan_mode_addendum_handles_unresolved_plan_dir() {
|
|
let prompt =
|
|
build_system_prompt(Path::new("/home/me/proj"), None, &[], &plan_mode(), None).unwrap();
|
|
assert!(prompt.contains("# Plan mode"));
|
|
assert!(prompt.contains("could not be resolved"));
|
|
}
|
|
|
|
/// Tiny temp-file helper that doesn't pull in the `tempfile` crate.
|
|
/// Writes under `target/` so it's cleaned up by `cargo clean`.
|
|
fn tempfile_in_target(name: &str) -> TempHandle {
|
|
let base = std::env::var("CARGO_TARGET_TMPDIR")
|
|
.ok()
|
|
.map(std::path::PathBuf::from)
|
|
.unwrap_or_else(std::env::temp_dir);
|
|
let _ = std::fs::create_dir_all(&base);
|
|
let pid = std::process::id();
|
|
let path = base.join(format!("helexa-acp-{pid}-{name}"));
|
|
let file = std::fs::File::create(&path).expect("create temp file");
|
|
TempHandle { file, path }
|
|
}
|
|
|
|
struct TempHandle {
|
|
file: std::fs::File,
|
|
path: std::path::PathBuf,
|
|
}
|
|
|
|
impl TempHandle {
|
|
fn path(&self) -> &Path {
|
|
&self.path
|
|
}
|
|
}
|
|
|
|
impl Write for TempHandle {
|
|
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
|
self.file.write(buf)
|
|
}
|
|
fn flush(&mut self) -> std::io::Result<()> {
|
|
self.file.flush()
|
|
}
|
|
}
|
|
}
|