Add hierarchical workspace instructions

This commit is contained in:
Georg Bauer
2026-08-29 21:57:11 +02:00
parent e35f57bc83
commit 1597ddbc11
11 changed files with 1431 additions and 213 deletions

View File

@@ -5,7 +5,7 @@ const TITLE_INSTRUCTION: &str = "Give this conversation a short title of at most
Reply with the title alone: no quotes, no trailing period, no explanation.";
const TITLE_MAX_TOKENS: i32 = 48;
const TITLE_MAX_CHARS: usize = 60;
const AGENTS_PREFIX: &str = "Project instructions from AGENTS.md:\n\n";
const LEGACY_AGENTS_PREFIX: &str = "Project instructions from AGENTS.md:\n\n";
/// A one-shot generation that produces a session title. It runs against the
/// transient KV cache, so it never creates or touches a stored session.
@@ -23,7 +23,8 @@ pub(super) struct TitleRequest {
pub(super) enum PendingContinuation {
None,
User(String),
Tool(String),
Tool(crate::agent::ToolRunResult),
DurableTool(Vec<PathBuf>),
}
#[cfg(target_os = "macos")]
@@ -36,15 +37,17 @@ pub(super) struct CompactionRequest {
#[cfg(target_os = "macos")]
#[derive(Clone, Copy)]
enum ToolCheckStage {
Initial,
AfterCompaction,
BoundedError,
ResultInitial,
ResultAfterCompaction,
BoundedResult,
Instructions,
InstructionsAfterCompaction,
}
#[cfg(target_os = "macos")]
pub(super) struct ToolResultCheck {
pub(super) active: ActiveGeneration,
result: String,
result: crate::agent::ToolRunResult,
stage: ToolCheckStage,
}
@@ -63,6 +66,7 @@ pub(crate) struct ChatMessage {
pub(super) content: String,
pub(super) model_content: Option<String>,
pub(super) tool_approval_reasons: Vec<Option<String>>,
pub(super) instruction_metadata: Option<String>,
pub(super) markdown: markdown::Content,
pub(super) transcript: text_editor::Content,
pub(super) a2ui_lines_processed: usize,
@@ -235,6 +239,7 @@ impl From<StoredMessage> for ChatMessage {
.as_deref()
.and_then(|reasons| serde_json::from_str(reasons).ok())
.unwrap_or_default(),
instruction_metadata: message.instruction_metadata,
markdown: iced::widget::markdown::Content::new(),
transcript: iced::widget::text_editor::Content::new(),
a2ui_lines_processed: 0,
@@ -364,16 +369,6 @@ fn has_chat_after_last_compaction(messages: &[ChatMessage]) -> bool {
})
}
fn project_agents(path: &Path) -> Result<Option<String>, String> {
let path = path.join("AGENTS.md");
match fs::read_to_string(&path) {
Ok(content) if content.is_empty() => Ok(None),
Ok(content) => Ok(Some(content)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(format!("Could not read {}: {error}", path.display())),
}
}
#[cfg(any(target_os = "macos", test))]
fn title_context(messages: impl IntoIterator<Item = ChatTurn>) -> Vec<ChatTurn> {
let mut started = false;
@@ -388,30 +383,13 @@ fn title_context(messages: impl IntoIterator<Item = ChatTurn>) -> Vec<ChatTurn>
.collect()
}
#[cfg(any(target_os = "macos", test))]
fn agents_prompt_for_turn(
opening_turn: bool,
opening_prompt: Option<String>,
stored_prompt: Option<&str>,
) -> Option<String> {
if opening_turn {
opening_prompt
} else {
stored_prompt.map(str::to_owned)
}
}
impl App {
fn chat_system_prompt(&self, model: ModelChoice, prompt: &str, agents: Option<&str>) -> String {
fn chat_system_prompt(&self, model: ModelChoice, prompt: &str) -> String {
let mut prompt = crate::agent::system_prompt(model, prompt, self.config.dev_brain.enabled);
if self.config.a2ui_enabled {
prompt.push_str("\n\n");
prompt.push_str(crate::a2ui::SYSTEM_PROMPT);
}
if let Some(agents) = agents {
prompt.push_str("\n\n");
prompt.push_str(agents);
}
if let Some(skills) = crate::agent::agent_skills_prompt() {
prompt.push_str("\n\n");
prompt.push_str(&skills);
@@ -419,6 +397,47 @@ impl App {
prompt
}
fn workspace_instruction_messages(
&mut self,
touched_paths: &[PathBuf],
opening: bool,
) -> Vec<SystemMessage> {
let Some(root) = self
.projects
.iter()
.find(|project| Some(project.project.id) == self.selected_project)
.map(|project| PathBuf::from(&project.project.path))
else {
return Vec::new();
};
let visible_start = compacted_context_start(&self.conversation);
let reconciliation = crate::instructions::reconcile(
&root,
&application_support_path().join("AGENTS.md"),
self.conversation
.iter()
.enumerate()
.filter_map(|(index, message)| {
message.instruction_metadata.as_deref().map(|metadata| {
crate::instructions::HistoryEntry {
metadata,
visible: index >= visible_start,
}
})
}),
touched_paths,
opening,
);
if let Some(diagnostic) = reconciliation.diagnostic {
self.context_notice = Some(diagnostic);
}
reconciliation
.messages
.into_iter()
.map(|message| SystemMessage::instruction(message.content, message.metadata))
.collect()
}
fn dev_brain_skills_prompt(&self) -> Option<String> {
self.config.dev_brain.enabled.then(|| {
let projects = self
@@ -434,13 +453,6 @@ impl App {
})
}
fn session_agents_prompt(&self) -> Option<&str> {
self.conversation
.iter()
.find(|message| message.system && message.content.starts_with(AGENTS_PREFIX))
.map(|message| message.content.as_str())
}
pub(super) fn can_compact_session(&self, session_id: i32) -> bool {
!self.generating
&& self.selected_session == Some(session_id)
@@ -486,31 +498,6 @@ impl App {
self.reset_agent_tool_repeats();
#[cfg(target_os = "macos")]
let opening_turn = self.selected_session.is_none();
#[cfg(target_os = "macos")]
let opening_agents = if opening_turn {
let Some(project) = self
.projects
.iter()
.find(|project| Some(project.project.id) == self.selected_project)
else {
self.error = Some("The selected project is unavailable.".into());
return;
};
match project_agents(Path::new(&project.project.path)) {
Ok(agents) => agents.map(|agents| format!("{AGENTS_PREFIX}{agents}")),
Err(error) => {
self.error = Some(error);
return;
}
}
} else {
None
};
#[cfg(target_os = "macos")]
let agents =
agents_prompt_for_turn(opening_turn, opening_agents, self.session_agents_prompt());
#[cfg(not(target_os = "macos"))]
let agents = None::<String>;
self.a2ui_auto_switch_pending = true;
self.context_notice = None;
#[cfg(target_os = "macos")]
@@ -542,7 +529,7 @@ impl App {
}
};
effective.turn.system_prompt =
self.chat_system_prompt(model, &effective.turn.system_prompt, agents.as_deref());
self.chat_system_prompt(model, &effective.turn.system_prompt);
effective.turn.system_prompt = crate::compaction::summary_system_prompt(
&effective.turn.system_prompt,
self.compaction_summary(),
@@ -559,18 +546,21 @@ impl App {
prompt.clone()
};
#[cfg(target_os = "macos")]
let mut injected_system = Vec::new();
let mut injected_system = self.workspace_instruction_messages(&[], opening_turn);
#[cfg(target_os = "macos")]
if opening_turn {
injected_system.extend(agents.clone());
injected_system.extend(self.dev_brain_skills_prompt());
injected_system.push(crate::agent::datetime_context());
injected_system.extend(self.dev_brain_skills_prompt().map(SystemMessage::plain));
injected_system.push(SystemMessage::plain(crate::agent::datetime_context()));
}
#[cfg(target_os = "macos")]
let reminder_injected = self.system_prompt_reminder_due();
#[cfg(target_os = "macos")]
if reminder_injected {
injected_system.extend(self.system_prompt_reminders(model));
injected_system.extend(
self.system_prompt_reminders(model)
.into_iter()
.map(SystemMessage::plain),
);
}
#[cfg(target_os = "macos")]
let mut messages = self
@@ -582,15 +572,15 @@ impl App {
messages.extend(
injected_system
.iter()
.skip(usize::from(opening_turn && agents.is_some()))
.map(|content| ChatTurn {
.filter(|message| !message.content.is_empty())
.map(|message| ChatTurn {
user: false,
tool: false,
system: true,
skip_previous_eos: false,
reasoning: None,
reasoning_complete: true,
content: content.clone(),
content: message.content.clone(),
}),
);
#[cfg(target_os = "macos")]
@@ -736,9 +726,6 @@ impl App {
if !self.config.system_prompt.trim().is_empty() {
reminders.push(self.config.system_prompt.clone());
}
if let Some(agents) = self.session_agents_prompt() {
reminders.push(agents.to_owned());
}
reminders
}
@@ -843,7 +830,7 @@ impl App {
"queued manual compaction",
)
} else {
self.start_tool_result_check(result, ToolCheckStage::Initial)
self.start_tool_result_check(result, ToolCheckStage::ResultInitial)
};
if let Err(error) = continuation {
self.generating = false;
@@ -1170,7 +1157,13 @@ impl App {
}
#[cfg(target_os = "macos")]
if let Some(feedback) = continuation_feedback
&& let Err(error) = self.continue_after_tool_result(&feedback)
&& let Err(error) = self.start_tool_result_check(
crate::agent::ToolRunResult {
content: feedback,
touched_paths: Vec::new(),
},
ToolCheckStage::ResultInitial,
)
{
self.generating = false;
self.activity = Some("Failed".into());
@@ -1288,9 +1281,13 @@ impl App {
&child_turn.system_prompt,
self.config.dev_brain.enabled,
);
if let Some(agents) = self.session_agents_prompt() {
for instruction in self
.model_chat_messages()
.into_iter()
.filter(|message| message.instruction_metadata.is_some())
{
child_turn.system_prompt.push_str("\n\n");
child_turn.system_prompt.push_str(agents);
child_turn.system_prompt.push_str(&instruction.content);
}
if let Some(skills) = self.dev_brain_skills_prompt() {
child_turn.system_prompt.push_str("\n\n");
@@ -1333,7 +1330,7 @@ impl App {
}
#[cfg(target_os = "macos")]
fn continue_after_tool_result(&mut self, result: &str) -> Result<(), String> {
fn continue_after_tool_result(&mut self) -> Result<(), String> {
let session_id = self
.selected_session
.ok_or_else(|| "The active session is unavailable.".to_owned())?;
@@ -1343,11 +1340,8 @@ impl App {
let runtime = self.config.runtime_for(model);
let mut effective =
crate::settings::effective_settings(model, &generation, &runtime, &models_path())?;
effective.turn.system_prompt = self.chat_system_prompt(
model,
&effective.turn.system_prompt,
self.session_agents_prompt(),
);
effective.turn.system_prompt =
self.chat_system_prompt(model, &effective.turn.system_prompt);
effective.turn.system_prompt = crate::compaction::summary_system_prompt(
&effective.turn.system_prompt,
self.compaction_summary(),
@@ -1363,15 +1357,19 @@ impl App {
} else {
Vec::new()
};
let system_messages = reminders
.iter()
.cloned()
.map(SystemMessage::plain)
.collect::<Vec<_>>();
let mut saved = self
.database
.as_mut()
.ok_or_else(|| "The project database is unavailable.".to_owned())?
.continue_tool_turn(
.continue_durable_tool_turn(
session_id,
result,
queued.as_deref(),
&reminders,
&system_messages,
assistant_reasoning,
)
.map_err(|error| format!("Could not save the tool turn: {error}"))?;
@@ -1414,6 +1412,41 @@ impl App {
Ok(())
}
#[cfg(target_os = "macos")]
fn persist_tool_result(&mut self, result: &str) -> Result<(), String> {
let session_id = self
.selected_session
.ok_or_else(|| "The active session is unavailable.".to_owned())?;
let stored = self
.database
.as_mut()
.ok_or_else(|| "The project database is unavailable.".to_owned())?
.record_tool_result(session_id, result)
.map_err(|error| format!("Could not save the tool result: {error}"))?;
self.conversation.push(ChatMessage::from(stored));
Ok(())
}
#[cfg(target_os = "macos")]
fn persist_workspace_instructions(&mut self, touched_paths: &[PathBuf]) -> Result<(), String> {
let session_id = self
.selected_session
.ok_or_else(|| "The active session is unavailable.".to_owned())?;
let messages = self.workspace_instruction_messages(touched_paths, false);
if messages.is_empty() {
return Ok(());
}
let stored = self
.database
.as_mut()
.ok_or_else(|| "The project database is unavailable.".to_owned())?
.record_system_messages(session_id, &messages)
.map_err(|error| format!("Could not save workspace instructions: {error}"))?;
self.conversation
.extend(stored.into_iter().map(ChatMessage::from));
Ok(())
}
#[cfg(target_os = "macos")]
pub(super) fn stop_agent_jobs(&mut self) {
let Some((_, tools)) = &self.agent_tools else {
@@ -1431,10 +1464,10 @@ impl App {
#[cfg(target_os = "macos")]
fn start_tool_result_check(
&mut self,
result: String,
result: crate::agent::ToolRunResult,
stage: ToolCheckStage,
) -> Result<(), String> {
if matches!(stage, ToolCheckStage::Initial)
if matches!(stage, ToolCheckStage::ResultInitial)
&& crate::compaction::should_compact(self.context_used, self.context_limit)
{
return self.start_compaction(
@@ -1442,34 +1475,45 @@ impl App {
"soft limit before tool continuation",
);
}
if matches!(stage, ToolCheckStage::Instructions)
&& crate::compaction::should_compact(self.context_used, self.context_limit)
{
return self.start_compaction(
PendingContinuation::DurableTool(result.touched_paths),
"soft limit before instruction continuation",
);
}
let model = self.config.model;
let generation = self.config.active_generation();
let runtime = self.config.runtime_for(model);
let mut effective =
crate::settings::effective_settings(model, &generation, &runtime, &models_path())?;
effective.turn.system_prompt = self.chat_system_prompt(
model,
&effective.turn.system_prompt,
self.session_agents_prompt(),
);
effective.turn.system_prompt =
self.chat_system_prompt(model, &effective.turn.system_prompt);
effective.turn.system_prompt = crate::compaction::summary_system_prompt(
&effective.turn.system_prompt,
self.compaction_summary(),
);
let instruction_stage = matches!(
stage,
ToolCheckStage::Instructions | ToolCheckStage::InstructionsAfterCompaction
);
let mut messages = self
.model_chat_messages()
.into_iter()
.map(chat_turn)
.collect::<Vec<_>>();
messages.push(ChatTurn {
user: false,
tool: true,
system: false,
skip_previous_eos: false,
reasoning: None,
reasoning_complete: true,
content: result.clone(),
});
if !instruction_stage {
messages.push(ChatTurn {
user: false,
tool: true,
system: false,
skip_previous_eos: false,
reasoning: None,
reasoning_complete: true,
content: result.content.clone(),
});
}
let idle_timeout = Duration::from_secs(self.config.idle_timeout_minutes.max(1) as u64 * 60);
let active = self
.generation_service
@@ -1502,7 +1546,9 @@ impl App {
}
Ok(GenerationEvent::Measured(Ok(projected))) => {
let check = self.active_tool_check.take().unwrap();
if self.manual_compaction_queued && matches!(check.stage, ToolCheckStage::Initial) {
if self.manual_compaction_queued
&& matches!(check.stage, ToolCheckStage::ResultInitial)
{
self.manual_compaction_queued = false;
if let Err(error) = self.start_compaction(
PendingContinuation::Tool(check.result),
@@ -1514,20 +1560,56 @@ impl App {
}
return true;
}
let reserve = if matches!(check.stage, ToolCheckStage::BoundedError) {
if self.manual_compaction_queued
&& matches!(check.stage, ToolCheckStage::Instructions)
{
self.manual_compaction_queued = false;
if let Err(error) = self.start_compaction(
PendingContinuation::DurableTool(check.result.touched_paths),
"queued manual compaction",
) {
self.generating = false;
self.activity = Some("Failed".into());
self.error = Some(error);
}
return true;
}
let reserve = if matches!(check.stage, ToolCheckStage::BoundedResult) {
16
} else {
crate::compaction::tool_result_reserve(self.context_limit)
};
if crate::compaction::tool_result_fits(projected, self.context_limit, reserve) {
if let Err(error) = self.continue_after_tool_result(&check.result) {
let continuation = if matches!(
check.stage,
ToolCheckStage::ResultInitial
| ToolCheckStage::ResultAfterCompaction
| ToolCheckStage::BoundedResult
) {
self.persist_tool_result(&check.result.content)
.and_then(|()| {
self.persist_workspace_instructions(&check.result.touched_paths)
})
.and_then(|()| {
self.start_tool_result_check(
crate::agent::ToolRunResult {
content: String::new(),
touched_paths: check.result.touched_paths,
},
ToolCheckStage::Instructions,
)
})
} else {
self.continue_after_tool_result()
};
if let Err(error) = continuation {
self.generating = false;
self.activity = Some("Failed".into());
self.error = Some(error);
}
} else {
match check.stage {
ToolCheckStage::Initial => {
ToolCheckStage::ResultInitial => {
if let Err(error) = self.start_compaction(
PendingContinuation::Tool(check.result),
"tool result would exceed context",
@@ -1537,25 +1619,40 @@ impl App {
self.error = Some(error);
}
}
ToolCheckStage::AfterCompaction => {
let error = crate::compaction::bounded_tool_error(
ToolCheckStage::ResultAfterCompaction => {
let content = crate::compaction::bounded_tool_error(
projected,
self.context_limit,
reserve,
);
let result = crate::agent::ToolRunResult {
content,
touched_paths: check.result.touched_paths,
};
if let Err(error) =
self.start_tool_result_check(error, ToolCheckStage::BoundedError)
self.start_tool_result_check(result, ToolCheckStage::BoundedResult)
{
self.generating = false;
self.activity = Some("Failed".into());
self.error = Some(error);
}
}
ToolCheckStage::BoundedError => {
ToolCheckStage::BoundedResult
| ToolCheckStage::InstructionsAfterCompaction => {
self.generating = false;
self.activity = Some("Failed".into());
self.error = Some("context full after compaction".into());
}
ToolCheckStage::Instructions => {
if let Err(error) = self.start_compaction(
PendingContinuation::DurableTool(check.result.touched_paths),
"workspace instructions would exceed context",
) {
self.generating = false;
self.activity = Some("Failed".into());
self.error = Some(error);
}
}
}
}
true
@@ -1598,11 +1695,8 @@ impl App {
let runtime = self.config.runtime_for(model);
let mut effective =
crate::settings::effective_settings(model, &generation, &runtime, &models_path())?;
effective.turn.system_prompt = self.chat_system_prompt(
model,
&effective.turn.system_prompt,
self.session_agents_prompt(),
);
effective.turn.system_prompt =
self.chat_system_prompt(model, &effective.turn.system_prompt);
let rebuild_system_prompt = effective.turn.system_prompt.clone();
effective.turn.system_prompt = crate::compaction::summary_system_prompt(
&effective.turn.system_prompt,
@@ -1685,13 +1779,31 @@ impl App {
PendingContinuation::Tool(result) => {
if let Err(error) = self.start_tool_result_check(
result,
ToolCheckStage::AfterCompaction,
ToolCheckStage::ResultAfterCompaction,
) {
self.generating = false;
self.activity = Some("Failed".into());
self.error = Some(error);
}
}
PendingContinuation::DurableTool(touched_paths) => {
let continuation = self
.persist_workspace_instructions(&touched_paths)
.and_then(|()| {
self.start_tool_result_check(
crate::agent::ToolRunResult {
content: String::new(),
touched_paths,
},
ToolCheckStage::InstructionsAfterCompaction,
)
});
if let Err(error) = continuation {
self.generating = false;
self.activity = Some("Failed".into());
self.error = Some(error);
}
}
}
return true;
}
@@ -1816,7 +1928,8 @@ impl App {
.iter()
.filter(|message| {
!message.compaction
&& !(message.system && message.content.starts_with(AGENTS_PREFIX))
&& !(message.system && message.content.starts_with(LEGACY_AGENTS_PREFIX))
&& !(message.instruction_metadata.is_some() && message.content.is_empty())
})
.collect()
}
@@ -2007,10 +2120,10 @@ pub(super) fn session_title(reply: &str) -> Option<String> {
#[cfg(test)]
mod tests {
use super::{
ChatMessage, TOOL_PROTOCOL_CORRECTION, TurnSummary, agents_prompt_for_turn, chat_turn,
compacted_context_start, correction_already_sent, has_chat_after_last_compaction,
has_misplaced_tool_call, is_empty_response, project_agents, promote_legacy_turn_summaries,
queued_prompt, sync_a2ui_message, title_context,
ChatMessage, TOOL_PROTOCOL_CORRECTION, TurnSummary, chat_turn, compacted_context_start,
correction_already_sent, has_chat_after_last_compaction, has_misplaced_tool_call,
is_empty_response, promote_legacy_turn_summaries, queued_prompt, sync_a2ui_message,
title_context,
};
use crate::engine::ChatTurn;
use crate::model::ModelChoice;
@@ -2030,6 +2143,7 @@ mod tests {
content: content.to_owned(),
model_content: None,
tool_approval_reasons: Vec::new(),
instruction_metadata: None,
markdown: iced::widget::markdown::Content::new(),
transcript: iced::widget::text_editor::Content::new(),
a2ui_lines_processed: 0,
@@ -2051,35 +2165,6 @@ mod tests {
}
}
#[test]
fn project_agents_is_optional_and_preserves_the_file() {
let directory =
std::env::temp_dir().join(format!("ds4-server-agents-{}", std::process::id()));
std::fs::create_dir_all(&directory).unwrap();
assert_eq!(project_agents(&directory).unwrap(), None);
let instructions = "# Instructions\n\nKeep this exact.\n";
std::fs::write(directory.join("AGENTS.md"), instructions).unwrap();
assert_eq!(
project_agents(&directory).unwrap().as_deref(),
Some(instructions)
);
std::fs::remove_dir_all(directory).unwrap();
}
#[test]
fn user_continuations_reuse_the_stored_agents_prompt() {
let stored = "Project AGENTS.md instructions:\nkeep this exact";
assert_eq!(
agents_prompt_for_turn(false, None, Some(stored)).as_deref(),
Some(stored)
);
assert_eq!(
agents_prompt_for_turn(true, Some("opening".into()), Some(stored)).as_deref(),
Some("opening")
);
}
#[test]
fn turn_summary_accumulates_model_continuations() {
let mut summary = TurnSummary::new();
@@ -2226,6 +2311,7 @@ mod tests {
.to_owned(),
model_content: None,
tool_approval_reasons: Vec::new(),
instruction_metadata: None,
markdown: iced::widget::markdown::Content::new(),
transcript: iced::widget::text_editor::Content::new(),
a2ui_lines_processed: 0,
@@ -2294,6 +2380,7 @@ mod tests {
content: format!("message {id}"),
model_content: None,
tool_approval_reasons: Vec::new(),
instruction_metadata: None,
markdown: iced::widget::markdown::Content::new(),
transcript: iced::widget::text_editor::Content::new(),
a2ui_lines_processed: 0,
@@ -2334,6 +2421,7 @@ mod tests {
content: format!("message {id}"),
model_content: None,
tool_approval_reasons: Vec::new(),
instruction_metadata: None,
markdown: iced::widget::markdown::Content::new(),
transcript: iced::widget::text_editor::Content::new(),
a2ui_lines_processed: 0,

View File

@@ -826,6 +826,7 @@ mod tests {
content: content.to_owned(),
model_content: None,
tool_approval_reasons: Vec::new(),
instruction_metadata: None,
markdown: markdown::Content::new(),
transcript: text_editor::Content::new(),
a2ui_lines_processed: 0,