feat: add agent-sdk and agent-types modules with initial implementations
ZhenYi committed
Added lib/agent-sdk/examples/prod_probe.rs lib/agent-sdk/examples/prod_probe.rs +64 −0 View lib/agent-sdk/examples/prod_probe.rs
@@ -0,0 +1,64 @@
1//! 生产连通性探测(Rust SDK 全路径):
2//! AdminClient 建会话 → 签发会话令牌 → Client 读会话/消息 → 订阅会话 SSE → 发消息触发 Run → 取消。
3//!
4//! 用法:cargo run -p agent-sdk --example prod_probe -- <base_url> <admin_key> <tenant_id>
5
6use std::time::Duration;
7
8use agent_sdk::{AdminClient, Client};
9use agent_types::ids::TenantId;
10
11#[tokio::main]
12async fn main() -> Result<(), Box<dyn std::error::Error>> {
13 let mut args = std::env::args().skip(1);
14 let usage = "用法: prod_probe <base_url> <admin_key> <tenant_id>";
15 let base = args.next().ok_or(usage)?;
16 let admin_key = args.next().ok_or(usage)?;
17 let tenant: TenantId = args.next().ok_or(usage)?.parse()?;
18
19 let admin = AdminClient::new(&base, admin_key)?;
20
21 println!("[1] admin 建会话 POST /api/v1/admin/sessions");
22 let session = admin.create_session(None, Some("sdk 连通性探测"), None).await?;
23 println!(" OK session_id={}", session.session_id);
24
25 println!("[2] admin 签发会话令牌 POST /api/v1/admin/sessions/{{id}}/tokens");
26 let issued =
27 admin.issue_session_token(session.session_id, tenant, "sdk-probe", Some(600)).await?;
28 println!(
29 " OK token_len={} expires_in={}s grant={}",
30 issued.token.len(),
31 issued.expires_in_secs,
32 issued.grant_id
33 );
34
35 let client = Client::new(&base, issued.token.to_string(), session.session_id)?;
36
37 println!("[3] 业务面 GET /api/v1/sessions/{{id}}");
38 let got = client.get_session().await?;
39 println!(" OK title={:?} created_at={}", got.title, got.created_at_millis);
40
41 println!("[4] 业务面 GET /api/v1/sessions/{{id}}/messages");
42 let page = client.list_messages(Some(10), None).await?;
43 println!(" OK messages={} has_more={}", page.items.len(), page.next_cursor.is_some());
44
45 println!("[5] 订阅 SSE GET /api/v1/sessions/{{id}}/events(等首帧,最长 8s)");
46 let mut stream = client.stream_events(None).await?;
47 match tokio::time::timeout(Duration::from_secs(8), stream.next()).await {
48 Ok(Some(Ok(ev))) => println!(" OK 首帧 #1: {ev:?}"),
49 Ok(Some(Err(e))) => return Err(format!("SSE 错误: {e}").into()),
50 Ok(None) => println!(" 流结束(无帧)"),
51 Err(_) => println!(" 8s 内无帧(会话空闲,订阅通道已建立即视为连通)"),
52 }
53
54 println!("[6] 业务面 POST /api/v1/sessions/{{id}}/messages(触发 Run)");
55 let accepted = client.send_message("连通性探测:请只回复 OK 两个字。", None).await?;
56 println!(" OK run_id={} state={:?}", accepted.run_id, accepted.state);
57
58 println!("[7] 业务面 POST /api/v1/runs/{{id}}/cancel");
59 let cancelled = client.cancel_run(accepted.run_id).await?;
60 println!(" OK state={:?}", cancelled.state);
61
62 println!("\n结果: Rust SDK 生产连通性 7/7 通过 ✅");
63 Ok(())
64}
Added lib/agent-sdk/src/admin.rs lib/agent-sdk/src/admin.rs +192 −0 View lib/agent-sdk/src/admin.rs
@@ -0,0 +1,192 @@
1use agent_types::api::{Page, RunDto};
2use agent_types::ids::{SessionId, TenantId};
3use agent_types::session::{
4 CreateSessionRequest, CreateSessionTokenRequest, MessageDto, SessionDto, SessionTokenDto,
5};
6use reqwest::Method;
7use smol_str::SmolStr;
8
9use crate::error::SdkError;
10use crate::normalize_base;
11
12#[derive(Clone)]
13pub struct AdminClient {
14 http: reqwest::Client,
15 base: String,
16 key: String,
17}
18
19impl std::fmt::Debug for AdminClient {
20 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21 f.debug_struct("AdminClient").field("base", &self.base).finish_non_exhaustive()
22 }
23}
24
25impl AdminClient {
26 pub fn new(base_url: &str, admin_key: impl Into<String>) -> Result<Self, SdkError> {
27 let base = normalize_base(base_url);
28 if base.is_empty() {
29 return Err(SdkError::Invalid("base_url 不能为空".into()));
30 }
31 let key = admin_key.into();
32 if key.trim().is_empty() {
33 return Err(SdkError::Invalid("admin_key 不能为空".into()));
34 }
35 let http =
36 reqwest::Client::builder().build().map_err(|e| SdkError::Transport(e.to_string()))?;
37 Ok(Self { http, base, key })
38 }
39
40 fn url(&self, path: &str) -> String {
41 format!("{}{path}", self.base)
42 }
43
44 async fn send_json<T: serde::de::DeserializeOwned>(
45 &self,
46 method: Method,
47 path: &str,
48 body: Option<serde_json::Value>,
49 ) -> Result<T, SdkError> {
50 let mut req = self
51 .http
52 .request(method, self.url(path))
53 .header("authorization", format!("Bearer {}", self.key));
54 if let Some(body) = body {
55 req = req.json(&body);
56 }
57 let res = req.send().await.map_err(|e| SdkError::Transport(e.to_string()))?;
58 let status = res.status().as_u16();
59 let text = res.text().await.map_err(|e| SdkError::Transport(e.to_string()))?;
60 if !(200..300).contains(&status) {
61 return Err(SdkError::from_response(status, &text));
62 }
63 serde_json::from_str(&text).map_err(|e| SdkError::Decode(e.to_string()))
64 }
65
66 async fn send_empty(&self, method: Method, path: &str) -> Result<(), SdkError> {
67 let res = self
68 .http
69 .request(method, self.url(path))
70 .header("authorization", format!("Bearer {}", self.key))
71 .send()
72 .await
73 .map_err(|e| SdkError::Transport(e.to_string()))?;
74 let status = res.status().as_u16();
75 if (200..300).contains(&status) {
76 return Ok(());
77 }
78 let text = res.text().await.unwrap_or_default();
79 Err(SdkError::from_response(status, &text))
80 }
81
82 pub async fn create_session(
83 &self,
84 session_id: Option<SessionId>,
85 title: Option<&str>,
86 metadata: Option<serde_json::Value>,
87 ) -> Result<SessionDto, SdkError> {
88 let body = serde_json::to_value(CreateSessionRequest {
89 session_id,
90 title: title.map(SmolStr::new),
91 metadata,
92 })
93 .map_err(|e| SdkError::Decode(e.to_string()))?;
94 self.send_json(Method::POST, "/api/v1/admin/sessions", Some(body)).await
95 }
96
97 pub async fn list_sessions(
98 &self,
99 limit: Option<u32>,
100 cursor: Option<SessionId>,
101 include_deleted: bool,
102 ) -> Result<Page<SessionDto>, SdkError> {
103 let mut path = format!("/api/v1/admin/sessions?include_deleted={include_deleted}",);
104 if let Some(limit) = limit {
105 path.push_str(&format!("&limit={limit}"));
106 }
107 if let Some(cursor) = cursor {
108 path.push_str(&format!("&cursor={cursor}"));
109 }
110 self.send_json(Method::GET, &path, None).await
111 }
112
113 pub async fn get_session(&self, session_id: SessionId) -> Result<SessionDto, SdkError> {
114 self.send_json(Method::GET, &format!("/api/v1/admin/sessions/{session_id}"), None).await
115 }
116
117 pub async fn delete_session(&self, session_id: SessionId) -> Result<(), SdkError> {
118 self.send_empty(Method::DELETE, &format!("/api/v1/admin/sessions/{session_id}")).await
119 }
120
121 pub async fn list_messages(
122 &self,
123 session_id: SessionId,
124 limit: Option<u32>,
125 cursor: Option<u64>,
126 ) -> Result<Page<MessageDto>, SdkError> {
127 let mut path = format!("/api/v1/admin/sessions/{session_id}/messages?");
128 if let Some(limit) = limit {
129 path.push_str(&format!("limit={limit}&"));
130 }
131 if let Some(cursor) = cursor {
132 path.push_str(&format!("cursor={cursor}&"));
133 }
134 self.send_json(Method::GET, path.trim_end_matches(['?', '&']), None).await
135 }
136
137 pub async fn issue_session_token(
138 &self,
139 session_id: SessionId,
140 tenant: TenantId,
141 subject: &str,
142 ttl_secs: Option<u64>,
143 ) -> Result<SessionTokenDto, SdkError> {
144 self.issue_session_token_with_scopes(session_id, tenant, subject, ttl_secs, None).await
145 }
146
147 /// 显式收窄权限:`scopes` 是权限字面量子集(如 `runs:read`、`sessions:*`),
148 /// 必须落在 invoke 用途上限之内。权限的权威在服务端登记行,因此
149 /// 后续收窄对已签发但未过期的令牌同样生效。
150 pub async fn issue_session_token_with_scopes(
151 &self,
152 session_id: SessionId,
153 tenant: TenantId,
154 subject: &str,
155 ttl_secs: Option<u64>,
156 scopes: Option<Vec<&str>>,
157 ) -> Result<SessionTokenDto, SdkError> {
158 let body = serde_json::to_value(CreateSessionTokenRequest {
159 tenant,
160 subject: SmolStr::new(subject),
161 subject_kind: None,
162 ttl_secs,
163 scopes: scopes.map(|list| list.into_iter().map(SmolStr::new).collect()),
164 })
165 .map_err(|e| SdkError::Decode(e.to_string()))?;
166 self.send_json(
167 Method::POST,
168 &format!("/api/v1/admin/sessions/{session_id}/tokens"),
169 Some(body),
170 )
171 .await
172 }
173
174 pub async fn list_runs(
175 &self,
176 tenant: Option<TenantId>,
177 session_id: Option<SessionId>,
178 limit: Option<u32>,
179 ) -> Result<Page<RunDto>, SdkError> {
180 let mut path = String::from("/api/v1/admin/runs?");
181 if let Some(tenant) = tenant {
182 path.push_str(&format!("tenant={tenant}&"));
183 }
184 if let Some(session_id) = session_id {
185 path.push_str(&format!("session_id={session_id}&"));
186 }
187 if let Some(limit) = limit {
188 path.push_str(&format!("limit={limit}&"));
189 }
190 self.send_json(Method::GET, path.trim_end_matches(['?', '&']), None).await
191 }
192}
Added lib/agent-sdk/src/client.rs lib/agent-sdk/src/client.rs +214 −0 View lib/agent-sdk/src/client.rs
@@ -0,0 +1,214 @@
1use agent_types::api::{Page, RunDto};
2use agent_types::ids::{RunId, SessionId};
3use agent_types::session::{MessageAccepted, MessageDto, SendMessageRequest, SessionDto};
4use futures::StreamExt as _;
5use smol_str::SmolStr;
6
7use crate::error::SdkError;
8use crate::normalize_base;
9use crate::sse::{SseEvent, SseParser};
10
11#[derive(Clone)]
12pub struct Client {
13 http: reqwest::Client,
14 base: String,
15 token: String,
16 session_id: SessionId,
17}
18
19impl std::fmt::Debug for Client {
20 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21 f.debug_struct("Client")
22 .field("base", &self.base)
23 .field("session_id", &self.session_id)
24 .finish_non_exhaustive()
25 }
26}
27
28impl Client {
29 pub fn new(
30 base_url: &str,
31 token: impl Into<String>,
32 session_id: SessionId,
33 ) -> Result<Self, SdkError> {
34 let base = normalize_base(base_url);
35 if base.is_empty() {
36 return Err(SdkError::Invalid("base_url 不能为空".into()));
37 }
38 let token = token.into();
39 if token.trim().is_empty() {
40 return Err(SdkError::Invalid("token 不能为空".into()));
41 }
42 let http =
43 reqwest::Client::builder().build().map_err(|e| SdkError::Transport(e.to_string()))?;
44 Ok(Self { http, base, token, session_id })
45 }
46
47 #[must_use]
48 pub const fn session_id(&self) -> SessionId {
49 self.session_id
50 }
51
52 fn url(&self, path: &str) -> String {
53 format!("{}{path}", self.base)
54 }
55
56 async fn get_json<T: serde::de::DeserializeOwned>(&self, path: &str) -> Result<T, SdkError> {
57 let res = self
58 .http
59 .get(self.url(path))
60 .header("authorization", format!("Bearer {}", self.token))
61 .send()
62 .await
63 .map_err(|e| SdkError::Transport(e.to_string()))?;
64 let status = res.status().as_u16();
65 let text = res.text().await.map_err(|e| SdkError::Transport(e.to_string()))?;
66 if !(200..300).contains(&status) {
67 return Err(SdkError::from_response(status, &text));
68 }
69 serde_json::from_str(&text).map_err(|e| SdkError::Decode(e.to_string()))
70 }
71
72 pub async fn get_session(&self) -> Result<SessionDto, SdkError> {
73 self.get_json(&format!("/api/v1/sessions/{}", self.session_id)).await
74 }
75
76 pub async fn list_messages(
77 &self,
78 limit: Option<u32>,
79 cursor: Option<u64>,
80 ) -> Result<Page<MessageDto>, SdkError> {
81 let mut path = format!("/api/v1/sessions/{}/messages?", self.session_id);
82 if let Some(limit) = limit {
83 path.push_str(&format!("limit={limit}&"));
84 }
85 if let Some(cursor) = cursor {
86 path.push_str(&format!("cursor={cursor}&"));
87 }
88 self.get_json(path.trim_end_matches(['?', '&'])).await
89 }
90
91 pub async fn send_message(
92 &self,
93 text: impl Into<SmolStr>,
94 idempotency_key: Option<SmolStr>,
95 ) -> Result<MessageAccepted, SdkError> {
96 let body = serde_json::to_value(SendMessageRequest {
97 text: text.into(),
98 idempotency_key,
99 limits: None,
100 })
101 .map_err(|e| SdkError::Decode(e.to_string()))?;
102 let res = self
103 .http
104 .post(self.url(&format!("/api/v1/sessions/{}/messages", self.session_id)))
105 .header("authorization", format!("Bearer {}", self.token))
106 .json(&body)
107 .send()
108 .await
109 .map_err(|e| SdkError::Transport(e.to_string()))?;
110 let status = res.status().as_u16();
111 let text = res.text().await.map_err(|e| SdkError::Transport(e.to_string()))?;
112 if !(200..300).contains(&status) {
113 return Err(SdkError::from_response(status, &text));
114 }
115 serde_json::from_str(&text).map_err(|e| SdkError::Decode(e.to_string()))
116 }
117
118 pub async fn get_run(&self, run_id: RunId) -> Result<RunDto, SdkError> {
119 self.get_json(&format!("/api/v1/runs/{run_id}")).await
120 }
121
122 pub async fn list_runs(&self, limit: Option<u32>) -> Result<Page<RunDto>, SdkError> {
123 let path = match limit {
124 Some(limit) => {
125 format!("/api/v1/runs?session_id={}&limit={limit}", self.session_id)
126 }
127 None => format!("/api/v1/runs?session_id={}", self.session_id),
128 };
129 self.get_json(&path).await
130 }
131
132 pub async fn cancel_run(&self, run_id: RunId) -> Result<RunDto, SdkError> {
133 let res = self
134 .http
135 .post(self.url(&format!("/api/v1/runs/{run_id}/cancel")))
136 .header("authorization", format!("Bearer {}", self.token))
137 .json(&serde_json::json!({}))
138 .send()
139 .await
140 .map_err(|e| SdkError::Transport(e.to_string()))?;
141 let status = res.status().as_u16();
142 let text = res.text().await.map_err(|e| SdkError::Transport(e.to_string()))?;
143 if !(200..300).contains(&status) {
144 return Err(SdkError::from_response(status, &text));
145 }
146 serde_json::from_str(&text).map_err(|e| SdkError::Decode(e.to_string()))
147 }
148
149 pub async fn stream_events(&self, last_event_id: Option<u64>) -> Result<EventStream, SdkError> {
150 let mut req = self
151 .http
152 .get(self.url(&format!("/api/v1/sessions/{}/events", self.session_id)))
153 .header("authorization", format!("Bearer {}", self.token))
154 .header("accept", "text/event-stream");
155 if let Some(seq) = last_event_id {
156 req = req.header("last-event-id", format!("m{seq}"));
157 }
158 let res = req.send().await.map_err(|e| SdkError::Transport(e.to_string()))?;
159 let status = res.status().as_u16();
160 if !(200..300).contains(&status) {
161 let text = res.text().await.unwrap_or_default();
162 return Err(SdkError::from_response(status, &text));
163 }
164 Ok(EventStream {
165 inner: Box::pin(res.bytes_stream()),
166 parser: SseParser::new(),
167 watermark: last_event_id.unwrap_or(0),
168 })
169 }
170}
171
172pub struct EventStream {
173 inner:
174 std::pin::Pin<Box<dyn futures::Stream<Item = Result<bytes::Bytes, reqwest::Error>> + Send>>,
175 parser: SseParser,
176 watermark: u64,
177}
178
179impl EventStream {
180 pub async fn next(&mut self) -> Option<Result<SseEvent, SdkError>> {
181 loop {
182 if let Some(frame) = self.parser.pop_frame() {
183 let event = SseEvent::parse(&frame);
184 if let Some(watermark) = event.as_ref().and_then(SseEvent::watermark) {
185 self.watermark = watermark;
186 }
187 return Some(
188 event.ok_or_else(|| {
189 SdkError::Decode(format!("无法解析 SSE 帧: {}", frame.event))
190 }),
191 );
192 }
193 match self.inner.next().await {
194 Some(Ok(chunk)) => self.parser.push(&chunk),
195 Some(Err(e)) => return Some(Err(SdkError::Transport(e.to_string()))),
196 None => {
197 let frame = self.parser.flush()?;
198 let event = SseEvent::parse(&frame);
199 if let Some(watermark) = event.as_ref().and_then(SseEvent::watermark) {
200 self.watermark = watermark;
201 }
202 return Some(event.ok_or_else(|| {
203 SdkError::Decode(format!("无法解析 SSE 帧: {}", frame.event))
204 }));
205 }
206 }
207 }
208 }
209
210 #[must_use]
211 pub const fn watermark(&self) -> u64 {
212 self.watermark
213 }
214}
Added lib/agent-sdk/src/error.rs lib/agent-sdk/src/error.rs +99 −0 View lib/agent-sdk/src/error.rs
@@ -0,0 +1,99 @@
1use agent_types::api::ApiErrorCode;
2
3#[derive(Debug, thiserror::Error)]
4pub enum SdkError {
5 #[error("transport: {0}")]
6 Transport(String),
7 #[error("unauthorized: {0}")]
8 Unauthorized(String),
9 #[error("forbidden: {0}")]
10 Forbidden(String),
11 #[error("not found: {0}")]
12 NotFound(String),
13 #[error("conflict: {0}")]
14 Conflict(String),
15 #[error("gone: {0}")]
16 Gone(String),
17 #[error("rate limited: {0}")]
18 RateLimited(String),
19 #[error("bad request: {0}")]
20 BadRequest(String),
21 #[error("server error ({status}): {message}")]
22 Server { status: u16, message: String },
23 #[error("decode: {0}")]
24 Decode(String),
25 #[error("invalid argument: {0}")]
26 Invalid(String),
27}
28
29impl SdkError {
30 #[must_use]
31 pub const fn is_retryable(&self) -> bool {
32 match self {
33 Self::Transport(_) | Self::RateLimited(_) => true,
34 Self::Server { status, .. } => *status >= 500,
35 _ => false,
36 }
37 }
38
39 pub(crate) fn from_response(status: u16, body: &str) -> Self {
40 let parsed: Option<agent_types::api::ApiError> = serde_json::from_str(body).ok();
41 let message = parsed
42 .as_ref()
43 .map(|e| e.message.to_string())
44 .unwrap_or_else(|| body.trim().to_string());
45 match crate::error::code_from_status(status, parsed.as_ref().map(|e| e.code)) {
46 ApiErrorCode::Unauthenticated => Self::Unauthorized(message),
47 ApiErrorCode::Forbidden => Self::Forbidden(message),
48 ApiErrorCode::NotFound => Self::NotFound(message),
49 ApiErrorCode::Conflict => Self::Conflict(message),
50 ApiErrorCode::Gone => Self::Gone(message),
51 ApiErrorCode::TooManyRequests => Self::RateLimited(message),
52 ApiErrorCode::BadRequest => Self::BadRequest(message),
53 _ => Self::Server { status, message },
54 }
55 }
56}
57
58pub(crate) fn code_from_status(status: u16, code: Option<ApiErrorCode>) -> ApiErrorCode {
59 if let Some(code) = code {
60 return code;
61 }
62 match status {
63 400 => ApiErrorCode::BadRequest,
64 401 => ApiErrorCode::Unauthenticated,
65 403 => ApiErrorCode::Forbidden,
66 404 => ApiErrorCode::NotFound,
67 409 => ApiErrorCode::Conflict,
68 410 => ApiErrorCode::Gone,
69 429 => ApiErrorCode::TooManyRequests,
70 _ => ApiErrorCode::Internal,
71 }
72}
73
74#[cfg(test)]
75mod tests {
76 use super::*;
77
78 #[test]
79 fn error_body_drives_the_variant() {
80 let body = r#"{"code":"forbidden","message":"跨会话访问被拒绝"}"#;
81 let err = SdkError::from_response(403, body);
82 assert!(matches!(err, SdkError::Forbidden(ref m) if m == "跨会话访问被拒绝"));
83 }
84
85 #[test]
86 fn non_json_body_falls_back_to_status() {
87 let err = SdkError::from_response(404, "not found");
88 assert!(matches!(err, SdkError::NotFound(ref m) if m == "not found"));
89 }
90
91 #[test]
92 fn retryability_matches_transport_and_5xx_only() {
93 assert!(SdkError::Server { status: 503, message: "down".into() }.is_retryable());
94 assert!(SdkError::Transport("timeout".into()).is_retryable());
95 assert!(!SdkError::BadRequest("bad".into()).is_retryable());
96 assert!(!SdkError::NotFound("x".into()).is_retryable());
97 assert!(!SdkError::Forbidden("x".into()).is_retryable());
98 }
99}
Added lib/agent-sdk/src/lib.rs lib/agent-sdk/src/lib.rs +16 −0 View lib/agent-sdk/src/lib.rs
@@ -0,0 +1,16 @@
1#![warn(clippy::unwrap_used, clippy::expect_used)]
2
3mod admin;
4mod client;
5mod error;
6mod sse;
7
8pub use admin::AdminClient;
9pub use client::{Client, EventStream};
10pub use error::SdkError;
11pub use sse::{SseEvent, SseFrame};
12
13#[must_use]
14pub(crate) fn normalize_base(base: &str) -> String {
15 base.trim().trim_end_matches('/').to_string()
16}
Added lib/agent-sdk/src/sse.rs lib/agent-sdk/src/sse.rs +156 −0 View lib/agent-sdk/src/sse.rs
@@ -0,0 +1,156 @@
1use agent_types::ids::{RunId, SessionId};
2use agent_types::session::MessageDto;
3
4#[derive(Debug, Clone, PartialEq)]
5pub struct SseFrame {
6 pub event: String,
7 pub id: Option<String>,
8 pub data: String,
9}
10
11#[derive(Debug, Clone, PartialEq)]
12pub enum SseEvent {
13 MessageAdded { session_id: SessionId, message: MessageDto },
14 RunEvent { run_id: RunId, seq: u64, event: agent_types::event::AgentEvent },
15 RunState { run_id: RunId, state: agent_types::state::RunState },
16 Other { event: String, data: String },
17}
18
19impl SseEvent {
20 #[must_use]
21 pub fn parse(frame: &SseFrame) -> Option<Self> {
22 match frame.event.as_str() {
23 "message_added" => {
24 let message: MessageDto = serde_json::from_str(&frame.data).ok()?;
25 Some(Self::MessageAdded { session_id: message.session_id, message })
26 }
27 "run_event" => {
28 #[derive(serde::Deserialize)]
29 struct Payload {
30 run_id: RunId,
31 seq: u64,
32 event: agent_types::event::AgentEvent,
33 }
34 let p: Payload = serde_json::from_str(&frame.data).ok()?;
35 Some(Self::RunEvent { run_id: p.run_id, seq: p.seq, event: p.event })
36 }
37 "run_state" => {
38 #[derive(serde::Deserialize)]
39 struct Payload {
40 run_id: RunId,
41 state: agent_types::state::RunState,
42 }
43 let p: Payload = serde_json::from_str(&frame.data).ok()?;
44 Some(Self::RunState { run_id: p.run_id, state: p.state })
45 }
46 other => Some(Self::Other { event: other.to_string(), data: frame.data.clone() }),
47 }
48 }
49
50 #[must_use]
51 pub fn watermark(&self) -> Option<u64> {
52 match self {
53 Self::MessageAdded { message, .. } => Some(message.seq),
54 _ => None,
55 }
56 }
57}
58#[derive(Debug, Default)]
59pub struct SseParser {
60 buffer: Vec<u8>,
61}
62
63impl SseParser {
64 #[must_use]
65 pub fn new() -> Self {
66 Self::default()
67 }
68
69 pub fn push(&mut self, chunk: &[u8]) {
70 self.buffer.extend_from_slice(chunk);
71 }
72
73 pub fn pop_frame(&mut self) -> Option<SseFrame> {
74 let sep = self.buffer.windows(2).position(|w| w == b"\n\n")?;
75 let head = self.buffer[..sep].to_vec();
76 self.buffer.drain(..sep + 2);
77 parse_block(&head)
78 }
79
80 pub fn flush(&mut self) -> Option<SseFrame> {
81 if self.buffer.iter().all(u8::is_ascii_whitespace) {
82 self.buffer.clear();
83 return None;
84 }
85 let block = std::mem::take(&mut self.buffer);
86 parse_block(&block)
87 }
88}
89
90fn parse_block(raw: &[u8]) -> Option<SseFrame> {
91 let block = std::str::from_utf8(raw).ok()?;
92 let mut event = None;
93 let mut id = None;
94 let mut data = String::new();
95 for line in block.lines() {
96 if let Some(value) = line.strip_prefix("event:") {
97 event = Some(value.trim().to_string());
98 } else if let Some(value) = line.strip_prefix("id:") {
99 id = Some(value.trim().to_string());
100 } else if let Some(value) = line.strip_prefix("data:") {
101 if !data.is_empty() {
102 data.push('\n');
103 }
104 data.push_str(value.strip_prefix(' ').unwrap_or(value));
105 }
106 }
107 let event = event?;
108 Some(SseFrame { event, id: id.filter(|s| !s.is_empty()), data })
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114
115 #[test]
116 fn parser_handles_frames_split_across_chunks() {
117 let mut p = SseParser::new();
118 p.push(b"event: message_added\nid: m1\ndata: {\"a\"");
119 assert!(p.pop_frame().is_none(), "半帧不应产出");
120 p.push(b":1}\n\n");
121 let frame = p.pop_frame().expect("完整帧");
122 assert_eq!(frame.event, "message_added");
123 assert_eq!(frame.id.as_deref(), Some("m1"));
124 assert_eq!(frame.data, "{\"a\":1}");
125 }
126
127 #[test]
128 fn parser_handles_multibyte_split_across_chunks() {
129 let mut p = SseParser::new();
130 let full = "event: x\ndata: 好\n\n".as_bytes().to_vec();
131 let (a, b) = full.split_at(full.len() - 4);
132 p.push(a);
133 assert!(p.pop_frame().is_none());
134 p.push(b);
135 let frame = p.pop_frame().expect("完整帧");
136 assert_eq!(frame.data, "好");
137 }
138
139 #[test]
140 fn parser_flushes_trailing_frame_without_blank_line() {
141 let mut p = SseParser::new();
142 p.push(b"event: run_state\ndata: {}");
143 assert!(p.pop_frame().is_none());
144 let frame = p.flush().expect("收尾帧");
145 assert_eq!(frame.event, "run_state");
146 }
147
148 #[test]
149 fn multiple_frames_are_popped_in_order() {
150 let mut p = SseParser::new();
151 p.push(b"event: a\ndata: 1\n\nevent: b\ndata: 2\n\n");
152 assert_eq!(p.pop_frame().unwrap().event, "a");
153 assert_eq!(p.pop_frame().unwrap().event, "b");
154 assert!(p.pop_frame().is_none());
155 }
156}
Added lib/agent-sdk/Cargo.toml lib/agent-sdk/Cargo.toml +24 −0 View lib/agent-sdk/Cargo.toml
@@ -0,0 +1,24 @@
1[package]
2name = "agent-sdk"
3description = "可信后端用的 Rust 客户端:/api/v1/admin/** + 会话面 + 业务面 + SSE"
4version.workspace = true
5edition.workspace = true
6rust-version = "1.85"
7publish = false
8license = "Apache-2.0"
9
10[dependencies]
11agent-types = { workspace = true }
12bytes = { workspace = true }
13futures = { workspace = true }
14reqwest = { workspace = true, features = ["stream"] }
15serde = { workspace = true, features = ["derive"] }
16serde_json = { workspace = true }
17smol_str = { workspace = true }
18thiserror = { workspace = true }
19
20[dev-dependencies]
21tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
22
23[lints.rust]
24unsafe_code = "forbid"
No newline at end of file
Added lib/agent-types/src/api.rs lib/agent-types/src/api.rs +198 −0 View lib/agent-types/src/api.rs
@@ -0,0 +1,198 @@
1use serde::Deserialize;
2use serde::Serialize;
3use smol_str::SmolStr;
4
5use crate::budget::RunLimits;
6use crate::event::AgentEvent;
7use crate::ids::{RunId, Seq, TaskId, TenantId};
8use crate::llm::TokenUsage;
9use crate::plan::Goal;
10use crate::queue::Priority;
11use crate::state::{RunState, TerminalState};
12
13#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
14pub struct SubmitTaskRequest {
15 pub goal: Goal,
16 #[serde(default)]
17 pub priority: Priority,
18 #[serde(default, skip_serializing_if = "Option::is_none")]
19 pub limits: Option<RunLimits>,
20 pub idempotency_key: SmolStr,
21}
22
23#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
24pub struct SubmitAccepted {
25 pub task_id: TaskId,
26 pub run_id: RunId,
27 pub state: RunState,
28 pub events_url: SmolStr,
29}
30
31#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
32pub struct RunDto {
33 pub run_id: RunId,
34 pub task_id: TaskId,
35 pub tenant: TenantId,
36 pub state: RunState,
37 pub plan_revision: u32,
38 pub steps: u32,
39 pub usage: TokenUsage,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub terminal: Option<TerminalState>,
42 pub created_at_millis: i64,
43 pub updated_at_millis: i64,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47pub struct SteeringMessage {
48 pub text: SmolStr,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52pub struct CancelRequest {
53 #[serde(default)]
54 pub reason: Option<SmolStr>,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub struct ApprovalDecision {
59 pub approved: bool,
60 pub decided_by: SmolStr,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub note: Option<SmolStr>,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66pub struct Page<T> {
67 pub items: Vec<T>,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub next_cursor: Option<SmolStr>,
70}
71
72pub type EventPage = Page<AgentEvent>;
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(rename_all = "snake_case")]
76pub enum ApiErrorCode {
77 BadRequest,
78 Unauthenticated,
79 Forbidden,
80 NotFound,
81 Conflict,
82 Gone,
83 TooManyRequests,
84 NotImplemented,
85 Internal,
86 Unavailable,
87}
88
89impl ApiErrorCode {
90 #[must_use]
91 pub const fn status(self) -> u16 {
92 match self {
93 Self::BadRequest => 400,
94 Self::Unauthenticated => 401,
95 Self::Forbidden => 403,
96 Self::NotFound => 404,
97 Self::Conflict => 409,
98 Self::Gone => 410,
99 Self::TooManyRequests => 429,
100 Self::NotImplemented => 501,
101 Self::Internal => 500,
102 Self::Unavailable => 503,
103 }
104 }
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108pub struct ApiError {
109 pub code: ApiErrorCode,
110 pub message: SmolStr,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub retry_after_ms: Option<u64>,
113}
114
115impl ApiError {
116 #[must_use]
117 pub fn new(code: ApiErrorCode, message: impl Into<SmolStr>) -> Self {
118 Self { code, message: message.into(), retry_after_ms: None }
119 }
120
121 #[must_use]
122 pub fn retryable(code: ApiErrorCode, message: impl Into<SmolStr>, retry_after_ms: u64) -> Self {
123 Self { code, message: message.into(), retry_after_ms: Some(retry_after_ms) }
124 }
125}
126
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct SseFrame {
129 pub event: &'static str,
130 pub id: Seq,
131 pub data: String,
132}
133
134impl SseFrame {
135 #[must_use]
136 pub fn render(&self) -> String {
137 format!("event: {}\nid: {}\ndata: {}\n\n", self.event, self.id, self.data)
138 }
139}
140
141#[must_use]
142pub fn frame_of(seq: Seq, event: &AgentEvent) -> SseFrame {
143 SseFrame {
144 event: event.kind(),
145 id: seq,
146 data: serde_json::to_string(event).unwrap_or_else(|_| "{}".to_string()),
147 }
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153 use crate::ids::RunId;
154
155 #[test]
156 fn sse_frame_renders_three_fields() {
157 let f = SseFrame { event: "step_started", id: 7, data: "{}".into() };
158 assert_eq!(f.render(), "event: step_started\nid: 7\ndata: {}\n\n");
159 }
160
161 #[test]
162 fn error_codes_map_to_status() {
163 assert_eq!(ApiErrorCode::TooManyRequests.status(), 429);
164 assert_eq!(ApiErrorCode::NotImplemented.status(), 501);
165 assert_eq!(ApiErrorCode::Gone.status(), 410);
166 }
167
168 #[test]
169 fn submit_request_defaults_priority_and_roundtrips() {
170 let req = SubmitTaskRequest {
171 goal: Goal {
172 text: "调研 X".into(),
173 constraints: Vec::new(),
174 inputs: Vec::new(),
175 history: Vec::new(),
176 },
177 priority: Priority::default(),
178 limits: None,
179 idempotency_key: "k-1".into(),
180 };
181 let json = serde_json::to_string(&req).unwrap();
182 let back: SubmitTaskRequest = serde_json::from_str(&json).unwrap();
183 assert_eq!(back, req);
184 }
185
186 #[test]
187 fn frame_of_uses_event_kind() {
188 let ev = AgentEvent::StepStarted {
189 run_id: RunId::new(),
190 step: 1,
191 ctx_fp: crate::ids::CtxFingerprint::digest(&[b"x"]),
192 };
193 let f = frame_of(3, &ev);
194 assert_eq!(f.event, "step_started");
195 assert_eq!(f.id, 3);
196 assert!(f.data.contains("\"kind\":\"step_started\""));
197 }
198}
Added lib/agent-types/src/artifact.rs lib/agent-types/src/artifact.rs +104 −0 View lib/agent-types/src/artifact.rs
@@ -0,0 +1,104 @@
1use serde::Deserialize;
2use serde::Serialize;
3use smol_str::SmolStr;
4
5use crate::ids::{ArtifactId, RunId, StepNo, TaskId, TenantId};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
8#[serde(rename_all = "snake_case")]
9pub enum ArtifactKind {
10 Workspace,
11 Screenshot,
12 Report,
13 Snapshot,
14 Dataset,
15 Code,
16 Conclusion,
17}
18
19impl ArtifactKind {
20 #[must_use]
21 pub const fn as_str(self) -> &'static str {
22 match self {
23 Self::Workspace => "workspace",
24 Self::Screenshot => "screenshot",
25 Self::Report => "report",
26 Self::Snapshot => "snapshot",
27 Self::Dataset => "dataset",
28 Self::Code => "code",
29 Self::Conclusion => "conclusion",
30 }
31 }
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub enum ArtifactState {
37 Claimed,
38 Sealed,
39 Abandoned,
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
43pub struct Claimant {
44 pub run_id: RunId,
45 pub step: StepNo,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
49pub struct ObjectRef {
50 pub bucket: SmolStr,
51 pub key: SmolStr,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub etag: Option<SmolStr>,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
57pub struct ArtifactRef {
58 pub id: ArtifactId,
59 pub kind: ArtifactKind,
60 pub object: ObjectRef,
61 pub size_bytes: u64,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub checksum: Option<SmolStr>,
64}
65
66#[must_use]
67pub fn object_key(
68 tenant: TenantId,
69 task: TaskId,
70 run: RunId,
71 kind: ArtifactKind,
72 artifact: ArtifactId,
73) -> String {
74 format!("{tenant}/{task}/{run}/{}/{artifact}", kind.as_str())
75}
76
77#[must_use]
78pub fn is_orphan_claim(claimed_at_millis: i64, now_millis: i64, grace_millis: i64) -> bool {
79 now_millis.saturating_sub(claimed_at_millis) >= grace_millis
80}
81
82#[cfg(test)]
83mod tests {
84 use super::*;
85
86 #[test]
87 fn object_key_follows_8_3_rule() {
88 let (t, task, run, a) = (TenantId::new(), TaskId::new(), RunId::new(), ArtifactId::new());
89 let key = object_key(t, task, run, ArtifactKind::Report, a);
90 assert_eq!(key, format!("{t}/{task}/{run}/report/{a}"));
91 assert!(key.starts_with(&t.to_string()), "租户前缀隔离(10.1 节)");
92 }
93
94 #[test]
95 fn orphan_detection_uses_grace_window() {
96 assert!(!is_orphan_claim(0, 1_000, 5_000));
97 assert!(is_orphan_claim(0, 5_000, 5_000));
98 }
99
100 #[test]
101 fn artifact_state_serde_is_snake_case() {
102 assert_eq!(serde_json::to_string(&ArtifactState::Sealed).unwrap(), "\"sealed\"");
103 }
104}
Added lib/agent-types/src/budget.rs lib/agent-types/src/budget.rs +109 −0 View lib/agent-types/src/budget.rs
@@ -0,0 +1,109 @@
1use serde::Deserialize;
2use serde::Serialize;
3
4use crate::ids::StepNo;
5use crate::llm::TokenUsage;
6use crate::state::FailReason;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9pub struct RunLimits {
10 pub max_steps: StepNo,
11 pub max_tokens: u32,
12 pub max_wall_millis: u64,
13}
14
15impl Default for RunLimits {
16 fn default() -> Self {
17 Self { max_steps: 160, max_tokens: 2_000_000, max_wall_millis: 45 * 60 * 1000 }
18 }
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct Budget {
23 pub limits: RunLimits,
24 pub steps: StepNo,
25 pub usage: TokenUsage,
26 pub started_at_millis: i64,
27}
28
29impl Budget {
30 #[must_use]
31 pub fn new(limits: RunLimits, started_at_millis: i64) -> Self {
32 Self { limits, steps: 0, usage: TokenUsage::default(), started_at_millis }
33 }
34
35 pub fn spend(&mut self, usage: TokenUsage) {
36 self.usage += usage;
37 }
38
39 pub fn record_step(&mut self) {
40 self.steps = self.steps.saturating_add(1);
41 }
42
43 #[must_use]
44 pub fn remaining_steps(&self) -> StepNo {
45 self.limits.max_steps.saturating_sub(self.steps)
46 }
47
48 #[must_use]
49 pub fn can_start_step(&self, step_budget_millis: u64, now_millis: i64) -> bool {
50 self.remaining_steps() > 0
51 && self
52 .limits
53 .max_wall_millis
54 .saturating_sub(now_millis.saturating_sub(self.started_at_millis).max(0) as u64)
55 >= step_budget_millis
56 }
57
58 #[must_use]
59 pub fn check(&self, now_millis: i64) -> Option<FailReason> {
60 if self.steps >= self.limits.max_steps {
61 return Some(FailReason::StepBudget);
62 }
63 if self.usage.total() >= self.limits.max_tokens {
64 return Some(FailReason::TokenBudget);
65 }
66 let elapsed = now_millis.saturating_sub(self.started_at_millis).max(0) as u64;
67 if elapsed >= self.limits.max_wall_millis {
68 return Some(FailReason::WallclockBudget);
69 }
70 None
71 }
72}
73
74#[cfg(test)]
75mod tests {
76 use super::*;
77
78 fn budget() -> Budget {
79 Budget::new(RunLimits { max_steps: 3, max_tokens: 1_000, max_wall_millis: 10_000 }, 0)
80 }
81
82 #[test]
83 fn step_budget_hits_first() {
84 let mut b = budget();
85 assert_eq!(b.check(1_000), None);
86 b.record_step();
87 b.record_step();
88 b.record_step();
89 assert_eq!(b.check(1_000), Some(FailReason::StepBudget));
90 }
91
92 #[test]
93 fn token_and_wallclock_budgets_are_independent() {
94 let mut b = budget();
95 b.spend(TokenUsage { prompt: 900, completion: 200, cached: 0 });
96 assert_eq!(b.check(1_000), Some(FailReason::TokenBudget));
97 let mut b2 = budget();
98 assert_eq!(b2.check(10_000), Some(FailReason::WallclockBudget));
99 b2.record_step();
100 assert_eq!(b2.check(9_999), None);
101 }
102
103 #[test]
104 fn can_start_step_respects_remaining_wallclock() {
105 let b = budget();
106 assert!(b.can_start_step(5_000, 1_000));
107 assert!(!b.can_start_step(5_000, 9_000), "剩余墙钟不足一个 Step 时应放弃");
108 }
109}
Added lib/agent-types/src/consts.rs lib/agent-types/src/consts.rs +66 −0 View lib/agent-types/src/consts.rs
@@ -0,0 +1,66 @@
1use std::time::Duration;
2
3pub const SNAPSHOT_INTERVAL_EVENTS: u64 = 64;
4pub const EVENTS_PER_STEP_ESTIMATE: u64 = 3;
5pub const SYSTEM_RESERVE_TOKENS: u32 = 8_192;
6pub const VERIFY_RESERVE_TOKENS: u32 = 4_096;
7pub const WORK_WINDOW_RATIO: u32 = 7;
8pub const HISTORY_WINDOW_RATIO: u32 = 3;
9pub const SOFT_COMPACT_PCT: u32 = 80;
10pub const HARD_COMPACT_PCT: u32 = 95;
11pub const LEASE_TTL: Duration = Duration::from_secs(90);
12pub const LEASE_HEARTBEAT: Duration = Duration::from_secs(30);
13pub const TENANT_CONCURRENCY_SOFT_CAP: u32 = 16;
14pub const WORKER_MAX_CONCURRENT_RUNS: u32 = 8;
15pub const SANDBOX_POOL_MIN_IDLE: u32 = 4;
16pub const SANDBOX_POOL_MAX_IDLE: u32 = 8;
17pub const SANDBOX_DEFAULT_TTL: Duration = Duration::from_secs(30 * 60);
18pub const POISON_MAX_ATTEMPTS: u32 = 3;
19pub const SANDBOX_FAILURE_ESCALATION: u32 = 3;
20pub const ARTIFACT_RETENTION_DAYS: i64 = 30;
21pub const MAX_OBJECT_BYTES: u64 = 5 * 1024 * 1024 * 1024;
22pub const LLM_STEP_TIMEOUT: Duration = Duration::from_secs(20);
23pub const STEP_WALLCLOCK_BUDGET: Duration = Duration::from_secs(25);
24/// 单个 Step 的墙钟硬上限(运行时真正中断)。
25///
26/// `STEP_WALLCLOCK_BUDGET` 只参与租约续期算式,不打断执行;而一个 Step 内 LLM
27/// 最多重试 4 次 × 20s(`LLM_CALL_DEADLINE`),再叠加工具执行,最坏可达分钟级。
28/// 没有这个上界时,一个卡住的 Step 会长期占住 worker 并发槽位。
29pub const STEP_HARD_CEILING: Duration = Duration::from_secs(300);
30pub const OBSERVATION_MAX_BYTES: usize = 8 * 1024;
31/// 孤儿判定的宽限期:投影停止推进多久之后才可以被回收器认领
32/// (同时用于产物声明对账)。
33///
34/// 必须明显大于 `LEASE_TTL`:租约过期只说明「上一个 worker 已经不行了」,
35/// 宽限期过了才说明「这个 run 确实没人管了」。
36/// 两者合并会让回收器与正在干活的 worker 抢活。
37pub const ORPHAN_CLAIM_GRACE: Duration = Duration::from_secs(120);
38/// 同一个 run 允许被孤儿回收器重新投递的次数上限。
39///
40/// 没有这个上界时,一个「每次被认领就在写第一条事件前崩掉」的 run 会被回收器
41/// 无限重投,把队列变成永动机。超过上限即判定为坏 run,落终态交人工排查。
42pub const ORPHAN_RECLAIM_MAX_ATTEMPTS: u32 = 3;
43pub const LOCAL_BUCKET: &str = "agent-local";
44pub const SSE_BROADCAST_CAPACITY: usize = 4096;
45pub const LLM_CALL_DEADLINE: Duration = Duration::from_secs(20);
46pub const COLLECT_MAX_FILES: usize = 256;
47pub const SKILL_BODY_MAX_BYTES: usize = 32 * 1024;
48pub const SKILL_SYNC_MAX_FILES: usize = COLLECT_MAX_FILES;
49pub const SKILL_SYNC_MAX_BYTES: u64 = 8 * 1024 * 1024;
50pub const SKILL_SYNC_FILE_MAX_BYTES: u64 = 1024 * 1024;
51pub const SKILL_DIR_NAME: &str = "SKILL.md";
52pub const SKILL_WORKSPACE_ROOT: &str = ".skills";
53pub const DEFAULT_SKILLS_DIR: &str = "/opt/skills";
54
55/// 决策模型(TypeSafe System One)默认模型名。该接口一次前向返回
56/// 分类/评分/是非判断及其概率与置信度,不生成文本。
57pub const DECISION_MODEL_DEFAULT: &str = "decision-model-preview";
58/// 决策模型单次调用墙钟上限:它在审批超时循环与 Agent 工具循环里都会被调用,
59/// 拿不到结论就应当回落原处置,而不是把主循环拖住。
60pub const DECISION_CALL_TIMEOUT: Duration = Duration::from_secs(8);
61/// 审批超时仲裁的放行置信度下限:低于它一律维持「超时自动拒绝」。
62pub const APPROVAL_ARBITER_MIN_CONFIDENCE: f64 = 0.90;
63/// 危害期望上限(score 等级的概率加权期望,0 起):超过即拒。
64pub const APPROVAL_ARBITER_MAX_SEVERITY: f64 = 1.0;
65/// 不可逆损害概率上限(noul P(yes)):超过即拒。
66pub const APPROVAL_ARBITER_MAX_RISKY: f64 = 0.20;
Added lib/agent-types/src/context.rs lib/agent-types/src/context.rs +425 −0 View lib/agent-types/src/context.rs
@@ -0,0 +1,425 @@
1use serde::Deserialize;
2use serde::Serialize;
3
4use crate::consts;
5use crate::event::AgentEvent;
6use crate::event::CompactionTier;
7use crate::ids::CtxFingerprint;
8use crate::llm::{ChatMessage, ChatRole};
9
10#[must_use]
11pub fn estimate_tokens(text: &str) -> u32 {
12 let bytes = text.len();
13 if bytes == 0 {
14 return 0;
15 }
16 bytes.div_ceil(4) as u32
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
20pub struct WindowBudget {
21 pub total: u32,
22 pub system_reserve: u32,
23 pub verify_reserve: u32,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
27pub struct WindowSplit {
28 pub work: u32,
29 pub history: u32,
30}
31
32impl WindowBudget {
33 #[must_use]
34 pub fn new(total: u32) -> Self {
35 Self {
36 total,
37 system_reserve: consts::SYSTEM_RESERVE_TOKENS,
38 verify_reserve: consts::VERIFY_RESERVE_TOKENS,
39 }
40 }
41
42 #[must_use]
43 pub fn split(&self) -> WindowSplit {
44 let free =
45 self.total.saturating_sub(self.system_reserve).saturating_sub(self.verify_reserve);
46 let parts = consts::WORK_WINDOW_RATIO + consts::HISTORY_WINDOW_RATIO;
47 let work = free / parts * consts::WORK_WINDOW_RATIO;
48 WindowSplit { work, history: free - work }
49 }
50}
51
52#[must_use]
53pub fn should_compact(used: u32, of: u32) -> Option<CompactionTier> {
54 if of == 0 {
55 return None;
56 }
57 let pct = used.saturating_mul(100) / of;
58 if pct >= consts::HARD_COMPACT_PCT {
59 Some(CompactionTier::Hard)
60 } else if pct >= consts::SOFT_COMPACT_PCT {
61 Some(CompactionTier::Soft)
62 } else {
63 None
64 }
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
68pub struct ContextWindow {
69 pub system: Vec<ChatMessage>,
70 pub history: Vec<ChatMessage>,
71 pub work: Vec<ChatMessage>,
72 pub compactions: u32,
73}
74
75const fn role_tag(role: ChatRole) -> &'static str {
76 match role {
77 ChatRole::System => "system",
78 ChatRole::User => "user",
79 ChatRole::Assistant => "assistant",
80 ChatRole::Tool => "tool",
81 }
82}
83
84#[must_use]
85pub fn render_actions(step: crate::ids::StepNo, actions: &[crate::event::AgentAction]) -> String {
86 let mut text = String::new();
87 for a in actions {
88 match a {
89 crate::event::AgentAction::ToolCall { call } => {
90 text.push_str(&format!("step {step} call {} ", call.name));
91 }
92 crate::event::AgentAction::Finish { summary } => {
93 text.push_str(&format!("finish: {summary}"));
94 }
95 crate::event::AgentAction::UpdatePlan { note, .. } => {
96 text.push_str(&format!("plan: {note} "));
97 }
98 crate::event::AgentAction::AckUserMessage { at_seq } => {
99 text.push_str(&format!("ack seq{at_seq} "));
100 }
101 }
102 }
103 text
104}
105
106/// 技能清单块:常驻系统提示,只列 name/description,正文按需加载。
107#[must_use]
108pub fn render_skill_manifest(skills: &[crate::skill::SkillEntry]) -> String {
109 let mut out = String::from(
110 "可用技能(按需加载:调用 skill.load 并传入技能名即可注入正文;配套文件位于 /workspace/.skills/<name>/):\n",
111 );
112 for skill in skills {
113 out.push_str(&format!("- {}: {}\n", skill.name, skill.description.trim()));
114 }
115 out
116}
117
118/// 技能正文块:正文以事件内联,重放时不再读取本机文件。
119#[must_use]
120pub fn render_skill_body(name: &str, hash: &str, body: &str) -> String {
121 format!("【技能 {name} 正文({hash})】\n{body}")
122}
123
124/// 技能不可用提示块:缺失 / 哈希不符 / 超预算 / 未在清单中。
125#[must_use]
126pub fn render_skill_rejected(name: &str, reason: &str) -> String {
127 format!("【技能 {name} 不可用】{reason}")
128}
129
130#[must_use]
131pub fn render_outcome(outcome: &crate::tool::ToolOutcome) -> String {
132 use crate::tool::ToolOutcome;
133 match outcome {
134 ToolOutcome::Ok { output, .. } => output.observation.to_string(),
135 ToolOutcome::Err { code, message, .. } => format!("{code:?} {message}"),
136 ToolOutcome::Timeout { limit_ms } => format!("timeout after {limit_ms}ms"),
137 // A-4:中断意味着「副作用状态未知」——进程在执行中途崩溃,外部世界
138 // 可能已经发生了变化。必须引导模型先验证再重试,而不是当普通失败重发
139 // (重复副作用是 agent 自评错误率的重灾区,人侧只看见「链完整、全绿」)。
140 ToolOutcome::Interrupted => {
141 "interrupted: 执行进程在调用中途崩溃,副作用状态未知。".to_string()
142 }
143 }
144}
145
146impl ContextWindow {
147 #[must_use]
148 pub fn messages(&self) -> Vec<ChatMessage> {
149 let mut out = Vec::with_capacity(self.system.len() + self.history.len() + self.work.len());
150 out.extend(self.system.iter().cloned());
151 out.extend(self.history.iter().cloned());
152 out.extend(self.work.iter().cloned());
153 out
154 }
155
156 #[must_use]
157 pub fn work_tokens(&self) -> u32 {
158 self.work.iter().map(ChatMessage::estimate_tokens).sum()
159 }
160
161 #[must_use]
162 pub fn history_tokens(&self) -> u32 {
163 self.history.iter().map(ChatMessage::estimate_tokens).sum()
164 }
165
166 #[must_use]
167 pub fn fingerprint(&self) -> CtxFingerprint {
168 let mut buf: Vec<u8> = Vec::with_capacity(256);
169 for group in [&self.system, &self.history, &self.work] {
170 buf.extend_from_slice(b"#group\n");
171 for m in group {
172 buf.extend_from_slice(role_tag(m.role).as_bytes());
173 buf.push(b'|');
174 buf.extend_from_slice(m.content.as_bytes());
175 buf.push(b'\n');
176 }
177 }
178 CtxFingerprint::digest(&[&buf])
179 }
180
181 #[must_use]
182 pub fn trimmed_work(&self, budget: u32) -> Vec<ChatMessage> {
183 let mut keep: Vec<ChatMessage> = Vec::new();
184 let mut used = 0u32;
185 for msg in self.work.iter().rev() {
186 let cost = msg.estimate_tokens();
187 if !keep.is_empty() && used.saturating_add(cost) > budget {
188 break;
189 }
190 used = used.saturating_add(cost);
191 keep.push(msg.clone());
192 }
193 keep.reverse();
194 keep
195 }
196}
197
198#[must_use]
199pub fn rebuild(
200 events: &[AgentEvent],
201 system: Vec<ChatMessage>,
202 budget: &WindowBudget,
203) -> ContextWindow {
204 rebuild_from(&ContextWindow { system, ..Default::default() }, events, budget)
205}
206
207#[must_use]
208pub fn rebuild_from(
209 base: &ContextWindow,
210 events: &[AgentEvent],
211 budget: &WindowBudget,
212) -> ContextWindow {
213 use crate::plan::ItemState;
214 let mut win = base.clone();
215 for ev in events {
216 match ev {
217 AgentEvent::PlanAdopted { plan, .. } | AgentEvent::PlanRevised { plan, .. } => {
218 let done = plan.items.iter().filter(|i| i.done == ItemState::Done).count();
219 win.history.push(ChatMessage::system(format!(
220 "plan rev{} {}/{} items done",
221 plan.revision,
222 done,
223 plan.items.len()
224 )));
225 }
226 AgentEvent::LlmCompleted { actions, step, .. } => {
227 let text = render_actions(*step, actions);
228 if !text.is_empty() {
229 win.work.push(ChatMessage::new(ChatRole::Assistant, text));
230 }
231 }
232 AgentEvent::ToolCallFinished { outcome, call_id, .. } => {
233 win.work.push(ChatMessage {
234 role: ChatRole::Tool,
235 content: render_outcome(outcome),
236 call_id: Some(*call_id),
237 name: None,
238 });
239 }
240 AgentEvent::UserMessageInjected { text, .. } => {
241 win.work.push(ChatMessage::new(ChatRole::User, text.to_string()));
242 }
243 AgentEvent::SkillManifestLoaded { skills, .. } => {
244 win.system.push(ChatMessage::system(render_skill_manifest(skills)));
245 }
246 AgentEvent::SkillLoaded { name, hash, body, .. } => {
247 win.system.push(ChatMessage::system(render_skill_body(name, hash, body)));
248 }
249 AgentEvent::SkillRejected { name, reason, .. } => {
250 win.system.push(ChatMessage::system(render_skill_rejected(name, reason)));
251 }
252 AgentEvent::ContextCompacted { summary, .. } => {
253 win.history.push(summary.clone());
254 win.work.clear();
255 win.compactions += 1;
256 }
257 _ => {}
258 }
259 }
260 let split = budget.split();
261 if win.work_tokens() > split.work {
262 win.work = win.trimmed_work(split.work);
263 }
264 win
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270 use crate::event::AgentAction;
271 use crate::ids::{CallId, RunId};
272 use crate::llm::TokenUsage;
273 use crate::tool::{ToolOutcome, ToolOutput};
274
275 fn assistant(run: RunId, step: u32, payload: &str) -> AgentEvent {
276 AgentEvent::LlmCompleted {
277 run_id: run,
278 step,
279 model: crate::llm::ModelId::new("fast"),
280 degraded: false,
281 usage: TokenUsage::default(),
282 actions: vec![AgentAction::Finish { summary: payload.into() }],
283 }
284 }
285
286 fn tool_done(run: RunId, payload: &str) -> AgentEvent {
287 AgentEvent::ToolCallFinished {
288 run_id: run,
289 call_id: CallId::new(),
290 outcome: ToolOutcome::Ok {
291 exit_code: 0,
292 output: ToolOutput {
293 observation: payload.into(),
294 artifacts: Vec::new(),
295 truncated: false,
296 exit_code: 0,
297 },
298 duration_ms: 10,
299 },
300 }
301 }
302
303 #[test]
304 fn token_estimate_is_zero_safe() {
305 assert_eq!(estimate_tokens(""), 0);
306 assert_eq!(estimate_tokens("abcd"), 1);
307 assert_eq!(estimate_tokens("abcde"), 2);
308 }
309
310 #[test]
311 fn split_follows_7_to_3_after_reserves() {
312 let b = WindowBudget::new(128_000);
313 let s = b.split();
314 let free = 128_000 - consts::SYSTEM_RESERVE_TOKENS - consts::VERIFY_RESERVE_TOKENS;
315 assert_eq!(s.work + s.history, free);
316 assert!(s.work > s.history);
317 }
318
319 #[test]
320 fn compaction_tiers_match_thresholds() {
321 assert_eq!(should_compact(79, 100), None);
322 assert_eq!(should_compact(80, 100), Some(CompactionTier::Soft));
323 assert_eq!(should_compact(94, 100), Some(CompactionTier::Soft));
324 assert_eq!(should_compact(95, 100), Some(CompactionTier::Hard));
325 }
326
327 #[test]
328 fn rebuild_is_deterministic() {
329 let run = RunId::new();
330 let events = vec![assistant(run, 1, "a"), tool_done(run, "obs"), assistant(run, 2, "b")];
331 let b = WindowBudget::new(128_000);
332 let w1 = rebuild(&events, vec![ChatMessage::system("goal")], &b);
333 let w2 = rebuild(&events, vec![ChatMessage::system("goal")], &b);
334 assert_eq!(w1, w2);
335 assert_eq!(w1.fingerprint(), w2.fingerprint());
336 assert_eq!(w1.messages().len(), 1 + 3);
337 }
338
339 #[test]
340 fn persisted_summary_replaces_regeneration() {
341 let run = RunId::new();
342 let events = vec![
343 assistant(run, 1, "old"),
344 AgentEvent::ContextCompacted {
345 run_id: run,
346 from_step: 1,
347 to_step: 1,
348 tier: CompactionTier::Soft,
349 summary: ChatMessage::system("SUMMARY: 已抓取 3 页"),
350 fp_before: CtxFingerprint::digest(&[b"a"]),
351 fp_after: CtxFingerprint::digest(&[b"b"]),
352 usage: TokenUsage::default(),
353 },
354 assistant(run, 2, "new"),
355 ];
356 let w = rebuild(&events, Vec::new(), &WindowBudget::new(128_000));
357 assert_eq!(w.compactions, 1);
358 assert_eq!(w.history.len(), 1, "摘要进入历史区");
359 assert!(w.history[0].content.contains("已抓取 3 页"));
360 assert_eq!(w.work.len(), 1, "压缩前的工作区被清空");
361 }
362
363 #[test]
364 fn skill_events_render_into_the_system_group_in_order() {
365 let run = RunId::new();
366 let events = vec![
367 AgentEvent::SkillManifestLoaded {
368 run_id: run,
369 skills: vec![crate::skill::SkillEntry::new("demo", "演示技能", "sha256:aa")],
370 },
371 AgentEvent::SkillLoaded {
372 run_id: run,
373 step: 1,
374 name: "demo".into(),
375 hash: "sha256:aa".into(),
376 size_bytes: 5,
377 body: "BODY!".into(),
378 },
379 AgentEvent::SkillRejected {
380 run_id: run,
381 step: 1,
382 name: "gone".into(),
383 reason: "技能文件不存在".into(),
384 },
385 ];
386 let base = vec![ChatMessage::system("base")];
387 let w = rebuild(&events, base.clone(), &WindowBudget::new(128_000));
388 assert_eq!(w.system.len(), 4, "基础提示 + 清单 + 正文 + 拒绝提示");
389 assert_eq!(w.system[0].content, "base");
390 assert!(w.system[1].content.contains("可用技能"), "{:?}", w.system[1]);
391 assert!(w.system[1].content.contains("- demo: 演示技能"));
392 assert!(w.system[2].content.contains("【技能 demo 正文(sha256:aa)】"));
393 assert!(w.system[2].content.contains("BODY!"));
394 assert!(w.system[3].content.contains("【技能 gone 不可用】技能文件不存在"));
395 assert_eq!(w.work.len(), 0, "技能内容不进工作区");
396
397 // 重放确定性:同一事件序列必须得到同一指纹,且与分批重建一致。
398 let full = rebuild(&events, base.clone(), &WindowBudget::new(128_000));
399 let mut stepwise = ContextWindow { system: base, ..Default::default() };
400 for ev in &events {
401 stepwise =
402 rebuild_from(&stepwise, std::slice::from_ref(ev), &WindowBudget::new(128_000));
403 }
404 assert_eq!(full, stepwise);
405 assert_eq!(full.fingerprint(), w.fingerprint());
406 }
407
408 #[test]
409 fn empty_skill_manifest_renders_a_short_listing() {
410 let text = render_skill_manifest(&[]);
411 assert!(text.starts_with("可用技能"), "{text}");
412 assert!(!text.contains("\n- "), "空清单不得列出条目:{text}");
413 }
414
415 #[test]
416 fn trimming_keeps_newest_observations() {
417 let run = RunId::new();
418 let tight = WindowBudget { total: 4_096, system_reserve: 2_048, verify_reserve: 2_048 };
419 let events: Vec<AgentEvent> =
420 (0..40).map(|i| tool_done(run, &format!("obs-{i}-{}", "x".repeat(400)))).collect();
421 let w = rebuild(&events, Vec::new(), &tight);
422 assert!(w.work.len() < events.len(), "工作区必须被裁剪");
423 assert!(w.work.last().expect("非空").content.starts_with("obs-39"), "必须保留最新观察");
424 }
425}
Added lib/agent-types/src/decision.rs lib/agent-types/src/decision.rs +62 −0 View lib/agent-types/src/decision.rs
@@ -0,0 +1,62 @@
1//! 审批超时仲裁的抽象契约。
2//!
3//! 为什么放在 types(最底层):决策模型的**实现**在 `agent-llm`,**消费方**是
4//! `run-manager` 的审批 TTL 扫描,而依赖方向(docs/03 §3.8)不允许 run-manager
5//! 反向依赖 llm。把契约抽到 types,两边各自依赖抽象,组装在 agent-runtime 完成。
6//!
7//! 语义边界:arbiter 只在**人已超时未裁决**时替代「盲目拒绝」这一个动作。
8//! 它不引入新的信任面——拿不到结论一律回落原处置(`Skip`)。
9use async_trait::async_trait;
10use smol_str::SmolStr;
11
12use crate::ids::{CallId, RunId, TenantId};
13use crate::tool::RiskLevel;
14
15/// 一次待仲裁的超时审批。
16///
17/// `args` 是**脱敏后**的参数副本:决策模型不需要明文密钥就能判断「这个动作该不该
18/// 放行」,把原文发给外部端点等于又开一条泄密通道(与事件流写脱敏副本同一口径)。
19#[derive(Debug, Clone, PartialEq)]
20pub struct ApprovalCase {
21 pub run_id: RunId,
22 pub call_id: CallId,
23 pub tenant: TenantId,
24 pub tool: SmolStr,
25 pub risk: RiskLevel,
26 /// 停车时写给运维/用户的理由(含风险等级与副作用分类)。
27 pub reason: SmolStr,
28 pub args: serde_json::Value,
29 /// 已等待时长(秒)。
30 pub waited_secs: i64,
31 /// run 的目标文本:判断「放行是否与任务一致」的唯一依据。
32 pub goal: SmolStr,
33}
34
35/// 仲裁结论。
36#[derive(Debug, Clone, PartialEq)]
37pub enum ArbiterVerdict {
38 /// 放行。`confidence` 与 `rationale` 会落进 `ApprovalDecided` 的 note,
39 /// 保证「机器替人做的决定」在事件流里可审计、可复盘。
40 Approve { confidence: f64, rationale: SmolStr },
41 /// 拒绝(与 TTL 自动拒绝同一条路径,只是理由换成模型给的)。
42 Deny { confidence: f64, rationale: SmolStr },
43 /// 未配置/不可用/结论不足:调用方保持原有处置(超时自动拒绝)。
44 /// 这是 fail-closed 的落点:宁可不判,也不能把「调用失败」读成「同意」。
45 Skip,
46}
47
48#[async_trait]
49pub trait ApprovalArbiter: Send + Sync {
50 /// 对一次超时审批给出处置建议。实现必须自行保证不 panic、不阻塞超过配置上限。
51 async fn verdict(&self, case: &ApprovalCase) -> ArbiterVerdict;
52}
53
54/// 不仲裁的默认实现(保持 A-5 原语义:超时即拒绝)。
55pub struct NoArbiter;
56
57#[async_trait]
58impl ApprovalArbiter for NoArbiter {
59 async fn verdict(&self, _case: &ApprovalCase) -> ArbiterVerdict {
60 ArbiterVerdict::Skip
61 }
62}
Added lib/agent-types/src/event.rs lib/agent-types/src/event.rs +398 −0 View lib/agent-types/src/event.rs
@@ -0,0 +1,398 @@
1use serde::Deserialize;
2use serde::Serialize;
3use smol_str::SmolStr;
4
5use crate::artifact::{ArtifactKind, ArtifactRef};
6use crate::ids::{
7 ArtifactId, CallId, CtxFingerprint, RunId, Seq, SessionId, StepNo, TaskId, TenantId,
8};
9use crate::llm::{ChatMessage, ModelId, TokenUsage};
10use crate::plan::{Plan, ReplanReason};
11use crate::skill::SkillEntry;
12use crate::state::{FailReason, RunState, TerminalState, Transition};
13use crate::tool::{RiskLevel, ToolCallSpec, ToolOutcome};
14
15/// 当前事件负载的 schema 版本。
16///
17/// 哈希链保证「不可篡改」,不保证「可兼容重放」:字段增删后的旧事件流能否
18/// 被新代码解释,需要显式版本号来回答。规则:
19/// - 新写入的 [`AgentEvent::RunStarted`] 携带当前版本;
20/// - 缺省(旧事件流)反序列化为 `0`,按旧语义容忍;
21/// - 版本高于当前支持上限时,恢复层必须拒绝(fail-closed),
22/// 而不是靠 serde 宽容分支静默丢字段。
23pub const EVENT_SCHEMA_VERSION: u32 = 1;
24
25/// 历史事件流里能容忍的最大版本(等于当前写入版本)。
26pub const MAX_SUPPORTED_EVENT_SCHEMA_VERSION: u32 = EVENT_SCHEMA_VERSION;
27
28#[must_use]
29fn default_event_schema_version() -> u32 {
30 0
31}
32
33#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
34#[serde(tag = "kind", rename_all = "snake_case")]
35pub enum AgentAction {
36 ToolCall { call: ToolCallSpec },
37 UpdatePlan { plan: Plan, note: SmolStr },
38 AckUserMessage { at_seq: Seq },
39 Finish { summary: SmolStr },
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
43#[serde(rename_all = "snake_case")]
44pub enum CompactionTier {
45 Soft,
46 Hard,
47}
48
49#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
50#[serde(tag = "kind", rename_all = "snake_case")]
51pub enum AgentEvent {
52 RunStarted {
53 run_id: RunId,
54 task_id: TaskId,
55 tenant: TenantId,
56 goal: crate::plan::Goal,
57 limits: crate::budget::RunLimits,
58 #[serde(default, skip_serializing_if = "Option::is_none")]
59 session_id: Option<SessionId>,
60 /// 事件 schema 版本:旧流缺省为 0,新写入为
61 /// [`EVENT_SCHEMA_VERSION`]。见该常量的文档。
62 #[serde(default = "default_event_schema_version", skip_serializing_if = "is_zero")]
63 schema_version: u32,
64 },
65 StateEntered {
66 run_id: RunId,
67 from: RunState,
68 to: RunState,
69 trigger: Transition,
70 },
71 PlanAdopted {
72 run_id: RunId,
73 plan: Plan,
74 },
75 PlanRevised {
76 run_id: RunId,
77 plan: Plan,
78 reason: ReplanReason,
79 },
80 StepStarted {
81 run_id: RunId,
82 step: StepNo,
83 ctx_fp: CtxFingerprint,
84 },
85 LlmCompleted {
86 run_id: RunId,
87 step: StepNo,
88 model: ModelId,
89 #[serde(default)]
90 degraded: bool,
91 usage: TokenUsage,
92 actions: Vec<AgentAction>,
93 },
94 ToolCallStarted {
95 run_id: RunId,
96 step: StepNo,
97 call_id: CallId,
98 tool: SmolStr,
99 args: serde_json::Value,
100 },
101 ToolCallFinished {
102 run_id: RunId,
103 call_id: CallId,
104 outcome: ToolOutcome,
105 },
106 ApprovalRequested {
107 run_id: RunId,
108 call_id: CallId,
109 tool: SmolStr,
110 risk: RiskLevel,
111 reason: SmolStr,
112 },
113 ApprovalDecided {
114 run_id: RunId,
115 call_id: CallId,
116 approved: bool,
117 decided_by: SmolStr,
118 /// 裁决理由(人工填的 or 审批超时仲裁机填的「为何放行/拒绝」)。
119 /// 机器代 Humans 放行时,这个字段是事件流里唯一的复盘入口,
120 /// 所以 decided_by=decision-model 的条目必须带 confidence/request_id。
121 ///
122 /// 新增的可选字段:旧事件反序列化→`None`,新事件被旧读者忽略→不崩,
123 /// 两个方向都兼容,因此**不**上升 `EVENT_SCHEMA_VERSION`(它只给
124 /// 不兼容变更保留)。
125 #[serde(default)]
126 note: Option<SmolStr>,
127 },
128 ContextCompacted {
129 run_id: RunId,
130 from_step: StepNo,
131 to_step: StepNo,
132 tier: CompactionTier,
133 summary: ChatMessage,
134 fp_before: CtxFingerprint,
135 fp_after: CtxFingerprint,
136 usage: TokenUsage,
137 },
138 CancelRequested {
139 run_id: RunId,
140 reason: SmolStr,
141 },
142 PlanItemUpdated {
143 run_id: RunId,
144 idx: usize,
145 state: crate::plan::ItemState,
146 #[serde(default, skip_serializing_if = "Option::is_none")]
147 evidence: Option<ArtifactRef>,
148 },
149 UserMessageInjected {
150 run_id: RunId,
151 text: SmolStr,
152 },
153 ArtifactClaimed {
154 run_id: RunId,
155 step: StepNo,
156 artifact_id: ArtifactId,
157 artifact_kind: ArtifactKind,
158 path: SmolStr,
159 },
160 ArtifactSealed {
161 run_id: RunId,
162 artifact_id: ArtifactId,
163 artifact: ArtifactRef,
164 },
165 ArtifactAbandoned {
166 run_id: RunId,
167 artifact_id: ArtifactId,
168 reason: SmolStr,
169 },
170 SkillManifestLoaded {
171 run_id: RunId,
172 skills: Vec<SkillEntry>,
173 },
174 SkillLoaded {
175 run_id: RunId,
176 step: StepNo,
177 name: SmolStr,
178 hash: SmolStr,
179 size_bytes: u64,
180 body: String,
181 },
182 SkillRejected {
183 run_id: RunId,
184 step: StepNo,
185 name: SmolStr,
186 reason: SmolStr,
187 },
188 SnapshotTaken {
189 run_id: RunId,
190 seq: Seq,
191 fp: CtxFingerprint,
192 },
193 LeaseReleased {
194 run_id: RunId,
195 worker: crate::ids::WorkerId,
196 reason: SmolStr,
197 },
198 RunFinished {
199 run_id: RunId,
200 terminal: TerminalState,
201 usage: TokenUsage,
202 steps: StepNo,
203 #[serde(default, skip_serializing_if = "Option::is_none")]
204 summary: Option<SmolStr>,
205 },
206}
207
208#[must_use]
209fn is_zero(v: &u32) -> bool {
210 *v == 0
211}
212
213impl AgentEvent {
214 /// 事件流第一个 `run_started` 携带的 schema 版本(无则 0,旧流)。
215 #[must_use]
216 pub fn schema_version_of(events: &[AgentEvent]) -> u32 {
217 events
218 .iter()
219 .find_map(|ev| match ev {
220 AgentEvent::RunStarted { schema_version, .. } => Some(*schema_version),
221 _ => None,
222 })
223 .unwrap_or(0)
224 }
225
226 #[must_use]
227 pub fn kind(&self) -> &'static str {
228 match self {
229 Self::RunStarted { .. } => "run_started",
230 Self::StateEntered { .. } => "state_entered",
231 Self::PlanAdopted { .. } => "plan_adopted",
232 Self::PlanRevised { .. } => "plan_revised",
233 Self::StepStarted { .. } => "step_started",
234 Self::LlmCompleted { .. } => "llm_completed",
235 Self::ToolCallStarted { .. } => "tool_call_started",
236 Self::ToolCallFinished { .. } => "tool_call_finished",
237 Self::ApprovalRequested { .. } => "approval_requested",
238 Self::ApprovalDecided { .. } => "approval_decided",
239 Self::ContextCompacted { .. } => "context_compacted",
240 Self::UserMessageInjected { .. } => "user_message_injected",
241 Self::CancelRequested { .. } => "cancel_requested",
242 Self::PlanItemUpdated { .. } => "plan_item_updated",
243 Self::ArtifactClaimed { .. } => "artifact_claimed",
244 Self::ArtifactSealed { .. } => "artifact_sealed",
245 Self::ArtifactAbandoned { .. } => "artifact_abandoned",
246 Self::SkillManifestLoaded { .. } => "skill_manifest_loaded",
247 Self::SkillLoaded { .. } => "skill_loaded",
248 Self::SkillRejected { .. } => "skill_rejected",
249 Self::SnapshotTaken { .. } => "snapshot_taken",
250 Self::LeaseReleased { .. } => "lease_released",
251 Self::RunFinished { .. } => "run_finished",
252 }
253 }
254
255 #[must_use]
256 pub fn run_id(&self) -> RunId {
257 match self {
258 Self::RunStarted { run_id, .. }
259 | Self::StateEntered { run_id, .. }
260 | Self::PlanAdopted { run_id, .. }
261 | Self::PlanRevised { run_id, .. }
262 | Self::StepStarted { run_id, .. }
263 | Self::LlmCompleted { run_id, .. }
264 | Self::ToolCallStarted { run_id, .. }
265 | Self::ToolCallFinished { run_id, .. }
266 | Self::ApprovalRequested { run_id, .. }
267 | Self::ApprovalDecided { run_id, .. }
268 | Self::ContextCompacted { run_id, .. }
269 | Self::UserMessageInjected { run_id, .. }
270 | Self::CancelRequested { run_id, .. }
271 | Self::PlanItemUpdated { run_id, .. }
272 | Self::ArtifactClaimed { run_id, .. }
273 | Self::ArtifactSealed { run_id, .. }
274 | Self::ArtifactAbandoned { run_id, .. }
275 | Self::SkillManifestLoaded { run_id, .. }
276 | Self::SkillLoaded { run_id, .. }
277 | Self::SkillRejected { run_id, .. }
278 | Self::SnapshotTaken { run_id, .. }
279 | Self::LeaseReleased { run_id, .. }
280 | Self::RunFinished { run_id, .. } => *run_id,
281 }
282 }
283
284 #[must_use]
285 pub fn step_started(run_id: RunId, step: StepNo, ctx_fp: CtxFingerprint) -> Self {
286 Self::StepStarted { run_id, step, ctx_fp }
287 }
288
289 #[must_use]
290 pub fn state_entered(run_id: RunId, from: RunState, trigger: Transition) -> Option<Self> {
291 crate::state::next_state(from, trigger)
292 .map(|to| Self::StateEntered { run_id, from, to, trigger })
293 .ok()
294 }
295
296 #[must_use]
297 pub fn failed(run_id: RunId, reason: FailReason, usage: TokenUsage, steps: StepNo) -> Self {
298 Self::RunFinished {
299 run_id,
300 terminal: TerminalState::Failed { reason },
301 usage,
302 steps,
303 summary: None,
304 }
305 }
306}
307
308impl AgentEvent {
309 #[must_use]
310 pub fn is_terminal(&self) -> bool {
311 matches!(self, Self::RunFinished { .. })
312 }
313}
314
315#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
316pub struct EventEnvelope {
317 pub run_id: RunId,
318 pub seq: Seq,
319 pub event: AgentEvent,
320 pub occurred_at_millis: i64,
321 pub prev_hash: Option<crate::ids::EventHash>,
322 pub hash: crate::ids::EventHash,
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328 use crate::plan::Goal;
329
330 #[test]
331 fn serde_roundtrip_preserves_variants() {
332 let ev = AgentEvent::RunStarted {
333 run_id: RunId::new(),
334 task_id: TaskId::new(),
335 tenant: TenantId::new(),
336 goal: Goal {
337 text: "调研 X 并产出报告".into(),
338 constraints: vec!["不超过 20 个来源".into()],
339 inputs: Vec::new(),
340 history: Vec::new(),
341 },
342 limits: crate::budget::RunLimits::default(),
343 session_id: None,
344 schema_version: EVENT_SCHEMA_VERSION,
345 };
346 let json = serde_json::to_string(&ev).unwrap();
347 assert!(json.contains("\"kind\":\"run_started\""), "{json}");
348 let back: AgentEvent = serde_json::from_str(&json).unwrap();
349 assert_eq!(back, ev);
350 assert_eq!(back.run_id(), ev.run_id());
351 }
352
353 #[test]
354 fn legacy_run_started_without_schema_version_deserializes_as_zero() {
355 let legacy = r#"{
356 "kind": "run_started",
357 "run_id": "0f4d2f10-8e58-4a90-9f52-6b13d5470001",
358 "task_id": "0f4d2f10-8e58-4a90-9f52-6b13d5470002",
359 "tenant": "0f4d2f10-8e58-4a90-9f52-6b13d5470003",
360 "goal": {"text": "g", "constraints": [], "inputs": [], "history": []},
361 "limits": {"max_steps": 160, "max_tokens": 2000000, "max_wall_millis": 2700000}
362 }"#;
363 let ev: AgentEvent = serde_json::from_str(legacy).unwrap();
364 assert_eq!(AgentEvent::schema_version_of(&[ev]), 0);
365 }
366
367 #[test]
368 fn schema_version_of_reads_the_first_run_started() {
369 let ev = AgentEvent::RunStarted {
370 run_id: RunId::new(),
371 task_id: TaskId::new(),
372 tenant: TenantId::new(),
373 goal: Goal {
374 text: "g".into(),
375 constraints: Vec::new(),
376 inputs: Vec::new(),
377 history: Vec::new(),
378 },
379 limits: crate::budget::RunLimits::default(),
380 session_id: None,
381 schema_version: EVENT_SCHEMA_VERSION,
382 };
383 assert_eq!(AgentEvent::schema_version_of(&[ev]), EVENT_SCHEMA_VERSION);
384 assert_eq!(AgentEvent::schema_version_of(&[]), 0);
385 }
386
387 #[test]
388 fn state_entered_helper_rejects_illegal_edges() {
389 assert!(
390 AgentEvent::state_entered(RunId::new(), RunState::Completed, Transition::Cancel)
391 .is_none()
392 );
393 let ev =
394 AgentEvent::state_entered(RunId::new(), RunState::Pending, Transition::ClaimedFresh)
395 .expect("legal");
396 assert_eq!(ev.kind(), "state_entered");
397 }
398}
Added lib/agent-types/src/ids.rs lib/agent-types/src/ids.rs +250 −0 View lib/agent-types/src/ids.rs
@@ -0,0 +1,250 @@
1use std::fmt;
2use std::str::FromStr;
3
4use serde::{Deserialize, Deserializer, Serialize, Serializer};
5use sha2::Digest as _;
6use sha2::Sha256;
7use uuid::Uuid;
8
9macro_rules! uuid_id {
10 ($(#[$doc:meta])* $name:ident) => {
11 $(#[$doc])*
12 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
13 #[serde(transparent)]
14 pub struct $name(Uuid);
15
16 impl $name {
17 #[must_use]
18 pub fn new() -> Self {
19 Self(Uuid::now_v7())
20 }
21
22 #[must_use]
23 pub const fn from_uuid(id: Uuid) -> Self {
24 Self(id)
25 }
26
27 #[must_use]
28 pub const fn as_uuid(&self) -> Uuid {
29 self.0
30 }
31 }
32
33 impl Default for $name {
34 fn default() -> Self {
35 Self::new()
36 }
37 }
38
39 impl fmt::Display for $name {
40 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41 write!(f, "{}", self.0)
42 }
43 }
44
45 impl FromStr for $name {
46 type Err = uuid::Error;
47
48 fn from_str(s: &str) -> Result<Self, Self::Err> {
49 Ok(Self(Uuid::parse_str(s)?))
50 }
51 }
52 };
53}
54
55uuid_id! {
56 TaskId
57}
58uuid_id! {
59 RunId
60}
61uuid_id! {
62 TenantId
63}
64uuid_id! {
65 PlanId
66}
67uuid_id! {
68 CallId
69}
70uuid_id! {
71 MsgId
72}
73uuid_id! {
74 WorkerId
75}
76uuid_id! {
77 ArtifactId
78}
79
80uuid_id! {
81 SessionId
82}
83
84uuid_id! {
85 MessageId
86}
87
88uuid_id! {
89 GrantId
90}
91
92pub type Seq = u64;
93pub type StepNo = u32;
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
96pub struct CtxFingerprint(pub [u8; 8]);
97
98impl CtxFingerprint {
99 #[must_use]
100 pub fn digest(parts: &[&[u8]]) -> Self {
101 let mut h = Sha256::new();
102 for p in parts {
103 h.update((p.len() as u64).to_le_bytes());
104 h.update(p);
105 }
106 let out: [u8; 32] = h.finalize().into();
107 let mut fp = [0u8; 8];
108 fp.copy_from_slice(&out[..8]);
109 Self(fp)
110 }
111
112 #[must_use]
113 pub fn as_hex(&self) -> String {
114 hex::encode(self.0)
115 }
116}
117
118impl fmt::Display for CtxFingerprint {
119 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120 f.write_str(&self.as_hex())
121 }
122}
123
124impl FromStr for CtxFingerprint {
125 type Err = hex::FromHexError;
126
127 fn from_str(s: &str) -> Result<Self, Self::Err> {
128 let bytes = hex::decode(s)?;
129 let fp: [u8; 8] = bytes
130 .as_slice()
131 .try_into()
132 .map_err(|_| hex::FromHexError::InvalidHexCharacter { c: '?', index: 0 })?;
133 Ok(Self(fp))
134 }
135}
136
137impl Serialize for CtxFingerprint {
138 fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
139 s.serialize_str(&self.as_hex())
140 }
141}
142
143impl<'de> Deserialize<'de> for CtxFingerprint {
144 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
145 let s = String::deserialize(d)?;
146 Self::from_str(&s).map_err(serde::de::Error::custom)
147 }
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
151pub struct EventHash(pub [u8; 32]);
152
153impl EventHash {
154 #[must_use]
155 pub fn chain(prev: Option<EventHash>, run_id: RunId, seq: Seq, payload_json: &[u8]) -> Self {
156 let mut h = Sha256::new();
157 match prev {
158 None => h.update([0u8; 32]),
159 Some(p) => h.update(p.0),
160 }
161 h.update(run_id.as_uuid().as_bytes());
162 h.update(seq.to_le_bytes());
163 h.update(payload_json);
164 Self(h.finalize().into())
165 }
166
167 #[must_use]
168 pub const fn zero() -> Self {
169 Self([0u8; 32])
170 }
171
172 #[must_use]
173 pub fn as_hex(&self) -> String {
174 hex::encode(self.0)
175 }
176}
177
178impl fmt::Display for EventHash {
179 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
180 f.write_str(&self.as_hex())
181 }
182}
183
184impl FromStr for EventHash {
185 type Err = hex::FromHexError;
186
187 fn from_str(s: &str) -> Result<Self, Self::Err> {
188 let bytes = hex::decode(s)?;
189 let arr: [u8; 32] = bytes
190 .as_slice()
191 .try_into()
192 .map_err(|_| hex::FromHexError::InvalidHexCharacter { c: '?', index: 0 })?;
193 Ok(Self(arr))
194 }
195}
196
197impl Serialize for EventHash {
198 fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
199 s.serialize_str(&self.as_hex())
200 }
201}
202
203impl<'de> Deserialize<'de> for EventHash {
204 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
205 let s = String::deserialize(d)?;
206 Self::from_str(&s).map_err(serde::de::Error::custom)
207 }
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213
214 #[test]
215 fn roundtrip_display_and_fromstr() {
216 let id = RunId::new();
217 let text = id.to_string();
218 let parsed: RunId = text.parse().unwrap();
219 assert_eq!(id, parsed);
220 }
221
222 #[test]
223 fn serde_is_transparent() {
224 let id = TaskId::new();
225 let json = serde_json::to_string(&id).unwrap();
226 assert_eq!(json, format!("\"{}\"", id));
227 let back: TaskId = serde_json::from_str(&json).unwrap();
228 assert_eq!(back, id);
229 }
230
231 #[test]
232 fn fingerprint_is_stable_and_order_sensitive() {
233 let a = CtxFingerprint::digest(&[b"system", b"work"]);
234 let b = CtxFingerprint::digest(&[b"system", b"work"]);
235 let c = CtxFingerprint::digest(&[b"work", b"system"]);
236 assert_eq!(a, b);
237 assert_ne!(a, c);
238 assert_eq!(a.to_string().len(), 16);
239 }
240
241 #[test]
242 fn event_hash_chain_detects_tampering() {
243 let id = RunId::new();
244 let h1 = EventHash::chain(None, id, 1, b"{}");
245 let h2 = EventHash::chain(Some(h1), id, 2, b"{}");
246 let h2b = EventHash::chain(Some(h1), id, 2, b"{\"x\":1}");
247 assert_ne!(h2, h2b);
248 assert_eq!(h2, EventHash::chain(Some(h1), id, 2, b"{}"));
249 }
250}
Added lib/agent-types/src/lib.rs lib/agent-types/src/lib.rs +46 −0 View lib/agent-types/src/lib.rs
@@ -0,0 +1,46 @@
1#![warn(clippy::unwrap_used, clippy::expect_used)]
2
3pub mod api;
4pub mod artifact;
5pub mod budget;
6pub mod consts;
7pub mod context;
8pub mod decision;
9pub mod event;
10pub mod ids;
11pub mod llm;
12pub mod plan;
13pub mod queue;
14pub mod quota;
15pub mod sandbox;
16pub mod session;
17pub mod skill;
18pub mod state;
19pub mod tool;
20pub mod trace;
21
22use std::time::Duration;
23
24#[must_use]
25pub fn now_millis() -> i64 {
26 chrono::Utc::now().timestamp_millis()
27}
28
29#[must_use]
30pub fn duration_to_millis(d: Duration) -> u64 {
31 u64::try_from(d.as_millis()).unwrap_or(u64::MAX)
32}
33
34#[must_use]
35pub fn checksum_of(bytes: &[u8]) -> String {
36 use sha2::Digest as _;
37 let mut h = sha2::Sha256::new();
38 sha2::Digest::update(&mut h, bytes);
39 let out: [u8; 32] = sha2::Digest::finalize(h).into();
40 format!("sha256:{}", hex::encode(out))
41}
42
43#[must_use]
44pub fn millis_to_duration(ms: u64) -> Duration {
45 Duration::from_millis(ms)
46}
Added lib/agent-types/src/llm.rs lib/agent-types/src/llm.rs +293 −0 View lib/agent-types/src/llm.rs
@@ -0,0 +1,293 @@
1use serde::Deserialize;
2use serde::Serialize;
3use smol_str::SmolStr;
4
5use crate::ids::CallId;
6use crate::tool::ToolCallSpec;
7
8#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
9#[serde(transparent)]
10pub struct ModelId(pub SmolStr);
11
12impl ModelId {
13 #[must_use]
14 pub fn new(name: &str) -> Self {
15 Self(name.into())
16 }
17
18 #[must_use]
19 pub fn as_str(&self) -> &str {
20 &self.0
21 }
22
23 #[must_use]
24 pub fn is_empty(&self) -> bool {
25 self.0.is_empty()
26 }
27}
28
29impl std::fmt::Display for ModelId {
30 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31 f.write_str(&self.0)
32 }
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
36#[serde(rename_all = "lowercase")]
37pub enum ChatRole {
38 System,
39 User,
40 Assistant,
41 Tool,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
45pub struct ChatMessage {
46 pub role: ChatRole,
47 pub content: String,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub call_id: Option<CallId>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub name: Option<SmolStr>,
52}
53
54impl ChatMessage {
55 #[must_use]
56 pub fn new(role: ChatRole, content: impl Into<String>) -> Self {
57 Self { role, content: content.into(), call_id: None, name: None }
58 }
59
60 #[must_use]
61 pub fn system(content: impl Into<String>) -> Self {
62 Self::new(ChatRole::System, content)
63 }
64
65 #[must_use]
66 pub const fn role_str(&self) -> &'static str {
67 match self.role {
68 ChatRole::System => "system",
69 ChatRole::User => "user",
70 ChatRole::Assistant => "assistant",
71 ChatRole::Tool => "tool",
72 }
73 }
74
75 #[must_use]
76 pub fn estimate_tokens(&self) -> u32 {
77 crate::context::estimate_tokens(&self.content)
78 }
79}
80
81#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
82pub struct ChatRequest {
83 pub model: ModelId,
84 pub messages: Vec<ChatMessage>,
85 pub tools: Vec<crate::tool::ToolManifest>,
86 pub max_tokens: u32,
87 pub temperature: f32,
88 pub deadline_ms: u64,
89 #[serde(default)]
90 pub parallel_tool_calls: bool,
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
94#[serde(rename_all = "snake_case")]
95pub enum FinishReason {
96 Stop,
97 ToolCalls,
98 Length,
99 ContentFilter,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(tag = "chunk", rename_all = "snake_case")]
104pub enum StreamChunk {
105 TextDelta {
106 delta: SmolStr,
107 },
108 ToolCallDelta {
109 index: u32,
110 #[serde(default, skip_serializing_if = "Option::is_none")]
111 call_id: Option<CallId>,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
113 name: Option<SmolStr>,
114 arguments_delta: String,
115 },
116 Usage {
117 usage: TokenUsage,
118 },
119 Done {
120 reason: FinishReason,
121 },
122}
123
124#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
125pub struct ChatResponse {
126 pub text: String,
127 pub tool_calls: Vec<ToolCallSpec>,
128 pub usage: TokenUsage,
129 pub finish_reason: Option<FinishReason>,
130}
131
132#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
133pub struct TokenUsage {
134 pub prompt: u32,
135 pub completion: u32,
136 pub cached: u32,
137}
138
139impl TokenUsage {
140 #[must_use]
141 pub fn billable_prompt(&self) -> u32 {
142 self.prompt.saturating_sub(self.cached)
143 }
144
145 #[must_use]
146 pub fn total(&self) -> u32 {
147 self.prompt.saturating_add(self.completion)
148 }
149}
150
151impl std::ops::Add for TokenUsage {
152 type Output = TokenUsage;
153
154 fn add(self, rhs: Self) -> Self::Output {
155 Self::Output {
156 prompt: self.prompt.saturating_add(rhs.prompt),
157 completion: self.completion.saturating_add(rhs.completion),
158 cached: self.cached.saturating_add(rhs.cached),
159 }
160 }
161}
162
163impl std::ops::AddAssign for TokenUsage {
164 fn add_assign(&mut self, rhs: Self) {
165 *self = *self + rhs;
166 }
167}
168
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
170pub struct LlmReceipt {
171 pub model: ModelId,
172 pub degraded: bool,
173 pub attempts: u8,
174 pub usage: TokenUsage,
175}
176
177#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
178pub enum LlmError {
179 #[error("llm http error: status={status}")]
180 Http { status: u16, detail: SmolStr },
181 #[error("llm rate limited")]
182 RateLimited { retry_after_ms: Option<u64> },
183 #[error("llm deadline exceeded")]
184 Timeout,
185 #[error("context length exceeded: requested={requested}")]
186 ContextOverflow { requested: u32 },
187 #[error("llm config/argument error: {}", detail)]
188 Config { detail: SmolStr },
189 #[error("llm stream decode error: {}", detail)]
190 Stream { detail: SmolStr },
191 #[error("llm call cancelled")]
192 Cancelled,
193}
194
195impl LlmError {
196 #[must_use]
197 pub fn is_retryable(&self) -> bool {
198 match self {
199 Self::RateLimited { .. } | Self::Timeout => true,
200 Self::Http { status, .. } => *status >= 500,
201 Self::Config { .. } | Self::ContextOverflow { .. } | Self::Cancelled => false,
202 Self::Stream { .. } => true,
203 }
204 }
205}
206
207#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
208pub struct RetryPolicy {
209 pub max_attempts: u8,
210 pub base_ms: u64,
211 pub jitter: f32,
212}
213
214impl Default for RetryPolicy {
215 fn default() -> Self {
216 Self { max_attempts: 4, base_ms: 250, jitter: 0.3 }
217 }
218}
219
220impl RetryPolicy {
221 #[must_use]
222 /// 第 `attempt` 次重试的退避时长(毫秒),含等量抖动。
223 ///
224 /// 抖动不是为了安全,而是为了打散并发 run 的重试节奏:没有抖动时,
225 /// 同一批 run 会在同一时刻一起重试,等于自我 DDoS。
226 pub fn backoff_ms(&self, attempt: u8) -> u64 {
227 let shift = attempt.saturating_sub(1).min(16) as u32;
228 let base = self.base_ms.saturating_mul(1u64 << shift);
229 let ratio = f64::from(self.jitter.clamp(0.0, 1.0));
230 if ratio <= 0.0 {
231 return base;
232 }
233 let spread = (base as f64 * ratio) as u64;
234 if spread == 0 {
235 return base;
236 }
237 let low = base.saturating_sub(spread / 2);
238 low.saturating_add(rand::random::<u64>() % spread)
239 }
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245
246 #[test]
247 fn token_usage_saturates_and_splits_billing() {
248 let u = TokenUsage { prompt: 1_000, completion: 200, cached: 400 };
249 assert_eq!(u.billable_prompt(), 600);
250 assert_eq!(u.total(), 1_200);
251 assert_eq!((u + u).total(), 2_400);
252 }
253
254 #[test]
255 fn retryability_matches_5_2() {
256 assert!(LlmError::Http { status: 503, detail: "".into() }.is_retryable());
257 assert!(LlmError::RateLimited { retry_after_ms: Some(10) }.is_retryable());
258 assert!(!LlmError::Http { status: 401, detail: "".into() }.is_retryable());
259 assert!(!LlmError::ContextOverflow { requested: 200_000 }.is_retryable());
260 }
261
262 #[test]
263 fn backoff_is_exponential() {
264 // 关掉抖动后退避是精确的指数序列(抖动只覆盖叠加量,不改变指数底数)
265 let p = RetryPolicy { jitter: 0.0, ..RetryPolicy::default() };
266 assert_eq!(p.backoff_ms(1), 250);
267 assert_eq!(p.backoff_ms(3), 1_000);
268 assert_eq!(p.backoff_ms(4), 2_000);
269 }
270
271 #[test]
272 fn backoff_jitter_stays_within_band() {
273 let p = RetryPolicy::default(); // jitter = 0.3
274 let base = 1_000u64; // attempt 3 的基准
275 let spread = 300u64;
276 let low = base - spread / 2;
277 for _ in 0..64 {
278 let v = p.backoff_ms(3);
279 assert!(
280 (low..low + spread).contains(&v),
281 "抖动后的退避 {v} 必须落在 [{low}, {}) 内",
282 low + spread
283 );
284 }
285 }
286
287 #[test]
288 fn stream_chunk_serde_is_tagged() {
289 let c = StreamChunk::TextDelta { delta: "hel".into() };
290 let json = serde_json::to_string(&c).unwrap();
291 assert!(json.starts_with("{\"chunk\":\"text_delta\""), "{json}");
292 }
293}
Added lib/agent-types/src/plan.rs lib/agent-types/src/plan.rs +303 −0 View lib/agent-types/src/plan.rs
@@ -0,0 +1,303 @@
1use serde::Deserialize;
2use serde::Serialize;
3use smol_str::SmolStr;
4
5use crate::artifact::ArtifactRef;
6use crate::ids::{PlanId, RunId};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum ItemState {
11 Pending,
12 InProgress,
13 Done,
14 Skipped,
15 Failed,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19pub struct PlanItem {
20 pub idx: usize,
21 pub title: SmolStr,
22 pub done: ItemState,
23 #[serde(default)]
24 pub depends_on: Vec<usize>,
25 #[serde(default)]
26 pub evidence: Vec<ArtifactRef>,
27 #[serde(default)]
28 pub failures: u8,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct Plan {
33 pub id: PlanId,
34 pub run_id: RunId,
35 pub goal_digest: SmolStr,
36 pub items: Vec<PlanItem>,
37 pub revision: u32,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41pub struct Goal {
42 pub text: SmolStr,
43 #[serde(default)]
44 pub constraints: Vec<SmolStr>,
45 #[serde(default)]
46 pub inputs: Vec<ArtifactRef>,
47 #[serde(default, skip_serializing_if = "Vec::is_empty")]
48 pub history: Vec<crate::llm::ChatMessage>,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "snake_case")]
53pub enum ReplanReason {
54 RepeatedFailure { idx: usize },
55 EvidenceStale { idx: usize },
56 UserConstraint { text: SmolStr },
57 CompactionLostEvidence { idx: usize },
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
61pub enum PlanError {
62 #[error("plan has no items")]
63 Empty,
64 #[error("item idx {idx} is out of range (len {len})")]
65 IndexGap { idx: usize, len: usize },
66 #[error("item {idx} depends on unknown item {dep}")]
67 UnknownDep { idx: usize, dep: usize },
68 #[error("item {idx} depends on itself")]
69 SelfDep { idx: usize },
70 #[error("dependency cycle involving item {idx}")]
71 Cycle { idx: usize },
72 #[error("item {idx} is Done without evidence")]
73 DoneWithoutEvidence { idx: usize },
74}
75
76impl Plan {
77 pub fn validate(&self, require_evidence: bool) -> Result<(), PlanError> {
78 if self.items.is_empty() {
79 return Err(PlanError::Empty);
80 }
81 for (pos, item) in self.items.iter().enumerate() {
82 if item.idx != pos {
83 return Err(PlanError::IndexGap { idx: item.idx, len: self.items.len() });
84 }
85 if require_evidence && item.done == ItemState::Done && item.evidence.is_empty() {
86 return Err(PlanError::DoneWithoutEvidence { idx: item.idx });
87 }
88 for dep in &item.depends_on {
89 if *dep == item.idx {
90 return Err(PlanError::SelfDep { idx: item.idx });
91 }
92 if *dep >= self.items.len() {
93 return Err(PlanError::UnknownDep { idx: item.idx, dep: *dep });
94 }
95 }
96 }
97 for item in &self.items {
98 if item.depends_on.iter().any(|dep| *dep > item.idx) {
99 return Err(PlanError::Cycle { idx: item.idx });
100 }
101 }
102 Ok(())
103 }
104
105 #[must_use]
106 pub fn next_actionable(&self) -> Option<&PlanItem> {
107 // 用 get() 而非下标:未通过 validate 的 Plan 里依赖可能越界,
108 // 此时按"未满足"处理(返回 None),而不是索引 panic。
109 let satisfied = |item: &PlanItem| {
110 item.depends_on.iter().all(|dep| {
111 self.items
112 .get(*dep)
113 .is_some_and(|d| matches!(d.done, ItemState::Done | ItemState::Skipped))
114 })
115 };
116 self.items.iter().find(|i| i.done == ItemState::Pending && satisfied(i))
117 }
118
119 #[must_use]
120 pub fn completion_ratio(&self) -> f32 {
121 if self.items.is_empty() {
122 return 0.0;
123 }
124 let done = self
125 .items
126 .iter()
127 .filter(|i| matches!(i.done, ItemState::Done | ItemState::Skipped))
128 .count();
129 done as f32 / self.items.len() as f32
130 }
131
132 #[must_use]
133 pub fn is_settled(&self) -> bool {
134 self.items.iter().all(|i| !matches!(i.done, ItemState::Pending | ItemState::InProgress))
135 }
136
137 pub fn record_failure(&mut self, idx: usize) -> bool {
138 if let Some(item) = self.items.get_mut(idx) {
139 item.failures = item.failures.saturating_add(1);
140 item.done = ItemState::Failed;
141 return item.failures >= 2;
142 }
143 false
144 }
145
146 pub fn set_state(&mut self, idx: usize, state: ItemState) -> bool {
147 match self.items.get_mut(idx) {
148 Some(item) if item.done != state => {
149 item.done = state;
150 true
151 }
152 _ => false,
153 }
154 }
155
156 pub fn attach_evidence(&mut self, idx: usize, artifact: &crate::artifact::ArtifactRef) -> bool {
157 let Some(item) = self.items.get_mut(idx) else {
158 return false;
159 };
160 if item.evidence.iter().any(|e| e.id == artifact.id) {
161 return false;
162 }
163 item.evidence.push(artifact.clone());
164 true
165 }
166
167 #[must_use]
168 pub fn progress_index(&self) -> Option<usize> {
169 let satisfied = |item: &PlanItem| {
170 item.depends_on.iter().all(|dep| {
171 self.items
172 .get(*dep)
173 .is_some_and(|d| matches!(d.done, ItemState::Done | ItemState::Skipped))
174 })
175 };
176 self.items.iter().position(|i| i.done == ItemState::InProgress && satisfied(i)).or_else(
177 || self.items.iter().position(|i| i.done == ItemState::Pending && satisfied(i)),
178 )
179 }
180
181 #[must_use]
182 pub fn revise(&self, mut next: Plan) -> Plan {
183 next.revision = self.revision + 1;
184 next.id = self.id;
185 next.run_id = self.run_id;
186 for item in &mut next.items {
187 if let Some(prev) = self.items.get(item.idx)
188 && prev.done == ItemState::Done
189 && !prev.evidence.is_empty()
190 {
191 item.evidence.clone_from(&prev.evidence);
192 item.done = ItemState::Done;
193 }
194 }
195 next
196 }
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202 use crate::artifact::ObjectRef;
203 use crate::ids::ArtifactId;
204
205 fn item(idx: usize, title: &str, deps: &[usize]) -> PlanItem {
206 PlanItem {
207 idx,
208 title: title.into(),
209 done: ItemState::Pending,
210 depends_on: deps.to_vec(),
211 evidence: Vec::new(),
212 failures: 0,
213 }
214 }
215
216 fn plan(items: Vec<PlanItem>) -> Plan {
217 Plan {
218 id: PlanId::new(),
219 run_id: RunId::new(),
220 goal_digest: "调研 X 并产出报告".into(),
221 items,
222 revision: 0,
223 }
224 }
225
226 fn artifact() -> ArtifactRef {
227 ArtifactRef {
228 id: ArtifactId::new(),
229 kind: crate::artifact::ArtifactKind::Report,
230 object: ObjectRef {
231 bucket: "agent".into(),
232 key: "t/task/run/report/a".into(),
233 etag: None,
234 },
235 size_bytes: 1,
236 checksum: None,
237 }
238 }
239
240 #[test]
241 fn rejects_gaps_and_cycles() {
242 let bad = plan(vec![item(0, "a", &[]), item(2, "b", &[0])]);
243 assert_eq!(bad.validate(false), Err(PlanError::IndexGap { idx: 2, len: 2 }));
244 let cyc = plan(vec![item(0, "a", &[1]), item(1, "b", &[])]);
245 assert_eq!(cyc.validate(false), Err(PlanError::Cycle { idx: 0 }));
246 let self_dep = plan(vec![item(0, "a", &[0])]);
247 assert_eq!(self_dep.validate(false), Err(PlanError::SelfDep { idx: 0 }));
248 assert_eq!(plan(Vec::new()).validate(false), Err(PlanError::Empty));
249 }
250
251 #[test]
252 fn done_requires_evidence_when_verifying() {
253 let mut p = plan(vec![item(0, "抓取", &[])]);
254 p.items[0].done = ItemState::Done;
255 assert_eq!(p.validate(true), Err(PlanError::DoneWithoutEvidence { idx: 0 }));
256 p.items[0].evidence.push(artifact());
257 assert_eq!(p.validate(true), Ok(()));
258 }
259
260 #[test]
261 fn next_actionable_respects_dependencies() {
262 let mut p = plan(vec![item(0, "抓取", &[]), item(1, "汇总", &[0])]);
263 assert_eq!(p.next_actionable().map(|i| i.idx), Some(0));
264 p.items[0].done = ItemState::Done;
265 p.items[0].evidence.push(artifact());
266 assert_eq!(p.next_actionable().map(|i| i.idx), Some(1));
267 assert!(!p.is_settled());
268 }
269
270 #[test]
271 fn two_failures_trigger_replan() {
272 let mut p = plan(vec![item(0, "抓取", &[])]);
273 assert!(!p.record_failure(0));
274 assert!(p.record_failure(0), "连续两次失败应触发重规划(4.1 节)");
275 }
276
277 #[test]
278 fn revise_inherits_evidence_and_bumps_revision() {
279 let mut old = plan(vec![item(0, "抓取", &[]), item(1, "汇总", &[0])]);
280 old.items[0].done = ItemState::Done;
281 old.items[0].evidence.push(artifact());
282 let new = old.revise(plan(vec![
283 item(0, "抓取", &[]),
284 item(1, "换源汇总", &[0]),
285 item(2, "出图", &[1]),
286 ]));
287 assert_eq!(new.revision, 1);
288 assert_eq!(new.items[0].done, ItemState::Done, "已完成项必须复用证据");
289 assert_eq!(new.items[0].evidence.len(), 1);
290 assert_eq!(new.items[1].done, ItemState::Pending);
291 }
292
293 #[test]
294 fn completion_ratio_counts_skipped_as_settled() {
295 let mut p = plan(vec![item(0, "a", &[]), item(1, "b", &[])]);
296 p.items[0].done = ItemState::Done;
297 p.items[0].evidence.push(artifact());
298 p.items[1].done = ItemState::Skipped;
299 assert!((p.completion_ratio() - 1.0).abs() < f32::EPSILON);
300 assert!(p.is_settled());
301 assert_eq!(p.next_actionable(), None);
302 }
303}
Added lib/agent-types/src/queue.rs lib/agent-types/src/queue.rs +177 −0 View lib/agent-types/src/queue.rs
@@ -0,0 +1,177 @@
1use serde::Deserialize;
2use serde::Serialize;
3use smol_str::SmolStr;
4
5use crate::consts;
6use crate::ids::{MsgId, RunId, TenantId, WorkerId};
7
8#[derive(
9 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default,
10)]
11#[serde(rename_all = "snake_case")]
12pub enum Priority {
13 #[default]
14 Interactive = 0,
15 Batch = 1,
16 Maintenance = 2,
17}
18
19impl Priority {
20 #[must_use]
21 pub const fn group(self) -> &'static str {
22 match self {
23 Self::Interactive => "interactive",
24 Self::Batch => "batch",
25 Self::Maintenance => "maintenance",
26 }
27 }
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub struct JobEnvelope {
32 pub msg_id: MsgId,
33 pub run_id: RunId,
34 pub tenant: TenantId,
35 pub priority: Priority,
36 pub attempt: u32,
37 pub not_before_millis: i64,
38 #[serde(default)]
39 pub fingerprint: SmolStr,
40 /// 全链路追踪标识。
41 ///
42 /// Redis 队列是整条链路上**唯一**会丢失调用栈上下文的地方(提交方与消费方
43 /// 是两个任务/两个进程),所以必须显式随信封带过去。`#[serde(default)]`
44 /// 保证队列里已在途的旧消息仍能反序列化。
45 #[serde(default)]
46 pub trace_id: SmolStr,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(tag = "outcome", rename_all = "snake_case")]
51pub enum JobOutcome {
52 Done,
53 Failed { retry: bool },
54 Deferred { after_ms: u64 },
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
58pub struct Lease {
59 pub msg_id: MsgId,
60 pub worker: WorkerId,
61 pub lease_until_millis: i64,
62 pub ttl_ms: u64,
63}
64
65impl Lease {
66 #[must_use]
67 pub fn new(msg_id: MsgId, worker: WorkerId, ttl: std::time::Duration, now_millis: i64) -> Self {
68 Self {
69 msg_id,
70 worker,
71 lease_until_millis: now_millis.saturating_add(ttl.as_millis() as i64),
72 ttl_ms: ttl.as_millis() as u64,
73 }
74 }
75
76 #[must_use]
77 pub fn is_valid(&self, now_millis: i64) -> bool {
78 now_millis < self.lease_until_millis
79 }
80
81 #[must_use]
82 pub fn covers_step(&self, now_millis: i64, step_budget: std::time::Duration) -> bool {
83 let remaining = self.lease_until_millis.saturating_sub(now_millis).max(0) as u64;
84 remaining >= step_budget.as_millis() as u64
85 }
86
87 pub fn renew(&mut self, now_millis: i64) {
88 self.lease_until_millis =
89 now_millis.saturating_add(i64::try_from(self.ttl_ms).unwrap_or(i64::MAX));
90 }
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
94pub struct LeasePolicy {
95 pub ttl_ms: u64,
96 pub heartbeat_ms: u64,
97 pub reclaim_grace_ms: u64,
98}
99
100impl Default for LeasePolicy {
101 fn default() -> Self {
102 Self {
103 ttl_ms: consts::LEASE_TTL.as_millis() as u64,
104 heartbeat_ms: consts::LEASE_HEARTBEAT.as_millis() as u64,
105 reclaim_grace_ms: consts::LEASE_HEARTBEAT.as_millis() as u64 * 2,
106 }
107 }
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
111pub struct FairnessPolicy {
112 pub tenant_soft_cap: u32,
113 pub demote_priority: Priority,
114}
115
116impl Default for FairnessPolicy {
117 fn default() -> Self {
118 Self {
119 tenant_soft_cap: consts::TENANT_CONCURRENCY_SOFT_CAP,
120 demote_priority: Priority::Maintenance,
121 }
122 }
123}
124
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
126pub struct BackpressurePolicy {
127 pub high_watermark: u32,
128 pub low_watermark: u32,
129}
130
131impl Default for BackpressurePolicy {
132 fn default() -> Self {
133 Self { high_watermark: 500, low_watermark: 200 }
134 }
135}
136
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub enum Admission {
139 Accept,
140 Reject,
141 Demote(Priority),
142}
143
144#[must_use]
145pub fn admit(
146 priority: Priority,
147 backlog: u32,
148 tenant_inflight: u32,
149 bp: &BackpressurePolicy,
150 fair: &FairnessPolicy,
151) -> Admission {
152 if backlog >= bp.high_watermark && priority != Priority::Interactive {
153 return Admission::Reject;
154 }
155 if tenant_inflight >= fair.tenant_soft_cap {
156 return Admission::Demote(fair.demote_priority);
157 }
158 Admission::Accept
159}
160
161#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
162pub enum QueueError {
163 #[error("unknown message {0}")]
164 UnknownMsg(MsgId),
165 #[error("lease lost for run {0}")]
166 LeaseLost(RunId),
167 /// 该消息的租约已被回收并被其他 worker 重新领取,本 worker 的确认是过期的。
168 ///
169 /// 必须拒绝:否则僵尸 worker 会 HDEL 掉新持有者正在使用的 payload,
170 /// 并重复递减租户在飞计数(导致计数漂移 + 任务丢失)。
171 #[error("stale ack for message {0}: 租约已易主,拒绝过期确认")]
172 StaleAck(MsgId),
173 #[error("poison job after {attempt} attempts")]
174 Poison { attempt: u32 },
175 #[error("queue backend error: {0}")]
176 Backend(SmolStr),
177}
Added lib/agent-types/src/quota.rs lib/agent-types/src/quota.rs +116 −0 View lib/agent-types/src/quota.rs
@@ -0,0 +1,116 @@
1use serde::Deserialize;
2use serde::Serialize;
3
4use crate::ids::TenantId;
5
6#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
7pub struct TenantQuota {
8 pub tenant: TenantId,
9 pub rpm: u32,
10 pub burst: u32,
11 pub max_concurrent_runs: u32,
12 pub token_budget_per_run: u32,
13 pub sandbox_mem_mib: u32,
14 pub retention_days: i64,
15}
16
17impl Default for TenantQuota {
18 fn default() -> Self {
19 Self {
20 tenant: TenantId::default(),
21 rpm: 60,
22 burst: 20,
23 max_concurrent_runs: 16,
24 token_budget_per_run: 2_000_000,
25 sandbox_mem_mib: 65_536,
26 retention_days: crate::consts::ARTIFACT_RETENTION_DAYS,
27 }
28 }
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct TokenBucket {
33 capacity_milli: i64,
34 refill_per_sec_milli: i64,
35 tokens_milli: i64,
36 last_millis: i64,
37}
38
39impl TokenBucket {
40 #[must_use]
41 pub fn new(rpm: u32, burst: u32, now_millis: i64) -> Self {
42 let capacity = i64::from(burst) * 1_000;
43 Self {
44 capacity_milli: capacity.max(1_000),
45 refill_per_sec_milli: i64::from(rpm) * 1_000 / 60,
46 tokens_milli: capacity.max(1_000),
47 last_millis: now_millis,
48 }
49 }
50
51 /// 该桶在 `now_millis` 时是否已经回满。
52 ///
53 /// 回满的桶与全新建的桶行为完全一致,因此可以从限流表里安全剔除——
54 /// 这是防止「按调用方可控的键无限增长内存」的关键手段。
55 #[must_use]
56 pub fn is_idle_at(&self, now_millis: i64) -> bool {
57 let elapsed = now_millis.saturating_sub(self.last_millis).max(0);
58 let refill = elapsed.saturating_mul(self.refill_per_sec_milli) / 1_000;
59 self.tokens_milli.saturating_add(refill) >= self.capacity_milli
60 }
61
62 #[must_use]
63 pub fn try_take(&mut self, cost: u32, now_millis: i64) -> RateVerdict {
64 // 全程使用饱和运算:系统时钟前跳或极端 rpm/burst 配置都不得触发
65 // debug 构建的 overflow panic。
66 let elapsed = now_millis.saturating_sub(self.last_millis).max(0);
67 let refill = elapsed.saturating_mul(self.refill_per_sec_milli) / 1_000;
68 self.tokens_milli = self.tokens_milli.saturating_add(refill).min(self.capacity_milli);
69 self.last_millis = now_millis;
70 let need = i64::from(cost).saturating_mul(1_000);
71 if self.tokens_milli >= need {
72 self.tokens_milli -= need;
73 RateVerdict::Allow
74 } else {
75 let missing = need.saturating_sub(self.tokens_milli);
76 let wait_ms = if self.refill_per_sec_milli <= 0 {
77 u64::MAX
78 } else {
79 u64::try_from(missing.saturating_mul(1_000) / self.refill_per_sec_milli)
80 .unwrap_or(u64::MAX)
81 };
82 RateVerdict::Deny { retry_after_ms: wait_ms }
83 }
84 }
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum RateVerdict {
89 Allow,
90 Deny { retry_after_ms: u64 },
91}
92
93#[cfg(test)]
94mod tests {
95 use super::*;
96
97 #[test]
98 fn bucket_refills_linearly() {
99 let mut b = TokenBucket::new(60, 2, 0);
100 assert_eq!(b.try_take(1, 0), RateVerdict::Allow);
101 assert_eq!(b.try_take(1, 0), RateVerdict::Allow);
102 let denied = b.try_take(1, 0);
103 assert!(matches!(denied, RateVerdict::Deny { .. }));
104 assert_eq!(b.try_take(1, 1_000), RateVerdict::Allow, "1 秒后补回 1 个令牌");
105 }
106
107 #[test]
108 fn deny_reports_retry_after() {
109 let mut b = TokenBucket::new(6, 1, 0);
110 assert_eq!(b.try_take(1, 0), RateVerdict::Allow);
111 match b.try_take(1, 0) {
112 RateVerdict::Deny { retry_after_ms } => assert!(retry_after_ms >= 1_000),
113 RateVerdict::Allow => panic!("should be denied"),
114 }
115 }
116}
Added lib/agent-types/src/sandbox.rs lib/agent-types/src/sandbox.rs +300 −0 View lib/agent-types/src/sandbox.rs
@@ -0,0 +1,300 @@
1use serde::Deserialize;
2use serde::Serialize;
3use smol_str::SmolStr;
4
5use crate::ids::TenantId;
6
7#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
8pub struct ImageRef {
9 pub reference: SmolStr,
10 pub toolchain_digest: SmolStr,
11}
12
13#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
14#[serde(tag = "mode", rename_all = "snake_case")]
15pub enum NetPolicy {
16 #[default]
17 NoNet,
18 Allowlist {
19 domains: Vec<SmolStr>,
20 },
21 Full,
22}
23
24/// 沙箱出网时**无条件**禁止访问的 IP。
25///
26/// 判据是"任何正常业务都不该访问,且访问成功即构成安全问题":
27/// - link-local(169.254.0.0/16、fe80::/10):**云元数据服务 169.254.169.254 就在这里**,
28/// 能连上就能换取节点 IAM 凭据。这是本次唯一的无条件拒绝项,也是真实的安全发现。
29/// - 未指定地址(0.0.0.0、::)、广播、组播:没有合法的出网语义。
30/// - IPv6 ULA(fc00::/7)与 IPv4 私有网段同类,按"私有"处理。
31///
32/// **刻意不含 loopback**:127.0.0.0/8 是沙箱自己的回环口,
33/// 本地开发/测试脚手架(例如本仓 golden harness 就显式白名单了 127.0.0.1 的取源服务)
34/// 与同 netns 的 sidecar 都依赖它。把它一律拒掉会误伤正常用法,
35/// 而它并不是凭据泄漏路径(meta 服务在 link-local,不在 loopback)。
36#[must_use]
37pub fn is_unreachable_egress_ip(ip: std::net::IpAddr) -> bool {
38 match ip {
39 std::net::IpAddr::V4(v4) => {
40 v4.is_link_local() || v4.is_unspecified() || v4.is_multicast() || v4.is_broadcast()
41 }
42 std::net::IpAddr::V6(v6) => {
43 v6.is_unspecified()
44 || v6.is_multicast()
45 || (v6.segments()[0] & 0xffc0) == 0xfe80
46 || (v6.segments()[0] & 0xfe00) == 0xfc00
47 }
48 }
49}
50
51/// RFC1918 / CGNAT / loopback 等"内网"地址。
52///
53/// 默认禁止(多租户沙箱不应能扫内网,也不该借回环打到同 netns 的 sidecar 管理口),
54/// 但可用 `AGENT_SANDBOX_ALLOW_PRIVATE_EGRESS=1` 显式放开——部署形态各异,
55/// 有的确实需要访问集群内私有服务或本机回环。
56#[must_use]
57pub fn is_private_egress_ip(ip: std::net::IpAddr) -> bool {
58 match ip {
59 std::net::IpAddr::V4(v4) => {
60 let [a, b, ..] = v4.octets();
61 v4.is_loopback()
62 || a == 10
63 || (a == 172 && (16..=31).contains(&b))
64 || (a == 192 && b == 168)
65 || (a == 100 && (64..=127).contains(&b))
66 }
67 std::net::IpAddr::V6(v6) => v6.is_loopback(),
68 }
69}
70
71/// 私有网段是否被显式放开。
72///
73/// fail-closed:只有取值恰为 `1` 才放开,其余任何取值(含拼错、`true`、空串)一律视为不放开。
74#[must_use]
75pub fn private_egress_allowed() -> bool {
76 matches!(std::env::var("AGENT_SANDBOX_ALLOW_PRIVATE_EGRESS").as_deref(), Ok("1"))
77}
78
79/// 综合判定:沙箱是否允许访问该 IP。
80#[must_use]
81pub fn egress_ip_allowed(ip: std::net::IpAddr) -> bool {
82 !is_unreachable_egress_ip(ip) && (!is_private_egress_ip(ip) || private_egress_allowed())
83}
84
85impl NetPolicy {
86 #[must_use]
87 pub fn allows_host(&self, host: &str) -> bool {
88 let host = host.trim_end_matches('.').to_ascii_lowercase();
89 if host.is_empty() {
90 return false;
91 }
92 let parsed = host.trim_matches(['[', ']']).parse::<std::net::IpAddr>().ok();
93 // 无条件拒绝集(云元数据等):**任何策略、任何显式白名单都不能放行**。
94 // 放行 169.254.169.254 就等于把节点 IAM 凭据交给沙箱,不存在"业务确实需要"的场景。
95 if let Some(ip) = parsed
96 && is_unreachable_egress_ip(ip)
97 {
98 return false;
99 }
100 match self {
101 Self::NoNet => false,
102 Self::Full => match parsed {
103 // Full 也要挡住内网与回环:否则一个 Full 策略就足以把集群内网扫一遍。
104 Some(ip) => !is_private_egress_ip(ip) || private_egress_allowed(),
105 // 域名形式无法在此判定 IP(要等 DNS 解析),交给 k8s NetworkPolicy
106 // 的 CIDR 过滤兜住。
107 None => true,
108 },
109 Self::Allowlist { domains } => {
110 let listed = domains.iter().any(|d| {
111 let d = d.trim().trim_end_matches('.').to_ascii_lowercase();
112 match d.strip_prefix('.') {
113 Some(suffix) => host == suffix || host.ends_with(&format!(".{suffix}")),
114 None => host == d,
115 }
116 });
117 // 显式列出即视为操作者的有意决定,对私有/回环网段同样生效——
118 // 否则本地脚手架(golden harness 白名单了 127.0.0.1 的取源服务)
119 // 与"确实要访问内网服务"的部署都没法表达意图。
120 listed
121 }
122 }
123 }
124}
125
126#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
127#[allow(clippy::derived_hash_with_manual_eq)]
128pub struct SandboxSpec {
129 pub image: ImageRef,
130 pub cpu: f32,
131 pub mem_mib: u32,
132 pub disk_mib: u32,
133 pub net: NetPolicy,
134 pub ttl_ms: u64,
135 pub tenant: TenantId,
136 #[serde(default)]
137 pub runtime_class: SmolStr,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub workspace_key: Option<SmolStr>,
140}
141
142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
143pub struct FetchResult {
144 pub status: u16,
145 pub body: String,
146 pub bytes: u64,
147 pub truncated: bool,
148 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub content_type: Option<SmolStr>,
150}
151
152#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
153pub struct CmdSpec {
154 pub argv: Vec<String>,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub cwd: Option<SmolStr>,
157 #[serde(default)]
158 pub env: std::collections::BTreeMap<SmolStr, SmolStr>,
159 pub timeout_ms: u64,
160 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub stdin: Option<String>,
162}
163
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165pub struct ExecResult {
166 pub exit_code: i32,
167 pub stdout: String,
168 pub stderr: String,
169 pub truncated: bool,
170 pub duration_ms: u64,
171}
172
173#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
174#[serde(tag = "stream", rename_all = "snake_case")]
175pub enum OutputChunk {
176 Stdout { text: String },
177 Stderr { text: String },
178 Exit { code: i32 },
179}
180
181#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
182#[serde(rename_all = "snake_case")]
183pub enum ReleaseMode {
184 ReturnToPool,
185 Destroy,
186}
187
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
189pub struct PoolConfig {
190 pub min_idle: u32,
191 pub max_idle: u32,
192 pub reconcile_interval_ms: u64,
193 pub acquire_timeout_ms: u64,
194}
195
196impl Default for PoolConfig {
197 fn default() -> Self {
198 Self {
199 min_idle: crate::consts::SANDBOX_POOL_MIN_IDLE,
200 max_idle: crate::consts::SANDBOX_POOL_MAX_IDLE,
201 reconcile_interval_ms: 5_000,
202 acquire_timeout_ms: 15_000,
203 }
204 }
205}
206
207#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
208pub enum SbError {
209 #[error("sandbox pool exhausted after {waited_ms}ms")]
210 PoolExhausted { waited_ms: u64 },
211 #[error("sandbox create failed: {detail}")]
212 Create { detail: SmolStr },
213 #[error("sandbox instance lost: {detail}")]
214 Lost { detail: SmolStr },
215 #[error("file gateway error: {detail}")]
216 Gateway { detail: SmolStr },
217 #[error("sandbox ttl exceeded")]
218 TtlExceeded,
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224
225 /// 无条件拒绝集:云元数据等。**任何策略、任何显式白名单都不能放行**。
226 /// 放行 169.254.169.254 等于把节点 IAM 凭据交给沙箱。
227 #[test]
228 fn never_valid_addresses_are_always_denied() {
229 for host in
230 ["169.254.169.254", "0.0.0.0", "255.255.255.255", "224.0.0.1", "fe80::1", "fc00::1"]
231 {
232 assert!(!NetPolicy::Full.allows_host(host), "Full 不得放行 {host}");
233 let listed = NetPolicy::Allowlist { domains: vec![SmolStr::new(host)] };
234 assert!(!listed.allows_host(host), "白名单里显式列出 {host} 也不得放行");
235 assert!(!NetPolicy::NoNet.allows_host(host));
236 }
237 }
238
239 #[test]
240 fn public_addresses_are_allowed() {
241 assert!(NetPolicy::Full.allows_host("8.8.8.8"));
242 assert!(NetPolicy::Full.allows_host("example.com"));
243 let listed = NetPolicy::Allowlist { domains: vec![SmolStr::new("example.com")] };
244 assert!(listed.allows_host("example.com"));
245 }
246
247 /// `Full` 下私有网段与回环默认拒绝,可由 `AGENT_SANDBOX_ALLOW_PRIVATE_EGRESS=1` 放开。
248 #[test]
249 fn full_denies_private_and_loopback_by_default() {
250 let opt_in = private_egress_allowed();
251 for host in ["10.0.0.1", "172.16.0.1", "192.168.1.1", "100.64.0.1", "127.0.0.1", "::1"] {
252 assert_eq!(
253 NetPolicy::Full.allows_host(host),
254 opt_in,
255 "{host} 的判定应跟随 AGENT_SANDBOX_ALLOW_PRIVATE_EGRESS"
256 );
257 }
258 }
259
260 /// 显式白名单是操作者的有意决定,对私有/回环同样生效。
261 /// 本仓的 golden harness 正是靠这一条访问 127.0.0.1 上的取源服务。
262 #[test]
263 fn explicit_allowlist_wins_over_private_default_deny() {
264 let listed = NetPolicy::Allowlist { domains: vec![SmolStr::new("127.0.0.1")] };
265 assert!(listed.allows_host("127.0.0.1"), "显式列出的回环地址应放行");
266 assert!(!listed.allows_host("127.0.0.2"), "未列出的回环地址仍拒绝");
267 assert!(!listed.allows_host("10.0.0.1"), "未列出的私有地址仍拒绝");
268 }
269
270 #[test]
271 fn no_net_denies_everything() {
272 assert!(!NetPolicy::NoNet.allows_host("8.8.8.8"));
273 assert!(!NetPolicy::NoNet.allows_host("example.com"));
274 }
275
276 /// 白名单的后缀匹配语义不能被这次改动破坏。
277 #[test]
278 fn allowlist_suffix_matching_still_works() {
279 let policy = NetPolicy::Allowlist { domains: vec![SmolStr::new(".example.com")] };
280 assert!(policy.allows_host("api.example.com"));
281 assert!(policy.allows_host("example.com"));
282 assert!(!policy.allows_host("evil.com"));
283 }
284
285 #[test]
286 fn egress_verdicts_cover_range_edges() {
287 assert!(is_unreachable_egress_ip("169.254.1.1".parse().unwrap()));
288 assert!(is_unreachable_egress_ip("224.0.0.1".parse().unwrap()));
289 assert!(is_unreachable_egress_ip("fc00::1".parse().unwrap()));
290 assert!(
291 !is_unreachable_egress_ip("127.0.0.1".parse().unwrap()),
292 "loopback 不属于无条件拒绝集(它归在私有网段里,可被显式放开)"
293 );
294 assert!(is_private_egress_ip("127.0.0.1".parse().unwrap()));
295 assert!(is_private_egress_ip("::1".parse().unwrap()));
296 assert!(is_private_egress_ip("172.31.255.255".parse().unwrap()));
297 assert!(!is_private_egress_ip("172.32.0.1".parse().unwrap()), "172.32 不在 RFC1918 内");
298 assert!(!is_private_egress_ip("11.0.0.1".parse().unwrap()));
299 }
300}
Added lib/agent-types/src/session.rs lib/agent-types/src/session.rs +250 −0 View lib/agent-types/src/session.rs
@@ -0,0 +1,250 @@
1use serde::Deserialize;
2use serde::Serialize;
3use smol_str::SmolStr;
4
5use crate::budget::RunLimits;
6use crate::ids::{GrantId, MessageId, RunId, SessionId, TenantId};
7use crate::state::RunState;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
10#[serde(rename_all = "lowercase")]
11pub enum MessageRole {
12 User,
13 Assistant,
14}
15
16impl MessageRole {
17 #[must_use]
18 pub const fn as_str(self) -> &'static str {
19 match self {
20 Self::User => "user",
21 Self::Assistant => "assistant",
22 }
23 }
24}
25
26impl std::fmt::Display for MessageRole {
27 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28 f.write_str(self.as_str())
29 }
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct SessionRow {
34 pub session_id: SessionId,
35 pub title: SmolStr,
36 pub metadata: serde_json::Value,
37 pub created_at_millis: i64,
38 pub updated_at_millis: i64,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub deleted_at_millis: Option<i64>,
41}
42
43impl SessionRow {
44 #[must_use]
45 pub fn new(
46 session_id: SessionId,
47 title: SmolStr,
48 metadata: serde_json::Value,
49 now: i64,
50 ) -> Self {
51 Self {
52 session_id,
53 title,
54 metadata,
55 created_at_millis: now,
56 updated_at_millis: now,
57 deleted_at_millis: None,
58 }
59 }
60
61 #[must_use]
62 pub const fn is_deleted(&self) -> bool {
63 self.deleted_at_millis.is_some()
64 }
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68pub struct MessageRow {
69 pub message_id: MessageId,
70 pub session_id: SessionId,
71 pub seq: u64,
72 pub role: MessageRole,
73 pub content: SmolStr,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub run_id: Option<RunId>,
76 pub metadata: serde_json::Value,
77 pub created_at_millis: i64,
78}
79
80#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
81pub struct SessionDto {
82 pub session_id: SessionId,
83 pub title: SmolStr,
84 pub metadata: serde_json::Value,
85 pub created_at_millis: i64,
86 pub updated_at_millis: i64,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub deleted_at_millis: Option<i64>,
89}
90
91impl From<&SessionRow> for SessionDto {
92 fn from(row: &SessionRow) -> Self {
93 Self {
94 session_id: row.session_id,
95 title: row.title.clone(),
96 metadata: row.metadata.clone(),
97 created_at_millis: row.created_at_millis,
98 updated_at_millis: row.updated_at_millis,
99 deleted_at_millis: row.deleted_at_millis,
100 }
101 }
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105pub struct MessageDto {
106 pub message_id: MessageId,
107 pub session_id: SessionId,
108 pub seq: u64,
109 pub role: MessageRole,
110 pub content: SmolStr,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub run_id: Option<RunId>,
113 pub created_at_millis: i64,
114}
115
116impl From<&MessageRow> for MessageDto {
117 fn from(row: &MessageRow) -> Self {
118 Self {
119 message_id: row.message_id,
120 session_id: row.session_id,
121 seq: row.seq,
122 role: row.role,
123 content: row.content.clone(),
124 run_id: row.run_id,
125 created_at_millis: row.created_at_millis,
126 }
127 }
128}
129
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131pub struct CreateSessionRequest {
132 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub session_id: Option<SessionId>,
134 #[serde(default)]
135 pub title: Option<SmolStr>,
136 #[serde(default)]
137 pub metadata: Option<serde_json::Value>,
138}
139
140#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
141#[serde(rename_all = "snake_case")]
142pub enum SubjectKindWire {
143 User,
144 Service,
145 Agent,
146}
147
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
149pub struct CreateSessionTokenRequest {
150 pub tenant: crate::ids::TenantId,
151 pub subject: SmolStr,
152 #[serde(default)]
153 pub subject_kind: Option<SubjectKindWire>,
154 #[serde(default)]
155 pub ttl_secs: Option<u64>,
156 /// 显式权限子集,必须落在用途上限之内;超界即 400。
157 /// 缺省保持既有行为(用途上限 ∩ 主体类型基线),即不改变现存调用方语义。
158 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub scopes: Option<Vec<SmolStr>>,
160}
161
162/// 会话绑定登记行:`(tenant, session)` 组合构成的授权。
163///
164/// 会话本身不带租户归属——因为「换会话 / 换租户即作废」隐含会话可以被重新绑定到
165/// 另一个租户,所以 `(tenant, session)` 是一份**可变、可吊销的授权**,不是会话的固有属性。
166/// 「同一时刻仅一行活跃」这一约束由 `0004_session_grants.sql` 的两个部分唯一索引
167/// 在数据库层强制,不依赖任何调用方纪律。
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
169pub struct SessionGrantRow {
170 pub grant_id: GrantId,
171 pub tenant: TenantId,
172 pub session_id: SessionId,
173 pub subject: SmolStr,
174 pub subject_kind: SubjectKindWire,
175 /// 权威权限集合(`resource:action` 字面量)。校验时与令牌的用途上限取交集,
176 /// 因此权限收窄对已签发但未过期的令牌立即生效。
177 pub scopes: Vec<SmolStr>,
178 pub issued_at_millis: i64,
179 pub expires_at_millis: i64,
180 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub revoked_at_millis: Option<i64>,
182}
183
184impl SessionGrantRow {
185 #[must_use]
186 pub const fn is_live(&self, now_millis: i64) -> bool {
187 self.revoked_at_millis.is_none() && now_millis < self.expires_at_millis
188 }
189}
190
191/// 换绑结果。冲突时携带导致冲突的活跃绑定,便于调用方自愈或返回 409。
192#[derive(Debug, Clone, PartialEq, Eq)]
193pub enum GrantBindOutcome {
194 Bound,
195 Conflict { active: Box<SessionGrantRow> },
196}
197
198#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
199pub struct SendMessageRequest {
200 pub text: SmolStr,
201 #[serde(default, skip_serializing_if = "Option::is_none")]
202 pub idempotency_key: Option<SmolStr>,
203 #[serde(default, skip_serializing_if = "Option::is_none")]
204 pub limits: Option<RunLimits>,
205}
206
207#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
208pub struct MessageAccepted {
209 pub message_id: MessageId,
210 pub session_id: SessionId,
211 pub seq: u64,
212 pub run_id: RunId,
213 pub state: RunState,
214 pub events_url: SmolStr,
215}
216
217#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
218pub struct SessionTokenDto {
219 pub token: SmolStr,
220 pub session_id: SessionId,
221 pub tenant: crate::ids::TenantId,
222 pub expires_in_secs: u64,
223 /// 绑定登记 id:可信后端可据此吊销或清点本次授权。
224 pub grant_id: GrantId,
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230
231 #[test]
232 fn message_role_serializes_lowercase() {
233 assert_eq!(serde_json::to_string(&MessageRole::Assistant).unwrap(), "\"assistant\"");
234 assert_eq!(serde_json::to_string(&MessageRole::User).unwrap(), "\"user\"");
235 }
236
237 #[test]
238 fn send_message_request_defaults_optionals() {
239 let req: SendMessageRequest = serde_json::from_str(r#"{"text":"你好"}"#).unwrap();
240 assert_eq!(req.text, "你好");
241 assert!(req.idempotency_key.is_none());
242 assert!(req.limits.is_none());
243 }
244
245 #[test]
246 fn session_row_soft_delete_flag() {
247 let row = SessionRow::new(SessionId::new(), "t".into(), serde_json::json!({}), 1);
248 assert!(!row.is_deleted());
249 }
250}
Added lib/agent-types/src/skill.rs lib/agent-types/src/skill.rs +42 −0 View lib/agent-types/src/skill.rs
@@ -0,0 +1,42 @@
1use serde::Deserialize;
2use serde::Serialize;
3use smol_str::SmolStr;
4
5use crate::consts::SKILL_BODY_MAX_BYTES;
6
7/// 一个已发现的技能:`SKILL.md` 的 frontmatter 摘要 + 正文哈希。
8///
9/// `hash` 是 SKILL.md 原始字节的 `sha256:<hex>`(见 `crate::checksum_of`)。
10/// 事件流以该结构钉住清单,重放时不再读取本机文件,从而让 `ctx_fp` 可复现。
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12pub struct SkillEntry {
13 pub name: SmolStr,
14 pub description: SmolStr,
15 pub hash: SmolStr,
16}
17
18impl SkillEntry {
19 #[must_use]
20 pub fn new(
21 name: impl Into<SmolStr>,
22 description: impl Into<SmolStr>,
23 hash: impl Into<SmolStr>,
24 ) -> Self {
25 Self { name: name.into(), description: description.into(), hash: hash.into() }
26 }
27}
28
29/// 技能名的合法字符集:ASCII 字母数字与 `.` `_` `-`;长度 1..=64;不得是 `.` / `..`。
30#[must_use]
31pub fn valid_skill_name(name: &str) -> bool {
32 if name.is_empty() || name.len() > 64 || name == "." || name == ".." {
33 return false;
34 }
35 name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
36}
37
38/// 技能正文是否超出行内上限(按字节计)。
39#[must_use]
40pub fn body_within_limit(body: &str) -> bool {
41 body.len() <= SKILL_BODY_MAX_BYTES
42}
Added lib/agent-types/src/state.rs lib/agent-types/src/state.rs +165 −0 View lib/agent-types/src/state.rs
@@ -0,0 +1,165 @@
1use serde::Deserialize;
2use serde::Serialize;
3use smol_str::SmolStr;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
6#[serde(rename_all = "snake_case")]
7pub enum RunState {
8 Pending,
9 Planning,
10 Executing,
11 AwaitingApproval,
12 Verifying,
13 Deferred,
14 Completed,
15 Failed,
16 Cancelled,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum Transition {
22 ClaimedFresh,
23 ClaimedResume,
24 PlanAdopted,
25 StepFinished,
26 FinishRequested,
27 ApprovalRequired,
28 ApprovalGranted,
29 ApprovalDenied,
30 VerificationPassed,
31 VerificationFailed,
32 Defer,
33 Resume,
34 Cancel,
35 BudgetExhausted,
36 Fatal,
37 LeaseReleased,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
41#[serde(tag = "kind", rename_all = "snake_case")]
42pub enum FailReason {
43 StepBudget,
44 TokenBudget,
45 WallclockBudget,
46 Fatal { detail: SmolStr },
47 SandboxExhausted,
48 PoisonPill,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
52#[serde(tag = "kind", rename_all = "snake_case")]
53pub enum TerminalState {
54 Completed,
55 Failed { reason: FailReason },
56 Cancelled,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
60pub enum TransitionError {
61 #[error("illegal transition: {from:?} --{trigger:?}--> (no target)")]
62 Illegal { from: RunState, trigger: Transition },
63 #[error("state {0:?} is terminal")]
64 Terminal(RunState),
65}
66
67impl RunState {
68 pub const ALL: [RunState; 9] = [
69 RunState::Pending,
70 RunState::Planning,
71 RunState::Executing,
72 RunState::AwaitingApproval,
73 RunState::Verifying,
74 RunState::Deferred,
75 RunState::Completed,
76 RunState::Failed,
77 RunState::Cancelled,
78 ];
79
80 pub const TRIGGERS: [Transition; 16] = [
81 Transition::ClaimedFresh,
82 Transition::ClaimedResume,
83 Transition::PlanAdopted,
84 Transition::StepFinished,
85 Transition::FinishRequested,
86 Transition::ApprovalRequired,
87 Transition::ApprovalGranted,
88 Transition::ApprovalDenied,
89 Transition::VerificationPassed,
90 Transition::VerificationFailed,
91 Transition::Defer,
92 Transition::Resume,
93 Transition::Cancel,
94 Transition::BudgetExhausted,
95 Transition::Fatal,
96 Transition::LeaseReleased,
97 ];
98
99 #[must_use]
100 pub fn is_terminal(self) -> bool {
101 matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
102 }
103
104 #[must_use]
105 pub fn holds_lease(self) -> bool {
106 matches!(self, Self::Planning | Self::Executing | Self::Verifying)
107 }
108
109 #[must_use]
110 pub fn may_hold_sandbox(self) -> bool {
111 matches!(self, Self::Executing | Self::Verifying)
112 }
113
114 #[must_use]
115 pub fn is_cancellable(self) -> bool {
116 !self.is_terminal()
117 }
118}
119
120pub fn next_state(from: RunState, trigger: Transition) -> Result<RunState, TransitionError> {
121 use RunState::*;
122 if from.is_terminal() {
123 return Err(TransitionError::Terminal(from));
124 }
125 let to = match (from, trigger) {
126 (_, Transition::Cancel) => Cancelled,
127 (_, Transition::LeaseReleased) => Pending,
128
129 (Pending, Transition::ClaimedFresh) => Planning,
130 (Pending, Transition::ClaimedResume) => Executing,
131 (Pending, Transition::Resume) => Pending,
132 (Pending, Transition::BudgetExhausted) => Failed,
133
134 (Planning, Transition::PlanAdopted) => Executing,
135 (Planning, Transition::Defer) => Deferred,
136 (Planning, Transition::BudgetExhausted) => Failed,
137 (Planning, Transition::Fatal) => Failed,
138
139 (Executing, Transition::StepFinished) => Executing,
140 (Executing, Transition::FinishRequested) => Verifying,
141 (Executing, Transition::ApprovalRequired) => AwaitingApproval,
142 (Executing, Transition::VerificationFailed) => Planning,
143 (Executing, Transition::Defer) => Deferred,
144 (Executing, Transition::BudgetExhausted) => Failed,
145 (Executing, Transition::Fatal) => Failed,
146
147 (AwaitingApproval, Transition::ApprovalGranted) => Executing,
148 (AwaitingApproval, Transition::ApprovalDenied) => Executing,
149 (AwaitingApproval, Transition::Defer) => Deferred,
150
151 (Verifying, Transition::VerificationPassed) => Completed,
152 (Verifying, Transition::VerificationFailed) => Planning,
153 (Verifying, Transition::ApprovalRequired) => AwaitingApproval,
154 (Verifying, Transition::StepFinished) => Verifying,
155 (Verifying, Transition::BudgetExhausted) => Failed,
156 (Verifying, Transition::Fatal) => Failed,
157
158 (Deferred, Transition::Resume) => Pending,
159 (Deferred, Transition::BudgetExhausted) => Failed,
160 (Deferred, Transition::Fatal) => Failed,
161
162 (from, trigger) => return Err(TransitionError::Illegal { from, trigger }),
163 };
164 Ok(to)
165}
Added lib/agent-types/src/tool.rs lib/agent-types/src/tool.rs +417 −0 View lib/agent-types/src/tool.rs
@@ -0,0 +1,417 @@
1use serde::Deserialize;
2use serde::Serialize;
3use smol_str::SmolStr;
4
5use crate::artifact::ArtifactRef;
6use crate::ids::{CallId, RunId, StepNo, TenantId};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum RiskLevel {
11 ReadOnly,
12 Mutating,
13 Dangerous,
14}
15
16impl RiskLevel {
17 #[must_use]
18 pub const fn as_str(self) -> &'static str {
19 match self {
20 Self::ReadOnly => "read_only",
21 Self::Mutating => "mutating",
22 Self::Dangerous => "dangerous",
23 }
24 }
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum StepBudgetHint {
30 Quick,
31 Standard,
32 Long,
33}
34
35impl StepBudgetHint {
36 #[must_use]
37 pub const fn exec_timeout_millis(self) -> u64 {
38 match self {
39 Self::Quick => 5_000,
40 Self::Standard => 60_000,
41 Self::Long => 600_000,
42 }
43 }
44}
45
46#[derive(Debug, Clone, PartialEq, Hash, Serialize, Deserialize, Default)]
47#[serde(transparent)]
48pub struct JsonSchema(pub serde_json::Value);
49
50impl JsonSchema {
51 #[must_use]
52 pub fn any() -> Self {
53 Self(serde_json::json!({}))
54 }
55
56 #[must_use]
57 pub fn from_value(v: serde_json::Value) -> Self {
58 Self(v)
59 }
60
61 #[must_use]
62 pub fn raw(&self) -> &serde_json::Value {
63 &self.0
64 }
65
66 pub fn validate(&self, args: &serde_json::Value) -> Result<(), Vec<SchemaViolation>> {
67 let mut errs = Vec::new();
68 check(&self.0, args, "$", &mut errs);
69 if errs.is_empty() { Ok(()) } else { Err(errs) }
70 }
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
74pub struct SchemaViolation {
75 pub path: SmolStr,
76 pub reason: SmolStr,
77}
78
79fn type_name(v: &serde_json::Value) -> &'static str {
80 match v {
81 serde_json::Value::Null => "null",
82 serde_json::Value::Bool(_) => "boolean",
83 serde_json::Value::Number(n) => {
84 if n.is_i64() || n.is_u64() {
85 "integer"
86 } else {
87 "number"
88 }
89 }
90 serde_json::Value::String(_) => "string",
91 serde_json::Value::Array(_) => "array",
92 serde_json::Value::Object(_) => "object",
93 }
94}
95
96fn type_matches(expected: &str, value: &serde_json::Value) -> bool {
97 match expected {
98 "object" => value.is_object(),
99 "array" => value.is_array(),
100 "string" => value.is_string(),
101 "boolean" => value.is_boolean(),
102 "number" => value.is_number(),
103 "integer" => {
104 value.is_i64() || value.is_u64() || value.as_f64().is_some_and(|f| f.fract() == 0.0)
105 }
106 "null" => value.is_null(),
107 _ => true,
108 }
109}
110
111fn check(
112 schema: &serde_json::Value,
113 value: &serde_json::Value,
114 path: &str,
115 errs: &mut Vec<SchemaViolation>,
116) {
117 let Some(obj) = schema.as_object() else {
118 return;
119 };
120 if let Some(allowed) = obj.get("enum").and_then(|e| e.as_array()) {
121 if !allowed.iter().any(|a| a == value) {
122 errs.push(SchemaViolation {
123 path: path.into(),
124 reason: format!("值必须是 {:?} 之一", allowed).into(),
125 });
126 }
127 }
128 if let Some(tys) = obj.get("type") {
129 let expected: Vec<&str> = match tys {
130 serde_json::Value::String(s) => vec![s.as_str()],
131 serde_json::Value::Array(a) => a.iter().filter_map(serde_json::Value::as_str).collect(),
132 _ => Vec::new(),
133 };
134 if !expected.is_empty() && !expected.iter().any(|t| type_matches(t, value)) {
135 errs.push(SchemaViolation {
136 path: path.into(),
137 reason: format!("期望类型 {},实际 {}", expected.join("|"), type_name(value))
138 .into(),
139 });
140 return;
141 }
142 }
143 if let Some(props) = obj.get("properties").and_then(|p| p.as_object()) {
144 if let Some(map) = value.as_object() {
145 for (key, sub) in props {
146 if let Some(child) = map.get(key) {
147 check(sub, child, &format!("{path}.{key}"), errs);
148 }
149 }
150 }
151 }
152 if let Some(required) = obj.get("required").and_then(|r| r.as_array()) {
153 if let Some(map) = value.as_object() {
154 for key in required.iter().filter_map(serde_json::Value::as_str) {
155 if !map.contains_key(key) {
156 errs.push(SchemaViolation {
157 path: format!("{path}.{key}").into(),
158 reason: "缺少必填参数".into(),
159 });
160 }
161 }
162 }
163 }
164 if let Some(items) = obj.get("items") {
165 if let Some(arr) = value.as_array() {
166 for (i, el) in arr.iter().enumerate() {
167 check(items, el, &format!("{path}[{i}]"), errs);
168 }
169 }
170 }
171 // 以下四项此前**完全没有实现**,于是工具 schema 里写的 "minimum": 1 / "maxLength": 4096
172 // 全是摆设:模型传 timeout_ms=0 或一个超长字符串都能静默通过校验。
173 if let Some(n) = value.as_f64() {
174 let bound = |key: &str| obj.get(key).and_then(serde_json::Value::as_f64);
175 if let Some(min) = bound("minimum")
176 && n < min
177 {
178 errs.push(SchemaViolation {
179 path: path.into(),
180 reason: format!("{n} 小于下界 {min}").into(),
181 });
182 }
183 if let Some(max) = bound("maximum")
184 && n > max
185 {
186 errs.push(SchemaViolation {
187 path: path.into(),
188 reason: format!("{n} 大于上界 {max}").into(),
189 });
190 }
191 if let Some(step) = bound("multipleOf")
192 && step > 0.0
193 && (n / step).fract().abs() > f64::EPSILON
194 {
195 errs.push(SchemaViolation {
196 path: path.into(),
197 reason: format!("{n} 不是 {step} 的整数倍").into(),
198 });
199 }
200 }
201 if let Some(s) = value.as_str() {
202 let chars = s.chars().count() as f64;
203 if let Some(max) = obj.get("maxLength").and_then(serde_json::Value::as_f64)
204 && chars > max
205 {
206 errs.push(SchemaViolation {
207 path: path.into(),
208 reason: format!("字符串长度 {chars} 超过上限 {max}").into(),
209 });
210 }
211 if let Some(min) = obj.get("minLength").and_then(serde_json::Value::as_f64)
212 && chars < min
213 {
214 errs.push(SchemaViolation {
215 path: path.into(),
216 reason: format!("字符串长度 {chars} 小于下限 {min}").into(),
217 });
218 }
219 }
220 if let Some(arr) = value.as_array() {
221 let len = arr.len() as f64;
222 if let Some(max) = obj.get("maxItems").and_then(serde_json::Value::as_f64)
223 && len > max
224 {
225 errs.push(SchemaViolation {
226 path: path.into(),
227 reason: format!("数组长度 {len} 超过上限 {max}").into(),
228 });
229 }
230 if let Some(min) = obj.get("minItems").and_then(serde_json::Value::as_f64)
231 && len < min
232 {
233 errs.push(SchemaViolation {
234 path: path.into(),
235 reason: format!("数组长度 {len} 小于下限 {min}").into(),
236 });
237 }
238 }
239 // 未知字段:`additionalProperties: false` 时拒绝,避免模型把参数名写错却静默通过。
240 if obj.get("additionalProperties") == Some(&serde_json::Value::Bool(false))
241 && let Some(map) = value.as_object()
242 {
243 let props = obj.get("properties").and_then(|p| p.as_object());
244 for key in map.keys() {
245 let known = props.is_some_and(|p| p.contains_key(key));
246 if !known {
247 errs.push(SchemaViolation {
248 path: format!("{path}.{key}").into(),
249 reason: "不接受的参数(additionalProperties: false)".into(),
250 });
251 }
252 }
253 }
254}
255
256/// 工具副作用的可逆性声明(对标「可逆性上界」分类:Idempotent / Reversible /
257/// Compensable / Irreversible 的简化三档)。
258///
259/// 用途:审批理由与工具清单里向人/模型透出「这次动作能不能撤回」,
260/// 为后续的自动重试与回滚策略(Earliest-Conflict Rollback 等)留下元数据。
261#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
262#[serde(rename_all = "snake_case")]
263pub enum Reversibility {
264 /// 同参数重复执行收敛到同一结果(幂等)。
265 #[default]
266 Idempotent,
267 /// 可以用反向操作撤回(例如删除前有备份的写)。
268 Reversible,
269 /// 无法自动撤回;只能人工补偿或接受后果。
270 Irreversible,
271}
272
273impl Reversibility {
274 #[must_use]
275 pub fn as_str(self) -> &'static str {
276 match self {
277 Self::Idempotent => "idempotent",
278 Self::Reversible => "reversible",
279 Self::Irreversible => "irreversible",
280 }
281 }
282}
283
284#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
285pub struct ToolManifest {
286 pub name: SmolStr,
287 pub description: SmolStr,
288 pub schema: JsonSchema,
289 pub risk: RiskLevel,
290 /// 副作用可逆性
291 #[serde(default)]
292 pub reversibility: Reversibility,
293}
294
295#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
296pub struct ToolCallSpec {
297 pub call_id: CallId,
298 pub name: SmolStr,
299 pub args: serde_json::Value,
300}
301
302#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
303pub struct ToolCall {
304 pub spec: ToolCallSpec,
305 pub run_id: RunId,
306 pub step: StepNo,
307 pub tenant: TenantId,
308}
309
310#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
311pub struct ToolOutput {
312 pub observation: SmolStr,
313 #[serde(default)]
314 pub artifacts: Vec<ArtifactRef>,
315 #[serde(default)]
316 pub truncated: bool,
317 #[serde(default)]
318 pub exit_code: i32,
319}
320
321#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
322#[serde(rename_all = "snake_case")]
323pub enum ToolErrorCode {
324 NotFound,
325 SchemaViolation,
326 RejectedByPolicy,
327 AwaitingApproval,
328 Sandbox,
329 Timeout,
330 Cancelled,
331 Internal,
332}
333
334#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
335#[serde(tag = "status", rename_all = "snake_case")]
336pub enum ToolOutcome {
337 Ok { exit_code: i32, output: ToolOutput, duration_ms: u64 },
338 Err { code: ToolErrorCode, message: SmolStr, duration_ms: u64 },
339 Timeout { limit_ms: u64 },
340 Interrupted,
341}
342
343impl ToolOutcome {
344 #[must_use]
345 pub fn is_success(&self) -> bool {
346 matches!(self, Self::Ok { .. })
347 }
348}
349
350#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
351pub struct PolicySet {
352 pub approval_at: RiskLevel,
353 pub allowlist: Option<Vec<SmolStr>>,
354 pub max_risk: RiskLevel,
355}
356
357impl Default for PolicySet {
358 fn default() -> Self {
359 // 审批门槛默认压到 Mutating:内置工具的最高风险级就是 Mutating,
360 // 若默认仍在 Dangerous,审批链路在默认配置下永远休眠。
361 // 无人值守场景(golden 回归、压测脚本)必须显式豁免。
362 Self { approval_at: RiskLevel::Mutating, allowlist: None, max_risk: RiskLevel::Dangerous }
363 }
364}
365
366#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
367#[serde(rename_all = "snake_case")]
368pub enum Authorization {
369 Execute,
370 RequireApproval,
371 Reject,
372}
373
374/// 参数字段名里出现这些子串时,值不往任何外部送:事件流、审批面、决策模型。
375///
376/// 三处共用同一个函数,口径不会分叉(否则会出现「日志里看得见、审批里看不见」
377/// 这类无法解释的差异)。
378pub fn redact_secret_args(args: &serde_json::Value) -> serde_json::Value {
379 const SECRET_HINTS: &[&str] = &["key", "token", "secret", "password", "authorization"];
380 fn scrub(value: &mut serde_json::Value) {
381 match value {
382 serde_json::Value::Object(map) => {
383 for (k, v) in map.iter_mut() {
384 if SECRET_HINTS.iter().any(|hint| k.to_lowercase().contains(hint))
385 && !v.is_object()
386 && !v.is_array()
387 {
388 *v = serde_json::Value::String("***MASKED***".to_string());
389 } else {
390 scrub(v);
391 }
392 }
393 }
394 serde_json::Value::Array(items) => items.iter_mut().for_each(scrub),
395 _ => {}
396 }
397 }
398 let mut out = args.clone();
399 scrub(&mut out);
400 out
401}
402
403#[must_use]
404pub fn authorize(manifest: &ToolManifest, policy: &PolicySet) -> Authorization {
405 if let Some(list) = &policy.allowlist
406 && !list.iter().any(|n| n == &manifest.name)
407 {
408 return Authorization::Reject;
409 }
410 if manifest.risk > policy.max_risk {
411 return Authorization::Reject;
412 }
413 if manifest.risk >= policy.approval_at {
414 return Authorization::RequireApproval;
415 }
416 Authorization::Execute
417}
Added lib/agent-types/src/trace.rs lib/agent-types/src/trace.rs +78 −0 View lib/agent-types/src/trace.rs
@@ -0,0 +1,78 @@
1//! 全链路追踪标识。
2//!
3//! 之前全仓没有任何跨进程关联键:一次 HTTP 请求产生的事件、被投递到 Redis 队列的
4//! 任务、由 Worker 执行的过程、以及最终落在沙箱里的命令,彼此之间没有任何可串起来的
5//! 标识。线上排查只能靠 `run_id` 手工拼,而 `run_id` 在提交之前并不存在。
6//!
7//! 这里采用最小但完整的方案:**一个 trace id 从接入层一路带到 Worker**,
8//! 中间唯一会丢失上下文的地方(Redis 队列)靠 `JobEnvelope.trace_id` 显式携带。
9//!
10//! 采用 W3C Trace Context 的 trace-id 形态(32 位小写十六进制),
11//! 因此可以直接作为上游 `traceparent` 的 trace id 复用,也能原样塞进 OTLP。
12
13use smol_str::SmolStr;
14
15/// 上游可用于传递 trace id 的请求头(按优先级)。
16///
17/// `x-request-id` 放在前面是因为它更常见且语义更宽松;`traceparent` 需要按
18/// W3C 规范从 `00-<trace-id>-<parent-id>-<flags>` 里取出第 2 段。
19pub const TRACE_ID_HEADERS: [&str; 2] = ["x-request-id", "traceparent"];
20
21/// 把 trace id 回写给调用方的响应头。
22pub const TRACE_ID_RESPONSE_HEADER: &str = "x-request-id";
23
24/// 生成一个新的 trace id:32 位小写十六进制(16 字节 CSPRNG)。
25#[must_use]
26pub fn new_trace_id() -> SmolStr {
27 // 用 u128 一次取满 16 字节随机数,避免循环里拼接带来的可预测性顾虑。
28 let bytes: u128 = rand::random();
29 SmolStr::new(format!("{bytes:032x}"))
30}
31
32/// 是否为合法的 W3C trace-id。
33///
34/// 规范要求:32 位小写十六进制,且**不得全为 0**(全 0 是"无效"的保留值)。
35#[must_use]
36pub fn is_valid_trace_id(raw: &str) -> bool {
37 let raw = raw.trim();
38 raw.len() == 32
39 && raw.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
40 && raw.bytes().any(|b| b != b'0')
41}
42
43/// 从候选头值中解析出 trace id。
44///
45/// 支持两种输入:
46/// - 直接的 32 位 hex(`x-request-id` 的常见形态)
47/// - W3C `traceparent`:`<version>-<trace-id>-<parent-id>-<flags>`
48///
49/// 解析失败或非法时返回 `None`,由调用方决定是否新生成——**不要**把非法值
50/// 原样透传下去,否则整条链路会带着一个无法与上游对齐的假 trace id。
51#[must_use]
52pub fn parse_trace_id(header_name: &str, raw: &str) -> Option<SmolStr> {
53 let raw = raw.trim();
54 if header_name.eq_ignore_ascii_case("traceparent") {
55 let mut parts = raw.split('-');
56 let version = parts.next()?;
57 let trace_id = parts.next()?;
58 // version 00 是当前唯一已定义的版本;长度不符一律视为非法。
59 if version.len() != 2 {
60 return None;
61 }
62 return is_valid_trace_id(trace_id).then(|| SmolStr::new(trace_id));
63 }
64 is_valid_trace_id(raw).then(|| SmolStr::new(raw))
65}
66
67/// 从一组候选头里挑出第一个合法的 trace id。
68#[must_use]
69pub fn trace_id_from_headers<'a>(
70 headers: impl IntoIterator<Item = (&'a str, &'a str)>,
71) -> Option<SmolStr> {
72 for (name, value) in headers {
73 if let Some(id) = parse_trace_id(name, value) {
74 return Some(id);
75 }
76 }
77 None
78}
Added lib/agent-types/Cargo.toml lib/agent-types/Cargo.toml +26 −0 View lib/agent-types/Cargo.toml
@@ -0,0 +1,26 @@
1[package]
2name = "agent-types"
3description = "共享类型与不变量:Run 状态机、AgentEvent、计划、工具、LLM、沙箱、队列、产物、上下文窗口、配额、对外 API DTO。"
4version.workspace = true
5edition.workspace = true
6rust-version = "1.85"
7publish = false
8license = "Apache-2.0"
9
10[dependencies]
11async-trait = { workspace = true }
12chrono = { workspace = true }
13hex = { workspace = true }
14rand = { workspace = true }
15serde = { workspace = true }
16serde_json = { workspace = true }
17sha2 = { workspace = true }
18smol_str = { workspace = true }
19thiserror = { workspace = true }
20uuid = { workspace = true }
21
22[lints.rust]
23unsafe_code = "forbid"
24
25[lints.clippy]
26needless_pass_by_value = "allow"
No newline at end of file
Modified Cargo.lock Cargo.lock +57 −0 View Cargo.lock
@@ -168,6 +168,37 @@
168168]
169169
170170[[package]]
171name = "agent-sdk"
172version = "1.0.0"
173dependencies = [
174 "agent-types",
175 "bytes",
176 "futures",
177 "reqwest 0.12.28",
178 "serde",
179 "serde_json",
180 "smol_str",
181 "thiserror 2.0.20",
182 "tokio",
183]
184
185[[package]]
186name = "agent-types"
187version = "1.0.0"
188dependencies = [
189 "async-trait",
190 "chrono",
191 "hex",
192 "rand 0.8.7",
193 "serde",
194 "serde_json",
195 "sha2 0.11.0",
196 "smol_str",
197 "thiserror 2.0.20",
198 "uuid",
199]
200
201[[package]]
171202name = "ahash"
172203version = "0.7.8"
173204source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -7985,6 +8016,7 @@
79858016 "base64 0.22.1",
79868017 "bytes",
79878018 "futures-core",
8019 "futures-util",
79888020 "http 1.5.0",
79898021 "http-body 1.1.0",
79908022 "http-body-util",
@@ -8004,12 +8036,14 @@
80048036 "sync_wrapper",
80058037 "tokio",
80068038 "tokio-rustls",
8039 "tokio-util",
80078040 "tower",
80088041 "tower-http 0.6.11",
80098042 "tower-service",
80108043 "url 2.5.8",
80118044 "wasm-bindgen",
80128045 "wasm-bindgen-futures",
8046 "wasm-streams",
80138047 "web-sys",
80148048 "webpki-roots 1.0.9",
80158049]
@@ -9145,6 +9179,16 @@
91459179]
91469180
91479181[[package]]
9182name = "smol_str"
9183version = "0.3.2"
9184source = "registry+https://github.com/rust-lang/crates.io-index"
9185checksum = "9676b89cd56310a87b93dec47b11af744f34d5fc9f367b829474eec0a891350d"
9186dependencies = [
9187 "borsh",
9188 "serde",
9189]
9190
9191[[package]]
91489192name = "social"
91499193version = "1.0.0"
91509194dependencies = [
@@ -10751,6 +10795,19 @@
1075110795]
1075210796
1075310797[[package]]
10798name = "wasm-streams"
10799version = "0.4.2"
10800source = "registry+https://github.com/rust-lang/crates.io-index"
10801checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
10802dependencies = [
10803 "futures-util",
10804 "js-sys",
10805 "wasm-bindgen",
10806 "wasm-bindgen-futures",
10807 "web-sys",
10808]
10809
10810[[package]]
1075410811name = "web"
1075510812version = "0.1.0"
1075610813
Modified Cargo.toml Cargo.toml +4 −0 View Cargo.toml
@@ -17,6 +17,8 @@
1717[workspace.dependencies]
1818admin = { path = "lib/admin" }
1919agent = { path = "lib/agent" }
20agent-sdk = { path = "lib/agent-sdk" }
21agent-types = { path = "lib/agent-types" }
2022aihost = { path = "app/aihost" }
2123admin-web = { path = "admin" }
2224auth = { path = "lib/auth" }
@@ -78,6 +80,8 @@
7880hmac = "0.13"
7981hkdf = "0.13"
8082uuid = { version = "1", features = ["v7", "serde"] }
83hex = "0.4"
84smol_str = { version = "0.3", features = ["serde"] }
8185argon2 = "0.6.0"
8286aes-gcm = "0.10"
8387time = { version = "0.3", features = ["serde", "serde-human-readable"] }