agent: don't hold agent lock across I/O

The agent lock was held for the entire duration of turn() — including
API streaming and tool dispatch awaits. This blocked the UI thread
whenever it needed the lock (render tick, compaction check, etc.),
causing 20+ second freezes.

Fix: turn() takes Arc<Mutex<Agent>> and manages locking internally.
Lock is held briefly for prepare/process phases, released during all
I/O (streaming, tool awaits, sleep retries). Also:

- check_compaction: spawns task instead of awaiting on event loop
- start_memory_scoring: already spawned, no change needed
- dispatch_tool_call_unlocked: drops lock before tool handle await
- Subconscious screen: renders all agents from state dynamically
  (no more hardcoded SUBCONSCIOUS_AGENTS list)
- Memory scoring shows n/m progress in snapshots

Co-Authored-By: Proof of Concept <poc@bcachefs.org>
This commit is contained in:
ProofOfConcept 2026-04-04 04:23:29 -04:00
parent 6fa881f811
commit fb54488f30
5 changed files with 301 additions and 269 deletions

View file

@ -127,8 +127,7 @@ impl Session {
let result_tx = self.turn_tx.clone();
self.turn_in_progress = true;
self.turn_handle = Some(tokio::spawn(async move {
let mut agent = agent.lock().await;
let result = agent.turn(&input, &ui_tx, target).await;
let result = Agent::turn(agent, &input, &ui_tx, target).await;
let _ = result_tx.send((result, target)).await;
}));
}
@ -209,40 +208,54 @@ impl Session {
}
self.update_status();
self.check_compaction().await;
self.maybe_start_memory_scoring().await;
self.check_compaction();
self.start_memory_scoring();
self.drain_pending();
}
/// Spawn incremental memory scoring if not already running.
async fn maybe_start_memory_scoring(&mut self) {
{
let agent = self.agent.lock().await;
if agent.agent_cycles.memory_scoring_in_flight {
return;
}
}
let (context, client, cursor) = {
let mut agent = self.agent.lock().await;
let cursor = agent.agent_cycles.memory_score_cursor;
agent.agent_cycles.memory_scoring_in_flight = true;
(agent.context.clone(), agent.client_clone(), cursor)
};
/// Non-blocking — all async work happens in the spawned task.
fn start_memory_scoring(&self) {
let agent = self.agent.clone();
let ui_tx = self.ui_tx.clone();
tokio::spawn(async move {
// Check + snapshot under one brief lock
let (context, client, cursor) = {
let mut agent = agent.lock().await;
if agent.agent_cycles.memory_scoring_in_flight {
return;
}
let cursor = agent.agent_cycles.memory_score_cursor;
agent.agent_cycles.memory_scoring_in_flight = true;
// Count total unique memories
let mut seen = std::collections::HashSet::new();
for entry in &agent.context.entries {
if let crate::agent::context::ConversationEntry::Memory { key, .. } = entry {
seen.insert(key.clone());
}
}
agent.agent_cycles.memory_total = seen.len();
let _ = ui_tx.send(UiMessage::AgentUpdate(agent.agent_cycles.snapshots()));
(agent.context.clone(), agent.client_clone(), cursor)
};
// Lock released — event loop is free
let result = crate::agent::training::score_memories_incremental(
&context, cursor, &client, &ui_tx,
).await;
let mut agent = agent.lock().await;
agent.agent_cycles.memory_scoring_in_flight = false;
match result {
Ok((new_cursor, scores)) => {
// Brief lock — just update fields, no heavy work
{
let mut agent = agent.lock().await;
agent.agent_cycles.memory_scoring_in_flight = false;
if let Ok((new_cursor, ref scores)) = result {
agent.agent_cycles.memory_score_cursor = new_cursor;
agent.agent_cycles.memory_scores.extend(scores);
agent.agent_cycles.memory_scores.extend(scores.clone());
}
}
// Snapshot and log outside the lock
match result {
Ok(_) => {
let agent = agent.lock().await;
let _ = ui_tx.send(UiMessage::AgentUpdate(agent.agent_cycles.snapshots()));
}
Err(e) => {
@ -255,23 +268,25 @@ impl Session {
}
/// Check if compaction is needed after a turn.
async fn check_compaction(&mut self) {
let mut agent_guard = self.agent.lock().await;
let tokens = agent_guard.last_prompt_tokens();
fn check_compaction(&self) {
let threshold = compaction_threshold(&self.config.app);
if tokens > threshold {
let _ = self.ui_tx.send(UiMessage::Info(format!(
"[compaction: {}K > {}K threshold]",
tokens / 1000,
threshold / 1000,
)));
agent_guard.compact();
let _ = self.ui_tx.send(UiMessage::Info(
"[compacted — journal + recent messages]".into(),
));
self.send_context_info();
}
let agent = self.agent.clone();
let ui_tx = self.ui_tx.clone();
tokio::spawn(async move {
let mut agent_guard = agent.lock().await;
let tokens = agent_guard.last_prompt_tokens();
if tokens > threshold {
let _ = ui_tx.send(UiMessage::Info(format!(
"[compaction: {}K > {}K threshold]",
tokens / 1000,
threshold / 1000,
)));
agent_guard.compact();
let _ = ui_tx.send(UiMessage::Info(
"[compacted — journal + recent messages]".into(),
));
}
});
}
/// Send any consolidated pending input as a single turn.
@ -791,6 +806,7 @@ pub async fn run(cli: crate::user::CliArgs) -> Result<()> {
let mut session = Session::new(agent, config, ui_tx.clone(), turn_tx);
session.update_status();
session.start_memory_scoring(); // also sends initial agent snapshots
session.send_context_info();
// Start observation socket