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:
parent
ac201a3949
commit
554d4b15ac
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "picobot"
|
name = "picobot"
|
||||||
version = "1.7.0"
|
version = "1.8.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@ -219,6 +219,8 @@ Cron 不是一个带 `action` 的统一工具,而是六个独立工具;仅
|
|||||||
|
|
||||||
参数 `seconds` 接受 0~86400 的整数。工具只暂停当前 Agent 工具调用,不持久化、不发送消息,也不保证跨进程重启继续;用户 `/stop`、Scheduler/SubAgent 超时和 Gateway shutdown 都会取消等待。超过 24 小时或需要可靠延迟执行时应使用 Scheduler。
|
参数 `seconds` 接受 0~86400 的整数。工具只暂停当前 Agent 工具调用,不持久化、不发送消息,也不保证跨进程重启继续;用户 `/stop`、Scheduler/SubAgent 超时和 Gateway shutdown 都会取消等待。超过 24 小时或需要可靠延迟执行时应使用 Scheduler。
|
||||||
|
|
||||||
|
主 Agent(root 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 / web_fetch — HTTP 和 Web 工具
|
||||||
|
|
||||||
`http_request` 支持 GET/POST/PUT/DELETE/PATCH、headers 和字符串 body;`web_fetch` 提取 HTML/JSON 的可读文本。两者校验 URL 与 DNS 解析结果,阻止回环、私网、link-local 和本地域名,并禁用自动重定向,以降低 SSRF 风险。
|
`http_request` 支持 GET/POST/PUT/DELETE/PATCH、headers 和字符串 body;`web_fetch` 提取 HTML/JSON 的可读文本。两者校验 URL 与 DNS 解析结果,阻止回环、私网、link-local 和本地域名,并禁用自动重定向,以降低 SSRF 风险。
|
||||||
|
|||||||
@ -32,6 +32,8 @@ pub struct AgentCoordinator {
|
|||||||
work_manager: Option<Arc<crate::work::WorkManager>>,
|
work_manager: Option<Arc<crate::work::WorkManager>>,
|
||||||
notifier: Arc<AgentInboxNotifier>,
|
notifier: Arc<AgentInboxNotifier>,
|
||||||
projection: Arc<AgentProjectionHub>,
|
projection: Arc<AgentProjectionHub>,
|
||||||
|
execution_gate: Arc<crate::agent::gate::ExecutionGate>,
|
||||||
|
admission: crate::gateway::reload::RuntimeAdmission,
|
||||||
task_supervisor: crate::task_supervisor::TaskSupervisor,
|
task_supervisor: crate::task_supervisor::TaskSupervisor,
|
||||||
runtime_generation: i64,
|
runtime_generation: i64,
|
||||||
max_pending_inbox_events_per_session: i64,
|
max_pending_inbox_events_per_session: i64,
|
||||||
@ -51,12 +53,14 @@ pub enum CoordinatorError {
|
|||||||
|
|
||||||
impl AgentCoordinator {
|
impl AgentCoordinator {
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn new(
|
pub(crate) fn new(
|
||||||
storage: Arc<Storage>,
|
storage: Arc<Storage>,
|
||||||
manager: Arc<SubAgentManager>,
|
manager: Arc<SubAgentManager>,
|
||||||
work_manager: Arc<crate::work::WorkManager>,
|
work_manager: Arc<crate::work::WorkManager>,
|
||||||
notifier: Arc<AgentInboxNotifier>,
|
notifier: Arc<AgentInboxNotifier>,
|
||||||
projection: Arc<AgentProjectionHub>,
|
projection: Arc<AgentProjectionHub>,
|
||||||
|
execution_gate: Arc<crate::agent::gate::ExecutionGate>,
|
||||||
|
admission: crate::gateway::reload::RuntimeAdmission,
|
||||||
task_supervisor: crate::task_supervisor::TaskSupervisor,
|
task_supervisor: crate::task_supervisor::TaskSupervisor,
|
||||||
runtime_generation: u64,
|
runtime_generation: u64,
|
||||||
orchestration: &crate::config::AgentOrchestrationConfig,
|
orchestration: &crate::config::AgentOrchestrationConfig,
|
||||||
@ -67,6 +71,8 @@ impl AgentCoordinator {
|
|||||||
work_manager: Some(work_manager),
|
work_manager: Some(work_manager),
|
||||||
notifier,
|
notifier,
|
||||||
projection,
|
projection,
|
||||||
|
execution_gate,
|
||||||
|
admission,
|
||||||
task_supervisor,
|
task_supervisor,
|
||||||
runtime_generation: runtime_generation as i64,
|
runtime_generation: runtime_generation as i64,
|
||||||
max_pending_inbox_events_per_session: orchestration.max_pending_inbox_events_per_session
|
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();
|
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
|
// 1. Reserve the completion slot; failure means the inbox is full and
|
||||||
// nothing is admitted.
|
// nothing is admitted.
|
||||||
if self
|
if self
|
||||||
@ -135,7 +156,7 @@ impl AgentCoordinator {
|
|||||||
root_turn_id: caller.turn_id.clone(),
|
root_turn_id: caller.turn_id.clone(),
|
||||||
parent_run_id: None,
|
parent_run_id: None,
|
||||||
caller_agent_id: "ROOT".to_string(),
|
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,
|
idempotency_key: None,
|
||||||
agent_id: resolved.agent_id.clone().unwrap_or_default(),
|
agent_id: resolved.agent_id.clone().unwrap_or_default(),
|
||||||
definition_hash: resolved.definition_hash.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(),
|
"remaining_depth": self.manager.catalog().max_tree_depth(),
|
||||||
})
|
})
|
||||||
.to_string(),
|
.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,
|
deadline_at: now + (resolved.timeout_secs * 1000) as i64,
|
||||||
runtime_generation: self.runtime_generation,
|
runtime_generation: self.runtime_generation,
|
||||||
completion_slot_reserved: true,
|
completion_slot_reserved: true,
|
||||||
@ -190,12 +219,16 @@ impl AgentCoordinator {
|
|||||||
let run_id = run_id.clone();
|
let run_id = run_id.clone();
|
||||||
async move {
|
async move {
|
||||||
coordinator
|
coordinator
|
||||||
.run_background_runner(&run_id, &config, resolved, token)
|
.run_background_runner(
|
||||||
|
&run_id, &config, resolved, token, run_permit, activity,
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if !spawned {
|
if !spawned {
|
||||||
// Compensation: undo the durable admission before returning.
|
// 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);
|
self.active_tokens.remove(&run_id);
|
||||||
let _ = self
|
let _ = self
|
||||||
.storage
|
.storage
|
||||||
@ -214,6 +247,8 @@ impl AgentCoordinator {
|
|||||||
config: &SubAgentConfig,
|
config: &SubAgentConfig,
|
||||||
resolved: ResolvedAgentRun,
|
resolved: ResolvedAgentRun,
|
||||||
token: CancellationToken,
|
token: CancellationToken,
|
||||||
|
_run_permit: crate::agent::gate::RunPermit,
|
||||||
|
_activity: crate::gateway::reload::ActivityGuard,
|
||||||
) {
|
) {
|
||||||
let now = chrono::Utc::now().timestamp_millis();
|
let now = chrono::Utc::now().timestamp_millis();
|
||||||
let execution_id = run_id.to_string();
|
let execution_id = run_id.to_string();
|
||||||
@ -229,6 +264,19 @@ impl AgentCoordinator {
|
|||||||
return;
|
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
|
let result = self
|
||||||
.manager
|
.manager
|
||||||
.execute_resolved(config, resolved, run_id)
|
.execute_resolved(config, resolved, run_id)
|
||||||
@ -243,18 +291,22 @@ impl AgentCoordinator {
|
|||||||
cost: None,
|
cost: None,
|
||||||
tool_calls: result.tool_calls_count as i64,
|
tool_calls: result.tool_calls_count as i64,
|
||||||
iterations: result.iterations as i64,
|
iterations: result.iterations as i64,
|
||||||
|
signal_ids: emitted_signals.clone(),
|
||||||
},
|
},
|
||||||
TaskStatus::Failed(error) => AgentTerminalOutcome::Failed {
|
TaskStatus::Failed(error) => AgentTerminalOutcome::Failed {
|
||||||
error: error.clone(),
|
error: error.clone(),
|
||||||
prompt_tokens: None,
|
prompt_tokens: None,
|
||||||
completion_tokens: None,
|
completion_tokens: None,
|
||||||
cost: None,
|
cost: None,
|
||||||
|
signal_ids: emitted_signals.clone(),
|
||||||
},
|
},
|
||||||
TaskStatus::TimedOut => AgentTerminalOutcome::TimedOut {
|
TaskStatus::TimedOut => AgentTerminalOutcome::TimedOut {
|
||||||
deadline_at: chrono::Utc::now().timestamp_millis(),
|
deadline_at: chrono::Utc::now().timestamp_millis(),
|
||||||
|
signal_ids: emitted_signals.clone(),
|
||||||
},
|
},
|
||||||
TaskStatus::Cancelled => AgentTerminalOutcome::Cancelled {
|
TaskStatus::Cancelled => AgentTerminalOutcome::Cancelled {
|
||||||
reason: "cancelled by user, parent or shutdown".to_string(),
|
reason: "cancelled by user, parent or shutdown".to_string(),
|
||||||
|
signal_ids: emitted_signals.clone(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Err(error) => AgentTerminalOutcome::Failed {
|
Err(error) => AgentTerminalOutcome::Failed {
|
||||||
@ -262,6 +314,7 @@ impl AgentCoordinator {
|
|||||||
prompt_tokens: None,
|
prompt_tokens: None,
|
||||||
completion_tokens: None,
|
completion_tokens: None,
|
||||||
cost: 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),
|
"remaining_depth": caller.agent.as_ref().map(|agent| agent.budget.remaining_depth),
|
||||||
}))
|
}))
|
||||||
.unwrap_or_default(),
|
.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,
|
deadline_at,
|
||||||
runtime_generation: self.runtime_generation,
|
runtime_generation: self.runtime_generation,
|
||||||
completion_slot_reserved: false,
|
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
|
let result = self
|
||||||
.manager
|
.manager
|
||||||
.execute_resolved(config, resolution, run_id)
|
.execute_resolved(config, resolution, run_id)
|
||||||
@ -610,18 +684,22 @@ impl AgentCoordinator {
|
|||||||
cost: None,
|
cost: None,
|
||||||
tool_calls: result.tool_calls_count as i64,
|
tool_calls: result.tool_calls_count as i64,
|
||||||
iterations: result.iterations as i64,
|
iterations: result.iterations as i64,
|
||||||
|
signal_ids: emitted_signals.clone(),
|
||||||
},
|
},
|
||||||
TaskStatus::Failed(error) => AgentTerminalOutcome::Failed {
|
TaskStatus::Failed(error) => AgentTerminalOutcome::Failed {
|
||||||
error: error.clone(),
|
error: error.clone(),
|
||||||
prompt_tokens: None,
|
prompt_tokens: None,
|
||||||
completion_tokens: None,
|
completion_tokens: None,
|
||||||
cost: None,
|
cost: None,
|
||||||
|
signal_ids: emitted_signals.clone(),
|
||||||
},
|
},
|
||||||
TaskStatus::TimedOut => AgentTerminalOutcome::TimedOut {
|
TaskStatus::TimedOut => AgentTerminalOutcome::TimedOut {
|
||||||
deadline_at: chrono::Utc::now().timestamp_millis(),
|
deadline_at: chrono::Utc::now().timestamp_millis(),
|
||||||
|
signal_ids: emitted_signals.clone(),
|
||||||
},
|
},
|
||||||
TaskStatus::Cancelled => AgentTerminalOutcome::Cancelled {
|
TaskStatus::Cancelled => AgentTerminalOutcome::Cancelled {
|
||||||
reason: "cancelled by user, parent or shutdown".to_string(),
|
reason: "cancelled by user, parent or shutdown".to_string(),
|
||||||
|
signal_ids: emitted_signals.clone(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Err(error) => AgentTerminalOutcome::Failed {
|
Err(error) => AgentTerminalOutcome::Failed {
|
||||||
@ -629,6 +707,7 @@ impl AgentCoordinator {
|
|||||||
prompt_tokens: None,
|
prompt_tokens: None,
|
||||||
completion_tokens: None,
|
completion_tokens: None,
|
||||||
cost: None,
|
cost: None,
|
||||||
|
signal_ids: emitted_signals,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -1038,6 +1117,12 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn coordinator() -> (Arc<AgentCoordinator>, tempfile::TempDir) {
|
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 dir = tempfile::tempdir().unwrap();
|
||||||
let storage = Arc::new(Storage::new(&dir.path().join("coord.db")).await.unwrap());
|
let storage = Arc::new(Storage::new(&dir.path().join("coord.db")).await.unwrap());
|
||||||
let (notify_tx, _notify_rx) = tokio::sync::mpsc::unbounded_channel();
|
let (notify_tx, _notify_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||||
@ -1059,7 +1144,7 @@ mod tests {
|
|||||||
let supervisor = crate::task_supervisor::TaskSupervisor::new();
|
let supervisor = crate::task_supervisor::TaskSupervisor::new();
|
||||||
let orchestration = crate::config::AgentOrchestrationConfig {
|
let orchestration = crate::config::AgentOrchestrationConfig {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
max_pending_inbox_events_per_session: 1,
|
max_pending_inbox_events_per_session: max_pending,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
(
|
(
|
||||||
@ -1069,6 +1154,8 @@ mod tests {
|
|||||||
work_manager,
|
work_manager,
|
||||||
notifier,
|
notifier,
|
||||||
Arc::new(crate::agent::AgentProjectionHub::new()),
|
Arc::new(crate::agent::AgentProjectionHub::new()),
|
||||||
|
crate::agent::gate::ExecutionGate::unbounded(),
|
||||||
|
crate::gateway::reload::RuntimeAdmission::open(),
|
||||||
supervisor,
|
supervisor,
|
||||||
1,
|
1,
|
||||||
&orchestration,
|
&orchestration,
|
||||||
@ -1259,12 +1346,13 @@ mod tests {
|
|||||||
assert_eq!(state, (1, 0));
|
assert_eq!(state, (1, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn storage_accept_run(
|
async fn storage_accept_run_with_delivery(
|
||||||
storage: &Arc<Storage>,
|
storage: &Arc<Storage>,
|
||||||
run_id: &str,
|
run_id: &str,
|
||||||
session: &str,
|
session: &str,
|
||||||
now: i64,
|
now: i64,
|
||||||
slot_reserved: bool,
|
slot_reserved: bool,
|
||||||
|
signal_delivery: Option<&str>,
|
||||||
) {
|
) {
|
||||||
let _ = storage.ensure_agent_session_state(session, now).await;
|
let _ = storage.ensure_agent_session_state(session, now).await;
|
||||||
if slot_reserved {
|
if slot_reserved {
|
||||||
@ -1290,6 +1378,8 @@ mod tests {
|
|||||||
task: "work".to_string(),
|
task: "work".to_string(),
|
||||||
context_json: None,
|
context_json: None,
|
||||||
budget_json: "{}".to_string(),
|
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,
|
deadline_at: now + 100_000,
|
||||||
runtime_generation: 1,
|
runtime_generation: 1,
|
||||||
completion_slot_reserved: slot_reserved,
|
completion_slot_reserved: slot_reserved,
|
||||||
@ -1304,6 +1394,16 @@ mod tests {
|
|||||||
.unwrap();
|
.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]
|
#[tokio::test]
|
||||||
async fn emit_signal_persists_wakes_and_respects_capacity_and_dedupe() {
|
async fn emit_signal_persists_wakes_and_respects_capacity_and_dedupe() {
|
||||||
let (coordinator, _dir) = coordinator().await;
|
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]
|
#[tokio::test]
|
||||||
async fn emit_signal_rejects_when_run_is_terminal_or_inbox_is_full() {
|
async fn emit_signal_rejects_when_run_is_terminal_or_inbox_is_full() {
|
||||||
let (coordinator, _dir) = coordinator().await;
|
let (coordinator, _dir) = coordinator().await;
|
||||||
@ -1419,6 +1611,7 @@ mod tests {
|
|||||||
1,
|
1,
|
||||||
&AgentTerminalOutcome::Cancelled {
|
&AgentTerminalOutcome::Cancelled {
|
||||||
reason: "test".to_string(),
|
reason: "test".to_string(),
|
||||||
|
signal_ids: Vec::new(),
|
||||||
},
|
},
|
||||||
None,
|
None,
|
||||||
now + 1,
|
now + 1,
|
||||||
@ -1462,6 +1655,8 @@ mod tests {
|
|||||||
work_manager,
|
work_manager,
|
||||||
notifier,
|
notifier,
|
||||||
Arc::new(crate::agent::AgentProjectionHub::new()),
|
Arc::new(crate::agent::AgentProjectionHub::new()),
|
||||||
|
crate::agent::gate::ExecutionGate::unbounded(),
|
||||||
|
crate::gateway::reload::RuntimeAdmission::open(),
|
||||||
supervisor,
|
supervisor,
|
||||||
1,
|
1,
|
||||||
&orchestration,
|
&orchestration,
|
||||||
@ -1494,4 +1689,74 @@ mod tests {
|
|||||||
Err(CoordinatorError::Rejected(_))
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,7 +21,7 @@ pub use definition::{AgentDefinition, AgentLimits};
|
|||||||
pub use gate::ExecutionGate;
|
pub use gate::ExecutionGate;
|
||||||
pub use inbox::{AgentInboxNotifier, AgentInboxWakeTarget};
|
pub use inbox::{AgentInboxNotifier, AgentInboxWakeTarget};
|
||||||
pub use projection::AgentProjectionHub;
|
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 steering::{SteeringDrain, SteeringPushError, TurnInput, TurnInputSource, TurnMailbox};
|
||||||
pub use sub_agent::{
|
pub use sub_agent::{
|
||||||
DelegateContext, ExecutionMode, SubAgentConfig, SubAgentError, SubAgentManager, SubAgentResult,
|
DelegateContext, ExecutionMode, SubAgentConfig, SubAgentError, SubAgentManager, SubAgentResult,
|
||||||
|
|||||||
@ -4,12 +4,6 @@ use std::sync::atomic::{AtomicUsize, Ordering};
|
|||||||
|
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum AgentCaller {
|
|
||||||
Root,
|
|
||||||
Agent,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct AgentBudget {
|
pub struct AgentBudget {
|
||||||
pub remaining_runs: usize,
|
pub remaining_runs: usize,
|
||||||
@ -21,8 +15,6 @@ pub struct AgentBudget {
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct EmittedSignal {
|
pub struct EmittedSignal {
|
||||||
pub signal_id: String,
|
pub signal_id: String,
|
||||||
pub severity: String,
|
|
||||||
pub summary: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|||||||
@ -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;
|
/// How the input reached the mailbox. Queue inputs belong to the next Turn;
|
||||||
/// only Steer entries are drained by the active Turn.
|
/// only Steer entries are drained by the active Turn.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@ -566,6 +664,34 @@ impl TurnMailbox {
|
|||||||
pub fn max_agent_messages(&self) -> usize {
|
pub fn max_agent_messages(&self) -> usize {
|
||||||
self.max_agent_messages
|
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 {
|
impl Default for TurnMailbox {
|
||||||
|
|||||||
@ -154,6 +154,9 @@ pub(crate) struct ResolvedAgentRun {
|
|||||||
pub agent_id: Option<String>,
|
pub agent_id: Option<String>,
|
||||||
pub definition_hash: Option<String>,
|
pub definition_hash: Option<String>,
|
||||||
pub llm_profile: 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 {
|
impl SubAgentManager {
|
||||||
@ -293,6 +296,7 @@ impl SubAgentManager {
|
|||||||
agent_id: None,
|
agent_id: None,
|
||||||
definition_hash: None,
|
definition_hash: None,
|
||||||
llm_profile: None,
|
llm_profile: None,
|
||||||
|
signal_contract: None,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -469,6 +473,7 @@ impl SubAgentManager {
|
|||||||
agent_id: Some(target.to_string()),
|
agent_id: Some(target.to_string()),
|
||||||
definition_hash: Some(definition.definition_hash.clone()),
|
definition_hash: Some(definition.definition_hash.clone()),
|
||||||
llm_profile: Some(definition.llm_profile.clone()),
|
llm_profile: Some(definition.llm_profile.clone()),
|
||||||
|
signal_contract: definition.signal_contract.clone(),
|
||||||
tool_context: ToolExecutionContext::for_session(format!("agent-run:{task_id}"))
|
tool_context: ToolExecutionContext::for_session(format!("agent-run:{task_id}"))
|
||||||
.with_turn_id(
|
.with_turn_id(
|
||||||
caller
|
caller
|
||||||
@ -564,7 +569,6 @@ impl SubAgentManager {
|
|||||||
&resolved.provider_config.workspace_dir,
|
&resolved.provider_config.workspace_dir,
|
||||||
&resolved.provider_config.model_id,
|
&resolved.provider_config.model_id,
|
||||||
resolved.skills_prompt,
|
resolved.skills_prompt,
|
||||||
false,
|
|
||||||
);
|
);
|
||||||
if let Some(role_prompt) = resolved.role_prompt {
|
if let Some(role_prompt) = resolved.role_prompt {
|
||||||
system_prompt.push_str("\n\n## Agent Definition\n\n");
|
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(
|
pub async fn run_foreground_batch(
|
||||||
&self,
|
&self,
|
||||||
configs: Vec<SubAgentConfig>,
|
configs: Vec<SubAgentConfig>,
|
||||||
@ -816,11 +812,6 @@ impl SubAgentManager {
|
|||||||
let tools = self.filter_tools(&config.allowed_tools);
|
let tools = self.filter_tools(&config.allowed_tools);
|
||||||
let timeout_secs = config.timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS);
|
let timeout_secs = config.timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS);
|
||||||
let timeout_human = format_duration(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 skills_prompt = self.get_skills_prompt(&tools);
|
||||||
let system_prompt = build_sub_agent_system_prompt(
|
let system_prompt = build_sub_agent_system_prompt(
|
||||||
&config.prompt,
|
&config.prompt,
|
||||||
@ -829,7 +820,6 @@ impl SubAgentManager {
|
|||||||
&self.provider_config.workspace_dir,
|
&self.provider_config.workspace_dir,
|
||||||
&self.provider_config.model_id,
|
&self.provider_config.model_id,
|
||||||
skills_prompt,
|
skills_prompt,
|
||||||
http_get_only,
|
|
||||||
);
|
);
|
||||||
let provider_config = self.provider_config.clone();
|
let provider_config = self.provider_config.clone();
|
||||||
let storage = self.storage.clone();
|
let storage = self.storage.clone();
|
||||||
|
|||||||
@ -57,7 +57,6 @@ impl SystemPromptBuilder {
|
|||||||
task: &str,
|
task: &str,
|
||||||
timeout: &str,
|
timeout: &str,
|
||||||
skills_prompt: Option<String>,
|
skills_prompt: Option<String>,
|
||||||
http_get_only: bool,
|
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let mut sections: Vec<Box<dyn PromptSection>> = vec![
|
let mut sections: Vec<Box<dyn PromptSection>> = vec![
|
||||||
Box::new(SubAgentIdentitySection {
|
Box::new(SubAgentIdentitySection {
|
||||||
@ -66,7 +65,7 @@ impl SystemPromptBuilder {
|
|||||||
}),
|
}),
|
||||||
Box::new(ToolHonestySection),
|
Box::new(ToolHonestySection),
|
||||||
Box::new(SafetySection),
|
Box::new(SafetySection),
|
||||||
Box::new(SubAgentToolsSection { http_get_only }),
|
Box::new(SubAgentToolsSection),
|
||||||
Box::new(WorkspaceSection),
|
Box::new(WorkspaceSection),
|
||||||
];
|
];
|
||||||
if let Some(sp) = skills_prompt {
|
if let Some(sp) = skills_prompt {
|
||||||
@ -390,9 +389,7 @@ impl PromptSection for SubAgentIdentitySection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Sub-agent available tools description.
|
/// Sub-agent available tools description.
|
||||||
pub struct SubAgentToolsSection {
|
pub struct SubAgentToolsSection;
|
||||||
pub http_get_only: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PromptSection for SubAgentToolsSection {
|
impl PromptSection for SubAgentToolsSection {
|
||||||
fn name(&self) -> &str {
|
fn name(&self) -> &str {
|
||||||
@ -402,11 +399,6 @@ impl PromptSection for SubAgentToolsSection {
|
|||||||
fn build(&self, ctx: &PromptContext<'_>) -> String {
|
fn build(&self, ctx: &PromptContext<'_>) -> String {
|
||||||
let mut s = String::from("## 可用工具\n\n");
|
let mut s = String::from("## 可用工具\n\n");
|
||||||
s.push_str(&ctx.tools.describe_for_prompt());
|
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
|
s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -514,15 +506,13 @@ pub fn build_sub_agent_system_prompt(
|
|||||||
workspace_dir: &Path,
|
workspace_dir: &Path,
|
||||||
model_name: &str,
|
model_name: &str,
|
||||||
skills_prompt: Option<String>,
|
skills_prompt: Option<String>,
|
||||||
http_get_only: bool,
|
|
||||||
) -> String {
|
) -> String {
|
||||||
let ctx = PromptContext {
|
let ctx = PromptContext {
|
||||||
workspace_dir,
|
workspace_dir,
|
||||||
model_name,
|
model_name,
|
||||||
tools,
|
tools,
|
||||||
};
|
};
|
||||||
SystemPromptBuilder::with_sub_agent_defaults(task, timeout_human, skills_prompt, http_get_only)
|
SystemPromptBuilder::with_sub_agent_defaults(task, timeout_human, skills_prompt).build(&ctx)
|
||||||
.build(&ctx)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
use std::collections::{HashMap, VecDeque};
|
use std::collections::{HashMap, VecDeque};
|
||||||
use std::sync::{Arc, Mutex as StdMutex};
|
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 tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
use super::persistence::{
|
use super::persistence::{
|
||||||
@ -485,7 +485,7 @@ mod cancelled_partial_tests {
|
|||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
controller.complete(None);
|
controller.complete(None);
|
||||||
let (sender, completion) = oneshot::channel();
|
let (sender, completion) = tokio::sync::oneshot::channel();
|
||||||
sender.send(Err(DeliveryError::FinalTimedOut)).unwrap();
|
sender.send(Err(DeliveryError::FinalTimedOut)).unwrap();
|
||||||
|
|
||||||
let target = crate::channels::TurnTarget {
|
let target = crate::channels::TurnTarget {
|
||||||
@ -624,12 +624,14 @@ pub struct Session {
|
|||||||
/// It is allocated while holding the Session mutex so worker cleanup can
|
/// It is allocated while holding the Session mutex so worker cleanup can
|
||||||
/// use that same lock as the send barrier.
|
/// use that same lock as the send barrier.
|
||||||
next_task_sequence: u64,
|
next_task_sequence: u64,
|
||||||
/// Cancel signal for the currently executing agent task
|
/// Whether the session has an active Turn or queued local fallback
|
||||||
current_cancel: Option<oneshot::Sender<()>>,
|
/// 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
|
/// Structured cancellation for the active Turn. `/stop` cancels it and
|
||||||
/// the token propagates through AgentLoop provider streams and tool
|
/// the token propagates through AgentLoop provider streams and tool
|
||||||
/// batches. The oneshot above remains the busy/stop compatibility
|
/// batches.
|
||||||
/// marker until it is removed together with the legacy adapter.
|
|
||||||
current_turn_token: Option<CancellationToken>,
|
current_turn_token: Option<CancellationToken>,
|
||||||
/// Latest durable inbox revision for this session. The worker watches
|
/// Latest durable inbox revision for this session. The worker watches
|
||||||
/// this to claim agent events; the value only merges wakes, the payload
|
/// 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
|
/// `AgentTurnContext`; keeping it on the session handle makes admission
|
||||||
/// atomic with `/stop` and worker cleanup.
|
/// atomic with `/stop` and worker cleanup.
|
||||||
steering: Arc<TurnMailbox>,
|
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
|
/// Original inbound tasks for accepted steering messages. ChatMessage
|
||||||
/// intentionally carries only durable history fields, so this side map
|
/// intentionally carries only durable history fields, so this side map
|
||||||
/// preserves channel context and rich MediaItem metadata if a terminal
|
/// preserves channel context and rich MediaItem metadata if a terminal
|
||||||
@ -688,7 +692,11 @@ struct AgentTask {
|
|||||||
fn steer_input_from_event(
|
fn steer_input_from_event(
|
||||||
event: &crate::storage::agent_inbox::AgentInboxEventRecord,
|
event: &crate::storage::agent_inbox::AgentInboxEventRecord,
|
||||||
now: i64,
|
now: i64,
|
||||||
) -> TurnInput {
|
) -> (
|
||||||
|
TurnInput,
|
||||||
|
crate::agent::steering::WakeupSource,
|
||||||
|
Option<String>,
|
||||||
|
) {
|
||||||
use crate::agent::steering::InputDelivery;
|
use crate::agent::steering::InputDelivery;
|
||||||
use crate::storage::agent_inbox::AgentEventType;
|
use crate::storage::agent_inbox::AgentEventType;
|
||||||
let payload: serde_json::Value =
|
let payload: serde_json::Value =
|
||||||
@ -699,13 +707,14 @@ fn steer_input_from_event(
|
|||||||
.and_then(serde_json::Value::as_str)
|
.and_then(serde_json::Value::as_str)
|
||||||
.unwrap_or("unknown")
|
.unwrap_or("unknown")
|
||||||
.to_string();
|
.to_string();
|
||||||
let (source, content) = match event.event_type {
|
let (source, wakeup_preview, content) = match event.event_type {
|
||||||
AgentEventType::Signal => {
|
AgentEventType::Signal => {
|
||||||
let severity = event.severity.clone().unwrap_or_else(|| "info".to_string());
|
let severity = event.severity.clone().unwrap_or_else(|| "info".to_string());
|
||||||
let summary = payload
|
let summary = payload
|
||||||
.get("summary")
|
.get("summary")
|
||||||
.and_then(serde_json::Value::as_str)
|
.and_then(serde_json::Value::as_str)
|
||||||
.unwrap_or_default();
|
.unwrap_or_default()
|
||||||
|
.to_string();
|
||||||
let mut content = format!(
|
let mut content = format!(
|
||||||
"[后台 Agent 信号] severity={severity}, agent={agent_id}, run={run_id}\n{summary}"
|
"[后台 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("\n详情: ");
|
||||||
content.push_str(&details.to_string());
|
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 => {
|
AgentEventType::Completion => {
|
||||||
let status = payload
|
let status = payload
|
||||||
@ -728,17 +744,27 @@ fn steer_input_from_event(
|
|||||||
content.push_str(&format!("\n错误: {error}"));
|
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,
|
content,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
AgentEventType::GroupCompletion => {
|
AgentEventType::GroupCompletion => {
|
||||||
let group_id = event.group_id.clone().unwrap_or_default();
|
let group_id = event.group_id.clone().unwrap_or_default();
|
||||||
let content = format!("[后台 Agent 任务组结果] group={group_id}");
|
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),
|
id: format!("steer:{}", event.id),
|
||||||
sequence: 0,
|
sequence: 0,
|
||||||
source,
|
source,
|
||||||
@ -749,7 +775,9 @@ fn steer_input_from_event(
|
|||||||
received_at: now,
|
received_at: now,
|
||||||
message_source: None,
|
message_source: None,
|
||||||
lease_token: Some(event.lease_token.clone().unwrap_or_default()),
|
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
|
/// Move terminally pending steering into the worker's local FIFO. The
|
||||||
@ -918,7 +946,7 @@ impl Session {
|
|||||||
memory_manager,
|
memory_manager,
|
||||||
agent_tx: None,
|
agent_tx: None,
|
||||||
next_task_sequence: 1,
|
next_task_sequence: 1,
|
||||||
current_cancel: None,
|
turn_busy: false,
|
||||||
current_turn_token: None,
|
current_turn_token: None,
|
||||||
agent_inbox_wake: watch::channel(0).0,
|
agent_inbox_wake: watch::channel(0).0,
|
||||||
active_turn_emitter: None,
|
active_turn_emitter: None,
|
||||||
@ -1115,7 +1143,7 @@ impl Session {
|
|||||||
memory_manager,
|
memory_manager,
|
||||||
agent_tx: None,
|
agent_tx: None,
|
||||||
next_task_sequence: 1,
|
next_task_sequence: 1,
|
||||||
current_cancel: None,
|
turn_busy: false,
|
||||||
current_turn_token: None,
|
current_turn_token: None,
|
||||||
agent_inbox_wake: watch::channel(0).0,
|
agent_inbox_wake: watch::channel(0).0,
|
||||||
active_turn_emitter: None,
|
active_turn_emitter: None,
|
||||||
@ -1280,6 +1308,7 @@ impl Session {
|
|||||||
turn_id: turn_id.to_string(),
|
turn_id: turn_id.to_string(),
|
||||||
emitter,
|
emitter,
|
||||||
steering: TurnMailbox::new_shared(),
|
steering: TurnMailbox::new_shared(),
|
||||||
|
wakeup: crate::agent::steering::TurnWakeupPublisher::new(),
|
||||||
recovery: StdArc::new(StdMutex::new(HashMap::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 {
|
impl SessionManager {
|
||||||
fn worker_deps(&self) -> AgentWorkerDeps {
|
fn worker_deps(&self) -> AgentWorkerDeps {
|
||||||
AgentWorkerDeps {
|
AgentWorkerDeps {
|
||||||
@ -1947,7 +1987,7 @@ impl SessionManager {
|
|||||||
Some(skills_loader.clone()),
|
Some(skills_loader.clone()),
|
||||||
task_supervisor.clone(),
|
task_supervisor.clone(),
|
||||||
)
|
)
|
||||||
.with_admission(admission)
|
.with_admission(admission.clone())
|
||||||
.with_catalog(agent_catalog.clone())
|
.with_catalog(agent_catalog.clone())
|
||||||
.with_execution_gate(execution_gate.clone())
|
.with_execution_gate(execution_gate.clone())
|
||||||
.with_work_manager(work_manager.clone()),
|
.with_work_manager(work_manager.clone()),
|
||||||
@ -1962,6 +2002,8 @@ impl SessionManager {
|
|||||||
work_manager.clone(),
|
work_manager.clone(),
|
||||||
inbox_notifier.clone(),
|
inbox_notifier.clone(),
|
||||||
agent_projection_hub.clone(),
|
agent_projection_hub.clone(),
|
||||||
|
execution_gate.clone(),
|
||||||
|
admission.clone(),
|
||||||
task_supervisor.clone(),
|
task_supervisor.clone(),
|
||||||
catalog_preparation.runtime_generation,
|
catalog_preparation.runtime_generation,
|
||||||
&catalog_preparation.config,
|
&catalog_preparation.config,
|
||||||
@ -2368,7 +2410,8 @@ impl SessionManager {
|
|||||||
let msgs = {
|
let msgs = {
|
||||||
let mut guard = session.lock().await;
|
let mut guard = session.lock().await;
|
||||||
let mut msgs: Vec<String> = Vec::new();
|
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());
|
msgs.push("当前任务已发送停止信号。".to_string());
|
||||||
}
|
}
|
||||||
if let Some(token) = guard.current_turn_token.take() {
|
if let Some(token) = guard.current_turn_token.take() {
|
||||||
@ -2501,7 +2544,7 @@ impl SessionManager {
|
|||||||
.agent_tx
|
.agent_tx
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|sender| sender.capacity() < sender.max_capacity());
|
.is_some_and(|sender| sender.capacity() < sender.max_capacity());
|
||||||
if session.current_cancel.is_some() || queued {
|
if session.turn_busy || queued {
|
||||||
busy = true;
|
busy = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -2535,7 +2578,7 @@ impl SessionManager {
|
|||||||
let mut count = 0;
|
let mut count = 0;
|
||||||
for session in sessions {
|
for session in sessions {
|
||||||
let session = session.lock().await;
|
let session = session.lock().await;
|
||||||
if session.current_cancel.is_some() {
|
if session.turn_busy {
|
||||||
count += 1;
|
count += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -3306,7 +3349,24 @@ impl SessionManager {
|
|||||||
message.timestamp,
|
message.timestamp,
|
||||||
);
|
);
|
||||||
match active.steering.try_push_user(input) {
|
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(_) => {
|
Err(_) => {
|
||||||
active
|
active
|
||||||
.recovery
|
.recovery
|
||||||
@ -3350,7 +3410,7 @@ impl SessionManager {
|
|||||||
guard.agent_tx.is_none() || guard.agent_tx.as_ref().is_some_and(|tx| tx.is_closed());
|
guard.agent_tx.is_none() || guard.agent_tx.as_ref().is_some_and(|tx| tx.is_closed());
|
||||||
if needs_spawn {
|
if needs_spawn {
|
||||||
guard.agent_tx = None;
|
guard.agent_tx = None;
|
||||||
guard.current_cancel = None;
|
guard.turn_busy = false;
|
||||||
guard.current_turn_token = None;
|
guard.current_turn_token = None;
|
||||||
guard.worker_generation = guard.worker_generation.wrapping_add(1);
|
guard.worker_generation = guard.worker_generation.wrapping_add(1);
|
||||||
let generation = guard.worker_generation;
|
let generation = guard.worker_generation;
|
||||||
@ -3381,7 +3441,7 @@ impl SessionManager {
|
|||||||
// worker under the same lock and retry the recovered task once.
|
// worker under the same lock and retry the recovered task once.
|
||||||
let task = error.into_inner();
|
let task = error.into_inner();
|
||||||
guard.agent_tx = None;
|
guard.agent_tx = None;
|
||||||
guard.current_cancel = None;
|
guard.turn_busy = false;
|
||||||
guard.current_turn_token = None;
|
guard.current_turn_token = None;
|
||||||
guard.worker_generation = guard.worker_generation.wrapping_add(1);
|
guard.worker_generation = guard.worker_generation.wrapping_add(1);
|
||||||
let generation = guard.worker_generation;
|
let generation = guard.worker_generation;
|
||||||
@ -3405,6 +3465,24 @@ impl SessionManager {
|
|||||||
AgentError::Other("agent worker spawn+send failed irrecoverably".to_string())
|
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)
|
Ok(HandleResult::AgentProcessing)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -3692,7 +3770,6 @@ fn spawn_agent_worker(
|
|||||||
mut compressor,
|
mut compressor,
|
||||||
system_prompt_out,
|
system_prompt_out,
|
||||||
base_version,
|
base_version,
|
||||||
cancel_rx,
|
|
||||||
turn_token,
|
turn_token,
|
||||||
turn_controller,
|
turn_controller,
|
||||||
turn_emitter,
|
turn_emitter,
|
||||||
@ -3728,13 +3805,12 @@ fn spawn_agent_worker(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let (cancel_tx, cancel_rx) = oneshot::channel();
|
|
||||||
let turn_token = CancellationToken::new();
|
let turn_token = CancellationToken::new();
|
||||||
|
|
||||||
if guard.worker_generation != worker_gen {
|
if guard.worker_generation != worker_gen {
|
||||||
return; // /stop replaced us
|
return; // /stop replaced us
|
||||||
}
|
}
|
||||||
guard.current_cancel = Some(cancel_tx);
|
guard.turn_busy = true;
|
||||||
guard.current_turn_token = Some(turn_token.clone());
|
guard.current_turn_token = Some(turn_token.clone());
|
||||||
|
|
||||||
// Install the active-turn handle before memory recall and
|
// Install the active-turn handle before memory recall and
|
||||||
@ -3750,10 +3826,12 @@ fn spawn_agent_worker(
|
|||||||
let initial_turn = turn_controller.snapshot();
|
let initial_turn = turn_controller.snapshot();
|
||||||
let steering = TurnMailbox::new_shared();
|
let steering = TurnMailbox::new_shared();
|
||||||
let recovery = StdArc::new(StdMutex::new(HashMap::new()));
|
let recovery = StdArc::new(StdMutex::new(HashMap::new()));
|
||||||
|
let turn_wakeup = crate::agent::steering::TurnWakeupPublisher::new();
|
||||||
guard.active_turn_emitter = Some(ActiveTurnEmitter {
|
guard.active_turn_emitter = Some(ActiveTurnEmitter {
|
||||||
turn_id: initial_turn.id.0.clone(),
|
turn_id: initial_turn.id.0.clone(),
|
||||||
emitter: turn_emitter.clone(),
|
emitter: turn_emitter.clone(),
|
||||||
steering: steering.clone(),
|
steering: steering.clone(),
|
||||||
|
wakeup: turn_wakeup,
|
||||||
recovery: recovery.clone(),
|
recovery: recovery.clone(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -3763,8 +3841,7 @@ fn spawn_agent_worker(
|
|||||||
guard.fresh_context_compressor(),
|
guard.fresh_context_compressor(),
|
||||||
guard.build_system_prompt(&skills_prompt),
|
guard.build_system_prompt(&skills_prompt),
|
||||||
guard.state_version,
|
guard.state_version,
|
||||||
cancel_rx,
|
turn_token.clone(),
|
||||||
turn_token,
|
|
||||||
turn_controller,
|
turn_controller,
|
||||||
turn_emitter,
|
turn_emitter,
|
||||||
turn_receiver,
|
turn_receiver,
|
||||||
@ -3826,7 +3903,7 @@ fn spawn_agent_worker(
|
|||||||
session_id = %guard.id,
|
session_id = %guard.id,
|
||||||
"Session changed while preparing agent history; dropping stale task"
|
"Session changed while preparing agent history; dropping stale task"
|
||||||
);
|
);
|
||||||
guard.current_cancel = None;
|
guard.turn_busy = false;
|
||||||
guard.current_turn_token = None;
|
guard.current_turn_token = None;
|
||||||
let mut released = Vec::new();
|
let mut released = Vec::new();
|
||||||
if guard
|
if guard
|
||||||
@ -3922,7 +3999,7 @@ fn spawn_agent_worker(
|
|||||||
turn_controller.cancel(Some(
|
turn_controller.cancel(Some(
|
||||||
"session changed before model execution".to_string(),
|
"session changed before model execution".to_string(),
|
||||||
));
|
));
|
||||||
guard.current_cancel = None;
|
guard.turn_busy = false;
|
||||||
guard.current_turn_token = None;
|
guard.current_turn_token = None;
|
||||||
drop(guard);
|
drop(guard);
|
||||||
if let Some(storage) = storage {
|
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 pending_turn_deliveries = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||||
let scoped_turn_deliveries = pending_turn_deliveries.clone();
|
let scoped_turn_deliveries = pending_turn_deliveries.clone();
|
||||||
let steering_for_process = steering.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 process_gate = execution_gate.clone();
|
||||||
|
let turn_token_for_process = turn_token.clone();
|
||||||
let process_future = async move {
|
let process_future = async move {
|
||||||
let response_session_id = unified_str2.clone();
|
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_turn_id(agent_turn.turn_id.clone())
|
||||||
.with_cancellation(turn_token.clone())
|
.with_cancellation(turn_token_for_process)
|
||||||
.with_execution_gate(process_gate.clone());
|
.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(
|
let process_result = crate::agent::sub_agent::DELEGATE_CONTEXT.scope(
|
||||||
crate::agent::DelegateContext {
|
crate::agent::DelegateContext {
|
||||||
session_id: unified_str2,
|
session_id: unified_str2,
|
||||||
@ -4341,8 +4429,9 @@ fn spawn_agent_worker(
|
|||||||
|
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
() = process_future => {}
|
() = process_future => {}
|
||||||
_ = cancel_rx => {
|
_ = turn_token.cancelled() => {
|
||||||
// cancelled — current_cancel already taken by /stop
|
// Cancelled by `/stop`, which took and cancelled the
|
||||||
|
// token; the terminal state is persisted here.
|
||||||
persist_cancelled_turn(
|
persist_cancelled_turn(
|
||||||
&turn_controller,
|
&turn_controller,
|
||||||
&session,
|
&session,
|
||||||
@ -4403,16 +4492,11 @@ fn spawn_agent_worker(
|
|||||||
if guard.worker_generation == worker_gen {
|
if guard.worker_generation == worker_gen {
|
||||||
guard.current_turn_token = None;
|
guard.current_turn_token = None;
|
||||||
consecutive_user_turns = consecutive_user_turns.saturating_add(1);
|
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
|
// Keep the session observable as busy while local
|
||||||
// fallback tasks are waiting for their next Turn.
|
// fallback tasks are waiting for their next Turn.
|
||||||
// `/stop` can still invalidate this generation before
|
// `/stop` can still invalidate this generation before
|
||||||
// the worker starts the next task.
|
// the worker starts the next task.
|
||||||
let (cancel_tx, _cancel_rx) = oneshot::channel();
|
guard.turn_busy = !local_tasks.is_empty();
|
||||||
guard.current_cancel = Some(cancel_tx);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}).await;
|
}).await;
|
||||||
@ -4692,8 +4776,25 @@ impl crate::agent::AgentInboxWakeTarget for SessionManager {
|
|||||||
// admission; everything that cannot be admitted stays pending
|
// admission; everything that cannot be admitted stays pending
|
||||||
// for the queue lane.
|
// for the queue lane.
|
||||||
if has_active_turn {
|
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;
|
.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;
|
let mut guard = session.lock().await;
|
||||||
@ -4732,9 +4833,11 @@ impl SessionManager {
|
|||||||
session: &Arc<Mutex<Session>>,
|
session: &Arc<Mutex<Session>>,
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
revision: i64,
|
revision: i64,
|
||||||
) {
|
) -> SteerAdmission {
|
||||||
let storage = { session.lock().await.storage.clone() };
|
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 now = chrono::Utc::now().timestamp_millis();
|
||||||
let Ok(Some(lease)) = crate::storage::Storage::claim_inbox_batch(
|
let Ok(Some(lease)) = crate::storage::Storage::claim_inbox_batch(
|
||||||
&storage,
|
&storage,
|
||||||
@ -4747,7 +4850,7 @@ impl SessionManager {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
else {
|
else {
|
||||||
return;
|
return SteerAdmission::NothingClaimed;
|
||||||
};
|
};
|
||||||
let token = lease.token.clone();
|
let token = lease.token.clone();
|
||||||
|
|
||||||
@ -4765,16 +4868,20 @@ impl SessionManager {
|
|||||||
.collect();
|
.collect();
|
||||||
release_steer_leases(&storage, leases).await;
|
release_steer_leases(&storage, leases).await;
|
||||||
let _ = session.lock().await.agent_inbox_wake.send_replace(revision);
|
let _ = session.lock().await.agent_inbox_wake.send_replace(revision);
|
||||||
return;
|
return SteerAdmission::ReleasedToQueue;
|
||||||
};
|
};
|
||||||
(active.turn_id.clone(), active.steering.clone())
|
(active.turn_id.clone(), active.steering.clone())
|
||||||
};
|
};
|
||||||
let mut rejected = Vec::new();
|
let mut rejected = Vec::new();
|
||||||
let mut reserved = Vec::new();
|
let mut reserved = Vec::new();
|
||||||
|
let mut wakeup_sources = Vec::new();
|
||||||
for event in &lease.events {
|
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()) {
|
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())),
|
Err(_) => rejected.push((event.id.clone(), token.clone())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -4806,13 +4913,33 @@ impl SessionManager {
|
|||||||
};
|
};
|
||||||
if still_active {
|
if still_active {
|
||||||
mailbox.activate_reserved();
|
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);
|
rejected.extend(admitted);
|
||||||
release_steer_leases(&storage, rejected).await;
|
release_steer_leases(&storage, rejected).await;
|
||||||
let _ = session.lock().await.agent_inbox_wake.send_replace(revision);
|
let _ = session.lock().await.agent_inbox_wake.send_replace(revision);
|
||||||
}
|
SteerAdmission::ReleasedToQueue
|
||||||
} else {
|
} else {
|
||||||
release_steer_leases(&storage, rejected).await;
|
release_steer_leases(&storage, rejected).await;
|
||||||
|
SteerAdmission::ReleasedToQueue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -4975,7 +5102,7 @@ fn format_task_notification(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod slash_command_tests {
|
mod slash_command_tests {
|
||||||
use super::{
|
use super::{
|
||||||
AgentTask, SLASH_COMMANDS, pop_lowest_sequence, prepend_pending_steering,
|
AgentTask, SLASH_COMMANDS, Session, pop_lowest_sequence, prepend_pending_steering,
|
||||||
resolve_slash_command,
|
resolve_slash_command,
|
||||||
};
|
};
|
||||||
use crate::agent::steering::{SteeringPushError, TurnInput, TurnMailbox};
|
use crate::agent::steering::{SteeringPushError, TurnInput, TurnMailbox};
|
||||||
@ -5161,4 +5288,92 @@ mod slash_command_tests {
|
|||||||
assert!(mailbox.is_closed());
|
assert!(mailbox.is_closed());
|
||||||
assert!(mailbox.take_pending().is_empty());
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1150,6 +1150,7 @@ pub(crate) async fn insert_completion_event_tx(
|
|||||||
session_id: &str,
|
session_id: &str,
|
||||||
status: &str,
|
status: &str,
|
||||||
error: Option<&str>,
|
error: Option<&str>,
|
||||||
|
signal_ids: &[String],
|
||||||
now: i64,
|
now: i64,
|
||||||
) -> Result<(), StorageError> {
|
) -> Result<(), StorageError> {
|
||||||
let Some(revision) = convert_reservation_tx(tx, session_id, now).await? else {
|
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!({
|
payload_json: serde_json::json!({
|
||||||
"status": status,
|
"status": status,
|
||||||
"error": error,
|
"error": error,
|
||||||
|
"signal_ids": signal_ids,
|
||||||
})
|
})
|
||||||
.to_string(),
|
.to_string(),
|
||||||
};
|
};
|
||||||
@ -1270,6 +1272,8 @@ mod tests {
|
|||||||
task: "work".to_string(),
|
task: "work".to_string(),
|
||||||
context_json: None,
|
context_json: None,
|
||||||
budget_json: "{}".to_string(),
|
budget_json: "{}".to_string(),
|
||||||
|
signal_contract_json: None,
|
||||||
|
signal_delivery: None,
|
||||||
deadline_at: 1000,
|
deadline_at: 1000,
|
||||||
runtime_generation: 1,
|
runtime_generation: 1,
|
||||||
completion_slot_reserved: false,
|
completion_slot_reserved: false,
|
||||||
@ -1685,6 +1689,8 @@ mod tests {
|
|||||||
task: "work".to_string(),
|
task: "work".to_string(),
|
||||||
context_json: None,
|
context_json: None,
|
||||||
budget_json: "{}".to_string(),
|
budget_json: "{}".to_string(),
|
||||||
|
signal_contract_json: None,
|
||||||
|
signal_delivery: None,
|
||||||
deadline_at: 1000,
|
deadline_at: 1000,
|
||||||
runtime_generation: 1,
|
runtime_generation: 1,
|
||||||
completion_slot_reserved: false,
|
completion_slot_reserved: false,
|
||||||
|
|||||||
@ -395,6 +395,8 @@ pub struct NewAgentRun {
|
|||||||
pub task: String,
|
pub task: String,
|
||||||
pub context_json: Option<String>,
|
pub context_json: Option<String>,
|
||||||
pub budget_json: String,
|
pub budget_json: String,
|
||||||
|
pub signal_contract_json: Option<String>,
|
||||||
|
pub signal_delivery: Option<String>,
|
||||||
pub deadline_at: i64,
|
pub deadline_at: i64,
|
||||||
pub runtime_generation: i64,
|
pub runtime_generation: i64,
|
||||||
/// Background runs reserve a completion slot at admission so their
|
/// Background runs reserve a completion slot at admission so their
|
||||||
@ -448,21 +450,28 @@ pub enum AgentTerminalOutcome {
|
|||||||
cost: Option<f64>,
|
cost: Option<f64>,
|
||||||
tool_calls: i64,
|
tool_calls: i64,
|
||||||
iterations: 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 {
|
Failed {
|
||||||
error: String,
|
error: String,
|
||||||
prompt_tokens: Option<i64>,
|
prompt_tokens: Option<i64>,
|
||||||
completion_tokens: Option<i64>,
|
completion_tokens: Option<i64>,
|
||||||
cost: Option<f64>,
|
cost: Option<f64>,
|
||||||
|
signal_ids: Vec<String>,
|
||||||
},
|
},
|
||||||
TimedOut {
|
TimedOut {
|
||||||
deadline_at: i64,
|
deadline_at: i64,
|
||||||
|
signal_ids: Vec<String>,
|
||||||
},
|
},
|
||||||
Cancelled {
|
Cancelled {
|
||||||
reason: String,
|
reason: String,
|
||||||
|
signal_ids: Vec<String>,
|
||||||
},
|
},
|
||||||
Interrupted {
|
Interrupted {
|
||||||
reason: String,
|
reason: String,
|
||||||
|
signal_ids: Vec<String>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -628,10 +637,11 @@ impl super::Storage {
|
|||||||
parent_run_id, caller_agent_id, caller_scope_id, idempotency_key, \
|
parent_run_id, caller_agent_id, caller_scope_id, idempotency_key, \
|
||||||
agent_id, definition_hash, provider_profile, provider_name, model_id, \
|
agent_id, definition_hash, provider_profile, provider_name, model_id, \
|
||||||
mode, depth, plan_item_id, execution_id, task, context_json, budget_json, \
|
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, \
|
status, runtime_generation, attempt, completion_slot_reserved, deadline_at, \
|
||||||
revision, created_at, updated_at) \
|
revision, created_at, updated_at) \
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, \
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, \
|
||||||
'queued', ?, 1, ?, ?, 0, ?, ?)",
|
?, ?, 'queued', ?, 1, ?, ?, 0, ?, ?)",
|
||||||
)
|
)
|
||||||
.bind(&run.id)
|
.bind(&run.id)
|
||||||
.bind(request.group.as_ref().map(|group| group.id.clone()))
|
.bind(request.group.as_ref().map(|group| group.id.clone()))
|
||||||
@ -653,6 +663,8 @@ impl super::Storage {
|
|||||||
.bind(&run.task)
|
.bind(&run.task)
|
||||||
.bind(&run.context_json)
|
.bind(&run.context_json)
|
||||||
.bind(&run.budget_json)
|
.bind(&run.budget_json)
|
||||||
|
.bind(&run.signal_contract_json)
|
||||||
|
.bind(&run.signal_delivery)
|
||||||
.bind(run.runtime_generation)
|
.bind(run.runtime_generation)
|
||||||
.bind(i64::from(run.completion_slot_reserved))
|
.bind(i64::from(run.completion_slot_reserved))
|
||||||
.bind(run.deadline_at)
|
.bind(run.deadline_at)
|
||||||
@ -1052,6 +1064,7 @@ impl super::Storage {
|
|||||||
cost,
|
cost,
|
||||||
tool_calls,
|
tool_calls,
|
||||||
iterations,
|
iterations,
|
||||||
|
..
|
||||||
} => (
|
} => (
|
||||||
Some(result.as_str()),
|
Some(result.as_str()),
|
||||||
None,
|
None,
|
||||||
@ -1066,6 +1079,7 @@ impl super::Storage {
|
|||||||
prompt_tokens,
|
prompt_tokens,
|
||||||
completion_tokens,
|
completion_tokens,
|
||||||
cost,
|
cost,
|
||||||
|
..
|
||||||
} => (
|
} => (
|
||||||
None,
|
None,
|
||||||
Some(error.as_str()),
|
Some(error.as_str()),
|
||||||
@ -1078,10 +1092,10 @@ impl super::Storage {
|
|||||||
AgentTerminalOutcome::TimedOut { .. } => {
|
AgentTerminalOutcome::TimedOut { .. } => {
|
||||||
(None, Some("deadline exceeded"), None, None, None, 0, 0)
|
(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)
|
(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)
|
(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.
|
// reservation into a durable completion event in the same commit.
|
||||||
// The event survives restarts, queue-full conditions and lost wakes.
|
// The event survives restarts, queue-full conditions and lost wakes.
|
||||||
if run.completion_slot_reserved {
|
if run.completion_slot_reserved {
|
||||||
let (status, error) = match outcome {
|
let (status, error, signal_ids) = match outcome {
|
||||||
AgentTerminalOutcome::Completed { .. } => ("completed", None),
|
AgentTerminalOutcome::Completed { signal_ids, .. } => {
|
||||||
AgentTerminalOutcome::Failed { error, .. } => ("failed", Some(error.as_str())),
|
("completed", None, signal_ids.as_slice())
|
||||||
AgentTerminalOutcome::TimedOut { .. } => ("timed_out", Some("deadline exceeded")),
|
}
|
||||||
AgentTerminalOutcome::Cancelled { reason } => ("cancelled", Some(reason.as_str())),
|
AgentTerminalOutcome::Failed {
|
||||||
AgentTerminalOutcome::Interrupted { reason } => {
|
error, signal_ids, ..
|
||||||
("interrupted", Some(reason.as_str()))
|
} => ("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(
|
super::agent_inbox::insert_completion_event_tx(
|
||||||
@ -1197,6 +1221,7 @@ impl super::Storage {
|
|||||||
&run.root_session_id,
|
&run.root_session_id,
|
||||||
status,
|
status,
|
||||||
error,
|
error,
|
||||||
|
signal_ids,
|
||||||
now,
|
now,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@ -1335,6 +1360,8 @@ mod tests {
|
|||||||
task: "do the work".to_string(),
|
task: "do the work".to_string(),
|
||||||
context_json: None,
|
context_json: None,
|
||||||
budget_json: "{\"remaining_runs\":15}".to_string(),
|
budget_json: "{\"remaining_runs\":15}".to_string(),
|
||||||
|
signal_contract_json: None,
|
||||||
|
signal_delivery: None,
|
||||||
deadline_at: 1_000,
|
deadline_at: 1_000,
|
||||||
runtime_generation: 1,
|
runtime_generation: 1,
|
||||||
completion_slot_reserved: false,
|
completion_slot_reserved: false,
|
||||||
@ -1429,6 +1456,7 @@ mod tests {
|
|||||||
cost: None,
|
cost: None,
|
||||||
tool_calls: 1,
|
tool_calls: 1,
|
||||||
iterations: 2,
|
iterations: 2,
|
||||||
|
signal_ids: Vec::new(),
|
||||||
},
|
},
|
||||||
None,
|
None,
|
||||||
120,
|
120,
|
||||||
@ -1449,6 +1477,7 @@ mod tests {
|
|||||||
prompt_tokens: None,
|
prompt_tokens: None,
|
||||||
completion_tokens: None,
|
completion_tokens: None,
|
||||||
cost: None,
|
cost: None,
|
||||||
|
signal_ids: Vec::new(),
|
||||||
},
|
},
|
||||||
None,
|
None,
|
||||||
130,
|
130,
|
||||||
@ -1489,6 +1518,7 @@ mod tests {
|
|||||||
cost: None,
|
cost: None,
|
||||||
tool_calls: 0,
|
tool_calls: 0,
|
||||||
iterations: 0,
|
iterations: 0,
|
||||||
|
signal_ids: Vec::new(),
|
||||||
},
|
},
|
||||||
None,
|
None,
|
||||||
120,
|
120,
|
||||||
@ -1602,6 +1632,7 @@ mod tests {
|
|||||||
cost: None,
|
cost: None,
|
||||||
tool_calls: 0,
|
tool_calls: 0,
|
||||||
iterations: 0,
|
iterations: 0,
|
||||||
|
signal_ids: Vec::new(),
|
||||||
},
|
},
|
||||||
Some("finished the work"),
|
Some("finished the work"),
|
||||||
120,
|
120,
|
||||||
|
|||||||
@ -240,10 +240,25 @@ impl DelegateTool {
|
|||||||
"mixed named and legacy general batches are not supported",
|
"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)
|
.run_foreground_batch(configs, context)
|
||||||
.await
|
.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
|
let payload: Vec<_> = results
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|||||||
@ -254,8 +254,6 @@ impl Tool for EmitSignalTool {
|
|||||||
if let Ok(mut signals) = agent.emitted_signals.lock() {
|
if let Ok(mut signals) = agent.emitted_signals.lock() {
|
||||||
signals.push(EmittedSignal {
|
signals.push(EmittedSignal {
|
||||||
signal_id: accepted.signal_id.clone(),
|
signal_id: accepted.signal_id.clone(),
|
||||||
severity: args.severity.clone(),
|
|
||||||
summary: args.summary.clone(),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,8 @@ use async_trait::async_trait;
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::agent::steering::{TurnWakeupState, WakeupSource};
|
||||||
|
|
||||||
const MAX_SLEEP_SECONDS: u64 = 86_400;
|
const MAX_SLEEP_SECONDS: u64 = 86_400;
|
||||||
|
|
||||||
pub struct SleepTool;
|
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! {
|
tokio::select! {
|
||||||
biased;
|
biased;
|
||||||
_ = context.cancellation.cancelled() => {
|
_ = context.cancellation.cancelled() => {
|
||||||
anyhow::bail!("sleep 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 {
|
Ok(ToolResult {
|
||||||
success: true,
|
success: true,
|
||||||
output: format!("Slept for {seconds} second(s)."),
|
output,
|
||||||
error: None,
|
error: None,
|
||||||
}
|
}
|
||||||
.into())
|
.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 AgentSignal(run_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 AgentCompletion(run_id={run_id}, agent={agent_id}),将在当前 Turn 的下一个安全边界注入。"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Some(WakeupSource::AgentGroupCompletion { group_id }) => {
|
||||||
|
message.push_str(&format!(
|
||||||
|
" 收到一条 steer AgentGroupCompletion(group={group_id}),将在当前 Turn 的下一个安全边界注入。"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Some(WakeupSource::AgentQueue) | None => {
|
||||||
|
message.push_str(&format!(
|
||||||
|
" 收到 {} 条排队输入。内容不会进入当前 Turn,将在当前工作结束后的下一 Turn处理。",
|
||||||
|
state.pending_agent_queue.max(1)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
message
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::agent::TurnEvent;
|
use crate::agent::TurnEvent;
|
||||||
|
use crate::agent::steering::TurnWakeupPublisher;
|
||||||
use crate::providers::ToolCall;
|
use crate::providers::ToolCall;
|
||||||
use crate::session::{ToolStatus, TurnBlock, TurnController, TurnStatus};
|
use crate::session::{ToolStatus, TurnBlock, TurnController, TurnStatus};
|
||||||
use crate::tools::Tool;
|
use crate::tools::Tool;
|
||||||
@ -135,6 +242,10 @@ mod tests {
|
|||||||
assert!(!tool.read_only());
|
assert!(!tool.read_only());
|
||||||
assert!(!tool.concurrency_safe());
|
assert!(!tool.concurrency_safe());
|
||||||
assert!(!tool.exclusive());
|
assert!(!tool.exclusive());
|
||||||
|
assert_eq!(
|
||||||
|
tool.input_interrupt_policy(),
|
||||||
|
crate::tools::InputInterruptPolicy::WakeOnly
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@ -289,4 +400,130 @@ mod tests {
|
|||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(error.to_string().contains("cancelled"));
|
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("排队输入"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,10 @@ pub struct ToolExecutionContext {
|
|||||||
pub agent: Option<std::sync::Arc<crate::agent::AgentExecutionContext>>,
|
pub agent: Option<std::sync::Arc<crate::agent::AgentExecutionContext>>,
|
||||||
pub cancellation: tokio_util::sync::CancellationToken,
|
pub cancellation: tokio_util::sync::CancellationToken,
|
||||||
pub execution_gate: Option<std::sync::Arc<crate::agent::gate::ExecutionGate>>,
|
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 {
|
impl Default for ToolExecutionContext {
|
||||||
@ -20,6 +24,7 @@ impl Default for ToolExecutionContext {
|
|||||||
agent: None,
|
agent: None,
|
||||||
cancellation: tokio_util::sync::CancellationToken::new(),
|
cancellation: tokio_util::sync::CancellationToken::new(),
|
||||||
execution_gate: None,
|
execution_gate: None,
|
||||||
|
turn_wakeup: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -32,6 +37,7 @@ impl ToolExecutionContext {
|
|||||||
agent: None,
|
agent: None,
|
||||||
cancellation: tokio_util::sync::CancellationToken::new(),
|
cancellation: tokio_util::sync::CancellationToken::new(),
|
||||||
execution_gate: None,
|
execution_gate: None,
|
||||||
|
turn_wakeup: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -60,6 +66,11 @@ impl ToolExecutionContext {
|
|||||||
self.execution_gate = Some(gate);
|
self.execution_gate = Some(gate);
|
||||||
self
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
|||||||
4
webui/package-lock.json
generated
4
webui/package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "picobot-webui",
|
"name": "picobot-webui",
|
||||||
"version": "1.7.0",
|
"version": "1.8.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picobot-webui",
|
"name": "picobot-webui",
|
||||||
"version": "1.7.0",
|
"version": "1.8.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bits-ui": "^2.0.0",
|
"bits-ui": "^2.0.0",
|
||||||
"dompurify": "^3.4.12",
|
"dompurify": "^3.4.12",
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "picobot-webui",
|
"name": "picobot-webui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.7.0",
|
"version": "1.8.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user