Implement tool approval, fix tool definition refresh, and wire embeddings

This commit addresses three critical issues from code review:

1. Tool approval enforcement: Tools declaring requires_approval() (shell,
   http, file write/patch, build_software) now gate execution. Adds
   PendingApproval struct, session-scoped auto-approved tools set, and
   approval flow with yes/no/always commands.

2. Tool definition refresh: Tool definitions now refresh each iteration
   in both chat and job loops, so newly built tools become visible
   immediately within the same session.

3. Worker tool call handling: Changed respond() to respond_with_tools()
   when select_tools returns empty, properly executing tool calls instead
   of formatting them as text.

Also includes prior work from the plan:
- Wire embeddings provider (OpenAI + NEAR AI) to workspace
- Load workspace system prompt (identity files) into LLM context
- Route heartbeat notifications through channel manager
- Enable auto-context compaction when threshold exceeded
- Refactor to config structs (AgentDeps, WorkerDeps, LlmCallRecord)
- Fix clippy warnings (saturating_sub, too_many_arguments)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
Illia Polosukhin
2026-02-03 11:34:10 -08:00
co-authored by Claude Opus 4.5
parent 8af48390a9
commit 2cc9aed364
18 changed files with 1079 additions and 198 deletions
+128 -68
View File
@@ -13,22 +13,30 @@ use crate::context::{ContextManager, JobState};
use crate::error::Error;
use crate::history::Store;
use crate::llm::{
ActionPlan, ChatMessage, LlmProvider, Reasoning, ReasoningContext, ToolSelection,
ActionPlan, ChatMessage, LlmProvider, Reasoning, ReasoningContext, RespondResult, ToolSelection,
};
use crate::safety::SafetyLayer;
use crate::tools::ToolRegistry;
/// Shared dependencies for worker execution.
///
/// This bundles the dependencies that are shared across all workers,
/// reducing the number of arguments to `Worker::new`.
#[derive(Clone)]
pub struct WorkerDeps {
pub context_manager: Arc<ContextManager>,
pub llm: Arc<dyn LlmProvider>,
pub safety: Arc<SafetyLayer>,
pub tools: Arc<ToolRegistry>,
pub store: Option<Arc<Store>>,
pub timeout: Duration,
pub use_planning: bool,
}
/// Worker that executes a single job.
pub struct Worker {
job_id: Uuid,
context_manager: Arc<ContextManager>,
llm: Arc<dyn LlmProvider>,
safety: Arc<SafetyLayer>,
tools: Arc<ToolRegistry>,
store: Option<Arc<Store>>,
timeout: Duration,
/// Whether to use planning before tool execution.
use_planning: bool,
deps: WorkerDeps,
}
/// Result of a tool execution with metadata for context building.
@@ -37,32 +45,43 @@ struct ToolExecResult {
}
impl Worker {
/// Create a new worker.
pub fn new(
job_id: Uuid,
context_manager: Arc<ContextManager>,
llm: Arc<dyn LlmProvider>,
safety: Arc<SafetyLayer>,
tools: Arc<ToolRegistry>,
store: Option<Arc<Store>>,
timeout: Duration,
use_planning: bool,
) -> Self {
Self {
job_id,
context_manager,
llm,
safety,
tools,
store,
timeout,
use_planning,
}
/// Create a new worker for a specific job.
pub fn new(job_id: Uuid, deps: WorkerDeps) -> Self {
Self { job_id, deps }
}
// Convenience accessors to avoid deps.field everywhere
fn context_manager(&self) -> &Arc<ContextManager> {
&self.deps.context_manager
}
fn llm(&self) -> &Arc<dyn LlmProvider> {
&self.deps.llm
}
fn safety(&self) -> &Arc<SafetyLayer> {
&self.deps.safety
}
fn tools(&self) -> &Arc<ToolRegistry> {
&self.deps.tools
}
fn store(&self) -> Option<&Arc<Store>> {
self.deps.store.as_ref()
}
fn timeout(&self) -> Duration {
self.deps.timeout
}
fn use_planning(&self) -> bool {
self.deps.use_planning
}
/// Fire-and-forget persistence of job status.
fn persist_status(&self, status: JobState, reason: Option<String>) {
if let Some(ref store) = self.store {
if let Some(store) = self.store() {
let store = store.clone();
let job_id = self.job_id;
tokio::spawn(async move {
@@ -91,16 +110,13 @@ impl Worker {
}
// Get job context
let job_ctx = self.context_manager.get_context(self.job_id).await?;
let job_ctx = self.context_manager().get_context(self.job_id).await?;
// Create reasoning engine
let reasoning = Reasoning::new(self.llm.clone(), self.safety.clone());
let reasoning = Reasoning::new(self.llm().clone(), self.safety().clone());
// Build initial reasoning context
let tool_defs = self.tools.tool_definitions().await;
let mut reason_ctx = ReasoningContext::new()
.with_job(&job_ctx.description)
.with_tools(tool_defs);
// Build initial reasoning context (tool definitions refreshed each iteration in execution_loop)
let mut reason_ctx = ReasoningContext::new().with_job(&job_ctx.description);
// Add system message
reason_ctx.messages.push(ChatMessage::system(format!(
@@ -116,7 +132,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
)));
// Main execution loop with timeout
let result = tokio::time::timeout(self.timeout, async {
let result = tokio::time::timeout(self.timeout(), async {
self.execution_loop(&mut rx, &reasoning, &mut reason_ctx)
.await
})
@@ -148,8 +164,11 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
let max_iterations = 50;
let mut iteration = 0;
// Initial tool definitions for planning (will be refreshed in loop)
reason_ctx.available_tools = self.tools().tool_definitions().await;
// Generate plan if planning is enabled
let plan = if self.use_planning {
let plan = if self.use_planning() {
match reasoning.plan(reason_ctx).await {
Ok(p) => {
tracing::info!(
@@ -213,29 +232,61 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
return Ok(());
}
// Refresh tool definitions so newly built tools become visible
reason_ctx.available_tools = self.tools().tool_definitions().await;
// Select next tool(s) to use
let selections = reasoning.select_tools(reason_ctx).await?;
if selections.is_empty() {
// No tools selected, ask LLM for next steps
let response = reasoning.respond(reason_ctx).await?;
// No tools from select_tools, ask LLM directly (may still return tool calls)
let respond_result = reasoning.respond_with_tools(reason_ctx).await?;
if response.to_lowercase().contains("complete")
|| response.to_lowercase().contains("finished")
|| response.to_lowercase().contains("done")
{
self.mark_completed().await?;
return Ok(());
}
match respond_result {
RespondResult::Text(response) => {
// Check for completion keywords
let response_lower = response.to_lowercase();
if response_lower.contains("complete")
|| response_lower.contains("finished")
|| response_lower.contains("done")
{
self.mark_completed().await?;
return Ok(());
}
// Add assistant response to context
reason_ctx.messages.push(ChatMessage::assistant(&response));
// Add assistant response to context
reason_ctx.messages.push(ChatMessage::assistant(&response));
// Give it one more chance to select a tool
if iteration > 3 && iteration % 5 == 0 {
reason_ctx.messages.push(ChatMessage::user(
"Are you stuck? Do you need help completing this job?",
));
// Give it one more chance to select a tool
if iteration > 3 && iteration % 5 == 0 {
reason_ctx.messages.push(ChatMessage::user(
"Are you stuck? Do you need help completing this job?",
));
}
}
RespondResult::ToolCalls(tool_calls) => {
// Model returned tool calls - execute them
tracing::debug!(
"Job {} respond_with_tools returned {} tool calls",
self.job_id,
tool_calls.len()
);
for tc in tool_calls {
let result = self.execute_tool(&tc.name, &tc.arguments).await;
// Create synthetic selection for process_tool_result
let selection = ToolSelection {
tool_name: tc.name.clone(),
parameters: tc.arguments.clone(),
reasoning: String::new(),
alternatives: vec![],
};
self.process_tool_result(reason_ctx, &selection, result)
.await?;
}
}
}
} else if selections.len() == 1 {
// Single tool: execute directly
@@ -282,10 +333,10 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
.map(|selection| {
let tool_name = selection.tool_name.clone();
let params = selection.parameters.clone();
let tools = self.tools.clone();
let context_manager = self.context_manager.clone();
let tools = self.tools().clone();
let context_manager = self.context_manager().clone();
let job_id = self.job_id;
let store = self.store.clone();
let store = self.deps.store.clone();
async move {
let result = Self::execute_tool_inner(
@@ -321,6 +372,15 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
name: tool_name.to_string(),
})?;
// Log warning if tool requires approval (autonomous jobs auto-approve for now)
if tool.requires_approval() {
tracing::warn!(
job_id = %job_id,
tool = %tool_name,
"Executing sensitive tool in autonomous job (auto-approved)"
);
}
// Get job context for the tool
let job_ctx = context_manager.get_context(job_id).await?;
@@ -412,11 +472,11 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
Ok(output) => {
// Sanitize output
let sanitized = self
.safety
.safety()
.sanitize_tool_output(&selection.tool_name, &output);
// Add to context
let wrapped = self.safety.wrap_for_llm(
let wrapped = self.safety().wrap_for_llm(
&selection.tool_name,
&sanitized.content,
sanitized.was_modified,
@@ -445,7 +505,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
);
// Record failure for self-repair tracking
if let Some(ref store) = self.store {
if let Some(store) = self.store() {
let store = store.clone();
let tool_name = selection.tool_name.clone();
let error_msg = e.to_string();
@@ -563,9 +623,9 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
params: &serde_json::Value,
) -> Result<String, Error> {
Self::execute_tool_inner(
self.tools.clone(),
self.context_manager.clone(),
self.store.clone(),
self.tools().clone(),
self.context_manager().clone(),
self.deps.store.clone(),
self.job_id,
tool_name,
params,
@@ -574,7 +634,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
}
async fn mark_completed(&self) -> Result<(), Error> {
self.context_manager
self.context_manager()
.update_context(self.job_id, |ctx| {
ctx.transition_to(
JobState::Completed,
@@ -595,7 +655,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
}
async fn mark_failed(&self, reason: &str) -> Result<(), Error> {
self.context_manager
self.context_manager()
.update_context(self.job_id, |ctx| {
ctx.transition_to(JobState::Failed, Some(reason.to_string()))
})
@@ -610,7 +670,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
}
async fn mark_stuck(&self, reason: &str) -> Result<(), Error> {
self.context_manager
self.context_manager()
.update_context(self.job_id, |ctx| ctx.mark_stuck(reason))
.await?
.map_err(|s| crate::error::JobError::ContextError {