feat: wire run quota and signal delivery persistence; remove redundant compat code

- RunQuota now enforced: background admission acquires the run permit
  (global→session) and the runner holds it until terminal commit;
  foreground never takes run permits so nested limit=1 cannot deadlock
- RuntimeAdmission activity guard held by background runners for the
  whole run; admission checked before any durable write
- signal_contract_json/signal_delivery persisted at run admission so
  definition-level steer delivery actually takes effect (was silently
  falling back to queue)
- terminal completion payload carries the run's emitted signal IDs
  (design §12.3) so the main Agent can recognise duplicates
- ROOT caller_scope_id is the literal "ROOT" (design §9.6)
- legacy general delegation returns a deprecation/migration hint

Cleanup:
- remove unused AgentCaller enum and run_parallel wrapper
- remove unreachable http_get_only prompt machinery (http_request is
  RootOnly and never enters sub-agent registries)
- drop never-read EmittedSignal fields; derive WakeupSource from
  TurnInputSource instead of duplicating match arms
- replace the /stop oneshot compatibility bridge with a plain turn_busy
  flag; the worker's forced-cancel path now selects on the cancellation
  token (implementation doc §11.1)

Version 1.8.0
This commit is contained in:
xiaoxixi 2026-08-11 15:24:49 +08:00
parent ac201a3949
commit 554d4b15ac
18 changed files with 1003 additions and 123 deletions

View File

@ -1,6 +1,6 @@
[package]
name = "picobot"
version = "1.7.0"
version = "1.8.0"
edition = "2024"
[dependencies]

File diff suppressed because one or more lines are too long

View File

