Nightly Build / build (push) Successful in 6m49s
The session handler is now a thin shell: three entry points in kernel_turn.rs (run_kernel_turn / recover_turn / resolve_pending_call) and the ChatSessionHandler. Everything that shaped a Value — projection, recovery, compaction mechanics, the LLM loop, message building — lives in agent-loop or behind a loop_adapters trait. agent-loop: - projection/ (mod + media): stored history -> wire messages, the one place provider divergence lives; well-formedness contract, DTL injections (append-only), media parts. LinearAssembler is now a Projection + ProjectionHooks config, not its own implementation - recovery.rs: reap interrupted batches -> resolve the deepest frame's non-terminal calls (Running by policy + RestartHint, AwaitingHuman re-asked) -> un-wedge finished children -> cascade up, every frame on its own agent (B3) - compaction.rs: split point (never assistant+tool group), transcript, SUMMARY_PREFIX/preamble/template, the no-tools model call, summary row - manager: resolve_pending (gate skipped, real ToolContext, then continue incl. sub-agent); start_loop used by recovery; LiveInput - delegate: AsyncExecutor + StoreSink for mode:async (durable cron row, result delivered back into the parent conversation) - kernel/context/store: support the above (TurnScope via Extensions, frame lookups, aligned result-text semantics) skald-core: - loop_adapters: UserLoopRuntime (D12 - one LoopManager per user), TurnScope (per-turn state in the Extensions type-map; no scope is denied), projection_cfg/media_source/tool_digest (Skald's projection knobs without owning projection code), async_task (CronExecutor + DurableSink) - session/handler: stripped to mod.rs + kernel_turn.rs + config.rs + interface_tools.rs + media.rs; deleted agent_dispatch, approval, dispatch, emitter, gate, llm_call, llm_loop, message_builder, messages, outcome, resume - compactor.rs: policy only (threshold, model pick, CompactionEvent); mechanics are the crate's CLAUDE.md updated (recovery, compaction, sub-agents, approval gate, projection sections now describe the crate-owned flow).
302 lines
9.8 KiB
Rust
302 lines
9.8 KiB
Rust
//! `InMemoryStore` — the shipped non-persistent store (chat not persisted;
|
|
//! testing; simple hosts). Monotonic ids per the store contract.
|
|
|
|
use std::collections::HashMap;
|
|
use std::sync::Mutex;
|
|
|
|
use async_trait::async_trait;
|
|
|
|
use crate::ids::{ConversationId, FrameId, MessageId, SummaryId, ToolCallId};
|
|
use crate::model::Usage;
|
|
use crate::store::{
|
|
CallOutcome, CallState, FrameRecord, FrameSpec, HistoryStore, NewCall, NewMessage, NewSummary,
|
|
StoredCall, StoredMessage, StoredSummary,
|
|
};
|
|
|
|
#[derive(Default)]
|
|
struct Inner {
|
|
frames: HashMap<FrameId, FrameRecord>,
|
|
messages: HashMap<FrameId, Vec<StoredMessage>>,
|
|
calls: HashMap<MessageId, Vec<StoredCall>>,
|
|
summaries: HashMap<FrameId, Vec<StoredSummary>>,
|
|
next_frame: i64,
|
|
next_msg: i64,
|
|
next_call: i64,
|
|
next_summary: i64,
|
|
}
|
|
|
|
/// Non-persistent store. A "crash" loses everything — which is exactly why
|
|
/// it's also the natural target for recovery scenario tests (build the
|
|
/// post-crash state by hand).
|
|
pub struct InMemoryStore {
|
|
inner: Mutex<Inner>,
|
|
}
|
|
|
|
impl InMemoryStore {
|
|
pub fn new() -> Self { Self { inner: Mutex::new(Inner::default()) } }
|
|
}
|
|
|
|
impl Default for InMemoryStore {
|
|
fn default() -> Self { Self::new() }
|
|
}
|
|
|
|
#[async_trait]
|
|
impl HistoryStore for InMemoryStore {
|
|
async fn open_frame(
|
|
&self,
|
|
conv: &ConversationId,
|
|
parent: Option<FrameId>,
|
|
spec: FrameSpec,
|
|
) -> crate::Result<FrameId> {
|
|
let mut i = self.inner.lock().unwrap();
|
|
i.next_frame += 1;
|
|
let id = FrameId(i.next_frame);
|
|
i.frames.insert(id, FrameRecord {
|
|
id,
|
|
conversation: conv.clone(),
|
|
parent,
|
|
spec,
|
|
active: true,
|
|
});
|
|
Ok(id)
|
|
}
|
|
|
|
async fn close_frame(&self, frame: FrameId) -> crate::Result<()> {
|
|
let mut i = self.inner.lock().unwrap();
|
|
if let Some(f) = i.frames.get_mut(&frame) {
|
|
f.active = false;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn get_frame(&self, frame: FrameId) -> crate::Result<Option<FrameRecord>> {
|
|
let i = self.inner.lock().unwrap();
|
|
Ok(i.frames.get(&frame).cloned())
|
|
}
|
|
|
|
async fn active_frames(&self, conv: &ConversationId) -> crate::Result<Vec<FrameRecord>> {
|
|
let i = self.inner.lock().unwrap();
|
|
Ok(i.frames.values().filter(|f| f.active && &f.conversation == conv).cloned().collect())
|
|
}
|
|
|
|
async fn deepest_active(&self, conv: &ConversationId) -> crate::Result<Option<FrameRecord>> {
|
|
let i = self.inner.lock().unwrap();
|
|
Ok(i.frames
|
|
.values()
|
|
.filter(|f| f.active && &f.conversation == conv)
|
|
.max_by_key(|f| f.spec.depth)
|
|
.cloned())
|
|
}
|
|
|
|
async fn append(&self, frame: FrameId, msg: NewMessage) -> crate::Result<MessageId> {
|
|
let mut i = self.inner.lock().unwrap();
|
|
i.next_msg += 1;
|
|
let id = MessageId(i.next_msg);
|
|
i.messages.entry(frame).or_default().push(StoredMessage {
|
|
id,
|
|
role: msg.role,
|
|
content: msg.content,
|
|
reasoning: msg.reasoning,
|
|
synthetic: msg.synthetic,
|
|
failed: false,
|
|
metadata: msg.metadata,
|
|
usage: Usage::default(),
|
|
calls: Vec::new(),
|
|
});
|
|
Ok(id)
|
|
}
|
|
|
|
async fn set_usage(&self, msg: MessageId, usage: &Usage) -> crate::Result<()> {
|
|
let mut i = self.inner.lock().unwrap();
|
|
for msgs in i.messages.values_mut() {
|
|
if let Some(m) = msgs.iter_mut().find(|m| m.id == msg) {
|
|
m.usage = usage.clone();
|
|
return Ok(());
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn load(&self, frame: FrameId) -> crate::Result<Vec<StoredMessage>> {
|
|
let i = self.inner.lock().unwrap();
|
|
Ok(load_frame(&i, frame, None))
|
|
}
|
|
|
|
async fn load_since(&self, frame: FrameId, after: MessageId) -> crate::Result<Vec<StoredMessage>> {
|
|
let i = self.inner.lock().unwrap();
|
|
Ok(load_frame(&i, frame, Some(after)))
|
|
}
|
|
|
|
async fn last(&self, frame: FrameId) -> crate::Result<Option<StoredMessage>> {
|
|
let i = self.inner.lock().unwrap();
|
|
Ok(load_frame(&i, frame, None).into_iter().last())
|
|
}
|
|
|
|
async fn mark_failed(&self, msg: MessageId) -> crate::Result<()> {
|
|
let mut i = self.inner.lock().unwrap();
|
|
for msgs in i.messages.values_mut() {
|
|
if let Some(m) = msgs.iter_mut().find(|m| m.id == msg) {
|
|
m.failed = true;
|
|
return Ok(());
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn append_call(&self, msg: MessageId, call: NewCall) -> crate::Result<ToolCallId> {
|
|
let mut i = self.inner.lock().unwrap();
|
|
i.next_call += 1;
|
|
let id = ToolCallId(i.next_call);
|
|
let provider_id = call.provider_id.unwrap_or_else(|| format!("call_{}", id.get()));
|
|
let stored = StoredCall {
|
|
id,
|
|
message_id: msg,
|
|
provider_id,
|
|
name: call.name,
|
|
arguments: call.arguments,
|
|
// Nothing to replay verbatim: this store never saw a wire string.
|
|
arguments_raw: None,
|
|
state: CallState::Running,
|
|
result: None,
|
|
result_kind: String::new(),
|
|
extras: serde_json::Value::Null,
|
|
};
|
|
i.calls.entry(msg).or_default().push(stored.clone());
|
|
// Keep the nested copy inside the message in sync.
|
|
for msgs in i.messages.values_mut() {
|
|
if let Some(m) = msgs.iter_mut().find(|m| m.id == msg) {
|
|
m.calls.push(stored);
|
|
break;
|
|
}
|
|
}
|
|
Ok(id)
|
|
}
|
|
|
|
async fn resolve_call(&self, id: ToolCallId, outcome: &CallOutcome) -> crate::Result<()> {
|
|
let mut i = self.inner.lock().unwrap();
|
|
update_call(&mut i, id, |c| {
|
|
c.state = outcome.state();
|
|
c.result = Some(outcome.result_text());
|
|
c.result_kind = outcome.result_kind().to_string();
|
|
});
|
|
Ok(())
|
|
}
|
|
|
|
async fn set_call_state(&self, id: ToolCallId, state: CallState) -> crate::Result<()> {
|
|
anyhow::ensure!(
|
|
!state.is_terminal(),
|
|
"set_call_state is only for Running → AwaitingHuman, not terminal {state:?}"
|
|
);
|
|
let mut i = self.inner.lock().unwrap();
|
|
update_call(&mut i, id, |c| c.state = state);
|
|
Ok(())
|
|
}
|
|
|
|
async fn get_call(&self, id: ToolCallId) -> crate::Result<Option<StoredCall>> {
|
|
let i = self.inner.lock().unwrap();
|
|
Ok(i.calls.values().flatten().find(|c| c.id == id).cloned())
|
|
}
|
|
|
|
async fn frame_of_call(&self, id: ToolCallId) -> crate::Result<Option<FrameRecord>> {
|
|
let i = self.inner.lock().unwrap();
|
|
let Some(msg_id) = i
|
|
.calls
|
|
.values()
|
|
.flatten()
|
|
.find(|c| c.id == id)
|
|
.map(|c| c.message_id)
|
|
else {
|
|
return Ok(None);
|
|
};
|
|
let frame = i
|
|
.messages
|
|
.iter()
|
|
.find(|(_, msgs)| msgs.iter().any(|m| m.id == msg_id))
|
|
.map(|(frame, _)| *frame);
|
|
Ok(frame.and_then(|f| i.frames.get(&f).cloned()))
|
|
}
|
|
|
|
async fn set_call_extras(&self, id: ToolCallId, extras: serde_json::Value) -> crate::Result<()> {
|
|
let mut i = self.inner.lock().unwrap();
|
|
update_call(&mut i, id, |c| {
|
|
if let (Some(dst), Some(src)) = (c.extras.as_object_mut(), extras.as_object()) {
|
|
for (k, v) in src {
|
|
dst.insert(k.clone(), v.clone());
|
|
}
|
|
} else {
|
|
c.extras = extras.clone();
|
|
}
|
|
});
|
|
Ok(())
|
|
}
|
|
|
|
async fn calls_in_state(&self, frame: FrameId, states: &[CallState]) -> crate::Result<Vec<StoredCall>> {
|
|
let i = self.inner.lock().unwrap();
|
|
Ok(i.messages
|
|
.get(&frame)
|
|
.map(|msgs| {
|
|
msgs.iter()
|
|
.flat_map(|m| &m.calls)
|
|
.filter(|c| states.contains(&c.state))
|
|
.cloned()
|
|
.collect()
|
|
})
|
|
.unwrap_or_default())
|
|
}
|
|
|
|
async fn save_summary(&self, frame: FrameId, s: NewSummary) -> crate::Result<SummaryId> {
|
|
let mut i = self.inner.lock().unwrap();
|
|
i.next_summary += 1;
|
|
let id = SummaryId(i.next_summary);
|
|
i.summaries.entry(frame).or_default().push(StoredSummary {
|
|
id,
|
|
text: s.text,
|
|
covered_up_to: s.covered_up_to,
|
|
});
|
|
Ok(id)
|
|
}
|
|
|
|
async fn latest_summary(&self, frame: FrameId) -> crate::Result<Option<StoredSummary>> {
|
|
let i = self.inner.lock().unwrap();
|
|
Ok(i.summaries.get(&frame).and_then(|v| v.last()).cloned())
|
|
}
|
|
}
|
|
|
|
/// Load a frame's history with calls nested, excluding failed messages,
|
|
/// optionally only messages after `after`.
|
|
fn load_frame(i: &Inner, frame: FrameId, after: Option<MessageId>) -> Vec<StoredMessage> {
|
|
i.messages
|
|
.get(&frame)
|
|
.map(|msgs| {
|
|
msgs.iter()
|
|
.filter(|m| !m.failed)
|
|
.filter(|m| after.is_none_or(|a| m.id > a))
|
|
.cloned()
|
|
.collect()
|
|
})
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// Apply a mutation to a call both in the by-message index and in the nested
|
|
/// copy inside its message.
|
|
fn update_call(i: &mut Inner, id: ToolCallId, f: impl Fn(&mut StoredCall)) {
|
|
let mut msg_id = None;
|
|
for calls in i.calls.values_mut() {
|
|
if let Some(c) = calls.iter_mut().find(|c| c.id == id) {
|
|
f(c);
|
|
msg_id = Some(c.message_id);
|
|
break;
|
|
}
|
|
}
|
|
if let Some(msg_id) = msg_id {
|
|
for msgs in i.messages.values_mut() {
|
|
if let Some(m) = msgs.iter_mut().find(|m| m.id == msg_id) {
|
|
if let Some(c) = m.calls.iter_mut().find(|c| c.id == id) {
|
|
f(c);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|