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.
437 lines
15 KiB
Rust
437 lines
15 KiB
Rust
use std::sync::Arc;
|
||
|
||
use async_trait::async_trait;
|
||
use serde_json::{Value, json};
|
||
|
||
use crate::agent::{AgentCoordinator, ExecutionMode, SubAgentConfig, SubAgentManager, TaskStatus};
|
||
use crate::tools::traits::{Tool, ToolExecutionContext, ToolOutput, ToolResult};
|
||
|
||
pub struct DelegateTool {
|
||
sub_agent_manager: Arc<SubAgentManager>,
|
||
coordinator: Option<Arc<AgentCoordinator>>,
|
||
}
|
||
|
||
/// Per-run schema view for a child Agent. Authorization still happens in the
|
||
/// manager from ToolExecutionContext; this wrapper keeps the model-visible
|
||
/// target enum aligned with that Agent's configured outgoing edges.
|
||
pub(crate) struct ScopedDelegateTool {
|
||
inner: Arc<dyn Tool>,
|
||
targets: Vec<String>,
|
||
}
|
||
|
||
impl ScopedDelegateTool {
|
||
pub(crate) fn new(inner: Arc<dyn Tool>, targets: Vec<String>) -> Self {
|
||
Self { inner, targets }
|
||
}
|
||
}
|
||
|
||
#[async_trait]
|
||
impl Tool for ScopedDelegateTool {
|
||
fn name(&self) -> &str {
|
||
self.inner.name()
|
||
}
|
||
|
||
fn description(&self) -> &str {
|
||
self.inner.description()
|
||
}
|
||
|
||
fn parameters_schema(&self) -> Value {
|
||
let mut schema = self.inner.parameters_schema();
|
||
schema["properties"]["target"]["enum"] = json!(self.targets);
|
||
schema["properties"]["tasks"]["items"]["properties"]["target"]["enum"] =
|
||
json!(self.targets);
|
||
schema
|
||
}
|
||
|
||
fn read_only(&self) -> bool {
|
||
self.inner.read_only()
|
||
}
|
||
|
||
fn runtime_injected(&self) -> bool {
|
||
true
|
||
}
|
||
|
||
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
|
||
self.inner.execute(args).await
|
||
}
|
||
|
||
async fn execute_with_context(
|
||
&self,
|
||
context: &ToolExecutionContext,
|
||
args: Value,
|
||
) -> anyhow::Result<ToolOutput> {
|
||
self.inner.execute_with_context(context, args).await
|
||
}
|
||
}
|
||
|
||
impl DelegateTool {
|
||
pub fn new(sub_agent_manager: Arc<SubAgentManager>) -> Self {
|
||
Self {
|
||
sub_agent_manager,
|
||
coordinator: None,
|
||
}
|
||
}
|
||
|
||
pub fn with_coordinator(mut self, coordinator: Arc<AgentCoordinator>) -> Self {
|
||
self.coordinator = Some(coordinator);
|
||
self
|
||
}
|
||
|
||
fn task_schema(&self) -> Value {
|
||
let targets: Vec<_> = self
|
||
.sub_agent_manager
|
||
.catalog()
|
||
.root_targets()
|
||
.into_iter()
|
||
.map(|definition| definition.id.clone())
|
||
.collect();
|
||
let mut target = json!({
|
||
"type": "string",
|
||
"description": "目标具名 Agent ID(来自 agent_orchestration 的 agents 目录)"
|
||
});
|
||
if !targets.is_empty() {
|
||
target["enum"] = json!(targets);
|
||
}
|
||
json!({
|
||
"type": "object",
|
||
"properties": {
|
||
"target": target,
|
||
"task": { "type": "string", "description": "明确、独立、可验收的子任务" },
|
||
"context": { "type": "string", "description": "完成任务所需的显式上下文;不会继承主会话历史" },
|
||
"plan_item_id": { "type": "string", "description": "可选的当前计划子项 ID" }
|
||
},
|
||
"required": ["task"]
|
||
})
|
||
}
|
||
|
||
fn parse_config(&self, value: &Value, mode: ExecutionMode) -> anyhow::Result<SubAgentConfig> {
|
||
let prompt = value
|
||
.get("task")
|
||
.or_else(|| value.get("prompt"))
|
||
.and_then(Value::as_str)
|
||
.ok_or_else(|| anyhow::anyhow!("missing required parameter: task"))?
|
||
.trim()
|
||
.to_string();
|
||
if prompt.is_empty() {
|
||
anyhow::bail!("task must not be empty");
|
||
}
|
||
let allowed_tools = value
|
||
.get("allowed_tools")
|
||
.and_then(Value::as_array)
|
||
.map(|items| {
|
||
items
|
||
.iter()
|
||
.filter_map(Value::as_str)
|
||
.map(str::to_string)
|
||
.collect()
|
||
});
|
||
Ok(SubAgentConfig {
|
||
target: value
|
||
.get("target")
|
||
.and_then(Value::as_str)
|
||
.map(str::to_string),
|
||
prompt,
|
||
context: value
|
||
.get("context")
|
||
.and_then(Value::as_str)
|
||
.map(str::to_string),
|
||
mode,
|
||
allowed_tools,
|
||
max_iterations: value
|
||
.get("max_iterations")
|
||
.and_then(Value::as_u64)
|
||
.map(|v| v as usize),
|
||
timeout_secs: value.get("timeout_secs").and_then(Value::as_u64),
|
||
plan_item_id: value
|
||
.get("plan_item_id")
|
||
.and_then(Value::as_str)
|
||
.map(str::to_string),
|
||
session_id: None,
|
||
})
|
||
}
|
||
|
||
async fn handle_run(
|
||
&self,
|
||
args: &Value,
|
||
context: &ToolExecutionContext,
|
||
) -> anyhow::Result<ToolResult> {
|
||
let requested_mode = match args
|
||
.get("mode")
|
||
.and_then(Value::as_str)
|
||
.unwrap_or("foreground")
|
||
{
|
||
"foreground" => ExecutionMode::Foreground,
|
||
"background" => ExecutionMode::Background,
|
||
other => {
|
||
return Ok(failure(format!(
|
||
"unknown mode '{other}'; supported modes are foreground and background"
|
||
)));
|
||
}
|
||
};
|
||
let background_downgraded =
|
||
requested_mode == ExecutionMode::Background && context.execution_origin.is_scheduled();
|
||
let mode = if background_downgraded {
|
||
ExecutionMode::Foreground
|
||
} else {
|
||
requested_mode
|
||
};
|
||
let task_values: Vec<&Value> = match args.get("tasks").and_then(Value::as_array) {
|
||
Some(tasks) if !tasks.is_empty() => tasks.iter().collect(),
|
||
Some(_) => return Ok(failure("tasks must not be empty")),
|
||
None => vec![args],
|
||
};
|
||
let mut configs = Vec::with_capacity(task_values.len());
|
||
for task in task_values {
|
||
let mut config = self.parse_config(task, mode.clone())?;
|
||
if config.target.is_none() {
|
||
config.target = args
|
||
.get("target")
|
||
.and_then(Value::as_str)
|
||
.map(str::to_string);
|
||
}
|
||
if config.context.is_none() {
|
||
config.context = args
|
||
.get("context")
|
||
.and_then(Value::as_str)
|
||
.map(str::to_string);
|
||
}
|
||
if config.allowed_tools.is_none() {
|
||
config.allowed_tools =
|
||
args.get("allowed_tools")
|
||
.and_then(Value::as_array)
|
||
.map(|items| {
|
||
items
|
||
.iter()
|
||
.filter_map(Value::as_str)
|
||
.map(str::to_string)
|
||
.collect()
|
||
});
|
||
}
|
||
config.max_iterations = config.max_iterations.or_else(|| {
|
||
args.get("max_iterations")
|
||
.and_then(Value::as_u64)
|
||
.map(|v| v as usize)
|
||
});
|
||
config.timeout_secs = config
|
||
.timeout_secs
|
||
.or_else(|| args.get("timeout_secs").and_then(Value::as_u64));
|
||
config.session_id = context
|
||
.agent
|
||
.as_ref()
|
||
.map(|agent| agent.root_session_id.clone())
|
||
.or_else(|| context.session_id.clone());
|
||
configs.push(config);
|
||
}
|
||
|
||
match mode {
|
||
ExecutionMode::Foreground => {
|
||
let Some(coordinator) = self.coordinator.as_ref() else {
|
||
return Ok(failure(
|
||
"delegate requires agent_orchestration to be enabled (named Agents only)",
|
||
));
|
||
};
|
||
let results = coordinator
|
||
.delegate_foreground(context, configs)
|
||
.await
|
||
.map_err(|error| anyhow::anyhow!(error.to_string()))?;
|
||
let payload: Vec<_> = results
|
||
.into_iter()
|
||
.map(|result| {
|
||
let (status, error) = status_projection(&result.status);
|
||
json!({
|
||
"run_id": result.task_id,
|
||
"status": status,
|
||
"result": result.content,
|
||
"result_truncated": result.content_truncated,
|
||
"error": error,
|
||
"tool_calls": result.tool_calls_count,
|
||
"iterations": result.iterations,
|
||
"duration_ms": result.duration_ms
|
||
})
|
||
})
|
||
.collect();
|
||
let all_completed = payload.iter().all(|value| value["status"] == "completed");
|
||
Ok(ToolResult {
|
||
success: all_completed,
|
||
output: serde_json::to_string(&json!({
|
||
"status": if all_completed { "completed" } else { "partial" },
|
||
"background_downgraded": background_downgraded,
|
||
"results": payload
|
||
}))?,
|
||
error: None,
|
||
})
|
||
}
|
||
ExecutionMode::Background => {
|
||
if context.agent.is_some() {
|
||
return Ok(failure(
|
||
"child Agents cannot create background runs in the current implementation",
|
||
));
|
||
}
|
||
let Some(coordinator) = self.coordinator.as_ref() else {
|
||
return Ok(failure(
|
||
"delegate requires agent_orchestration to be enabled (named Agents only)",
|
||
));
|
||
};
|
||
match coordinator.delegate_background(context, configs).await {
|
||
Ok(admission) => {
|
||
let runs: Vec<_> = admission
|
||
.run_ids
|
||
.into_iter()
|
||
.map(|run_id| json!({ "run_id": run_id, "status": "queued" }))
|
||
.collect();
|
||
Ok(success(json!({
|
||
"status": "accepted",
|
||
"runs": runs
|
||
})))
|
||
}
|
||
Err(error) => Ok(failure(error.to_string())),
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[async_trait]
|
||
impl Tool for DelegateTool {
|
||
fn name(&self) -> &str {
|
||
"delegate"
|
||
}
|
||
|
||
fn description(&self) -> &str {
|
||
"Delegate one or more independent tasks to configured Agents. foreground waits for all results; background returns accepted run IDs. Multiple tasks execute concurrently."
|
||
}
|
||
|
||
fn parameters_schema(&self) -> Value {
|
||
json!({
|
||
"type": "object",
|
||
"properties": {
|
||
"target": self.task_schema()["properties"]["target"].clone(),
|
||
"task": { "type": "string", "description": "Single delegated task" },
|
||
"context": { "type": "string", "description": "Explicit context for the child Agent" },
|
||
"mode": {
|
||
"type": "string",
|
||
"enum": ["foreground", "background"],
|
||
"description": "foreground waits; background returns after acceptance"
|
||
},
|
||
"tasks": {
|
||
"type": "array",
|
||
"minItems": 1,
|
||
"items": self.task_schema(),
|
||
"description": "Independent tasks; execution is concurrent and results preserve request order"
|
||
},
|
||
"plan_item_id": { "type": "string" },
|
||
"allowed_tools": {
|
||
"type": "array",
|
||
"items": { "type": "string" },
|
||
"description": "Only narrows the target Definition's tool set; never expands permissions"
|
||
},
|
||
"max_iterations": { "type": "integer", "minimum": 1 },
|
||
"timeout_secs": { "type": "integer", "minimum": 1 }
|
||
},
|
||
"anyOf": [
|
||
{ "required": ["task"] },
|
||
{ "required": ["tasks"] }
|
||
]
|
||
})
|
||
}
|
||
|
||
fn read_only(&self) -> bool {
|
||
false
|
||
}
|
||
|
||
fn runtime_injected(&self) -> bool {
|
||
true
|
||
}
|
||
|
||
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
|
||
self.execute_with_context(&ToolExecutionContext::default(), args)
|
||
.await
|
||
.map(|output| output.result)
|
||
}
|
||
|
||
async fn execute_with_context(
|
||
&self,
|
||
context: &ToolExecutionContext,
|
||
args: Value,
|
||
) -> anyhow::Result<ToolOutput> {
|
||
self.handle_run(&args, context).await.map(Into::into)
|
||
}
|
||
}
|
||
|
||
fn status_projection(status: &TaskStatus) -> (&'static str, Option<&str>) {
|
||
match status {
|
||
TaskStatus::Completed => ("completed", None),
|
||
TaskStatus::Failed(error) => ("failed", Some(error.as_str())),
|
||
TaskStatus::Cancelled => ("cancelled", None),
|
||
TaskStatus::TimedOut => ("timed_out", None),
|
||
}
|
||
}
|
||
|
||
fn success(value: Value) -> ToolResult {
|
||
ToolResult {
|
||
success: true,
|
||
output: value.to_string(),
|
||
error: None,
|
||
}
|
||
}
|
||
|
||
fn failure(error: impl Into<String>) -> ToolResult {
|
||
ToolResult {
|
||
success: false,
|
||
output: String::new(),
|
||
error: Some(error.into()),
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use std::collections::HashMap;
|
||
|
||
fn manager() -> Arc<SubAgentManager> {
|
||
Arc::new(SubAgentManager::new(
|
||
crate::config::LLMProviderConfig {
|
||
provider_type: "openai".to_string(),
|
||
name: "test".to_string(),
|
||
base_url: "https://example.invalid/v1".to_string(),
|
||
api_key: "test".to_string(),
|
||
extra_headers: HashMap::new(),
|
||
model_id: "test-model".to_string(),
|
||
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".to_string()],
|
||
price_input_per_million: None,
|
||
price_output_per_million: None,
|
||
},
|
||
Arc::new(crate::tools::ToolRegistry::new()),
|
||
None,
|
||
None,
|
||
))
|
||
}
|
||
|
||
#[test]
|
||
fn schema_exposes_only_canonical_lifecycle_modes() {
|
||
let schema = DelegateTool::new(manager()).parameters_schema();
|
||
assert_eq!(
|
||
schema["properties"]["mode"]["enum"],
|
||
json!(["foreground", "background"])
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn scoped_schema_replaces_root_targets_for_child() {
|
||
let inner: Arc<dyn Tool> = Arc::new(DelegateTool::new(manager()));
|
||
let scoped = ScopedDelegateTool::new(inner, vec!["reviewer".to_string()]);
|
||
let schema = scoped.parameters_schema();
|
||
assert_eq!(schema["properties"]["target"]["enum"], json!(["reviewer"]));
|
||
assert_eq!(
|
||
schema["properties"]["tasks"]["items"]["properties"]["target"]["enum"],
|
||
json!(["reviewer"])
|
||
);
|
||
}
|
||
}
|