Replace the dual task/monitor model, NO_REPLY string protocol, and Agent self-delivery with a single Scheduled Run path: claim-time JobRun snapshots, isolated Root/named Agent execution, exactly-once complete_scheduled_run termination, and Scheduler-owned policy delivery through a persistent outbox. - SQLite v11: drop job_kind/model/delete_after_run, add job_runs with status/outcome joint constraints and delivery lease columns; one-shot BEGIN IMMEDIATE migration with atomic rollback. - Non-blocking JoinSet event loop with bounded run/delivery concurrency; terminal commit before any channel I/O; recover unfinished runs as unknown. - ExecutionOrigin::Scheduled propagates to descendants, completion sink is top-level only, background delegation downgrades to foreground. - Typed delivery receipts, fixed target_session_id, idempotent scheduled:<job_run_id> history insert. - New cron_runs read-only tool; cron_add/update drop kind/model; WebUI and Health consume the same JobRun projection. - Bump version to 1.22.0.
824 lines
30 KiB
Rust
824 lines
30 KiB
Rust
use std::sync::Arc;
|
|
use std::time::Instant;
|
|
|
|
use crate::agent::AgentError;
|
|
use crate::agent::AgentLoop;
|
|
use crate::agent::system_prompt::build_sub_agent_system_prompt;
|
|
use crate::bus::ChatMessage;
|
|
use crate::config::LLMProviderConfig;
|
|
use crate::providers::{LLMProvider, create_provider};
|
|
use crate::skills::SkillsLoader;
|
|
use crate::tools::{ToolExecutionContext, ToolRegistry};
|
|
|
|
const SCHEDULED_DISABLED_TOOLS: &[&str] = &[
|
|
"send_message",
|
|
"cron_add",
|
|
"cron_update",
|
|
"cron_remove",
|
|
"cron_enable",
|
|
"cron_disable",
|
|
"cron_list",
|
|
"cron_runs",
|
|
"reload_config",
|
|
"agent_task",
|
|
"chat_manager",
|
|
"todo",
|
|
"emit_signal",
|
|
];
|
|
|
|
const DEFAULT_MAX_ITERATIONS: usize = 99;
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct SubAgentConfig {
|
|
pub target: Option<String>,
|
|
pub prompt: String,
|
|
pub context: Option<String>,
|
|
pub mode: ExecutionMode,
|
|
pub allowed_tools: Option<Vec<String>>,
|
|
pub max_iterations: Option<usize>,
|
|
pub timeout_secs: Option<u64>,
|
|
pub plan_item_id: Option<String>,
|
|
pub session_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum ExecutionMode {
|
|
Foreground,
|
|
Background,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct SubAgentResult {
|
|
pub task_id: String,
|
|
/// Bounded projection returned to the model; may carry a truncation note.
|
|
pub content: String,
|
|
pub content_truncated: bool,
|
|
/// Untruncated final text, persisted as the durable run result.
|
|
pub full_content: String,
|
|
pub status: TaskStatus,
|
|
pub tool_calls_count: usize,
|
|
pub iterations: usize,
|
|
pub duration_ms: u64,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum TaskStatus {
|
|
Completed,
|
|
Failed(String),
|
|
Cancelled,
|
|
TimedOut,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub enum SubAgentError {
|
|
ProviderCreation(String),
|
|
Storage(String),
|
|
Other(String),
|
|
}
|
|
|
|
impl std::fmt::Display for SubAgentError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::ProviderCreation(e) => write!(f, "provider creation failed: {}", e),
|
|
Self::Storage(e) => write!(f, "storage error: {}", e),
|
|
Self::Other(e) => write!(f, "{}", e),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for SubAgentError {}
|
|
|
|
pub struct SubAgentManager {
|
|
provider_config: LLMProviderConfig,
|
|
full_tools: Arc<ToolRegistry>,
|
|
storage: Option<Arc<crate::storage::Storage>>,
|
|
skills_loader: Option<Arc<SkillsLoader>>,
|
|
work_manager: Option<Arc<crate::work::WorkManager>>,
|
|
catalog: Arc<crate::agent::AgentCatalog>,
|
|
execution_gate: Arc<crate::agent::gate::ExecutionGate>,
|
|
/// Late-bound durable Coordinator. Signals are only available to runs
|
|
/// whose definition carries a signal contract AND the runtime has an
|
|
/// active Coordinator; resolution happens at delegate time.
|
|
coordinator: std::sync::RwLock<Option<std::sync::Weak<super::coordinator::AgentCoordinator>>>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub(crate) struct ResolvedAgentRun {
|
|
pub provider_config: Arc<LLMProviderConfig>,
|
|
pub tools: Arc<ToolRegistry>,
|
|
pub timeout_secs: u64,
|
|
pub max_iterations: usize,
|
|
pub max_result_chars: usize,
|
|
pub role_prompt: Option<String>,
|
|
pub skills_prompt: Option<String>,
|
|
pub tool_context: ToolExecutionContext,
|
|
/// Named-definition metadata used by the durable Coordinator; `None` for
|
|
/// the legacy transient general Agent.
|
|
pub agent_id: Option<String>,
|
|
pub definition_hash: Option<String>,
|
|
pub llm_profile: Option<String>,
|
|
/// Durable signal contract of the definition; `None` means the run can
|
|
/// never emit signals and must not persist contract state.
|
|
pub signal_contract: Option<crate::agent::definition::SignalContract>,
|
|
}
|
|
|
|
impl SubAgentManager {
|
|
pub fn new(
|
|
provider_config: LLMProviderConfig,
|
|
full_tools: Arc<ToolRegistry>,
|
|
storage: Option<Arc<crate::storage::Storage>>,
|
|
skills_loader: Option<Arc<SkillsLoader>>,
|
|
) -> Self {
|
|
Self {
|
|
provider_config,
|
|
full_tools,
|
|
storage,
|
|
skills_loader,
|
|
work_manager: None,
|
|
catalog: Arc::new(crate::agent::AgentCatalog::legacy()),
|
|
execution_gate: crate::agent::gate::ExecutionGate::unbounded(),
|
|
coordinator: std::sync::RwLock::new(None),
|
|
}
|
|
}
|
|
|
|
/// Bind the durable Coordinator so named runs can resolve the
|
|
/// contract-bound `emit_signal` tool. Kept as a weak reference: the
|
|
/// Coordinator owns this manager, so a strong cycle must never exist.
|
|
pub fn bind_coordinator(&self, coordinator: &Arc<super::coordinator::AgentCoordinator>) {
|
|
*self.coordinator.write().unwrap() = Some(Arc::downgrade(coordinator));
|
|
}
|
|
|
|
fn coordinator(&self) -> Option<Arc<super::coordinator::AgentCoordinator>> {
|
|
self.coordinator
|
|
.read()
|
|
.unwrap()
|
|
.as_ref()
|
|
.and_then(std::sync::Weak::upgrade)
|
|
}
|
|
|
|
pub fn with_catalog(mut self, catalog: Arc<crate::agent::AgentCatalog>) -> Self {
|
|
self.catalog = catalog;
|
|
self
|
|
}
|
|
|
|
pub fn with_execution_gate(
|
|
mut self,
|
|
execution_gate: Arc<crate::agent::gate::ExecutionGate>,
|
|
) -> Self {
|
|
self.execution_gate = execution_gate;
|
|
self
|
|
}
|
|
|
|
pub fn catalog(&self) -> Arc<crate::agent::AgentCatalog> {
|
|
self.catalog.clone()
|
|
}
|
|
|
|
pub fn with_work_manager(mut self, work_manager: Arc<crate::work::WorkManager>) -> Self {
|
|
self.work_manager = Some(work_manager);
|
|
self
|
|
}
|
|
|
|
pub(crate) fn resolve_agent(
|
|
&self,
|
|
config: &SubAgentConfig,
|
|
caller: &ToolExecutionContext,
|
|
task_id: &str,
|
|
) -> Result<ResolvedAgentRun, SubAgentError> {
|
|
let Some(target) = config.target.as_deref() else {
|
|
return Err(SubAgentError::Other(
|
|
"delegate requires a named target Agent; the legacy anonymous general Agent is no longer available"
|
|
.to_string(),
|
|
));
|
|
};
|
|
|
|
let definition = self
|
|
.catalog
|
|
.get(target)
|
|
.ok_or_else(|| SubAgentError::Other(format!("unknown Agent target '{target}'")))?;
|
|
let root_session_id = caller
|
|
.agent
|
|
.as_ref()
|
|
.map(|context| context.root_session_id.clone())
|
|
.or_else(|| caller.session_id.clone())
|
|
.ok_or_else(|| {
|
|
SubAgentError::Other(
|
|
"delegate requires a session-bound ToolExecutionContext".to_string(),
|
|
)
|
|
})?;
|
|
let cancellation = caller.cancellation.child_token();
|
|
let execution = if let Some(parent) = caller.agent.as_ref() {
|
|
let allowed =
|
|
if caller.execution_origin.is_scheduled() && parent.current_agent_id == "ROOT" {
|
|
self.catalog.root_can_delegate(target)
|
|
} else {
|
|
self.catalog.can_delegate(&parent.current_agent_id, target)
|
|
};
|
|
if !allowed {
|
|
return Err(SubAgentError::Other(format!(
|
|
"Agent '{}' is not allowed to delegate to '{target}'",
|
|
parent.current_agent_id
|
|
)));
|
|
}
|
|
if parent.ancestry.iter().any(|agent| agent == target) {
|
|
return Err(SubAgentError::Other(format!(
|
|
"delegation cycle rejected: '{target}' is already in the current ancestry"
|
|
)));
|
|
}
|
|
if parent.budget.remaining_runs == 0 || parent.budget.remaining_depth == 0 {
|
|
return Err(SubAgentError::Other(
|
|
"delegation budget exhausted".to_string(),
|
|
));
|
|
}
|
|
let next_depth = parent.depth.saturating_add(1);
|
|
if next_depth > self.catalog.max_tree_depth() {
|
|
return Err(SubAgentError::Other(format!(
|
|
"delegation depth {next_depth} exceeds global limit {}",
|
|
self.catalog.max_tree_depth()
|
|
)));
|
|
}
|
|
if parent
|
|
.reserve_tree_run(self.catalog.max_runs_per_tree())
|
|
.is_none()
|
|
{
|
|
return Err(SubAgentError::Other(format!(
|
|
"delegation tree already uses {} runs; max_runs_per_tree is {}",
|
|
self.catalog.max_runs_per_tree(),
|
|
self.catalog.max_runs_per_tree()
|
|
)));
|
|
}
|
|
let mut child = crate::agent::AgentExecutionContext::child(
|
|
parent,
|
|
task_id.to_string(),
|
|
target.to_string(),
|
|
config.plan_item_id.clone(),
|
|
cancellation.clone(),
|
|
);
|
|
child.budget.remaining_depth = child
|
|
.budget
|
|
.remaining_depth
|
|
.min(definition.limits.max_depth);
|
|
child.signal_contract = if caller.execution_origin.is_scheduled() {
|
|
None
|
|
} else {
|
|
definition
|
|
.signal_contract
|
|
.as_ref()
|
|
.map(|contract| Arc::new(contract.clone()))
|
|
};
|
|
Arc::new(child)
|
|
} else {
|
|
if !self.catalog.root_can_delegate(target) {
|
|
return Err(SubAgentError::Other(format!(
|
|
"ROOT is not allowed to delegate to '{target}'"
|
|
)));
|
|
}
|
|
Arc::new(crate::agent::AgentExecutionContext {
|
|
root_session_id: root_session_id.clone(),
|
|
root_turn_id: caller.turn_id.clone(),
|
|
run_id: task_id.to_string(),
|
|
execution_id: task_id.to_string(),
|
|
parent_run_id: None,
|
|
caller_agent_id: if caller.execution_origin.is_scheduled() {
|
|
"SCHEDULER".to_string()
|
|
} else {
|
|
"ROOT".to_string()
|
|
},
|
|
current_agent_id: target.to_string(),
|
|
ancestry: vec![target.to_string()],
|
|
depth: 1,
|
|
plan_item_id: config.plan_item_id.clone(),
|
|
cancellation: cancellation.clone(),
|
|
budget: crate::agent::AgentBudget {
|
|
remaining_runs: self.catalog.max_runs_per_tree().saturating_sub(1),
|
|
remaining_depth: self
|
|
.catalog
|
|
.max_tree_depth()
|
|
.saturating_sub(1)
|
|
.min(definition.limits.max_depth),
|
|
},
|
|
tree_runs: Arc::new(std::sync::atomic::AtomicUsize::new(1)),
|
|
signal_contract: if caller.execution_origin.is_scheduled() {
|
|
None
|
|
} else {
|
|
definition
|
|
.signal_contract
|
|
.as_ref()
|
|
.map(|contract| Arc::new(contract.clone()))
|
|
},
|
|
emitted_signals: Arc::new(std::sync::Mutex::new(Vec::new())),
|
|
})
|
|
};
|
|
|
|
let mut effective_names = definition.tools.clone();
|
|
if let Some(allowed) = config.allowed_tools.as_ref() {
|
|
effective_names.retain(|name| allowed.iter().any(|allowed| allowed == name));
|
|
}
|
|
if caller.execution_origin.is_scheduled() {
|
|
effective_names.retain(|name| !SCHEDULED_DISABLED_TOOLS.contains(&name.as_str()));
|
|
}
|
|
let has_get_skill = effective_names.iter().any(|name| name == "get_skill");
|
|
let mut names = effective_names;
|
|
names.retain(|name| name != "get_skill");
|
|
let mut runtime_tools = Vec::new();
|
|
let skills_prompt = if has_get_skill {
|
|
let loader = self
|
|
.skills_loader
|
|
.as_ref()
|
|
.ok_or_else(|| SubAgentError::Other("skills loader is unavailable".to_string()))?;
|
|
runtime_tools.push(Arc::new(crate::tools::GetSkillTool::scoped(
|
|
loader.clone(),
|
|
&definition.skills,
|
|
)) as Arc<dyn crate::tools::Tool>);
|
|
let prompt = loader.build_scoped_skills_prompt(&definition.skills);
|
|
(!prompt.is_empty()).then_some(prompt)
|
|
} else {
|
|
None
|
|
};
|
|
let delegate_targets = self.catalog.delegate_targets(target);
|
|
if !delegate_targets.is_empty() {
|
|
let delegate = self.full_tools.get("delegate").ok_or_else(|| {
|
|
SubAgentError::Other("delegate runtime tool is unavailable".to_string())
|
|
})?;
|
|
runtime_tools.push(Arc::new(crate::tools::delegate::ScopedDelegateTool::new(
|
|
delegate,
|
|
delegate_targets,
|
|
)) as Arc<dyn crate::tools::Tool>);
|
|
}
|
|
// The signal tool is contract-bound: it exists only when the
|
|
// definition declares a signal block and the durable Coordinator is
|
|
// live. If either is missing the run cannot emit signals.
|
|
if definition.signal_contract.is_some() && !caller.execution_origin.is_scheduled() {
|
|
match self.coordinator() {
|
|
Some(coordinator) => {
|
|
let contract = definition.signal_contract.clone().unwrap();
|
|
runtime_tools.push(Arc::new(crate::tools::EmitSignalTool::new(
|
|
coordinator,
|
|
Arc::new(contract),
|
|
)) as Arc<dyn crate::tools::Tool>);
|
|
}
|
|
None => {
|
|
return Err(SubAgentError::Other(
|
|
"Agent '{}' declares a signal contract but the durable Coordinator is unavailable"
|
|
.replace("{}", target),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
let tools = self
|
|
.full_tools
|
|
.scoped_for_agent(&names, runtime_tools)
|
|
.map_err(SubAgentError::Other)?;
|
|
|
|
Ok(ResolvedAgentRun {
|
|
provider_config: definition.provider_config.clone(),
|
|
tools,
|
|
timeout_secs: definition.limits.timeout_secs,
|
|
max_iterations: definition.limits.max_iterations,
|
|
max_result_chars: definition.limits.max_result_chars,
|
|
role_prompt: Some(definition.role_prompt.clone()),
|
|
skills_prompt,
|
|
agent_id: Some(target.to_string()),
|
|
definition_hash: Some(definition.definition_hash.clone()),
|
|
llm_profile: definition.llm_profile.clone(),
|
|
signal_contract: (!caller.execution_origin.is_scheduled())
|
|
.then(|| definition.signal_contract.clone())
|
|
.flatten(),
|
|
tool_context: ToolExecutionContext::for_session(
|
|
if caller.execution_origin.is_scheduled() {
|
|
root_session_id
|
|
} else {
|
|
format!("agent-run:{task_id}")
|
|
},
|
|
)
|
|
.with_turn_id(
|
|
caller
|
|
.turn_id
|
|
.clone()
|
|
.unwrap_or_else(|| task_id.to_string()),
|
|
)
|
|
.with_agent(execution)
|
|
.with_cancellation(cancellation)
|
|
.with_execution_gate(self.execution_gate.clone())
|
|
.with_execution_origin(caller.execution_origin),
|
|
})
|
|
}
|
|
|
|
pub(crate) fn resolve_scheduled_root(
|
|
&self,
|
|
caller: &ToolExecutionContext,
|
|
task_id: &str,
|
|
timeout_secs: u64,
|
|
sink: Arc<crate::tools::ScheduledCompletionSink>,
|
|
) -> Result<ResolvedAgentRun, SubAgentError> {
|
|
if !caller.execution_origin.is_scheduled() {
|
|
return Err(SubAgentError::Other(
|
|
"scheduled root resolution requires Scheduled execution origin".to_string(),
|
|
));
|
|
}
|
|
let root_session_id = caller.session_id.clone().ok_or_else(|| {
|
|
SubAgentError::Other("scheduled root requires an execution scope".to_string())
|
|
})?;
|
|
let run_id = task_id.to_string();
|
|
let cancellation = caller.cancellation.child_token();
|
|
let execution = Arc::new(crate::agent::AgentExecutionContext {
|
|
root_session_id: root_session_id.clone(),
|
|
root_turn_id: None,
|
|
run_id: run_id.clone(),
|
|
execution_id: run_id,
|
|
parent_run_id: None,
|
|
caller_agent_id: "SCHEDULER".to_string(),
|
|
current_agent_id: "ROOT".to_string(),
|
|
ancestry: vec!["ROOT".to_string()],
|
|
depth: 1,
|
|
plan_item_id: None,
|
|
cancellation: cancellation.clone(),
|
|
budget: crate::agent::AgentBudget {
|
|
remaining_runs: self.catalog.max_runs_per_tree().saturating_sub(1),
|
|
remaining_depth: self.catalog.max_tree_depth().saturating_sub(1),
|
|
},
|
|
tree_runs: Arc::new(std::sync::atomic::AtomicUsize::new(1)),
|
|
signal_contract: None,
|
|
emitted_signals: Arc::new(std::sync::Mutex::new(Vec::new())),
|
|
});
|
|
let tools = self.full_tools.without(SCHEDULED_DISABLED_TOOLS);
|
|
tools.register(crate::tools::CompleteScheduledRunTool::new());
|
|
let skills_prompt = self
|
|
.skills_loader
|
|
.as_ref()
|
|
.map(|loader| loader.build_skills_prompt())
|
|
.filter(|prompt| !prompt.is_empty());
|
|
Ok(ResolvedAgentRun {
|
|
provider_config: Arc::new(self.provider_config.clone()),
|
|
tools,
|
|
timeout_secs,
|
|
max_iterations: self.provider_config.max_tool_iterations,
|
|
max_result_chars: 16_384,
|
|
role_prompt: None,
|
|
skills_prompt,
|
|
tool_context: ToolExecutionContext::for_session(root_session_id)
|
|
.with_agent(execution)
|
|
.with_cancellation(cancellation)
|
|
.with_execution_gate(self.execution_gate.clone())
|
|
.with_execution_origin(caller.execution_origin)
|
|
.with_scheduled_completion(sink),
|
|
agent_id: None,
|
|
definition_hash: None,
|
|
llm_profile: None,
|
|
signal_contract: None,
|
|
})
|
|
}
|
|
|
|
pub fn build_sub_agent(
|
|
&self,
|
|
config: &SubAgentConfig,
|
|
tools: Arc<ToolRegistry>,
|
|
) -> Result<AgentLoop, AgentError> {
|
|
self.build_sub_agent_with_provider(config, tools, &self.provider_config)
|
|
}
|
|
|
|
fn build_sub_agent_with_provider(
|
|
&self,
|
|
config: &SubAgentConfig,
|
|
tools: Arc<ToolRegistry>,
|
|
provider_config: &LLMProviderConfig,
|
|
) -> Result<AgentLoop, AgentError> {
|
|
let mut provider = create_provider(provider_config.clone())
|
|
.map_err(|e| AgentError::ProviderCreation(e.to_string()))?;
|
|
if let Some(ref s) = self.storage {
|
|
provider.set_storage(s.clone());
|
|
}
|
|
let provider: Arc<dyn LLMProvider> = Arc::from(provider);
|
|
|
|
let max_iterations = config.max_iterations.unwrap_or(DEFAULT_MAX_ITERATIONS);
|
|
let workspace_dir = provider_config.workspace_dir.clone();
|
|
let model_name = provider_config.model_id.clone();
|
|
let input_types = provider_config.input_types.clone();
|
|
|
|
let agent = AgentLoop::with_provider_and_tools(
|
|
provider,
|
|
tools,
|
|
max_iterations,
|
|
model_name,
|
|
workspace_dir,
|
|
input_types,
|
|
)
|
|
.with_context_window(provider_config.token_limit);
|
|
|
|
Ok(agent)
|
|
}
|
|
|
|
/// Execute an already-resolved Agent. The durable Coordinator owns run
|
|
/// admission and terminal commits; this is the shared execution core.
|
|
pub(crate) async fn execute_resolved(
|
|
&self,
|
|
config: &SubAgentConfig,
|
|
resolved: ResolvedAgentRun,
|
|
task_id: &str,
|
|
) -> Result<SubAgentResult, SubAgentError> {
|
|
let tools = resolved.tools;
|
|
let timeout_secs = resolved.timeout_secs;
|
|
let timeout_human = format_duration(timeout_secs);
|
|
let mut system_prompt = build_sub_agent_system_prompt(
|
|
&config.prompt,
|
|
&timeout_human,
|
|
&tools,
|
|
&resolved.provider_config.workspace_dir,
|
|
&resolved.provider_config.model_id,
|
|
resolved.skills_prompt,
|
|
);
|
|
if let Some(role_prompt) = resolved.role_prompt {
|
|
system_prompt.push_str("\n\n## Agent Definition\n\n");
|
|
system_prompt.push_str(&role_prompt);
|
|
}
|
|
if let Some(context) = config
|
|
.context
|
|
.as_deref()
|
|
.filter(|value| !value.trim().is_empty())
|
|
{
|
|
system_prompt.push_str("\n\n## 调用方提供的任务上下文\n\n");
|
|
system_prompt.push_str(context);
|
|
}
|
|
|
|
let mut effective_config = config.clone();
|
|
effective_config.max_iterations = Some(resolved.max_iterations);
|
|
let max_result_chars = resolved.max_result_chars;
|
|
|
|
let (transcript_tx, transcript_rx) = tokio::sync::mpsc::unbounded_channel();
|
|
let agent = self
|
|
.build_sub_agent_with_provider(&effective_config, tools, &resolved.provider_config)
|
|
.map_err(|e| SubAgentError::ProviderCreation(e.to_string()))?
|
|
.with_transcript_sink(transcript_tx);
|
|
|
|
let history = vec![
|
|
ChatMessage::system(system_prompt),
|
|
ChatMessage::user(&config.prompt),
|
|
];
|
|
|
|
let start = Instant::now();
|
|
let tool_context = resolved.tool_context;
|
|
|
|
let writer = self.spawn_transcript_writer(task_id, transcript_rx);
|
|
|
|
let outcome = tokio::select! {
|
|
result = tokio::time::timeout(
|
|
std::time::Duration::from_secs(timeout_secs),
|
|
agent.process_with_context(history, tool_context.clone()),
|
|
) => match result {
|
|
Ok(inner) => ExecutionOutcome::Finished(Box::new(inner)),
|
|
Err(_elapsed) => ExecutionOutcome::TimedOut,
|
|
},
|
|
_ = tool_context.cancellation.cancelled() => ExecutionOutcome::Cancelled,
|
|
};
|
|
|
|
let duration_ms = start.elapsed().as_millis() as u64;
|
|
|
|
// Drop the agent (which owns the transcript sender) so the writer can
|
|
// drain, then await the writer before the caller's terminal commit so
|
|
// the persisted transcript is complete first.
|
|
drop(agent);
|
|
if let Err(error) = writer.await {
|
|
tracing::warn!(run_id = task_id, error = %error, "transcript writer failed");
|
|
}
|
|
|
|
Ok(match outcome {
|
|
ExecutionOutcome::Finished(result) => match *result {
|
|
Ok(agent_result) => {
|
|
let (content, truncated) = truncate_sub_agent_result_at(
|
|
&agent_result.final_response.content,
|
|
max_result_chars,
|
|
);
|
|
let tool_calls_count = agent_result
|
|
.emitted_messages
|
|
.iter()
|
|
.filter(|m| m.tool_calls.is_some())
|
|
.count();
|
|
let iterations = agent_result
|
|
.emitted_messages
|
|
.iter()
|
|
.filter(|m| m.role == "assistant" && m.tool_calls.is_some())
|
|
.count();
|
|
SubAgentResult {
|
|
task_id: task_id.to_string(),
|
|
content,
|
|
content_truncated: truncated,
|
|
full_content: agent_result.final_response.content,
|
|
status: TaskStatus::Completed,
|
|
tool_calls_count,
|
|
iterations,
|
|
duration_ms,
|
|
}
|
|
}
|
|
Err(error) => SubAgentResult {
|
|
task_id: task_id.to_string(),
|
|
content: String::new(),
|
|
content_truncated: false,
|
|
full_content: String::new(),
|
|
status: terminal_status_from_error(error),
|
|
tool_calls_count: 0,
|
|
iterations: 0,
|
|
duration_ms,
|
|
},
|
|
},
|
|
ExecutionOutcome::TimedOut => SubAgentResult {
|
|
task_id: task_id.to_string(),
|
|
content: String::new(),
|
|
content_truncated: false,
|
|
full_content: String::new(),
|
|
status: TaskStatus::TimedOut,
|
|
tool_calls_count: 0,
|
|
iterations: 0,
|
|
duration_ms,
|
|
},
|
|
ExecutionOutcome::Cancelled => SubAgentResult {
|
|
task_id: task_id.to_string(),
|
|
content: String::new(),
|
|
content_truncated: false,
|
|
full_content: String::new(),
|
|
status: TaskStatus::Cancelled,
|
|
tool_calls_count: 0,
|
|
iterations: 0,
|
|
duration_ms,
|
|
},
|
|
})
|
|
}
|
|
|
|
/// Spawn a task that drains the transcript channel into
|
|
/// `agent_run_messages`, assigning a monotonically increasing `seq` and
|
|
/// stripping `provider_state` (which must never be persisted or exposed).
|
|
/// With no storage the writer becomes a drain-and-discard no-op.
|
|
fn spawn_transcript_writer(
|
|
&self,
|
|
run_id: &str,
|
|
receiver: tokio::sync::mpsc::UnboundedReceiver<ChatMessage>,
|
|
) -> tokio::task::JoinHandle<()> {
|
|
let storage = self.storage.clone();
|
|
let run_id = run_id.to_string();
|
|
tokio::spawn(async move {
|
|
let Some(storage) = storage else {
|
|
let mut receiver = receiver;
|
|
while receiver.recv().await.is_some() {}
|
|
return;
|
|
};
|
|
let mut seq = 0i64;
|
|
let mut receiver = receiver;
|
|
while let Some(mut message) = receiver.recv().await {
|
|
message.provider_state = None;
|
|
let now = chrono::Utc::now().timestamp_millis();
|
|
if let Err(error) = storage
|
|
.append_agent_run_message(&run_id, seq, &message, now)
|
|
.await
|
|
{
|
|
tracing::warn!(
|
|
run_id = %run_id,
|
|
seq,
|
|
error = %error,
|
|
"failed to append transcript message"
|
|
);
|
|
}
|
|
seq += 1;
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Intermediate outcome of a resolved run, unified so the transcript writer
|
|
/// is awaited on every exit path before `execute_resolved` returns.
|
|
enum ExecutionOutcome {
|
|
Finished(Box<Result<crate::agent::AgentProcessResult, AgentError>>),
|
|
TimedOut,
|
|
Cancelled,
|
|
}
|
|
|
|
fn terminal_status_from_error(error: AgentError) -> TaskStatus {
|
|
match error {
|
|
AgentError::Cancelled => TaskStatus::Cancelled,
|
|
AgentError::TimedOut => TaskStatus::TimedOut,
|
|
other => TaskStatus::Failed(other.to_string()),
|
|
}
|
|
}
|
|
|
|
fn format_duration(seconds: u64) -> String {
|
|
if seconds < 60 {
|
|
format!("{}s", seconds)
|
|
} else if seconds < 3600 {
|
|
format!("{}m", seconds / 60)
|
|
} else {
|
|
format!("{}h", seconds / 3600)
|
|
}
|
|
}
|
|
|
|
fn truncate_sub_agent_result_at(content: &str, max_chars: usize) -> (String, bool) {
|
|
if content.len() <= max_chars {
|
|
(content.to_string(), false)
|
|
} else {
|
|
let truncate_at = content.floor_char_boundary(max_chars);
|
|
(
|
|
format!(
|
|
"{}\n\n[... 结果已截断,共 {} 字符,完整结果请使用 check_task 查看 ...]",
|
|
&content[..truncate_at],
|
|
content.len()
|
|
),
|
|
true,
|
|
)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::collections::HashMap;
|
|
|
|
fn manager() -> SubAgentManager {
|
|
SubAgentManager::new(
|
|
LLMProviderConfig {
|
|
provider_type: "openai".into(),
|
|
name: "test".into(),
|
|
base_url: "http://localhost".into(),
|
|
api_key: "test".into(),
|
|
extra_headers: HashMap::new(),
|
|
model_id: "test".into(),
|
|
temperature: None,
|
|
max_tokens: None,
|
|
model_extra: HashMap::new(),
|
|
max_tool_iterations: 1,
|
|
token_limit: 4096,
|
|
workspace_dir: std::env::temp_dir(),
|
|
input_types: vec!["text".into()],
|
|
price_input_per_million: None,
|
|
price_output_per_million: None,
|
|
},
|
|
Arc::new(ToolRegistry::new()),
|
|
None,
|
|
None,
|
|
)
|
|
}
|
|
|
|
fn config(target: Option<&str>) -> SubAgentConfig {
|
|
SubAgentConfig {
|
|
target: target.map(str::to_string),
|
|
prompt: "test".into(),
|
|
context: None,
|
|
mode: ExecutionMode::Foreground,
|
|
allowed_tools: None,
|
|
max_iterations: None,
|
|
timeout_secs: None,
|
|
plan_item_id: None,
|
|
session_id: Some("cli:test:dialog".to_string()),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn runtime_injected_tools_are_marked_but_ordinary_tools_are_not() {
|
|
let reload = crate::tools::ReloadConfigTool::new(
|
|
crate::gateway::reload::ReloadHandle::unavailable(),
|
|
);
|
|
assert!(!crate::tools::Tool::runtime_injected(&reload));
|
|
}
|
|
|
|
#[test]
|
|
fn scheduled_runs_remove_direct_and_interactive_control_tools() {
|
|
for name in [
|
|
"send_message",
|
|
"cron_add",
|
|
"cron_runs",
|
|
"reload_config",
|
|
"agent_task",
|
|
"chat_manager",
|
|
"todo",
|
|
"emit_signal",
|
|
] {
|
|
assert!(SCHEDULED_DISABLED_TOOLS.contains(&name));
|
|
}
|
|
assert!(!SCHEDULED_DISABLED_TOOLS.contains(&"bash"));
|
|
assert!(!SCHEDULED_DISABLED_TOOLS.contains(&"delegate"));
|
|
assert!(!SCHEDULED_DISABLED_TOOLS.contains(&"complete_scheduled_run"));
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_agent_rejects_missing_target() {
|
|
let manager = manager();
|
|
let error =
|
|
match manager.resolve_agent(&config(None), &ToolExecutionContext::default(), "t-1") {
|
|
Ok(_) => panic!("expected rejection"),
|
|
Err(error) => error,
|
|
};
|
|
assert!(matches!(error, SubAgentError::Other(message) if message.contains("named target")));
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_agent_rejects_unknown_target_without_catalog() {
|
|
let manager = manager();
|
|
let error = match manager.resolve_agent(
|
|
&config(Some("ghost")),
|
|
&ToolExecutionContext::default(),
|
|
"t-2",
|
|
) {
|
|
Ok(_) => panic!("expected rejection"),
|
|
Err(error) => error,
|
|
};
|
|
assert!(
|
|
matches!(error, SubAgentError::Other(message) if message.contains("unknown Agent target"))
|
|
);
|
|
}
|
|
}
|