fix: recover partial session log writes
This commit is contained in:
@@ -50,6 +50,9 @@ pub enum EngineError {
|
||||
/// Config warnings (unsupported options)
|
||||
#[error("Config warnings: {}", .0.iter().map(|w| w.to_string()).collect::<Vec<_>>().join(", "))]
|
||||
ConfigWarnings(Vec<ConfigWarning>),
|
||||
/// A durable-history observer rejected an item before it entered history.
|
||||
#[error("History append failed: {0}")]
|
||||
HistoryAppend(String),
|
||||
}
|
||||
|
||||
/// Tool registration error
|
||||
@@ -222,10 +225,10 @@ pub struct Engine<C: LlmClient, S: EngineState = Mutable> {
|
||||
/// truncation have been applied — i.e. on the same data that
|
||||
/// enters history.
|
||||
tool_result_cbs: Vec<Box<dyn Fn(&ToolResult) + Send + Sync>>,
|
||||
/// History-append callbacks. Invoked for non-streamed items when they
|
||||
/// are appended to persistent engine history, so upper layers can
|
||||
/// broadcast those items using history itself as the source of truth.
|
||||
history_append_cbs: Vec<Box<dyn Fn(&Item) + Send + Sync>>,
|
||||
/// History-append callbacks. Invoked before non-streamed items enter
|
||||
/// engine history. An error rejects the item and aborts the turn, allowing
|
||||
/// upper layers to make durable storage the commit gate.
|
||||
history_append_cbs: Vec<Box<dyn Fn(&Item) -> Result<(), String> + Send + Sync>>,
|
||||
/// Request configuration (max_tokens, temperature, etc.)
|
||||
request_config: RequestConfig,
|
||||
/// Whether the previous run was interrupted
|
||||
@@ -498,23 +501,31 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a callback invoked for items appended directly to engine
|
||||
/// history outside streaming timeline callbacks.
|
||||
pub fn on_history_append(&mut self, callback: impl Fn(&Item) + Send + Sync + 'static) {
|
||||
/// Register a fallible callback invoked before an item enters engine
|
||||
/// history. Returning an error rejects that item and aborts the turn.
|
||||
pub fn on_history_append(
|
||||
&mut self,
|
||||
callback: impl Fn(&Item) -> Result<(), String> + Send + Sync + 'static,
|
||||
) {
|
||||
self.history_append_cbs.push(Box::new(callback));
|
||||
}
|
||||
|
||||
fn emit_history_append(&self, item: &Item) {
|
||||
fn emit_history_append(&self, item: &Item) -> Result<(), EngineError> {
|
||||
for cb in &self.history_append_cbs {
|
||||
cb(item);
|
||||
cb(item).map_err(EngineError::HistoryAppend)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn append_history_items(&mut self, items: impl IntoIterator<Item = Item>) {
|
||||
fn append_history_items(
|
||||
&mut self,
|
||||
items: impl IntoIterator<Item = Item>,
|
||||
) -> Result<(), EngineError> {
|
||||
for item in items {
|
||||
self.emit_history_append(&item);
|
||||
self.emit_history_append(&item)?;
|
||||
self.history.push(item);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn request_trace_payload(&self, request: &Request) -> Value {
|
||||
@@ -1125,9 +1136,13 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
|
||||
// These are committed *before* the per-request clone so they
|
||||
// participate in the LLM request below and get persisted by
|
||||
// the caller that owns durable history.
|
||||
let pending = self.interceptor.pending_history_appends().await;
|
||||
let pending = self
|
||||
.interceptor
|
||||
.pending_history_appends()
|
||||
.await
|
||||
.map_err(EngineError::HistoryAppend)?;
|
||||
if !pending.is_empty() {
|
||||
self.append_history_items(pending);
|
||||
self.append_history_items(pending)?;
|
||||
}
|
||||
|
||||
// Clone the history into a per-request context. Everything
|
||||
@@ -1202,7 +1217,7 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
|
||||
return Err(EngineError::Aborted(reason));
|
||||
}
|
||||
PreRequestAction::YieldWith(items) => {
|
||||
self.append_history_items(items.clone());
|
||||
self.append_history_items(items.clone())?;
|
||||
request_context.extend(items);
|
||||
info!("Yielded by interceptor after pre-request history append");
|
||||
for cb in &self.turn_end_cbs {
|
||||
@@ -1220,7 +1235,7 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
|
||||
return Ok(EngineResult::Yielded);
|
||||
}
|
||||
PreRequestAction::ContinueWith(items) => {
|
||||
self.append_history_items(items.clone());
|
||||
self.append_history_items(items.clone())?;
|
||||
request_context.extend(items);
|
||||
}
|
||||
PreRequestAction::Continue => {}
|
||||
@@ -1280,7 +1295,7 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
|
||||
let assistant_items =
|
||||
self.build_assistant_items(&reasoning_items, &text_blocks, &[]);
|
||||
if !assistant_items.is_empty() {
|
||||
self.append_history_items(assistant_items);
|
||||
self.append_history_items(assistant_items)?;
|
||||
}
|
||||
self.emit_llm_continuation(
|
||||
current_llm_call,
|
||||
@@ -1307,7 +1322,7 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
|
||||
let tool_calls = self.tool_call_collector.take_collected();
|
||||
let assistant_items =
|
||||
self.build_assistant_items(&reasoning_items, &text_blocks, &tool_calls);
|
||||
self.append_history_items(assistant_items);
|
||||
self.append_history_items(assistant_items)?;
|
||||
|
||||
if tool_calls.is_empty() {
|
||||
match self.interceptor.on_turn_end(&self.history).await {
|
||||
@@ -1316,7 +1331,7 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
|
||||
return Ok(EngineResult::Finished);
|
||||
}
|
||||
TurnEndAction::ContinueWithMessages(additional) => {
|
||||
self.append_history_items(additional);
|
||||
self.append_history_items(additional)?;
|
||||
continue;
|
||||
}
|
||||
TurnEndAction::Pause => {
|
||||
@@ -1610,7 +1625,7 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
|
||||
result.is_error,
|
||||
)
|
||||
});
|
||||
self.append_history_items(items);
|
||||
self.append_history_items(items)?;
|
||||
Ok(None)
|
||||
}
|
||||
Err(err) => {
|
||||
@@ -1815,12 +1830,15 @@ impl<C: LlmClient> Engine<C, Mutable> {
|
||||
self.history = items;
|
||||
}
|
||||
|
||||
/// Append items to history and notify history-append observers for each
|
||||
/// item before it lands. This is the only public Mutable-state API for
|
||||
/// growing engine history; callers that need session-log persistence must
|
||||
/// install [`on_history_append`](Self::on_history_append) before calling it.
|
||||
pub fn append_history(&mut self, items: impl IntoIterator<Item = Item>) {
|
||||
self.append_history_items(items);
|
||||
/// Append items to history after every history-append observer accepts the
|
||||
/// item. This is the only public Mutable-state API for growing engine
|
||||
/// history; callers that need session-log persistence must install
|
||||
/// [`on_history_append`](Self::on_history_append) before calling it.
|
||||
pub fn append_history(
|
||||
&mut self,
|
||||
items: impl IntoIterator<Item = Item>,
|
||||
) -> Result<(), EngineError> {
|
||||
self.append_history_items(items)
|
||||
}
|
||||
|
||||
/// Truncate history without emitting append callbacks.
|
||||
@@ -1969,9 +1987,9 @@ impl<C: LlmClient> Engine<C, Locked> {
|
||||
PromptAction::Continue => Vec::new(),
|
||||
PromptAction::ContinueWith(items) => items,
|
||||
};
|
||||
self.append_history_items(std::iter::once(user_item));
|
||||
self.append_history_items(std::iter::once(user_item))?;
|
||||
if !extras.is_empty() {
|
||||
self.append_history_items(extras);
|
||||
self.append_history_items(extras)?;
|
||||
}
|
||||
let result = self.run_turn_loop().await;
|
||||
self.finalize_interruption(result).await
|
||||
|
||||
@@ -158,8 +158,8 @@ pub trait Interceptor: Send + Sync {
|
||||
/// reproducible per-request transformations (pruning, content
|
||||
/// trimming, cache anchors) that depend only on the existing
|
||||
/// history.
|
||||
async fn pending_history_appends(&self) -> Vec<Item> {
|
||||
Vec::new()
|
||||
async fn pending_history_appends(&self) -> Result<Vec<Item>, String> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
/// Called before each LLM request. The context starts as a clone
|
||||
|
||||
@@ -44,12 +44,18 @@ fn test_mutable_history_manipulation() {
|
||||
assert!(engine.history().is_empty());
|
||||
|
||||
// Add to history
|
||||
engine.append_history(vec![Item::user_message("Hello")]);
|
||||
engine.append_history(vec![Item::assistant_message("Hi there!")]);
|
||||
engine
|
||||
.append_history(vec![Item::user_message("Hello")])
|
||||
.unwrap();
|
||||
engine
|
||||
.append_history(vec![Item::assistant_message("Hi there!")])
|
||||
.unwrap();
|
||||
assert_eq!(engine.history().len(), 2);
|
||||
|
||||
// Append to history via the callback-aware API.
|
||||
engine.append_history(vec![Item::user_message("How are you?")]);
|
||||
engine
|
||||
.append_history(vec![Item::user_message("How are you?")])
|
||||
.unwrap();
|
||||
assert_eq!(engine.history().len(), 3);
|
||||
|
||||
// Clear history
|
||||
@@ -86,15 +92,20 @@ fn test_mutable_append_history() {
|
||||
if let Some(text) = item.as_text() {
|
||||
observed_for_callback.lock().unwrap().push(text.to_string());
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
|
||||
engine.append_history(vec![Item::user_message("First")]);
|
||||
engine
|
||||
.append_history(vec![Item::user_message("First")])
|
||||
.unwrap();
|
||||
|
||||
engine.append_history(vec![
|
||||
Item::assistant_message("Response 1"),
|
||||
Item::user_message("Second"),
|
||||
Item::assistant_message("Response 2"),
|
||||
]);
|
||||
engine
|
||||
.append_history(vec![
|
||||
Item::assistant_message("Response 1"),
|
||||
Item::user_message("Second"),
|
||||
Item::assistant_message("Response 2"),
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(engine.history().len(), 4);
|
||||
assert_eq!(
|
||||
@@ -157,6 +168,40 @@ fn test_mutable_can_register_tool() {
|
||||
engine.register_tool(tool.definition());
|
||||
}
|
||||
|
||||
/// A durable-history failure on a tool call must stop the turn before the
|
||||
/// tool can produce an external side effect.
|
||||
#[tokio::test]
|
||||
async fn history_append_failure_stops_before_tool_execution() {
|
||||
let client = MockLlmClient::new(vec![
|
||||
Event::tool_use_start(0, "call_1", "count_tool"),
|
||||
Event::tool_input_delta(0, r#"{}"#),
|
||||
Event::tool_use_stop(0),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
]);
|
||||
let tool = CountingTool::new("count_tool");
|
||||
let mut engine = Engine::new(client);
|
||||
engine.register_tool(tool.definition());
|
||||
engine.on_history_append(|item| {
|
||||
if item.is_tool_call() {
|
||||
Err("simulated ENOSPC".to_string())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
});
|
||||
|
||||
let mut engine = engine.lock();
|
||||
let error = engine.run("use the tool").await.unwrap_err();
|
||||
|
||||
assert!(
|
||||
matches!(error, EngineError::HistoryAppend(ref message) if message == "simulated ENOSPC")
|
||||
);
|
||||
assert_eq!(tool.call_count(), 0);
|
||||
assert_eq!(engine.history().len(), 1);
|
||||
assert_eq!(engine.history()[0].as_text(), Some("use the tool"));
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// State Transition Tests
|
||||
// =============================================================================
|
||||
@@ -168,8 +213,12 @@ fn test_lock_transition() {
|
||||
let mut engine = Engine::new(client);
|
||||
|
||||
engine.set_system_prompt("System");
|
||||
engine.append_history(vec![Item::user_message("Hello")]);
|
||||
engine.append_history(vec![Item::assistant_message("Hi")]);
|
||||
engine
|
||||
.append_history(vec![Item::user_message("Hello")])
|
||||
.unwrap();
|
||||
engine
|
||||
.append_history(vec![Item::assistant_message("Hi")])
|
||||
.unwrap();
|
||||
|
||||
// Lock
|
||||
let locked_engine = engine.lock();
|
||||
@@ -186,14 +235,18 @@ fn test_unlock_transition() {
|
||||
let client = MockLlmClient::new(vec![]);
|
||||
let mut engine = Engine::new(client);
|
||||
|
||||
engine.append_history(vec![Item::user_message("Hello")]);
|
||||
engine
|
||||
.append_history(vec![Item::user_message("Hello")])
|
||||
.unwrap();
|
||||
let locked_engine = engine.lock();
|
||||
|
||||
// Unlock
|
||||
let mut engine = locked_engine.unlock();
|
||||
|
||||
// History operations are available again in Mutable state
|
||||
engine.append_history(vec![Item::assistant_message("Hi")]);
|
||||
engine
|
||||
.append_history(vec![Item::assistant_message("Hi")])
|
||||
.unwrap();
|
||||
engine.clear_history();
|
||||
assert!(engine.history().is_empty());
|
||||
}
|
||||
@@ -316,8 +369,12 @@ async fn test_locked_prefix_len_tracking() {
|
||||
let mut engine = Engine::new(client);
|
||||
|
||||
// Add items beforehand
|
||||
engine.append_history(vec![Item::user_message("Pre-existing message 1")]);
|
||||
engine.append_history(vec![Item::assistant_message("Pre-existing response 1")]);
|
||||
engine
|
||||
.append_history(vec![Item::user_message("Pre-existing message 1")])
|
||||
.unwrap();
|
||||
engine
|
||||
.append_history(vec![Item::assistant_message("Pre-existing response 1")])
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(engine.history().len(), 2);
|
||||
|
||||
@@ -387,10 +444,12 @@ async fn test_unlock_edit_relock() {
|
||||
]]);
|
||||
|
||||
let mut engine = Engine::new(client);
|
||||
engine.append_history(vec![
|
||||
Item::user_message("Hello"),
|
||||
Item::assistant_message("Hi"),
|
||||
]);
|
||||
engine
|
||||
.append_history(vec![
|
||||
Item::user_message("Hello"),
|
||||
Item::assistant_message("Hi"),
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
// Lock -> Unlock
|
||||
let locked = engine.lock();
|
||||
@@ -400,7 +459,9 @@ async fn test_unlock_edit_relock() {
|
||||
|
||||
// Edit history
|
||||
unlocked.clear_history();
|
||||
unlocked.append_history(vec![Item::user_message("Fresh start")]);
|
||||
unlocked
|
||||
.append_history(vec![Item::user_message("Fresh start")])
|
||||
.unwrap();
|
||||
|
||||
// Re-lock
|
||||
let relocked = unlocked.lock();
|
||||
|
||||
Reference in New Issue
Block a user