Agent/AgentState split complete — separate context and state locks

Agent is now Arc<Agent> (immutable config). ContextState and AgentState
have separate tokio::sync::Mutex locks. The parser locks only context,
tool dispatch locks only state. No contention between the two.

All callers migrated: mind/, user/, tools/, oneshot, dmn, learn.
28 tests pass, zero errors.

Co-Authored-By: Proof of Concept <poc@bcachefs.org>
This commit is contained in:
Kent Overstreet 2026-04-08 15:47:21 -04:00
parent 1d61b091b0
commit 0b9813431a
8 changed files with 156 additions and 159 deletions

View file

@ -33,17 +33,17 @@ fn commands() -> Vec<SlashCommand> { vec![
handler: |s, _| { let _ = s.mind_tx.send(MindCommand::NewSession); } },
SlashCommand { name: "/save", help: "Save session to disk",
handler: |s, _| {
if let Ok(mut ag) = s.agent.try_lock() { ag.notify("saved"); }
if let Ok(mut ag) = s.agent.state.try_lock() { ag.notify("saved"); }
} },
SlashCommand { name: "/model", help: "Show/switch model (/model <name>)",
handler: |s, arg| {
if arg.is_empty() {
if let Ok(mut ag) = s.agent.try_lock() {
let names = ag.app_config.model_names();
if let Ok(mut ag) = s.agent.state.try_lock() {
let names = s.agent.app_config.model_names();
let label = if names.is_empty() {
format!("model: {}", ag.model())
format!("model: {}", s.agent.model())
} else {
format!("model: {} ({})", ag.model(), names.join(", "))
format!("model: {} ({})", s.agent.model(), names.join(", "))
};
ag.notify(label);
}
@ -61,7 +61,7 @@ fn commands() -> Vec<SlashCommand> { vec![
SlashCommand { name: "/dmn", help: "Show DMN state",
handler: |s, _| {
let st = s.shared_mind.lock().unwrap();
if let Ok(mut ag) = s.agent.try_lock() {
if let Ok(mut ag) = s.agent.state.try_lock() {
ag.notify(format!("DMN: {:?} ({}/{})", st.dmn, st.dmn_turns, st.max_dmn_turns));
}
} },
@ -70,7 +70,7 @@ fn commands() -> Vec<SlashCommand> { vec![
let mut st = s.shared_mind.lock().unwrap();
st.dmn = crate::mind::dmn::State::Resting { since: std::time::Instant::now() };
st.dmn_turns = 0;
if let Ok(mut ag) = s.agent.try_lock() { ag.notify("DMN sleeping"); }
if let Ok(mut ag) = s.agent.state.try_lock() { ag.notify("DMN sleeping"); }
} },
SlashCommand { name: "/wake", help: "Wake DMN to foraging",
handler: |s, _| {
@ -78,14 +78,14 @@ fn commands() -> Vec<SlashCommand> { vec![
if matches!(st.dmn, crate::mind::dmn::State::Off) { crate::mind::dmn::set_off(false); }
st.dmn = crate::mind::dmn::State::Foraging;
st.dmn_turns = 0;
if let Ok(mut ag) = s.agent.try_lock() { ag.notify("DMN foraging"); }
if let Ok(mut ag) = s.agent.state.try_lock() { ag.notify("DMN foraging"); }
} },
SlashCommand { name: "/pause", help: "Full stop — no autonomous ticks (Ctrl+P)",
handler: |s, _| {
let mut st = s.shared_mind.lock().unwrap();
st.dmn = crate::mind::dmn::State::Paused;
st.dmn_turns = 0;
if let Ok(mut ag) = s.agent.try_lock() { ag.notify("DMN paused"); }
if let Ok(mut ag) = s.agent.state.try_lock() { ag.notify("DMN paused"); }
} },
SlashCommand { name: "/help", help: "Show this help",
handler: |s, _| { notify_help(&s.agent); } },
@ -101,36 +101,27 @@ pub async fn cmd_switch_model(
agent: &std::sync::Arc<crate::agent::Agent>,
name: &str,
) {
let resolved = {
let ag = agent.lock().await;
match ag.app_config.resolve_model(name) {
Ok(r) => r,
Err(e) => {
agent.lock().await.notify(format!("model error: {}", e));
return;
}
let resolved = match agent.app_config.resolve_model(name) {
Ok(r) => r,
Err(e) => {
agent.state.lock().await.notify(format!("model error: {}", e));
return;
}
};
let new_client = crate::agent::api::ApiClient::new(
let _new_client = crate::agent::api::ApiClient::new(
&resolved.api_base, &resolved.api_key, &resolved.model_id,
);
let prompt_changed = {
let ag = agent.lock().await;
resolved.prompt_file != ag.prompt_file
};
let mut ag = agent.lock().await;
ag.swap_client(new_client);
let prompt_changed = resolved.prompt_file != agent.prompt_file;
if prompt_changed {
ag.prompt_file = resolved.prompt_file.clone();
ag.compact();
ag.notify(format!("switched to {} (recompacted)", resolved.model_id));
agent.compact().await;
agent.state.lock().await.notify(format!("switched to {} (recompacted)", resolved.model_id));
} else {
ag.notify(format!("switched to {}", resolved.model_id));
agent.state.lock().await.notify(format!("switched to {}", resolved.model_id));
}
}
fn notify_help(agent: &std::sync::Arc<crate::agent::Agent>) {
if let Ok(mut ag) = agent.try_lock() {
if let Ok(mut ag) = agent.state.try_lock() {
let mut help = String::new();
for cmd in &commands() {
help.push_str(&format!("{:12} {}\n", cmd.name, cmd.help));
@ -471,48 +462,57 @@ impl InteractScreen {
}
self.pending_display_count = 0;
if let Ok(agent) = self.agent.try_lock() {
let generation = agent.generation;
let entries = agent.conversation();
let (generation, entries) = {
let st = match self.agent.state.try_lock() {
Ok(st) => st,
Err(_) => return,
};
let generation = st.generation;
drop(st);
let ctx = match self.agent.context.try_lock() {
Ok(ctx) => ctx,
Err(_) => return,
};
(generation, ctx.conversation().to_vec())
};
if generation != self.last_generation || entries.len() < self.last_entries.len() {
self.conversation = PaneState::new(true);
self.autonomous = PaneState::new(true);
self.tools = PaneState::new(false);
self.last_entries.clear();
}
if generation != self.last_generation || entries.len() < self.last_entries.len() {
self.conversation = PaneState::new(true);
self.autonomous = PaneState::new(true);
self.tools = PaneState::new(false);
self.last_entries.clear();
}
let start = self.last_entries.len();
for node in entries.iter().skip(start) {
for (target, text, marker) in Self::route_node(node) {
match target {
PaneTarget::Conversation => {
self.conversation.current_color = Color::Cyan;
self.conversation.append_text(&text);
self.conversation.pending_marker = marker;
self.conversation.flush_pending();
},
PaneTarget::ConversationAssistant => {
self.conversation.current_color = Color::Reset;
self.conversation.append_text(&text);
self.conversation.pending_marker = marker;
self.conversation.flush_pending();
},
PaneTarget::Tools =>
self.tools.push_line(text, Color::Yellow),
PaneTarget::ToolResult => {
for line in text.lines().take(20) {
self.tools.push_line(format!(" {}", line), Color::DarkGray);
}
let start = self.last_entries.len();
for node in entries.iter().skip(start) {
for (target, text, marker) in Self::route_node(node) {
match target {
PaneTarget::Conversation => {
self.conversation.current_color = Color::Cyan;
self.conversation.append_text(&text);
self.conversation.pending_marker = marker;
self.conversation.flush_pending();
},
PaneTarget::ConversationAssistant => {
self.conversation.current_color = Color::Reset;
self.conversation.append_text(&text);
self.conversation.pending_marker = marker;
self.conversation.flush_pending();
},
PaneTarget::Tools =>
self.tools.push_line(text, Color::Yellow),
PaneTarget::ToolResult => {
for line in text.lines().take(20) {
self.tools.push_line(format!(" {}", line), Color::DarkGray);
}
}
}
self.last_entries.push(node.clone());
}
self.last_generation = generation;
self.last_entries.push(node.clone());
}
self.last_generation = generation;
// Display pending input (queued in Mind, not yet accepted)
let mind = self.shared_mind.lock().unwrap();
for input in &mind.input {
@ -537,7 +537,7 @@ impl InteractScreen {
if let Some(cmd) = dispatch_command(input) {
(cmd.handler)(self, &input[cmd.name.len()..].trim_start());
} else {
if let Ok(mut ag) = self.agent.try_lock() {
if let Ok(mut ag) = self.agent.state.try_lock() {
ag.notify(format!("unknown: {}", input.split_whitespace().next().unwrap_or(input)));
}
}
@ -833,15 +833,17 @@ impl ScreenView for InteractScreen {
self.sync_from_agent();
// Read status from agent + mind state
if let Ok(mut agent) = self.agent.try_lock() {
agent.expire_activities();
app.status.prompt_tokens = agent.last_prompt_tokens();
app.status.model = agent.model().to_string();
app.status.context_budget = format!("{} tokens", agent.context.tokens());
app.activity = agent.activities.last()
if let Ok(mut st) = self.agent.state.try_lock() {
st.expire_activities();
app.status.prompt_tokens = st.last_prompt_tokens;
app.status.model = self.agent.model().to_string();
app.activity = st.activities.last()
.map(|a| a.label.clone())
.unwrap_or_default();
}
if let Ok(ctx) = self.agent.context.try_lock() {
app.status.context_budget = format!("{} tokens", ctx.tokens());
}
{
let mind = self.shared_mind.lock().unwrap();
app.status.dmn_state = mind.dmn.label().to_string();

View file

@ -20,22 +20,22 @@ impl ConsciousScreen {
}
fn read_context_views(&self) -> Vec<SectionView> {
let ag = match self.agent.try_lock() {
Ok(ag) => ag,
let ctx = match self.agent.context.try_lock() {
Ok(ctx) => ctx,
Err(_) => return Vec::new(),
};
let mut views: Vec<SectionView> = Vec::new();
views.push(section_to_view("System", ag.context.system()));
views.push(section_to_view("Identity", ag.context.identity()));
views.push(section_to_view("Journal", ag.context.journal()));
views.push(section_to_view("System", ctx.system()));
views.push(section_to_view("Identity", ctx.identity()));
views.push(section_to_view("Journal", ctx.journal()));
// Memory nodes extracted from conversation
let mut mem_children: Vec<SectionView> = Vec::new();
let mut scored = 0usize;
let mut unscored = 0usize;
for node in ag.context.conversation() {
for node in ctx.conversation() {
if let AstNode::Leaf(leaf) = node {
if let NodeBody::Memory { key, score, text } = leaf.body() {
let status = match score {
@ -63,7 +63,7 @@ impl ConsciousScreen {
});
}
views.push(section_to_view("Conversation", ag.context.conversation()));
views.push(section_to_view("Conversation", ctx.conversation()));
views
}
}

View file

@ -179,9 +179,9 @@ async fn start(cli: crate::user::CliArgs) -> Result<()> {
let (turn_tx, turn_rx) = tokio::sync::mpsc::channel(1);
let (mind_tx, mind_rx) = tokio::sync::mpsc::unbounded_channel();
let mind = crate::mind::Mind::new(config, turn_tx);
let mind = crate::mind::Mind::new(config, turn_tx).await;
let shared_active_tools = mind.agent.lock().await.active_tools.clone();
let shared_active_tools = mind.agent.state.lock().await.active_tools.clone();
let mut result = Ok(());
tokio_scoped::scope(|s| {
@ -203,7 +203,7 @@ async fn start(cli: crate::user::CliArgs) -> Result<()> {
}
fn hotkey_cycle_reasoning(mind: &crate::mind::Mind) {
if let Ok(mut ag) = mind.agent.try_lock() {
if let Ok(mut ag) = mind.agent.state.try_lock() {
let next = match ag.reasoning_effort.as_str() {
"none" => "low",
"low" => "high",
@ -221,17 +221,17 @@ fn hotkey_cycle_reasoning(mind: &crate::mind::Mind) {
}
async fn hotkey_kill_processes(mind: &crate::mind::Mind) {
let mut ag = mind.agent.lock().await;
let active_tools = ag.active_tools.clone();
let mut st = mind.agent.state.lock().await;
let active_tools = st.active_tools.clone();
let mut tools = active_tools.lock().unwrap();
if tools.is_empty() {
ag.notify("no running tools");
st.notify("no running tools");
} else {
let count = tools.len();
for entry in tools.drain(..) {
entry.handle.abort();
}
ag.notify(format!("killed {} tools", count));
st.notify(format!("killed {} tools", count));
}
}
@ -259,7 +259,7 @@ fn hotkey_cycle_autonomy(mind: &crate::mind::Mind) {
};
s.dmn_turns = 0;
drop(s);
if let Ok(mut ag) = mind.agent.try_lock() {
if let Ok(mut ag) = mind.agent.state.try_lock() {
ag.notify(format!("DMN → {}", label));
}
}
@ -325,13 +325,13 @@ async fn run(
}
});
let agent_changed = agent.lock().await.changed.clone();
let agent_changed = agent.state.lock().await.changed.clone();
let mut turn_watch = mind.turn_watch();
let mut pending: Vec<ratatui::crossterm::event::Event> = Vec::new();
terminal.hide_cursor()?;
if let Ok(mut ag) = agent.try_lock() { ag.notify("consciousness v0.3"); }
if let Ok(mut ag) = agent.state.try_lock() { ag.notify("consciousness v0.3"); }
// Initial render
{
@ -378,8 +378,8 @@ async fn run(
app.agent_state = mind.subconscious_snapshots().await;
app.walked_count = mind.subconscious_walked().await.len();
if !startup_done {
if let Ok(mut ag) = agent.try_lock() {
let model = ag.model().to_string();
if let Ok(mut ag) = agent.state.try_lock() {
let model = agent.model().to_string();
ag.notify(format!("model: {}", model));
startup_done = true;
}

View file

@ -150,9 +150,9 @@ impl SubconsciousScreen {
None => return Vec::new(),
};
snap.forked_agent.as_ref()
.and_then(|agent| agent.try_lock().ok())
.map(|ag| {
let conv = ag.context.conversation();
.and_then(|agent| agent.context.try_lock().ok())
.map(|ctx| {
let conv = ctx.conversation();
let mut view = section_to_view("Conversation", conv);
let fork = snap.fork_point.min(view.children.len());
view.children = view.children.split_off(fork);
@ -177,8 +177,8 @@ impl SubconsciousScreen {
.map(|s| format_age(s))
.unwrap_or_else(|| "".to_string());
let entries = snap.forked_agent.as_ref()
.and_then(|a| a.try_lock().ok())
.map(|ag| ag.context.conversation().len().saturating_sub(snap.fork_point))
.and_then(|a| a.context.try_lock().ok())
.map(|ctx| ctx.conversation().len().saturating_sub(snap.fork_point))
.unwrap_or(0);
ListItem::from(Line::from(vec![
Span::styled(&snap.name, Style::default().fg(Color::Gray)),