- AgentCatalog/definitions with strict Markdown frontmatter, delegation graph, fail-closed tool scoping, and signal contracts - structured cancellation (AgentError::Cancelled/TimedOut) across provider streams, tool batches, and sleep; /stop drives the same terminal state - schema v6 run/group/inbox persistence with execution-ID conditional transitions and completion-slot reservations - ExecutionGate separating run quota from provider/tool step permits - background completion inbox with hidden-trigger continuation turns, fairness scheduling, lease release, dead-lettering, and activation recovery - typed TurnMailbox with two-phase steer admission and atomic consumption at turn commit; /stop releases admitted steer events back to pending - emit_signal tool with contract-enforced rate/dedupe/severity/size limits - WS run/event projection (GetAgentRuns, AgentRunUpdated, AgentEventUpdated), /api/agent-runs* management endpoints, /api/tasks union, WebUI run tree and signal cards - ChannelContext.durable_private persisted for continuation delivery reuse Version 1.7.0
355 lines
12 KiB
Rust
355 lines
12 KiB
Rust
//! Contract-bound `emit_signal` tool for background Agents.
|
|
//!
|
|
//! The tool only exists inside a run whose definition declares a `signal`
|
|
//! contract. Every limit (total count, rate, burst, severity allowlist,
|
|
//! payload size/depth, dedupe cooldown) is enforced here and in the
|
|
//! Coordinator; the model only supplies `key`, `severity`, `summary`,
|
|
//! `details` and an optional `dedupe_key`. Signals are durable inbox events
|
|
//! delivered to the run's root session lane (queue or steer per contract).
|
|
//! The tool returns only after the event is persisted.
|
|
|
|
use std::collections::VecDeque;
|
|
use std::sync::Mutex;
|
|
|
|
use serde::Deserialize;
|
|
use serde_json::Value;
|
|
|
|
use crate::agent::coordinator::AgentCoordinator;
|
|
use crate::agent::definition::SignalContract;
|
|
use crate::agent::run::{AgentExecutionContext, EmittedSignal};
|
|
use crate::storage::agent_inbox::{AgentEventDelivery, AgentEventType, NewInboxEvent};
|
|
use crate::tools::{DelegationPolicy, Tool, ToolExecutionContext, ToolOutput, ToolResult};
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct SignalInput {
|
|
pub key: String,
|
|
pub severity: String,
|
|
pub summary: String,
|
|
pub details: Option<Value>,
|
|
pub dedupe_key: Option<String>,
|
|
/// Durable event key computed by the tool (dedupe window included).
|
|
pub event_key: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum SignalAcceptedStatus {
|
|
Accepted,
|
|
Deduplicated,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct SignalAccepted {
|
|
pub signal_id: String,
|
|
pub status: SignalAcceptedStatus,
|
|
pub delivery: AgentEventDelivery,
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
struct SignalRateState {
|
|
total: u32,
|
|
last_at_ms: i64,
|
|
burst_times: VecDeque<i64>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct EmitSignalArgs {
|
|
pub key: String,
|
|
pub severity: String,
|
|
pub summary: String,
|
|
#[serde(default)]
|
|
pub details: Option<Value>,
|
|
#[serde(default)]
|
|
pub dedupe_key: Option<String>,
|
|
}
|
|
|
|
use std::sync::Arc;
|
|
|
|
pub struct EmitSignalTool {
|
|
coordinator: Arc<AgentCoordinator>,
|
|
contract: Arc<SignalContract>,
|
|
rate: Mutex<SignalRateState>,
|
|
}
|
|
|
|
impl EmitSignalTool {
|
|
pub fn new(coordinator: Arc<AgentCoordinator>, contract: Arc<SignalContract>) -> Self {
|
|
Self {
|
|
coordinator,
|
|
contract,
|
|
rate: Mutex::new(SignalRateState::default()),
|
|
}
|
|
}
|
|
}
|
|
|
|
const MAX_KEY_CHARS: usize = 128;
|
|
const MAX_DEDUPE_KEY_CHARS: usize = 128;
|
|
const MAX_SUMMARY_CHARS: usize = 1024;
|
|
|
|
#[async_trait::async_trait]
|
|
impl Tool for EmitSignalTool {
|
|
fn name(&self) -> &str {
|
|
"emit_signal"
|
|
}
|
|
|
|
fn description(&self) -> &str {
|
|
"向主 Agent 发送一条结构化内部信号,用于重要中间状态或监控告警;普通进度请留在工具调用记录里,不要滥用信号。"
|
|
}
|
|
|
|
fn parameters_schema(&self) -> serde_json::Value {
|
|
serde_json::json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"key": { "type": "string", "description": "信号键,用于区分不同信号" },
|
|
"severity": { "type": "string", "description": "严重级别" },
|
|
"summary": { "type": "string", "description": "简短摘要" },
|
|
"details": { "type": "object", "description": "结构化详情" },
|
|
"dedupe_key": { "type": "string", "description": "去重键(可选)" }
|
|
},
|
|
"required": ["key", "severity", "summary"]
|
|
})
|
|
}
|
|
|
|
async fn execute(&self, _args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
|
Ok(ToolResult {
|
|
success: false,
|
|
output: String::new(),
|
|
error: Some("emit_signal requires a run-bound context".to_string()),
|
|
})
|
|
}
|
|
|
|
fn delegation_policy(&self) -> DelegationPolicy {
|
|
DelegationPolicy::RuntimeInjected
|
|
}
|
|
|
|
async fn execute_with_context(
|
|
&self,
|
|
context: &ToolExecutionContext,
|
|
args: Value,
|
|
) -> anyhow::Result<ToolOutput> {
|
|
let args: EmitSignalArgs = match serde_json::from_value(args) {
|
|
Ok(args) => args,
|
|
Err(error) => {
|
|
return Ok(ToolResult {
|
|
success: false,
|
|
output: String::new(),
|
|
error: Some(error.to_string()),
|
|
}
|
|
.into());
|
|
}
|
|
};
|
|
let contract = self.contract.clone();
|
|
let coordinator = self.coordinator.clone();
|
|
let rate = &self.rate;
|
|
let context = context.clone();
|
|
let result: Result<ToolOutput, String> = async move {
|
|
let Some(agent) = context.agent.as_deref() else {
|
|
return Err("emit_signal requires a run-bound Agent context".to_string());
|
|
};
|
|
let now_ms = chrono::Utc::now().timestamp_millis();
|
|
|
|
// Structural validation against the contract, independent of the
|
|
// model's cooperation.
|
|
if args.key.trim().is_empty() || args.key.len() > MAX_KEY_CHARS {
|
|
return Err(format!("key must contain 1..={MAX_KEY_CHARS} characters"));
|
|
}
|
|
if let Some(dedupe) = args.dedupe_key.as_deref()
|
|
&& (dedupe.trim().is_empty() || dedupe.len() > MAX_DEDUPE_KEY_CHARS)
|
|
{
|
|
return Err(format!(
|
|
"dedupe_key must contain 1..={MAX_DEDUPE_KEY_CHARS} characters"
|
|
));
|
|
}
|
|
if !contract
|
|
.severity_allowlist
|
|
.iter()
|
|
.any(|allowed| allowed == &args.severity)
|
|
{
|
|
return Err(format!(
|
|
"severity '{0}' is not allowed; allowlist: {1}",
|
|
args.severity,
|
|
contract.severity_allowlist.join(", ")
|
|
));
|
|
}
|
|
if args.summary.trim().is_empty() || args.summary.chars().count() > MAX_SUMMARY_CHARS {
|
|
return Err(format!(
|
|
"summary must contain 1..={MAX_SUMMARY_CHARS} characters"
|
|
));
|
|
}
|
|
if let Some(details) = args.details.as_ref() {
|
|
let bytes = details.to_string().len();
|
|
if bytes > contract.max_details_bytes {
|
|
return Err(format!(
|
|
"details exceed the {} byte contract limit",
|
|
contract.max_details_bytes
|
|
));
|
|
}
|
|
if json_depth(details) > contract.max_payload_depth {
|
|
return Err(format!(
|
|
"details exceed the {} level depth limit",
|
|
contract.max_payload_depth
|
|
));
|
|
}
|
|
}
|
|
|
|
// Per-run rate limits. Rate state is in-memory and per-run: the
|
|
// tool instance is created for exactly one run's registry.
|
|
{
|
|
let mut state = rate.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
if state.total >= contract.max_total {
|
|
return Err(format!(
|
|
"signal limit reached: {} signals for this run",
|
|
contract.max_total
|
|
));
|
|
}
|
|
if state.total > 0 && now_ms - state.last_at_ms < contract.min_interval_ms as i64 {
|
|
return Err(format!(
|
|
"signal rate limited: wait at least {}ms between signals",
|
|
contract.min_interval_ms
|
|
));
|
|
}
|
|
let window_start = now_ms - contract.burst_window_ms as i64;
|
|
while state
|
|
.burst_times
|
|
.front()
|
|
.is_some_and(|time| *time < window_start)
|
|
{
|
|
state.burst_times.pop_front();
|
|
}
|
|
if state.burst_times.len() as u32 >= contract.max_burst {
|
|
return Err(format!(
|
|
"signal burst limited: at most {} signals per {}ms",
|
|
contract.max_burst, contract.burst_window_ms
|
|
));
|
|
}
|
|
}
|
|
|
|
let event_key = match args.dedupe_key.as_deref() {
|
|
Some(key) => {
|
|
let window = now_ms / contract.dedupe_cooldown_ms as i64;
|
|
format!("signal:{key}:{window}")
|
|
}
|
|
None => format!("signal:{}", uuid::Uuid::new_v4()),
|
|
};
|
|
let accepted = coordinator
|
|
.emit_signal(
|
|
agent,
|
|
SignalInput {
|
|
key: args.key.clone(),
|
|
severity: args.severity.clone(),
|
|
summary: args.summary.clone(),
|
|
details: args.details.clone(),
|
|
dedupe_key: args.dedupe_key.clone(),
|
|
event_key,
|
|
},
|
|
)
|
|
.await
|
|
.map_err(|error| error.to_string())?;
|
|
|
|
if accepted.status == SignalAcceptedStatus::Accepted {
|
|
if let Ok(mut state) = rate.lock() {
|
|
state.total = state.total.saturating_add(1);
|
|
state.last_at_ms = now_ms;
|
|
state.burst_times.push_back(now_ms);
|
|
}
|
|
if let Ok(mut signals) = agent.emitted_signals.lock() {
|
|
signals.push(EmittedSignal {
|
|
signal_id: accepted.signal_id.clone(),
|
|
severity: args.severity.clone(),
|
|
summary: args.summary.clone(),
|
|
});
|
|
}
|
|
}
|
|
|
|
let output = serde_json::json!({
|
|
"signal_id": accepted.signal_id,
|
|
"status": if accepted.status == SignalAcceptedStatus::Accepted {
|
|
"accepted"
|
|
} else {
|
|
"deduplicated"
|
|
},
|
|
"delivery": accepted.delivery.as_str(),
|
|
})
|
|
.to_string();
|
|
Ok(ToolOutput {
|
|
result: ToolResult {
|
|
success: true,
|
|
output,
|
|
error: None,
|
|
},
|
|
artifacts: Vec::new(),
|
|
})
|
|
}
|
|
.await;
|
|
result.map_err(|error| anyhow::anyhow!(error))
|
|
}
|
|
}
|
|
|
|
/// Maximum JSON nesting depth, counting objects/arrays.
|
|
fn json_depth(value: &Value) -> usize {
|
|
match value {
|
|
Value::Object(map) => 1 + map.values().map(json_depth).max().unwrap_or(0),
|
|
Value::Array(items) => 1 + items.iter().map(json_depth).max().unwrap_or(0),
|
|
_ => 0,
|
|
}
|
|
}
|
|
|
|
/// Helper for tests and non-coordinator embedders: build the durable inbox
|
|
/// event descriptor for a signal without enforcing runtime state.
|
|
pub fn build_signal_event(
|
|
context: &AgentExecutionContext,
|
|
input: &SignalInput,
|
|
event_id: String,
|
|
delivery: AgentEventDelivery,
|
|
) -> NewInboxEvent {
|
|
NewInboxEvent {
|
|
id: event_id,
|
|
root_session_id: context.root_session_id.clone(),
|
|
scope_kind: "run".to_string(),
|
|
scope_id: context.run_id.clone(),
|
|
run_id: Some(context.run_id.clone()),
|
|
group_id: context.group_id.clone(),
|
|
event_type: AgentEventType::Signal,
|
|
event_key: input.event_key.clone(),
|
|
delivery,
|
|
requires_continuation: true,
|
|
severity: Some(input.severity.clone()),
|
|
payload_json: serde_json::json!({
|
|
"kind": "signal",
|
|
"severity": input.severity,
|
|
"summary": input.summary,
|
|
"details": input.details,
|
|
"dedupe_key": input.dedupe_key,
|
|
"key": input.key,
|
|
"run_id": context.run_id,
|
|
"agent_id": context.current_agent_id,
|
|
})
|
|
.to_string(),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn details_depth_is_measured() {
|
|
assert_eq!(json_depth(&Value::Null), 0);
|
|
assert_eq!(json_depth(&Value::String("x".into())), 0);
|
|
assert_eq!(
|
|
json_depth(&serde_json::json!({"a": {"b": [1, {"c": 2}]}})),
|
|
4
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn default_contract_limits_are_sane() {
|
|
let contract = SignalContract::default();
|
|
assert_eq!(contract.max_burst, 5);
|
|
assert!(
|
|
contract
|
|
.severity_allowlist
|
|
.contains(&"critical".to_string())
|
|
);
|
|
}
|
|
}
|