fix(goal): inject session goal into system prompt
Thread the /goal objective from the TUI into engine prompt assembly so follow-up turns can see the current session objective. Add prompt and engine regression tests that pin the session_goal block and verify empty goals are skipped.
This commit is contained in:
@@ -139,6 +139,7 @@ pub struct EngineConfig {
|
||||
/// Path to the user memory file (#489). Always populated; only
|
||||
/// consulted when `memory_enabled` is `true`.
|
||||
pub memory_path: PathBuf,
|
||||
pub goal_objective: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for EngineConfig {
|
||||
@@ -168,6 +169,7 @@ impl Default for EngineConfig {
|
||||
subagent_model_overrides: HashMap::new(),
|
||||
memory_enabled: false,
|
||||
memory_path: PathBuf::from("./memory.md"),
|
||||
goal_objective: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -355,13 +357,16 @@ impl Engine {
|
||||
let working_set_summary = session.working_set.summary_block(&config.workspace);
|
||||
let user_memory_block =
|
||||
crate::memory::compose_block(config.memory_enabled, &config.memory_path);
|
||||
let system_prompt = prompts::system_prompt_for_mode_with_context_and_skills(
|
||||
let system_prompt = prompts::system_prompt_for_mode_with_context_skills_and_session(
|
||||
AppMode::Agent,
|
||||
&config.workspace,
|
||||
None,
|
||||
Some(&config.skills_dir),
|
||||
Some(&config.instructions),
|
||||
user_memory_block.as_deref(),
|
||||
prompts::PromptSessionContext {
|
||||
user_memory_block: user_memory_block.as_deref(),
|
||||
goal_objective: config.goal_objective.as_deref(),
|
||||
},
|
||||
);
|
||||
session.system_prompt =
|
||||
append_working_set_summary(Some(system_prompt), working_set_summary.as_deref());
|
||||
@@ -461,6 +466,7 @@ impl Engine {
|
||||
content,
|
||||
mode,
|
||||
model,
|
||||
goal_objective,
|
||||
reasoning_effort,
|
||||
allow_shell,
|
||||
trust_mode,
|
||||
@@ -470,6 +476,7 @@ impl Engine {
|
||||
content,
|
||||
mode,
|
||||
model,
|
||||
goal_objective,
|
||||
reasoning_effort,
|
||||
allow_shell,
|
||||
trust_mode,
|
||||
@@ -653,6 +660,7 @@ impl Engine {
|
||||
new_message,
|
||||
mode,
|
||||
self.session.model.clone(),
|
||||
self.config.goal_objective.clone(),
|
||||
self.session.reasoning_effort.clone(),
|
||||
self.session.allow_shell,
|
||||
self.session.trust_mode,
|
||||
@@ -691,6 +699,7 @@ impl Engine {
|
||||
content: String,
|
||||
mode: AppMode,
|
||||
model: String,
|
||||
goal_objective: Option<String>,
|
||||
reasoning_effort: Option<String>,
|
||||
allow_shell: bool,
|
||||
trust_mode: bool,
|
||||
@@ -770,6 +779,7 @@ impl Engine {
|
||||
|
||||
self.session.model = model;
|
||||
self.config.model.clone_from(&self.session.model);
|
||||
self.config.goal_objective = goal_objective;
|
||||
self.session.reasoning_effort = reasoning_effort;
|
||||
self.session.allow_shell = allow_shell;
|
||||
self.config.allow_shell = allow_shell;
|
||||
@@ -1631,13 +1641,16 @@ impl Engine {
|
||||
.summary_block(&self.config.workspace);
|
||||
let user_memory_block =
|
||||
crate::memory::compose_block(self.config.memory_enabled, &self.config.memory_path);
|
||||
let base = prompts::system_prompt_for_mode_with_context_and_skills(
|
||||
let base = prompts::system_prompt_for_mode_with_context_skills_and_session(
|
||||
mode,
|
||||
&self.config.workspace,
|
||||
None,
|
||||
Some(&self.config.skills_dir),
|
||||
Some(&self.config.instructions),
|
||||
user_memory_block.as_deref(),
|
||||
prompts::PromptSessionContext {
|
||||
user_memory_block: user_memory_block.as_deref(),
|
||||
goal_objective: self.config.goal_objective.as_deref(),
|
||||
},
|
||||
);
|
||||
let stable_prompt =
|
||||
merge_system_prompts(Some(&base), self.session.compaction_summary_prompt.clone());
|
||||
|
||||
@@ -66,6 +66,27 @@ fn engine_handle_cancel_tracks_latest_turn_token() {
|
||||
assert!(!stale_token.is_cancelled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_initial_prompt_includes_configured_goal() {
|
||||
let config = EngineConfig {
|
||||
goal_objective: Some("Fix goal handoff".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let (engine, _handle) = Engine::new(config, &Config::default());
|
||||
let prompt = match engine.session.system_prompt {
|
||||
Some(SystemPrompt::Text(text)) => text,
|
||||
Some(SystemPrompt::Blocks(blocks)) => blocks
|
||||
.into_iter()
|
||||
.map(|block| block.text)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
None => panic!("expected system prompt"),
|
||||
};
|
||||
|
||||
assert!(prompt.contains("<session_goal>"));
|
||||
assert!(prompt.contains("Fix goal handoff"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_batch_requires_read_only_parallel_tools() {
|
||||
let plans = vec![make_plan(true, true, false, false)];
|
||||
|
||||
@@ -16,6 +16,7 @@ pub enum Op {
|
||||
content: String,
|
||||
mode: AppMode,
|
||||
model: String,
|
||||
goal_objective: Option<String>,
|
||||
/// Reasoning-effort tier: `"off" | "low" | "medium" | "high" | "max"`.
|
||||
/// `None` lets the provider apply its default.
|
||||
reasoning_effort: Option<String>,
|
||||
@@ -104,6 +105,7 @@ impl Op {
|
||||
content: content.into(),
|
||||
mode,
|
||||
model: model.into(),
|
||||
goal_objective: None,
|
||||
reasoning_effort,
|
||||
allow_shell,
|
||||
trust_mode,
|
||||
|
||||
@@ -3631,6 +3631,7 @@ async fn run_exec_agent(
|
||||
subagent_model_overrides: config.subagent_model_overrides(),
|
||||
memory_enabled: config.memory_enabled(),
|
||||
memory_path: config.memory_path(),
|
||||
goal_objective: None,
|
||||
};
|
||||
|
||||
let engine_handle = spawn_engine(engine_config, config);
|
||||
|
||||
@@ -12,6 +12,12 @@ use crate::project_context::{ProjectContext, load_project_context_with_parents};
|
||||
use crate::tui::app::AppMode;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct PromptSessionContext<'a> {
|
||||
pub user_memory_block: Option<&'a str>,
|
||||
pub goal_objective: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// Conventional location for the structured session-handoff artifact (#32).
|
||||
/// A previous session writes it on exit / `/compact`; the next session reads
|
||||
/// it back on startup and prepends it to the system prompt so a fresh agent
|
||||
@@ -260,6 +266,27 @@ pub fn system_prompt_for_mode_with_context_and_skills(
|
||||
skills_dir: Option<&Path>,
|
||||
instructions: Option<&[PathBuf]>,
|
||||
user_memory_block: Option<&str>,
|
||||
) -> SystemPrompt {
|
||||
system_prompt_for_mode_with_context_skills_and_session(
|
||||
mode,
|
||||
workspace,
|
||||
working_set_summary,
|
||||
skills_dir,
|
||||
instructions,
|
||||
PromptSessionContext {
|
||||
user_memory_block,
|
||||
goal_objective: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn system_prompt_for_mode_with_context_skills_and_session(
|
||||
mode: AppMode,
|
||||
workspace: &Path,
|
||||
working_set_summary: Option<&str>,
|
||||
skills_dir: Option<&Path>,
|
||||
instructions: Option<&[PathBuf]>,
|
||||
session_context: PromptSessionContext<'_>,
|
||||
) -> SystemPrompt {
|
||||
let mode_prompt = compose_mode_prompt(mode);
|
||||
|
||||
@@ -293,12 +320,21 @@ pub fn system_prompt_for_mode_with_context_and_skills(
|
||||
// 2.5b. User memory block (#489). Goes above skills/context-management
|
||||
// because it's session-stable: the memory file changes when the user
|
||||
// edits it via `/memory` or `# foo` quick-add, but not turn-over-turn.
|
||||
if let Some(memory_block) = user_memory_block
|
||||
if let Some(memory_block) = session_context.user_memory_block
|
||||
&& !memory_block.trim().is_empty()
|
||||
{
|
||||
full_prompt = format!("{full_prompt}\n\n{memory_block}");
|
||||
}
|
||||
|
||||
if let Some(goal_objective) = session_context.goal_objective
|
||||
&& !goal_objective.trim().is_empty()
|
||||
{
|
||||
full_prompt = format!(
|
||||
"{full_prompt}\n\n## Current Session Goal\n\n<session_goal>\n{}\n</session_goal>",
|
||||
goal_objective.trim()
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Skills block. #432: walks every candidate workspace
|
||||
// skills directory (`.agents/skills`, `skills`,
|
||||
// `.opencode/skills`, `.claude/skills`) plus the global
|
||||
@@ -503,6 +539,55 @@ mod tests {
|
||||
assert!(prompt.contains("### Next step"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_goal_is_injected_above_volatile_prompt_tail() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let prompt = match system_prompt_for_mode_with_context_skills_and_session(
|
||||
AppMode::Agent,
|
||||
tmp.path(),
|
||||
Some("## Repo Working Set\nsrc/lib.rs"),
|
||||
None,
|
||||
None,
|
||||
PromptSessionContext {
|
||||
user_memory_block: None,
|
||||
goal_objective: Some("Fix transcript corruption"),
|
||||
},
|
||||
) {
|
||||
SystemPrompt::Text(text) => text,
|
||||
SystemPrompt::Blocks(_) => panic!("expected text system prompt"),
|
||||
};
|
||||
|
||||
let goal_pos = prompt.find("<session_goal>").expect("goal block");
|
||||
let compact_pos = prompt.find("## Compaction Handoff").expect("compact block");
|
||||
let working_set_pos = prompt.find("## Repo Working Set").expect("working set");
|
||||
|
||||
assert!(prompt.contains("Fix transcript corruption"));
|
||||
assert!(goal_pos < compact_pos);
|
||||
assert!(goal_pos < working_set_pos);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_session_goal_is_not_injected() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let prompt = match system_prompt_for_mode_with_context_skills_and_session(
|
||||
AppMode::Agent,
|
||||
tmp.path(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
PromptSessionContext {
|
||||
user_memory_block: None,
|
||||
goal_objective: Some(" "),
|
||||
},
|
||||
) {
|
||||
SystemPrompt::Text(text) => text,
|
||||
SystemPrompt::Blocks(_) => panic!("expected text system prompt"),
|
||||
};
|
||||
|
||||
assert!(!prompt.contains("<session_goal>"));
|
||||
assert!(!prompt.contains("## Current Session Goal"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn when_not_to_use_sections_present() {
|
||||
let prompt = compose_prompt(AppMode::Agent, Personality::Calm);
|
||||
|
||||
@@ -1566,6 +1566,7 @@ impl RuntimeThreadManager {
|
||||
subagent_model_overrides: self.config.subagent_model_overrides(),
|
||||
memory_enabled: self.config.memory_enabled(),
|
||||
memory_path: self.config.memory_path(),
|
||||
goal_objective: None,
|
||||
};
|
||||
|
||||
let engine = spawn_engine(engine_cfg, &self.config);
|
||||
|
||||
@@ -534,6 +534,7 @@ fn build_engine_config(app: &App, config: &Config) -> EngineConfig {
|
||||
subagent_model_overrides: config.subagent_model_overrides(),
|
||||
memory_enabled: config.memory_enabled(),
|
||||
memory_path: config.memory_path(),
|
||||
goal_objective: app.goal.goal_objective.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3017,11 +3018,19 @@ async fn dispatch_user_message(
|
||||
);
|
||||
let content = queued_message_content_for_app(app, &message, cwd);
|
||||
let message_index = app.api_messages.len();
|
||||
app.system_prompt = Some(prompts::system_prompt_for_mode_with_context(
|
||||
app.mode,
|
||||
&app.workspace,
|
||||
None,
|
||||
));
|
||||
app.system_prompt = Some(
|
||||
prompts::system_prompt_for_mode_with_context_skills_and_session(
|
||||
app.mode,
|
||||
&app.workspace,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
prompts::PromptSessionContext {
|
||||
user_memory_block: None,
|
||||
goal_objective: app.goal.goal_objective.as_deref(),
|
||||
},
|
||||
),
|
||||
);
|
||||
app.add_message(HistoryCell::User {
|
||||
content: message.display.clone(),
|
||||
});
|
||||
@@ -3065,6 +3074,7 @@ async fn dispatch_user_message(
|
||||
content,
|
||||
mode: app.mode,
|
||||
model: effective_model,
|
||||
goal_objective: app.goal.goal_objective.clone(),
|
||||
reasoning_effort: app.reasoning_effort.api_value().map(str::to_string),
|
||||
allow_shell: app.allow_shell,
|
||||
trust_mode: app.trust_mode,
|
||||
|
||||
Reference in New Issue
Block a user