PicoBot/src/tools/delegate.rs
xiaoxixi d9ad58b84b feat(scheduler): unify scheduled task execution and delivery
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.
2026-08-21 14:59:02 +08:00

437 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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"])
);
}
}