@ -219,6 +219,8 @@ Cron 不是一个带 `action` 的统一工具,而是六个独立工具;仅
参数 `seconds` 接受 086400 的整数。工具只暂停当前 Agent 工具调用,不持久化、不发送消息,也不保证跨进程重启继续;用户 `/stop`、Scheduler/SubAgent 超时和 Gateway shutdown 都会取消等待。超过 24 小时或需要可靠延迟执行时应使用 Scheduler。
主 Agentroot interactive Turn的 sleep 是 wake-aware当前 session 收到任何新输入(用户 steer/queue、后台 Agent 的 steer 信号或排队结果都会提前结束等待。Steer 唤醒会告知来源 run/agent 与安全摘要,并在当前 Turn 的下一个安全边界注入queue 唤醒只说明类型与数量,内容不会进入当前 Turn。子 Agent run 与 continuation Turn 没有 session 输入通道,其 sleep 只响应 timer/cancel。
## http_request / web_fetch — HTTP 和 Web 工具
`http_request` 支持 GET/POST/PUT/DELETE/PATCH、headers 和字符串 body`web_fetch` 提取 HTML/JSON 的可读文本。两者校验 URL 与 DNS 解析结果阻止回环、私网、link-local 和本地域名,并禁用自动重定向,以降低 SSRF 风险。

View File

@ -32,6 +32,8 @@ pub struct AgentCoordinator {
work_manager: Option<Arc<crate::work::WorkManager>>,
notifier: Arc<AgentInboxNotifier>,
projection: Arc<AgentProjectionHub>,
execution_gate: Arc<crate::agent::gate::ExecutionGate>,
admission: crate::gateway::reload::RuntimeAdmission,
task_supervisor: crate::task_supervisor::TaskSupervisor,
runtime_generation: i64,
max_pending_inbox_events_per_session: i64,
@ -51,12 +53,14 @@ pub enum CoordinatorError {
impl AgentCoordinator {
#[allow(clippy::too_many_arguments)]
pub fn new(
pub(crate) fn new(
storage: Arc<Storage>,
manager: Arc<SubAgentManager>,
work_manager: Arc<crate::work::WorkManager>,
notifier: Arc<AgentInboxNotifier>,
projection: Arc<AgentProjectionHub>,
execution_gate: Arc<crate::agent::gate::ExecutionGate>,
admission: crate::gateway::reload::RuntimeAdmission,
task_supervisor: crate::task_supervisor::TaskSupervisor,
runtime_generation: u64,
orchestration: &crate::config::AgentOrchestrationConfig,
@ -67,6 +71,8 @@ impl AgentCoordinator {
work_manager: Some(work_manager),
notifier,
projection,
execution_gate,
admission,
task_supervisor,
runtime_generation: runtime_generation as i64,
max_pending_inbox_events_per_session: orchestration.max_pending_inbox_events_per_session
@ -110,6 +116,21 @@ impl AgentCoordinator {
})?;
let now = chrono::Utc::now().timestamp_millis();
// 0. Run quota + admission guard before any durable write; any
// failure here releases everything without touching SQLite. The
// permit stays with the runner until the terminal commit.
let run_permit = self
.execution_gate
.acquire_run(&root_session_id, &caller.cancellation)
.await
.map_err(|error| CoordinatorError::Rejected(error.to_string()))?;
let activity = self.admission.try_enter().ok_or_else(|| {
CoordinatorError::Rejected(
"gateway is draining for configuration reload and cannot accept background tasks"
.to_string(),
)
})?;
// 1. Reserve the completion slot; failure means the inbox is full and
// nothing is admitted.
if self
@ -135,7 +156,7 @@ impl AgentCoordinator {
root_turn_id: caller.turn_id.clone(),
parent_run_id: None,
caller_agent_id: "ROOT".to_string(),
caller_scope_id: caller.turn_id.clone().unwrap_or_else(|| "root".to_string()),
caller_scope_id: "ROOT".to_string(),
idempotency_key: None,
agent_id: resolved.agent_id.clone().unwrap_or_default(),
definition_hash: resolved.definition_hash.clone().unwrap_or_default(),
@ -153,6 +174,14 @@ impl AgentCoordinator {
"remaining_depth": self.manager.catalog().max_tree_depth(),
})
.to_string(),
signal_contract_json: resolved
.signal_contract
.as_ref()
.map(|contract| serde_json::to_string(contract).unwrap_or_default()),
signal_delivery: resolved
.signal_contract
.as_ref()
.map(|contract| contract.delivery.as_str().to_string()),
deadline_at: now + (resolved.timeout_secs * 1000) as i64,
runtime_generation: self.runtime_generation,
completion_slot_reserved: true,
@ -190,12 +219,16 @@ impl AgentCoordinator {
let run_id = run_id.clone();
async move {
coordinator
.run_background_runner(&run_id, &config, resolved, token)
.run_background_runner(
&run_id, &config, resolved, token, run_permit, activity,
)
.await;
}
});
if !spawned {
// Compensation: undo the durable admission before returning.
// The rejected closure was dropped by the supervisor, which
// released the run quota permit and activity guard.
self.active_tokens.remove(&run_id);
let _ = self
.storage
@ -214,6 +247,8 @@ impl AgentCoordinator {
config: &SubAgentConfig,
resolved: ResolvedAgentRun,
token: CancellationToken,
_run_permit: crate::agent::gate::RunPermit,
_activity: crate::gateway::reload::ActivityGuard,
) {
let now = chrono::Utc::now().timestamp_millis();
let execution_id = run_id.to_string();
@ -229,6 +264,19 @@ impl AgentCoordinator {
return;
}
let emitted_signals: Vec<String> = resolved
.tool_context
.agent
.as_ref()
.and_then(|agent| agent.emitted_signals.lock().ok())
.map(|signals| {
signals
.iter()
.map(|signal| signal.signal_id.clone())
.collect()
})
.unwrap_or_default();
let result = self
.manager
.execute_resolved(config, resolved, run_id)
@ -243,18 +291,22 @@ impl AgentCoordinator {
cost: None,
tool_calls: result.tool_calls_count as i64,
iterations: result.iterations as i64,
signal_ids: emitted_signals.clone(),
},
TaskStatus::Failed(error) => AgentTerminalOutcome::Failed {
error: error.clone(),
prompt_tokens: None,
completion_tokens: None,
cost: None,
signal_ids: emitted_signals.clone(),
},
TaskStatus::TimedOut => AgentTerminalOutcome::TimedOut {
deadline_at: chrono::Utc::now().timestamp_millis(),
signal_ids: emitted_signals.clone(),
},
TaskStatus::Cancelled => AgentTerminalOutcome::Cancelled {
reason: "cancelled by user, parent or shutdown".to_string(),
signal_ids: emitted_signals.clone(),
},
},
Err(error) => AgentTerminalOutcome::Failed {
@ -262,6 +314,7 @@ impl AgentCoordinator {
prompt_tokens: None,
completion_tokens: None,
cost: None,
signal_ids: emitted_signals,
},
};
@ -478,6 +531,14 @@ impl AgentCoordinator {
"remaining_depth": caller.agent.as_ref().map(|agent| agent.budget.remaining_depth),
}))
.unwrap_or_default(),
signal_contract_json: resolution
.signal_contract
.as_ref()
.map(|contract| serde_json::to_string(contract).unwrap_or_default()),
signal_delivery: resolution
.signal_contract
.as_ref()
.map(|contract| contract.delivery.as_str().to_string()),
deadline_at,
runtime_generation: self.runtime_generation,
completion_slot_reserved: false,
@ -596,6 +657,19 @@ impl AgentCoordinator {
});
}
let emitted_signals: Vec<String> = resolution
.tool_context
.agent
.as_ref()
.and_then(|agent| agent.emitted_signals.lock().ok())
.map(|signals| {
signals
.iter()
.map(|signal| signal.signal_id.clone())
.collect()
})
.unwrap_or_default();
let result = self
.manager
.execute_resolved(config, resolution, run_id)
@ -610,18 +684,22 @@ impl AgentCoordinator {
cost: None,
tool_calls: result.tool_calls_count as i64,
iterations: result.iterations as i64,
signal_ids: emitted_signals.clone(),
},
TaskStatus::Failed(error) => AgentTerminalOutcome::Failed {
error: error.clone(),
prompt_tokens: None,
completion_tokens: None,
cost: None,
signal_ids: emitted_signals.clone(),
},
TaskStatus::TimedOut => AgentTerminalOutcome::TimedOut {
deadline_at: chrono::Utc::now().timestamp_millis(),
signal_ids: emitted_signals.clone(),
},
TaskStatus::Cancelled => AgentTerminalOutcome::Cancelled {
reason: "cancelled by user, parent or shutdown".to_string(),
signal_ids: emitted_signals.clone(),
},
},
Err(error) => AgentTerminalOutcome::Failed {
@ -629,6 +707,7 @@ impl AgentCoordinator {
prompt_tokens: None,
completion_tokens: None,
cost: None,
signal_ids: emitted_signals,
},
};
@ -1038,6 +1117,12 @@ mod tests {
}
async fn coordinator() -> (Arc<AgentCoordinator>, tempfile::TempDir) {
coordinator_with_inbox_limit(1).await
}
async fn coordinator_with_inbox_limit(
max_pending: usize,
) -> (Arc<AgentCoordinator>, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let storage = Arc::new(Storage::new(&dir.path().join("coord.db")).await.unwrap());
let (notify_tx, _notify_rx) = tokio::sync::mpsc::unbounded_channel();
@ -1059,7 +1144,7 @@ mod tests {
let supervisor = crate::task_supervisor::TaskSupervisor::new();
let orchestration = crate::config::AgentOrchestrationConfig {
enabled: true,
max_pending_inbox_events_per_session: 1,
max_pending_inbox_events_per_session: max_pending,
..Default::default()
};
(
@ -1069,6 +1154,8 @@ mod tests {
work_manager,
notifier,
Arc::new(crate::agent::AgentProjectionHub::new()),
crate::agent::gate::ExecutionGate::unbounded(),
crate::gateway::reload::RuntimeAdmission::open(),
supervisor,
1,
&orchestration,
@ -1259,12 +1346,13 @@ mod tests {
assert_eq!(state, (1, 0));
}
async fn storage_accept_run(
async fn storage_accept_run_with_delivery(
storage: &Arc<Storage>,
run_id: &str,
session: &str,
now: i64,
slot_reserved: bool,
signal_delivery: Option<&str>,
) {
let _ = storage.ensure_agent_session_state(session, now).await;
if slot_reserved {
@ -1290,6 +1378,8 @@ mod tests {
task: "work".to_string(),
context_json: None,
budget_json: "{}".to_string(),
signal_contract_json: signal_delivery.map(|_| "{}".to_string()),
signal_delivery: signal_delivery.map(str::to_string),
deadline_at: now + 100_000,
runtime_generation: 1,
completion_slot_reserved: slot_reserved,
@ -1304,6 +1394,16 @@ mod tests {
.unwrap();
}
async fn storage_accept_run(
storage: &Arc<Storage>,
run_id: &str,
session: &str,
now: i64,
slot_reserved: bool,
) {
storage_accept_run_with_delivery(storage, run_id, session, now, slot_reserved, None).await
}
#[tokio::test]
async fn emit_signal_persists_wakes_and_respects_capacity_and_dedupe() {
let (coordinator, _dir) = coordinator().await;
@ -1373,6 +1473,98 @@ mod tests {
));
}
#[tokio::test]
async fn terminal_commit_carries_emitted_signal_ids_in_completion_payload() {
let (coordinator, _dir) = coordinator_with_inbox_limit(8).await;
let run_id = "run-sig-carrier";
let session = "cli:test:dialog";
let now = chrono::Utc::now().timestamp_millis();
storage_accept_run(&coordinator.storage, run_id, session, now, true).await;
// Emit one signal, then commit the run terminal.
let context = crate::agent::AgentExecutionContext {
root_session_id: session.to_string(),
root_turn_id: None,
run_id: run_id.to_string(),
execution_id: run_id.to_string(),
group_id: None,
parent_run_id: None,
caller_agent_id: "ROOT".to_string(),
current_agent_id: "researcher".to_string(),
ancestry: vec!["researcher".to_string()],
depth: 1,
plan_item_id: None,
cancellation: tokio_util::sync::CancellationToken::new(),
budget: crate::agent::AgentBudget {
remaining_runs: 15,
remaining_depth: 3,
},
tree_runs: Arc::new(std::sync::atomic::AtomicUsize::new(1)),
signal_contract: None,
emitted_signals: Arc::new(std::sync::Mutex::new(Vec::new())),
};
let signal = SignalInput {
key: "k".to_string(),
severity: "warning".to_string(),
summary: "s".to_string(),
details: None,
dedupe_key: None,
event_key: format!("signal:{}", uuid::Uuid::new_v4()),
};
let accepted = coordinator.emit_signal(&context, signal).await.unwrap();
context
.emitted_signals
.lock()
.unwrap()
.push(crate::agent::run::EmittedSignal {
signal_id: accepted.signal_id.clone(),
});
let signal_ids: Vec<String> = context
.emitted_signals
.lock()
.unwrap()
.iter()
.map(|signal| signal.signal_id.clone())
.collect();
coordinator
.storage
.commit_agent_terminal(
run_id,
run_id,
1,
&AgentTerminalOutcome::Completed {
result: "done".to_string(),
prompt_tokens: None,
completion_tokens: None,
cost: None,
tool_calls: 1,
iterations: 1,
signal_ids,
},
None,
now + 1,
)
.await
.unwrap();
let events = coordinator
.storage
.list_agent_inbox_events(session, 10)
.await
.unwrap();
assert_eq!(events.len(), 2);
let completion = events
.iter()
.find(|event| {
event.event_type == crate::storage::agent_inbox::AgentEventType::Completion
})
.unwrap();
let payload: serde_json::Value = serde_json::from_str(&completion.payload_json).unwrap();
assert_eq!(payload["status"], "completed");
assert_eq!(payload["signal_ids"][0], accepted.signal_id);
}
#[tokio::test]
async fn emit_signal_rejects_when_run_is_terminal_or_inbox_is_full() {
let (coordinator, _dir) = coordinator().await;
@ -1419,6 +1611,7 @@ mod tests {
1,
&AgentTerminalOutcome::Cancelled {
reason: "test".to_string(),
signal_ids: Vec::new(),
},
None,
now + 1,
@ -1462,6 +1655,8 @@ mod tests {
work_manager,
notifier,
Arc::new(crate::agent::AgentProjectionHub::new()),
crate::agent::gate::ExecutionGate::unbounded(),
crate::gateway::reload::RuntimeAdmission::open(),
supervisor,
1,
&orchestration,
@ -1494,4 +1689,74 @@ mod tests {
Err(CoordinatorError::Rejected(_))
));
}
#[tokio::test]
async fn emitted_signal_uses_the_runs_persisted_delivery_lane() {
let (coordinator, _dir) = coordinator_with_inbox_limit(8).await;
let run_id = "run-sig-steer";
let session = "cli:test:dialog";
let now = chrono::Utc::now().timestamp_millis();
storage_accept_run_with_delivery(
&coordinator.storage,
run_id,
session,
now,
false,
Some("steer"),
)
.await;
let run = coordinator
.storage
.get_agent_run(run_id)
.await
.unwrap()
.unwrap();
assert_eq!(run.signal_delivery.as_deref(), Some("steer"));
let context = crate::agent::AgentExecutionContext {
root_session_id: session.to_string(),
root_turn_id: None,
run_id: run_id.to_string(),
execution_id: run_id.to_string(),
group_id: None,
parent_run_id: None,
caller_agent_id: "ROOT".to_string(),
current_agent_id: "researcher".to_string(),
ancestry: vec!["researcher".to_string()],
depth: 1,
plan_item_id: None,
cancellation: tokio_util::sync::CancellationToken::new(),
budget: crate::agent::AgentBudget {
remaining_runs: 15,
remaining_depth: 3,
},
tree_runs: Arc::new(std::sync::atomic::AtomicUsize::new(1)),
signal_contract: None,
emitted_signals: Arc::new(std::sync::Mutex::new(Vec::new())),
};
let signal = SignalInput {
key: "k".to_string(),
severity: "info".to_string(),
summary: "s".to_string(),
details: None,
dedupe_key: None,
event_key: format!("signal:{}", uuid::Uuid::new_v4()),
};
let accepted = coordinator.emit_signal(&context, signal).await.unwrap();
assert!(matches!(
accepted.delivery,
crate::storage::agent_inbox::AgentEventDelivery::Steer
));
let event = coordinator
.storage
.get_agent_inbox_event(&accepted.signal_id)
.await
.unwrap()
.unwrap();
assert_eq!(
event.delivery,
crate::storage::agent_inbox::AgentEventDelivery::Steer
);
}
}

View File

@ -21,7 +21,7 @@ pub use definition::{AgentDefinition, AgentLimits};
pub use gate::ExecutionGate;
pub use inbox::{AgentInboxNotifier, AgentInboxWakeTarget};
pub use projection::AgentProjectionHub;
pub use run::{AgentBudget, AgentCaller, AgentExecutionContext};
pub use run::{AgentBudget, AgentExecutionContext};
pub use steering::{SteeringDrain, SteeringPushError, TurnInput, TurnInputSource, TurnMailbox};
pub use sub_agent::{
DelegateContext, ExecutionMode, SubAgentConfig, SubAgentError, SubAgentManager, SubAgentResult,

View File

@ -4,12 +4,6 @@ use std::sync::atomic::{AtomicUsize, Ordering};
use tokio_util::sync::CancellationToken;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AgentCaller {
Root,
Agent,
}
#[derive(Debug, Clone)]
pub struct AgentBudget {
pub remaining_runs: usize,
@ -21,8 +15,6 @@ pub struct AgentBudget {
#[derive(Debug, Clone)]
pub struct EmittedSignal {
pub signal_id: String,
pub severity: String,
pub summary: String,
}
#[derive(Debug, Clone)]

View File

@ -47,6 +47,104 @@ impl TurnInputSource {
}
}
impl From<&TurnInputSource> for WakeupSource {
fn from(source: &TurnInputSource) -> Self {
match source {
TurnInputSource::User => WakeupSource::UserSteer,
TurnInputSource::AgentSignal { run_id, agent_id } => WakeupSource::AgentSignal {
run_id: run_id.clone(),
agent_id: agent_id.clone(),
},
TurnInputSource::AgentCompletion { run_id, agent_id } => {
WakeupSource::AgentCompletion {
run_id: run_id.clone(),
agent_id: agent_id.clone(),
}
}
TurnInputSource::AgentGroupCompletion { group_id } => {
WakeupSource::AgentGroupCompletion {
group_id: group_id.clone(),
}
}
}
}
}
/// What woke a root-interactive sleep. Queue wakes carry no content: the
/// model only learns a type/count, never the payload.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WakeupSource {
UserSteer,
UserQueue,
AgentSignal { run_id: String, agent_id: String },
AgentCompletion { run_id: String, agent_id: String },
AgentGroupCompletion { group_id: String },
AgentQueue,
}
/// Snapshot published to sleeping root Turns whenever a new input is
/// durably admitted anywhere on the session's receive surface.
#[derive(Debug, Clone, Default)]
pub struct TurnWakeupState {
pub revision: u64,
pub pending_user_steer: usize,
pub pending_user_queue: usize,
pub pending_agent_steer: usize,
pub pending_agent_queue: usize,
pub latest_source: Option<WakeupSource>,
/// Safe, model-visible preview for steer wakes only. Queue wakes never
/// carry content.
pub latest_safe_preview: Option<String>,
}
impl TurnWakeupState {
pub fn pending_total(&self) -> usize {
self.pending_user_steer
.saturating_add(self.pending_user_queue)
.saturating_add(self.pending_agent_steer)
.saturating_add(self.pending_agent_queue)
}
}
/// Root-Turn-side receiver used by wake-aware tools (sleep).
#[derive(Debug, Clone)]
pub struct TurnWakeupHandle {
pub receiver: tokio::sync::watch::Receiver<TurnWakeupState>,
}
/// Session-side publisher for the active Turn. Admission points bump the
/// revision and `send_replace` AFTER the durable fact is visible, so a
/// waking sleep can always observe the input it was told about.
#[derive(Debug, Clone)]
pub struct TurnWakeupPublisher {
sender: tokio::sync::watch::Sender<TurnWakeupState>,
}
impl TurnWakeupPublisher {
pub fn new() -> Self {
let (sender, _) = tokio::sync::watch::channel(TurnWakeupState::default());
Self { sender }
}
pub fn subscribe(&self) -> TurnWakeupHandle {
TurnWakeupHandle {
receiver: self.sender.subscribe(),
}
}
pub fn publish(&self, state: TurnWakeupState) {
let mut state = state;
state.revision = state.revision.saturating_add(1);
let _ = self.sender.send_replace(state);
}
}
impl Default for TurnWakeupPublisher {
fn default() -> Self {
Self::new()
}
}
/// How the input reached the mailbox. Queue inputs belong to the next Turn;
/// only Steer entries are drained by the active Turn.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -566,6 +664,34 @@ impl TurnMailbox {
pub fn max_agent_messages(&self) -> usize {
self.max_agent_messages
}
/// Currently pending user steer entries (wake-state hint).
pub fn user_pending_count(&self) -> usize {
let state = self
.state
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
state
.pending
.iter()
.filter(|input| !input.source.is_agent())
.count()
}
/// Currently pending agent steer entries, including reservations
/// (wake-state hint).
pub fn agent_pending_count(&self) -> usize {
let state = self
.state
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
state
.pending
.iter()
.chain(state.reserved.iter())
.filter(|input| input.source.is_agent())
.count()
}
}
impl Default for TurnMailbox {

View File

@ -154,6 +154,9 @@ pub(crate) struct ResolvedAgentRun {
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 {
@ -293,6 +296,7 @@ impl SubAgentManager {
agent_id: None,
definition_hash: None,
llm_profile: None,
signal_contract: None,
});
};
@ -469,6 +473,7 @@ impl SubAgentManager {
agent_id: Some(target.to_string()),
definition_hash: Some(definition.definition_hash.clone()),
llm_profile: Some(definition.llm_profile.clone()),
signal_contract: definition.signal_contract.clone(),
tool_context: ToolExecutionContext::for_session(format!("agent-run:{task_id}"))
.with_turn_id(
caller
@ -564,7 +569,6 @@ impl SubAgentManager {
&resolved.provider_config.workspace_dir,
&resolved.provider_config.model_id,
resolved.skills_prompt,
false,
);
if let Some(role_prompt) = resolved.role_prompt {
system_prompt.push_str("\n\n## Agent Definition\n\n");
@ -665,14 +669,6 @@ impl SubAgentManager {
})
}
pub async fn run_parallel(
&self,
configs: Vec<SubAgentConfig>,
) -> Result<Vec<SubAgentResult>, SubAgentError> {
self.run_foreground_batch(configs, &ToolExecutionContext::default())
.await
}
pub async fn run_foreground_batch(
&self,
configs: Vec<SubAgentConfig>,
@ -816,11 +812,6 @@ impl SubAgentManager {
let tools = self.filter_tools(&config.allowed_tools);
let timeout_secs = config.timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS);
let timeout_human = format_duration(timeout_secs);
let http_get_only = config.allowed_tools.is_none()
|| config
.allowed_tools
.as_ref()
.is_some_and(|v| v.iter().any(|t| t == "http_request"));
let skills_prompt = self.get_skills_prompt(&tools);
let system_prompt = build_sub_agent_system_prompt(
&config.prompt,
@ -829,7 +820,6 @@ impl SubAgentManager {
&self.provider_config.workspace_dir,
&self.provider_config.model_id,
skills_prompt,
http_get_only,
);
let provider_config = self.provider_config.clone();
let storage = self.storage.clone();

View File

@ -57,7 +57,6 @@ impl SystemPromptBuilder {
task: &str,
timeout: &str,
skills_prompt: Option<String>,
http_get_only: bool,
) -> Self {
let mut sections: Vec<Box<dyn PromptSection>> = vec![
Box::new(SubAgentIdentitySection {
@ -66,7 +65,7 @@ impl SystemPromptBuilder {
}),
Box::new(ToolHonestySection),
Box::new(SafetySection),
Box::new(SubAgentToolsSection { http_get_only }),
Box::new(SubAgentToolsSection),
Box::new(WorkspaceSection),
];
if let Some(sp) = skills_prompt {
@ -390,9 +389,7 @@ impl PromptSection for SubAgentIdentitySection {
}
/// Sub-agent available tools description.
pub struct SubAgentToolsSection {
pub http_get_only: bool,
}
pub struct SubAgentToolsSection;
impl PromptSection for SubAgentToolsSection {
fn name(&self) -> &str {
@ -402,11 +399,6 @@ impl PromptSection for SubAgentToolsSection {
fn build(&self, ctx: &PromptContext<'_>) -> String {
let mut s = String::from("## 可用工具\n\n");
s.push_str(&ctx.tools.describe_for_prompt());
if self.http_get_only {
s.push_str(
"\n\n**注意**:使用 http_request 时只允许 GET 方法,禁止 POST、PUT、DELETE 等。",
);
}
s
}
}
@ -514,15 +506,13 @@ pub fn build_sub_agent_system_prompt(
workspace_dir: &Path,
model_name: &str,
skills_prompt: Option<String>,
http_get_only: bool,
) -> String {
let ctx = PromptContext {
workspace_dir,
model_name,
tools,
};
SystemPromptBuilder::with_sub_agent_defaults(task, timeout_human, skills_prompt, http_get_only)
.build(&ctx)
SystemPromptBuilder::with_sub_agent_defaults(task, timeout_human, skills_prompt).build(&ctx)
}
#[cfg(test)]

View File

@ -1,7 +1,7 @@
use std::collections::{HashMap, VecDeque};
use std::sync::{Arc, Mutex as StdMutex};
use tokio::sync::{Mutex, mpsc, oneshot, watch};
use tokio::sync::{Mutex, mpsc, watch};
use tokio_util::sync::CancellationToken;
use super::persistence::{
@ -485,7 +485,7 @@ mod cancelled_partial_tests {
})
.unwrap();
controller.complete(None);
let (sender, completion) = oneshot::channel();
let (sender, completion) = tokio::sync::oneshot::channel();
sender.send(Err(DeliveryError::FinalTimedOut)).unwrap();
let target = crate::channels::TurnTarget {
@ -624,12 +624,14 @@ pub struct Session {
/// It is allocated while holding the Session mutex so worker cleanup can
/// use that same lock as the send barrier.
next_task_sequence: u64,
/// Cancel signal for the currently executing agent task
current_cancel: Option<oneshot::Sender<()>>,
/// Whether the session has an active Turn or queued local fallback
/// tasks. `/stop` clears it; the busy signal is observability-only
/// (idle wait, active-turn count). Cancellation itself is driven solely
/// by `current_turn_token`.
turn_busy: bool,
/// Structured cancellation for the active Turn. `/stop` cancels it and
/// the token propagates through AgentLoop provider streams and tool
/// batches. The oneshot above remains the busy/stop compatibility
/// marker until it is removed together with the legacy adapter.
/// batches.
current_turn_token: Option<CancellationToken>,
/// Latest durable inbox revision for this session. The worker watches
/// this to claim agent events; the value only merges wakes, the payload
@ -661,6 +663,8 @@ struct ActiveTurnEmitter {
/// `AgentTurnContext`; keeping it on the session handle makes admission
/// atomic with `/stop` and worker cleanup.
steering: Arc<TurnMailbox>,
/// Watch publisher for wake-aware tools (sleep) of this root Turn.
wakeup: crate::agent::steering::TurnWakeupPublisher,
/// Original inbound tasks for accepted steering messages. ChatMessage
/// intentionally carries only durable history fields, so this side map
/// preserves channel context and rich MediaItem metadata if a terminal
@ -688,7 +692,11 @@ struct AgentTask {
fn steer_input_from_event(
event: &crate::storage::agent_inbox::AgentInboxEventRecord,
now: i64,
) -> TurnInput {
) -> (
TurnInput,
crate::agent::steering::WakeupSource,
Option<String>,
) {
use crate::agent::steering::InputDelivery;
use crate::storage::agent_inbox::AgentEventType;
let payload: serde_json::Value =
@ -699,13 +707,14 @@ fn steer_input_from_event(
.and_then(serde_json::Value::as_str)
.unwrap_or("unknown")
.to_string();
let (source, content) = match event.event_type {
let (source, wakeup_preview, content) = match event.event_type {
AgentEventType::Signal => {
let severity = event.severity.clone().unwrap_or_else(|| "info".to_string());
let summary = payload
.get("summary")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
.unwrap_or_default()
.to_string();
let mut content = format!(
"[后台 Agent 信号] severity={severity}, agent={agent_id}, run={run_id}\n{summary}"
);
@ -715,7 +724,14 @@ fn steer_input_from_event(
content.push_str("\n详情: ");
content.push_str(&details.to_string());
}
(TurnInputSource::AgentSignal { run_id, agent_id }, content)
(
TurnInputSource::AgentSignal {
run_id: run_id.clone(),
agent_id: agent_id.clone(),
},
(!summary.is_empty()).then_some(summary),
content,
)
}
AgentEventType::Completion => {
let status = payload
@ -728,17 +744,27 @@ fn steer_input_from_event(
content.push_str(&format!("\n错误: {error}"));
}
(
TurnInputSource::AgentCompletion { run_id, agent_id },
TurnInputSource::AgentCompletion {
run_id: run_id.clone(),
agent_id: agent_id.clone(),
},
None,
content,
)
}
AgentEventType::GroupCompletion => {
let group_id = event.group_id.clone().unwrap_or_default();
let content = format!("[后台 Agent 任务组结果] group={group_id}");
(TurnInputSource::AgentGroupCompletion { group_id }, content)
(
TurnInputSource::AgentGroupCompletion {
group_id: group_id.clone(),
},
None,
content,
)
}
};
TurnInput {
let input = TurnInput {
id: format!("steer:{}", event.id),
sequence: 0,
source,
@ -749,7 +775,9 @@ fn steer_input_from_event(
received_at: now,
message_source: None,
lease_token: Some(event.lease_token.clone().unwrap_or_default()),
}
};
let wakeup_source = crate::agent::steering::WakeupSource::from(&input.source);
(input, wakeup_source, wakeup_preview)
}
/// Move terminally pending steering into the worker's local FIFO. The
@ -918,7 +946,7 @@ impl Session {
memory_manager,
agent_tx: None,
next_task_sequence: 1,
current_cancel: None,
turn_busy: false,
current_turn_token: None,
agent_inbox_wake: watch::channel(0).0,
active_turn_emitter: None,
@ -1115,7 +1143,7 @@ impl Session {
memory_manager,
agent_tx: None,
next_task_sequence: 1,
current_cancel: None,
turn_busy: false,
current_turn_token: None,
agent_inbox_wake: watch::channel(0).0,
active_turn_emitter: None,
@ -1280,6 +1308,7 @@ impl Session {
turn_id: turn_id.to_string(),
emitter,
steering: TurnMailbox::new_shared(),
wakeup: crate::agent::steering::TurnWakeupPublisher::new(),
recovery: StdArc::new(StdMutex::new(HashMap::new())),
});
}
@ -1866,6 +1895,17 @@ fn resolve_slash_command(command: &str) -> Option<&'static SlashCommand> {
})
}
/// Result of one steer admission pass over the active Turn.
enum SteerAdmission {
/// Events were activated into the active Turn's mailbox.
Activated,
/// Events were claimed but could not be admitted and returned to
/// the queue lane (or the active Turn disappeared).
ReleasedToQueue,
/// No steer events were due.
NothingClaimed,
}
impl SessionManager {
fn worker_deps(&self) -> AgentWorkerDeps {
AgentWorkerDeps {
@ -1947,7 +1987,7 @@ impl SessionManager {
Some(skills_loader.clone()),
task_supervisor.clone(),
)
.with_admission(admission)
.with_admission(admission.clone())
.with_catalog(agent_catalog.clone())
.with_execution_gate(execution_gate.clone())
.with_work_manager(work_manager.clone()),
@ -1962,6 +2002,8 @@ impl SessionManager {
work_manager.clone(),
inbox_notifier.clone(),
agent_projection_hub.clone(),
execution_gate.clone(),
admission.clone(),
task_supervisor.clone(),
catalog_preparation.runtime_generation,
&catalog_preparation.config,
@ -2368,7 +2410,8 @@ impl SessionManager {
let msgs = {
let mut guard = session.lock().await;
let mut msgs: Vec<String> = Vec::new();
if guard.current_cancel.take().is_some() {
if guard.turn_busy {
guard.turn_busy = false;
msgs.push("当前任务已发送停止信号。".to_string());
}
if let Some(token) = guard.current_turn_token.take() {
@ -2501,7 +2544,7 @@ impl SessionManager {
.agent_tx
.as_ref()
.is_some_and(|sender| sender.capacity() < sender.max_capacity());
if session.current_cancel.is_some() || queued {
if session.turn_busy || queued {
busy = true;
break;
}
@ -2535,7 +2578,7 @@ impl SessionManager {
let mut count = 0;
for session in sessions {
let session = session.lock().await;
if session.current_cancel.is_some() {
if session.turn_busy {
count += 1;
}
}
@ -3306,7 +3349,24 @@ impl SessionManager {
message.timestamp,
);
match active.steering.try_push_user(input) {
Ok(()) => return Ok(HandleResult::AgentProcessing),
Ok(()) => {
// Wake-aware sleep: the input is durably visible
// in the mailbox before the publish.
if let Some(active) = guard.active_turn_emitter.as_ref() {
active
.wakeup
.publish(crate::agent::steering::TurnWakeupState {
pending_user_steer: active.steering.user_pending_count(),
pending_agent_steer: active.steering.agent_pending_count(),
latest_source: Some(
crate::agent::steering::WakeupSource::UserSteer,
),
latest_safe_preview: None,
..Default::default()
});
}
return Ok(HandleResult::AgentProcessing);
}
Err(_) => {
active
.recovery
@ -3350,7 +3410,7 @@ impl SessionManager {
guard.agent_tx.is_none() || guard.agent_tx.as_ref().is_some_and(|tx| tx.is_closed());
if needs_spawn {
guard.agent_tx = None;
guard.current_cancel = None;
guard.turn_busy = false;
guard.current_turn_token = None;
guard.worker_generation = guard.worker_generation.wrapping_add(1);
let generation = guard.worker_generation;
@ -3381,7 +3441,7 @@ impl SessionManager {
// worker under the same lock and retry the recovered task once.
let task = error.into_inner();
guard.agent_tx = None;
guard.current_cancel = None;
guard.turn_busy = false;
guard.current_turn_token = None;
guard.worker_generation = guard.worker_generation.wrapping_add(1);
let generation = guard.worker_generation;
@ -3405,6 +3465,24 @@ impl SessionManager {
AgentError::Other("agent worker spawn+send failed irrecoverably".to_string())
})?;
}
// Wake-aware sleep: a queued user input must wake a sleeping root
// Turn (the content stays in the queue for the next Turn).
if let Some(active) = guard.active_turn_emitter.as_ref() {
let queued = guard
.agent_tx
.as_ref()
.map(|tx| tx.max_capacity() - tx.capacity())
.unwrap_or(1)
.max(1);
active
.wakeup
.publish(crate::agent::steering::TurnWakeupState {
pending_user_queue: queued,
latest_source: Some(crate::agent::steering::WakeupSource::UserQueue),
latest_safe_preview: None,
..Default::default()
});
}
Ok(HandleResult::AgentProcessing)
}
}
@ -3692,7 +3770,6 @@ fn spawn_agent_worker(
mut compressor,
system_prompt_out,
base_version,
cancel_rx,
turn_token,
turn_controller,
turn_emitter,
@ -3728,13 +3805,12 @@ fn spawn_agent_worker(
}
};
let (cancel_tx, cancel_rx) = oneshot::channel();
let turn_token = CancellationToken::new();
if guard.worker_generation != worker_gen {
return; // /stop replaced us
}
guard.current_cancel = Some(cancel_tx);
guard.turn_busy = true;
guard.current_turn_token = Some(turn_token.clone());
// Install the active-turn handle before memory recall and
@ -3750,10 +3826,12 @@ fn spawn_agent_worker(
let initial_turn = turn_controller.snapshot();
let steering = TurnMailbox::new_shared();
let recovery = StdArc::new(StdMutex::new(HashMap::new()));
let turn_wakeup = crate::agent::steering::TurnWakeupPublisher::new();
guard.active_turn_emitter = Some(ActiveTurnEmitter {
turn_id: initial_turn.id.0.clone(),
emitter: turn_emitter.clone(),
steering: steering.clone(),
wakeup: turn_wakeup,
recovery: recovery.clone(),
});
@ -3763,8 +3841,7 @@ fn spawn_agent_worker(
guard.fresh_context_compressor(),
guard.build_system_prompt(&skills_prompt),
guard.state_version,
cancel_rx,
turn_token,
turn_token.clone(),
turn_controller,
turn_emitter,
turn_receiver,
@ -3826,7 +3903,7 @@ fn spawn_agent_worker(
session_id = %guard.id,
"Session changed while preparing agent history; dropping stale task"
);
guard.current_cancel = None;
guard.turn_busy = false;
guard.current_turn_token = None;
let mut released = Vec::new();
if guard
@ -3922,7 +3999,7 @@ fn spawn_agent_worker(
turn_controller.cancel(Some(
"session changed before model execution".to_string(),
));
guard.current_cancel = None;
guard.turn_busy = false;
guard.current_turn_token = None;
drop(guard);
if let Some(storage) = storage {
@ -3953,13 +4030,24 @@ fn spawn_agent_worker(
let pending_turn_deliveries = Arc::new(std::sync::Mutex::new(Vec::new()));
let scoped_turn_deliveries = pending_turn_deliveries.clone();
let steering_for_process = steering.clone();
let wakeup_handle = {
let guard = session.lock().await;
guard
.active_turn_emitter
.as_ref()
.map(|active| active.wakeup.subscribe())
};
let process_gate = execution_gate.clone();
let turn_token_for_process = turn_token.clone();
let process_future = async move {
let response_session_id = unified_str2.clone();
let tool_context = ToolExecutionContext::for_session(&response_session_id)
let mut tool_context = ToolExecutionContext::for_session(&response_session_id)
.with_turn_id(agent_turn.turn_id.clone())
.with_cancellation(turn_token.clone())
.with_cancellation(turn_token_for_process)
.with_execution_gate(process_gate.clone());
if let Some(handle) = wakeup_handle {
tool_context = tool_context.with_turn_wakeup(handle);
}
let process_result = crate::agent::sub_agent::DELEGATE_CONTEXT.scope(
crate::agent::DelegateContext {
session_id: unified_str2,
@ -4341,8 +4429,9 @@ fn spawn_agent_worker(
tokio::select! {
() = process_future => {}
_ = cancel_rx => {
// cancelled — current_cancel already taken by /stop
_ = turn_token.cancelled() => {
// Cancelled by `/stop`, which took and cancelled the
// token; the terminal state is persisted here.
persist_cancelled_turn(
&turn_controller,
&session,
@ -4403,16 +4492,11 @@ fn spawn_agent_worker(
if guard.worker_generation == worker_gen {
guard.current_turn_token = None;
consecutive_user_turns = consecutive_user_turns.saturating_add(1);
if local_tasks.is_empty() {
guard.current_cancel = None;
} else {
// Keep the session observable as busy while local
// fallback tasks are waiting for their next Turn.
// `/stop` can still invalidate this generation before
// the worker starts the next task.
let (cancel_tx, _cancel_rx) = oneshot::channel();
guard.current_cancel = Some(cancel_tx);
}
guard.turn_busy = !local_tasks.is_empty();
}
}
}).await;
@ -4692,8 +4776,25 @@ impl crate::agent::AgentInboxWakeTarget for SessionManager {
// admission; everything that cannot be admitted stays pending
// for the queue lane.
if has_active_turn {
self.try_steer_inbox_events(&session, session_id, revision)
let outcome = self
.try_steer_inbox_events(&session, session_id, revision)
.await;
// Events remain pending for the queue lane: wake-aware
// sleep must end even though nothing entered the Turn.
if !matches!(outcome, SteerAdmission::Activated) {
let guard = session.lock().await;
if let Some(active) = guard.active_turn_emitter.as_ref() {
active
.wakeup
.publish(crate::agent::steering::TurnWakeupState {
pending_agent_queue: 1,
latest_source: Some(
crate::agent::steering::WakeupSource::AgentQueue,
),
..Default::default()
});
}
}
}
}
let mut guard = session.lock().await;
@ -4732,9 +4833,11 @@ impl SessionManager {
session: &Arc<Mutex<Session>>,
session_id: &str,
revision: i64,
) {
) -> SteerAdmission {
let storage = { session.lock().await.storage.clone() };
let Some(storage) = storage else { return };
let Some(storage) = storage else {
return SteerAdmission::NothingClaimed;
};
let now = chrono::Utc::now().timestamp_millis();
let Ok(Some(lease)) = crate::storage::Storage::claim_inbox_batch(
&storage,
@ -4747,7 +4850,7 @@ impl SessionManager {
)
.await
else {
return;
return SteerAdmission::NothingClaimed;
};
let token = lease.token.clone();
@ -4765,16 +4868,20 @@ impl SessionManager {
.collect();
release_steer_leases(&storage, leases).await;
let _ = session.lock().await.agent_inbox_wake.send_replace(revision);
return;
return SteerAdmission::ReleasedToQueue;
};
(active.turn_id.clone(), active.steering.clone())
};
let mut rejected = Vec::new();
let mut reserved = Vec::new();
let mut wakeup_sources = Vec::new();
for event in &lease.events {
let input = steer_input_from_event(event, now);
let (input, wakeup_source, preview) = steer_input_from_event(event, now);
match mailbox.try_reserve_steer(input, token.clone()) {
Ok(()) => reserved.push(event.id.clone()),
Ok(()) => {
reserved.push(event.id.clone());
wakeup_sources.push((wakeup_source, preview));
}
Err(_) => rejected.push((event.id.clone(), token.clone())),
}
}
@ -4806,13 +4913,33 @@ impl SessionManager {
};
if still_active {
mailbox.activate_reserved();
} else {
// Wake-aware sleep: publish AFTER the durable admit, so a
// waking tool can observe the input it was told about.
let guard = session.lock().await;
if let Some(active) = guard.active_turn_emitter.as_ref() {
let (latest_source, latest_safe_preview) = wakeup_sources
.into_iter()
.next()
.unwrap_or((crate::agent::steering::WakeupSource::AgentQueue, None));
active
.wakeup
.publish(crate::agent::steering::TurnWakeupState {
pending_user_steer: active.steering.user_pending_count(),
pending_agent_steer: active.steering.agent_pending_count(),
latest_source: Some(latest_source),
latest_safe_preview,
..Default::default()
});
}
return SteerAdmission::Activated;
}
rejected.extend(admitted);
release_steer_leases(&storage, rejected).await;
let _ = session.lock().await.agent_inbox_wake.send_replace(revision);
}
SteerAdmission::ReleasedToQueue
} else {
release_steer_leases(&storage, rejected).await;
SteerAdmission::ReleasedToQueue
}
}
}
@ -4975,7 +5102,7 @@ fn format_task_notification(
#[cfg(test)]
mod slash_command_tests {
use super::{
AgentTask, SLASH_COMMANDS, pop_lowest_sequence, prepend_pending_steering,
AgentTask, SLASH_COMMANDS, Session, pop_lowest_sequence, prepend_pending_steering,
resolve_slash_command,
};
use crate::agent::steering::{SteeringPushError, TurnInput, TurnMailbox};
@ -5161,4 +5288,92 @@ mod slash_command_tests {
assert!(mailbox.is_closed());
assert!(mailbox.take_pending().is_empty());
}
#[tokio::test]
async fn active_turn_wakeup_publisher_reaches_sleep_handles() {
use crate::agent::steering::{TurnWakeupState, WakeupSource};
use crate::config::LLMProviderConfig;
use crate::memory::MemoryManager;
use crate::session::UnifiedSessionId;
use crate::tools::ToolRegistry;
use std::collections::HashMap;
use std::path::PathBuf;
let dir = tempfile::tempdir().unwrap();
let storage = Arc::new(
crate::storage::Storage::new(&dir.path().join("wakeup.db"))
.await
.unwrap(),
);
let memory_manager = Arc::new(MemoryManager::new(
storage,
"test".to_string(),
"test".to_string(),
));
let config = LLMProviderConfig {
provider_type: "openai".to_string(),
name: "test".to_string(),
base_url: "http://127.0.0.1".to_string(),
api_key: "test".to_string(),
extra_headers: HashMap::new(),
model_id: "test".to_string(),
temperature: None,
max_tokens: None,
model_extra: HashMap::new(),
max_tool_iterations: 1,
token_limit: 8_192,
workspace_dir: PathBuf::from("."),
input_types: vec!["text".to_string()],
price_input_per_million: None,
price_output_per_million: None,
};
let session = Arc::new(tokio::sync::Mutex::new(
Session::new(
UnifiedSessionId::new("cli_chat", "chat", "dialog"),
config,
Arc::new(ToolRegistry::new()),
None,
String::new(),
"test".to_string(),
memory_manager,
)
.await
.unwrap(),
));
session.lock().await.set_active_turn_for_test("turn-1");
// The emitter's publisher is the same one a sleep handle subscribes
// to via `with_turn_wakeup`.
let handle = {
let guard = session.lock().await;
guard
.active_turn_emitter
.as_ref()
.expect("active turn installed")
.wakeup
.subscribe()
};
let mut rx = handle.receiver.clone();
assert_eq!(rx.borrow_and_update().pending_total(), 0);
// User steer publish (what handle_message performs).
{
let guard = session.lock().await;
guard
.active_turn_emitter
.as_ref()
.unwrap()
.wakeup
.publish(TurnWakeupState {
pending_user_steer: 1,
latest_source: Some(WakeupSource::UserSteer),
..Default::default()
});
}
assert!(rx.changed().await.is_ok());
let state = rx.borrow_and_update();
assert_eq!(state.pending_total(), 1);
assert_eq!(state.latest_source, Some(WakeupSource::UserSteer));
assert!(state.revision > 0);
}
}

View File

@ -1150,6 +1150,7 @@ pub(crate) async fn insert_completion_event_tx(
session_id: &str,
status: &str,
error: Option<&str>,
signal_ids: &[String],
now: i64,
) -> Result<(), StorageError> {
let Some(revision) = convert_reservation_tx(tx, session_id, now).await? else {
@ -1176,6 +1177,7 @@ pub(crate) async fn insert_completion_event_tx(
payload_json: serde_json::json!({
"status": status,
"error": error,
"signal_ids": signal_ids,
})
.to_string(),
};
@ -1270,6 +1272,8 @@ mod tests {
task: "work".to_string(),
context_json: None,
budget_json: "{}".to_string(),
signal_contract_json: None,
signal_delivery: None,
deadline_at: 1000,
runtime_generation: 1,
completion_slot_reserved: false,
@ -1685,6 +1689,8 @@ mod tests {
task: "work".to_string(),
context_json: None,
budget_json: "{}".to_string(),
signal_contract_json: None,
signal_delivery: None,
deadline_at: 1000,
runtime_generation: 1,
completion_slot_reserved: false,

View File

@ -395,6 +395,8 @@ pub struct NewAgentRun {
pub task: String,
pub context_json: Option<String>,
pub budget_json: String,
pub signal_contract_json: Option<String>,
pub signal_delivery: Option<String>,
pub deadline_at: i64,
pub runtime_generation: i64,
/// Background runs reserve a completion slot at admission so their
@ -448,21 +450,28 @@ pub enum AgentTerminalOutcome {
cost: Option<f64>,
tool_calls: i64,
iterations: i64,
/// Signal IDs emitted by this run; included in the completion
/// payload so the main Agent can recognise duplicates (design §12.3).
signal_ids: Vec<String>,
},
Failed {
error: String,
prompt_tokens: Option<i64>,
completion_tokens: Option<i64>,
cost: Option<f64>,
signal_ids: Vec<String>,
},
TimedOut {
deadline_at: i64,
signal_ids: Vec<String>,
},
Cancelled {
reason: String,
signal_ids: Vec<String>,
},
Interrupted {
reason: String,
signal_ids: Vec<String>,
},
}
@ -628,10 +637,11 @@ impl super::Storage {
parent_run_id, caller_agent_id, caller_scope_id, idempotency_key, \
agent_id, definition_hash, provider_profile, provider_name, model_id, \
mode, depth, plan_item_id, execution_id, task, context_json, budget_json, \
signal_contract_json, signal_delivery, \
status, runtime_generation, attempt, completion_slot_reserved, deadline_at, \
revision, created_at, updated_at) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, \
'queued', ?, 1, ?, ?, 0, ?, ?)",
?, ?, 'queued', ?, 1, ?, ?, 0, ?, ?)",
)
.bind(&run.id)
.bind(request.group.as_ref().map(|group| group.id.clone()))
@ -653,6 +663,8 @@ impl super::Storage {
.bind(&run.task)
.bind(&run.context_json)
.bind(&run.budget_json)
.bind(&run.signal_contract_json)
.bind(&run.signal_delivery)
.bind(run.runtime_generation)
.bind(i64::from(run.completion_slot_reserved))
.bind(run.deadline_at)
@ -1052,6 +1064,7 @@ impl super::Storage {
cost,
tool_calls,
iterations,
..
} => (
Some(result.as_str()),
None,
@ -1066,6 +1079,7 @@ impl super::Storage {
prompt_tokens,
completion_tokens,
cost,
..
} => (
None,
Some(error.as_str()),
@ -1078,10 +1092,10 @@ impl super::Storage {
AgentTerminalOutcome::TimedOut { .. } => {
(None, Some("deadline exceeded"), None, None, None, 0, 0)
}
AgentTerminalOutcome::Cancelled { reason } => {
AgentTerminalOutcome::Cancelled { reason, .. } => {
(None, Some(reason.as_str()), None, None, None, 0, 0)
}
AgentTerminalOutcome::Interrupted { reason } => {
AgentTerminalOutcome::Interrupted { reason, .. } => {
(None, Some(reason.as_str()), None, None, None, 0, 0)
}
};
@ -1182,13 +1196,23 @@ impl super::Storage {
// reservation into a durable completion event in the same commit.
// The event survives restarts, queue-full conditions and lost wakes.
if run.completion_slot_reserved {
let (status, error) = match outcome {
AgentTerminalOutcome::Completed { .. } => ("completed", None),
AgentTerminalOutcome::Failed { error, .. } => ("failed", Some(error.as_str())),
AgentTerminalOutcome::TimedOut { .. } => ("timed_out", Some("deadline exceeded")),
AgentTerminalOutcome::Cancelled { reason } => ("cancelled", Some(reason.as_str())),
AgentTerminalOutcome::Interrupted { reason } => {
("interrupted", Some(reason.as_str()))
let (status, error, signal_ids) = match outcome {
AgentTerminalOutcome::Completed { signal_ids, .. } => {
("completed", None, signal_ids.as_slice())
}
AgentTerminalOutcome::Failed {
error, signal_ids, ..
} => ("failed", Some(error.as_str()), signal_ids.as_slice()),
AgentTerminalOutcome::TimedOut { signal_ids, .. } => (
"timed_out",
Some("deadline exceeded"),
signal_ids.as_slice(),
),
AgentTerminalOutcome::Cancelled { reason, signal_ids } => {
("cancelled", Some(reason.as_str()), signal_ids.as_slice())
}
AgentTerminalOutcome::Interrupted { reason, signal_ids } => {
("interrupted", Some(reason.as_str()), signal_ids.as_slice())
}
};
super::agent_inbox::insert_completion_event_tx(
@ -1197,6 +1221,7 @@ impl super::Storage {
&run.root_session_id,
status,
error,
signal_ids,
now,
)
.await?;
@ -1335,6 +1360,8 @@ mod tests {
task: "do the work".to_string(),
context_json: None,
budget_json: "{\"remaining_runs\":15}".to_string(),
signal_contract_json: None,
signal_delivery: None,
deadline_at: 1_000,
runtime_generation: 1,
completion_slot_reserved: false,
@ -1429,6 +1456,7 @@ mod tests {
cost: None,
tool_calls: 1,
iterations: 2,
signal_ids: Vec::new(),
},
None,
120,
@ -1449,6 +1477,7 @@ mod tests {
prompt_tokens: None,
completion_tokens: None,
cost: None,
signal_ids: Vec::new(),
},
None,
130,
@ -1489,6 +1518,7 @@ mod tests {
cost: None,
tool_calls: 0,
iterations: 0,
signal_ids: Vec::new(),
},
None,
120,
@ -1602,6 +1632,7 @@ mod tests {
cost: None,
tool_calls: 0,
iterations: 0,
signal_ids: Vec::new(),
},
Some("finished the work"),
120,

View File

@ -240,10 +240,25 @@ impl DelegateTool {
"mixed named and legacy general batches are not supported",
));
}
self.sub_agent_manager
let mut results = self
.sub_agent_manager
.run_foreground_batch(configs, context)
.await
.map_err(|error| anyhow::anyhow!(error.to_string()))?
.map_err(|error| anyhow::anyhow!(error.to_string()))?;
// Legacy general compatibility: tell the model the
// unnamed path is deprecated so it migrates to named
// Agents (which get durable runs, fixed tool sets and
// per-run resource scopes).
for result in results.iter_mut() {
if matches!(result.status, TaskStatus::Completed) {
result.content = format!(
"{}\n\n[提示] 无 target 的通用 Agent 是兼容模式:不持久化、不可审计。\
Agent definition 使 target ",
result.content
);
}
}
results
};
let payload: Vec<_> = results
.into_iter()

View File

@ -254,8 +254,6 @@ impl Tool for EmitSignalTool {
if let Ok(mut signals) = agent.emitted_signals.lock() {
signals.push(EmittedSignal {
signal_id: accepted.signal_id.clone(),
severity: args.severity.clone(),
summary: args.summary.clone(),
});
}
}

View File

@ -3,6 +3,8 @@ use async_trait::async_trait;
use serde_json::json;
use std::time::Duration;
use crate::agent::steering::{TurnWakeupState, WakeupSource};
const MAX_SLEEP_SECONDS: u64 = 86_400;
pub struct SleepTool;
@ -88,27 +90,132 @@ impl Tool for SleepTool {
}
};
let started = std::time::Instant::now();
let mut wakeup_rx = context
.turn_wakeup
.as_ref()
.map(|handle| handle.receiver.clone());
// Root interactive Turn: if inputs are already pending, do not wait
// at all. The watch revision is monotonic, so an input arriving
// between this check and the select below still fires `changed()`.
if let Some(rx) = wakeup_rx.as_mut() {
let state = rx.borrow_and_update();
if state.pending_total() > 0 {
return Ok(ToolResult {
success: true,
output: wake_message(&state, started.elapsed(), 0),
error: None,
}
.into());
}
}
let outcome = match wakeup_rx.as_mut() {
Some(rx) => {
tokio::select! {
biased;
_ = context.cancellation.cancelled() => {
anyhow::bail!("sleep cancelled");
}
_ = tokio::time::sleep(Duration::from_secs(seconds)) => {}
_ = tokio::time::sleep(Duration::from_secs(seconds)) => {
WakeOutcome::Elapsed
}
changed = rx.changed() => {
let _ = changed;
let state = rx.borrow_and_update();
WakeOutcome::InputArrived(state.clone())
}
}
}
// Child runs and continuation Turns have no session input lane:
// their sleep answers only the timer, run cancellation, timeout
// and shutdown.
None => {
tokio::select! {
biased;
_ = context.cancellation.cancelled() => {
anyhow::bail!("sleep cancelled");
}
_ = tokio::time::sleep(Duration::from_secs(seconds)) => {
WakeOutcome::Elapsed
}
}
}
};
let output = match outcome {
WakeOutcome::Elapsed => format!("Slept for {seconds} second(s)."),
WakeOutcome::InputArrived(state) => wake_message(&state, started.elapsed(), seconds),
};
Ok(ToolResult {
success: true,
output: format!("Slept for {seconds} second(s)."),
output,
error: None,
}
.into())
}
}
enum WakeOutcome {
Elapsed,
InputArrived(TurnWakeupState),
}
/// Build the model-visible wake message. Steer wakes describe the source,
/// run identity and a safe preview; queue wakes only state the type/count and
/// explicitly promise the content stays out of the current Turn.
fn wake_message(state: &TurnWakeupState, waited: std::time::Duration, planned: u64) -> String {
let waited_secs = waited.as_secs();
let mut message = format!("Sleep 提前结束:已等待 {waited_secs}");
if planned > 0 {
message.push_str(&format!("(原计划 {planned} 秒)"));
}
message.push('。');
match &state.latest_source {
Some(WakeupSource::UserSteer) => {
message.push_str(" 收到一条新的用户输入,将在当前 Turn 的下一个安全边界注入。");
}
Some(WakeupSource::UserQueue) => {
message.push_str(&format!(
" 收到 {} 条排队输入。内容不会进入当前 Turn将在当前工作结束后的下一 Turn处理。",
state.pending_user_queue.max(1)
));
}
Some(WakeupSource::AgentSignal { run_id, agent_id }) => {
message.push_str(&format!(
" 收到一条 steer AgentSignalrun_id={run_id}, agent={agent_id}"
));
if let Some(preview) = state.latest_safe_preview.as_deref() {
message.push_str(&format!("{preview}"));
}
message.push_str("。该信号将在当前 Turn 的下一个安全边界注入。");
}
Some(WakeupSource::AgentCompletion { run_id, agent_id }) => {
message.push_str(&format!(
" 收到一条 steer AgentCompletionrun_id={run_id}, agent={agent_id}),将在当前 Turn 的下一个安全边界注入。"
));
}
Some(WakeupSource::AgentGroupCompletion { group_id }) => {
message.push_str(&format!(
" 收到一条 steer AgentGroupCompletiongroup={group_id}),将在当前 Turn 的下一个安全边界注入。"
));
}
Some(WakeupSource::AgentQueue) | None => {
message.push_str(&format!(
" 收到 {} 条排队输入。内容不会进入当前 Turn将在当前工作结束后的下一 Turn处理。",
state.pending_agent_queue.max(1)
));
}
}
message
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::TurnEvent;
use crate::agent::steering::TurnWakeupPublisher;
use crate::providers::ToolCall;
use crate::session::{ToolStatus, TurnBlock, TurnController, TurnStatus};
use crate::tools::Tool;
@ -135,6 +242,10 @@ mod tests {
assert!(!tool.read_only());
assert!(!tool.concurrency_safe());
assert!(!tool.exclusive());
assert_eq!(
tool.input_interrupt_policy(),
crate::tools::InputInterruptPolicy::WakeOnly
);
}
#[tokio::test]
@ -289,4 +400,130 @@ mod tests {
.unwrap_err();
assert!(error.to_string().contains("cancelled"));
}
#[tokio::test(start_paused = true)]
async fn pending_input_before_listen_returns_immediately() {
let publisher = TurnWakeupPublisher::new();
let handle = publisher.subscribe();
publisher.publish(TurnWakeupState {
pending_user_steer: 1,
latest_source: Some(WakeupSource::UserSteer),
..Default::default()
});
let context = crate::tools::ToolExecutionContext::default().with_turn_wakeup(handle);
let result = SleepTool::new()
.execute_with_context(&context, json!({"seconds": 3600}))
.await
.unwrap();
assert!(result.result.success);
assert!(result.result.output.contains("提前结束"));
assert!(result.result.output.contains("用户输入"));
}
#[tokio::test(start_paused = true)]
async fn steer_publish_wakes_sleep_with_source_and_preview() {
let publisher = TurnWakeupPublisher::new();
let handle = publisher.subscribe();
let context = crate::tools::ToolExecutionContext::default().with_turn_wakeup(handle);
let tool = SleepTool::new();
let wait = tokio::spawn(async move {
tool.execute_with_context(&context, json!({"seconds": 3600}))
.await
.unwrap()
.result
.output
});
tokio::task::yield_now().await;
assert!(!wait.is_finished());
publisher.publish(TurnWakeupState {
pending_agent_steer: 1,
latest_source: Some(WakeupSource::AgentSignal {
run_id: "run-123".to_string(),
agent_id: "monitor".to_string(),
}),
latest_safe_preview: Some("服务错误率超过 5%".to_string()),
..Default::default()
});
tokio::task::yield_now().await;
let output = wait.await.unwrap();
assert!(output.contains("提前结束"));
assert!(output.contains("run-123"));
assert!(output.contains("服务错误率超过 5%"));
assert!(output.contains("安全边界注入"));
}
#[tokio::test(start_paused = true)]
async fn queue_publish_wakes_sleep_without_content() {
let publisher = TurnWakeupPublisher::new();
let handle = publisher.subscribe();
let context = crate::tools::ToolExecutionContext::default().with_turn_wakeup(handle);
let tool = SleepTool::new();
let wait = tokio::spawn(async move {
tool.execute_with_context(&context, json!({"seconds": 3600}))
.await
.unwrap()
.result
.output
});
tokio::task::yield_now().await;
publisher.publish(TurnWakeupState {
pending_agent_queue: 1,
latest_source: Some(WakeupSource::AgentQueue),
..Default::default()
});
tokio::task::yield_now().await;
let output = wait.await.unwrap();
assert!(output.contains("排队输入"));
assert!(output.contains("不会进入当前 Turn"));
assert!(!output.contains("run-"));
}
#[tokio::test(start_paused = true)]
async fn child_sleep_without_handle_is_not_woken_by_publishes() {
let publisher = TurnWakeupPublisher::new();
let _handle = publisher.subscribe();
let context = crate::tools::ToolExecutionContext::default();
let tool = SleepTool::new();
let wait = tokio::spawn(async move {
tool.execute_with_context(&context, json!({"seconds": 30}))
.await
.unwrap()
.result
.output
});
tokio::task::yield_now().await;
publisher.publish(TurnWakeupState {
pending_agent_steer: 1,
latest_source: Some(WakeupSource::AgentSignal {
run_id: "run-9".to_string(),
agent_id: "a".to_string(),
}),
..Default::default()
});
tokio::task::yield_now().await;
assert!(!wait.is_finished());
tokio::time::advance(Duration::from_secs(30)).await;
tokio::task::yield_now().await;
assert!(wait.await.unwrap().contains("Slept for 30"));
}
#[tokio::test(start_paused = true)]
async fn pre_listen_publish_does_not_lose_the_wake() {
// Publish BEFORE the sleep subscribes its own receiver: watch keeps
// the latest value, so the borrow_and_update pre-check sees it.
let publisher = TurnWakeupPublisher::new();
let handle = publisher.subscribe();
publisher.publish(TurnWakeupState {
pending_agent_queue: 2,
latest_source: Some(WakeupSource::AgentQueue),
..Default::default()
});
let context = crate::tools::ToolExecutionContext::default().with_turn_wakeup(handle);
let result = SleepTool::new()
.execute_with_context(&context, json!({"seconds": 3600}))
.await
.unwrap();
assert!(result.result.success);
assert!(result.result.output.contains("排队输入"));
}
}

View File

@ -10,6 +10,10 @@ pub struct ToolExecutionContext {
pub agent: Option<std::sync::Arc<crate::agent::AgentExecutionContext>>,
pub cancellation: tokio_util::sync::CancellationToken,
pub execution_gate: Option<std::sync::Arc<crate::agent::gate::ExecutionGate>>,
/// Root interactive Turn only. Wake-aware tools (sleep) select on this
/// receiver so a user or Agent input ends the wait early; sub-runs and
/// continuations never receive it.
pub turn_wakeup: Option<crate::agent::steering::TurnWakeupHandle>,
}
impl Default for ToolExecutionContext {
@ -20,6 +24,7 @@ impl Default for ToolExecutionContext {
agent: None,
cancellation: tokio_util::sync::CancellationToken::new(),
execution_gate: None,
turn_wakeup: None,
}
}
}
@ -32,6 +37,7 @@ impl ToolExecutionContext {
agent: None,
cancellation: tokio_util::sync::CancellationToken::new(),
execution_gate: None,
turn_wakeup: None,
}
}
@ -60,6 +66,11 @@ impl ToolExecutionContext {
self.execution_gate = Some(gate);
self
}
pub fn with_turn_wakeup(mut self, handle: crate::agent::steering::TurnWakeupHandle) -> Self {
self.turn_wakeup = Some(handle);
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]

View File

@ -1,12 +1,12 @@
{
"name": "picobot-webui",
"version": "1.7.0",
"version": "1.8.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picobot-webui",
"version": "1.7.0",
"version": "1.8.0",
"dependencies": {
"bits-ui": "^2.0.0",
"dompurify": "^3.4.12",

View File

@ -1,7 +1,7 @@
{
"name": "picobot-webui",
"private": true,
"version": "1.7.0",
"version": "1.8.0",
"type": "module",
"engines": {
"node": ">=20"