mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-09-01 09:09:19 +00:00
fix: persist turns after approval and add agent-level tests (#250)
* fix: persist turns after approval and add agent-level tests Port relevant changes from PR #112 that were not carried over to #237: - Add persist_turn calls in process_approval for the response, error, and auth-required paths. Previously, turns completed after tool approval were never persisted to DB — if the process crashed after approval the entire turn (user message + assistant response) was lost. - Add agent-level unit tests: StaticLlmProvider mock, make_test_agent helper, tests for auto-approval logic, destructive shell command detection, and PendingApproval backward-compatible deserialization (without deferred_tool_calls field). - Remove unused _thread_state binding in process_approval. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address 14 audit findings in src/agent/ Audit of the agent module found 2 High, 7 Medium, 3 Low, and 2 Nit severity issues. This commit fixes all of them: High: - Remove 4 `.expect()` calls in session.rs (entry API, match, direct indexing, if-let) to eliminate panic paths in production - Add typed RoutineError enum replacing Result<_, String> across routine.rs, routine_engine.rs, and callers in history/store.rs and db/libsql/mod.rs Medium: - Sanitize routine names in path construction to prevent directory traversal (routine_engine.rs) - Log warnings for 5 silently-swallowed errors in scheduler.rs, compaction.rs, and worker.rs - Extract shared handle_auth_intercept helper to deduplicate auth interception in thread_ops.rs - Add session count warning threshold in session_manager.rs - Make FullJob stub degradation visible via warn-level log and prepended warning in output Low: - Restrict dead code visibility with #[cfg(test)] on 19 unused items in submission.rs, task.rs, and undo.rs - Narrow pub to pub(crate) on self_repair.rs builder methods - Remove TaskStatus from mod.rs re-exports (test-only type) Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address PR review comments - Reorder persist_turn before persist_response_chain so the conversation row exists before the metadata UPDATE runs - Add persist_response_chain call to handle_auth_intercept so auth-required paths preserve the response chain - Harden sanitize_routine_name to use allowlist (alphanumeric, dash, underscore) instead of denylist replacements - Fix stale active_thread ID in get_or_create_thread: fall back to create_thread() when the stored ID is missing from the map - Persist turn on approval rejection so user messages survive crashes after a tool is rejected Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
97a7637f30
commit
3f135bdde9
+86
-55
@@ -10,7 +10,7 @@ use uuid::Uuid;
|
||||
|
||||
use crate::agent::Agent;
|
||||
use crate::agent::compaction::ContextCompactor;
|
||||
use crate::agent::dispatcher::{AgenticLoopResult, detect_auth_awaiting, parse_auth_result};
|
||||
use crate::agent::dispatcher::{AgenticLoopResult, check_auth_required, parse_auth_result};
|
||||
use crate::agent::session::{PendingApproval, Session, ThreadState};
|
||||
use crate::agent::submission::SubmissionResult;
|
||||
use crate::channels::{IncomingMessage, StatusUpdate};
|
||||
@@ -608,8 +608,8 @@ impl Agent {
|
||||
approved: bool,
|
||||
always: bool,
|
||||
) -> Result<SubmissionResult, Error> {
|
||||
// Get thread state and pending approval
|
||||
let (_thread_state, pending) = {
|
||||
// Get pending approval for this thread
|
||||
let pending = {
|
||||
let mut sess = session.lock().await;
|
||||
let thread = sess
|
||||
.threads
|
||||
@@ -620,8 +620,7 @@ impl Agent {
|
||||
return Ok(SubmissionResult::error("No pending approval request."));
|
||||
}
|
||||
|
||||
let pending = thread.take_pending_approval();
|
||||
(thread.state, pending)
|
||||
thread.take_pending_approval()
|
||||
};
|
||||
|
||||
let pending = match pending {
|
||||
@@ -734,29 +733,17 @@ impl Agent {
|
||||
// If tool_auth returned awaiting_token, enter auth mode and
|
||||
// return instructions directly (skip agentic loop continuation).
|
||||
if let Some((ext_name, instructions)) =
|
||||
detect_auth_awaiting(&pending.tool_name, &tool_result)
|
||||
check_auth_required(&pending.tool_name, &tool_result)
|
||||
{
|
||||
let auth_data = parse_auth_result(&tool_result);
|
||||
{
|
||||
let mut sess = session.lock().await;
|
||||
if let Some(thread) = sess.threads.get_mut(&thread_id) {
|
||||
thread.enter_auth_mode(ext_name.clone());
|
||||
thread.complete_turn(&instructions);
|
||||
}
|
||||
}
|
||||
let _ = self
|
||||
.channels
|
||||
.send_status(
|
||||
&message.channel,
|
||||
StatusUpdate::AuthRequired {
|
||||
extension_name: ext_name,
|
||||
instructions: Some(instructions.clone()),
|
||||
auth_url: auth_data.auth_url,
|
||||
setup_url: auth_data.setup_url,
|
||||
},
|
||||
&message.metadata,
|
||||
)
|
||||
.await;
|
||||
self.handle_auth_intercept(
|
||||
&session,
|
||||
thread_id,
|
||||
message,
|
||||
&tool_result,
|
||||
ext_name,
|
||||
instructions.clone(),
|
||||
)
|
||||
.await;
|
||||
return Ok(SubmissionResult::response(instructions));
|
||||
}
|
||||
|
||||
@@ -912,29 +899,17 @@ impl Agent {
|
||||
|
||||
// Auth detection for deferred tools
|
||||
if let Some((ext_name, instructions)) =
|
||||
detect_auth_awaiting(&tc.name, &deferred_result)
|
||||
check_auth_required(&tc.name, &deferred_result)
|
||||
{
|
||||
let auth_data = parse_auth_result(&deferred_result);
|
||||
{
|
||||
let mut sess = session.lock().await;
|
||||
if let Some(thread) = sess.threads.get_mut(&thread_id) {
|
||||
thread.enter_auth_mode(ext_name.clone());
|
||||
thread.complete_turn(&instructions);
|
||||
}
|
||||
}
|
||||
let _ = self
|
||||
.channels
|
||||
.send_status(
|
||||
&message.channel,
|
||||
StatusUpdate::AuthRequired {
|
||||
extension_name: ext_name,
|
||||
instructions: Some(instructions.clone()),
|
||||
auth_url: auth_data.auth_url,
|
||||
setup_url: auth_data.setup_url,
|
||||
},
|
||||
&message.metadata,
|
||||
)
|
||||
.await;
|
||||
self.handle_auth_intercept(
|
||||
&session,
|
||||
thread_id,
|
||||
message,
|
||||
&deferred_result,
|
||||
ext_name,
|
||||
instructions.clone(),
|
||||
)
|
||||
.await;
|
||||
return Ok(SubmissionResult::response(instructions));
|
||||
}
|
||||
|
||||
@@ -967,7 +942,11 @@ impl Agent {
|
||||
|
||||
match result {
|
||||
Ok(AgenticLoopResult::Response(response)) => {
|
||||
let user_input = thread.last_turn().map(|t| t.user_input.clone());
|
||||
thread.complete_turn(&response);
|
||||
if let Some(input) = user_input {
|
||||
self.persist_turn(thread_id, &message.user_id, &input, Some(&response));
|
||||
}
|
||||
self.persist_response_chain(thread);
|
||||
let _ = self
|
||||
.channels
|
||||
@@ -1003,16 +982,30 @@ impl Agent {
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
let user_input = thread.last_turn().map(|t| t.user_input.clone());
|
||||
thread.fail_turn(e.to_string());
|
||||
if let Some(input) = user_input {
|
||||
self.persist_turn(thread_id, &message.user_id, &input, None);
|
||||
}
|
||||
Ok(SubmissionResult::error(e.to_string()))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Rejected - clear approval and return to idle
|
||||
// Rejected - complete the turn with a rejection message and persist
|
||||
let rejection = format!(
|
||||
"Tool '{}' was rejected. The agent will not execute this tool.\n\n\
|
||||
You can continue the conversation or try a different approach.",
|
||||
pending.tool_name
|
||||
);
|
||||
{
|
||||
let mut sess = session.lock().await;
|
||||
if let Some(thread) = sess.threads.get_mut(&thread_id) {
|
||||
let user_input = thread.last_turn().map(|t| t.user_input.clone());
|
||||
thread.clear_pending_approval();
|
||||
thread.complete_turn(&rejection);
|
||||
if let Some(input) = user_input {
|
||||
self.persist_turn(thread_id, &message.user_id, &input, Some(&rejection));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1025,14 +1018,52 @@ impl Agent {
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(SubmissionResult::response(format!(
|
||||
"Tool '{}' was rejected. The agent will not execute this tool.\n\n\
|
||||
You can continue the conversation or try a different approach.",
|
||||
pending.tool_name
|
||||
)))
|
||||
Ok(SubmissionResult::response(rejection))
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle an auth-required result from a tool execution.
|
||||
///
|
||||
/// Enters auth mode on the thread, completes + persists the turn,
|
||||
/// and sends the AuthRequired status to the channel.
|
||||
/// Returns the instructions string for the caller to wrap in a response.
|
||||
async fn handle_auth_intercept(
|
||||
&self,
|
||||
session: &Arc<Mutex<Session>>,
|
||||
thread_id: Uuid,
|
||||
message: &IncomingMessage,
|
||||
tool_result: &Result<String, Error>,
|
||||
ext_name: String,
|
||||
instructions: String,
|
||||
) {
|
||||
let auth_data = parse_auth_result(tool_result);
|
||||
{
|
||||
let mut sess = session.lock().await;
|
||||
if let Some(thread) = sess.threads.get_mut(&thread_id) {
|
||||
let user_input = thread.last_turn().map(|t| t.user_input.clone());
|
||||
thread.enter_auth_mode(ext_name.clone());
|
||||
thread.complete_turn(&instructions);
|
||||
if let Some(input) = user_input {
|
||||
self.persist_turn(thread_id, &message.user_id, &input, Some(&instructions));
|
||||
}
|
||||
self.persist_response_chain(thread);
|
||||
}
|
||||
}
|
||||
let _ = self
|
||||
.channels
|
||||
.send_status(
|
||||
&message.channel,
|
||||
StatusUpdate::AuthRequired {
|
||||
extension_name: ext_name,
|
||||
instructions: Some(instructions.clone()),
|
||||
auth_url: auth_data.auth_url,
|
||||
setup_url: auth_data.setup_url,
|
||||
},
|
||||
&message.metadata,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Handle an auth token submitted while the thread is in auth mode.
|
||||
///
|
||||
/// The token goes directly to the extension manager's credential store,
|
||||
|
||||
Reference in New Issue
Block a user