已合并
Merge feat/super-gateway into dev #210
hypothesier创建于 6月18日
Merge feat/super-gateway into dev #210
已合并
hypothesier创建于 6月18日
168 个文件变更+8502-2079
MCargo.lock+124-64
@@ -4810,70 +4810,6 @@ version = "0.13.2"
4810source = "registry+https://github.com/rust-lang/crates.io-index"4810source = "registry+https://github.com/rust-lang/crates.io-index"
4811checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"4811checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
4812 4812 
4813-[[package]]
4814-name = "xiaoo-app"
4815-version = "0.1.0"
4816-dependencies = [
4817- "aes-gcm",
4818- "agent-contracts",
4819- "agent-llm",
4820- "agent-types",
4821- "anyhow",
4822- "arboard",
4823- "async-trait",
4824- "axum",
4825- "base64",
4826- "chrono",
4827- "clap",
4828- "compact",
4829- "crossterm",
4830- "dirs 6.0.0",
4831- "futures-util",
4832- "glob",
4833- "governor",
4834- "hex",
4835- "hook",
4836- "llm-client",
4837- "lsp",
4838- "memory",
4839- "mime_guess",
4840- "operation_backend",
4841- "parking_lot",
4842- "prompt",
4843- "prost",
4844- "protoc-bin-vendored",
4845- "rand 0.8.6",
4846- "ratatui",
4847- "regex",
4848- "reqwest",
4849- "serde",
4850- "serde_json",
4851- "skill",
4852- "subagent",
4853- "sysinfo",
4854- "tempfile",
4855- "textwrap",
4856- "thiserror 1.0.69",
4857- "tokio",
4858- "tokio-stream",
4859- "tokio-tungstenite",
4860- "tokio-util",
4861- "toml",
4862- "tonic",
4863- "tonic-build",
4864- "tool",
4865- "tower 0.5.3",
4866- "tower_governor",
4867- "trace",
4868- "tracing",
4869- "tracing-subscriber",
4870- "unicode-width 0.1.14",
4871- "url",
4872- "uuid",
4873- "vault",
4874- "xiaoo-core",
4875-]
4876- 
4877[[package]]4813[[package]]
4878name = "xiaoo-core"4814name = "xiaoo-core"
4879version = "0.1.0"4815version = "0.1.0"
@@ -4898,6 +4834,130 @@ dependencies = [
4898 "uuid",4834 "uuid",
4899]4835]
4900 4836 
4837+[[package]]
4838+name = "xiaoo-endside"
4839+version = "0.1.0"
4840+dependencies = [
4841+ "agent-contracts",
4842+ "agent-types",
4843+ "anyhow",
4844+ "arboard",
4845+ "async-trait",
4846+ "base64",
4847+ "chrono",
4848+ "clap",
4849+ "compact",
4850+ "crossterm",
4851+ "dirs 6.0.0",
4852+ "futures-util",
4853+ "llm-client",
4854+ "lsp",
4855+ "operation_backend",
4856+ "ratatui",
4857+ "reqwest",
4858+ "serde",
4859+ "serde_json",
4860+ "skill",
4861+ "tempfile",
4862+ "textwrap",
4863+ "tokio",
4864+ "toml",
4865+ "tool",
4866+ "tracing",
4867+ "tracing-subscriber",
4868+ "unicode-width 0.1.14",
4869+ "uuid",
4870+ "xiaoo-core",
4871+ "xiaoo-shared",
4872+]
4873+ 
4874+[[package]]
4875+name = "xiaoo-serverside"
4876+version = "0.1.0"
4877+dependencies = [
4878+ "agent-contracts",
4879+ "agent-types",
4880+ "anyhow",
4881+ "async-trait",
4882+ "axum",
4883+ "chrono",
4884+ "compact",
4885+ "dirs 6.0.0",
4886+ "futures-util",
4887+ "governor",
4888+ "llm-client",
4889+ "lsp",
4890+ "mime_guess",
4891+ "operation_backend",
4892+ "prompt",
4893+ "prost",
4894+ "regex",
4895+ "reqwest",
4896+ "serde",
4897+ "serde_json",
4898+ "skill",
4899+ "tempfile",
4900+ "thiserror 1.0.69",
4901+ "tokio",
4902+ "tokio-stream",
4903+ "tokio-tungstenite",
4904+ "tokio-util",
4905+ "toml",
4906+ "tool",
4907+ "tower 0.5.3",
4908+ "tower_governor",
4909+ "tracing",
4910+ "tracing-subscriber",
4911+ "url",
4912+ "uuid",
4913+ "xiaoo-shared",
4914+]
4915+ 
4916+[[package]]
4917+name = "xiaoo-shared"
4918+version = "0.1.0"
4919+dependencies = [
4920+ "aes-gcm",
4921+ "agent-contracts",
4922+ "agent-types",
4923+ "anyhow",
4924+ "async-trait",
4925+ "base64",
4926+ "compact",
4927+ "glob",
4928+ "hook",
4929+ "http",
4930+ "llm-client",
4931+ "lsp",
4932+ "memory",
4933+ "operation_backend",
4934+ "parking_lot",
4935+ "prompt",
4936+ "prost",
4937+ "protoc-bin-vendored",
4938+ "rand 0.8.6",
4939+ "reqwest",
4940+ "serde",
4941+ "serde_json",
4942+ "skill",
4943+ "subagent",
4944+ "sysinfo",
4945+ "tempfile",
4946+ "thiserror 1.0.69",
4947+ "tokio",
4948+ "tokio-util",
4949+ "toml",
4950+ "tonic",
4951+ "tonic-build",
4952+ "tool",
4953+ "tower 0.5.3",
4954+ "trace",
4955+ "tracing",
4956+ "uuid",
4957+ "vault",
4958+ "xiaoo-core",
4959+]
4960+ 
4901[[package]]4961[[package]]
4902name = "xml5ever"4962name = "xml5ever"
4903version = "0.38.0"4963version = "0.38.0"
MCargo.toml+3-1
@@ -1,6 +1,8 @@
1[workspace]1[workspace]
2members = [2members = [
3- "apps/xiaoo-app",3+ "apps/shared",
4+ "apps/endside",
5+ "apps/serverside",
4 "apps/vault",6 "apps/vault",
5 "crates/llm-client",7 "crates/llm-client",
6 "crates/llm-client-cli",8 "crates/llm-client-cli",
MREADME.md+14-14
@@ -46,7 +46,7 @@ The runtime also includes a layered memory and adaptive context-compression syst
46```bash46```bash
47git clone https://gitcode.com/openeuler/xiaoO.git47git clone https://gitcode.com/openeuler/xiaoO.git
48cd xiaoO48cd xiaoO
49-cargo install --path apps/xiaoo-app49+cargo install --path apps/endside
50```50```
51 51 
52This installs the application binaries into `~/.cargo/bin` and attempts to install builtin skills. Make sure `~/.cargo/bin` is in your `PATH`.52This installs the application binaries into `~/.cargo/bin` and attempts to install builtin skills. Make sure `~/.cargo/bin` is in your `PATH`.
@@ -60,13 +60,13 @@ This installs the application binaries into `~/.cargo/bin` and attempts to insta
60> - Without these skills, security features may be unavailable.60> - Without these skills, security features may be unavailable.
61>61>
62> **For system-wide installation** (recommended for multi-user environments):62> **For system-wide installation** (recommended for multi-user environments):
63-> - Run `cargo install` with root privileges: `sudo cargo install --path apps/xiaoo-app`63+> - Run `cargo install` with root privileges: `sudo cargo install --path apps/endside`
64 64 
65### Uninstallation65### Uninstallation
66 66 
67```bash67```bash
68# Uninstall binaries68# Uninstall binaries
69-cargo uninstall xiaoo-app69+cargo uninstall xiaoo-endside
70 70 
71# Remove the guardian skill (system level requires root)71# Remove the guardian skill (system level requires root)
72sudo rm -rf /usr/lib/.xiaoo/skills/xiaoo-guardian72sudo rm -rf /usr/lib/.xiaoo/skills/xiaoo-guardian
@@ -131,10 +131,10 @@ Run xiaoO:
131 131 
132```bash132```bash
133# Terminal UI133# Terminal UI
134-xiaoo-tui134+xiaoo
135 135 
136# Single-shot CLI136# Single-shot CLI
137-xiaoo run -p "Count the characters in hello world"137+xiaoo --cli run -p "Count the characters in hello world"
138```138```
139 139 
140Example CLI output:140Example CLI output:
@@ -175,7 +175,7 @@ More details are available in [Memory & Context Compression](./docs/memory_conte
175The TUI status bar shows the current value as `Think off/high/max`. Press `Ctrl+T` to cycle `off -> high -> max -> off` for the next turn. In CLI mode, use:175The TUI status bar shows the current value as `Think off/high/max`. Press `Ctrl+T` to cycle `off -> high -> max -> off` for the next turn. In CLI mode, use:
176 176 
177```bash177```bash
178-xiaoo run --reasoning-effort high -p "Explain this repository"178+xiaoo --cli run --reasoning-effort high -p "Explain this repository"
179```179```
180 180 
181Provider mapping is best-effort. OpenAI-compatible providers receive `reasoning_effort` for `high` and `max`; Anthropic receives `thinking.budget_tokens`; Gemini receives `thinkingConfig.thinkingBudget`; unsupported providers ignore the setting. `off` omits provider-specific reasoning fields so default requests keep each provider's native behavior.181Provider mapping is best-effort. OpenAI-compatible providers receive `reasoning_effort` for `high` and `max`; Anthropic receives `thinking.budget_tokens`; Gemini receives `thinkingConfig.thinkingBudget`; unsupported providers ignore the setting. `off` omits provider-specific reasoning fields so default requests keep each provider's native behavior.
@@ -185,12 +185,12 @@ Provider mapping is best-effort. OpenAI-compatible providers receive `reasoning_
185xiaoO loads skills from `~/.xiaoo/skills` by default. Each skill is a reusable instruction pack backed by `SKILL.md` or `SKILL.toml`.185xiaoO loads skills from `~/.xiaoo/skills` by default. Each skill is a reusable instruction pack backed by `SKILL.md` or `SKILL.toml`.
186 186 
187```bash187```bash
188-xiaoo skill list188+xiaoo --cli skill list
189-xiaoo skill show <name>189+xiaoo --cli skill show <name>
190-xiaoo skill audit <path>190+xiaoo --cli skill audit <path>
191-xiaoo skill install ./my-skill/191+xiaoo --cli skill install ./my-skill/
192-xiaoo skill install https://github.com/user/my-skill.git192+xiaoo --cli skill install https://github.com/user/my-skill.git
193-xiaoo skill remove <name>193+xiaoo --cli skill remove <name>
194```194```
195 195 
196See [docs/skill_usage.md](./docs/skill_usage.md) for the full skill workflow.196See [docs/skill_usage.md](./docs/skill_usage.md) for the full skill workflow.
@@ -201,10 +201,10 @@ xiaoO can run as a daemon and expose a REST API for external systems such as Fei
201 201 
202```bash202```bash
203# Default address: 0.0.0.0:18080203# Default address: 0.0.0.0:18080
204-xiaoo-app daemon204+xiaoo-daemon
205 205 
206# Specify configuration file, host, and port206# Specify configuration file, host, and port
207-xiaoo-app daemon --config /path/to/config.toml --host 127.0.0.1 --port 18080207+xiaoo-daemon --config /path/to/config.toml --host 127.0.0.1 --port 18080
208```208```
209 209 
210HTTP requests can select an agent role preset by passing `agent` in the JSON body:210HTTP requests can select an agent role preset by passing `agent` in the JSON body:
MREADME.zh-CN.md+14-14
@@ -46,7 +46,7 @@ xiaoO 的运行时还内置了分层记忆和自适应上下文压缩系统,
46```bash46```bash
47git clone https://gitcode.com/openeuler/xiaoO.git47git clone https://gitcode.com/openeuler/xiaoO.git
48cd xiaoO48cd xiaoO
49-cargo install --path apps/xiaoo-app49+cargo install --path apps/endside
50```50```
51 51 
52安装后应用二进制会位于 `~/.cargo/bin`,并尝试安装内置技能。请确认 `~/.cargo/bin` 已加入 `PATH`52安装后应用二进制会位于 `~/.cargo/bin`,并尝试安装内置技能。请确认 `~/.cargo/bin` 已加入 `PATH`
@@ -60,13 +60,13 @@ cargo install --path apps/xiaoo-app
60> - 缺少这些技能可能导致安全功能不可用。60> - 缺少这些技能可能导致安全功能不可用。
61>61>
62> **系统级安装**(推荐用于多用户环境):62> **系统级安装**(推荐用于多用户环境):
63-> - 使用 root 权限运行 `cargo install`:`sudo cargo install --path apps/xiaoo-app`63+> - 使用 root 权限运行 `cargo install`:`sudo cargo install --path apps/endside`
64 64 
65### 卸载65### 卸载
66 66 
67```bash67```bash
68# 卸载二进制文件68# 卸载二进制文件
69-cargo uninstall xiaoo-app69+cargo uninstall xiaoo-endside
70 70 
71# 删除系统级 guardian 技能(需要 root 权限)71# 删除系统级 guardian 技能(需要 root 权限)
72sudo rm -rf /usr/lib/.xiaoo/skills/xiaoo-guardian72sudo rm -rf /usr/lib/.xiaoo/skills/xiaoo-guardian
@@ -131,10 +131,10 @@ api_key_env = "LLM_API_KEY"
131 131 
132```bash132```bash
133# 终端 UI133# 终端 UI
134-xiaoo-tui134+xiaoo
135 135 
136# 单次 CLI 调用136# 单次 CLI 调用
137-xiaoo run -p "Count the characters in hello world"137+xiaoo --cli run -p "Count the characters in hello world"
138```138```
139 139 
140CLI 输出示例:140CLI 输出示例:
@@ -175,7 +175,7 @@ CLI 输出示例:
175TUI 状态栏会显示当前值:`Think off/high/max`。按 `Ctrl+T` 可按 `off -> high -> max -> off` 为下一轮切换强度。CLI 模式可使用:175TUI 状态栏会显示当前值:`Think off/high/max`。按 `Ctrl+T` 可按 `off -> high -> max -> off` 为下一轮切换强度。CLI 模式可使用:
176 176 
177```bash177```bash
178-xiaoo run --reasoning-effort high -p "Explain this repository"178+xiaoo --cli run --reasoning-effort high -p "Explain this repository"
179```179```
180 180 
181Provider 映射采用 best-effort 策略:OpenAI-compatible provider 在 `high``max` 时接收 `reasoning_effort`;Anthropic 接收 `thinking.budget_tokens`;Gemini 接收 `thinkingConfig.thinkingBudget`;不支持该能力的 provider 会忽略此设置。`off` 会省略 provider 专用推理字段,使默认请求保留各 provider 的原生行为。181Provider 映射采用 best-effort 策略:OpenAI-compatible provider 在 `high``max` 时接收 `reasoning_effort`;Anthropic 接收 `thinking.budget_tokens`;Gemini 接收 `thinkingConfig.thinkingBudget`;不支持该能力的 provider 会忽略此设置。`off` 会省略 provider 专用推理字段,使默认请求保留各 provider 的原生行为。
@@ -185,12 +185,12 @@ Provider 映射采用 best-effort 策略:OpenAI-compatible provider 在 `high`
185xiaoO 默认从 `~/.xiaoo/skills` 加载技能。每个技能都是一个由 `SKILL.md``SKILL.toml` 描述的可复用指令包。185xiaoO 默认从 `~/.xiaoo/skills` 加载技能。每个技能都是一个由 `SKILL.md``SKILL.toml` 描述的可复用指令包。
186 186 
187```bash187```bash
188-xiaoo skill list188+xiaoo --cli skill list
189-xiaoo skill show <name>189+xiaoo --cli skill show <name>
190-xiaoo skill audit <path>190+xiaoo --cli skill audit <path>
191-xiaoo skill install ./my-skill/191+xiaoo --cli skill install ./my-skill/
192-xiaoo skill install https://github.com/user/my-skill.git192+xiaoo --cli skill install https://github.com/user/my-skill.git
193-xiaoo skill remove <name>193+xiaoo --cli skill remove <name>
194```194```
195 195 
196完整技能工作流请参考 [docs/skill_usage.md](./docs/skill_usage.md)。196完整技能工作流请参考 [docs/skill_usage.md](./docs/skill_usage.md)。
@@ -201,10 +201,10 @@ xiaoO 可以作为 daemon 运行,并为 Feishu、Telegram 或自定义服务
201 201 
202```bash202```bash
203# 默认监听地址:0.0.0.0:18080203# 默认监听地址:0.0.0.0:18080
204-xiaoo-app daemon204+xiaoo-daemon
205 205 
206# 指定配置文件、监听地址和端口206# 指定配置文件、监听地址和端口
207-xiaoo-app daemon --config /path/to/config.toml --host 127.0.0.1 --port 18080207+xiaoo-daemon --config /path/to/config.toml --host 127.0.0.1 --port 18080
208```208```
209 209 
210HTTP 请求可在 JSON body 中通过 `agent` 选择 Agent 角色预设:210HTTP 请求可在 JSON body 中通过 `agent` 选择 Agent 角色预设:
Aapps/endside/Cargo.toml+49-0
@@ -0,0 +1,49 @@
1+[package]
2+name = "xiaoo-endside"
3+version.workspace = true
4+edition.workspace = true
5+license.workspace = true
6+ 
7+[features]
8+default = []
9+tee_sdf = ["xiaoo-shared/tee_sdf"]
10+ 
11+[[bin]]
12+name = "xiaoo"
13+path = "src/main.rs"
14+ 
15+[dependencies]
16+anyhow.workspace = true
17+arboard = { workspace = true }
18+async-trait.workspace = true
19+base64.workspace = true
20+chrono.workspace = true
21+clap = { version = "4", features = ["derive"] }
22+crossterm.workspace = true
23+dirs = "6"
24+futures-util.workspace = true
25+llm-client = { path = "../../crates/llm-client" }
26+lsp = { path = "../../crates/lsp" }
27+operation_backend = { path = "../../crates/operation_backend" }
28+ratatui.workspace = true
29+reqwest.workspace = true
30+serde.workspace = true
31+serde_json.workspace = true
32+skill = { path = "../../crates/skill" }
33+tokio = { workspace = true, features = ["macros", "net", "rt-multi-thread", "signal", "time"] }
34+toml.workspace = true
35+tool = { path = "../../crates/tool" }
36+tracing.workspace = true
37+tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
38+unicode-width = "0.1.14"
39+uuid.workspace = true
40+textwrap = "0.16"
41+ 
42+agent-contracts = { path = "../../crates/agent-contracts" }
43+agent-types = { path = "../../crates/agent-types" }
44+compact = { path = "../../crates/compact" }
45+xiaoo-core = { path = "../../crates/core" }
46+xiaoo-shared = { path = "../shared" }
47+ 
48+[dev-dependencies]
49+tempfile.workspace = true
Rapps/xiaoo-app/src/tui/app/core.rsapps/endside/src/app/core.rs+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/tui/app/mod.rsapps/endside/src/app/mod.rs+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/cli/config.rsapps/endside/src/cli/config.rs+1-1
@@ -1,4 +1,4 @@
1-use crate::gateway::backend::GatewayBackendConfig;1+use crate::backend::GatewayBackendConfig;
2use agent_types::hook::HookerRegistryConfig;2use agent_types::hook::HookerRegistryConfig;
3use agent_types::ReasoningEffort;3use agent_types::ReasoningEffort;
4use serde::de::DeserializeOwned;4use serde::de::DeserializeOwned;
Rapps/xiaoo-app/src/cli/main.rsapps/endside/src/cli/entry.rs+31-18
@@ -1,6 +1,11 @@
1use std::path::PathBuf;1use std::path::PathBuf;
2use std::sync::Arc;2use std::sync::Arc;
3 3 
4+use crate::cli::config::FileConfig;
5+use crate::cli::{
6+ build_compression_pipeline, build_llm_provider, resolve_effective_context_window, CliConfig,
7+ CliEventSink,
8+};
4use agent_contracts::{LoopEventSink, SkillRegistry};9use agent_contracts::{LoopEventSink, SkillRegistry};
5use clap::Parser;10use clap::Parser;
6use operation_backend::process_group::ProcessGroupCleanupGuard;11use operation_backend::process_group::ProcessGroupCleanupGuard;
@@ -8,15 +13,11 @@ use serde_json::Value;
8use skill::audit::{audit_skill_directory, SkillAuditOptions};13use skill::audit::{audit_skill_directory, SkillAuditOptions};
9use skill::registry::FileSkillRegistry;14use skill::registry::FileSkillRegistry;
10use skill::types::config::SkillsConfig;15use skill::types::config::SkillsConfig;
11-use xiaoo_app::cli::config::FileConfig;16+use xiaoo_shared::gateway::{
12-use xiaoo_app::cli::{
13- build_compression_pipeline, build_llm_provider, resolve_effective_context_window, CliConfig,
14- CliEventSink,
15-};
16-use xiaoo_app::gateway::{
17 session_record::SubagentRoleRecord, AppBootstrap, AppTurnRequest, GatewayEntryContext,17 session_record::SubagentRoleRecord, AppBootstrap, AppTurnRequest, GatewayEntryContext,
18 HostedSessionRuntimeConfig, HostedSessionRuntimeResolver, InMemorySessionStore,18 HostedSessionRuntimeConfig, HostedSessionRuntimeResolver, InMemorySessionStore,
19- SessionRuntimeBindings, SessionRuntimeDescriptor, SessionRuntimeResolver, SessionStore,19+ LlmRuntimeConfig, SessionRuntimeBindings, SessionRuntimeDescriptor, SessionRuntimeResolver,
20+ SessionStore,
20};21};
21 22 
22use agent_types::common::ids::AgentId;23use agent_types::common::ids::AgentId;
@@ -27,7 +28,7 @@ use agent_types::ReasoningEffort;
27const DEFAULT_SYSTEM_PROMPT: &str = include_str!("../prompts/cli_default_system_prompt.txt");28const DEFAULT_SYSTEM_PROMPT: &str = include_str!("../prompts/cli_default_system_prompt.txt");
28 29 
29#[derive(Parser)]30#[derive(Parser)]
30-#[command(name = "xiaoo", about = "XiaoO AgentLoop CLI")]31+#[command(name = "xiaoo --cli", about = "XiaoO AgentLoop CLI")]
31struct Args {32struct Args {
32 /// Path to config file (default: ~/.config/xiaoo/config.toml)33 /// Path to config file (default: ~/.config/xiaoo/config.toml)
33 #[arg(long, global = true)]34 #[arg(long, global = true)]
@@ -109,16 +110,19 @@ enum SkillCommands {
109 Remove { name: String },110 Remove { name: String },
110}111}
111 112 
112-#[tokio::main]113+pub async fn run_cli_from_args<I, T>(args: I)
113-async fn main() {114+where
115+ I: IntoIterator<Item = T>,
116+ T: Into<std::ffi::OsString> + Clone,
117+{
114 let _cleanup_guard = ProcessGroupCleanupGuard;118 let _cleanup_guard = ProcessGroupCleanupGuard;
115 119 
116- tracing_subscriber::fmt()120+ let _ = tracing_subscriber::fmt()
117 .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())121 .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
118 .with_writer(std::io::stderr)122 .with_writer(std::io::stderr)
119- .init();123+ .try_init();
120 124 
121- let args = Args::parse();125+ let args = Args::parse_from(args);
122 let debug = args.debug;126 let debug = args.debug;
123 let config_path = FileConfig::resolve_path(args.config.as_deref());127 let config_path = FileConfig::resolve_path(args.config.as_deref());
124 128 
@@ -136,7 +140,8 @@ async fn main() {
136 reasoning_effort,140 reasoning_effort,
137 } => {141 } => {
138 if let Some(path) = config_path.as_ref() {142 if let Some(path) = config_path.as_ref() {
139- if let Err(error) = xiaoo_app::llm_secrets::init_on_demand_secret_provider(path) {143+ if let Err(error) = xiaoo_shared::llm_secrets::init_on_demand_secret_provider(path)
144+ {
140 eprintln!(145 eprintln!(
141 "Failed to initialize LLM secrets from {}: {}",146 "Failed to initialize LLM secrets from {}: {}",
142 path.display(),147 path.display(),
@@ -160,7 +165,7 @@ async fn main() {
160 let api_key = api_key.or_else(|| {165 let api_key = api_key.or_else(|| {
161 llm.and_then(|l| l.api_key_env.clone())166 llm.and_then(|l| l.api_key_env.clone())
162 .and_then(|env_name| {167 .and_then(|env_name| {
163- xiaoo_app::gateway::get_decrypted_api_key(env_name.as_str())168+ xiaoo_shared::gateway::get_decrypted_api_key(env_name.as_str())
164 })169 })
165 });170 });
166 let api_key_env = llm.and_then(|l| l.api_key_env.clone());171 let api_key_env = llm.and_then(|l| l.api_key_env.clone());
@@ -191,7 +196,7 @@ async fn main() {
191 default: HookerDefaultMode::None,196 default: HookerDefaultMode::None,
192 ..HookerRegistryConfig::default()197 ..HookerRegistryConfig::default()
193 }),198 }),
194- operation_backend: None,199+ operation_backend: file_cfg.operation_backend.clone(),
195 skills_config,200 skills_config,
196 subagent: file_cfg.subagent.clone(),201 subagent: file_cfg.subagent.clone(),
197 };202 };
@@ -795,6 +800,13 @@ async fn run_once(config: CliConfig, prompt: String, debug: bool) {
795 descriptor: SessionRuntimeDescriptor {800 descriptor: SessionRuntimeDescriptor {
796 agent_id: AgentId("defaultagent".into()),801 agent_id: AgentId("defaultagent".into()),
797 model: config.model.clone(),802 model: config.model.clone(),
803+ llm: Some(LlmRuntimeConfig {
804+ provider: Some(config.provider.clone()),
805+ model: Some(config.model.clone()),
806+ api_base: config.api_base.clone(),
807+ api_key_env: config.api_key_env.clone(),
808+ api_key: None,
809+ }),
798 system_prompt: config.system_prompt.clone(),810 system_prompt: config.system_prompt.clone(),
799 feature_flags: FeatureFlags {811 feature_flags: FeatureFlags {
800 tool_execution: config.enable_tools,812 tool_execution: config.enable_tools,
@@ -842,7 +854,7 @@ async fn run_once(config: CliConfig, prompt: String, debug: bool) {
842 llm_provider: Some(llm_provider),854 llm_provider: Some(llm_provider),
843 hooker: config.hooker.clone(),855 hooker: config.hooker.clone(),
844 lsp_registry: None,856 lsp_registry: None,
845- operation_backend: None,857+ operation_backend: config.operation_backend.clone(),
846 skills_config: config.skills_config.clone(),858 skills_config: config.skills_config.clone(),
847 subagent_roles: config859 subagent_roles: config
848 .subagent860 .subagent
@@ -850,7 +862,7 @@ async fn run_once(config: CliConfig, prompt: String, debug: bool) {
850 .map(|(k, v)| {862 .map(|(k, v)| {
851 (863 (
852 k.clone(),864 k.clone(),
853- xiaoo_app::gateway::SubagentRoleConfigEntry {865+ xiaoo_shared::gateway::SubagentRoleConfigEntry {
854 description: v.description.clone(),866 description: v.description.clone(),
855 prompt: v.prompt.clone(),867 prompt: v.prompt.clone(),
856 max_turns: v.max_turns,868 max_turns: v.max_turns,
@@ -904,6 +916,7 @@ async fn run_once(config: CliConfig, prompt: String, debug: bool) {
904 root_message_id: None,916 root_message_id: None,
905 mentions: Vec::new(),917 mentions: Vec::new(),
906 reasoning_effort: config.reasoning_effort,918 reasoning_effort: config.reasoning_effort,
919+ llm: None,
907 };920 };
908 921 
909 // 7. Run turn via gateway session service, then explicitly close the922 // 7. Run turn via gateway session service, then explicitly close the
Rapps/xiaoo-app/src/cli/mod.rsapps/endside/src/cli/mod.rs+2-1
@@ -1,4 +1,5 @@
1pub mod config;1pub mod config;
2+pub mod entry;
2 3 
3use std::sync::{Arc, Mutex};4use std::sync::{Arc, Mutex};
4 5 
@@ -129,7 +130,7 @@ pub struct CliConfig {
129 pub kvcache_debug_enabled: bool,130 pub kvcache_debug_enabled: bool,
130 pub compact: config::CompactSection,131 pub compact: config::CompactSection,
131 pub hooker: HookerRegistryConfig,132 pub hooker: HookerRegistryConfig,
132- pub operation_backend: Option<crate::gateway::backend::GatewayBackendConfig>,133+ pub operation_backend: Option<crate::backend::GatewayBackendConfig>,
133 pub skills_config: skill::SkillsConfig,134 pub skills_config: skill::SkillsConfig,
134 pub subagent: std::collections::BTreeMap<String, config::SubagentRoleConfig>,135 pub subagent: std::collections::BTreeMap<String, config::SubagentRoleConfig>,
135}136}
Rapps/xiaoo-app/src/tui/gateway_api/mod.rsapps/endside/src/gateway_api/mod.rs+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/tui/gateway_api/remote.rsapps/endside/src/gateway_api/remote.rs+73-43
@@ -1,14 +1,18 @@
1use futures_util::StreamExt;1use futures_util::StreamExt;
2use serde::{Deserialize, Serialize};2use serde::{Deserialize, Serialize};
3-use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender};3+use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel};
4 4 
5use agent_types::common::ids::AgentId;5use agent_types::common::ids::AgentId;
6use agent_types::interaction::{InteractionRequest, InteractionResponse};6use agent_types::interaction::{InteractionRequest, InteractionResponse};
7 7 
8-use crate::app_state::{sandbox_display_name, AppState};8+use crate::app_state::{AppState, sandbox_display_name};
9use crate::chat::{Message, ToolExecutionStatus, ToolExecutionUpdate};9use crate::chat::{Message, ToolExecutionStatus, ToolExecutionUpdate};
10-use crate::gateway::{AppTurnRequest, GatewayEntryContext, SessionOpenRequest};10+use crate::gateway::{
11+ GatewayEntryContext, RuntimeCancelRequest, RuntimeCloseRequest, RuntimeInteractionRequest,
12+ RuntimeOpenRequest, RuntimeTurnRequest,
13+};
11use crate::interaction_prompt::{PromptChoice, PromptRequest, PromptResolution, UserPromptResult};14use crate::interaction_prompt::{PromptChoice, PromptRequest, PromptResolution, UserPromptResult};
15+use crate::remote_sessions_service::record_remote_session;
12use crate::session_gateway::SessionTurnUpdate;16use crate::session_gateway::SessionTurnUpdate;
13 17 
14use super::runtime::GatewayRuntime;18use super::runtime::GatewayRuntime;
@@ -54,6 +58,7 @@ enum RemoteSseEvent {
54 raw_reply: String,58 raw_reply: String,
55 #[allow(dead_code)]59 #[allow(dead_code)]
56 conversation_id: String,60 conversation_id: String,
61+ #[serde(rename = "runtime_id", alias = "session_id")]
57 #[allow(dead_code)]62 #[allow(dead_code)]
58 session_id: String,63 session_id: String,
59 #[allow(dead_code)]64 #[allow(dead_code)]
@@ -70,6 +75,7 @@ enum RemoteSseEvent {
70 error: String,75 error: String,
71 },76 },
72 Cancelled {77 Cancelled {
78+ #[serde(rename = "runtime_id", alias = "session_id")]
73 session_id: String,79 session_id: String,
74 },80 },
75}81}
@@ -121,7 +127,6 @@ impl GatewayRuntime {
121 }127 }
122 128 
123 pub async fn disconnect_remote(&mut self, state: &mut AppState) -> Result<(), String> {129 pub async fn disconnect_remote(&mut self, state: &mut AppState) -> Result<(), String> {
124- self.close_remote_session(&state.session_id).await;
125 self.remote = None;130 self.remote = None;
126 self.remote_session_open = false;131 self.remote_session_open = false;
127 state132 state
@@ -154,8 +159,8 @@ impl GatewayRuntime {
154 Err(error) => error.to_string(),159 Err(error) => error.to_string(),
155 };160 };
156 format!(161 format!(
157- "Backend: Remote {}\nSession open: {}\nHealth: {}",162+ "Backend: Remote {}\nSession: {}\nSession open: {}\nHealth: {}",
158- remote.base_url, self.remote_session_open, health163+ remote.base_url, state.session_id, self.remote_session_open, health
159 )164 )
160 }165 }
161 166 
@@ -182,7 +187,7 @@ impl GatewayRuntime {
182 &client,187 &client,
183 &remote,188 &remote,
184 token.as_deref(),189 token.as_deref(),
185- "/api/v1/sessions/open",190+ "/api/v1/runtimes/open",
186 &open_request,191 &open_request,
187 )192 )
188 .await?;193 .await?;
@@ -198,6 +203,17 @@ impl GatewayRuntime {
198 state.chat_state.messages.push(Message::user(prompt));203 state.chat_state.messages.push(Message::user(prompt));
199 state.chat_state.input.reset();204 state.chat_state.input.reset();
200 }205 }
206+ let _ = record_remote_session(
207+ &state.session_id,
208+ &remote.base_url,
209+ remote.bearer_token_env.clone(),
210+ state
211+ .chat_state
212+ .messages
213+ .iter()
214+ .find(|message| message.role == crate::chat::MessageRole::User)
215+ .map(|message| message.content.as_str()),
216+ );
201 state.chat_state.is_loading = true;217 state.chat_state.is_loading = true;
202 state218 state
203 .chat_state219 .chat_state
@@ -235,8 +251,16 @@ impl GatewayRuntime {
235 return;251 return;
236 };252 };
237 let client = reqwest::Client::new();253 let client = reqwest::Client::new();
238- let path = format!("/api/v1/sessions/{session_id}/close");254+ let _ = post_json(
239- let _ = post_empty(&client, &remote, token.as_deref(), &path).await;255+ &client,
256+ &remote,
257+ token.as_deref(),
258+ "/api/v1/runtimes/close",
259+ &RuntimeCloseRequest {
260+ session_id: session_id.to_string(),
261+ },
262+ )
263+ .await;
240 self.remote_session_open = false;264 self.remote_session_open = false;
241 }265 }
242 266 
@@ -249,20 +273,37 @@ impl GatewayRuntime {
249 };273 };
250 tokio::spawn(async move {274 tokio::spawn(async move {
251 let client = reqwest::Client::new();275 let client = reqwest::Client::new();
252- let path = format!("/api/v1/sessions/{session_id}/cancel");276+ let _ = post_json(
253- let _ = post_empty(&client, &remote, token.as_deref(), &path).await;277+ &client,
278+ &remote,
279+ token.as_deref(),
280+ "/api/v1/runtimes/cancel",
281+ &RuntimeCancelRequest { session_id },
282+ )
283+ .await;
254 });284 });
255 }285 }
256 286 
257- fn remote_session_open_request(&self, state: &AppState) -> Result<SessionOpenRequest, String> {287+ fn remote_session_open_request(&self, state: &AppState) -> Result<RuntimeOpenRequest, String> {
288+ Self::remote_session_open_request_for(
289+ state,
290+ self.remote.as_ref().map(|remote| remote.base_url.clone()),
291+ )
292+ }
293+ 
294+ fn remote_session_open_request_for(
295+ state: &AppState,
296+ base_url: Option<String>,
297+ ) -> Result<RuntimeOpenRequest, String> {
258 let sender_id = super::runtime_request::resolve_agent_id(None, None, &state.agent_config)?;298 let sender_id = super::runtime_request::resolve_agent_id(None, None, &state.agent_config)?;
259- Ok(SessionOpenRequest {299+ Ok(RuntimeOpenRequest {
260 session_id: state.session_id.clone(),300 session_id: state.session_id.clone(),
261 conversation_id: state.session_id.clone(),301 conversation_id: state.session_id.clone(),
262 sender_id,302 sender_id,
263- entry: GatewayEntryContext::tui(self.remote.as_ref().map(|r| r.base_url.clone())),303+ entry: GatewayEntryContext::tui(base_url),
264 channel: None,304 channel: None,
265 channel_instance_id: None,305 channel_instance_id: None,
306+ llm: Some(super::runtime_request::llm_runtime_config_from_state(state)),
266 })307 })
267 }308 }
268 309 
@@ -270,9 +311,9 @@ impl GatewayRuntime {
270 &self,311 &self,
271 state: &AppState,312 state: &AppState,
272 text: String,313 text: String,
273- ) -> Result<AppTurnRequest, String> {314+ ) -> Result<RuntimeTurnRequest, String> {
274 let sender_id = super::runtime_request::resolve_agent_id(None, None, &state.agent_config)?;315 let sender_id = super::runtime_request::resolve_agent_id(None, None, &state.agent_config)?;
275- Ok(AppTurnRequest {316+ Ok(RuntimeTurnRequest {
276 session_id: state.session_id.clone(),317 session_id: state.session_id.clone(),
277 entry: GatewayEntryContext::tui(self.remote.as_ref().map(|r| r.base_url.clone())),318 entry: GatewayEntryContext::tui(self.remote.as_ref().map(|r| r.base_url.clone())),
278 channel: None,319 channel: None,
@@ -286,6 +327,7 @@ impl GatewayRuntime {
286 root_message_id: None,327 root_message_id: None,
287 mentions: Vec::new(),328 mentions: Vec::new(),
288 reasoning_effort: state.reasoning_effort,329 reasoning_effort: state.reasoning_effort,
330+ llm: Some(super::runtime_request::llm_runtime_config_from_state(state)),
289 })331 })
290 }332 }
291}333}
@@ -294,14 +336,11 @@ async fn run_remote_stream(
294 client: reqwest::Client,336 client: reqwest::Client,
295 remote: RemoteRuntimeConfig,337 remote: RemoteRuntimeConfig,
296 token: Option<String>,338 token: Option<String>,
297- turn_request: AppTurnRequest,339+ turn_request: RuntimeTurnRequest,
298 updates_tx: UnboundedSender<SessionTurnUpdate>,340 updates_tx: UnboundedSender<SessionTurnUpdate>,
299 mut interaction_rx: UnboundedReceiver<UserPromptResult>,341 mut interaction_rx: UnboundedReceiver<UserPromptResult>,
300) {342) {
301- let url = format!(343+ let url = format!("{}/api/v1/runtimes/input", remote.base_url);
302- "{}/api/v1/sessions/{}/turn/stream",
303- remote.base_url, turn_request.session_id
304- );
305 let mut request = client.post(url).json(&turn_request);344 let mut request = client.post(url).json(&turn_request);
306 if let Some(token) = token.as_ref() {345 if let Some(token) = token.as_ref() {
307 request = request.bearer_auth(token);346 request = request.bearer_auth(token);
@@ -317,7 +356,7 @@ async fn run_remote_stream(
317 let status = response.status();356 let status = response.status();
318 let body = response.text().await.unwrap_or_default();357 let body = response.text().await.unwrap_or_default();
319 let _ = updates_tx.send(SessionTurnUpdate::Err(format!(358 let _ = updates_tx.send(SessionTurnUpdate::Err(format!(
320- "remote turn failed: HTTP {status} {body}"359+ "remote input failed: HTTP {status} {body}"
321 )));360 )));
322 return;361 return;
323 }362 }
@@ -420,8 +459,17 @@ async fn handle_remote_event(
420 }459 }
421 let response = map_response(&request, result)460 let response = map_response(&request, result)
422 .unwrap_or_else(|| default_interaction_response(&request));461 .unwrap_or_else(|| default_interaction_response(&request));
423- let path = format!("/api/v1/sessions/{session_id}/interaction");462+ let _ = post_json(
424- let _ = post_json(client, remote, token, &path, &response).await;463+ client,
464+ remote,
465+ token,
466+ "/api/v1/runtimes/interaction",
467+ &RuntimeInteractionRequest {
468+ session_id: session_id.to_string(),
469+ response,
470+ },
471+ )
472+ .await;
425 break;473 break;
426 }474 }
427 }475 }
@@ -468,24 +516,6 @@ fn resolve_bearer_token(env_name: Option<&str>) -> Result<Option<String>, String
468 Ok(Some(value))516 Ok(Some(value))
469}517}
470 518 
471-async fn post_empty(
472- client: &reqwest::Client,
473- remote: &RemoteRuntimeConfig,
474- token: Option<&str>,
475- path: &str,
476-) -> Result<(), String> {
477- let mut request = client.post(format!("{}{}", remote.base_url, path));
478- if let Some(token) = token {
479- request = request.bearer_auth(token);
480- }
481- let response = request.send().await.map_err(|error| error.to_string())?;
482- if response.status().is_success() {
483- Ok(())
484- } else {
485- Err(format!("HTTP {}", response.status()))
486- }
487-}
488- 
489async fn post_json<T: Serialize + ?Sized>(519async fn post_json<T: Serialize + ?Sized>(
490 client: &reqwest::Client,520 client: &reqwest::Client,
491 remote: &RemoteRuntimeConfig,521 remote: &RemoteRuntimeConfig,
@@ -652,7 +682,7 @@ fn default_interaction_response(request: &InteractionRequest) -> InteractionResp
652 682 
653#[cfg(test)]683#[cfg(test)]
654mod tests {684mod tests {
655- use super::{parse_sse_frame, take_sse_frame, RemoteSseEvent};685+ use super::{RemoteSseEvent, parse_sse_frame, take_sse_frame};
656 686 
657 #[test]687 #[test]
658 fn parses_sse_frame_from_split_buffer() {688 fn parses_sse_frame_from_split_buffer() {
Rapps/xiaoo-app/src/tui/gateway_api/runtime.rsapps/endside/src/gateway_api/runtime.rs+3-3
@@ -94,10 +94,10 @@ impl GatewayRuntime {
94 self.session_gateway.session_store.clone()94 self.session_gateway.session_store.clone()
95 }95 }
96 96 
97- /// Closes all active sessions, firing the SessionClosed hook for each.97+ /// Closes local active sessions before exit.
98- /// Should be called before the application exits.98+ /// Remote sessions are left on the daemon so they can be reattached later.
99 pub async fn close_sessions(&mut self, session_id: &str) {99 pub async fn close_sessions(&mut self, session_id: &str) {
100- self.close_remote_session(session_id).await;100+ let _ = session_id;
101 self.session_gateway.close_all_sessions().await;101 self.session_gateway.close_all_sessions().await;
102 if let Err(error) = self.session_gateway.backend_manager.shutdown_all().await {102 if let Err(error) = self.session_gateway.backend_manager.shutdown_all().await {
103 tracing::warn!(error = %error, "failed to shutdown TUI backend manager");103 tracing::warn!(error = %error, "failed to shutdown TUI backend manager");
Rapps/xiaoo-app/src/tui/gateway_api/runtime_interaction.rsapps/endside/src/gateway_api/runtime_interaction.rs+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/tui/gateway_api/runtime_request.rsapps/endside/src/gateway_api/runtime_request.rs+17-3
@@ -9,8 +9,8 @@ use crate::app_state::AppState;
9use crate::chat::Message;9use crate::chat::Message;
10use crate::config::Config;10use crate::config::Config;
11use crate::gateway::{11use crate::gateway::{
12- AppTurnRequest, GatewayEntryContext, HostedSessionRuntimeConfig, SessionOpenRequest,12+ AppTurnRequest, GatewayEntryContext, HostedSessionRuntimeConfig, LlmRuntimeConfig,
13- SessionRuntimeDescriptor,13+ SessionOpenRequest, SessionRuntimeDescriptor,
14};14};
15use agent_types::common::ids::AgentId;15use agent_types::common::ids::AgentId;
16use agent_types::context::{FeatureFlags, TokenBudgetConfig};16use agent_types::context::{FeatureFlags, TokenBudgetConfig};
@@ -19,7 +19,7 @@ use tool::{load_tool_sources_with_services, ToolRuntimeServices};
19use super::runtime::GatewayRuntime;19use super::runtime::GatewayRuntime;
20use xiaoo_core::spawn_prefetch;20use xiaoo_core::spawn_prefetch;
21 21 
22-const DEFAULT_SYSTEM_PROMPT: &str = include_str!("../../prompts/tui_default_system_prompt.txt");22+const DEFAULT_SYSTEM_PROMPT: &str = include_str!("../prompts/tui_default_system_prompt.txt");
23const DEFAULT_SYSTEM_TOKEN_RESERVE: usize = 2048;23const DEFAULT_SYSTEM_TOKEN_RESERVE: usize = 2048;
24 24 
25impl GatewayRuntime {25impl GatewayRuntime {
@@ -178,6 +178,7 @@ impl GatewayRuntime {
178 descriptor: SessionRuntimeDescriptor {178 descriptor: SessionRuntimeDescriptor {
179 agent_id: AgentId(agent_id),179 agent_id: AgentId(agent_id),
180 model: state.agent_config.llm.model.clone(),180 model: state.agent_config.llm.model.clone(),
181+ llm: Some(llm_runtime_config_from_state(state)),
181 system_prompt,182 system_prompt,
182 feature_flags: {183 feature_flags: {
183 let mut flags = FeatureFlags::default();184 let mut flags = FeatureFlags::default();
@@ -262,6 +263,7 @@ impl GatewayRuntime {
262 entry: tui_entry_context(state),263 entry: tui_entry_context(state),
263 channel: None,264 channel: None,
264 channel_instance_id: None,265 channel_instance_id: None,
266+ llm: None,
265 })267 })
266 }268 }
267 269 
@@ -281,10 +283,22 @@ impl GatewayRuntime {
281 root_message_id: None,283 root_message_id: None,
282 mentions: Vec::new(),284 mentions: Vec::new(),
283 reasoning_effort: state.reasoning_effort,285 reasoning_effort: state.reasoning_effort,
286+ llm: None,
284 })287 })
285 }288 }
286}289}
287 290 
291+pub(crate) fn llm_runtime_config_from_state(state: &AppState) -> LlmRuntimeConfig {
292+ LlmRuntimeConfig {
293+ provider: Some(state.agent_config.llm.provider.clone()),
294+ model: Some(state.agent_config.llm.model.clone()),
295+ api_base: (!state.agent_config.llm.api_base.trim().is_empty())
296+ .then(|| state.agent_config.llm.api_base.clone()),
297+ api_key_env: state.agent_config.llm.api_key_env.clone(),
298+ api_key: None,
299+ }
300+}
301+ 
288fn tui_entry_context(state: &AppState) -> GatewayEntryContext {302fn tui_entry_context(state: &AppState) -> GatewayEntryContext {
289 let mut entry = GatewayEntryContext::tui(None);303 let mut entry = GatewayEntryContext::tui(None);
290 entry.runtime_profile_id = state.active_agent_role.clone();304 entry.runtime_profile_id = state.active_agent_role.clone();
Rapps/xiaoo-app/src/tui/gateway_api/runtime_stream.rsapps/endside/src/gateway_api/runtime_stream.rs+1-1
@@ -86,7 +86,7 @@ impl GatewayRuntime {
86 self.stream_rx = None;86 self.stream_rx = None;
87 }87 }
88 SessionTurnUpdate::Err(error) => {88 SessionTurnUpdate::Err(error) => {
89- let display_error = crate::error_log::record_tui_error("turn_stream", &error);89+ let display_error = crate::error_log::record_tui_error("remote_input", &error);
90 self.stream_reveal_buffer.clear();90 self.stream_reveal_buffer.clear();
91 self.pending_stream_done = None;91 self.pending_stream_done = None;
92 self.set_stream_message_content(state, display_error, false);92 self.set_stream_message_content(state, display_error, false);
Rapps/xiaoo-app/src/tui/gateway_api/session.rsapps/endside/src/gateway_api/session.rs+4-3
@@ -4,8 +4,9 @@ use std::sync::{Arc, Mutex};
4use async_trait::async_trait;4use async_trait::async_trait;
5use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};5use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
6 6 
7+use crate::backend::BackendManager;
7use crate::chat::ToolExecutionUpdate;8use crate::chat::ToolExecutionUpdate;
8-use crate::gateway::{backend::ExternalBackendManager, InMemorySessionStore, SessionControlPlane};9+use crate::gateway::{InMemorySessionStore, SessionControlPlane};
9use crate::interaction_prompt::PromptRequest;10use crate::interaction_prompt::PromptRequest;
10 11 
11use agent_types::common::ids::AgentId;12use agent_types::common::ids::AgentId;
@@ -47,7 +48,7 @@ pub struct SessionGateway {
47 Arc<tokio::sync::Mutex<Option<Arc<dyn SessionControlPlane>>>>,48 Arc<tokio::sync::Mutex<Option<Arc<dyn SessionControlPlane>>>>,
48 /// Session IDs that have been opened and not yet closed.49 /// Session IDs that have been opened and not yet closed.
49 pub(super) active_session_ids: Arc<tokio::sync::Mutex<HashSet<String>>>,50 pub(super) active_session_ids: Arc<tokio::sync::Mutex<HashSet<String>>>,
50- pub(super) backend_manager: Arc<ExternalBackendManager>,51+ pub(super) backend_manager: Arc<BackendManager>,
51}52}
52 53 
53impl Default for SessionGateway {54impl Default for SessionGateway {
@@ -56,7 +57,7 @@ impl Default for SessionGateway {
56 session_store: Arc::new(InMemorySessionStore::default()),57 session_store: Arc::new(InMemorySessionStore::default()),
57 lifecycle_control_plane: Arc::new(tokio::sync::Mutex::new(None)),58 lifecycle_control_plane: Arc::new(tokio::sync::Mutex::new(None)),
58 active_session_ids: Arc::new(tokio::sync::Mutex::new(HashSet::new())),59 active_session_ids: Arc::new(tokio::sync::Mutex::new(HashSet::new())),
59- backend_manager: Arc::new(ExternalBackendManager::new()),60+ backend_manager: Arc::new(BackendManager::new()),
60 }61 }
61 }62 }
62}63}
Rapps/xiaoo-app/src/tui/gateway_api/session_core.rsapps/endside/src/gateway_api/session_core.rs+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/tui/gateway_api/session_interaction.rsapps/endside/src/gateway_api/session_interaction.rs+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/tui/gateway_api/session_sink.rsapps/endside/src/gateway_api/session_sink.rs+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/tui/input/core.rsapps/endside/src/input/core.rs+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/tui/input/event_handler.rsapps/endside/src/input/event_handler.rs+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/tui/input/event_key.rsapps/endside/src/input/event_key.rs+225-12
@@ -14,6 +14,10 @@ use crate::provider_service::{
14 copy_to_clipboard, persist_active_provider_selection, persisted_selection_settings,14 copy_to_clipboard, persist_active_provider_selection, persisted_selection_settings,
15 validate_and_connect_api_key,15 validate_and_connect_api_key,
16};16};
17+use crate::remote_sessions_service::{
18+ list_remote_sessions, record_remote_session, RemoteSessionDialog, RemoteSessionDialogEntry,
19+ RemoteSessionDialogMode, RemoteSessionRecord,
20+};
17use crate::services::turn_delete::DeleteDialog;21use crate::services::turn_delete::DeleteDialog;
18use crate::session_snapshot_service::{22use crate::session_snapshot_service::{
19 apply_snapshot, build_snapshot, list_session_snapshots, load_snapshot, load_snapshot_by_key,23 apply_snapshot, build_snapshot, list_session_snapshots, load_snapshot, load_snapshot_by_key,
@@ -93,6 +97,10 @@ impl App {
93 return self.handle_sandbox_selection_key(key);97 return self.handle_sandbox_selection_key(key);
94 }98 }
95 99 
100+ if self.state.input_mode == InputMode::RemoteSessionSelection {
101+ return self.handle_remote_session_selection_key(key).await;
102+ }
103+ 
96 if self.state.input_mode == InputMode::TurnDelete {104 if self.state.input_mode == InputMode::TurnDelete {
97 return self.handle_turn_delete_key(key).await;105 return self.handle_turn_delete_key(key).await;
98 }106 }
@@ -101,6 +109,7 @@ impl App {
101 InputMode::Editing => self.handle_editing_mode_key(key).await,109 InputMode::Editing => self.handle_editing_mode_key(key).await,
102 InputMode::ProviderSelection => self.handle_provider_selection_key(key),110 InputMode::ProviderSelection => self.handle_provider_selection_key(key),
103 InputMode::SandboxSelection => Ok(()),111 InputMode::SandboxSelection => Ok(()),
112+ InputMode::RemoteSessionSelection => Ok(()),
104 InputMode::SessionSnapshotSelection => Ok(()),113 InputMode::SessionSnapshotSelection => Ok(()),
105 InputMode::InteractionPrompt => Ok(()),114 InputMode::InteractionPrompt => Ok(()),
106 InputMode::TurnDelete => Ok(()),115 InputMode::TurnDelete => Ok(()),
@@ -397,9 +406,6 @@ impl App {
397 }406 }
398 407 
399 if trimmed.eq_ignore_ascii_case("/new") {408 if trimmed.eq_ignore_ascii_case("/new") {
400- self.gateway
401- .close_remote_session(&self.state.session_id)
402- .await;
403 self.gateway.reset_for_new_session(&mut self.state);409 self.gateway.reset_for_new_session(&mut self.state);
404 self.state.reset_for_new_session();410 self.state.reset_for_new_session();
405 if let Some(base_url) = self.gateway.remote_base_url() {411 if let Some(base_url) = self.gateway.remote_base_url() {
@@ -636,20 +642,31 @@ impl App {
636 .map(str::trim)642 .map(str::trim)
637 .filter(|value| !value.is_empty());643 .filter(|value| !value.is_empty());
638 644 
645+ let Some(arg) = arg else {
646+ self.open_remote_session_dialog();
647+ return;
648+ };
649+ 
639 let message = match arg {650 let message = match arg {
640- None | Some("status") => {651+ "status" => crate::chat::Message::system(self.gateway.remote_status(&self.state).await),
641- crate::chat::Message::system(self.gateway.remote_status(&self.state).await)652+ "off" => match self.gateway.disconnect_remote(&mut self.state).await {
642- }
643- Some("off") => match self.gateway.disconnect_remote(&mut self.state).await {
644 Ok(()) => crate::chat::Message::system(format!(653 Ok(()) => crate::chat::Message::system(format!(
645- "Remote backend disabled. Backend: {}.",654+ "Remote disconnected. Remote sessions are kept on the daemon. Backend: {}.",
646 sandbox_display_name(&self.state.agent_config.operation_backend)655 sandbox_display_name(&self.state.agent_config.operation_backend)
647 )),656 )),
648 Err(error) => {657 Err(error) => {
649 crate::chat::Message::error(format!("Remote disconnect failed: {error}"))658 crate::chat::Message::error(format!("Remote disconnect failed: {error}"))
650 }659 }
651 },660 },
652- Some(base_url) => {661+ "close" => {
662+ let session_id = self.state.session_id.clone();
663+ self.gateway.close_remote_session(&session_id).await;
664+ crate::chat::Message::system(format!(
665+ "Remote session closed on daemon: {session_id}"
666+ ))
667+ }
668+ base_url => {
669+ let base_url = normalize_remote_url_input(base_url);
653 let token_env = self670 let token_env = self
654 .state671 .state
655 .agent_config672 .agent_config
@@ -659,10 +676,23 @@ impl App {
659 .and_then(|remote| remote.bearer_token_env.clone());676 .and_then(|remote| remote.bearer_token_env.clone());
660 match self677 match self
661 .gateway678 .gateway
662- .connect_remote(&mut self.state, base_url.to_string(), token_env)679+ .connect_remote(&mut self.state, base_url.clone(), token_env)
663 .await680 .await
664 {681 {
665- Ok(message) => crate::chat::Message::system(message),682+ Ok(message) => {
683+ let _ = record_remote_session(
684+ &self.state.session_id,
685+ &base_url,
686+ self.state
687+ .agent_config
688+ .tui
689+ .remote
690+ .as_ref()
691+ .and_then(|remote| remote.bearer_token_env.clone()),
692+ None,
693+ );
694+ crate::chat::Message::system(message)
695+ }
666 Err(error) => {696 Err(error) => {
667 crate::chat::Message::error(format!("Remote connect failed: {error}"))697 crate::chat::Message::error(format!("Remote connect failed: {error}"))
668 }698 }
@@ -673,6 +703,180 @@ impl App {
673 self.state.chat_state.stick_to_bottom = true;703 self.state.chat_state.stick_to_bottom = true;
674 }704 }
675 705 
706+ fn open_remote_session_dialog(&mut self) {
707+ match list_remote_sessions() {
708+ Ok(records) => {
709+ self.state.input_mode = InputMode::RemoteSessionSelection;
710+ self.state.remote_session_dialog = Some(RemoteSessionDialog::new(records));
711+ }
712+ Err(error) => {
713+ self.state
714+ .chat_state
715+ .messages
716+ .push(crate::chat::Message::error(format!(
717+ "Remote session history failed: {error:#}"
718+ )));
719+ self.state.chat_state.stick_to_bottom = true;
720+ }
721+ }
722+ }
723+ 
724+ async fn handle_remote_session_selection_key(&mut self, key: KeyEvent) -> Result<()> {
725+ let Some(mode) = self
726+ .state
727+ .remote_session_dialog
728+ .as_ref()
729+ .map(|dialog| dialog.mode)
730+ else {
731+ self.state.input_mode = InputMode::Editing;
732+ return Ok(());
733+ };
734+ 
735+ match mode {
736+ RemoteSessionDialogMode::List => self.handle_remote_session_list_key(key).await,
737+ RemoteSessionDialogMode::NewUrl => self.handle_remote_session_new_url_key(key).await,
738+ }
739+ }
740+ 
741+ async fn handle_remote_session_list_key(&mut self, key: KeyEvent) -> Result<()> {
742+ match key.code {
743+ KeyCode::Esc => {
744+ self.state.input_mode = InputMode::Editing;
745+ self.state.remote_session_dialog = None;
746+ }
747+ KeyCode::Up => {
748+ if let Some(dialog) = self.state.remote_session_dialog.as_mut() {
749+ dialog.move_up();
750+ }
751+ }
752+ KeyCode::Down => {
753+ if let Some(dialog) = self.state.remote_session_dialog.as_mut() {
754+ dialog.move_down();
755+ }
756+ }
757+ KeyCode::Enter => {
758+ let selected = self
759+ .state
760+ .remote_session_dialog
761+ .as_ref()
762+ .and_then(|dialog| dialog.selected_entry().cloned());
763+ match selected {
764+ Some(RemoteSessionDialogEntry::Existing(record)) => {
765+ self.state.input_mode = InputMode::Editing;
766+ self.state.remote_session_dialog = None;
767+ self.activate_remote_session(record).await;
768+ }
769+ Some(RemoteSessionDialogEntry::New) => {
770+ if let Some(dialog) = self.state.remote_session_dialog.as_mut() {
771+ dialog.enter_new_url_mode();
772+ }
773+ }
774+ None => {}
775+ }
776+ }
777+ _ => {}
778+ }
779+ Ok(())
780+ }
781+ 
782+ async fn handle_remote_session_new_url_key(&mut self, key: KeyEvent) -> Result<()> {
783+ match key.code {
784+ KeyCode::Esc => {
785+ if let Some(dialog) = self.state.remote_session_dialog.as_mut() {
786+ dialog.mode = RemoteSessionDialogMode::List;
787+ dialog.error = None;
788+ }
789+ }
790+ KeyCode::Enter => {
791+ let url = self
792+ .state
793+ .remote_session_dialog
794+ .as_ref()
795+ .map(|dialog| dialog.url_input.value().trim().to_string())
796+ .unwrap_or_default();
797+ if url.is_empty() {
798+ if let Some(dialog) = self.state.remote_session_dialog.as_mut() {
799+ dialog.error =
800+ Some("请输入 daemon URL,例如 http://127.0.0.1:8070".to_string());
801+ }
802+ return Ok(());
803+ }
804+ self.create_new_remote_session(normalize_remote_url_input(&url))
805+ .await;
806+ }
807+ _ => {
808+ if let Some(dialog) = self.state.remote_session_dialog.as_mut() {
809+ dialog.url_input.handle_event(&Event::Key(key));
810+ dialog.error = None;
811+ }
812+ }
813+ }
814+ Ok(())
815+ }
816+ 
817+ async fn activate_remote_session(&mut self, record: RemoteSessionRecord) {
818+ self.gateway.reset_for_new_session(&mut self.state);
819+ self.state.reset_for_new_session();
820+ self.state.session_id = record.session_id.clone();
821+ self.gateway.configure_remote(
822+ &mut self.state,
823+ record.base_url.clone(),
824+ record.bearer_token_env.clone(),
825+ );
826+ let _ = record_remote_session(
827+ &record.session_id,
828+ &record.base_url,
829+ record.bearer_token_env.clone(),
830+ None,
831+ );
832+ self.state
833+ .chat_state
834+ .messages
835+ .push(crate::chat::Message::system(format!(
836+ "Remote session selected: {} ({})",
837+ record.session_id, record.base_url
838+ )));
839+ self.state.chat_state.stick_to_bottom = true;
840+ }
841+ 
842+ async fn create_new_remote_session(&mut self, base_url: String) {
843+ let token_env = self
844+ .state
845+ .agent_config
846+ .tui
847+ .remote
848+ .as_ref()
849+ .and_then(|remote| remote.bearer_token_env.clone());
850+ match self
851+ .gateway
852+ .connect_remote(&mut self.state, base_url.clone(), token_env.clone())
853+ .await
854+ {
855+ Ok(message) => {
856+ self.gateway.reset_for_new_session(&mut self.state);
857+ self.state.reset_for_new_session();
858+ self.gateway
859+ .configure_remote(&mut self.state, base_url.clone(), token_env.clone());
860+ let session_id = self.state.session_id.clone();
861+ let _ = record_remote_session(&session_id, &base_url, token_env, None);
862+ self.state.input_mode = InputMode::Editing;
863+ self.state.remote_session_dialog = None;
864+ self.state
865+ .chat_state
866+ .messages
867+ .push(crate::chat::Message::system(format!(
868+ "{message}\nNew remote session: {session_id}"
869+ )));
870+ self.state.chat_state.stick_to_bottom = true;
871+ }
872+ Err(error) => {
873+ if let Some(dialog) = self.state.remote_session_dialog.as_mut() {
874+ dialog.error = Some(format!("Remote connect failed: {error}"));
875+ }
876+ }
877+ }
878+ }
879+ 
676 fn handle_provider_selection_key(&mut self, key: KeyEvent) -> Result<()> {880 fn handle_provider_selection_key(&mut self, key: KeyEvent) -> Result<()> {
677 let mut selection_to_apply = None;881 let mut selection_to_apply = None;
678 let mut need_api_key_dialog = None;882 let mut need_api_key_dialog = None;
@@ -830,7 +1034,7 @@ impl App {
830 .chat_state1034 .chat_state
831 .messages1035 .messages
832 .push(crate::chat::Message::system(format!(1036 .push(crate::chat::Message::system(format!(
833- "Sandbox backend: {name}. Applies from the next local turn."1037+ "Sandbox backend: {name}. Applies to new local sessions. Use /new to start one."
834 )));1038 )));
835 self.state.chat_state.stick_to_bottom = true;1039 self.state.chat_state.stick_to_bottom = true;
836 }1040 }
@@ -1039,6 +1243,15 @@ fn slash_command_argument<'a>(trimmed: &'a str, command: &str) -> Option<&'a str
1039 }1243 }
1040}1244}
1041 1245 
1246+fn normalize_remote_url_input(value: &str) -> String {
1247+ let trimmed = value.trim().trim_end_matches('/');
1248+ if trimmed.contains("://") || trimmed.is_empty() {
1249+ trimmed.to_string()
1250+ } else {
1251+ format!("http://{trimmed}")
1252+ }
1253+}
1254+ 
1042fn expand_external_command(1255fn expand_external_command(
1043 trimmed: &str,1256 trimmed: &str,
1044 external: &[crate::services::command_loader::ExternalCommand],1257 external: &[crate::services::command_loader::ExternalCommand],
Rapps/xiaoo-app/src/tui/input/event_mouse.rsapps/endside/src/input/event_mouse.rs+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/tui/input/event_paste.rsapps/endside/src/input/event_paste.rs+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/tui/input/mod.rsapps/endside/src/input/mod.rs+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/tui/input/slash_complete.rsapps/endside/src/input/slash_complete.rs+1-1
@@ -42,7 +42,7 @@ pub const SLASH_COMMANDS: &[SlashCommandSpec] = &[
42 },42 },
43 SlashCommandSpec {43 SlashCommandSpec {
44 name: "/remote",44 name: "/remote",
45- summary: "连接远端 gateway daemon,或使用 off/status 管理远端模式。",45+ summary: "连接远端 gateway daemon,或使用 off/status/close 管理远端模式。",
46 },46 },
47 SlashCommandSpec {47 SlashCommandSpec {
48 name: "/sandbox",48 name: "/sandbox",
Rapps/xiaoo-app/src/tui/main.rsapps/endside/src/main.rs+112-7
@@ -8,12 +8,15 @@ use crossterm::terminal::{
8 disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,8 disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
9};9};
10use operation_backend::process_group::ProcessGroupCleanupGuard;10use operation_backend::process_group::ProcessGroupCleanupGuard;
11+use std::env;
12+use std::ffi::{OsStr, OsString};
11use std::io;13use std::io;
12use std::path::PathBuf;14use std::path::PathBuf;
13 15 
14-pub use xiaoo_app::gateway;16+pub use xiaoo_shared::{backend, gateway};
15 17 
16mod app;18mod app;
19+mod cli;
17mod gateway_api;20mod gateway_api;
18mod input;21mod input;
19mod render;22mod render;
@@ -30,6 +33,7 @@ pub(crate) use render::provider_dialog;
30pub(crate) use render::status_panel;33pub(crate) use render::status_panel;
31pub(crate) use render::theme;34pub(crate) use render::theme;
32pub(crate) use services::provider as provider_service;35pub(crate) use services::provider as provider_service;
36+pub(crate) use services::remote_sessions as remote_sessions_service;
33pub(crate) use services::session_snapshot as session_snapshot_service;37pub(crate) use services::session_snapshot as session_snapshot_service;
34pub(crate) use services::skills as skills_service;38pub(crate) use services::skills as skills_service;
35pub(crate) use services::workspace as workspace_service;39pub(crate) use services::workspace as workspace_service;
@@ -41,12 +45,31 @@ pub(crate) use support::debug_log;
41pub(crate) use support::error_log;45pub(crate) use support::error_log;
42 46 
43const CONFIG_ENV_VAR: &str = "XIAOO_CONFIG";47const CONFIG_ENV_VAR: &str = "XIAOO_CONFIG";
48+const CLI_SWITCH: &str = "--cli";
44 49 
45#[tokio::main]50#[tokio::main]
46async fn main() -> Result<()> {51async fn main() -> Result<()> {
47 let _cleanup_guard = ProcessGroupCleanupGuard;52 let _cleanup_guard = ProcessGroupCleanupGuard;
48 53 
49- let config_arg = parse_config_path()?;54+ match classify_args(env::args_os().collect()) {
55+ EntryInvocation::Help { program } => {
56+ print_end_side_usage(&program);
57+ Ok(())
58+ }
59+ EntryInvocation::Cli(args) => {
60+ cli::entry::run_cli_from_args(args).await;
61+ Ok(())
62+ }
63+ EntryInvocation::Tui(args) => run_tui_from_args(args).await,
64+ }
65+}
66+ 
67+pub async fn run_tui_from_args<I, T>(args: I) -> Result<()>
68+where
69+ I: IntoIterator<Item = T>,
70+ T: Into<OsString>,
71+{
72+ let config_arg = parse_config_path_from(args)?;
50 let config = load_tui_config(&config_arg)?;73 let config = load_tui_config(&config_arg)?;
51 config::load_llm_secrets_to_memory(&config_arg.path).with_context(|| {74 config::load_llm_secrets_to_memory(&config_arg.path).with_context(|| {
52 format!(75 format!(
@@ -58,16 +81,55 @@ async fn main() -> Result<()> {
58 run_tui(config, config_arg.path).await81 run_tui(config, config_arg.path).await
59}82}
60 83 
84+#[derive(Debug, PartialEq, Eq)]
85+enum EntryInvocation {
86+ Help { program: OsString },
87+ Cli(Vec<OsString>),
88+ Tui(Vec<OsString>),
89+}
90+ 
91+fn classify_args(mut args: Vec<OsString>) -> EntryInvocation {
92+ let program = args
93+ .first()
94+ .cloned()
95+ .unwrap_or_else(|| OsString::from("xiaoo"));
96+ match args.get(1) {
97+ Some(first) if os_str_eq(first, "--help") || os_str_eq(first, "-h") => {
98+ EntryInvocation::Help { program }
99+ }
100+ Some(first) if os_str_eq(first, CLI_SWITCH) => {
101+ args.remove(1);
102+ EntryInvocation::Cli(args)
103+ }
104+ _ => EntryInvocation::Tui(args),
105+ }
106+}
107+ 
108+fn os_str_eq(value: &OsStr, expected: &str) -> bool {
109+ value == OsStr::new(expected)
110+}
111+ 
112+fn print_end_side_usage(program: &OsStr) {
113+ eprintln!(
114+ "Usage: {} [--config <path>]\n {} --cli <command>\n\nDefault: launch the TUI.\nCLI: pass --cli before existing CLI commands, for example `{} --cli run -p \"hello\"`.",
115+ PathBuf::from(program).display(),
116+ PathBuf::from(program).display(),
117+ PathBuf::from(program).display()
118+ );
119+}
120+ 
61struct ConfigArg {121struct ConfigArg {
62 path: PathBuf,122 path: PathBuf,
63 explicit: bool,123 explicit: bool,
64}124}
65 125 
66-fn parse_config_path() -> Result<ConfigArg> {126+fn parse_config_path_from<I, T>(args: I) -> Result<ConfigArg>
67- let mut args = std::env::args_os();127+where
68- let program = args128+ I: IntoIterator<Item = T>,
69- .next()129+ T: Into<OsString>,
70- .unwrap_or_else(|| std::ffi::OsString::from("xiaoo-app"));130+{
131+ let mut args = args.into_iter().map(Into::into);
132+ let program = args.next().unwrap_or_else(|| OsString::from("xiaoo"));
71 133 
72 let cli_path = match args.next() {134 let cli_path = match args.next() {
73 None => None,135 None => None,
@@ -372,3 +434,46 @@ fn validate_config_for_tui(
372 434 
373 (errors, warnings)435 (errors, warnings)
374}436}
437+ 
438+#[cfg(test)]
439+mod tests {
440+ use super::{classify_args, EntryInvocation};
441+ use std::ffi::OsString;
442+ 
443+ #[test]
444+ fn no_args_dispatches_to_tui() {
445+ assert_eq!(
446+ classify_args(vec![OsString::from("xiaoo")]),
447+ EntryInvocation::Tui(vec![OsString::from("xiaoo")])
448+ );
449+ }
450+ 
451+ #[test]
452+ fn cli_switch_dispatches_to_cli_without_switch() {
453+ assert_eq!(
454+ classify_args(vec![
455+ OsString::from("xiaoo"),
456+ OsString::from("--cli"),
457+ OsString::from("run"),
458+ OsString::from("-p"),
459+ OsString::from("hello"),
460+ ]),
461+ EntryInvocation::Cli(vec![
462+ OsString::from("xiaoo"),
463+ OsString::from("run"),
464+ OsString::from("-p"),
465+ OsString::from("hello"),
466+ ])
467+ );
468+ }
469+ 
470+ #[test]
471+ fn help_dispatches_to_end_side_help() {
472+ assert_eq!(
473+ classify_args(vec![OsString::from("xiaoo"), OsString::from("--help")]),
474+ EntryInvocation::Help {
475+ program: OsString::from("xiaoo"),
476+ }
477+ );
478+ }
479+}
Rapps/xiaoo-app/src/prompts/cli_default_system_prompt.txtapps/endside/src/prompts/cli_default_system_prompt.txt+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/prompts/subagent_system_prompt.txtapps/endside/src/prompts/subagent_system_prompt.txt+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/prompts/tui_default_system_prompt.txtapps/endside/src/prompts/tui_default_system_prompt.txt+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/tui/render/header.rsapps/endside/src/render/header.rs+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/tui/render/interaction_prompt.rsapps/endside/src/render/interaction_prompt.rs+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/tui/render/markdown.rsapps/endside/src/render/markdown.rs+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/tui/render/mod.rsapps/endside/src/render/mod.rs+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/tui/render/overlay.rsapps/endside/src/render/overlay.rs+164-1
@@ -12,10 +12,14 @@ use crate::app::App;
12use crate::app_state::{ApiKeyDialogState, InputMode, SandboxDialog};12use crate::app_state::{ApiKeyDialogState, InputMode, SandboxDialog};
13use crate::interaction_prompt::{interaction_prompt_outer_height, render_interaction_prompt};13use crate::interaction_prompt::{interaction_prompt_outer_height, render_interaction_prompt};
14use crate::provider_dialog::ProviderDialog;14use crate::provider_dialog::ProviderDialog;
15+use crate::remote_sessions_service::{
16+ daemon_display, format_remote_time, RemoteSessionDialog, RemoteSessionDialogEntry,
17+ RemoteSessionDialogMode,
18+};
15use crate::services::turn_delete::DeleteDialog;19use crate::services::turn_delete::DeleteDialog;
16use crate::session_snapshot_service::{format_snapshot_time, SessionSnapshotDialog};20use crate::session_snapshot_service::{format_snapshot_time, SessionSnapshotDialog};
17 21 
18-use super::utils::sanitize_terminal_text;22+use super::utils::{line_prefix_width, sanitize_terminal_text};
19 23 
20/// Flatten newlines and truncate `text` to fit within `max_width` terminal columns,24/// Flatten newlines and truncate `text` to fit within `max_width` terminal columns,
21/// appending "..." when truncated.25/// appending "..." when truncated.
@@ -299,6 +303,8 @@ impl App {
299 " Enter 连接 | Esc 取消 "303 " Enter 连接 | Esc 取消 "
300 } else if self.state.session_snapshot_dialog.is_some() {304 } else if self.state.session_snapshot_dialog.is_some() {
301 " ↑↓ 选择快照 | Enter 读取 | Esc 取消 "305 " ↑↓ 选择快照 | Enter 读取 | Esc 取消 "
306+ } else if self.state.remote_session_dialog.is_some() {
307+ " ↑↓ 选择 remote session | Enter 确认 | Esc 取消 "
302 } else if self.state.delete_dialog.is_some() {308 } else if self.state.delete_dialog.is_some() {
303 " ↑↓ 选择 | Enter 确认 | Esc 取消 "309 " ↑↓ 选择 | Enter 确认 | Esc 取消 "
304 } else if self.state.provider_dialog.is_some() {310 } else if self.state.provider_dialog.is_some() {
@@ -357,12 +363,14 @@ impl App {
357 && self.state.api_key_dialog.is_none()363 && self.state.api_key_dialog.is_none()
358 && self.state.provider_dialog.is_none()364 && self.state.provider_dialog.is_none()
359 && self.state.sandbox_dialog.is_none()365 && self.state.sandbox_dialog.is_none()
366+ && self.state.remote_session_dialog.is_none()
360 && self.state.session_snapshot_dialog.is_none()367 && self.state.session_snapshot_dialog.is_none()
361 && matches!(368 && matches!(
362 self.state.input_mode,369 self.state.input_mode,
363 InputMode::Editing370 InputMode::Editing
364 | InputMode::ProviderSelection371 | InputMode::ProviderSelection
365 | InputMode::SandboxSelection372 | InputMode::SandboxSelection
373+ | InputMode::RemoteSessionSelection
366 | InputMode::SessionSnapshotSelection374 | InputMode::SessionSnapshotSelection
367 )375 )
368 {376 {
@@ -555,6 +563,161 @@ impl App {
555 frame.render_widget(hint, hint_area);563 frame.render_widget(hint, hint_area);
556 }564 }
557 565 
566+ pub(crate) fn render_remote_session_dialog(
567+ &self,
568+ frame: &mut Frame,
569+ area: Rect,
570+ dialog: &RemoteSessionDialog,
571+ ) {
572+ let dialog_width = area.width.min(92).max(56);
573+ let desired_height = match dialog.mode {
574+ RemoteSessionDialogMode::List => (dialog.entries.len() as u16 + 4).clamp(8, 22),
575+ RemoteSessionDialogMode::NewUrl => 9,
576+ };
577+ let dialog_height = area.height.min(desired_height).max(8);
578+ let dialog_x = area.x + (area.width.saturating_sub(dialog_width)) / 2;
579+ let dialog_y = area.y + (area.height.saturating_sub(dialog_height)) / 2;
580+ let dialog_area = Rect {
581+ x: dialog_x,
582+ y: dialog_y,
583+ width: dialog_width,
584+ height: dialog_height,
585+ };
586+ 
587+ render_popup_backdrop(frame, dialog_area, area, self.state.theme.background);
588+ let block = Block::default()
589+ .title(" Remote Sessions ")
590+ .borders(Borders::ALL)
591+ .border_type(BorderType::Rounded)
592+ .border_style(self.state.theme.border_style(true))
593+ .style(Style::default().bg(self.state.theme.background))
594+ .padding(Padding::horizontal(1));
595+ let inner = block.inner(dialog_area);
596+ frame.render_widget(block, dialog_area);
597+ 
598+ match dialog.mode {
599+ RemoteSessionDialogMode::List => {
600+ let list_area = Rect {
601+ x: inner.x,
602+ y: inner.y,
603+ width: inner.width,
604+ height: inner.height.saturating_sub(1),
605+ };
606+ let hint_area = Rect {
607+ x: inner.x,
608+ y: inner.y + inner.height.saturating_sub(1),
609+ width: inner.width,
610+ height: 1,
611+ };
612+ let daemon_width = (list_area.width / 3).clamp(16, 28) as usize;
613+ let time_width = 12usize;
614+ let preview_width = list_area
615+ .width
616+ .saturating_sub(daemon_width as u16)
617+ .saturating_sub(time_width as u16)
618+ .saturating_sub(6) as usize;
619+ let items: Vec<ListItem> = dialog
620+ .entries
621+ .iter()
622+ .enumerate()
623+ .map(|(index, entry)| {
624+ let selected = index == dialog.selected;
625+ let style = if selected {
626+ Style::default()
627+ .fg(self.state.theme.foreground)
628+ .bg(self.state.theme.selection)
629+ .add_modifier(Modifier::BOLD)
630+ } else {
631+ Style::default().fg(self.state.theme.foreground)
632+ };
633+ match entry {
634+ RemoteSessionDialogEntry::Existing(record) => {
635+ let daemon =
636+ truncate_chars(&daemon_display(&record.base_url), daemon_width);
637+ let time = format_remote_time(record.last_active_at_ms);
638+ let preview = record
639+ .first_message_preview
640+ .as_deref()
641+ .filter(|value| !value.trim().is_empty())
642+ .unwrap_or("No messages yet");
643+ let preview = truncate_chars(preview, preview_width.max(8));
644+ ListItem::new(Line::from(vec![
645+ Span::styled(format!("{daemon:<daemon_width$}"), style),
646+ Span::styled(" ", style),
647+ Span::styled(format!("{time:<time_width$}"), style),
648+ Span::styled(" ", style),
649+ Span::styled(preview, style),
650+ ]))
651+ }
652+ RemoteSessionDialogEntry::New => ListItem::new(Line::from(vec![
653+ Span::styled("New remote session...", style),
654+ Span::styled(
655+ " configure daemon URL",
656+ Style::default().fg(self.state.theme.muted),
657+ ),
658+ ])),
659+ }
660+ })
661+ .collect();
662+ let mut list_state = ListState::default();
663+ list_state.select(Some(dialog.selected));
664+ frame.render_stateful_widget(List::new(items), list_area, &mut list_state);
665+ 
666+ let hint = Paragraph::new("Enter 继续/新建 Esc 取消")
667+ .style(Style::default().fg(self.state.theme.muted));
668+ frame.render_widget(hint, hint_area);
669+ }
670+ RemoteSessionDialogMode::NewUrl => {
671+ let chunks = Layout::default()
672+ .direction(Direction::Vertical)
673+ .constraints([
674+ Constraint::Length(1),
675+ Constraint::Length(3),
676+ Constraint::Length(1),
677+ Constraint::Min(1),
678+ ])
679+ .split(inner);
680+ frame.render_widget(
681+ Paragraph::new("Daemon URL / IP:port")
682+ .style(Style::default().fg(self.state.theme.muted)),
683+ chunks[0],
684+ );
685+ let input_block = Block::default()
686+ .borders(Borders::ALL)
687+ .border_type(BorderType::Rounded)
688+ .border_style(self.state.theme.border_style(true))
689+ .padding(Padding::horizontal(1));
690+ let input_inner = input_block.inner(chunks[1]);
691+ frame.render_widget(
692+ Paragraph::new(dialog.url_input.value())
693+ .style(self.state.theme.default_style())
694+ .block(input_block),
695+ chunks[1],
696+ );
697+ let cursor_x = input_inner.x.saturating_add(
698+ line_prefix_width(dialog.url_input.value(), dialog.url_input.cursor())
699+ .min(input_inner.width.saturating_sub(1) as usize)
700+ as u16,
701+ );
702+ frame.set_cursor_position((cursor_x, input_inner.y));
703+ 
704+ if let Some(error) = dialog.error.as_ref() {
705+ frame.render_widget(
706+ Paragraph::new(error.as_str())
707+ .style(Style::default().fg(self.state.theme.error)),
708+ chunks[2],
709+ );
710+ }
711+ frame.render_widget(
712+ Paragraph::new("Enter 连接并创建新的本地 session Esc 返回列表")
713+ .style(Style::default().fg(self.state.theme.muted))
714+ .wrap(Wrap { trim: true }),
715+ chunks[3],
716+ );
717+ }
718+ }
719+ }
720+ 
558 pub(crate) fn render_session_snapshot_dialog(721 pub(crate) fn render_session_snapshot_dialog(
559 &self,722 &self,
560 frame: &mut Frame,723 frame: &mut Frame,
Rapps/xiaoo-app/src/tui/render/provider_dialog.rsapps/endside/src/render/provider_dialog.rs+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/tui/render/root.rsapps/endside/src/render/root.rs+4-0
@@ -72,6 +72,7 @@ impl App {
72 72 
73 if self.state.provider_dialog.is_none()73 if self.state.provider_dialog.is_none()
74 && self.state.sandbox_dialog.is_none()74 && self.state.sandbox_dialog.is_none()
75+ && self.state.remote_session_dialog.is_none()
75 && self.state.api_key_dialog.is_none()76 && self.state.api_key_dialog.is_none()
76 && self.state.session_snapshot_dialog.is_none()77 && self.state.session_snapshot_dialog.is_none()
77 {78 {
@@ -84,6 +85,9 @@ impl App {
84 if let Some(dialog) = self.state.sandbox_dialog.as_ref() {85 if let Some(dialog) = self.state.sandbox_dialog.as_ref() {
85 self.render_sandbox_dialog(frame, frame.area(), dialog);86 self.render_sandbox_dialog(frame, frame.area(), dialog);
86 }87 }
88+ if let Some(dialog) = self.state.remote_session_dialog.clone() {
89+ self.render_remote_session_dialog(frame, frame.area(), &dialog);
90+ }
87 if let Some(dialog) = self.state.session_snapshot_dialog.as_ref() {91 if let Some(dialog) = self.state.session_snapshot_dialog.as_ref() {
88 self.render_session_snapshot_dialog(frame, frame.area(), dialog);92 self.render_session_snapshot_dialog(frame, frame.area(), dialog);
89 }93 }
Rapps/xiaoo-app/src/tui/render/session_diff.rsapps/endside/src/render/session_diff.rs+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/tui/render/status_panel.rsapps/endside/src/render/status_panel.rs+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/tui/render/theme.rsapps/endside/src/render/theme.rs+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/tui/render/transcript.rsapps/endside/src/render/transcript.rs+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/tui/render/utils.rsapps/endside/src/render/utils.rs+7-0
@@ -20,6 +20,13 @@ pub fn paste_into_input(input: &mut Input, text: &str) {
20 }20 }
21}21}
22 22 
23+pub(crate) fn line_prefix_width(line: &str, col_chars: usize) -> usize {
24+ line.chars()
25+ .take(col_chars)
26+ .map(|ch| UnicodeWidthChar::width(ch).unwrap_or(0))
27+ .sum()
28+}
29+ 
23fn use_ascii_terminal_symbols() -> bool {30fn use_ascii_terminal_symbols() -> bool {
24 static CACHE: OnceLock<bool> = OnceLock::new();31 static CACHE: OnceLock<bool> = OnceLock::new();
25 *CACHE.get_or_init(|| {32 *CACHE.get_or_init(|| {
Rapps/xiaoo-app/src/tui/services/command_loader.rsapps/endside/src/services/command_loader.rs+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/tui/services/mod.rsapps/endside/src/services/mod.rs+1-0
@@ -1,5 +1,6 @@
1pub(crate) mod command_loader;1pub(crate) mod command_loader;
2pub(crate) mod provider;2pub(crate) mod provider;
3+pub(crate) mod remote_sessions;
3pub(crate) mod session_snapshot;4pub(crate) mod session_snapshot;
4pub(crate) mod skills;5pub(crate) mod skills;
5pub(crate) mod turn_delete;6pub(crate) mod turn_delete;
Rapps/xiaoo-app/src/tui/services/provider.rsapps/endside/src/services/provider.rs+1-1
@@ -275,7 +275,7 @@ mod tests {
275 let saved = Config::load_from(&config_path).expect("load saved config");275 let saved = Config::load_from(&config_path).expect("load saved config");
276 assert!(saved.vault.enabled);276 assert!(saved.vault.enabled);
277 assert_eq!(saved.llm.api_key_env.as_deref(), Some("OPENAI_API_KEY"));277 assert_eq!(saved.llm.api_key_env.as_deref(), Some("OPENAI_API_KEY"));
278- assert!(xiaoo_app::llm_secrets::llm_secrets_path(&config_path).exists());278+ assert!(xiaoo_shared::llm_secrets::llm_secrets_path(&config_path).exists());
279 }279 }
280 280 
281 #[test]281 #[test]
Aapps/endside/src/services/remote_sessions.rs+260-0
@@ -0,0 +1,260 @@
1+use anyhow::{Context, Result};
2+use chrono::{Local, TimeZone};
3+use serde::{Deserialize, Serialize};
4+use std::fs;
5+use std::path::PathBuf;
6+ 
7+use crate::input::Input;
8+ 
9+const REGISTRY_VERSION: u32 = 1;
10+const MAX_REMOTE_SESSIONS: usize = 50;
11+ 
12+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
13+pub struct RemoteSessionRecord {
14+ pub session_id: String,
15+ pub base_url: String,
16+ #[serde(default)]
17+ pub bearer_token_env: Option<String>,
18+ #[serde(default)]
19+ pub first_message_preview: Option<String>,
20+ pub created_at_ms: u64,
21+ pub last_active_at_ms: u64,
22+}
23+ 
24+#[derive(Debug, Clone, Serialize, Deserialize)]
25+struct RemoteSessionRegistry {
26+ version: u32,
27+ #[serde(default)]
28+ sessions: Vec<RemoteSessionRecord>,
29+}
30+ 
31+impl Default for RemoteSessionRegistry {
32+ fn default() -> Self {
33+ Self {
34+ version: REGISTRY_VERSION,
35+ sessions: Vec::new(),
36+ }
37+ }
38+}
39+ 
40+#[derive(Debug, Clone)]
41+pub enum RemoteSessionDialogEntry {
42+ Existing(RemoteSessionRecord),
43+ New,
44+}
45+ 
46+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47+pub enum RemoteSessionDialogMode {
48+ List,
49+ NewUrl,
50+}
51+ 
52+#[derive(Debug, Clone)]
53+pub struct RemoteSessionDialog {
54+ pub entries: Vec<RemoteSessionDialogEntry>,
55+ pub selected: usize,
56+ pub mode: RemoteSessionDialogMode,
57+ pub url_input: Input,
58+ pub error: Option<String>,
59+}
60+ 
61+impl RemoteSessionDialog {
62+ pub fn new(records: Vec<RemoteSessionRecord>) -> Self {
63+ let mut entries = records
64+ .into_iter()
65+ .map(RemoteSessionDialogEntry::Existing)
66+ .collect::<Vec<_>>();
67+ entries.push(RemoteSessionDialogEntry::New);
68+ Self {
69+ entries,
70+ selected: 0,
71+ mode: RemoteSessionDialogMode::List,
72+ url_input: Input::default(),
73+ error: None,
74+ }
75+ }
76+ 
77+ pub fn move_up(&mut self) {
78+ self.selected = self.selected.saturating_sub(1);
79+ }
80+ 
81+ pub fn move_down(&mut self) {
82+ if !self.entries.is_empty() {
83+ self.selected = (self.selected + 1).min(self.entries.len() - 1);
84+ }
85+ }
86+ 
87+ pub fn selected_entry(&self) -> Option<&RemoteSessionDialogEntry> {
88+ self.entries.get(self.selected)
89+ }
90+ 
91+ pub fn enter_new_url_mode(&mut self) {
92+ self.mode = RemoteSessionDialogMode::NewUrl;
93+ self.error = None;
94+ }
95+}
96+ 
97+pub fn list_remote_sessions() -> Result<Vec<RemoteSessionRecord>> {
98+ let mut registry = load_registry()?;
99+ registry.sessions.sort_by(|left, right| {
100+ right
101+ .last_active_at_ms
102+ .cmp(&left.last_active_at_ms)
103+ .then_with(|| left.base_url.cmp(&right.base_url))
104+ .then_with(|| left.session_id.cmp(&right.session_id))
105+ });
106+ Ok(registry.sessions)
107+}
108+ 
109+pub fn record_remote_session(
110+ session_id: &str,
111+ base_url: &str,
112+ bearer_token_env: Option<String>,
113+ first_message: Option<&str>,
114+) -> Result<RemoteSessionRecord> {
115+ let now = current_time_ms();
116+ let mut registry = load_registry()?;
117+ let normalized_url = normalize_base_url(base_url);
118+ let first_message_preview = first_message
119+ .map(summarize_first_message)
120+ .filter(|value| !value.is_empty());
121+ 
122+ let record = match registry.sessions.iter_mut().find(|record| {
123+ record.session_id == session_id && normalize_base_url(&record.base_url) == normalized_url
124+ }) {
125+ Some(record) => {
126+ record.base_url = normalized_url.clone();
127+ record.bearer_token_env = bearer_token_env;
128+ if record.first_message_preview.is_none() {
129+ record.first_message_preview = first_message_preview;
130+ }
131+ record.last_active_at_ms = now;
132+ record.clone()
133+ }
134+ None => {
135+ let record = RemoteSessionRecord {
136+ session_id: session_id.to_string(),
137+ base_url: normalized_url,
138+ bearer_token_env,
139+ first_message_preview,
140+ created_at_ms: now,
141+ last_active_at_ms: now,
142+ };
143+ registry.sessions.push(record.clone());
144+ record
145+ }
146+ };
147+ 
148+ registry.sessions.sort_by(|left, right| {
149+ right
150+ .last_active_at_ms
151+ .cmp(&left.last_active_at_ms)
152+ .then_with(|| left.base_url.cmp(&right.base_url))
153+ .then_with(|| left.session_id.cmp(&right.session_id))
154+ });
155+ registry.sessions.truncate(MAX_REMOTE_SESSIONS);
156+ save_registry(&registry)?;
157+ Ok(record)
158+}
159+ 
160+pub fn daemon_display(base_url: &str) -> String {
161+ let trimmed = base_url.trim().trim_end_matches('/');
162+ let without_scheme = trimmed
163+ .strip_prefix("http://")
164+ .or_else(|| trimmed.strip_prefix("https://"))
165+ .unwrap_or(trimmed);
166+ let host_port = without_scheme.split('/').next().unwrap_or(without_scheme);
167+ if host_port.is_empty() {
168+ trimmed.to_string()
169+ } else {
170+ host_port.to_string()
171+ }
172+}
173+ 
174+pub fn format_remote_time(timestamp_ms: u64) -> String {
175+ match Local.timestamp_millis_opt(timestamp_ms as i64).single() {
176+ Some(dt) => dt.format("%m-%d %H:%M").to_string(),
177+ None => "-".to_string(),
178+ }
179+}
180+ 
181+pub fn summarize_first_message(text: &str) -> String {
182+ let flattened = text.split_whitespace().collect::<Vec<_>>().join(" ");
183+ truncate_chars(&flattened, 36)
184+}
185+ 
186+fn load_registry() -> Result<RemoteSessionRegistry> {
187+ let path = registry_path()?;
188+ if !path.exists() {
189+ return Ok(RemoteSessionRegistry::default());
190+ }
191+ let content = fs::read_to_string(&path)
192+ .with_context(|| format!("failed to read remote session registry {}", path.display()))?;
193+ if content.trim().is_empty() {
194+ return Ok(RemoteSessionRegistry::default());
195+ }
196+ serde_json::from_str(&content)
197+ .with_context(|| format!("failed to parse remote session registry {}", path.display()))
198+}
199+ 
200+fn save_registry(registry: &RemoteSessionRegistry) -> Result<()> {
201+ let path = registry_path()?;
202+ if let Some(parent) = path.parent() {
203+ fs::create_dir_all(parent)
204+ .with_context(|| format!("failed to create remote session dir {}", parent.display()))?;
205+ }
206+ let content = serde_json::to_string_pretty(registry)?;
207+ fs::write(&path, content)
208+ .with_context(|| format!("failed to write remote session registry {}", path.display()))
209+}
210+ 
211+fn registry_path() -> Result<PathBuf> {
212+ let home = dirs::home_dir().context("unable to resolve home directory for ~/.xiaoo")?;
213+ Ok(home.join(".xiaoo").join("remote_sessions.json"))
214+}
215+ 
216+fn normalize_base_url(url: &str) -> String {
217+ url.trim().trim_end_matches('/').to_string()
218+}
219+ 
220+fn current_time_ms() -> u64 {
221+ std::time::SystemTime::now()
222+ .duration_since(std::time::UNIX_EPOCH)
223+ .map(|duration| duration.as_millis() as u64)
224+ .unwrap_or(0)
225+}
226+ 
227+fn truncate_chars(value: &str, max_chars: usize) -> String {
228+ let count = value.chars().count();
229+ if count <= max_chars {
230+ return value.to_string();
231+ }
232+ if max_chars <= 3 {
233+ return ".".repeat(max_chars);
234+ }
235+ let prefix: String = value.chars().take(max_chars - 3).collect();
236+ format!("{prefix}...")
237+}
238+ 
239+#[cfg(test)]
240+mod tests {
241+ use super::{daemon_display, summarize_first_message};
242+ 
243+ #[test]
244+ fn daemon_display_extracts_host_and_port() {
245+ assert_eq!(daemon_display("http://127.0.0.1:8070/"), "127.0.0.1:8070");
246+ assert_eq!(
247+ daemon_display("https://example.com:443/api"),
248+ "example.com:443"
249+ );
250+ }
251+ 
252+ #[test]
253+ fn first_message_summary_is_one_line() {
254+ assert_eq!(
255+ summarize_first_message("hello\nremote session"),
256+ "hello remote session"
257+ );
258+ assert!(summarize_first_message(&"a".repeat(80)).ends_with("..."));
259+ }
260+}
Rapps/xiaoo-app/src/tui/services/session_snapshot.rsapps/endside/src/services/session_snapshot.rs+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/tui/services/skills.rsapps/endside/src/services/skills.rs+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/tui/services/turn_delete.rsapps/endside/src/services/turn_delete.rs+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/tui/services/workspace.rsapps/endside/src/services/workspace.rs+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/tui/state/app_state.rsapps/endside/src/state/app_state.rs+8-3
@@ -6,9 +6,9 @@ use std::fs;
6use std::path::{Path, PathBuf};6use std::path::{Path, PathBuf};
7use std::time::{Duration, Instant};7use std::time::{Duration, Instant};
8 8 
9+use crate::backend::GatewayBackendConfig;
9use crate::chat::{default_provider_list, merge_config_provider, ChatState, TodoMessageState};10use crate::chat::{default_provider_list, merge_config_provider, ChatState, TodoMessageState};
10use crate::config::{AgentRoleConfig, Config};11use crate::config::{AgentRoleConfig, Config};
11-use crate::gateway::backend::GatewayBackendConfig;
12use crate::input::Input;12use crate::input::Input;
13use crate::interaction_prompt::{InteractionPromptState, PromptRequest};13use crate::interaction_prompt::{InteractionPromptState, PromptRequest};
14use crate::provider_dialog::ProviderDialog;14use crate::provider_dialog::ProviderDialog;
@@ -23,6 +23,7 @@ pub enum InputMode {
23 Editing,23 Editing,
24 ProviderSelection,24 ProviderSelection,
25 SandboxSelection,25 SandboxSelection,
26+ RemoteSessionSelection,
26 SessionSnapshotSelection,27 SessionSnapshotSelection,
27 InteractionPrompt,28 InteractionPrompt,
28 TurnDelete,29 TurnDelete,
@@ -187,6 +188,7 @@ pub struct AppState {
187 pub should_quit: bool,188 pub should_quit: bool,
188 pub provider_dialog: Option<ProviderDialog>,189 pub provider_dialog: Option<ProviderDialog>,
189 pub sandbox_dialog: Option<SandboxDialog>,190 pub sandbox_dialog: Option<SandboxDialog>,
191+ pub remote_session_dialog: Option<crate::remote_sessions_service::RemoteSessionDialog>,
190 pub session_snapshot_dialog: Option<crate::session_snapshot_service::SessionSnapshotDialog>,192 pub session_snapshot_dialog: Option<crate::session_snapshot_service::SessionSnapshotDialog>,
191 pub delete_dialog: Option<crate::services::turn_delete::DeleteDialog>,193 pub delete_dialog: Option<crate::services::turn_delete::DeleteDialog>,
192 pub api_key_dialog: Option<ApiKeyDialogState>,194 pub api_key_dialog: Option<ApiKeyDialogState>,
@@ -225,6 +227,7 @@ impl AppState {
225 should_quit: false,227 should_quit: false,
226 provider_dialog: None,228 provider_dialog: None,
227 sandbox_dialog: None,229 sandbox_dialog: None,
230+ remote_session_dialog: None,
228 session_snapshot_dialog: None,231 session_snapshot_dialog: None,
229 delete_dialog: None,232 delete_dialog: None,
230 api_key_dialog: None,233 api_key_dialog: None,
@@ -264,6 +267,7 @@ impl AppState {
264 should_quit: false,267 should_quit: false,
265 provider_dialog: None,268 provider_dialog: None,
266 sandbox_dialog: None,269 sandbox_dialog: None,
270+ remote_session_dialog: None,
267 session_snapshot_dialog: None,271 session_snapshot_dialog: None,
268 delete_dialog: None,272 delete_dialog: None,
269 api_key_dialog: None,273 api_key_dialog: None,
@@ -297,6 +301,7 @@ impl AppState {
297 self.input_mode = InputMode::Editing;301 self.input_mode = InputMode::Editing;
298 self.provider_dialog = None;302 self.provider_dialog = None;
299 self.sandbox_dialog = None;303 self.sandbox_dialog = None;
304+ self.remote_session_dialog = None;
300 self.session_snapshot_dialog = None;305 self.session_snapshot_dialog = None;
301 self.delete_dialog = None;306 self.delete_dialog = None;
302 self.api_key_dialog = None;307 self.api_key_dialog = None;
@@ -1004,8 +1009,8 @@ mod tests {
1004 current_sandbox_id, sandbox_backend_config, sandbox_display_name, ApiKeyDialogState,1009 current_sandbox_id, sandbox_backend_config, sandbox_display_name, ApiKeyDialogState,
1005 AppState, RuntimeStatusLight,1010 AppState, RuntimeStatusLight,
1006 };1011 };
1012+ use crate::backend::GatewayBackendConfig;
1007 use crate::config::{AgentRoleConfig, Config};1013 use crate::config::{AgentRoleConfig, Config};
1008- use crate::gateway::backend::GatewayBackendConfig;
1009 use crate::input::Input;1014 use crate::input::Input;
1010 use crate::interaction_prompt::{PromptChoice, PromptRequest};1015 use crate::interaction_prompt::{PromptChoice, PromptRequest};
1011 use agent_types::ReasoningEffort;1016 use agent_types::ReasoningEffort;
@@ -1249,8 +1254,8 @@ mod tests {
1249 }],1254 }],
1250 allow_custom_input: true,1255 allow_custom_input: true,
1251 multi_select: false,1256 multi_select: false,
1252- default_index: Some(0),
1253 is_secret: false,1257 is_secret: false,
1258+ default_index: Some(0),
1254 }1259 }
1255 }1260 }
1256}1261}
Rapps/xiaoo-app/src/tui/state/chat.rsapps/endside/src/state/chat.rs+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/tui/state/mod.rsapps/endside/src/state/mod.rs+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/tui/state/selection.rsapps/endside/src/state/selection.rs+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/tui/support/config.rsapps/endside/src/support/config.rs+5-5
@@ -1,4 +1,4 @@
1-use crate::gateway::backend::GatewayBackendConfig;1+use crate::backend::GatewayBackendConfig;
2use agent_types::hook::HookerRegistryConfig;2use agent_types::hook::HookerRegistryConfig;
3use agent_types::ReasoningEffort;3use agent_types::ReasoningEffort;
4use anyhow::{bail, Context, Result};4use anyhow::{bail, Context, Result};
@@ -11,7 +11,7 @@ use std::collections::{BTreeMap, BTreeSet};
11use std::fs;11use std::fs;
12use std::path::{Path, PathBuf};12use std::path::{Path, PathBuf};
13use std::sync::Arc;13use std::sync::Arc;
14-use xiaoo_app::builtin_agent_roles::{PLAN_AGENT_DESCRIPTION, PLAN_AGENT_ID, PLAN_AGENT_PROMPT};14+use xiaoo_shared::builtin_agent_roles::{PLAN_AGENT_DESCRIPTION, PLAN_AGENT_ID, PLAN_AGENT_PROMPT};
15 15 
16const DEFAULT_AGENT_ID: &str = "main";16const DEFAULT_AGENT_ID: &str = "main";
17const DEFAULT_LLM_MAX_TOKENS: u32 = 16384;17const DEFAULT_LLM_MAX_TOKENS: u32 = 16384;
@@ -402,7 +402,7 @@ fn install_builtin_agent_roles(agent_roles: &mut BTreeMap<String, AgentRoleConfi
402}402}
403 403 
404pub fn save_llm_secret(config_path: &Path, env_name: &str, secret: &str) -> Result<()> {404pub fn save_llm_secret(config_path: &Path, env_name: &str, secret: &str) -> Result<()> {
405- xiaoo_app::llm_secrets::save_llm_secret(config_path, env_name, secret)405+ xiaoo_shared::llm_secrets::save_llm_secret(config_path, env_name, secret)
406}406}
407 407 
408pub fn load_llm_secrets_to_memory(config_path: &Path) -> Result<()> {408pub fn load_llm_secrets_to_memory(config_path: &Path) -> Result<()> {
@@ -411,7 +411,7 @@ pub fn load_llm_secrets_to_memory(config_path: &Path) -> Result<()> {
411 Err(_) => return Ok(()),411 Err(_) => return Ok(()),
412 };412 };
413 413 
414- let secrets_path = xiaoo_app::llm_secrets::llm_secrets_path(config_path);414+ let secrets_path = xiaoo_shared::llm_secrets::llm_secrets_path(config_path);
415 let file_existed_before = secrets_path.exists();415 let file_existed_before = secrets_path.exists();
416 416 
417 if !should_initialize_secret_provider(&config, file_existed_before) {417 if !should_initialize_secret_provider(&config, file_existed_before) {
@@ -420,7 +420,7 @@ pub fn load_llm_secrets_to_memory(config_path: &Path) -> Result<()> {
420 }420 }
421 421 
422 if config.vault.enabled {422 if config.vault.enabled {
423- xiaoo_app::llm_secrets::auto_save_from_env(config_path)?;423+ xiaoo_shared::llm_secrets::auto_save_from_env(config_path)?;
424 } else {424 } else {
425 tracing::info!(425 tracing::info!(
426 "existing llm_secrets.json found while vault.enabled=false; initializing secret provider"426 "existing llm_secrets.json found while vault.enabled=false; initializing secret provider"
Rapps/xiaoo-app/src/tui/support/debug_log.rsapps/endside/src/support/debug_log.rs+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/tui/support/error_log.rsapps/endside/src/support/error_log.rs+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/tui/support/mod.rsapps/endside/src/support/mod.rs+0-0
文件重命名但无更改。
Aapps/serverside/Cargo.toml+55-0
@@ -0,0 +1,55 @@
1+[package]
2+name = "xiaoo-serverside"
3+version.workspace = true
4+edition.workspace = true
5+license.workspace = true
6+ 
7+[features]
8+default = []
9+tee_sdf = ["xiaoo-shared/tee_sdf"]
10+ 
11+[[bin]]
12+name = "xiaoo-daemon"
13+path = "src/main.rs"
14+ 
15+[dependencies]
16+anyhow.workspace = true
17+async-trait.workspace = true
18+axum = "0.7"
19+chrono.workspace = true
20+compact = { path = "../../crates/compact" }
21+dirs = "6"
22+futures-util.workspace = true
23+governor = "0.6"
24+llm-client = { path = "../../crates/llm-client" }
25+lsp = { path = "../../crates/lsp" }
26+mime_guess.workspace = true
27+operation_backend = { path = "../../crates/operation_backend" }
28+prompt = { path = "../../crates/prompt" }
29+prost = "0.13"
30+regex = "1.11"
31+reqwest.workspace = true
32+serde.workspace = true
33+serde_json.workspace = true
34+skill = { path = "../../crates/skill" }
35+tokio = { workspace = true, features = ["macros", "net", "rt-multi-thread", "signal", "time"] }
36+tokio-stream.workspace = true
37+tokio-tungstenite = { version = "0.24", features = ["rustls-tls-native-roots"] }
38+tokio-util.workspace = true
39+toml.workspace = true
40+tower = { workspace = true }
41+tower_governor = "0.4"
42+tracing.workspace = true
43+tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
44+thiserror.workspace = true
45+url = "2.5"
46+uuid.workspace = true
47+ 
48+agent-contracts = { path = "../../crates/agent-contracts" }
49+agent-types = { path = "../../crates/agent-types" }
50+tool = { path = "../../crates/tool" }
51+xiaoo-shared = { path = "../shared" }
52+ 
53+[dev-dependencies]
54+tempfile.workspace = true
55+tower = { workspace = true, features = ["util"] }
Rapps/xiaoo-app/src/channels/feishu/channel.rsapps/serverside/src/channels/feishu/channel.rs+2-0
@@ -31,11 +31,13 @@ impl FeishuAdapter {
31 })31 })
32 }32 }
33 33 
34+ #[allow(dead_code)]
34 pub async fn get_chat_info(&self, chat_id: &str) -> ChannelResult<FeishuChatInfo> {35 pub async fn get_chat_info(&self, chat_id: &str) -> ChannelResult<FeishuChatInfo> {
35 self.client.get_chat_info(chat_id).await36 self.client.get_chat_info(chat_id).await
36 }37 }
37}38}
38 39 
40+#[allow(dead_code)]
39pub fn runtime(config: FeishuConfig) -> ChannelResult<ChannelRuntime> {41pub fn runtime(config: FeishuConfig) -> ChannelResult<ChannelRuntime> {
40 let adapter: std::sync::Arc<dyn ChannelAdapter> =42 let adapter: std::sync::Arc<dyn ChannelAdapter> =
41 std::sync::Arc::new(FeishuAdapter::new(config)?);43 std::sync::Arc::new(FeishuAdapter::new(config)?);
Rapps/xiaoo-app/src/channels/feishu/client.rsapps/serverside/src/channels/feishu/client.rs+3-0
@@ -144,6 +144,7 @@ impl FeishuClient {
144 }144 }
145 }145 }
146 146 
147+ #[allow(dead_code)]
147 pub async fn get_chat_info(&self, chat_id: &str) -> ChannelResult<FeishuChatInfo> {148 pub async fn get_chat_info(&self, chat_id: &str) -> ChannelResult<FeishuChatInfo> {
148 let token = self.fetch_tenant_access_token().await?;149 let token = self.fetch_tenant_access_token().await?;
149 let response = self150 let response = self
@@ -554,6 +555,7 @@ fn parse_member_list_payload(
554 Ok((members, next_page_token))555 Ok((members, next_page_token))
555}556}
556 557 
558+#[allow(dead_code)]
557async fn parse_chat_info_response(response: reqwest::Response) -> ChannelResult<FeishuChatInfo> {559async fn parse_chat_info_response(response: reqwest::Response) -> ChannelResult<FeishuChatInfo> {
558 let status = response.status();560 let status = response.status();
559 let body = response561 let body = response
@@ -566,6 +568,7 @@ async fn parse_chat_info_response(response: reqwest::Response) -> ChannelResult<
566 parse_chat_info_payload(status, &body)568 parse_chat_info_payload(status, &body)
567}569}
568 570 
571+#[allow(dead_code)]
569fn parse_chat_info_payload(status: StatusCode, body: &str) -> ChannelResult<FeishuChatInfo> {572fn parse_chat_info_payload(status: StatusCode, body: &str) -> ChannelResult<FeishuChatInfo> {
570 if !status.is_success() {573 if !status.is_success() {
571 return Err(ChannelError::Delivery {574 return Err(ChannelError::Delivery {
Rapps/xiaoo-app/src/channels/feishu/ingress.rsapps/serverside/src/channels/feishu/ingress.rs+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/channels/feishu/mod.rsapps/serverside/src/channels/feishu/mod.rs+2-6
@@ -4,10 +4,6 @@ mod ingress;
4pub mod types;4pub mod types;
5pub mod websocket;5pub mod websocket;
6 6 
7-pub use channel::{capabilities, meta, runtime, FeishuAdapter};7+pub use channel::{capabilities, meta, FeishuAdapter};
8-pub use client::FeishuClient;8+pub use types::{FeishuConfig, FeishuEventTransport};
9-pub use types::{
10- FeishuCardRequest, FeishuChatInfo, FeishuChatMember, FeishuConfig, FeishuConfigError,
11- FeishuEventTransport, FeishuSendRequest,
12-};
13pub use websocket::{FeishuWebsocketMessageHandler, FeishuWebsocketService};9pub use websocket::{FeishuWebsocketMessageHandler, FeishuWebsocketService};
Rapps/xiaoo-app/src/channels/feishu/types.rsapps/serverside/src/channels/feishu/types.rs+3-0
@@ -97,6 +97,7 @@ pub struct FeishuChatMember {
97 pub name: Option<String>,97 pub name: Option<String>,
98}98}
99 99 
100+#[allow(dead_code)]
100#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]101#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
101pub struct FeishuChatInfo {102pub struct FeishuChatInfo {
102 pub owner_id: Option<String>,103 pub owner_id: Option<String>,
@@ -249,6 +250,7 @@ pub(crate) struct SendMessageData {
249 pub message_id: Option<String>,250 pub message_id: Option<String>,
250}251}
251 252 
253+#[allow(dead_code)]
252#[derive(Debug, Deserialize)]254#[derive(Debug, Deserialize)]
253pub(crate) struct ChatInfoEnvelope {255pub(crate) struct ChatInfoEnvelope {
254 pub code: i32,256 pub code: i32,
@@ -256,6 +258,7 @@ pub(crate) struct ChatInfoEnvelope {
256 pub data: Option<ChatInfoData>,258 pub data: Option<ChatInfoData>,
257}259}
258 260 
261+#[allow(dead_code)]
259#[derive(Debug, Deserialize)]262#[derive(Debug, Deserialize)]
260pub(crate) struct ChatInfoData {263pub(crate) struct ChatInfoData {
261 pub owner_id: Option<String>,264 pub owner_id: Option<String>,
Rapps/xiaoo-app/src/channels/feishu/websocket.rsapps/serverside/src/channels/feishu/websocket.rs+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/channels/mod.rsapps/serverside/src/channels/mod.rs+10-13
@@ -1,22 +1,19 @@
1-pub mod adapter;
2pub mod feishu;1pub mod feishu;
3pub mod telegram;2pub mod telegram;
4 3 
5-pub use adapter::{
6- AdapterResponse, ChannelAdapter, ChannelAttachment, ChannelCapabilities, ChannelError,
7- ChannelMember, ChannelMention, ChannelMessage, ChannelMeta, ChannelOutboundAttachment,
8- ChannelOutboundAttachmentKind, ChannelProgressSection, ChannelProgressState,
9- ChannelProgressUpdate, ChannelResult, ChannelRuntime, ChannelTextFormat,
10-};
11pub use feishu::{4pub use feishu::{
12- capabilities as feishu_capabilities, meta as feishu_meta, FeishuAdapter, FeishuCardRequest,5+ capabilities as feishu_capabilities, meta as feishu_meta, FeishuAdapter, FeishuConfig,
13- FeishuChatInfo, FeishuChatMember, FeishuClient, FeishuConfig, FeishuConfigError,6+ FeishuEventTransport, FeishuWebsocketMessageHandler, FeishuWebsocketService,
14- FeishuEventTransport, FeishuSendRequest, FeishuWebsocketMessageHandler, FeishuWebsocketService,
15};7};
16pub use telegram::{8pub use telegram::{
17- capabilities as telegram_capabilities, meta as telegram_meta, TelegramAdapter, TelegramClient,9+ capabilities as telegram_capabilities, meta as telegram_meta, TelegramAdapter, TelegramConfig,
18- TelegramConfig, TelegramConfigError, TelegramEventTransport, TelegramPollingMessageHandler,10+ TelegramEventTransport, TelegramPollingMessageHandler, TelegramPollingService,
19- TelegramPollingService,11+};
12+pub use xiaoo_shared::channels::{
13+ AdapterResponse, ChannelAdapter, ChannelCapabilities, ChannelError, ChannelMember,
14+ ChannelMention, ChannelMessage, ChannelMeta, ChannelOutboundAttachment,
15+ ChannelOutboundAttachmentKind, ChannelProgressSection, ChannelProgressState,
16+ ChannelProgressUpdate, ChannelResult, ChannelRuntime, ChannelTextFormat,
20};17};
21 18 
22pub fn build_feishu_runtime(config: FeishuConfig) -> ChannelResult<ChannelRuntime> {19pub fn build_feishu_runtime(config: FeishuConfig) -> ChannelResult<ChannelRuntime> {
Rapps/xiaoo-app/src/channels/telegram/channel.rsapps/serverside/src/channels/telegram/channel.rs+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/channels/telegram/client.rsapps/serverside/src/channels/telegram/client.rs+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/channels/telegram/mod.rsapps/serverside/src/channels/telegram/mod.rs+1-2
@@ -4,6 +4,5 @@ mod polling;
4mod types;4mod types;
5 5 
6pub use channel::{capabilities, meta, TelegramAdapter};6pub use channel::{capabilities, meta, TelegramAdapter};
7-pub use client::TelegramClient;
8pub use polling::{TelegramPollingMessageHandler, TelegramPollingService};7pub use polling::{TelegramPollingMessageHandler, TelegramPollingService};
9-pub use types::{TelegramConfig, TelegramConfigError, TelegramEventTransport};8+pub use types::{TelegramConfig, TelegramEventTransport};
Rapps/xiaoo-app/src/channels/telegram/polling.rsapps/serverside/src/channels/telegram/polling.rs+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/channels/telegram/types.rsapps/serverside/src/channels/telegram/types.rs+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/daemon_config.rsapps/serverside/src/daemon_config.rs+101-11
@@ -1,3 +1,8 @@
1+use crate::channels::{
2+ build_feishu_runtime, build_telegram_runtime, ChannelRuntime, FeishuConfig,
3+ FeishuEventTransport, TelegramConfig, TelegramEventTransport,
4+};
5+use crate::httpserver::rate_limit::RateLimitConfig;
1use agent_types::hook::HookerRegistryConfig;6use agent_types::hook::HookerRegistryConfig;
2use anyhow::{bail, Context, Result};7use anyhow::{bail, Context, Result};
3use lsp::LspServiceRegistry;8use lsp::LspServiceRegistry;
@@ -9,13 +14,8 @@ use std::env;
9use std::fs;14use std::fs;
10use std::path::{Path, PathBuf};15use std::path::{Path, PathBuf};
11use std::sync::Arc;16use std::sync::Arc;
12-use xiaoo_app::builtin_agent_roles::{PLAN_AGENT_DESCRIPTION, PLAN_AGENT_ID, PLAN_AGENT_PROMPT};17+use xiaoo_shared::backend::GatewayBackendConfig;
13-use xiaoo_app::channels::{18+use xiaoo_shared::builtin_agent_roles::{PLAN_AGENT_DESCRIPTION, PLAN_AGENT_ID, PLAN_AGENT_PROMPT};
14- build_feishu_runtime, build_telegram_runtime, ChannelRuntime, FeishuConfig,
15- FeishuEventTransport, TelegramConfig, TelegramEventTransport,
16-};
17-use xiaoo_app::gateway::backend::GatewayBackendConfig;
18-use xiaoo_app::httpserver::rate_limit::RateLimitConfig;
19 19 
20const DEFAULT_OUTPUT_TOKENS: usize = 16384;20const DEFAULT_OUTPUT_TOKENS: usize = 16384;
21const DEFAULT_SYSTEM_PROMPT: &str = include_str!("prompts/default_system_prompt.txt");21const DEFAULT_SYSTEM_PROMPT: &str = include_str!("prompts/default_system_prompt.txt");
@@ -46,7 +46,7 @@ pub struct AppConfig {
46 #[serde(default)]46 #[serde(default)]
47 pub lsp: Option<LspConfig>,47 pub lsp: Option<LspConfig>,
48 #[serde(default)]48 #[serde(default)]
49- pub operation_backend: Option<GatewayBackendConfig>,49+ pub server: ServerConfig,
50}50}
51 51 
52#[derive(Debug, Clone, Deserialize)]52#[derive(Debug, Clone, Deserialize)]
@@ -186,6 +186,12 @@ pub struct PathsConfig {
186 pub data_dir: Option<PathBuf>,186 pub data_dir: Option<PathBuf>,
187}187}
188 188 
189+#[derive(Debug, Clone, Default, Deserialize)]
190+pub struct ServerConfig {
191+ #[serde(default)]
192+ pub operation_backend: Option<GatewayBackendConfig>,
193+}
194+ 
189#[derive(Debug, Clone, Default, Deserialize)]195#[derive(Debug, Clone, Default, Deserialize)]
190pub struct TraceConfig {196pub struct TraceConfig {
191 /// Storage backend identifier: "moirai-sqlite" (default), "stdout", or "noop".197 /// Storage backend identifier: "moirai-sqlite" (default), "stdout", or "noop".
@@ -227,7 +233,6 @@ pub struct LspConfig {
227 233 
228 /// Server IDs to disable (e.g. ["pyright"] to turn off the built-in pyright).234 /// Server IDs to disable (e.g. ["pyright"] to turn off the built-in pyright).
229 #[serde(default)]235 #[serde(default)]
230- #[allow(dead_code)]
231 pub disabled_servers: Vec<String>,236 pub disabled_servers: Vec<String>,
232 237 
233 /// Extra language servers not covered by the built-in list.238 /// Extra language servers not covered by the built-in list.
@@ -247,6 +252,32 @@ pub struct ExtraServerConfig {
247 pub language_id: String,252 pub language_id: String,
248}253}
249 254 
255+impl xiaoo_shared::lsp_support::ExtraServerConfigView for ExtraServerConfig {
256+ fn id(&self) -> &str {
257+ &self.id
258+ }
259+ 
260+ fn extensions(&self) -> &[String] {
261+ &self.extensions
262+ }
263+ 
264+ fn command(&self) -> &str {
265+ &self.command
266+ }
267+ 
268+ fn args(&self) -> &[String] {
269+ &self.args
270+ }
271+ 
272+ fn root_markers(&self) -> &[String] {
273+ &self.root_markers
274+ }
275+ 
276+ fn language_id(&self) -> &str {
277+ &self.language_id
278+ }
279+}
280+ 
250#[derive(Debug, Clone)]281#[derive(Debug, Clone)]
251pub struct ResolvedAgentConfig {282pub struct ResolvedAgentConfig {
252 pub id: String,283 pub id: String,
@@ -335,6 +366,10 @@ impl DaemonConfig {
335 self.app.channels.interaction_timeout_secs.unwrap_or(600)366 self.app.channels.interaction_timeout_secs.unwrap_or(600)
336 }367 }
337 368 
369+ pub fn server_operation_backend(&self) -> Option<GatewayBackendConfig> {
370+ self.app.server.operation_backend.clone()
371+ }
372+ 
338 pub fn http_bearer_token(&self) -> Result<Option<String>> {373 pub fn http_bearer_token(&self) -> Result<Option<String>> {
339 let direct = self374 let direct = self
340 .app375 .app
@@ -548,8 +583,11 @@ impl DaemonConfig {
548 return None;583 return None;
549 }584 }
550 585 
551- let extra = crate::lsp_support::build_extra_server_configs(&lsp.extra_servers);586+ let extra = xiaoo_shared::lsp_support::build_extra_server_configs(&lsp.extra_servers);
552- Some(Arc::new(LspServiceRegistry::new(extra)))587+ Some(Arc::new(LspServiceRegistry::new_with_disabled(
588+ extra,
589+ lsp.disabled_servers.clone(),
590+ )))
553 }591 }
554}592}
555 593 
@@ -774,6 +812,58 @@ mod tests {
774 .is_empty());812 .is_empty());
775 }813 }
776 814 
815+ #[test]
816+ fn resolves_server_operation_backend_from_server_namespace() {
817+ let content = r#"
818+ [llm]
819+ provider = "openrouter"
820+ model = "z-ai/glm-5"
821+ 
822+ [server.operation_backend]
823+ kind = "e2b"
824+ 
825+ [server.operation_backend.options]
826+ api_key = "test-key"
827+ template_id = "base"
828+ "#;
829+ 
830+ let config: AppConfig = toml::from_str(content).expect("config should parse");
831+ let daemon = DaemonConfig {
832+ app: config,
833+ config_path: "config.toml".into(),
834+ };
835+ let backend = daemon
836+ .server_operation_backend()
837+ .expect("server backend should resolve");
838+ 
839+ assert_eq!(backend.kind, "e2b");
840+ assert_eq!(backend.options["api_key"].as_str(), Some("test-key"));
841+ assert_eq!(backend.options["template_id"].as_str(), Some("base"));
842+ }
843+ 
844+ #[test]
845+ fn daemon_ignores_top_level_operation_backend() {
846+ let content = r#"
847+ [llm]
848+ provider = "openrouter"
849+ model = "z-ai/glm-5"
850+ 
851+ [operation_backend]
852+ kind = "e2b"
853+ 
854+ [operation_backend.options]
855+ api_key = "test-key"
856+ "#;
857+ 
858+ let config: AppConfig = toml::from_str(content).expect("config should parse");
859+ let daemon = DaemonConfig {
860+ app: config,
861+ config_path: "config.toml".into(),
862+ };
863+ 
864+ assert!(daemon.server_operation_backend().is_none());
865+ }
866+ 
777 #[test]867 #[test]
778 fn resolves_xdg_config_when_present() {868 fn resolves_xdg_config_when_present() {
779 let temp = TempDir::new().expect("tempdir");869 let temp = TempDir::new().expect("tempdir");
Rapps/xiaoo-app/src/daemon_runtime.rsapps/serverside/src/daemon_runtime.rs+199-27
@@ -1,5 +1,7 @@
1use crate::daemon_config::SubagentRoleConfig as ConfigSubagentRole;1use crate::daemon_config::SubagentRoleConfig as ConfigSubagentRole;
2-use crate::daemon_config::{AgentRoleConfig, DaemonConfig, ResolvedAgentConfig};2+use crate::daemon_config::{
3+ AgentRoleConfig, CompactConfig, DaemonConfig, LlmConfig, ResolvedAgentConfig,
4+};
3use agent_contracts::{CompressionPipeline, SkillRegistry, ToolRegistry, ToolRegistryBuilder};5use agent_contracts::{CompressionPipeline, SkillRegistry, ToolRegistry, ToolRegistryBuilder};
4use agent_types::common::ids::{AgentId, ToolName};6use agent_types::common::ids::{AgentId, ToolName};
5use agent_types::context::{FeatureFlags, TokenBudgetConfig};7use agent_types::context::{FeatureFlags, TokenBudgetConfig};
@@ -13,13 +15,14 @@ use compact::{
13};15};
14use llm_client::{16use llm_client::{
15 create_llm_provider_from_resolved, resolve_config, resolve_model_context_length,17 create_llm_provider_from_resolved, resolve_config, resolve_model_context_length,
16- LlmProviderWrapper, ResolveInput,18+ resolve_provider_profile, LlmProviderWrapper, ResolveInput,
17};19};
18use lsp::LspServiceRegistry;20use lsp::LspServiceRegistry;
19use prompt::{compose_channel_system_prompt, ChannelPromptSections};21use prompt::{compose_channel_system_prompt, ChannelPromptSections};
20use serde_json::Value;22use serde_json::Value;
21use skill::FileSkillRegistry;23use skill::FileSkillRegistry;
22use std::collections::{BTreeMap, BTreeSet, HashMap};24use std::collections::{BTreeMap, BTreeSet, HashMap};
25+use std::env;
23use std::path::PathBuf;26use std::path::PathBuf;
24use std::sync::Arc;27use std::sync::Arc;
25use std::{fs, path::Path};28use std::{fs, path::Path};
@@ -27,29 +30,59 @@ use tool::{
27 load_tool_sources_with_services, SubagentRoleConfig, ToolRegistryBuilderImpl,30 load_tool_sources_with_services, SubagentRoleConfig, ToolRegistryBuilderImpl,
28 ToolRuntimeServices,31 ToolRuntimeServices,
29};32};
30-use xiaoo_app::gateway::prompt_utils::{33+use xiaoo_shared::backend::GatewayBackendConfig;
34+use xiaoo_shared::gateway::prompt_utils::{
31 compose_subagent_delegation_rules, generate_skills_dirs_table,35 compose_subagent_delegation_rules, generate_skills_dirs_table,
32};36};
33-use xiaoo_app::gateway::session_record::SubagentRoleRecord;37+use xiaoo_shared::gateway::session_record::SubagentRoleRecord;
34-use xiaoo_app::gateway::{38+use xiaoo_shared::gateway::{
35- backend::GatewayBackendConfig, compose_workspace_system_prompt, ResolvedSessionRuntime,39+ compose_workspace_system_prompt, ResolvedSessionRuntime, SessionRecord, SessionRuntimeBindings,
36- SessionRecord, SessionRuntimeBindings, SessionRuntimeBuildInput, SessionRuntimeDescriptor,40+ SessionRuntimeBuildInput, SessionRuntimeDescriptor, SessionRuntimeResolveError,
37- SessionRuntimeResolveError, SessionRuntimeResolver,41+ SessionRuntimeResolver,
38};42};
39 43 
40const DEFAULT_SYSTEM_TOKEN_RESERVE: usize = 2048;44const DEFAULT_SYSTEM_TOKEN_RESERVE: usize = 2048;
41const DEFAULT_MIN_PROMPT_TOKEN_RESERVE: usize = 2048;45const DEFAULT_MIN_PROMPT_TOKEN_RESERVE: usize = 2048;
42const DEFAULT_HARD_LIMIT_RATIO: f64 = 0.8;46const DEFAULT_HARD_LIMIT_RATIO: f64 = 0.8;
43 47 
44-pub struct ConfiguredRuntimeResolver {48+struct EffectiveLlmConfig {
45- agent: ResolvedAgentConfig,49+ provider: String,
46- agent_roles: BTreeMap<String, AgentRoleConfig>,50+ model: String,
47- subagent_roles: BTreeMap<String, ConfigSubagentRole>,51+ api_base: Option<String>,
52+ api_key_env: Option<String>,
53+ api_key: Option<String>,
54+}
55+ 
56+impl EffectiveLlmConfig {
57+ fn session_config(&self) -> xiaoo_shared::gateway::LlmRuntimeConfig {
58+ xiaoo_shared::gateway::LlmRuntimeConfig {
59+ provider: Some(self.provider.clone()),
60+ model: Some(self.model.clone()),
61+ api_base: self.api_base.clone(),
62+ api_key_env: self.api_key_env.clone(),
63+ api_key: None,
64+ }
65+ }
66+}
67+ 
68+struct ResolvedLlmRuntime {
69+ model: String,
70+ llm_config: xiaoo_shared::gateway::LlmRuntimeConfig,
48 llm_provider: Arc<LlmProviderWrapper>,71 llm_provider: Arc<LlmProviderWrapper>,
49 token_budget: TokenBudgetConfig,72 token_budget: TokenBudgetConfig,
73+ compression_pipeline: Option<Arc<dyn CompressionPipeline>>,
74+}
75+ 
76+pub struct ConfiguredRuntimeResolver {
77+ agent: ResolvedAgentConfig,
78+ llm: LlmConfig,
79+ config_path: PathBuf,
80+ max_output_tokens: usize,
81+ compact: Option<CompactConfig>,
82+ agent_roles: BTreeMap<String, AgentRoleConfig>,
83+ subagent_roles: BTreeMap<String, ConfigSubagentRole>,
50 feature_flags: FeatureFlags,84 feature_flags: FeatureFlags,
51 trace: Value,85 trace: Value,
52- compression_pipeline: Option<Arc<dyn CompressionPipeline>>,
53 hooker: HookerRegistryConfig,86 hooker: HookerRegistryConfig,
54 skill_registry: Arc<dyn SkillRegistry>,87 skill_registry: Arc<dyn SkillRegistry>,
55 skills_dirs: Vec<PathBuf>,88 skills_dirs: Vec<PathBuf>,
@@ -95,7 +128,6 @@ impl ConfiguredRuntimeResolver {
95 );128 );
96 129 
97 let trace = config.resolve_trace_config();130 let trace = config.resolve_trace_config();
98- let compression_pipeline = build_compression_pipeline(config, &llm_provider)?;
99 let skills_config = config.resolve_skills_config();131 let skills_config = config.resolve_skills_config();
100 let skill_registry: Arc<dyn SkillRegistry> =132 let skill_registry: Arc<dyn SkillRegistry> =
101 Arc::new(FileSkillRegistry::new(&skills_config));133 Arc::new(FileSkillRegistry::new(&skills_config));
@@ -104,10 +136,12 @@ impl ConfiguredRuntimeResolver {
104 136 
105 Ok(Self {137 Ok(Self {
106 agent,138 agent,
139+ llm: config.app.llm.clone(),
140+ config_path: config.config_path().to_path_buf(),
141+ max_output_tokens: config.max_output_tokens(),
142+ compact: config.resolve_compact_config().cloned(),
107 agent_roles: config.app.agent.clone(),143 agent_roles: config.app.agent.clone(),
108 subagent_roles: config.app.subagent.clone(),144 subagent_roles: config.app.subagent.clone(),
109- llm_provider,
110- token_budget,
111 feature_flags: {145 feature_flags: {
112 let mut flags = FeatureFlags::default();146 let mut flags = FeatureFlags::default();
113 flags.kvcache_enabled = config.app.llm.kvcache_enabled.unwrap_or(false);147 flags.kvcache_enabled = config.app.llm.kvcache_enabled.unwrap_or(false);
@@ -115,15 +149,99 @@ impl ConfiguredRuntimeResolver {
115 flags149 flags
116 },150 },
117 trace,151 trace,
118- compression_pipeline: Some(compression_pipeline),
119 hooker: config.app.hooker.clone(),152 hooker: config.app.hooker.clone(),
120 skill_registry,153 skill_registry,
121 skills_dirs: skills_config.skills_dirs.clone(),154 skills_dirs: skills_config.skills_dirs.clone(),
122- operation_backend: config.app.operation_backend.clone(),155+ operation_backend: config.server_operation_backend(),
123 lsp_registry,156 lsp_registry,
124 })157 })
125 }158 }
126 159 
160+ async fn resolve_llm_runtime(
161+ &self,
162+ request: &SessionRuntimeBuildInput,
163+ existing: Option<&SessionRecord>,
164+ ) -> Result<ResolvedLlmRuntime, SessionRuntimeResolveError> {
165+ let effective = self.effective_llm_config(request, existing);
166+ let api_key = resolve_llm_api_key(&effective)?;
167+ let resolved_provider = resolve_config(ResolveInput {
168+ provider: Some(effective.provider.clone()),
169+ protocol: None,
170+ api_key,
171+ api_key_env: None,
172+ base_url: effective.api_base.clone(),
173+ })
174+ .map_err(|error| SessionRuntimeResolveError::ResolveFailed {
175+ message: format!("failed to resolve llm provider config: {error}"),
176+ })?;
177+ 
178+ let llm_provider = Arc::new(
179+ create_llm_provider_from_resolved(
180+ &resolved_provider,
181+ effective.model.clone(),
182+ Some(self.agent.id.clone()),
183+ None,
184+ )
185+ .map_err(|error| SessionRuntimeResolveError::ResolveFailed {
186+ message: format!("failed to create llm provider: {error}"),
187+ })?,
188+ );
189+ let effective_context_window = resolve_effective_context_window(
190+ &resolved_provider,
191+ &effective.model,
192+ llm_provider.capabilities().max_context_window,
193+ )
194+ .await;
195+ let token_budget = build_token_budget(effective_context_window, self.max_output_tokens);
196+ 
197+ validate_token_budget_config(
198+ &token_budget,
199+ self.max_output_tokens,
200+ &effective.model,
201+ &self.config_path,
202+ );
203+ 
204+ let compression_pipeline = build_compression_pipeline(self.compact.as_ref(), &llm_provider)
205+ .map_err(|error| SessionRuntimeResolveError::ResolveFailed {
206+ message: format!("failed to build compression pipeline: {error}"),
207+ })?;
208+ 
209+ let llm_config = effective.session_config();
210+ Ok(ResolvedLlmRuntime {
211+ model: effective.model,
212+ llm_config,
213+ llm_provider,
214+ token_budget,
215+ compression_pipeline: Some(compression_pipeline),
216+ })
217+ }
218+ 
219+ fn effective_llm_config(
220+ &self,
221+ request: &SessionRuntimeBuildInput,
222+ existing: Option<&SessionRecord>,
223+ ) -> EffectiveLlmConfig {
224+ let override_llm = request.llm.as_ref();
225+ let existing_llm = existing.and_then(|session| session.runtime.llm.as_ref());
226+ EffectiveLlmConfig {
227+ provider: optional_non_empty(override_llm.and_then(|llm| llm.provider.as_ref()))
228+ .or_else(|| optional_non_empty(existing_llm.and_then(|llm| llm.provider.as_ref())))
229+ .unwrap_or_else(|| self.llm.provider.clone()),
230+ model: optional_non_empty(override_llm.and_then(|llm| llm.model.as_ref()))
231+ .or_else(|| optional_non_empty(existing_llm.and_then(|llm| llm.model.as_ref())))
232+ .unwrap_or_else(|| self.agent.model.clone()),
233+ api_base: optional_non_empty(override_llm.and_then(|llm| llm.api_base.as_ref()))
234+ .or_else(|| optional_non_empty(existing_llm.and_then(|llm| llm.api_base.as_ref())))
235+ .or_else(|| self.llm.api_base.clone()),
236+ api_key_env: optional_non_empty(override_llm.and_then(|llm| llm.api_key_env.as_ref()))
237+ .or_else(|| {
238+ optional_non_empty(existing_llm.and_then(|llm| llm.api_key_env.as_ref()))
239+ })
240+ .or_else(|| self.llm.api_key_env.clone()),
241+ api_key: optional_non_empty(override_llm.and_then(|llm| llm.api_key.as_ref())),
242+ }
243+ }
244+ 
127 fn build_tool_registry(245 fn build_tool_registry(
128 &self,246 &self,
129 agent_role: Option<&AgentRoleConfig>,247 agent_role: Option<&AgentRoleConfig>,
@@ -220,12 +338,63 @@ async fn resolve_effective_context_window(
220 fallback338 fallback
221}339}
222 340 
341+fn resolve_llm_api_key(
342+ config: &EffectiveLlmConfig,
343+) -> Result<Option<String>, SessionRuntimeResolveError> {
344+ if let Some(api_key) = config.api_key.as_ref() {
345+ return Ok(Some(api_key.clone()));
346+ }
347+ 
348+ if let Some(env_name) = config.api_key_env.as_deref() {
349+ return resolve_api_key_env(env_name, true);
350+ }
351+ 
352+ if let Some(env_name) = resolve_provider_profile(&config.provider)
353+ .and_then(|profile| profile.default_api_key_env.map(str::to_string))
354+ {
355+ return resolve_api_key_env(&env_name, false);
356+ }
357+ 
358+ Ok(None)
359+}
360+ 
361+fn resolve_api_key_env(
362+ env_name: &str,
363+ fail_when_missing: bool,
364+) -> Result<Option<String>, SessionRuntimeResolveError> {
365+ if let Some(api_key) = xiaoo_shared::gateway::get_decrypted_api_key(env_name) {
366+ if !api_key.trim().is_empty() {
367+ return Ok(Some(api_key));
368+ }
369+ }
370+ 
371+ match env::var(env_name) {
372+ Ok(value) if !value.trim().is_empty() => Ok(Some(value)),
373+ Ok(_) | Err(env::VarError::NotPresent) if fail_when_missing => {
374+ Err(SessionRuntimeResolveError::ResolveFailed {
375+ message: format!("missing required API key environment variable: {env_name}"),
376+ })
377+ }
378+ Ok(_) | Err(env::VarError::NotPresent) => Ok(None),
379+ Err(env::VarError::NotUnicode(_)) => Err(SessionRuntimeResolveError::ResolveFailed {
380+ message: format!("API key environment variable is not valid unicode: {env_name}"),
381+ }),
382+ }
383+}
384+ 
385+fn optional_non_empty(value: Option<&String>) -> Option<String> {
386+ value
387+ .map(|value| value.trim())
388+ .filter(|value| !value.is_empty())
389+ .map(ToString::to_string)
390+}
391+ 
223#[async_trait]392#[async_trait]
224impl SessionRuntimeResolver for ConfiguredRuntimeResolver {393impl SessionRuntimeResolver for ConfiguredRuntimeResolver {
225 async fn resolve(394 async fn resolve(
226 &self,395 &self,
227 request: &SessionRuntimeBuildInput,396 request: &SessionRuntimeBuildInput,
228- _existing: Option<&SessionRecord>,397+ existing: Option<&SessionRecord>,
229 ) -> Result<ResolvedSessionRuntime, SessionRuntimeResolveError> {398 ) -> Result<ResolvedSessionRuntime, SessionRuntimeResolveError> {
230 let agent_role = resolve_agent_role(&self.agent_roles, request)?;399 let agent_role = resolve_agent_role(&self.agent_roles, request)?;
231 let system_prompt = agent_role400 let system_prompt = agent_role
@@ -255,11 +424,13 @@ impl SessionRuntimeResolver for ConfiguredRuntimeResolver {
255 .as_ref()424 .as_ref()
256 .map(|override_id| override_id != &AgentId(self.agent.id.clone()))425 .map(|override_id| override_id != &AgentId(self.agent.id.clone()))
257 .unwrap_or(false);426 .unwrap_or(false);
427+ let llm_runtime = self.resolve_llm_runtime(request, existing).await?;
258 428 
259 Ok(ResolvedSessionRuntime {429 Ok(ResolvedSessionRuntime {
260 descriptor: SessionRuntimeDescriptor {430 descriptor: SessionRuntimeDescriptor {
261 agent_id: AgentId(self.agent.id.clone()),431 agent_id: AgentId(self.agent.id.clone()),
262- model: self.agent.model.clone(),432+ model: llm_runtime.model.clone(),
433+ llm: Some(llm_runtime.llm_config.clone()),
263 system_prompt: build_system_prompt(434 system_prompt: build_system_prompt(
264 system_prompt,435 system_prompt,
265 &self.agent.workspace_root,436 &self.agent.workspace_root,
@@ -270,17 +441,17 @@ impl SessionRuntimeResolver for ConfiguredRuntimeResolver {
270 ),441 ),
271 feature_flags: self.feature_flags.clone(),442 feature_flags: self.feature_flags.clone(),
272 443 
273- token_budget: self.token_budget.clone(),444+ token_budget: llm_runtime.token_budget.clone(),
274 workspace_root: self.agent.workspace_root.clone(),445 workspace_root: self.agent.workspace_root.clone(),
275 max_turns: agent_role.and_then(|role| role.max_turns),446 max_turns: agent_role.and_then(|role| role.max_turns),
276 subagent_roles,447 subagent_roles,
277 },448 },
278 entry_kind: request.entry.kind.clone(),449 entry_kind: request.entry.kind.clone(),
279- llm_provider: Arc::clone(&self.llm_provider),450+ llm_provider: llm_runtime.llm_provider,
280 tool_registry: self.build_tool_registry(agent_role)?,451 tool_registry: self.build_tool_registry(agent_role)?,
281 skill_registry: Some(Arc::clone(&self.skill_registry)),452 skill_registry: Some(Arc::clone(&self.skill_registry)),
282 bindings: SessionRuntimeBindings::default(),453 bindings: SessionRuntimeBindings::default(),
283- compression_pipeline: self.compression_pipeline.clone(),454+ compression_pipeline: llm_runtime.compression_pipeline,
284 trace: self.trace.clone(),455 trace: self.trace.clone(),
285 hooker: self.hooker.clone(),456 hooker: self.hooker.clone(),
286 operation_backend: self.operation_backend.clone(),457 operation_backend: self.operation_backend.clone(),
@@ -456,10 +627,10 @@ fn ensure_workspace_exists(path: &Path) -> Result<()> {
456}627}
457 628 
458fn build_compression_pipeline(629fn build_compression_pipeline(
459- config: &DaemonConfig,630+ compact: Option<&CompactConfig>,
460 llm_provider: &Arc<LlmProviderWrapper>,631 llm_provider: &Arc<LlmProviderWrapper>,
461) -> Result<Arc<dyn CompressionPipeline>> {632) -> Result<Arc<dyn CompressionPipeline>> {
462- let compact = match config.resolve_compact_config() {633+ let compact = match compact {
463 Some(cc) => cc,634 Some(cc) => cc,
464 None => {635 None => {
465 return Ok(Arc::from(compact::PassthroughCompressionPipeline::new())636 return Ok(Arc::from(compact::PassthroughCompressionPipeline::new())
@@ -523,13 +694,12 @@ mod tests {
523 use std::collections::BTreeMap;694 use std::collections::BTreeMap;
524 use std::fs;695 use std::fs;
525 use tempfile::tempdir;696 use tempfile::tempdir;
526- use xiaoo_app::gateway::{GatewayEntryContext, SessionRuntimeBuildInput};697+ use xiaoo_shared::gateway::{GatewayEntryContext, SessionRuntimeBuildInput};
527 698 
528 #[test]699 #[test]
529 fn token_budget_caps_output_to_preserve_prompt_budget() {700 fn token_budget_caps_output_to_preserve_prompt_budget() {
530 let budget = build_token_budget(128_000, 150_000);701 let budget = build_token_budget(128_000, 150_000);
531 assert_eq!(budget.total_budget, 128_000);702 assert_eq!(budget.total_budget, 128_000);
532- assert_eq!(budget.reserved_for_output, 123_904);
533 assert_eq!(budget.reserved_for_system, 2_048);703 assert_eq!(budget.reserved_for_system, 2_048);
534 }704 }
535 705 
@@ -556,6 +726,7 @@ mod tests {
556 agent_id_override: None,726 agent_id_override: None,
557 max_turns_override: None,727 max_turns_override: None,
558 subagent_role_id: None,728 subagent_role_id: None,
729+ llm: None,
559 };730 };
560 731 
561 let prompt = build_system_prompt(732 let prompt = build_system_prompt(
@@ -622,6 +793,7 @@ mod tests {
622 agent_id_override: None,793 agent_id_override: None,
623 max_turns_override: None,794 max_turns_override: None,
624 subagent_role_id: None,795 subagent_role_id: None,
796+ llm: None,
625 };797 };
626 798 
627 let resolved = resolve_agent_role(&agent_roles, &request)799 let resolved = resolve_agent_role(&agent_roles, &request)
Rapps/xiaoo-app/src/httpserver/channel_ingress.rsapps/serverside/src/httpserver/channel_ingress.rs+4-2
@@ -1,6 +1,6 @@
1use crate::channels::ChannelMessage;1use crate::channels::ChannelMessage;
2-use crate::gateway::{channel_session_id, AppTurnRequest, GatewayEntryContext, TurnMention};
3use thiserror::Error;2use thiserror::Error;
3+use xiaoo_shared::gateway::{channel_session_id, AppTurnRequest, GatewayEntryContext, TurnMention};
4 4 
5#[derive(Debug, Clone, PartialEq, Eq)]5#[derive(Debug, Clone, PartialEq, Eq)]
6pub struct GatewayChannelMention {6pub struct GatewayChannelMention {
@@ -99,6 +99,7 @@ pub fn build_channel_turn_request(message: &GatewayChannelMessage) -> AppTurnReq
99 })99 })
100 .collect(),100 .collect(),
101 reasoning_effort: Default::default(),101 reasoning_effort: Default::default(),
102+ llm: None,
102 }103 }
103}104}
104 105 
@@ -108,7 +109,8 @@ mod tests {
108 build_channel_turn_request, build_gateway_channel_message, GatewayChannelIngressError,109 build_channel_turn_request, build_gateway_channel_message, GatewayChannelIngressError,
109 GatewayChannelMention, GatewayChannelMessage,110 GatewayChannelMention, GatewayChannelMessage,
110 };111 };
111- use crate::channels::{ChannelAttachment, ChannelMention, ChannelMessage};112+ use crate::channels::{ChannelMention, ChannelMessage};
113+ use xiaoo_shared::channels::ChannelAttachment;
112 114 
113 #[test]115 #[test]
114 fn builds_channel_turn_request_with_instance_scoped_session_id() {116 fn builds_channel_turn_request_with_instance_scoped_session_id() {
Rapps/xiaoo-app/src/httpserver/channel_runtime.rsapps/serverside/src/httpserver/channel_runtime.rs+5-5
@@ -2,11 +2,6 @@ use crate::channels::{
2 ChannelAdapter, ChannelError, ChannelOutboundAttachment, ChannelOutboundAttachmentKind,2 ChannelAdapter, ChannelError, ChannelOutboundAttachment, ChannelOutboundAttachmentKind,
3 ChannelRuntime,3 ChannelRuntime,
4};4};
5-use crate::gateway::channel_interaction::{
6- resolve_interaction_from_text, ChannelInteractionHandle,
7-};
8-use crate::gateway::pending_interaction::PendingInteractionStore;
9-use crate::gateway::{channel_session_id, ChannelProgressRelayHandle, SessionService};
10use crate::httpserver::channel_ingress::{5use crate::httpserver::channel_ingress::{
11 build_gateway_channel_message, GatewayChannelIngressError, GatewayChannelMention,6 build_gateway_channel_message, GatewayChannelIngressError, GatewayChannelMention,
12};7};
@@ -17,6 +12,11 @@ use std::collections::HashSet;
17use std::sync::Arc;12use std::sync::Arc;
18use thiserror::Error;13use thiserror::Error;
19use tracing::warn;14use tracing::warn;
15+use xiaoo_shared::gateway::channel_interaction::{
16+ resolve_interaction_from_text, ChannelInteractionHandle,
17+};
18+use xiaoo_shared::gateway::pending_interaction::PendingInteractionStore;
19+use xiaoo_shared::gateway::{channel_session_id, ChannelProgressRelayHandle, SessionService};
20 20 
21#[derive(Debug, Error)]21#[derive(Debug, Error)]
22pub enum ChannelMessageProcessingError {22pub enum ChannelMessageProcessingError {
Aapps/serverside/src/httpserver/mod.rs+13-0
@@ -0,0 +1,13 @@
1+pub mod channel_ingress;
2+pub mod channel_runtime;
3+pub mod rate_limit;
4+pub mod router;
5+pub mod service;
6+pub mod sse_sink;
7+ 
8+pub use channel_runtime::ChannelRuntimeProcessor;
9+pub use router::{
10+ create_router_with_channel_runtimes_control_plane_and_timeout_and_auth,
11+ create_router_with_control_plane_and_auth, HttpBearerAuthConfig,
12+};
13+pub use service::{GatewayService, GatewayServiceError};
Rapps/xiaoo-app/src/httpserver/rate_limit.rsapps/serverside/src/httpserver/rate_limit.rs+4-0
@@ -19,14 +19,17 @@ pub struct RateLimitConfig {
19 #[serde(default = "default_burst")]19 #[serde(default = "default_burst")]
20 pub burst: u32,20 pub burst: u32,
21 #[serde(default)]21 #[serde(default)]
22+ #[allow(dead_code)]
22 pub routes: BTreeMap<String, RouteRateLimitOverride>,23 pub routes: BTreeMap<String, RouteRateLimitOverride>,
23}24}
24 25 
25#[derive(Debug, Clone, serde::Deserialize)]26#[derive(Debug, Clone, serde::Deserialize)]
26pub struct RouteRateLimitOverride {27pub struct RouteRateLimitOverride {
27 #[serde(default = "default_rps")]28 #[serde(default = "default_rps")]
29+ #[allow(dead_code)]
28 pub requests_per_second: u32,30 pub requests_per_second: u32,
29 #[serde(default = "default_burst")]31 #[serde(default = "default_burst")]
32+ #[allow(dead_code)]
30 pub burst: u32,33 pub burst: u32,
31}34}
32 35 
@@ -104,6 +107,7 @@ impl RateLimitConfig {
104 }107 }
105 108 
106 /// Per-route limit lookup; falls back to global defaults when route key is absent.109 /// Per-route limit lookup; falls back to global defaults when route key is absent.
110+ #[allow(dead_code)]
107 pub fn effective_limit(&self, route_key: &str) -> (u32, u32) {111 pub fn effective_limit(&self, route_key: &str) -> (u32, u32) {
108 self.routes112 self.routes
109 .get(route_key)113 .get(route_key)
Rapps/xiaoo-app/src/httpserver/router.rsapps/serverside/src/httpserver/router.rs+297-441
@@ -1,44 +1,38 @@
1-use crate::channels::{1+use crate::channels::{AdapterResponse, ChannelError, ChannelResult, ChannelRuntime};
2- build_feishu_runtime, AdapterResponse, ChannelError, ChannelResult, ChannelRuntime,2+use crate::httpserver::GatewayServiceError;
3- FeishuConfig,3+use crate::httpserver::channel_ingress::GatewayChannelIngressError;
4-};
5-use crate::gateway::SessionService;
6-use crate::httpserver::channel_ingress::{
7- GatewayChannelIngressError, GatewayChannelMention, GatewayChannelMessage,
8-};
9use crate::httpserver::channel_runtime::{ChannelMessageProcessingError, ChannelRuntimeProcessor};4use crate::httpserver::channel_runtime::{ChannelMessageProcessingError, ChannelRuntimeProcessor};
10use crate::httpserver::rate_limit::RateLimitConfig;5use crate::httpserver::rate_limit::RateLimitConfig;
11-use crate::httpserver::sse_sink::{sse_stream_from_receiver, SseLoopEventSink, SseStreamEvent};6+use crate::httpserver::sse_sink::{SseLoopEventSink, SseStreamEvent, sse_stream_from_receiver};
12-use crate::httpserver::{GatewayService, GatewayServiceError};
13use agent_contracts::InteractionHandle;7use agent_contracts::InteractionHandle;
14use agent_types::interaction::{InteractionRequest, InteractionResponse};8use agent_types::interaction::{InteractionRequest, InteractionResponse};
15use async_trait::async_trait;9use async_trait::async_trait;
16use axum::{10use axum::{
11+ Json, Router,
17 body::Bytes,12 body::Bytes,
18 extract::{Path, Query, State},13 extract::{Path, Query, State},
19 http::{14 http::{
20- header::{AUTHORIZATION, WWW_AUTHENTICATE},
21 HeaderMap, Request, StatusCode,15 HeaderMap, Request, StatusCode,
16+ header::{AUTHORIZATION, WWW_AUTHENTICATE},
22 },17 },
23 middleware::{self, Next},18 middleware::{self, Next},
24 response::{19 response::{
25- sse::{KeepAlive, Sse},
26 IntoResponse, Response,20 IntoResponse, Response,
21+ sse::{KeepAlive, Sse},
27 },22 },
28 routing::{get, post},23 routing::{get, post},
29- Json, Router,
30};24};
31use serde::{Deserialize, Serialize};25use serde::{Deserialize, Serialize};
32use std::collections::HashMap;26use std::collections::HashMap;
33use std::sync::Arc;27use std::sync::Arc;
34-use tokio::sync::{oneshot, Mutex};28+use tokio::sync::{Mutex, oneshot};
35use tracing::warn;29use tracing::warn;
30+use xiaoo_shared::gateway::SessionService;
36 31 
37#[derive(Clone)]32#[derive(Clone)]
38pub struct GatewayAppState {33pub struct GatewayAppState {
39- gateway_service: Arc<GatewayService>,
40 session_service: Arc<dyn SessionService>,34 session_service: Arc<dyn SessionService>,
41- session_control_plane: Option<Arc<dyn crate::gateway::SessionControlPlane>>,35+ session_control_plane: Option<Arc<dyn xiaoo_shared::gateway::SessionControlPlane>>,
42 channel_runtimes: Arc<HashMap<String, ChannelRuntime>>,36 channel_runtimes: Arc<HashMap<String, ChannelRuntime>>,
43 channel_processor: ChannelRuntimeProcessor,37 channel_processor: ChannelRuntimeProcessor,
44 remote_interactions: Arc<RemoteInteractionStore>,38 remote_interactions: Arc<RemoteInteractionStore>,
@@ -47,7 +41,6 @@ pub struct GatewayAppState {
47impl GatewayAppState {41impl GatewayAppState {
48 pub fn new(session_service: Arc<dyn SessionService>) -> Self {42 pub fn new(session_service: Arc<dyn SessionService>) -> Self {
49 Self {43 Self {
50- gateway_service: Arc::new(GatewayService::new(session_service.clone())),
51 channel_processor: ChannelRuntimeProcessor::new(session_service.clone()),44 channel_processor: ChannelRuntimeProcessor::new(session_service.clone()),
52 session_service,45 session_service,
53 session_control_plane: None,46 session_control_plane: None,
@@ -58,33 +51,14 @@ impl GatewayAppState {
58 51 
59 pub fn with_control_plane(52 pub fn with_control_plane(
60 session_service: Arc<dyn SessionService>,53 session_service: Arc<dyn SessionService>,
61- session_control_plane: Arc<dyn crate::gateway::SessionControlPlane>,54+ session_control_plane: Arc<dyn xiaoo_shared::gateway::SessionControlPlane>,
62 ) -> Self {55 ) -> Self {
63 let mut state = Self::new(session_service);56 let mut state = Self::new(session_service);
64 state.session_control_plane = Some(session_control_plane);57 state.session_control_plane = Some(session_control_plane);
65 state58 state
66 }59 }
67 60 
68- pub fn with_feishu(61+ #[cfg(test)]
69- session_service: Arc<dyn SessionService>,
70- feishu_config: FeishuConfig,
71- ) -> ChannelResult<Self> {
72- Ok(Self::with_channel_runtime(
73- session_service,
74- build_feishu_runtime(feishu_config)?,
75- ))
76- }
77- 
78- pub fn with_feishu_and_control_plane(
79- session_service: Arc<dyn SessionService>,
80- session_control_plane: Arc<dyn crate::gateway::SessionControlPlane>,
81- feishu_config: FeishuConfig,
82- ) -> ChannelResult<Self> {
83- let mut state = Self::with_feishu(session_service, feishu_config)?;
84- state.session_control_plane = Some(session_control_plane);
85- Ok(state)
86- }
87- 
88 pub(crate) fn with_channel_runtime(62 pub(crate) fn with_channel_runtime(
89 session_service: Arc<dyn SessionService>,63 session_service: Arc<dyn SessionService>,
90 runtime: ChannelRuntime,64 runtime: ChannelRuntime,
@@ -92,7 +66,6 @@ impl GatewayAppState {
92 let mut runtimes = HashMap::new();66 let mut runtimes = HashMap::new();
93 runtimes.insert(runtime.channel_id.clone(), runtime);67 runtimes.insert(runtime.channel_id.clone(), runtime);
94 Self {68 Self {
95- gateway_service: Arc::new(GatewayService::new(session_service.clone())),
96 channel_processor: ChannelRuntimeProcessor::new(session_service.clone()),69 channel_processor: ChannelRuntimeProcessor::new(session_service.clone()),
97 session_service,70 session_service,
98 session_control_plane: None,71 session_control_plane: None,
@@ -117,7 +90,6 @@ impl GatewayAppState {
117 }90 }
118 }91 }
119 Ok(Self {92 Ok(Self {
120- gateway_service: Arc::new(GatewayService::new(session_service.clone())),
121 channel_processor: ChannelRuntimeProcessor::new(session_service.clone()),93 channel_processor: ChannelRuntimeProcessor::new(session_service.clone()),
122 session_service,94 session_service,
123 session_control_plane: None,95 session_control_plane: None,
@@ -128,7 +100,7 @@ impl GatewayAppState {
128 100 
129 pub fn with_channel_runtimes_and_control_plane(101 pub fn with_channel_runtimes_and_control_plane(
130 session_service: Arc<dyn SessionService>,102 session_service: Arc<dyn SessionService>,
131- session_control_plane: Arc<dyn crate::gateway::SessionControlPlane>,103+ session_control_plane: Arc<dyn xiaoo_shared::gateway::SessionControlPlane>,
132 runtimes: Vec<ChannelRuntime>,104 runtimes: Vec<ChannelRuntime>,
133 ) -> ChannelResult<Self> {105 ) -> ChannelResult<Self> {
134 let mut state = Self::with_channel_runtimes(session_service, runtimes)?;106 let mut state = Self::with_channel_runtimes(session_service, runtimes)?;
@@ -226,68 +198,7 @@ impl HttpBearerAuthConfig {
226 }198 }
227}199}
228 200 
229-#[derive(Debug, Deserialize)]201+#[cfg(test)]
230-pub struct TestChatTurnRequest {
231- pub text: String,
232- pub channel: String,
233- #[serde(default)]
234- pub channel_instance_id: Option<String>,
235- pub sender_id: String,
236- #[serde(default)]
237- pub agent: Option<String>,
238- pub conversation_id: String,
239- #[serde(default)]
240- pub message_id: Option<String>,
241- #[serde(default)]
242- pub reply_to_message_id: Option<String>,
243- #[serde(default)]
244- pub root_message_id: Option<String>,
245- #[serde(default)]
246- pub mentions: Vec<TestChatMention>,
247-}
248- 
249-#[derive(Debug, Deserialize)]
250-pub struct TestChatRequest {
251- pub text: String,
252- #[serde(default)]
253- pub channel: Option<String>,
254- #[serde(default)]
255- pub channel_instance_id: Option<String>,
256- #[serde(default)]
257- pub sender_id: Option<String>,
258- #[serde(default)]
259- pub agent: Option<String>,
260- #[serde(default)]
261- pub conversation_id: Option<String>,
262- #[serde(default)]
263- pub message_id: Option<String>,
264- #[serde(default)]
265- pub reply_to_message_id: Option<String>,
266- #[serde(default)]
267- pub root_message_id: Option<String>,
268- #[serde(default)]
269- pub mentions: Vec<TestChatMention>,
270-}
271- 
272-#[derive(Debug, Clone, Deserialize, Serialize)]
273-pub struct TestChatMention {
274- pub id: String,
275- #[serde(default)]
276- pub display_name: Option<String>,
277-}
278- 
279-#[derive(Debug, Serialize)]
280-pub struct TestChatResponse {
281- pub reply: String,
282- pub raw_reply: String,
283- pub conversation_id: String,
284- pub session_id: String,
285-}
286- 
287-pub fn create_router(session_service: Arc<dyn SessionService>) -> Router {
288- create_router_with_auth(session_service, None, None)
289-}
290- 
291pub fn create_router_with_auth(202pub fn create_router_with_auth(
292 session_service: Arc<dyn SessionService>,203 session_service: Arc<dyn SessionService>,
293 bearer_auth: Option<HttpBearerAuthConfig>,204 bearer_auth: Option<HttpBearerAuthConfig>,
@@ -300,52 +211,9 @@ pub fn create_router_with_auth(
300 )211 )
301}212}
302 213 
303-pub fn create_router_with_feishu_and_timeout(
304- session_service: Arc<dyn SessionService>,
305- feishu_config: FeishuConfig,
306- interaction_timeout_secs: u64,
307-) -> ChannelResult<Router> {
308- create_router_with_feishu_and_timeout_and_auth(
309- session_service,
310- feishu_config,
311- interaction_timeout_secs,
312- None,
313- None,
314- )
315-}
316- 
317-pub fn create_router_with_feishu_and_timeout_and_auth(
318- session_service: Arc<dyn SessionService>,
319- feishu_config: FeishuConfig,
320- interaction_timeout_secs: u64,
321- bearer_auth: Option<HttpBearerAuthConfig>,
322- rate_limit: Option<RateLimitConfig>,
323-) -> ChannelResult<Router> {
324- let mut state = GatewayAppState::with_feishu(session_service, feishu_config)?;
325- state.set_channel_interaction_timeout(interaction_timeout_secs);
326- Ok(create_router_from_state(state, bearer_auth, rate_limit))
327-}
328- 
329-pub fn create_router_with_feishu_control_plane_and_timeout_and_auth(
330- session_service: Arc<dyn SessionService>,
331- session_control_plane: Arc<dyn crate::gateway::SessionControlPlane>,
332- feishu_config: FeishuConfig,
333- interaction_timeout_secs: u64,
334- bearer_auth: Option<HttpBearerAuthConfig>,
335- rate_limit: Option<RateLimitConfig>,
336-) -> ChannelResult<Router> {
337- let mut state = GatewayAppState::with_feishu_and_control_plane(
338- session_service,
339- session_control_plane,
340- feishu_config,
341- )?;
342- state.set_channel_interaction_timeout(interaction_timeout_secs);
343- Ok(create_router_from_state(state, bearer_auth, rate_limit))
344-}
345- 
346pub fn create_router_with_channel_runtimes_control_plane_and_timeout_and_auth(214pub fn create_router_with_channel_runtimes_control_plane_and_timeout_and_auth(
347 session_service: Arc<dyn SessionService>,215 session_service: Arc<dyn SessionService>,
348- session_control_plane: Arc<dyn crate::gateway::SessionControlPlane>,216+ session_control_plane: Arc<dyn xiaoo_shared::gateway::SessionControlPlane>,
349 runtimes: Vec<ChannelRuntime>,217 runtimes: Vec<ChannelRuntime>,
350 interaction_timeout_secs: u64,218 interaction_timeout_secs: u64,
351 bearer_auth: Option<HttpBearerAuthConfig>,219 bearer_auth: Option<HttpBearerAuthConfig>,
@@ -365,32 +233,26 @@ fn create_router_from_state(
365 bearer_auth: Option<HttpBearerAuthConfig>,233 bearer_auth: Option<HttpBearerAuthConfig>,
366 rate_limit: Option<RateLimitConfig>,234 rate_limit: Option<RateLimitConfig>,
367) -> Router {235) -> Router {
368- let protected_routes = apply_http_bearer_auth(236+ let protected_runtime_routes = apply_http_bearer_auth(
369 Router::new()237 Router::new()
370- .route("/api/v1/chat", post(handle_chat))238+ .route("/api/v1/runtimes/open", post(handle_session_open))
371- .route("/api/v1/chat/stream", post(handle_chat_stream)),239+ .route("/api/v1/runtimes/input", post(handle_session_input))
372- bearer_auth.clone(),
373- );
374- let protected_session_routes = apply_http_bearer_auth(
375- Router::new()
376- .route("/api/v1/sessions/open", post(handle_session_open))
377 .route(240 .route(
378- "/api/v1/sessions/:session_id/turn/stream",241+ "/api/v1/runtimes/interaction",
379- post(handle_session_turn_stream),
380- )
381- .route(
382- "/api/v1/sessions/:session_id/interaction",
383 post(handle_session_interaction),242 post(handle_session_interaction),
384 )243 )
244+ .route("/api/v1/runtimes/cancel", post(handle_session_cancel))
245+ .route("/api/v1/runtimes/close", post(handle_session_close))
385 .route(246 .route(
386- "/api/v1/sessions/:session_id/cancel",247+ "/api/v1/runtimes/checkpoint",
387- post(handle_session_cancel),248+ post(handle_runtime_checkpoint),
388 )249 )
389 .route(250 .route(
390- "/api/v1/sessions/:session_id/close",251+ "/api/v1/runtimes/checkpoint/delete-snapshot",
391- post(handle_session_close),252+ post(handle_runtime_checkpoint_snapshot_delete),
392- ),253+ )
393- bearer_auth,254+ .route("/api/v1/runtimes/checkout", post(handle_runtime_checkout)),
255+ bearer_auth.clone(),
394 );256 );
395 257 
396 let router = Router::new()258 let router = Router::new()
@@ -399,8 +261,7 @@ fn create_router_from_state(
399 "/api/v1/channels/:channel_id/events",261 "/api/v1/channels/:channel_id/events",
400 post(handle_channel_events),262 post(handle_channel_events),
401 )263 )
402- .merge(protected_routes)264+ .merge(protected_runtime_routes)
403- .merge(protected_session_routes)
404 .with_state(Arc::new(state));265 .with_state(Arc::new(state));
405 266 
406 match rate_limit.and_then(|c| c.governor_layer()) {267 match rate_limit.and_then(|c| c.governor_layer()) {
@@ -411,7 +272,7 @@ fn create_router_from_state(
411 272 
412pub fn create_router_with_control_plane_and_auth(273pub fn create_router_with_control_plane_and_auth(
413 session_service: Arc<dyn SessionService>,274 session_service: Arc<dyn SessionService>,
414- session_control_plane: Arc<dyn crate::gateway::SessionControlPlane>,275+ session_control_plane: Arc<dyn xiaoo_shared::gateway::SessionControlPlane>,
415 bearer_auth: Option<HttpBearerAuthConfig>,276 bearer_auth: Option<HttpBearerAuthConfig>,
416 rate_limit: Option<RateLimitConfig>,277 rate_limit: Option<RateLimitConfig>,
417) -> Router {278) -> Router {
@@ -496,7 +357,7 @@ async fn health_check() -> Json<GatewayHealthResponse> {
496 357 
497async fn handle_session_open(358async fn handle_session_open(
498 State(state): State<Arc<GatewayAppState>>,359 State(state): State<Arc<GatewayAppState>>,
499- Json(payload): Json<crate::gateway::SessionOpenRequest>,360+ Json(payload): Json<xiaoo_shared::gateway::RuntimeOpenRequest>,
500) -> Response {361) -> Response {
501 let Some(control_plane) = state.session_control_plane.as_ref() else {362 let Some(control_plane) = state.session_control_plane.as_ref() else {
502 return (363 return (
@@ -514,21 +375,18 @@ async fn handle_session_open(
514 }375 }
515}376}
516 377 
517-async fn handle_session_turn_stream(378+async fn handle_session_input(
518 State(state): State<Arc<GatewayAppState>>,379 State(state): State<Arc<GatewayAppState>>,
519- Path(session_id): Path<String>,380+ Json(payload): Json<xiaoo_shared::gateway::RuntimeTurnRequest>,
520- Json(payload): Json<crate::gateway::AppTurnRequest>,
521) -> Response {381) -> Response {
522- if payload.session_id != session_id {382+ stream_session_input(state, payload.session_id.clone(), payload).await
523- return (383+}
524- StatusCode::BAD_REQUEST,
525- Json(GatewayErrorResponse {
526- error: "path session_id does not match request session_id".to_string(),
527- }),
528- )
529- .into_response();
530- }
531 384 
385+async fn stream_session_input(
386+ state: Arc<GatewayAppState>,
387+ session_id: String,
388+ payload: xiaoo_shared::gateway::RuntimeTurnRequest,
389+) -> Response {
532 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<SseStreamEvent>();390 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<SseStreamEvent>();
533 let sink = Arc::new(SseLoopEventSink::new(tx.clone()));391 let sink = Arc::new(SseLoopEventSink::new(tx.clone()));
534 let interaction_handle = Arc::new(RemoteSseInteractionHandle {392 let interaction_handle = Arc::new(RemoteSseInteractionHandle {
@@ -576,10 +434,13 @@ async fn handle_session_turn_stream(
576 434 
577async fn handle_session_interaction(435async fn handle_session_interaction(
578 State(state): State<Arc<GatewayAppState>>,436 State(state): State<Arc<GatewayAppState>>,
579- Path(session_id): Path<String>,437+ Json(payload): Json<xiaoo_shared::gateway::RuntimeInteractionRequest>,
580- Json(payload): Json<InteractionResponse>,
581) -> Response {438) -> Response {
582- if state.remote_interactions.answer(&session_id, payload).await {439+ if state
440+ .remote_interactions
441+ .answer(&payload.session_id, payload.response)
442+ .await
443+ {
583 StatusCode::NO_CONTENT.into_response()444 StatusCode::NO_CONTENT.into_response()
584 } else {445 } else {
585 (446 (
@@ -594,8 +455,9 @@ async fn handle_session_interaction(
594 455 
595async fn handle_session_cancel(456async fn handle_session_cancel(
596 State(state): State<Arc<GatewayAppState>>,457 State(state): State<Arc<GatewayAppState>>,
597- Path(session_id): Path<String>,458+ Json(payload): Json<xiaoo_shared::gateway::RuntimeCancelRequest>,
598) -> Response {459) -> Response {
460+ let session_id = payload.session_id;
599 let Some(control_plane) = state.session_control_plane.as_ref() else {461 let Some(control_plane) = state.session_control_plane.as_ref() else {
600 return Json(SseStreamEvent::Cancelled { session_id }).into_response();462 return Json(SseStreamEvent::Cancelled { session_id }).into_response();
601 };463 };
@@ -608,13 +470,23 @@ async fn handle_session_cancel(
608 }),470 }),
609 )471 )
610 .into_response(),472 .into_response(),
611- _ => Json(SseStreamEvent::Cancelled { session_id }).into_response(),473+ Ok(Some(_)) => match control_plane
474+ .submit_input(
475+ &session_id,
476+ xiaoo_shared::gateway::SessionInput::CancelActiveTurn,
477+ )
478+ .await
479+ {
480+ Ok(_) => Json(SseStreamEvent::Cancelled { session_id }).into_response(),
481+ Err(error) => map_session_error(error),
482+ },
483+ Err(error) => map_session_error(error),
612 }484 }
613}485}
614 486 
615async fn handle_session_close(487async fn handle_session_close(
616 State(state): State<Arc<GatewayAppState>>,488 State(state): State<Arc<GatewayAppState>>,
617- Path(session_id): Path<String>,489+ Json(payload): Json<xiaoo_shared::gateway::RuntimeCloseRequest>,
618) -> Response {490) -> Response {
619 let Some(control_plane) = state.session_control_plane.as_ref() else {491 let Some(control_plane) = state.session_control_plane.as_ref() else {
620 return (492 return (
@@ -626,16 +498,80 @@ async fn handle_session_close(
626 .into_response();498 .into_response();
627 };499 };
628 500 
629- match control_plane.force_close_session(&session_id).await {501+ match control_plane.force_close_session(&payload.session_id).await {
630 Ok(record) => Json(record).into_response(),502 Ok(record) => Json(record).into_response(),
631 Err(error) => map_session_error(error),503 Err(error) => map_session_error(error),
632 }504 }
633}505}
634 506 
635-fn map_session_error(error: crate::gateway::SessionServiceError) -> Response {507+async fn handle_runtime_checkpoint(
508+ State(state): State<Arc<GatewayAppState>>,
509+ Json(payload): Json<xiaoo_shared::RuntimeCheckpointRequest>,
510+) -> Response {
511+ let Some(control_plane) = state.session_control_plane.as_ref() else {
512+ return (
513+ StatusCode::NOT_IMPLEMENTED,
514+ Json(GatewayErrorResponse {
515+ error: "session control plane is not configured".to_string(),
516+ }),
517+ )
518+ .into_response();
519+ };
520+ 
521+ match control_plane.checkpoint_runtime(payload).await {
522+ Ok(result) => Json(result).into_response(),
523+ Err(error) => map_session_error(error),
524+ }
525+}
526+ 
527+async fn handle_runtime_checkout(
528+ State(state): State<Arc<GatewayAppState>>,
529+ Json(payload): Json<xiaoo_shared::RuntimeCheckoutRequest>,
530+) -> Response {
531+ let Some(control_plane) = state.session_control_plane.as_ref() else {
532+ return (
533+ StatusCode::NOT_IMPLEMENTED,
534+ Json(GatewayErrorResponse {
535+ error: "session control plane is not configured".to_string(),
536+ }),
537+ )
538+ .into_response();
539+ };
540+ 
541+ match control_plane.checkout_runtime(payload).await {
542+ Ok(result) => Json(result).into_response(),
543+ Err(error) => map_session_error(error),
544+ }
545+}
546+ 
547+async fn handle_runtime_checkpoint_snapshot_delete(
548+ State(state): State<Arc<GatewayAppState>>,
549+ Json(payload): Json<xiaoo_shared::RuntimeCheckpointSnapshotDeleteRequest>,
550+) -> Response {
551+ let Some(control_plane) = state.session_control_plane.as_ref() else {
552+ return (
553+ StatusCode::NOT_IMPLEMENTED,
554+ Json(GatewayErrorResponse {
555+ error: "session control plane is not configured".to_string(),
556+ }),
557+ )
558+ .into_response();
559+ };
560+ 
561+ match control_plane.delete_checkpoint_snapshot(payload).await {
562+ Ok(result) => Json(result).into_response(),
563+ Err(error) => map_session_error(error),
564+ }
565+}
566+ 
567+fn map_session_error(error: xiaoo_shared::gateway::SessionServiceError) -> Response {
636 let status = match &error {568 let status = match &error {
637- crate::gateway::SessionServiceError::SessionNotFound { .. } => StatusCode::NOT_FOUND,569+ xiaoo_shared::gateway::SessionServiceError::SessionNotFound { .. } => StatusCode::NOT_FOUND,
638- crate::gateway::SessionServiceError::UnsupportedCapability { .. } => {570+ xiaoo_shared::gateway::SessionServiceError::SessionBusy { .. } => {
571+ StatusCode::TOO_MANY_REQUESTS
572+ }
573+ xiaoo_shared::gateway::SessionServiceError::SessionClosed { .. } => StatusCode::CONFLICT,
574+ xiaoo_shared::gateway::SessionServiceError::UnsupportedCapability { .. } => {
639 StatusCode::NOT_IMPLEMENTED575 StatusCode::NOT_IMPLEMENTED
640 }576 }
641 _ => StatusCode::INTERNAL_SERVER_ERROR,577 _ => StatusCode::INTERNAL_SERVER_ERROR,
@@ -649,133 +585,6 @@ fn map_session_error(error: crate::gateway::SessionServiceError) -> Response {
649 .into_response()585 .into_response()
650}586}
651 587 
652-async fn handle_chat(
653- State(state): State<Arc<GatewayAppState>>,
654- Json(payload): Json<TestChatRequest>,
655-) -> Response {
656- let request = match validate_test_chat_request(payload) {
657- Ok(request) => request,
658- Err(error) => {
659- return (
660- StatusCode::BAD_REQUEST,
661- Json(GatewayErrorResponse { error }),
662- )
663- .into_response();
664- }
665- };
666- 
667- let message = GatewayChannelMessage {
668- channel: request.channel,
669- channel_instance_id: request.channel_instance_id,
670- conversation_id: request.conversation_id,
671- sender_id: request.sender_id,
672- agent_preset_id: request.agent,
673- message_id: request
674- .message_id
675- .unwrap_or_else(|| format!("test-msg-{}", uuid::Uuid::new_v4())),
676- text: request.text,
677- channel_identity_prompt: None,
678- reply_to_message_id: request.reply_to_message_id,
679- root_message_id: request.root_message_id,
680- mentions: request
681- .mentions
682- .into_iter()
683- .map(|mention| GatewayChannelMention {
684- id: mention.id,
685- display_name: mention.display_name,
686- })
687- .collect(),
688- };
689- 
690- match state.gateway_service.handle_channel_message(message).await {
691- Ok(response) => Json(TestChatResponse {
692- reply: response.visible_reply,
693- raw_reply: response.raw_reply,
694- conversation_id: response.conversation_id,
695- session_id: response.session_id,
696- })
697- .into_response(),
698- Err(error) => map_gateway_error(error),
699- }
700-}
701- 
702-async fn handle_chat_stream(
703- State(state): State<Arc<GatewayAppState>>,
704- Json(payload): Json<TestChatRequest>,
705-) -> Response {
706- let request = match validate_test_chat_request(payload) {
707- Ok(request) => request,
708- Err(error) => {
709- return (
710- StatusCode::BAD_REQUEST,
711- Json(GatewayErrorResponse { error }),
712- )
713- .into_response();
714- }
715- };
716- 
717- let conversation_id = request.conversation_id.clone();
718- let message = GatewayChannelMessage {
719- channel: request.channel,
720- channel_instance_id: request.channel_instance_id,
721- conversation_id,
722- sender_id: request.sender_id,
723- agent_preset_id: request.agent,
724- message_id: request
725- .message_id
726- .unwrap_or_else(|| format!("test-msg-{}", uuid::Uuid::new_v4())),
727- text: request.text,
728- channel_identity_prompt: None,
729- reply_to_message_id: request.reply_to_message_id,
730- root_message_id: request.root_message_id,
731- mentions: request
732- .mentions
733- .into_iter()
734- .map(|mention| GatewayChannelMention {
735- id: mention.id,
736- display_name: mention.display_name,
737- })
738- .collect(),
739- };
740- 
741- let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<SseStreamEvent>();
742- let sink = Arc::new(SseLoopEventSink::new(tx.clone()));
743- 
744- tokio::spawn(async move {
745- match state
746- .gateway_service
747- .handle_channel_message_with_interaction(message, Some(sink.clone()), None, None)
748- .await
749- {
750- Ok(response) => {
751- let summary = sink.take_loop_summary();
752- let _ = tx.send(SseStreamEvent::Done {
753- reply: response.visible_reply,
754- raw_reply: response.raw_reply,
755- conversation_id: response.conversation_id,
756- session_id: response.session_id,
757- turn_count: summary.as_ref().map_or(0, |s| s.turn_count),
758- total_tokens: summary.as_ref().map_or(0, |s| s.total_tokens),
759- prompt_tokens: 0,
760- completion_tokens: 0,
761- estimated_input_tokens: 0,
762- messages: Vec::new(),
763- stop_reason: summary.map(|s| s.stop_reason).unwrap_or_default(),
764- });
765- }
766- Err(error) => {
767- let _ = tx.send(SseStreamEvent::Error {
768- error: error.to_string(),
769- });
770- }
771- }
772- });
773- 
774- Sse::new(sse_stream_from_receiver(rx))
775- .keep_alive(KeepAlive::default())
776- .into_response()
777-}
778- 
779async fn handle_channel_events(588async fn handle_channel_events(
780 State(state): State<Arc<GatewayAppState>>,589 State(state): State<Arc<GatewayAppState>>,
781 Path(channel_id): Path<String>,590 Path(channel_id): Path<String>,
@@ -809,10 +618,7 @@ async fn handle_channel_events(
809 {618 {
810 warn!(619 warn!(
811 "failed to acknowledge channel message: channel={} id={} conversation={} error={}",620 "failed to acknowledge channel message: channel={} id={} conversation={} error={}",
812- runtime.meta.id,621+ runtime.meta.id, message.message_id, message.conversation_id, error
813- message.message_id,
814- message.conversation_id,
815- error
816 );622 );
817 }623 }
818 }624 }
@@ -838,42 +644,6 @@ async fn handle_channel_events(
838 }644 }
839}645}
840 646 
841-fn validate_test_chat_request(payload: TestChatRequest) -> Result<TestChatTurnRequest, String> {
842- let channel = payload
843- .channel
844- .or(payload.channel_instance_id.clone())
845- .filter(|value| !value.trim().is_empty())
846- .ok_or_else(|| "channel or channel_instance_id is required".to_string())?;
847- let sender_id = payload
848- .sender_id
849- .filter(|value| !value.trim().is_empty())
850- .ok_or_else(|| "sender_id is required".to_string())?;
851- let conversation_id = payload
852- .conversation_id
853- .filter(|value| !value.trim().is_empty())
854- .ok_or_else(|| "conversation_id is required".to_string())?;
855- let text = payload.text.trim().to_string();
856- if text.is_empty() {
857- return Err("text must not be empty".to_string());
858- }
859- 
860- Ok(TestChatTurnRequest {
861- text,
862- channel,
863- channel_instance_id: payload.channel_instance_id,
864- sender_id,
865- agent: payload
866- .agent
867- .map(|value| value.trim().to_string())
868- .filter(|value| !value.is_empty()),
869- conversation_id,
870- message_id: payload.message_id,
871- reply_to_message_id: payload.reply_to_message_id,
872- root_message_id: payload.root_message_id,
873- mentions: payload.mentions,
874- })
875-}
876- 
877fn map_adapter_response(adapter_response: AdapterResponse) -> Response {647fn map_adapter_response(adapter_response: AdapterResponse) -> Response {
878 match adapter_response {648 match adapter_response {
879 AdapterResponse::Accepted => {649 AdapterResponse::Accepted => {
@@ -938,73 +708,30 @@ fn map_channel_message_processing_error(error: ChannelMessageProcessingError) ->
938#[cfg(test)]708#[cfg(test)]
939mod tests {709mod tests {
940 use super::{710 use super::{
941- create_router_with_auth, handle_channel_events, validate_test_chat_request,711+ GatewayAppState, GatewayErrorResponse, HttpBearerAuthConfig, create_router_with_auth,
942- GatewayAppState, GatewayErrorResponse, HttpBearerAuthConfig, TestChatMention,712+ handle_channel_events,
943- TestChatRequest,
944 };713 };
945 use crate::channels::{714 use crate::channels::{
946 AdapterResponse, ChannelAdapter, ChannelCapabilities, ChannelMember, ChannelMention,715 AdapterResponse, ChannelAdapter, ChannelCapabilities, ChannelMember, ChannelMention,
947 ChannelMessage, ChannelMeta, ChannelResult, ChannelRuntime, ChannelTextFormat,716 ChannelMessage, ChannelMeta, ChannelResult, ChannelRuntime, ChannelTextFormat,
948 };717 };
949- use crate::gateway::{AppTurnRequest, AppTurnResult, SessionService, SessionServiceError};
950 use agent_contracts::LoopEventSink;718 use agent_contracts::LoopEventSink;
951 use async_trait::async_trait;719 use async_trait::async_trait;
952 use axum::{720 use axum::{
953- body::{to_bytes, Body, Bytes},721+ body::{Body, Bytes, to_bytes},
954 extract::{Path, Query, State},722 extract::{Path, Query, State},
955 http::{HeaderMap, Request, StatusCode},723 http::{HeaderMap, Request, StatusCode},
956 };724 };
957 use std::collections::HashMap;725 use std::collections::HashMap;
958 use std::sync::{Arc, Mutex};726 use std::sync::{Arc, Mutex};
959- use tokio::time::{sleep, timeout, Duration};727+ use tokio::time::{Duration, sleep, timeout};
960 use tower::util::ServiceExt;728 use tower::util::ServiceExt;
961- 729+ use xiaoo_shared::gateway::{
962- #[test]730+ AppTurnRequest, AppTurnResult, SessionService, SessionServiceError,
963- fn rejects_missing_identity_fields() {731+ };
964- let error = validate_test_chat_request(TestChatRequest {
965- text: "hello".to_string(),
966- channel: None,
967- channel_instance_id: None,
968- sender_id: None,
969- agent: None,
970- conversation_id: None,
971- message_id: None,
972- reply_to_message_id: None,
973- root_message_id: None,
974- mentions: Vec::new(),
975- })
976- .expect_err("request should fail fast");
977- 
978- assert_eq!(error, "channel or channel_instance_id is required");
979- }
980- 
981- #[test]
982- fn accepts_explicit_test_chat_request() {
983- let request = validate_test_chat_request(TestChatRequest {
984- text: " hello ".to_string(),
985- channel: Some("feishu".to_string()),
986- channel_instance_id: Some("ops-feishu".to_string()),
987- sender_id: Some("user-1".to_string()),
988- agent: Some("test-agent".to_string()),
989- conversation_id: Some("conv-1".to_string()),
990- message_id: Some("msg-1".to_string()),
991- reply_to_message_id: None,
992- root_message_id: None,
993- mentions: vec![TestChatMention {
994- id: "bot".to_string(),
995- display_name: Some("XiaoO".to_string()),
996- }],
997- })
998- .expect("request should be valid");
999- 
1000- assert_eq!(request.text, "hello");
1001- assert_eq!(request.channel, "feishu");
1002- assert_eq!(request.channel_instance_id.as_deref(), Some("ops-feishu"));
1003- assert_eq!(request.mentions.len(), 1);
1004- }
1005 732 
1006 #[tokio::test(flavor = "current_thread")]733 #[tokio::test(flavor = "current_thread")]
1007- async fn bearer_auth_rejects_missing_token_for_chat_routes() {734+ async fn bearer_auth_rejects_missing_token_for_runtime_input() {
1008 let router = create_router_with_auth(735 let router = create_router_with_auth(
1009 Arc::new(FakeSessionService::new("unused")),736 Arc::new(FakeSessionService::new("unused")),
1010 Some(HttpBearerAuthConfig::new("secret-token")),737 Some(HttpBearerAuthConfig::new("secret-token")),
@@ -1015,10 +742,10 @@ mod tests {
1015 .oneshot(742 .oneshot(
1016 Request::builder()743 Request::builder()
1017 .method("POST")744 .method("POST")
1018- .uri("/api/v1/chat")745+ .uri("/api/v1/runtimes/input")
1019 .header("content-type", "application/json")746 .header("content-type", "application/json")
1020 .body(Body::from(747 .body(Body::from(
1021- r#"{"text":"hello","channel":"test","sender_id":"user-1","conversation_id":"conv-1"}"#,748+ r#"{"runtime_id":"runtime-1","entry":{"kind":"tui"},"channel":"tui","conversation_id":"conv-1","sender_id":"user-1","text":"hello","mentions":[]}"#,
1022 ))749 ))
1023 .expect("request should build"),750 .expect("request should build"),
1024 )751 )
@@ -1043,10 +770,9 @@ mod tests {
1043 }770 }
1044 771 
1045 #[tokio::test(flavor = "current_thread")]772 #[tokio::test(flavor = "current_thread")]
1046- async fn bearer_auth_allows_valid_token_for_chat_routes() {773+ async fn bearer_auth_allows_valid_token_for_runtime_input() {
1047- let session_service = Arc::new(FakeSessionService::new("处理完成"));
1048 let router = create_router_with_auth(774 let router = create_router_with_auth(
1049- session_service.clone(),775+ Arc::new(FakeSessionService::new("unused")),
1050 Some(HttpBearerAuthConfig::new("secret-token")),776 Some(HttpBearerAuthConfig::new("secret-token")),
1051 None,777 None,
1052 );778 );
@@ -1055,11 +781,11 @@ mod tests {
1055 .oneshot(781 .oneshot(
1056 Request::builder()782 Request::builder()
1057 .method("POST")783 .method("POST")
1058- .uri("/api/v1/chat")784+ .uri("/api/v1/runtimes/input")
1059 .header("authorization", "Bearer secret-token")785 .header("authorization", "Bearer secret-token")
1060 .header("content-type", "application/json")786 .header("content-type", "application/json")
1061 .body(Body::from(787 .body(Body::from(
1062- r#"{"text":"hello","channel":"test","sender_id":"user-1","conversation_id":"conv-1"}"#,788+ r#"{"runtime_id":"runtime-1","entry":{"kind":"tui"},"channel":"tui","conversation_id":"conv-1","sender_id":"user-1","text":"hello","mentions":[]}"#,
1063 ))789 ))
1064 .expect("request should build"),790 .expect("request should build"),
1065 )791 )
@@ -1067,14 +793,144 @@ mod tests {
1067 .expect("router should respond");793 .expect("router should respond");
1068 794 
1069 assert_eq!(response.status(), StatusCode::OK);795 assert_eq!(response.status(), StatusCode::OK);
1070- assert_eq!(796+ }
1071- session_service797+ 
1072- .requests798+ #[tokio::test(flavor = "current_thread")]
1073- .lock()799+ async fn bearer_auth_applies_to_runtime_checkpoint_route() {
1074- .expect("session service mutex poisoned")800+ let router = create_router_with_auth(
1075- .len(),801+ Arc::new(FakeSessionService::new("unused")),
1076- 1802+ Some(HttpBearerAuthConfig::new("secret-token")),
803+ None,
1077 );804 );
805+ 
806+ let missing_auth = router
807+ .clone()
808+ .oneshot(
809+ Request::builder()
810+ .method("POST")
811+ .uri("/api/v1/runtimes/checkpoint")
812+ .header("content-type", "application/json")
813+ .body(Body::from(r#"{"runtime_id":"runtime-1"}"#))
814+ .expect("request should build"),
815+ )
816+ .await
817+ .expect("router should respond");
818+ assert_eq!(missing_auth.status(), StatusCode::UNAUTHORIZED);
819+ 
820+ let valid_auth = router
821+ .clone()
822+ .oneshot(
823+ Request::builder()
824+ .method("POST")
825+ .uri("/api/v1/runtimes/checkpoint")
826+ .header("authorization", "Bearer secret-token")
827+ .header("content-type", "application/json")
828+ .body(Body::from(r#"{"runtime_id":"runtime-1"}"#))
829+ .expect("request should build"),
830+ )
831+ .await
832+ .expect("router should respond");
833+ assert_eq!(valid_auth.status(), StatusCode::NOT_IMPLEMENTED);
834+ 
835+ let missing_auth_delete_snapshot = router
836+ .clone()
837+ .oneshot(
838+ Request::builder()
839+ .method("POST")
840+ .uri("/api/v1/runtimes/checkpoint/delete-snapshot")
841+ .header("content-type", "application/json")
842+ .body(Body::from(r#"{"checkpoint_id":"rtcp_demo"}"#))
843+ .expect("request should build"),
844+ )
845+ .await
846+ .expect("router should respond");
847+ assert_eq!(
848+ missing_auth_delete_snapshot.status(),
849+ StatusCode::UNAUTHORIZED
850+ );
851+ 
852+ let valid_auth_delete_snapshot = router
853+ .oneshot(
854+ Request::builder()
855+ .method("POST")
856+ .uri("/api/v1/runtimes/checkpoint/delete-snapshot")
857+ .header("authorization", "Bearer secret-token")
858+ .header("content-type", "application/json")
859+ .body(Body::from(r#"{"checkpoint_id":"rtcp_demo"}"#))
860+ .expect("request should build"),
861+ )
862+ .await
863+ .expect("router should respond");
864+ assert_eq!(
865+ valid_auth_delete_snapshot.status(),
866+ StatusCode::NOT_IMPLEMENTED
867+ );
868+ }
869+ 
870+ #[tokio::test(flavor = "current_thread")]
871+ async fn runtime_close_uses_body_runtime_id_route() {
872+ let router = create_router_with_auth(
873+ Arc::new(FakeSessionService::new("unused")),
874+ Some(HttpBearerAuthConfig::new("secret-token")),
875+ None,
876+ );
877+ 
878+ let response = router
879+ .oneshot(
880+ Request::builder()
881+ .method("POST")
882+ .uri("/api/v1/runtimes/close")
883+ .header("authorization", "Bearer secret-token")
884+ .header("content-type", "application/json")
885+ .body(Body::from(r#"{"runtime_id":"runtime-1"}"#))
886+ .expect("request should build"),
887+ )
888+ .await
889+ .expect("router should respond");
890+ 
891+ assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
892+ }
893+ 
894+ #[tokio::test(flavor = "current_thread")]
895+ async fn old_session_control_plane_routes_are_not_registered() {
896+ let router = create_router_with_auth(
897+ Arc::new(FakeSessionService::new("unused")),
898+ Some(HttpBearerAuthConfig::new("secret-token")),
899+ None,
900+ );
901+ 
902+ let input_response = router
903+ .clone()
904+ .oneshot(
905+ Request::builder()
906+ .method("POST")
907+ .uri("/api/v1/sessions/input")
908+ .header("authorization", "Bearer secret-token")
909+ .header("content-type", "application/json")
910+ .body(Body::from(
911+ r#"{"session_id":"session-1","entry":{"kind":"tui"},"channel":"tui","conversation_id":"conv-1","sender_id":"user-1","text":"hello","mentions":[]}"#,
912+ ))
913+ .expect("request should build"),
914+ )
915+ .await
916+ .expect("router should respond");
917+ 
918+ assert_eq!(input_response.status(), StatusCode::NOT_FOUND);
919+ 
920+ let close_response = router
921+ .oneshot(
922+ Request::builder()
923+ .method("POST")
924+ .uri("/api/v1/sessions/close")
925+ .header("authorization", "Bearer secret-token")
926+ .header("content-type", "application/json")
927+ .body(Body::from(r#"{"runtime_id":"runtime-1"}"#))
928+ .expect("request should build"),
929+ )
930+ .await
931+ .expect("router should respond");
932+ 
933+ assert_eq!(close_response.status(), StatusCode::NOT_FOUND);
1078 }934 }
1079 935 
1080 #[tokio::test(flavor = "current_thread")]936 #[tokio::test(flavor = "current_thread")]
@@ -1354,11 +1210,13 @@ mod tests {
1354 .await;1210 .await;
1355 1211 
1356 assert_eq!(response.status(), StatusCode::OK);1212 assert_eq!(response.status(), StatusCode::OK);
1357- assert!(session_service1213+ assert!(
1358- .requests1214+ session_service
1359- .lock()1215+ .requests
1360- .expect("session service mutex poisoned")1216+ .lock()
1361- .is_empty());1217+ .expect("session service mutex poisoned")
1218+ .is_empty()
1219+ );
1362 }1220 }
1363 1221 
1364 #[tokio::test(flavor = "current_thread")]1222 #[tokio::test(flavor = "current_thread")]
@@ -1408,11 +1266,13 @@ mod tests {
1408 .expect("async webhook route should acknowledge immediately");1266 .expect("async webhook route should acknowledge immediately");
1409 1267 
1410 assert_eq!(response.status(), StatusCode::OK);1268 assert_eq!(response.status(), StatusCode::OK);
1411- assert!(session_service1269+ assert!(
1412- .requests1270+ session_service
1413- .lock()1271+ .requests
1414- .expect("session service mutex poisoned")1272+ .lock()
1415- .is_empty());1273+ .expect("session service mutex poisoned")
1274+ .is_empty()
1275+ );
1416 1276 
1417 sleep(Duration::from_millis(250)).await;1277 sleep(Duration::from_millis(250)).await;
1418 1278 
@@ -1484,13 +1344,9 @@ fn filter_ask_user_question_output(output: &str) -> String {
1484 for answer in answers_array {1344 for answer in answers_array {
1485 if let Some(kind) = answer.get("kind") {1345 if let Some(kind) = answer.get("kind") {
1486 if kind.as_str() == Some("text") {1346 if kind.as_str() == Some("text") {
1487- let display_value = answer.get("display_value").and_then(|v| {1347+ let display_value = answer
1488- if v.is_null() {1348+ .get("display_value")
1489- None1349+ .and_then(|v| if v.is_null() { None } else { Some(v.clone()) });
1490- } else {
1491- Some(v.clone())
1492- }
1493- });
1494 if let Some(display_val) = display_value {1350 if let Some(display_val) = display_value {
1495 if let Some(obj) = answer.as_object_mut() {1351 if let Some(obj) = answer.as_object_mut() {
1496 obj["value"] = display_val;1352 obj["value"] = display_val;
Rapps/xiaoo-app/src/httpserver/service.rsapps/serverside/src/httpserver/service.rs+6-27
@@ -1,14 +1,11 @@
1-use crate::gateway::{AppTurnResult, SessionService, SessionServiceError};
2use crate::httpserver::channel_ingress::{build_channel_turn_request, GatewayChannelMessage};1use crate::httpserver::channel_ingress::{build_channel_turn_request, GatewayChannelMessage};
3use agent_contracts::{ChannelFileSender, InteractionHandle, LoopEventSink};2use agent_contracts::{ChannelFileSender, InteractionHandle, LoopEventSink};
4use std::sync::Arc;3use std::sync::Arc;
5use thiserror::Error;4use thiserror::Error;
5+use xiaoo_shared::gateway::{SessionService, SessionServiceError};
6 6 
7#[derive(Debug, Clone)]7#[derive(Debug, Clone)]
8pub struct GatewayTurnResponse {8pub struct GatewayTurnResponse {
9- pub session_id: String,
10- pub conversation_id: String,
11- pub raw_reply: String,
12 pub visible_reply: String,9 pub visible_reply: String,
13}10}
14 11 
@@ -27,14 +24,6 @@ impl GatewayService {
27 Self { session_service }24 Self { session_service }
28 }25 }
29 26 
30- pub async fn handle_channel_message(
31- &self,
32- message: GatewayChannelMessage,
33- ) -> Result<GatewayTurnResponse, GatewayServiceError> {
34- self.handle_channel_message_with_interaction(message, None, None, None)
35- .await
36- }
37- 
38 pub async fn handle_channel_message_with_interaction(27 pub async fn handle_channel_message_with_interaction(
39 &self,28 &self,
40 message: GatewayChannelMessage,29 message: GatewayChannelMessage,
@@ -43,26 +32,16 @@ impl GatewayService {
43 channel_file_sender: Option<Arc<dyn ChannelFileSender>>,32 channel_file_sender: Option<Arc<dyn ChannelFileSender>>,
44 ) -> Result<GatewayTurnResponse, GatewayServiceError> {33 ) -> Result<GatewayTurnResponse, GatewayServiceError> {
45 let request = build_channel_turn_request(&message);34 let request = build_channel_turn_request(&message);
46- let session_id = request.session_id.clone();35+ let visible_reply = self
47- let conversation_id = request.conversation_id.clone();
48- let AppTurnResult {
49- raw_reply,
50- visible_reply,
51- messages: _messages,
52- ..
53- } = self
54 .session_service36 .session_service
55 .run_turn_with_interaction(request, event_sink, interaction_handle, channel_file_sender)37 .run_turn_with_interaction(request, event_sink, interaction_handle, channel_file_sender)
56- .await?;38+ .await?
39+ .visible_reply;
57 40 
58- Ok(GatewayTurnResponse {41+ Ok(GatewayTurnResponse { visible_reply })
59- session_id,
60- conversation_id,
61- raw_reply,
62- visible_reply,
63- })
64 }42 }
65 43 
44+ #[allow(dead_code)]
66 pub async fn handle_channel_message_with_events(45 pub async fn handle_channel_message_with_events(
67 &self,46 &self,
68 message: GatewayChannelMessage,47 message: GatewayChannelMessage,
Rapps/xiaoo-app/src/httpserver/sse_sink.rsapps/serverside/src/httpserver/sse_sink.rs+18-0
@@ -39,6 +39,7 @@ pub enum SseStreamEvent {
39 reply: String,39 reply: String,
40 raw_reply: String,40 raw_reply: String,
41 conversation_id: String,41 conversation_id: String,
42+ #[serde(rename = "runtime_id")]
42 session_id: String,43 session_id: String,
43 turn_count: u32,44 turn_count: u32,
44 total_tokens: usize,45 total_tokens: usize,
@@ -52,6 +53,7 @@ pub enum SseStreamEvent {
52 error: String,53 error: String,
53 },54 },
54 Cancelled {55 Cancelled {
56+ #[serde(rename = "runtime_id")]
55 session_id: String,57 session_id: String,
56 },58 },
57}59}
@@ -176,3 +178,19 @@ pub fn sse_stream_from_receiver(
176 Ok(sse::Event::default().event(name).data(data))178 Ok(sse::Event::default().event(name).data(data))
177 })179 })
178}180}
181+ 
182+#[cfg(test)]
183+mod tests {
184+ use super::*;
185+ 
186+ #[test]
187+ fn cancelled_event_serializes_runtime_id() {
188+ let value = serde_json::to_value(SseStreamEvent::Cancelled {
189+ session_id: "runtime-1".to_string(),
190+ })
191+ .expect("event should serialize");
192+ 
193+ assert_eq!(value["runtime_id"], "runtime-1");
194+ assert!(value.get("session_id").is_none());
195+ }
196+}
Rapps/xiaoo-app/src/main.rsapps/serverside/src/main.rs+94-78
@@ -1,9 +1,19 @@
1+mod channels;
1mod daemon_config;2mod daemon_config;
2mod daemon_runtime;3mod daemon_runtime;
3-mod lsp_support;4+mod httpserver;
4 5 
6+use crate::channels::{
7+ build_feishu_runtime, build_telegram_runtime, FeishuConfig, FeishuEventTransport,
8+ FeishuWebsocketMessageHandler, FeishuWebsocketService, TelegramConfig,
9+ TelegramPollingMessageHandler, TelegramPollingService,
10+};
5use crate::daemon_config::{resolve_config_path, DaemonConfig};11use crate::daemon_config::{resolve_config_path, DaemonConfig};
6use crate::daemon_runtime::ConfiguredRuntimeResolver;12use crate::daemon_runtime::ConfiguredRuntimeResolver;
13+use crate::httpserver::{
14+ create_router_with_channel_runtimes_control_plane_and_timeout_and_auth,
15+ create_router_with_control_plane_and_auth, ChannelRuntimeProcessor, HttpBearerAuthConfig,
16+};
7use anyhow::{bail, Context, Result};17use anyhow::{bail, Context, Result};
8use futures_util::future::BoxFuture;18use futures_util::future::BoxFuture;
9use operation_backend::process_group::ProcessGroupCleanupGuard;19use operation_backend::process_group::ProcessGroupCleanupGuard;
@@ -12,18 +22,8 @@ use std::net::SocketAddr;
12use std::path::PathBuf;22use std::path::PathBuf;
13use std::sync::Arc;23use std::sync::Arc;
14use tracing_subscriber::EnvFilter;24use tracing_subscriber::EnvFilter;
15-use xiaoo_app::channels::{25+use xiaoo_shared::backend::BackendManager;
16- build_feishu_runtime, build_telegram_runtime, FeishuEventTransport,26+use xiaoo_shared::gateway::{AppBootstrap, InMemorySessionStore, SessionStore};
17- FeishuWebsocketMessageHandler, FeishuWebsocketService, TelegramPollingMessageHandler,
18- TelegramPollingService,
19-};
20-use xiaoo_app::gateway::{
21- backend::ExternalBackendManager, AppBootstrap, InMemorySessionStore, SessionStore,
22-};
23-use xiaoo_app::httpserver::{
24- create_router_with_channel_runtimes_control_plane_and_timeout_and_auth,
25- create_router_with_control_plane_and_auth, ChannelRuntimeProcessor, HttpBearerAuthConfig,
26-};
27 27 
28#[tokio::main]28#[tokio::main]
29async fn main() -> Result<()> {29async fn main() -> Result<()> {
@@ -31,20 +31,28 @@ async fn main() -> Result<()> {
31 31 
32 init_tracing();32 init_tracing();
33 let cli = Cli::parse(env::args().skip(1))?;33 let cli = Cli::parse(env::args().skip(1))?;
34- match cli.command {34+ if cli.help {
35- Command::Daemon { config, host, port } => run_daemon(config, host, port).await,35+ print_usage();
36+ return Ok(());
36 }37 }
38+ run_daemon(cli.config, cli.host, cli.port).await
37}39}
38 40 
39async fn run_daemon(config_path: Option<PathBuf>, host: String, port: u16) -> Result<()> {41async fn run_daemon(config_path: Option<PathBuf>, host: String, port: u16) -> Result<()> {
40 let config_path = resolve_config_path(config_path)?;42 let config_path = resolve_config_path(config_path)?;
43+ xiaoo_shared::llm_secrets::init_on_demand_secret_provider(&config_path).with_context(|| {
44+ format!(
45+ "failed to initialize LLM secrets from {}",
46+ config_path.display()
47+ )
48+ })?;
41 let config = DaemonConfig::load_from(&config_path)?;49 let config = DaemonConfig::load_from(&config_path)?;
42 let hooker_config = config.app.hooker.clone();50 let hooker_config = config.app.hooker.clone();
43 let bearer_auth = config.http_bearer_token()?.map(HttpBearerAuthConfig::new);51 let bearer_auth = config.http_bearer_token()?.map(HttpBearerAuthConfig::new);
44 let rate_limit = config.app.http.rate_limit.clone();52 let rate_limit = config.app.http.rate_limit.clone();
45 let resolver = Arc::new(ConfiguredRuntimeResolver::from_config(&config).await?);53 let resolver = Arc::new(ConfiguredRuntimeResolver::from_config(&config).await?);
46 let session_store: Arc<dyn SessionStore> = Arc::new(InMemorySessionStore::default());54 let session_store: Arc<dyn SessionStore> = Arc::new(InMemorySessionStore::default());
47- let backend_manager = Arc::new(ExternalBackendManager::new());55+ let backend_manager = Arc::new(BackendManager::new());
48 let app = AppBootstrap::from_session_components_with_hooks_and_backend_manager(56 let app = AppBootstrap::from_session_components_with_hooks_and_backend_manager(
49 session_store,57 session_store,
50 resolver,58 resolver,
@@ -113,8 +121,8 @@ async fn run_daemon(config_path: Option<PathBuf>, host: String, port: u16) -> Re
113}121}
114 122 
115fn spawn_feishu_websocket_service(123fn spawn_feishu_websocket_service(
116- feishu_config: xiaoo_app::channels::FeishuConfig,124+ feishu_config: FeishuConfig,
117- session_service: Arc<dyn xiaoo_app::gateway::SessionService>,125+ session_service: Arc<dyn xiaoo_shared::gateway::SessionService>,
118 interaction_timeout_secs: u64,126 interaction_timeout_secs: u64,
119) -> Result<()> {127) -> Result<()> {
120 let runtime = build_feishu_runtime(feishu_config.clone()).map_err(anyhow::Error::new)?;128 let runtime = build_feishu_runtime(feishu_config.clone()).map_err(anyhow::Error::new)?;
@@ -138,8 +146,8 @@ fn spawn_feishu_websocket_service(
138}146}
139 147 
140fn spawn_telegram_polling_service(148fn spawn_telegram_polling_service(
141- telegram_config: xiaoo_app::channels::TelegramConfig,149+ telegram_config: TelegramConfig,
142- session_service: Arc<dyn xiaoo_app::gateway::SessionService>,150+ session_service: Arc<dyn xiaoo_shared::gateway::SessionService>,
143 interaction_timeout_secs: u64,151 interaction_timeout_secs: u64,
144) -> Result<()> {152) -> Result<()> {
145 let runtime = build_telegram_runtime(telegram_config.clone()).map_err(anyhow::Error::new)?;153 let runtime = build_telegram_runtime(telegram_config.clone()).map_err(anyhow::Error::new)?;
@@ -164,7 +172,7 @@ fn spawn_telegram_polling_service(
164 172 
165fn init_tracing() {173fn init_tracing() {
166 let filter = EnvFilter::try_from_default_env()174 let filter = EnvFilter::try_from_default_env()
167- .unwrap_or_else(|_| EnvFilter::new("info,xiaoo_app=debug"));175+ .unwrap_or_else(|_| EnvFilter::new("info,xiaoo_serverside=debug,xiaoo_shared=debug"));
168 let _ = tracing_subscriber::fmt()176 let _ = tracing_subscriber::fmt()
169 .with_env_filter(filter)177 .with_env_filter(filter)
170 .with_target(false)178 .with_target(false)
@@ -172,15 +180,10 @@ fn init_tracing() {
172}180}
173 181 
174struct Cli {182struct Cli {
175- command: Command,183+ config: Option<PathBuf>,
176-}184+ host: String,
177- 185+ port: u16,
178-enum Command {186+ help: bool,
179- Daemon {
180- config: Option<PathBuf>,
181- host: String,
182- port: u16,
183- },
184}187}
185 188 
186impl Cli {189impl Cli {
@@ -188,59 +191,66 @@ impl Cli {
188 where191 where
189 I: IntoIterator<Item = String>,192 I: IntoIterator<Item = String>,
190 {193 {
191- let mut args = args.into_iter();194+ let mut config = None;
192- let Some(command) = args.next() else {195+ let mut host = "0.0.0.0".to_string();
193- bail!("missing command: expected `daemon`");196+ let mut port = 18080_u16;
194- };197+ let remaining = args.into_iter().collect::<Vec<_>>();
195- match command.as_str() {198+ let mut index = 0;
196- "daemon" => {199+ while index < remaining.len() {
197- let mut config = None;200+ match remaining[index].as_str() {
198- let mut host = "0.0.0.0".to_string();201+ "--help" | "-h" => {
199- let mut port = 18080_u16;202+ return Ok(Self {
200- let remaining = args.collect::<Vec<_>>();203+ config,
201- let mut index = 0;204+ host,
202- while index < remaining.len() {205+ port,
203- match remaining[index].as_str() {206+ help: true,
204- "--config" => {207+ });
205- index += 1;
206- let value =
207- remaining.get(index).context("missing value for --config")?;
208- config = Some(PathBuf::from(value));
209- }
210- "--host" => {
211- index += 1;
212- let value = remaining.get(index).context("missing value for --host")?;
213- host = value.clone();
214- }
215- "--port" => {
216- index += 1;
217- let value = remaining.get(index).context("missing value for --port")?;
218- port = value
219- .parse()
220- .with_context(|| format!("invalid port `{value}`"))?;
221- }
222- other => bail!("unknown argument `{other}` for daemon"),
223- }
224- index += 1;
225 }208 }
226- Ok(Self {209+ "--config" => {
227- command: Command::Daemon { config, host, port },210+ index += 1;
228- })211+ let value = remaining.get(index).context("missing value for --config")?;
212+ config = Some(PathBuf::from(value));
213+ }
214+ "--host" => {
215+ index += 1;
216+ let value = remaining.get(index).context("missing value for --host")?;
217+ host = value.clone();
218+ }
219+ "--port" => {
220+ index += 1;
221+ let value = remaining.get(index).context("missing value for --port")?;
222+ port = value
223+ .parse()
224+ .with_context(|| format!("invalid port `{value}`"))?;
225+ }
226+ other => bail!("unknown argument `{other}`"),
229 }227 }
230- other => bail!("unknown command `{other}`"),228+ index += 1;
231 }229 }
230+ Ok(Self {
231+ config,
232+ host,
233+ port,
234+ help: false,
235+ })
232 }236 }
233}237}
234 238 
239+fn print_usage() {
240+ eprintln!(
241+ "Usage: xiaoo-daemon [--config <path>] [--host <host>] [--port <port>]\n\nDefaults: --host 0.0.0.0 --port 18080"
242+ );
243+}
244+ 
235#[cfg(test)]245#[cfg(test)]
236mod tests {246mod tests {
237 use super::Cli;247 use super::Cli;
248+ use std::path::PathBuf;
238 249 
239 #[test]250 #[test]
240 fn parses_daemon_arguments() {251 fn parses_daemon_arguments() {
241 let cli = Cli::parse(252 let cli = Cli::parse(
242 [253 [
243- "daemon",
244 "--config",254 "--config",
245 "/tmp/demo.toml",255 "/tmp/demo.toml",
246 "--host",256 "--host",
@@ -253,19 +263,25 @@ mod tests {
253 )263 )
254 .expect("cli should parse");264 .expect("cli should parse");
255 265 
256- assert!(matches!(cli.command, super::Command::Daemon { .. }));266+ assert_eq!(cli.config, Some(PathBuf::from("/tmp/demo.toml")));
267+ assert_eq!(cli.host, "127.0.0.1");
268+ assert_eq!(cli.port, 18080);
269+ assert!(!cli.help);
257 }270 }
258 271 
259 #[test]272 #[test]
260 fn daemon_defaults_to_port_18080() {273 fn daemon_defaults_to_port_18080() {
261- let cli = Cli::parse(["daemon"].into_iter().map(str::to_string))274+ let cli = Cli::parse(std::iter::empty::<String>()).expect("cli should parse with defaults");
262- .expect("cli should parse with defaults");
263 275 
264- match cli.command {276+ assert_eq!(cli.host, "0.0.0.0");
265- super::Command::Daemon { host, port, .. } => {277+ assert_eq!(cli.port, 18080);
266- assert_eq!(host, "0.0.0.0");278+ }
267- assert_eq!(port, 18080);279+ 
268- }280+ #[test]
269- }281+ fn daemon_help_flag_does_not_require_config() {
282+ let cli =
283+ Cli::parse(["--help"].into_iter().map(str::to_string)).expect("cli should parse help");
284+ 
285+ assert!(cli.help);
270 }286 }
271}287}
Rapps/xiaoo-app/src/prompts/default_system_prompt.txtapps/serverside/src/prompts/default_system_prompt.txt+0-0
文件重命名但无更改。
Rapps/xiaoo-app/Cargo.tomlapps/shared/Cargo.toml+3-31
@@ -1,5 +1,5 @@
1[package]1[package]
2-name = "xiaoo-app"2+name = "xiaoo-shared"
3version.workspace = true3version.workspace = true
4edition.workspace = true4edition.workspace = true
5license.workspace = true5license.workspace = true
@@ -13,52 +13,25 @@ protoc-bin-vendored = "3"
13tonic-build = "0.12"13tonic-build = "0.12"
14sysinfo = "0.31"14sysinfo = "0.31"
15 15 
16-[[bin]]
17-name = "xiaoo"
18-path = "src/cli/main.rs"
19- 
20-[[bin]]
21-name = "xiaoo-tui"
22-path = "src/tui/main.rs"
23- 
24[dependencies]16[dependencies]
25-clap = { version = "4", features = ["derive"] }
26-dirs = "6"
27toml = "0.8"17toml = "0.8"
28anyhow.workspace = true18anyhow.workspace = true
29-arboard = { workspace = true }
30-base64.workspace = true
31async-trait.workspace = true19async-trait.workspace = true
32-axum = "0.7"20+http = "1"
33-regex = "1.11"
34glob = "0.3"21glob = "0.3"
35reqwest.workspace = true22reqwest.workspace = true
36-mime_guess.workspace = true23+base64.workspace = true
37-chrono.workspace = true
38-crossterm.workspace = true
39-futures-util.workspace = true
40prost = "0.13"24prost = "0.13"
41tonic = { version = "0.12", features = ["transport"] }25tonic = { version = "0.12", features = ["transport"] }
42-ratatui.workspace = true
43serde.workspace = true26serde.workspace = true
44serde_json.workspace = true27serde_json.workspace = true
45thiserror.workspace = true28thiserror.workspace = true
46tokio = { workspace = true, features = ["macros", "net", "rt-multi-thread", "signal", "time"] }29tokio = { workspace = true, features = ["macros", "net", "rt-multi-thread", "signal", "time"] }
47-tokio-stream.workspace = true
48-tokio-tungstenite = { version = "0.24", features = ["rustls-tls-native-roots"] }
49tokio-util.workspace = true30tokio-util.workspace = true
50-tower = { workspace = true }
51-tower_governor = "0.4"
52parking_lot.workspace = true31parking_lot.workspace = true
53-governor = "0.6"
54tracing.workspace = true32tracing.workspace = true
55-textwrap = "0.16"
56-unicode-width = "0.1.14"
57-url = "2.5"
58uuid.workspace = true33uuid.workspace = true
59-tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
60agent-contracts = { path = "../../crates/agent-contracts" }34agent-contracts = { path = "../../crates/agent-contracts" }
61-agent-llm = { path = "../../crates/agent-llm" }
62agent-types = { path = "../../crates/agent-types" }35agent-types = { path = "../../crates/agent-types" }
63compact = { path = "../../crates/compact" }36compact = { path = "../../crates/compact" }
64xiaoo-core = { path = "../../crates/core" }37xiaoo-core = { path = "../../crates/core" }
@@ -75,7 +48,6 @@ operation_backend = { path = "../../crates/operation_backend" }
75vault = { path = "../vault" }48vault = { path = "../vault" }
76aes-gcm.workspace = true49aes-gcm.workspace = true
77rand.workspace = true50rand.workspace = true
78-hex.workspace = true
79 51 
80[dev-dependencies]52[dev-dependencies]
81tempfile.workspace = true53tempfile.workspace = true
Aapps/shared/README.md+24-0
@@ -0,0 +1,24 @@
1+# xiaoo-shared
2+ 
3+Application-layer package for XiaoO.
4+ 
5+## Current role
6+ 
7+- Own the app assembly layer for gateway, TUI, channel ingress, and process bootstrap.
8+- Depend on `crates/*` for runtime, memory, contracts, and shared types.
9+- Keep transport concerns out of `crates/core`.
10+ 
11+## Runtime/session/backend layout
12+ 
13+- Runtime checkpoint and checkout API types live in `src/runtime_checkpoint.rs`
14+ and are re-exported from the crate root.
15+- Session-scoped runtime resolution and assembly live in
16+ `src/gateway/session_runtime/`.
17+- Backend manager types live in `src/backend/`, with shared backend
18+ request/result shapes grouped in `src/backend/base.rs`.
19+ 
20+The public control plane should talk in runtime/checkpoint terms. In the current
21+v1 implementation, `runtime_id` is backed by the internal `session_id`, while
22+backend ids remain internal manager state. See
23+[`docs/runtime_checkpoint.md`](../../docs/runtime_checkpoint.md)
24+for the current checkpoint layering.
Rapps/xiaoo-app/build.rsapps/shared/build.rs+2-2
@@ -17,8 +17,8 @@ fn compile_conch_proto() {
17 tonic_build::configure()17 tonic_build::configure()
18 .build_server(false)18 .build_server(false)
19 .compile_protos(19 .compile_protos(
20- &["src/gateway/backend/conch/proto/agent.proto"],20+ &["src/backend/conch/proto/agent.proto"],
21- &["src/gateway/backend/conch/proto"],21+ &["src/backend/conch/proto"],
22 )22 )
23 .expect("compile conch agent proto");23 .expect("compile conch agent proto");
24}24}
Aapps/shared/src/backend/backend_manager.rs+720-0
@@ -0,0 +1,720 @@
1+use agent_contracts::backend::{
2+ BackendId, BackendLifecycleReason, BackendResourceLimits, OperationBackendBuildError,
3+ OperationError,
4+};
5+use serde_json::Value;
6+use std::collections::{BTreeMap, HashMap};
7+use std::sync::Arc;
8+use tokio::sync::Mutex;
9+ 
10+use super::{
11+ backend_tree_node, build_backend, current_time_ms, delete_backend_instance, detach_from_parent,
12+ e2b, expires_at_ms_from_timeout, forked_provider_options, hash_config, metadata_matches_filter,
13+ new_backend_id, requested_backend_id, resolve_backend_config, resolve_session_backend_config,
14+ workspace_root_string, BackendCheckoutRequest, BackendCheckoutResult, BackendCheckpointRef,
15+ BackendCheckpointRequest, BackendCheckpointResult, BackendCheckpointSnapshotDeleteRequest,
16+ BackendCheckpointSnapshotDeleteResult, BackendConnectRequest, BackendCreateRequest,
17+ BackendEnsureSessionRequest, BackendError, BackendForkRequest, BackendForkResult, BackendInfo,
18+ BackendInstanceEntry, BackendLease, BackendLineageEntry, BackendListFilter, BackendTreeNode,
19+ BuildBackendInput,
20+};
21+ 
22+#[derive(Default)]
23+pub struct BackendManager {
24+ pub(super) state: Mutex<BackendManagerState>,
25+}
26+ 
27+#[derive(Default)]
28+pub(super) struct BackendManagerState {
29+ pub(super) backends: HashMap<BackendId, BackendInstanceEntry>,
30+ pub(super) session_index: HashMap<String, BackendId>,
31+}
32+ 
33+fn checkout_metadata(
34+ metadata: Value,
35+ checkpoint: &BackendCheckpointRef,
36+ child_backend_id: &str,
37+) -> Value {
38+ let mut object = match metadata {
39+ Value::Object(object) => object,
40+ Value::Null => serde_json::Map::new(),
41+ other => {
42+ let mut object = serde_json::Map::new();
43+ object.insert("user_metadata".to_string(), other);
44+ object
45+ }
46+ };
47+ object.insert(
48+ "xiaoo_checkpoint_id".to_string(),
49+ Value::String(checkpoint.checkpoint_id.clone()),
50+ );
51+ if let Some(snapshot_id) = checkpoint.provider_snapshot_id.as_ref() {
52+ object.insert(
53+ "xiaoo_provider_snapshot_id".to_string(),
54+ Value::String(snapshot_id.clone()),
55+ );
56+ }
57+ if let Some(source_backend_id) = checkpoint.source_backend_id.as_ref() {
58+ object.insert(
59+ "xiaoo_checkpoint_source_backend_id".to_string(),
60+ Value::String(source_backend_id.clone()),
61+ );
62+ }
63+ object.insert(
64+ "xiaoo_checkout_child_backend_id".to_string(),
65+ Value::String(child_backend_id.to_string()),
66+ );
67+ Value::Object(object)
68+}
69+ 
70+fn insert_checkout_child(
71+ state: &mut BackendManagerState,
72+ source_backend_id: Option<&str>,
73+ child_backend_id: &BackendId,
74+ child_session_id: Option<String>,
75+ child_entry: BackendInstanceEntry,
76+) -> Result<(), BackendError> {
77+ if let Some(source_backend_id) = source_backend_id {
78+ if let Some(parent) = state
79+ .backends
80+ .get_mut(&BackendId(source_backend_id.to_string()))
81+ {
82+ parent
83+ .lineage
84+ .children_backend_ids
85+ .insert(child_backend_id.0.clone(), ());
86+ }
87+ }
88+ if let Some(session_id) = child_session_id {
89+ state
90+ .session_index
91+ .insert(session_id, child_backend_id.clone());
92+ }
93+ state.backends.insert(child_backend_id.clone(), child_entry);
94+ Ok(())
95+}
96+ 
97+fn resolve_checkpoint_backend_id(
98+ state: &BackendManagerState,
99+ backend_id: Option<&str>,
100+ session_id: Option<&str>,
101+) -> Result<BackendId, BackendError> {
102+ let by_backend = backend_id
103+ .filter(|value| !value.trim().is_empty())
104+ .map(|value| BackendId(value.to_string()));
105+ let by_session = session_id
106+ .filter(|value| !value.trim().is_empty())
107+ .map(|session_id| {
108+ state
109+ .session_index
110+ .get(session_id)
111+ .cloned()
112+ .ok_or_else(|| BackendError::NotFound {
113+ backend_id: format!("session:{session_id}"),
114+ })
115+ })
116+ .transpose()?;
117+ 
118+ match (by_backend, by_session) {
119+ (Some(backend_id), Some(session_backend_id)) if backend_id != session_backend_id => {
120+ Err(BackendError::Conflict {
121+ message: format!(
122+ "backend_id {backend_id} does not match session_id backend {session_backend_id}"
123+ ),
124+ })
125+ }
126+ (Some(backend_id), _) | (_, Some(backend_id)) => Ok(backend_id),
127+ (None, None) => Err(BackendError::InvalidRequest {
128+ message: "checkpoint requires backend_id or session_id".to_string(),
129+ }),
130+ }
131+}
132+ 
133+impl BackendManager {
134+ pub fn new() -> Self {
135+ Self::default()
136+ }
137+ 
138+ pub async fn create_backend(
139+ &self,
140+ request: BackendCreateRequest,
141+ ) -> Result<BackendInfo, BackendError> {
142+ let config = resolve_backend_config(request.provider.clone(), request.options)?;
143+ let backend_id = requested_backend_id(request.backend_id)?;
144+ let workspace_root = workspace_root_string(&request.workspace_root)
145+ .map_err(BackendError::from_build_error)?;
146+ let config_hash = hash_config(&config);
147+ let expires_at_ms = request.timeout.map(expires_at_ms_from_timeout);
148+ 
149+ let mut state = self.state.lock().await;
150+ if state.backends.contains_key(&backend_id) {
151+ return Err(BackendError::Conflict {
152+ message: format!("backend_id {backend_id} already exists"),
153+ });
154+ }
155+ if let Some(session_id) = request.session_id.as_ref() {
156+ if let Some(existing_backend_id) = state.session_index.get(session_id) {
157+ return Err(BackendError::Conflict {
158+ message: format!(
159+ "session_id {session_id} is already bound to backend_id {existing_backend_id}"
160+ ),
161+ });
162+ }
163+ }
164+ 
165+ let entry = build_backend(BuildBackendInput {
166+ backend_id: backend_id.clone(),
167+ config,
168+ workspace_root_text: workspace_root,
169+ config_hash,
170+ session_id_for_instance: request
171+ .session_id
172+ .clone()
173+ .unwrap_or_else(|| backend_id.0.clone()),
174+ session_id: request.session_id,
175+ resource_limits: request.resource_limits,
176+ metadata: request.metadata,
177+ expires_at_ms,
178+ lineage: BackendLineageEntry::default(),
179+ backend_checkpoint: None,
180+ })
181+ .await?;
182+ let info = entry.info();
183+ let session_ids = entry.session_ids.keys().cloned().collect::<Vec<_>>();
184+ for session_id in session_ids {
185+ state.session_index.insert(session_id, backend_id.clone());
186+ }
187+ state.backends.insert(backend_id, entry);
188+ Ok(info)
189+ }
190+ 
191+ pub async fn connect_backend(
192+ &self,
193+ backend_id: &str,
194+ request: BackendConnectRequest,
195+ ) -> Result<BackendInfo, BackendError> {
196+ let mut state = self.state.lock().await;
197+ let backend_id = BackendId(backend_id.to_string());
198+ if let Some(session_id) = request.session_id.as_ref() {
199+ if let Some(existing_backend_id) = state.session_index.get(session_id) {
200+ if existing_backend_id != &backend_id {
201+ return Err(BackendError::Conflict {
202+ message: format!(
203+ "session_id {session_id} is already bound to backend_id {existing_backend_id}"
204+ ),
205+ });
206+ }
207+ }
208+ }
209+ let session_id = request.session_id;
210+ let info = {
211+ let entry =
212+ state
213+ .backends
214+ .get_mut(&backend_id)
215+ .ok_or_else(|| BackendError::NotFound {
216+ backend_id: backend_id.0.clone(),
217+ })?;
218+ if let Some(timeout) = request.timeout {
219+ entry.expires_at_ms = Some(expires_at_ms_from_timeout(timeout));
220+ }
221+ if let Some(session_id) = session_id.as_ref() {
222+ entry.session_ids.insert(session_id.clone(), ());
223+ }
224+ entry.info()
225+ };
226+ if let Some(session_id) = session_id {
227+ state.session_index.insert(session_id, backend_id);
228+ }
229+ Ok(info)
230+ }
231+ 
232+ pub async fn get_backend(&self, backend_id: &str) -> Result<BackendInfo, BackendError> {
233+ let state = self.state.lock().await;
234+ state
235+ .backends
236+ .get(&BackendId(backend_id.to_string()))
237+ .map(BackendInstanceEntry::info)
238+ .ok_or_else(|| BackendError::NotFound {
239+ backend_id: backend_id.to_string(),
240+ })
241+ }
242+ 
243+ pub async fn list_backends(&self, filter: BackendListFilter) -> Vec<BackendInfo> {
244+ let state = self.state.lock().await;
245+ state
246+ .backends
247+ .values()
248+ .filter(|entry| metadata_matches_filter(&entry.instance.metadata, &filter.metadata))
249+ .map(BackendInstanceEntry::info)
250+ .collect()
251+ }
252+ 
253+ pub async fn list_backend_trees(&self) -> Vec<BackendTreeNode> {
254+ let state = self.state.lock().await;
255+ let mut roots = state
256+ .backends
257+ .iter()
258+ .filter_map(|(backend_id, entry)| {
259+ let parent_is_live = entry
260+ .lineage
261+ .parent_backend_id
262+ .as_ref()
263+ .is_some_and(|parent| state.backends.contains_key(parent));
264+ (!parent_is_live).then(|| backend_id.clone())
265+ })
266+ .collect::<Vec<_>>();
267+ roots.sort_by(|a, b| a.0.cmp(&b.0));
268+ roots
269+ .into_iter()
270+ .filter_map(|backend_id| backend_tree_node(&state, &backend_id))
271+ .collect()
272+ }
273+ 
274+ pub async fn delete_backend(&self, backend_id: &str) -> Result<(), BackendError> {
275+ let removed = {
276+ let mut state = self.state.lock().await;
277+ let backend_id = BackendId(backend_id.to_string());
278+ let entry =
279+ state
280+ .backends
281+ .remove(&backend_id)
282+ .ok_or_else(|| BackendError::NotFound {
283+ backend_id: backend_id.0.clone(),
284+ })?;
285+ for session_id in entry.session_ids.keys() {
286+ state.session_index.remove(session_id);
287+ }
288+ detach_from_parent(&mut state, &backend_id, &entry);
289+ entry
290+ };
291+ delete_backend_instance(removed, BackendLifecycleReason::UserRequested)
292+ .await
293+ .map_err(BackendError::from_operation_error)
294+ }
295+ 
296+ pub async fn checkpoint_backend(
297+ &self,
298+ request: BackendCheckpointRequest,
299+ ) -> Result<BackendCheckpointResult, BackendError> {
300+ let source = {
301+ let state = self.state.lock().await;
302+ let backend_id = resolve_checkpoint_backend_id(
303+ &state,
304+ request.backend_id.as_deref(),
305+ request.session_id.as_deref(),
306+ )?;
307+ let entry = state
308+ .backends
309+ .get(&backend_id)
310+ .ok_or_else(|| BackendError::NotFound {
311+ backend_id: backend_id.0.clone(),
312+ })?;
313+ if !entry.dirty_tracker.is_dirty() {
314+ if let Some(checkpoint) = entry.dirty_tracker.checkpoint() {
315+ return Ok(BackendCheckpointResult {
316+ backend: entry.info(),
317+ checkpoint,
318+ reused: true,
319+ });
320+ }
321+ }
322+ (
323+ backend_id,
324+ entry.config.clone(),
325+ entry.workspace_root.clone(),
326+ entry.instance.instance_id.0.clone(),
327+ )
328+ };
329+ 
330+ let (backend_id, config, workspace_root, instance_id) = source;
331+ let now_ms = current_time_ms();
332+ let (provider_snapshot_id, provider_snapshot_names) = if config.kind == "e2b" {
333+ let snapshot = e2b::create_snapshot(e2b::E2bSnapshotInput {
334+ provider_options: config.options.clone(),
335+ sandbox_id: instance_id,
336+ name: request.name.clone(),
337+ })
338+ .await?;
339+ (Some(snapshot.snapshot_id), snapshot.names)
340+ } else {
341+ (None, Vec::new())
342+ };
343+ 
344+ let checkpoint = BackendCheckpointRef {
345+ checkpoint_id: format!("bcp_{}", uuid::Uuid::new_v4().simple()),
346+ provider: config.kind,
347+ source_backend_id: Some(backend_id.0.clone()),
348+ provider_snapshot_id,
349+ provider_snapshot_names,
350+ workspace_root,
351+ name: request.name,
352+ metadata: request.metadata,
353+ created_at_ms: now_ms,
354+ provider_options: config.options,
355+ };
356+ 
357+ let backend = {
358+ let state = self.state.lock().await;
359+ let entry = state
360+ .backends
361+ .get(&backend_id)
362+ .ok_or_else(|| BackendError::NotFound {
363+ backend_id: backend_id.0.clone(),
364+ })?;
365+ entry.dirty_tracker.set_checkpoint(checkpoint.clone());
366+ entry.info()
367+ };
368+ 
369+ Ok(BackendCheckpointResult {
370+ backend,
371+ checkpoint,
372+ reused: false,
373+ })
374+ }
375+ 
376+ pub async fn delete_checkpoint_snapshot(
377+ &self,
378+ request: BackendCheckpointSnapshotDeleteRequest,
379+ ) -> Result<BackendCheckpointSnapshotDeleteResult, BackendError> {
380+ let checkpoint = request.checkpoint;
381+ let provider = checkpoint.provider.clone();
382+ let provider_snapshot_id = checkpoint.provider_snapshot_id.clone();
383+ let provider_snapshot_names = checkpoint.provider_snapshot_names.clone();
384+ 
385+ let Some(snapshot_id) = provider_snapshot_id.as_deref() else {
386+ return Ok(BackendCheckpointSnapshotDeleteResult {
387+ checkpoint_id: checkpoint.checkpoint_id,
388+ provider,
389+ provider_snapshot_id,
390+ provider_snapshot_names,
391+ deleted: false,
392+ });
393+ };
394+ 
395+ if provider != "e2b" {
396+ return Err(BackendError::UnsupportedBackend {
397+ kind: format!("{provider}:delete_snapshot"),
398+ });
399+ }
400+ 
401+ let deleted = e2b::delete_snapshot(e2b::E2bDeleteSnapshotInput {
402+ provider_options: checkpoint.provider_options.clone(),
403+ snapshot_id: snapshot_id.to_string(),
404+ })
405+ .await?;
406+ 
407+ if let Some(source_backend_id) = checkpoint.source_backend_id.as_ref() {
408+ let state = self.state.lock().await;
409+ if let Some(entry) = state.backends.get(&BackendId(source_backend_id.clone())) {
410+ entry
411+ .dirty_tracker
412+ .clear_checkpoint_if_matches(&checkpoint.checkpoint_id);
413+ }
414+ }
415+ 
416+ Ok(BackendCheckpointSnapshotDeleteResult {
417+ checkpoint_id: checkpoint.checkpoint_id,
418+ provider,
419+ provider_snapshot_id,
420+ provider_snapshot_names,
421+ deleted,
422+ })
423+ }
424+ 
425+ pub async fn checkout_backend(
426+ &self,
427+ request: BackendCheckoutRequest,
428+ ) -> Result<BackendCheckoutResult, BackendError> {
429+ let Some(snapshot_id) = request.checkpoint.provider_snapshot_id.as_deref() else {
430+ return Err(BackendError::UnsupportedBackend {
431+ kind: format!("{}:checkout", request.checkpoint.provider),
432+ });
433+ };
434+ if request.checkpoint.provider != "e2b" {
435+ return Err(BackendError::UnsupportedBackend {
436+ kind: format!("{}:checkout", request.checkpoint.provider),
437+ });
438+ }
439+ 
440+ let child_backend_id = requested_backend_id(request.backend_id.clone())?;
441+ let child_session_id = request.session_id.clone();
442+ let expires_at_ms = request.timeout.map(expires_at_ms_from_timeout);
443+ {
444+ let state = self.state.lock().await;
445+ if state.backends.contains_key(&child_backend_id) {
446+ return Err(BackendError::Conflict {
447+ message: format!("backend_id {child_backend_id} already exists"),
448+ });
449+ }
450+ if let Some(session_id) = child_session_id.as_ref() {
451+ if let Some(existing_backend_id) = state.session_index.get(session_id) {
452+ return Err(BackendError::Conflict {
453+ message: format!(
454+ "session_id {session_id} is already bound to backend_id {existing_backend_id}"
455+ ),
456+ });
457+ }
458+ }
459+ }
460+ 
461+ let mut create_config =
462+ super::GatewayBackendConfig::new("e2b", request.checkpoint.provider_options.clone());
463+ create_config.options = forked_provider_options(
464+ &request.checkpoint.provider_options,
465+ request.options.as_ref(),
466+ snapshot_id,
467+ );
468+ let create_config_hash = hash_config(&create_config);
469+ let stored_config =
470+ super::GatewayBackendConfig::new("e2b", request.checkpoint.provider_options.clone());
471+ let stored_config_hash = hash_config(&stored_config);
472+ let metadata = checkout_metadata(
473+ request.metadata,
474+ &request.checkpoint,
475+ child_backend_id.0.as_str(),
476+ );
477+ let lineage = BackendLineageEntry {
478+ parent_backend_id: request
479+ .checkpoint
480+ .source_backend_id
481+ .as_ref()
482+ .map(|id| BackendId(id.clone())),
483+ children_backend_ids: BTreeMap::new(),
484+ forked_from_snapshot_id: Some(snapshot_id.to_string()),
485+ forked_snapshot_names: request.checkpoint.provider_snapshot_names.clone(),
486+ forked_at_ms: Some(current_time_ms()),
487+ };
488+ 
489+ let mut child_entry = build_backend(BuildBackendInput {
490+ backend_id: child_backend_id.clone(),
491+ config: create_config,
492+ workspace_root_text: request.checkpoint.workspace_root.clone(),
493+ config_hash: create_config_hash,
494+ session_id_for_instance: child_session_id
495+ .clone()
496+ .unwrap_or_else(|| child_backend_id.0.clone()),
497+ session_id: child_session_id.clone(),
498+ resource_limits: request.resource_limits,
499+ metadata,
500+ expires_at_ms,
501+ lineage,
502+ backend_checkpoint: Some(request.checkpoint.clone()),
503+ })
504+ .await?;
505+ child_entry.config = stored_config;
506+ child_entry.config_hash = stored_config_hash;
507+ 
508+ let child_info = child_entry.info();
509+ let mut child_entry = Some(child_entry);
510+ let insert_result = {
511+ let mut state = self.state.lock().await;
512+ if state.backends.contains_key(&child_backend_id) {
513+ Err(BackendError::Conflict {
514+ message: format!("backend_id {child_backend_id} already exists"),
515+ })
516+ } else if let Some(session_id) = child_session_id.as_ref() {
517+ if let Some(existing_backend_id) = state.session_index.get(session_id) {
518+ Err(BackendError::Conflict {
519+ message: format!(
520+ "session_id {session_id} is already bound to backend_id {existing_backend_id}"
521+ ),
522+ })
523+ } else {
524+ insert_checkout_child(
525+ &mut state,
526+ request.checkpoint.source_backend_id.as_deref(),
527+ &child_backend_id,
528+ child_session_id.clone(),
529+ child_entry.take().expect("child entry should be present"),
530+ )
531+ }
532+ } else {
533+ insert_checkout_child(
534+ &mut state,
535+ request.checkpoint.source_backend_id.as_deref(),
536+ &child_backend_id,
537+ None,
538+ child_entry.take().expect("child entry should be present"),
539+ )
540+ }
541+ };
542+ 
543+ if let Err(error) = insert_result {
544+ if let Some(entry) = child_entry {
545+ delete_backend_instance(entry, BackendLifecycleReason::UserRequested)
546+ .await
547+ .map_err(BackendError::from_operation_error)?;
548+ }
549+ return Err(error);
550+ }
551+ 
552+ Ok(BackendCheckoutResult {
553+ backend: child_info,
554+ checkpoint: request.checkpoint,
555+ })
556+ }
557+ 
558+ pub async fn fork_backend(
559+ &self,
560+ request: BackendForkRequest,
561+ ) -> Result<BackendForkResult, BackendError> {
562+ let checkpoint = self
563+ .checkpoint_backend(BackendCheckpointRequest {
564+ backend_id: request.parent_backend_id,
565+ session_id: request.parent_session_id,
566+ name: request.snapshot_name,
567+ metadata: Value::Null,
568+ })
569+ .await?;
570+ let checkout = self
571+ .checkout_backend(BackendCheckoutRequest {
572+ checkpoint: checkpoint.checkpoint.clone(),
573+ backend_id: request.backend_id,
574+ session_id: request.session_id,
575+ timeout: request.timeout,
576+ metadata: request.metadata,
577+ resource_limits: request.resource_limits,
578+ options: request.options,
579+ })
580+ .await?;
581+ let snapshot_id = checkpoint
582+ .checkpoint
583+ .provider_snapshot_id
584+ .clone()
585+ .unwrap_or_else(|| checkpoint.checkpoint.checkpoint_id.clone());
586+ 
587+ Ok(BackendForkResult {
588+ parent: checkpoint.backend,
589+ child: checkout.backend,
590+ snapshot_id,
591+ snapshot_names: checkpoint.checkpoint.provider_snapshot_names,
592+ })
593+ }
594+ 
595+ pub async fn ensure_session_backend(
596+ &self,
597+ request: BackendEnsureSessionRequest,
598+ ) -> Result<BackendLease, OperationBackendBuildError> {
599+ let config = resolve_session_backend_config(request.config.clone())?;
600+ let workspace_root = workspace_root_string(&request.workspace_root)?;
601+ let config_hash = hash_config(&config);
602+ let mut state = self.state.lock().await;
603+ 
604+ if let Some(existing_backend_id) = state.session_index.get(&request.session_id) {
605+ let entry = state.backends.get(existing_backend_id).ok_or_else(|| {
606+ OperationBackendBuildError::BuildFailed {
607+ message: format!(
608+ "session {} is bound to missing backend {}",
609+ request.session_id, existing_backend_id
610+ ),
611+ }
612+ })?;
613+ if entry.workspace_root != workspace_root || entry.config_hash != config_hash {
614+ return Err(OperationBackendBuildError::BuildFailed {
615+ message: format!(
616+ "session {} is already bound to backend {} with different workspace or config",
617+ request.session_id, existing_backend_id
618+ ),
619+ });
620+ }
621+ return Ok(BackendLease::new(
622+ Arc::clone(&entry.backend),
623+ entry.instance.clone(),
624+ ));
625+ }
626+ 
627+ let backend_id = new_backend_id();
628+ let entry = build_backend(BuildBackendInput {
629+ backend_id: backend_id.clone(),
630+ config,
631+ workspace_root_text: workspace_root,
632+ config_hash,
633+ session_id_for_instance: request.session_id.clone(),
634+ session_id: Some(request.session_id.clone()),
635+ resource_limits: BackendResourceLimits::default(),
636+ metadata: Value::Null,
637+ expires_at_ms: None,
638+ lineage: BackendLineageEntry::default(),
639+ backend_checkpoint: None,
640+ })
641+ .await
642+ .map_err(BackendError::into_build_error)?;
643+ let backend = Arc::clone(&entry.backend);
644+ let instance = entry.instance.clone();
645+ 
646+ state
647+ .session_index
648+ .insert(request.session_id, backend_id.clone());
649+ state.backends.insert(backend_id, entry);
650+ Ok(BackendLease::new(backend, instance))
651+ }
652+ 
653+ pub async fn lease_bound_session(
654+ &self,
655+ session_id: &str,
656+ ) -> Result<BackendLease, BackendError> {
657+ let state = self.state.lock().await;
658+ let backend_id =
659+ state
660+ .session_index
661+ .get(session_id)
662+ .ok_or_else(|| BackendError::NotFound {
663+ backend_id: format!("session:{session_id}"),
664+ })?;
665+ let entry = state
666+ .backends
667+ .get(backend_id)
668+ .ok_or_else(|| BackendError::NotFound {
669+ backend_id: backend_id.0.clone(),
670+ })?;
671+ Ok(BackendLease::new(
672+ Arc::clone(&entry.backend),
673+ entry.instance.clone(),
674+ ))
675+ }
676+ 
677+ pub async fn release_session(&self, session_id: &str) -> Result<(), OperationError> {
678+ let removed = {
679+ let mut state = self.state.lock().await;
680+ let Some(backend_id) = state.session_index.remove(session_id) else {
681+ return Ok(());
682+ };
683+ let Some(entry) = state.backends.get_mut(&backend_id) else {
684+ return Ok(());
685+ };
686+ entry.session_ids.remove(session_id);
687+ if entry.session_ids.is_empty() {
688+ let removed = state.backends.remove(&backend_id);
689+ if let Some(entry) = removed.as_ref() {
690+ detach_from_parent(&mut state, &backend_id, entry);
691+ }
692+ removed
693+ } else {
694+ None
695+ }
696+ };
697+ 
698+ if let Some(instance) = removed {
699+ delete_backend_instance(instance, BackendLifecycleReason::SessionClose).await?;
700+ }
701+ Ok(())
702+ }
703+ 
704+ pub async fn shutdown_all(&self) -> Result<(), OperationError> {
705+ let removed = {
706+ let mut state = self.state.lock().await;
707+ state.session_index.clear();
708+ state
709+ .backends
710+ .drain()
711+ .map(|(_, instance)| instance)
712+ .collect::<Vec<_>>()
713+ };
714+ 
715+ for instance in removed {
716+ delete_backend_instance(instance, BackendLifecycleReason::DaemonShutdown).await?;
717+ }
718+ Ok(())
719+ }
720+}
Aapps/shared/src/backend/base.rs+314-0
@@ -0,0 +1,314 @@
1+use agent_contracts::backend::{
2+ BackendControlError, BackendEndpoint, BackendInstance, BackendLifecycleState,
3+ BackendResourceAllocation, BackendResourceLimits, OperationBackend, OperationBackendBuildError,
4+ OperationError,
5+};
6+use serde::{Deserialize, Serialize};
7+use serde_json::Value;
8+use std::collections::BTreeMap;
9+use std::path::PathBuf;
10+use std::sync::Arc;
11+ 
12+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13+pub struct GatewayBackendConfig {
14+ pub kind: String,
15+ pub options: Value,
16+}
17+ 
18+impl GatewayBackendConfig {
19+ pub fn new(kind: impl Into<String>, options: Value) -> Self {
20+ Self {
21+ kind: kind.into(),
22+ options,
23+ }
24+ }
25+}
26+ 
27+#[derive(Debug, Clone)]
28+pub struct BackendEnsureSessionRequest {
29+ pub config: Option<GatewayBackendConfig>,
30+ pub workspace_root: PathBuf,
31+ pub session_id: String,
32+}
33+ 
34+#[derive(Debug, Clone, Serialize, Deserialize)]
35+pub struct BackendCreateRequest {
36+ pub workspace_root: PathBuf,
37+ #[serde(default)]
38+ pub backend_id: Option<String>,
39+ #[serde(default)]
40+ pub provider: Option<String>,
41+ #[serde(default)]
42+ pub session_id: Option<String>,
43+ #[serde(default)]
44+ pub timeout: Option<u64>,
45+ #[serde(default)]
46+ pub metadata: Value,
47+ #[serde(default)]
48+ pub resource_limits: BackendResourceLimits,
49+ #[serde(default)]
50+ pub options: Option<Value>,
51+}
52+ 
53+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
54+pub struct BackendConnectRequest {
55+ #[serde(default)]
56+ pub timeout: Option<u64>,
57+ #[serde(default)]
58+ pub session_id: Option<String>,
59+}
60+ 
61+#[derive(Debug, Clone, Default)]
62+pub struct BackendListFilter {
63+ pub metadata: BTreeMap<String, String>,
64+}
65+ 
66+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
67+pub struct BackendLineageInfo {
68+ #[serde(default)]
69+ pub parent_backend_id: Option<String>,
70+ #[serde(default)]
71+ pub children_backend_ids: Vec<String>,
72+ #[serde(default)]
73+ pub forked_from_snapshot_id: Option<String>,
74+ #[serde(default)]
75+ pub forked_snapshot_names: Vec<String>,
76+ #[serde(default)]
77+ pub forked_at_ms: Option<u64>,
78+}
79+ 
80+#[derive(Debug, Clone, Serialize, Deserialize)]
81+pub struct BackendInfo {
82+ pub backend_id: String,
83+ pub provider: String,
84+ pub instance_id: String,
85+ pub state: BackendLifecycleState,
86+ pub workspace_root: String,
87+ pub endpoint: Option<BackendEndpoint>,
88+ pub metadata: Value,
89+ pub resources: BackendResourceAllocation,
90+ pub session_id: Option<String>,
91+ #[serde(default)]
92+ pub session_ids: Vec<String>,
93+ pub expires_at_ms: Option<u64>,
94+ #[serde(default)]
95+ pub lineage: BackendLineageInfo,
96+}
97+ 
98+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
99+pub struct BackendForkRequest {
100+ #[serde(default)]
101+ pub parent_backend_id: Option<String>,
102+ #[serde(default)]
103+ pub parent_session_id: Option<String>,
104+ #[serde(default)]
105+ pub backend_id: Option<String>,
106+ #[serde(default)]
107+ pub session_id: Option<String>,
108+ #[serde(default)]
109+ pub timeout: Option<u64>,
110+ #[serde(default)]
111+ pub metadata: Value,
112+ #[serde(default)]
113+ pub resource_limits: BackendResourceLimits,
114+ #[serde(default)]
115+ pub options: Option<Value>,
116+ #[serde(default)]
117+ pub snapshot_name: Option<String>,
118+}
119+ 
120+#[derive(Debug, Clone, Serialize, Deserialize)]
121+pub struct BackendCheckpointRef {
122+ pub checkpoint_id: String,
123+ pub provider: String,
124+ #[serde(default)]
125+ pub source_backend_id: Option<String>,
126+ #[serde(default)]
127+ pub provider_snapshot_id: Option<String>,
128+ #[serde(default)]
129+ pub provider_snapshot_names: Vec<String>,
130+ pub workspace_root: String,
131+ #[serde(default)]
132+ pub name: Option<String>,
133+ #[serde(default)]
134+ pub metadata: Value,
135+ pub created_at_ms: u64,
136+ #[serde(default, skip_serializing)]
137+ pub provider_options: Value,
138+}
139+ 
140+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
141+pub struct BackendCheckpointRequest {
142+ #[serde(default)]
143+ pub backend_id: Option<String>,
144+ #[serde(default)]
145+ pub session_id: Option<String>,
146+ #[serde(default)]
147+ pub name: Option<String>,
148+ #[serde(default)]
149+ pub metadata: Value,
150+}
151+ 
152+#[derive(Debug, Clone, Serialize, Deserialize)]
153+pub struct BackendCheckpointResult {
154+ pub backend: BackendInfo,
155+ pub checkpoint: BackendCheckpointRef,
156+ pub reused: bool,
157+}
158+ 
159+#[derive(Debug, Clone, Serialize, Deserialize)]
160+pub struct BackendCheckpointSnapshotDeleteRequest {
161+ pub checkpoint: BackendCheckpointRef,
162+}
163+ 
164+#[derive(Debug, Clone, Serialize, Deserialize)]
165+pub struct BackendCheckpointSnapshotDeleteResult {
166+ pub checkpoint_id: String,
167+ pub provider: String,
168+ #[serde(default)]
169+ pub provider_snapshot_id: Option<String>,
170+ #[serde(default)]
171+ pub provider_snapshot_names: Vec<String>,
172+ pub deleted: bool,
173+}
174+ 
175+#[derive(Debug, Clone, Serialize, Deserialize)]
176+pub struct BackendCheckoutRequest {
177+ pub checkpoint: BackendCheckpointRef,
178+ #[serde(default)]
179+ pub backend_id: Option<String>,
180+ #[serde(default)]
181+ pub session_id: Option<String>,
182+ #[serde(default)]
183+ pub timeout: Option<u64>,
184+ #[serde(default)]
185+ pub metadata: Value,
186+ #[serde(default)]
187+ pub resource_limits: BackendResourceLimits,
188+ #[serde(default)]
189+ pub options: Option<Value>,
190+}
191+ 
192+#[derive(Debug, Clone, Serialize, Deserialize)]
193+pub struct BackendCheckoutResult {
194+ pub backend: BackendInfo,
195+ pub checkpoint: BackendCheckpointRef,
196+}
197+ 
198+#[derive(Debug, Clone, Serialize, Deserialize)]
199+pub struct BackendForkResult {
200+ pub parent: BackendInfo,
201+ pub child: BackendInfo,
202+ pub snapshot_id: String,
203+ #[serde(default)]
204+ pub snapshot_names: Vec<String>,
205+}
206+ 
207+#[derive(Debug, Clone, Serialize, Deserialize)]
208+pub struct BackendTreeNode {
209+ pub backend: BackendInfo,
210+ #[serde(default)]
211+ pub children: Vec<BackendTreeNode>,
212+}
213+ 
214+#[derive(Debug, thiserror::Error)]
215+pub enum BackendError {
216+ #[error("invalid managed backend request: {message}")]
217+ InvalidRequest { message: String },
218+ #[error("managed backend conflict: {message}")]
219+ Conflict { message: String },
220+ #[error("managed backend not found: {backend_id}")]
221+ NotFound { backend_id: String },
222+ #[error("unsupported backend kind: {kind}")]
223+ UnsupportedBackend { kind: String },
224+ #[error("managed backend build failed: {message}")]
225+ BuildFailed { message: String },
226+ #[error("managed backend operation failed: {message}")]
227+ Operation { message: String },
228+}
229+ 
230+impl BackendError {
231+ pub(super) fn from_build_error(error: OperationBackendBuildError) -> Self {
232+ match error {
233+ OperationBackendBuildError::InvalidConfig { message } => {
234+ Self::InvalidRequest { message }
235+ }
236+ OperationBackendBuildError::UnsupportedBackend { kind } => {
237+ Self::UnsupportedBackend { kind }
238+ }
239+ OperationBackendBuildError::Unsupported { message }
240+ | OperationBackendBuildError::BuildFailed { message } => Self::BuildFailed { message },
241+ }
242+ }
243+ 
244+ pub(super) fn from_control_error(error: BackendControlError) -> Self {
245+ match error {
246+ BackendControlError::InvalidRequest { message } => Self::InvalidRequest { message },
247+ BackendControlError::UnsupportedCapability {
248+ provider,
249+ capability,
250+ } => Self::UnsupportedBackend {
251+ kind: format!("{}:{capability}", provider.0),
252+ },
253+ BackendControlError::NotFound { id, .. } => Self::NotFound { backend_id: id },
254+ BackendControlError::InvalidState { message, .. } => Self::Conflict { message },
255+ BackendControlError::ProviderError { message, .. }
256+ | BackendControlError::Transport { message } => Self::BuildFailed { message },
257+ BackendControlError::Timeout {
258+ operation,
259+ timeout_ms,
260+ } => Self::BuildFailed {
261+ message: format!("{operation} timed out after {timeout_ms} ms"),
262+ },
263+ }
264+ }
265+ 
266+ pub(super) fn from_operation_error(error: OperationError) -> Self {
267+ Self::Operation {
268+ message: error.to_string(),
269+ }
270+ }
271+ 
272+ pub(super) fn into_build_error(self) -> OperationBackendBuildError {
273+ match self {
274+ Self::InvalidRequest { message } | Self::Conflict { message } => {
275+ OperationBackendBuildError::InvalidConfig { message }
276+ }
277+ Self::UnsupportedBackend { kind } => {
278+ OperationBackendBuildError::UnsupportedBackend { kind }
279+ }
280+ Self::NotFound { backend_id } => OperationBackendBuildError::BuildFailed {
281+ message: format!("managed backend not found: {backend_id}"),
282+ },
283+ Self::BuildFailed { message } | Self::Operation { message } => {
284+ OperationBackendBuildError::BuildFailed { message }
285+ }
286+ }
287+ }
288+ 
289+ pub(super) fn into_operation_error(self) -> OperationError {
290+ OperationError::Transport {
291+ message: self.to_string(),
292+ }
293+ }
294+}
295+ 
296+#[derive(Clone)]
297+pub struct BackendLease {
298+ backend: Arc<dyn OperationBackend>,
299+ instance: BackendInstance,
300+}
301+ 
302+impl BackendLease {
303+ pub(super) fn new(backend: Arc<dyn OperationBackend>, instance: BackendInstance) -> Self {
304+ Self { backend, instance }
305+ }
306+ 
307+ pub fn backend(&self) -> Arc<dyn OperationBackend> {
308+ Arc::clone(&self.backend)
309+ }
310+ 
311+ pub fn instance(&self) -> BackendInstance {
312+ self.instance.clone()
313+ }
314+}
Rapps/xiaoo-app/src/gateway/backend/conch/agent.rsapps/shared/src/backend/conch/agent.rs+5-5
@@ -1,8 +1,8 @@
1-use crate::gateway::backend::conch::backend::ConchBackendState;1+use crate::backend::conch::backend::ConchBackendState;
2-use crate::gateway::backend::conch::backend::ConchExecOutput;2+use crate::backend::conch::backend::ConchExecOutput;
3-use crate::gateway::backend::conch::backend::ConchSandboxHandle;3+use crate::backend::conch::backend::ConchSandboxHandle;
4-use crate::gateway::backend::conch::backend::ConchStartProcess;4+use crate::backend::conch::backend::ConchStartProcess;
5-use crate::gateway::backend::conch::backend::ConchUploadFile;5+use crate::backend::conch::backend::ConchUploadFile;
6use agent_contracts::backend::OperationError;6use agent_contracts::backend::OperationError;
7use tonic::transport::{Channel, Endpoint};7use tonic::transport::{Channel, Endpoint};
8 8 
Rapps/xiaoo-app/src/gateway/backend/conch/backend.rsapps/shared/src/backend/conch/backend.rs+2-2
@@ -1,4 +1,4 @@
1-use crate::gateway::backend::conch::{1+use crate::backend::conch::{
2 exec::ConchExec, filesystem::ConchFileSystem, path::ConchPathResolver, search::ConchSearch,2 exec::ConchExec, filesystem::ConchFileSystem, path::ConchPathResolver, search::ConchSearch,
3};3};
4use agent_contracts::backend::{4use agent_contracts::backend::{
@@ -252,7 +252,7 @@ impl OperationBackend for ConchOperationBackend {
252 if !self.exec.state().begin_shutdown()? {252 if !self.exec.state().begin_shutdown()? {
253 return Ok(());253 return Ok(());
254 }254 }
255- match crate::gateway::backend::conch::control::delete_sandbox(self.exec.state()).await {255+ match crate::backend::conch::control::delete_sandbox(self.exec.state()).await {
256 Ok(()) => {256 Ok(()) => {
257 self.exec.state().finish_shutdown()?;257 self.exec.state().finish_shutdown()?;
258 Ok(())258 Ok(())
Rapps/xiaoo-app/src/gateway/backend/conch/control.rsapps/shared/src/backend/conch/control.rs+1-1
@@ -1,4 +1,4 @@
1-use crate::gateway::backend::conch::backend::{1+use crate::backend::conch::backend::{
2 ConchBackendState, ConchControlTransport, ConchSandboxHandle,2 ConchBackendState, ConchControlTransport, ConchSandboxHandle,
3};3};
4use agent_contracts::backend::{OperationBackendBuildError, OperationError};4use agent_contracts::backend::{OperationBackendBuildError, OperationError};
Rapps/xiaoo-app/src/gateway/backend/conch/exec.rsapps/shared/src/backend/conch/exec.rs+4-4
@@ -1,6 +1,6 @@
1-use crate::gateway::backend::conch::agent;1+use crate::backend::conch::agent;
2-use crate::gateway::backend::conch::backend::shell_quote;2+use crate::backend::conch::backend::shell_quote;
3-use crate::gateway::backend::conch::backend::{3+use crate::backend::conch::backend::{
4 ConchBackendState, ConchExecOutput, ConchStartProcess,4 ConchBackendState, ConchExecOutput, ConchStartProcess,
5};5};
6use agent_contracts::backend::{6use agent_contracts::backend::{
@@ -171,7 +171,7 @@ impl OperationExec for ConchExec {
171#[cfg(test)]171#[cfg(test)]
172mod tests {172mod tests {
173 use super::*;173 use super::*;
174- use crate::gateway::backend::conch::backend::{174+ use crate::backend::conch::backend::{
175 ConchControlPlane, ConchControlTransport, ConchLifecycle, ConchSandboxHandle,175 ConchControlPlane, ConchControlTransport, ConchLifecycle, ConchSandboxHandle,
176 };176 };
177 use agent_contracts::backend::BackendPath;177 use agent_contracts::backend::BackendPath;
Rapps/xiaoo-app/src/gateway/backend/conch/factory.rsapps/shared/src/backend/conch/factory.rs+3-3
@@ -1,9 +1,9 @@
1-use crate::gateway::backend::conch::agent;1+use crate::backend::conch::agent;
2-use crate::gateway::backend::conch::backend::{2+use crate::backend::conch::backend::{
3 normalize_backend_path, ConchBackendState, ConchControlPlane, ConchControlTransport,3 normalize_backend_path, ConchBackendState, ConchControlPlane, ConchControlTransport,
4 ConchLifecycle, ConchOperationBackend, ConchSandboxHandle,4 ConchLifecycle, ConchOperationBackend, ConchSandboxHandle,
5};5};
6-use crate::gateway::backend::conch::control::{self, ConchCreateOptions};6+use crate::backend::conch::control::{self, ConchCreateOptions};
7use agent_contracts::backend::{7use agent_contracts::backend::{
8 BackendPath, OperationBackend, OperationBackendBuildError, OperationBackendConfig,8 BackendPath, OperationBackend, OperationBackendBuildError, OperationBackendConfig,
9};9};
Rapps/xiaoo-app/src/gateway/backend/conch/filesystem.rsapps/shared/src/backend/conch/filesystem.rs+3-3
@@ -1,6 +1,6 @@
1-use crate::gateway::backend::conch::agent;1+use crate::backend::conch::agent;
2-use crate::gateway::backend::conch::backend::{shell_quote, ConchBackendState, ConchUploadFile};2+use crate::backend::conch::backend::{shell_quote, ConchBackendState, ConchUploadFile};
3-use crate::gateway::backend::conch::exec::ConchExec;3+use crate::backend::conch::exec::ConchExec;
4use agent_contracts::backend::{4use agent_contracts::backend::{
5 capability::{5 capability::{
6 export::ExportFileRequest,6 export::ExportFileRequest,
Rapps/xiaoo-app/src/gateway/backend/conch/mod.rsapps/shared/src/backend/conch/mod.rs+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/gateway/backend/conch/path.rsapps/shared/src/backend/conch/path.rs+1-1
@@ -1,4 +1,4 @@
1-use crate::gateway::backend::conch::backend::ConchBackendState;1+use crate::backend::conch::backend::ConchBackendState;
2use agent_contracts::backend::{2use agent_contracts::backend::{
3 capability::{path::ResolveBase, path::ResolvePathRequest, OperationPathResolver},3 capability::{path::ResolveBase, path::ResolvePathRequest, OperationPathResolver},
4 BackendPath, OperationError,4 BackendPath, OperationError,
Rapps/xiaoo-app/src/gateway/backend/conch/proto/agent.protoapps/shared/src/backend/conch/proto/agent.proto+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/gateway/backend/conch/search.rsapps/shared/src/backend/conch/search.rs+2-2
@@ -1,5 +1,5 @@
1-use crate::gateway::backend::conch::backend::{shell_quote, ConchBackendState};1+use crate::backend::conch::backend::{shell_quote, ConchBackendState};
2-use crate::gateway::backend::conch::exec::ConchExec;2+use crate::backend::conch::exec::ConchExec;
3use agent_contracts::backend::{3use agent_contracts::backend::{
4 capability::{4 capability::{
5 search::{GlobRequest, GrepMode, GrepRequest, GrepResult},5 search::{GlobRequest, GrepMode, GrepRequest, GrepResult},
Aapps/shared/src/backend/dirty_write.rs+179-0
@@ -0,0 +1,179 @@
1+use agent_contracts::backend::capability::exec::{ExecRequest, ExecResult, OperationExec};
2+use agent_contracts::backend::capability::filesystem::{
3+ OperationFileSystem, ReadBytesRequest, TempPathRequest, WriteBytesOutcome, WriteBytesRequest,
4+};
5+use agent_contracts::backend::capability::path::OperationPathResolver;
6+use agent_contracts::backend::capability::search::OperationSearch;
7+use agent_contracts::backend::{
8+ BackendPath, OperationBackend, OperationBackendCapabilities, OperationError,
9+ OperationPermissionControl, PathStat,
10+};
11+use async_trait::async_trait;
12+use std::sync::{Arc, Mutex};
13+ 
14+use super::BackendCheckpointRef;
15+ 
16+#[derive(Default)]
17+pub(super) struct BackendDirtyTracker {
18+ state: Mutex<BackendDirtyState>,
19+}
20+ 
21+#[derive(Default)]
22+struct BackendDirtyState {
23+ dirty: bool,
24+ checkpoint: Option<BackendCheckpointRef>,
25+}
26+ 
27+impl BackendDirtyTracker {
28+ pub(super) fn mark_dirty(&self) {
29+ if let Ok(mut state) = self.state.lock() {
30+ state.dirty = true;
31+ }
32+ }
33+ 
34+ pub(super) fn is_dirty(&self) -> bool {
35+ self.state.lock().map(|state| state.dirty).unwrap_or(true)
36+ }
37+ 
38+ pub(super) fn checkpoint(&self) -> Option<BackendCheckpointRef> {
39+ self.state
40+ .lock()
41+ .ok()
42+ .and_then(|state| state.checkpoint.clone())
43+ }
44+ 
45+ pub(super) fn set_checkpoint(&self, checkpoint: BackendCheckpointRef) {
46+ if let Ok(mut state) = self.state.lock() {
47+ state.checkpoint = Some(checkpoint);
48+ state.dirty = false;
49+ }
50+ }
51+ 
52+ pub(super) fn clear_checkpoint_if_matches(&self, checkpoint_id: &str) {
53+ if let Ok(mut state) = self.state.lock() {
54+ if state
55+ .checkpoint
56+ .as_ref()
57+ .is_some_and(|checkpoint| checkpoint.checkpoint_id == checkpoint_id)
58+ {
59+ state.checkpoint = None;
60+ state.dirty = true;
61+ }
62+ }
63+ }
64+}
65+ 
66+pub(super) struct DirtyTrackedOperationBackend {
67+ inner: Arc<dyn OperationBackend>,
68+ files: DirtyTrackedFileSystem,
69+ exec: DirtyTrackedExec,
70+}
71+ 
72+impl DirtyTrackedOperationBackend {
73+ pub(super) fn wrap(
74+ inner: Arc<dyn OperationBackend>,
75+ tracker: Arc<BackendDirtyTracker>,
76+ ) -> Arc<dyn OperationBackend> {
77+ Arc::new(Self {
78+ files: DirtyTrackedFileSystem {
79+ inner: Arc::clone(&inner),
80+ tracker: Arc::clone(&tracker),
81+ },
82+ exec: DirtyTrackedExec {
83+ inner: Arc::clone(&inner),
84+ tracker,
85+ },
86+ inner,
87+ })
88+ }
89+}
90+ 
91+#[async_trait]
92+impl OperationBackend for DirtyTrackedOperationBackend {
93+ fn backend_id(&self) -> &str {
94+ self.inner.backend_id()
95+ }
96+ 
97+ fn capabilities(&self) -> OperationBackendCapabilities {
98+ self.inner.capabilities()
99+ }
100+ 
101+ fn paths(&self) -> &dyn OperationPathResolver {
102+ self.inner.paths()
103+ }
104+ 
105+ fn files(&self) -> &dyn OperationFileSystem {
106+ &self.files
107+ }
108+ 
109+ fn search(&self) -> &dyn OperationSearch {
110+ self.inner.search()
111+ }
112+ 
113+ fn exec(&self) -> &dyn OperationExec {
114+ &self.exec
115+ }
116+ 
117+ fn export(&self) -> &dyn agent_contracts::backend::capability::export::OperationExport {
118+ self.inner.export()
119+ }
120+ 
121+ fn permission_control(&self) -> Option<&dyn OperationPermissionControl> {
122+ self.inner.permission_control()
123+ }
124+ 
125+ async fn shutdown(&self) -> Result<(), OperationError> {
126+ self.inner.shutdown().await
127+ }
128+}
129+ 
130+struct DirtyTrackedFileSystem {
131+ inner: Arc<dyn OperationBackend>,
132+ tracker: Arc<BackendDirtyTracker>,
133+}
134+ 
135+#[async_trait]
136+impl OperationFileSystem for DirtyTrackedFileSystem {
137+ async fn stat(&self, path: &BackendPath) -> Result<PathStat, OperationError> {
138+ self.inner.files().stat(path).await
139+ }
140+ 
141+ async fn read_bytes(&self, request: ReadBytesRequest) -> Result<Vec<u8>, OperationError> {
142+ self.inner.files().read_bytes(request).await
143+ }
144+ 
145+ async fn write_bytes(
146+ &self,
147+ request: WriteBytesRequest,
148+ ) -> Result<WriteBytesOutcome, OperationError> {
149+ let outcome = self.inner.files().write_bytes(request).await?;
150+ self.tracker.mark_dirty();
151+ Ok(outcome)
152+ }
153+ 
154+ async fn create_dir_all(&self, path: &BackendPath) -> Result<(), OperationError> {
155+ self.inner.files().create_dir_all(path).await?;
156+ self.tracker.mark_dirty();
157+ Ok(())
158+ }
159+ 
160+ async fn temp_path(&self, request: TempPathRequest) -> Result<BackendPath, OperationError> {
161+ let path = self.inner.files().temp_path(request).await?;
162+ self.tracker.mark_dirty();
163+ Ok(path)
164+ }
165+}
166+ 
167+struct DirtyTrackedExec {
168+ inner: Arc<dyn OperationBackend>,
169+ tracker: Arc<BackendDirtyTracker>,
170+}
171+ 
172+#[async_trait]
173+impl OperationExec for DirtyTrackedExec {
174+ async fn exec(&self, request: ExecRequest) -> Result<ExecResult, OperationError> {
175+ let result = self.inner.exec().exec(request).await?;
176+ self.tracker.mark_dirty();
177+ Ok(result)
178+ }
179+}
Aapps/shared/src/backend/e2b/backend.rs+364-0
@@ -0,0 +1,364 @@
1+use agent_contracts::backend::{
2+ capability::{
3+ OperationExec, OperationExport, OperationFileSystem, OperationPathResolver, OperationSearch,
4+ },
5+ BackendPath, OperationBackend, OperationBackendCapabilities, OperationError,
6+};
7+use async_trait::async_trait;
8+use base64::Engine;
9+use reqwest::header::{HeaderValue, ACCEPT, AUTHORIZATION, CONTENT_TYPE};
10+use reqwest::{Method, StatusCode};
11+use serde::Deserialize;
12+use serde_json::Value;
13+use std::path::{Component, Path, PathBuf};
14+use std::sync::{Arc, Mutex};
15+ 
16+use super::exec::E2bExec;
17+use super::filesystem::E2bFileSystem;
18+use super::path::E2bPathResolver;
19+use super::search::E2bSearch;
20+ 
21+pub(crate) const E2B_PROVIDER_KIND: &str = "e2b";
22+pub(crate) const DEFAULT_API_BASE: &str = "https://api.e2b.app";
23+pub(crate) const DEFAULT_TEMPLATE_ID: &str = "base";
24+pub(crate) const DEFAULT_ENVD_PORT: u16 = 49983;
25+pub(crate) const DEFAULT_WORKSPACE_ROOT: &str = "/home/user/workspace";
26+pub(crate) const DEFAULT_HOME_DIR: &str = "/home/user";
27+pub(crate) const DEFAULT_TEMP_ROOT: &str = "/tmp";
28+pub(crate) const DEFAULT_SHELL: &str = "/bin/sh";
29+pub(crate) const DEFAULT_TIMEOUT_SECS: u64 = 3600;
30+ 
31+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32+pub(crate) enum E2bLifecycle {
33+ Active,
34+ ShuttingDown,
35+ Closed,
36+}
37+ 
38+pub(crate) struct E2bBackendState {
39+ pub(crate) backend_id: String,
40+ pub(crate) api_base: String,
41+ pub(crate) api_key: String,
42+ pub(crate) sandbox_id: String,
43+ pub(crate) envd_access_token: Option<String>,
44+ pub(crate) envd_port: u16,
45+ pub(crate) envd_scheme: String,
46+ pub(crate) workspace_root: BackendPath,
47+ pub(crate) home_dir: Option<BackendPath>,
48+ pub(crate) temp_root: BackendPath,
49+ pub(crate) default_shell: Option<String>,
50+ pub(crate) username: Option<String>,
51+ pub(crate) http: reqwest::Client,
52+ pub(crate) lifecycle: Mutex<E2bLifecycle>,
53+}
54+ 
55+pub struct E2bOperationBackend {
56+ backend_id: String,
57+ capabilities: OperationBackendCapabilities,
58+ state: Arc<E2bBackendState>,
59+ paths: E2bPathResolver,
60+ files: E2bFileSystem,
61+ search: E2bSearch,
62+ exec: E2bExec,
63+}
64+ 
65+impl E2bOperationBackend {
66+ pub(crate) fn new(state: Arc<E2bBackendState>) -> Self {
67+ Self {
68+ backend_id: state.backend_id.clone(),
69+ capabilities: OperationBackendCapabilities {
70+ supports_atomic_write: false,
71+ supports_grep: true,
72+ supports_export_file: true,
73+ supports_lsp: false,
74+ },
75+ paths: E2bPathResolver::new(Arc::clone(&state)),
76+ files: E2bFileSystem::new(Arc::clone(&state)),
77+ search: E2bSearch::new(Arc::clone(&state)),
78+ exec: E2bExec::new(state.clone()),
79+ state,
80+ }
81+ }
82+}
83+ 
84+impl E2bBackendState {
85+ pub(crate) fn ensure_active(&self) -> Result<(), OperationError> {
86+ let lifecycle = self
87+ .lifecycle
88+ .lock()
89+ .map_err(|_| OperationError::Transport {
90+ message: "e2b backend state lock poisoned".to_string(),
91+ })?;
92+ match *lifecycle {
93+ E2bLifecycle::Active => Ok(()),
94+ E2bLifecycle::ShuttingDown => Err(OperationError::Transport {
95+ message: format!("e2b backend {} is shutting down", self.backend_id),
96+ }),
97+ E2bLifecycle::Closed => Err(OperationError::Transport {
98+ message: format!("e2b backend {} is already closed", self.backend_id),
99+ }),
100+ }
101+ }
102+ 
103+ fn begin_shutdown(&self) -> Result<bool, OperationError> {
104+ let mut lifecycle = self
105+ .lifecycle
106+ .lock()
107+ .map_err(|_| OperationError::Transport {
108+ message: "e2b backend state lock poisoned".to_string(),
109+ })?;
110+ match *lifecycle {
111+ E2bLifecycle::Active => {
112+ *lifecycle = E2bLifecycle::ShuttingDown;
113+ Ok(true)
114+ }
115+ E2bLifecycle::ShuttingDown | E2bLifecycle::Closed => Ok(false),
116+ }
117+ }
118+ 
119+ fn finish_shutdown(&self) -> Result<(), OperationError> {
120+ let mut lifecycle = self
121+ .lifecycle
122+ .lock()
123+ .map_err(|_| OperationError::Transport {
124+ message: "e2b backend state lock poisoned".to_string(),
125+ })?;
126+ *lifecycle = E2bLifecycle::Closed;
127+ Ok(())
128+ }
129+ 
130+ fn abort_shutdown(&self) -> Result<(), OperationError> {
131+ let mut lifecycle = self
132+ .lifecycle
133+ .lock()
134+ .map_err(|_| OperationError::Transport {
135+ message: "e2b backend state lock poisoned".to_string(),
136+ })?;
137+ if *lifecycle == E2bLifecycle::ShuttingDown {
138+ *lifecycle = E2bLifecycle::Active;
139+ }
140+ Ok(())
141+ }
142+ 
143+ pub(crate) fn resolve_backend_path(
144+ &self,
145+ raw_path: &str,
146+ base: &BackendPath,
147+ ) -> Result<BackendPath, OperationError> {
148+ if raw_path == "~" || raw_path.starts_with("~/") {
149+ let home_dir = self
150+ .home_dir
151+ .as_ref()
152+ .ok_or_else(|| OperationError::Unsupported {
153+ message: "home_dir is not configured".to_string(),
154+ })?;
155+ let suffix = raw_path.strip_prefix("~/").unwrap_or_default();
156+ return normalize_backend_path(Path::new(home_dir.0.as_str()).join(suffix).as_path());
157+ }
158+ 
159+ let candidate = Path::new(raw_path);
160+ if candidate.is_absolute() {
161+ return normalize_backend_path(candidate);
162+ }
163+ normalize_backend_path(Path::new(base.0.as_str()).join(candidate).as_path())
164+ }
165+ 
166+ pub(crate) fn envd_url(&self, path: &str) -> String {
167+ let path = path.trim_start_matches('/');
168+ format!(
169+ "{}://{}-{}.e2b.app/{}",
170+ self.envd_scheme, self.envd_port, self.sandbox_id, path
171+ )
172+ }
173+ 
174+ pub(crate) fn platform_url(&self, path: &str) -> String {
175+ join_url(self.api_base.as_str(), path)
176+ }
177+ 
178+ pub(crate) fn envd_request(&self, method: Method, path: &str) -> reqwest::RequestBuilder {
179+ let mut request = self.http.request(method, self.envd_url(path));
180+ if let Some(token) = self
181+ .envd_access_token
182+ .as_deref()
183+ .filter(|token| !token.trim().is_empty())
184+ {
185+ request = request.header("X-Access-Token", token);
186+ }
187+ if let Some(username) = self.username.as_deref().filter(|name| !name.is_empty()) {
188+ let encoded = base64::engine::general_purpose::STANDARD.encode(format!("{username}:"));
189+ if let Ok(value) = HeaderValue::from_str(format!("Basic {encoded}").as_str()) {
190+ request = request.header(AUTHORIZATION, value);
191+ }
192+ }
193+ request
194+ }
195+ 
196+ pub(crate) async fn delete_sandbox(&self) -> Result<(), OperationError> {
197+ let response = self
198+ .http
199+ .delete(self.platform_url(format!("/sandboxes/{}", self.sandbox_id).as_str()))
200+ .header("X-API-Key", self.api_key.as_str())
201+ .send()
202+ .await
203+ .map_err(|error| OperationError::Transport {
204+ message: format!("failed to call e2b delete sandbox: {error}"),
205+ })?;
206+ 
207+ if response.status() == StatusCode::NO_CONTENT || response.status() == StatusCode::NOT_FOUND
208+ {
209+ return Ok(());
210+ }
211+ 
212+ Err(http_error("delete e2b sandbox", response).await)
213+ }
214+}
215+ 
216+#[async_trait]
217+impl OperationBackend for E2bOperationBackend {
218+ fn backend_id(&self) -> &str {
219+ self.backend_id.as_str()
220+ }
221+ 
222+ fn capabilities(&self) -> OperationBackendCapabilities {
223+ self.capabilities
224+ }
225+ 
226+ fn paths(&self) -> &dyn OperationPathResolver {
227+ &self.paths as &dyn OperationPathResolver
228+ }
229+ 
230+ fn files(&self) -> &dyn OperationFileSystem {
231+ &self.files as &dyn OperationFileSystem
232+ }
233+ 
234+ fn search(&self) -> &dyn OperationSearch {
235+ &self.search as &dyn OperationSearch
236+ }
237+ 
238+ fn exec(&self) -> &dyn OperationExec {
239+ &self.exec as &dyn OperationExec
240+ }
241+ 
242+ fn export(&self) -> &dyn OperationExport {
243+ &self.files as &dyn OperationExport
244+ }
245+ 
246+ async fn shutdown(&self) -> Result<(), OperationError> {
247+ if !self.state.begin_shutdown()? {
248+ return Ok(());
249+ }
250+ match self.state.delete_sandbox().await {
251+ Ok(()) => {
252+ self.state.finish_shutdown()?;
253+ Ok(())
254+ }
255+ Err(error) => {
256+ self.state.abort_shutdown()?;
257+ Err(error)
258+ }
259+ }
260+ }
261+}
262+ 
263+pub(crate) fn normalize_backend_path(path: &Path) -> Result<BackendPath, OperationError> {
264+ if !path.is_absolute() {
265+ return Err(OperationError::InvalidPath {
266+ message: format!("path must be absolute: {}", path.display()),
267+ });
268+ }
269+ 
270+ let mut normalized = PathBuf::new();
271+ for component in path.components() {
272+ match component {
273+ Component::RootDir => normalized.push(Path::new("/")),
274+ Component::CurDir => {}
275+ Component::ParentDir => {
276+ if !normalized.pop() {
277+ return Err(OperationError::InvalidPath {
278+ message: format!("path escapes root: {}", path.display()),
279+ });
280+ }
281+ }
282+ Component::Normal(part) => normalized.push(part),
283+ Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
284+ }
285+ }
286+ 
287+ let text = normalized
288+ .to_str()
289+ .ok_or_else(|| OperationError::InvalidPath {
290+ message: format!("path is not valid utf-8: {}", normalized.display()),
291+ })?;
292+ Ok(BackendPath(text.to_string()))
293+}
294+ 
295+pub(crate) fn shell_quote(value: &str) -> String {
296+ if value.is_empty() {
297+ return "''".to_string();
298+ }
299+ format!("'{}'", value.replace('\'', "'\\''"))
300+}
301+ 
302+pub(crate) fn join_url(base: &str, path: &str) -> String {
303+ format!(
304+ "{}/{}",
305+ base.trim_end_matches('/'),
306+ path.trim_start_matches('/')
307+ )
308+}
309+ 
310+pub(crate) async fn http_error(context: &str, response: reqwest::Response) -> OperationError {
311+ let status = response.status();
312+ let text = response.text().await.unwrap_or_default();
313+ let message = parse_error_message(text.as_str()).unwrap_or(text);
314+ match status {
315+ StatusCode::NOT_FOUND => OperationError::NotFound { path: message },
316+ StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
317+ OperationError::PermissionDenied { path: message }
318+ }
319+ _ => OperationError::Transport {
320+ message: format!("{context} failed with HTTP {status}: {message}"),
321+ },
322+ }
323+}
324+ 
325+pub(crate) fn parse_error_message(text: &str) -> Option<String> {
326+ serde_json::from_str::<E2bError>(text)
327+ .ok()
328+ .map(|error| error.message)
329+ .filter(|message| !message.is_empty())
330+}
331+ 
332+pub(crate) async fn connect_json<T: for<'de> Deserialize<'de>>(
333+ state: &E2bBackendState,
334+ path: &str,
335+ body: Value,
336+) -> Result<T, OperationError> {
337+ let response = state
338+ .envd_request(Method::POST, path)
339+ .header("Connect-Protocol-Version", "1")
340+ .header(CONTENT_TYPE, "application/json")
341+ .header(ACCEPT, "application/json")
342+ .json(&body)
343+ .send()
344+ .await
345+ .map_err(|error| OperationError::Transport {
346+ message: format!("failed to call e2b envd {path}: {error}"),
347+ })?;
348+ 
349+ if !response.status().is_success() {
350+ return Err(http_error(path, response).await);
351+ }
352+ 
353+ response
354+ .json::<T>()
355+ .await
356+ .map_err(|error| OperationError::Transport {
357+ message: format!("failed to decode e2b envd {path} response: {error}"),
358+ })
359+}
360+ 
361+#[derive(Debug, Deserialize)]
362+struct E2bError {
363+ message: String,
364+}
Aapps/shared/src/backend/e2b/exec.rs+401-0
@@ -0,0 +1,401 @@
1+use agent_contracts::backend::{
2+ capability::{exec::ExecRequest, exec::ExecResult, OperationExec},
3+ OperationError,
4+};
5+use async_trait::async_trait;
6+use base64::Engine;
7+use reqwest::header::{ACCEPT, CONTENT_TYPE};
8+use reqwest::Method;
9+use serde::Deserialize;
10+use serde_json::{json, Value};
11+use std::collections::HashMap;
12+use std::sync::Arc;
13+ 
14+use super::backend::{http_error, E2bBackendState, DEFAULT_SHELL};
15+ 
16+pub(crate) struct E2bExec {
17+ state: Arc<E2bBackendState>,
18+}
19+ 
20+impl E2bExec {
21+ pub(crate) fn new(state: Arc<E2bBackendState>) -> Self {
22+ Self { state }
23+ }
24+ 
25+ pub(crate) fn state(&self) -> &Arc<E2bBackendState> {
26+ &self.state
27+ }
28+ 
29+ pub(crate) async fn run_shell_script(
30+ &self,
31+ script: &str,
32+ cwd: Option<&str>,
33+ ) -> Result<E2bExecOutput, OperationError> {
34+ let shell = self
35+ .state
36+ .default_shell
37+ .as_deref()
38+ .unwrap_or(DEFAULT_SHELL)
39+ .to_string();
40+ start_process(
41+ &self.state,
42+ E2bStartProcess {
43+ cmd: shell,
44+ args: vec!["-c".to_string(), script.to_string()],
45+ env: HashMap::new(),
46+ cwd: cwd.map(str::to_string),
47+ connect_timeout_ms: None,
48+ },
49+ )
50+ .await
51+ }
52+ 
53+ fn timeout_arg(timeout_ms: u64) -> String {
54+ let seconds = timeout_ms / 1000;
55+ let millis = timeout_ms % 1000;
56+ if millis == 0 {
57+ format!("{seconds}s")
58+ } else {
59+ format!("{seconds}.{millis:03}s")
60+ }
61+ }
62+ 
63+ fn connect_timeout_ms(timeout_ms: Option<u64>) -> Option<u64> {
64+ timeout_ms.map(|value| value.saturating_add(10_000))
65+ }
66+}
67+ 
68+pub(crate) struct E2bStartProcess {
69+ pub(crate) cmd: String,
70+ pub(crate) args: Vec<String>,
71+ pub(crate) env: HashMap<String, String>,
72+ pub(crate) cwd: Option<String>,
73+ pub(crate) connect_timeout_ms: Option<u64>,
74+}
75+ 
76+pub(crate) struct E2bExecOutput {
77+ pub(crate) stdout: Vec<u8>,
78+ pub(crate) stderr: Vec<u8>,
79+ pub(crate) exit_code: Option<i32>,
80+ pub(crate) timed_out: bool,
81+}
82+ 
83+#[async_trait]
84+impl OperationExec for E2bExec {
85+ async fn exec(&self, request: ExecRequest) -> Result<ExecResult, OperationError> {
86+ self.state.ensure_active()?;
87+ if request.command.trim().is_empty() {
88+ return Err(OperationError::ExecutionFailed {
89+ message: "command cannot be empty".to_string(),
90+ });
91+ }
92+ 
93+ let timeout_ms = request.timeout_ms;
94+ let env: HashMap<String, String> = request
95+ .env
96+ .as_ref()
97+ .map(|pairs| pairs.iter().cloned().collect())
98+ .unwrap_or_default();
99+ 
100+ let (cmd, args) = if let Some(shell) = request
101+ .shell
102+ .clone()
103+ .or_else(|| self.state.default_shell.clone())
104+ {
105+ if !request.args.is_empty() {
106+ return Err(OperationError::Unsupported {
107+ message: "shell execution does not support args".to_string(),
108+ });
109+ }
110+ if let Some(timeout_ms) = timeout_ms {
111+ (
112+ "timeout".to_string(),
113+ vec![
114+ "--signal=TERM".to_string(),
115+ Self::timeout_arg(timeout_ms),
116+ shell,
117+ "-c".to_string(),
118+ request.command,
119+ ],
120+ )
121+ } else {
122+ (shell, vec!["-c".to_string(), request.command])
123+ }
124+ } else if let Some(timeout_ms) = timeout_ms {
125+ let mut args = vec![
126+ "--signal=TERM".to_string(),
127+ Self::timeout_arg(timeout_ms),
128+ request.command,
129+ ];
130+ args.extend(request.args);
131+ ("timeout".to_string(), args)
132+ } else {
133+ (request.command, request.args)
134+ };
135+ 
136+ let output = start_process(
137+ &self.state,
138+ E2bStartProcess {
139+ cmd,
140+ args,
141+ env,
142+ cwd: request.cwd.map(|path| path.0),
143+ connect_timeout_ms: Self::connect_timeout_ms(timeout_ms),
144+ },
145+ )
146+ .await?;
147+ let timed_out = timeout_ms.is_some() && output.exit_code == Some(124);
148+ 
149+ Ok(ExecResult {
150+ stdout: output.stdout,
151+ stderr: output.stderr,
152+ exit_code: output.exit_code,
153+ timed_out: output.timed_out || timed_out,
154+ })
155+ }
156+}
157+ 
158+pub(crate) async fn start_process(
159+ state: &E2bBackendState,
160+ request: E2bStartProcess,
161+) -> Result<E2bExecOutput, OperationError> {
162+ state.ensure_active()?;
163+ let body = json!({
164+ "process": {
165+ "cmd": request.cmd,
166+ "args": request.args,
167+ "envs": request.env,
168+ "cwd": request.cwd,
169+ },
170+ "pty": null,
171+ "stdin": false,
172+ });
173+ 
174+ let mut builder = state
175+ .envd_request(Method::POST, "/process.Process/Start")
176+ .header("Connect-Protocol-Version", "1")
177+ .header(CONTENT_TYPE, "application/connect+json")
178+ .header(ACCEPT, "application/connect+json")
179+ .body(connect_envelope(body.to_string().as_bytes()));
180+ if let Some(timeout_ms) = request.connect_timeout_ms {
181+ builder = builder.header("Connect-Timeout-Ms", timeout_ms.to_string());
182+ }
183+ 
184+ let response = builder
185+ .send()
186+ .await
187+ .map_err(|error| OperationError::Transport {
188+ message: format!("failed to start e2b process: {error}"),
189+ })?;
190+ 
191+ if !response.status().is_success() {
192+ return Err(http_error("start e2b process", response).await);
193+ }
194+ 
195+ let bytes = response
196+ .bytes()
197+ .await
198+ .map_err(|error| OperationError::Transport {
199+ message: format!("failed to read e2b process stream: {error}"),
200+ })?;
201+ parse_start_stream(bytes.as_ref())
202+}
203+ 
204+fn parse_start_stream(bytes: &[u8]) -> Result<E2bExecOutput, OperationError> {
205+ if let Ok(response) = serde_json::from_slice::<StartResponse>(bytes) {
206+ return collect_start_response([response]);
207+ }
208+ 
209+ let mut offset = 0usize;
210+ let mut responses = Vec::new();
211+ while offset < bytes.len() {
212+ if bytes.len().saturating_sub(offset) < 5 {
213+ return Err(OperationError::Transport {
214+ message: "malformed e2b process stream frame header".to_string(),
215+ });
216+ }
217+ 
218+ let flags = bytes[offset];
219+ let len = u32::from_be_bytes([
220+ bytes[offset + 1],
221+ bytes[offset + 2],
222+ bytes[offset + 3],
223+ bytes[offset + 4],
224+ ]) as usize;
225+ offset += 5;
226+ if bytes.len().saturating_sub(offset) < len {
227+ return Err(OperationError::Transport {
228+ message: "malformed e2b process stream frame length".to_string(),
229+ });
230+ }
231+ 
232+ let payload = &bytes[offset..offset + len];
233+ offset += len;
234+ 
235+ if flags & 0x02 != 0 {
236+ parse_end_stream_payload(payload)?;
237+ continue;
238+ }
239+ if flags & 0x01 != 0 {
240+ return Err(OperationError::Unsupported {
241+ message: "compressed e2b process stream frames are not supported".to_string(),
242+ });
243+ }
244+ if payload.is_empty() {
245+ continue;
246+ }
247+ responses.push(
248+ serde_json::from_slice::<StartResponse>(payload).map_err(|error| {
249+ OperationError::Transport {
250+ message: format!("failed to decode e2b process stream event: {error}"),
251+ }
252+ })?,
253+ );
254+ }
255+ 
256+ collect_start_response(responses)
257+}
258+ 
259+fn connect_envelope(payload: &[u8]) -> Vec<u8> {
260+ let mut framed = Vec::with_capacity(payload.len() + 5);
261+ framed.push(0);
262+ framed.extend_from_slice(&(payload.len() as u32).to_be_bytes());
263+ framed.extend_from_slice(payload);
264+ framed
265+}
266+ 
267+fn collect_start_response<I>(responses: I) -> Result<E2bExecOutput, OperationError>
268+where
269+ I: IntoIterator<Item = StartResponse>,
270+{
271+ let mut stdout = Vec::new();
272+ let mut stderr = Vec::new();
273+ let mut exit_code = None;
274+ let mut end_error = None;
275+ 
276+ for response in responses {
277+ match response.event {
278+ Some(ProcessEvent {
279+ data: Some(data), ..
280+ }) => {
281+ if let Some(chunk) = data.stdout {
282+ stdout.extend(decode_stream_chunk("stdout", chunk.as_str())?);
283+ }
284+ if let Some(chunk) = data.stderr {
285+ stderr.extend(decode_stream_chunk("stderr", chunk.as_str())?);
286+ }
287+ if let Some(chunk) = data.pty {
288+ stdout.extend(decode_stream_chunk("pty", chunk.as_str())?);
289+ }
290+ }
291+ Some(ProcessEvent { end: Some(end), .. }) => {
292+ exit_code = end
293+ .exit_code
294+ .or_else(|| parse_status_exit_code(end.status.as_deref().unwrap_or_default()));
295+ end_error = end.error;
296+ }
297+ _ => {}
298+ }
299+ }
300+ 
301+ if let Some(error) = end_error.filter(|value| !value.trim().is_empty()) {
302+ stderr.extend(error.as_bytes());
303+ }
304+ 
305+ Ok(E2bExecOutput {
306+ stdout,
307+ stderr,
308+ exit_code,
309+ timed_out: false,
310+ })
311+}
312+ 
313+fn decode_stream_chunk(label: &str, value: &str) -> Result<Vec<u8>, OperationError> {
314+ base64::engine::general_purpose::STANDARD
315+ .decode(value)
316+ .map_err(|error| OperationError::Transport {
317+ message: format!("failed to decode e2b process {label} chunk: {error}"),
318+ })
319+}
320+ 
321+fn parse_status_exit_code(status: &str) -> Option<i32> {
322+ let status = status.trim();
323+ status
324+ .strip_prefix("exit status ")
325+ .and_then(|value| value.trim().parse::<i32>().ok())
326+ .or_else(|| {
327+ status
328+ .rsplit_once(' ')
329+ .and_then(|(_, tail)| tail.parse().ok())
330+ })
331+}
332+ 
333+fn parse_end_stream_payload(payload: &[u8]) -> Result<(), OperationError> {
334+ if payload.is_empty() {
335+ return Ok(());
336+ }
337+ let Ok(value) = serde_json::from_slice::<Value>(payload) else {
338+ return Ok(());
339+ };
340+ if let Some(error) = value.get("error").filter(|value| !value.is_null()) {
341+ return Err(OperationError::Transport {
342+ message: format!("e2b process stream ended with error: {error}"),
343+ });
344+ }
345+ Ok(())
346+}
347+ 
348+#[derive(Debug, Deserialize)]
349+struct StartResponse {
350+ event: Option<ProcessEvent>,
351+}
352+ 
353+#[derive(Debug, Deserialize)]
354+struct ProcessEvent {
355+ data: Option<DataEvent>,
356+ end: Option<EndEvent>,
357+}
358+ 
359+#[derive(Debug, Deserialize)]
360+struct DataEvent {
361+ stdout: Option<String>,
362+ stderr: Option<String>,
363+ pty: Option<String>,
364+}
365+ 
366+#[derive(Debug, Deserialize)]
367+#[serde(rename_all = "camelCase")]
368+struct EndEvent {
369+ exit_code: Option<i32>,
370+ status: Option<String>,
371+ error: Option<String>,
372+}
373+ 
374+#[cfg(test)]
375+mod tests {
376+ use super::*;
377+ use base64::Engine;
378+ 
379+ #[test]
380+ fn parses_connect_start_stream() {
381+ let stdout = base64::engine::general_purpose::STANDARD.encode("hello\n");
382+ let event = format!(r#"{{"event":{{"data":{{"stdout":"{stdout}"}}}}}}"#);
383+ let end = r#"{"event":{"end":{"status":"exit status 0","exited":true}}}"#;
384+ let mut bytes = Vec::new();
385+ for payload in [event.as_bytes(), end.as_bytes()] {
386+ bytes.push(0);
387+ bytes.extend_from_slice(&(payload.len() as u32).to_be_bytes());
388+ bytes.extend_from_slice(payload);
389+ }
390+ 
391+ let parsed = parse_start_stream(bytes.as_slice()).expect("parse stream");
392+ 
393+ assert_eq!(parsed.stdout, b"hello\n");
394+ assert_eq!(parsed.exit_code, Some(0));
395+ }
396+ 
397+ #[test]
398+ fn parses_status_exit_code() {
399+ assert_eq!(parse_status_exit_code("exit status 124"), Some(124));
400+ }
401+}
Aapps/shared/src/backend/e2b/filesystem.rs+317-0
@@ -0,0 +1,317 @@
1+use agent_contracts::backend::{
2+ capability::{
3+ export::ExportFileRequest,
4+ filesystem::{
5+ ReadBytesRequest, TempPathKind, TempPathRequest, WriteBytesOutcome, WriteBytesRequest,
6+ WriteMode,
7+ },
8+ OperationExport, OperationFileSystem,
9+ },
10+ BackendPath, ExportedFileHandle, ExportedFileMeta, ExportedFileReader, OperationError,
11+ PathKind, PathStat, SharedExportedFileHandle,
12+};
13+use async_trait::async_trait;
14+use reqwest::header::{ACCEPT, CONTENT_TYPE};
15+use reqwest::{Method, StatusCode};
16+use serde::Deserialize;
17+use serde_json::json;
18+use std::io::Cursor;
19+use std::sync::Arc;
20+ 
21+use super::backend::{connect_json, http_error, shell_quote, E2bBackendState};
22+use super::exec::E2bExec;
23+ 
24+pub(crate) struct E2bFileSystem {
25+ state: Arc<E2bBackendState>,
26+ exec: E2bExec,
27+}
28+ 
29+impl E2bFileSystem {
30+ pub(crate) fn new(state: Arc<E2bBackendState>) -> Self {
31+ Self {
32+ exec: E2bExec::new(Arc::clone(&state)),
33+ state,
34+ }
35+ }
36+}
37+ 
38+struct E2bExportedFileHandle {
39+ state: Arc<E2bBackendState>,
40+ path: BackendPath,
41+ metadata: ExportedFileMeta,
42+}
43+ 
44+#[async_trait]
45+impl ExportedFileHandle for E2bExportedFileHandle {
46+ fn metadata(&self) -> &ExportedFileMeta {
47+ &self.metadata
48+ }
49+ 
50+ async fn open_read(&self) -> Result<ExportedFileReader, OperationError> {
51+ let fs = E2bFileSystem::new(Arc::clone(&self.state));
52+ let content = fs
53+ .read_bytes(ReadBytesRequest {
54+ path: self.path.clone(),
55+ })
56+ .await?;
57+ Ok(Box::new(Cursor::new(content)))
58+ }
59+}
60+ 
61+#[async_trait]
62+impl OperationFileSystem for E2bFileSystem {
63+ async fn stat(&self, path: &BackendPath) -> Result<PathStat, OperationError> {
64+ self.state.ensure_active()?;
65+ let response: Result<StatResponse, OperationError> = connect_json(
66+ &self.state,
67+ "/filesystem.Filesystem/Stat",
68+ json!({ "path": path.0 }),
69+ )
70+ .await;
71+ 
72+ match response {
73+ Ok(response) => Ok(path_stat_from_entry(response.entry)),
74+ Err(OperationError::NotFound { .. }) => Ok(PathStat {
75+ exists: false,
76+ kind: None,
77+ size_bytes: None,
78+ modified_at: None,
79+ }),
80+ Err(error) => Err(error),
81+ }
82+ }
83+ 
84+ async fn read_bytes(&self, request: ReadBytesRequest) -> Result<Vec<u8>, OperationError> {
85+ self.state.ensure_active()?;
86+ let response = self
87+ .state
88+ .envd_request(Method::GET, "/files")
89+ .query(&[("path", request.path.0.as_str())])
90+ .header(ACCEPT, "application/octet-stream")
91+ .send()
92+ .await
93+ .map_err(|error| OperationError::Transport {
94+ message: format!("failed to download e2b file {}: {error}", request.path.0),
95+ })?;
96+ 
97+ if response.status() == StatusCode::NOT_FOUND {
98+ return Err(OperationError::NotFound {
99+ path: request.path.0,
100+ });
101+ }
102+ if !response.status().is_success() {
103+ return Err(http_error("download e2b file", response).await);
104+ }
105+ 
106+ response
107+ .bytes()
108+ .await
109+ .map(|bytes| bytes.to_vec())
110+ .map_err(|error| OperationError::Transport {
111+ message: format!("failed to read e2b file response: {error}"),
112+ })
113+ }
114+ 
115+ async fn write_bytes(
116+ &self,
117+ request: WriteBytesRequest,
118+ ) -> Result<WriteBytesOutcome, OperationError> {
119+ self.state.ensure_active()?;
120+ if matches!(request.mode, WriteMode::Create) {
121+ let stat = self.stat(&request.path).await?;
122+ if stat.exists {
123+ return Err(OperationError::AlreadyExists {
124+ path: request.path.0,
125+ });
126+ }
127+ }
128+ let before = self.stat(&request.path).await?;
129+ 
130+ match request.mode {
131+ WriteMode::AtomicOverwrite => {
132+ let temp_path = self
133+ .temp_path(TempPathRequest {
134+ kind: TempPathKind::File,
135+ preferred_parent: None,
136+ prefix: Some(".xiaoo-atomic-".to_string()),
137+ suffix: Some(".tmp".to_string()),
138+ })
139+ .await?;
140+ self.upload_raw(&temp_path, request.content).await?;
141+ let destination = shell_quote(request.path.0.as_str());
142+ let script = format!(
143+ "parent=$(dirname {destination}) && mkdir -p \"$parent\" && mv -f {} {destination}",
144+ shell_quote(temp_path.0.as_str()),
145+ );
146+ let output = self.exec.run_shell_script(script.as_str(), None).await?;
147+ if output.exit_code != Some(0) {
148+ return Err(OperationError::ExecutionFailed {
149+ message: String::from_utf8_lossy(output.stderr.as_slice()).to_string(),
150+ });
151+ }
152+ }
153+ WriteMode::Create | WriteMode::Overwrite => {
154+ self.upload_raw(&request.path, request.content).await?;
155+ }
156+ }
157+ 
158+ Ok(WriteBytesOutcome {
159+ path: request.path,
160+ created: !before.exists,
161+ })
162+ }
163+ 
164+ async fn create_dir_all(&self, path: &BackendPath) -> Result<(), OperationError> {
165+ let script = format!("mkdir -p {}", shell_quote(path.0.as_str()));
166+ let output = self.exec.run_shell_script(script.as_str(), None).await?;
167+ if output.exit_code == Some(0) {
168+ return Ok(());
169+ }
170+ Err(OperationError::ExecutionFailed {
171+ message: String::from_utf8_lossy(output.stderr.as_slice()).to_string(),
172+ })
173+ }
174+ 
175+ async fn temp_path(&self, request: TempPathRequest) -> Result<BackendPath, OperationError> {
176+ let parent = request
177+ .preferred_parent
178+ .unwrap_or_else(|| self.state.temp_root.clone());
179+ let quoted_parent = shell_quote(parent.0.as_str());
180+ let prefix = shell_quote(request.prefix.as_deref().unwrap_or("tmp-"));
181+ let suffix = shell_quote(request.suffix.as_deref().unwrap_or(""));
182+ let creation = match request.kind {
183+ TempPathKind::File => ": > \"$path\"",
184+ TempPathKind::Directory => "mkdir \"$path\"",
185+ };
186+ let script = format!(
187+ "mkdir -p {quoted_parent}\nprefix={prefix}\nsuffix={suffix}\nwhile true; do\n path=\"{parent}/$prefix$(date +%s%N)-$RANDOM$suffix\"\n if [ ! -e \"$path\" ]; then\n {creation}\n printf '%s' \"$path\"\n exit 0\n fi\ndone",
188+ parent = parent.0,
189+ );
190+ let output = self.exec.run_shell_script(script.as_str(), None).await?;
191+ if output.exit_code != Some(0) {
192+ return Err(OperationError::ExecutionFailed {
193+ message: String::from_utf8_lossy(output.stderr.as_slice()).to_string(),
194+ });
195+ }
196+ let text = String::from_utf8_lossy(output.stdout.as_slice())
197+ .trim()
198+ .to_string();
199+ Ok(BackendPath(text))
200+ }
201+}
202+ 
203+impl E2bFileSystem {
204+ async fn upload_raw(&self, path: &BackendPath, content: Vec<u8>) -> Result<(), OperationError> {
205+ let response = self
206+ .state
207+ .envd_request(Method::POST, "/files")
208+ .query(&[("path", path.0.as_str())])
209+ .header(CONTENT_TYPE, "application/octet-stream")
210+ .header(ACCEPT, "application/json")
211+ .body(content)
212+ .send()
213+ .await
214+ .map_err(|error| OperationError::Transport {
215+ message: format!("failed to upload e2b file {}: {error}", path.0),
216+ })?;
217+ 
218+ if response.status().is_success() {
219+ return Ok(());
220+ }
221+ Err(http_error("upload e2b file", response).await)
222+ }
223+}
224+ 
225+#[async_trait]
226+impl OperationExport for E2bFileSystem {
227+ async fn export_file(
228+ &self,
229+ request: ExportFileRequest,
230+ ) -> Result<SharedExportedFileHandle, OperationError> {
231+ let stat = self.stat(&request.path).await?;
232+ if !stat.exists {
233+ return Err(OperationError::NotFound {
234+ path: request.path.0.clone(),
235+ });
236+ }
237+ if stat.kind != Some(PathKind::File) {
238+ return Err(OperationError::NotFile {
239+ path: request.path.0.clone(),
240+ });
241+ }
242+ let file_name = request.preferred_name.unwrap_or_else(|| {
243+ request
244+ .path
245+ .0
246+ .rsplit('/')
247+ .next()
248+ .unwrap_or("exported-file")
249+ .to_string()
250+ });
251+ Ok(Arc::new(E2bExportedFileHandle {
252+ state: Arc::clone(&self.state),
253+ path: request.path,
254+ metadata: ExportedFileMeta {
255+ file_name,
256+ size_bytes: stat.size_bytes,
257+ media_type: None,
258+ },
259+ }))
260+ }
261+}
262+ 
263+fn path_stat_from_entry(entry: Option<EntryInfo>) -> PathStat {
264+ let Some(entry) = entry else {
265+ return PathStat {
266+ exists: false,
267+ kind: None,
268+ size_bytes: None,
269+ modified_at: None,
270+ };
271+ };
272+ PathStat {
273+ exists: true,
274+ kind: file_type_to_kind(entry.file_type.as_deref()),
275+ size_bytes: entry.size.and_then(|size| size.as_u64()),
276+ modified_at: None,
277+ }
278+}
279+ 
280+fn file_type_to_kind(value: Option<&str>) -> Option<PathKind> {
281+ match value {
282+ Some("FILE_TYPE_FILE") => Some(PathKind::File),
283+ Some("FILE_TYPE_DIRECTORY") => Some(PathKind::Directory),
284+ Some("FILE_TYPE_SYMLINK") => Some(PathKind::Symlink),
285+ Some("FILE_TYPE_UNSPECIFIED") | None => None,
286+ Some(_) => Some(PathKind::Other),
287+ }
288+}
289+ 
290+#[derive(Debug, Deserialize)]
291+struct StatResponse {
292+ entry: Option<EntryInfo>,
293+}
294+ 
295+#[derive(Debug, Deserialize)]
296+#[serde(rename_all = "camelCase")]
297+struct EntryInfo {
298+ #[serde(rename = "type")]
299+ file_type: Option<String>,
300+ size: Option<SizeValue>,
301+}
302+ 
303+#[derive(Debug, Deserialize)]
304+#[serde(untagged)]
305+enum SizeValue {
306+ Number(u64),
307+ String(String),
308+}
309+ 
310+impl SizeValue {
311+ fn as_u64(&self) -> Option<u64> {
312+ match self {
313+ Self::Number(value) => Some(*value),
314+ Self::String(value) => value.parse().ok(),
315+ }
316+ }
317+}
Aapps/shared/src/backend/e2b/mod.rs+11-0
@@ -0,0 +1,11 @@
1+mod backend;
2+mod exec;
3+mod filesystem;
4+mod path;
5+mod provider;
6+mod search;
7+ 
8+pub(crate) use provider::{
9+ create_backend, create_snapshot, delete_snapshot, E2bCreateBackendInput,
10+ E2bDeleteSnapshotInput, E2bSnapshotInput,
11+};
Aapps/shared/src/backend/e2b/path.rs+54-0
@@ -0,0 +1,54 @@
1+use agent_contracts::backend::{
2+ capability::{path::ResolveBase, path::ResolvePathRequest, OperationPathResolver},
3+ BackendPath, OperationError,
4+};
5+use async_trait::async_trait;
6+use std::sync::Arc;
7+ 
8+use super::backend::E2bBackendState;
9+ 
10+pub(crate) struct E2bPathResolver {
11+ state: Arc<E2bBackendState>,
12+}
13+ 
14+impl E2bPathResolver {
15+ pub(crate) fn new(state: Arc<E2bBackendState>) -> Self {
16+ Self { state }
17+ }
18+}
19+ 
20+#[async_trait]
21+impl OperationPathResolver for E2bPathResolver {
22+ fn workspace_root(&self) -> &BackendPath {
23+ &self.state.workspace_root
24+ }
25+ 
26+ fn home_dir(&self) -> Option<&BackendPath> {
27+ self.state.home_dir.as_ref()
28+ }
29+ 
30+ async fn resolve_path(
31+ &self,
32+ request: ResolvePathRequest,
33+ ) -> Result<BackendPath, OperationError> {
34+ self.state.ensure_active()?;
35+ let base = match request.base {
36+ ResolveBase::WorkspaceRoot => &self.state.workspace_root,
37+ ResolveBase::HomeDir => {
38+ self.state
39+ .home_dir
40+ .as_ref()
41+ .ok_or_else(|| OperationError::Unsupported {
42+ message: "home_dir is not configured".to_string(),
43+ })?
44+ }
45+ ResolveBase::Explicit(path) => {
46+ return self
47+ .state
48+ .resolve_backend_path(request.raw_path.as_str(), &path);
49+ }
50+ };
51+ self.state
52+ .resolve_backend_path(request.raw_path.as_str(), base)
53+ }
54+}
Aapps/shared/src/backend/e2b/provider.rs+631-0
@@ -0,0 +1,631 @@
1+use agent_contracts::backend::{
2+ BackendEndpoint, BackendId, BackendInstance, BackendInstanceId, BackendLifecycleState,
3+ BackendPath, BackendProviderKind, BackendResourceAllocation, BackendRuntimeCapabilities,
4+ OperationBackend, OperationError,
5+};
6+use serde::Deserialize;
7+use serde_json::{json, Map, Value};
8+use std::collections::BTreeMap;
9+use std::sync::{Arc, Mutex};
10+ 
11+use super::backend::{
12+ join_url, normalize_backend_path, E2bBackendState, E2bLifecycle, E2bOperationBackend,
13+ DEFAULT_API_BASE, DEFAULT_ENVD_PORT, DEFAULT_HOME_DIR, DEFAULT_SHELL, DEFAULT_TEMPLATE_ID,
14+ DEFAULT_TEMP_ROOT, DEFAULT_TIMEOUT_SECS, DEFAULT_WORKSPACE_ROOT, E2B_PROVIDER_KIND,
15+};
16+use super::exec::E2bExec;
17+use crate::backend::BackendError;
18+ 
19+pub(crate) struct E2bCreateBackendInput {
20+ pub(crate) backend_id: BackendId,
21+ pub(crate) session_id_for_instance: String,
22+ pub(crate) workspace_root_text: String,
23+ pub(crate) provider_options: Value,
24+ pub(crate) resource_limits: agent_contracts::backend::BackendResourceLimits,
25+ pub(crate) metadata: Value,
26+}
27+ 
28+pub(crate) struct E2bCreatedBackend {
29+ pub(crate) instance: BackendInstance,
30+ pub(crate) backend: Arc<dyn OperationBackend>,
31+}
32+ 
33+pub(crate) struct E2bSnapshotInput {
34+ pub(crate) provider_options: Value,
35+ pub(crate) sandbox_id: String,
36+ pub(crate) name: Option<String>,
37+}
38+ 
39+pub(crate) struct E2bDeleteSnapshotInput {
40+ pub(crate) provider_options: Value,
41+ pub(crate) snapshot_id: String,
42+}
43+ 
44+#[derive(Debug, Clone)]
45+pub(crate) struct E2bSnapshotResult {
46+ pub(crate) snapshot_id: String,
47+ pub(crate) names: Vec<String>,
48+}
49+ 
50+#[derive(Debug, Clone, Default, Deserialize)]
51+#[serde(default, deny_unknown_fields)]
52+struct E2bProviderOptions {
53+ api_key: Option<String>,
54+ api_key_env: Option<String>,
55+ #[serde(alias = "apiBase")]
56+ api_base: Option<String>,
57+ #[serde(alias = "templateID", alias = "template")]
58+ template_id: Option<String>,
59+ #[serde(alias = "timeout")]
60+ timeout_secs: Option<u64>,
61+ secure: Option<bool>,
62+ #[serde(alias = "allowInternetAccess")]
63+ allow_internet_access: Option<bool>,
64+ #[serde(alias = "autoPause")]
65+ auto_pause: Option<bool>,
66+ #[serde(alias = "autoResume")]
67+ auto_resume: Option<bool>,
68+ #[serde(alias = "envdPort")]
69+ envd_port: Option<u16>,
70+ #[serde(alias = "envdScheme")]
71+ envd_scheme: Option<String>,
72+ #[serde(alias = "workspaceRoot", alias = "remoteWorkspaceRoot")]
73+ workspace_root: Option<String>,
74+ #[serde(alias = "homeDir")]
75+ home_dir: Option<String>,
76+ #[serde(alias = "tempRoot")]
77+ temp_root: Option<String>,
78+ #[serde(alias = "defaultShell")]
79+ default_shell: Option<String>,
80+ username: Option<String>,
81+ metadata: Option<BTreeMap<String, String>>,
82+ #[serde(alias = "envVars")]
83+ env_vars: Option<BTreeMap<String, String>>,
84+ network: Option<Value>,
85+ mcp: Option<Value>,
86+ #[serde(alias = "volumeMounts")]
87+ volume_mounts: Option<Value>,
88+}
89+ 
90+#[derive(Debug, Deserialize)]
91+#[serde(rename_all = "camelCase")]
92+struct CreateSandboxResponse {
93+ #[serde(rename = "sandboxID")]
94+ sandbox_id: String,
95+ #[serde(rename = "templateID")]
96+ template_id: String,
97+ #[serde(rename = "envdAccessToken")]
98+ envd_access_token: Option<String>,
99+ #[serde(rename = "trafficAccessToken")]
100+ traffic_access_token: Option<String>,
101+}
102+ 
103+#[derive(Debug, Deserialize)]
104+struct CreateSnapshotResponse {
105+ #[serde(rename = "snapshotID")]
106+ snapshot_id: String,
107+ #[serde(default)]
108+ names: Vec<String>,
109+}
110+ 
111+pub(crate) async fn create_backend(
112+ input: E2bCreateBackendInput,
113+) -> Result<E2bCreatedBackend, BackendError> {
114+ let options = parse_options(&input.provider_options)?;
115+ let api_key = resolve_api_key(&options)?;
116+ let http = reqwest::Client::new();
117+ let api_base = options
118+ .api_base
119+ .as_deref()
120+ .filter(|value| !value.trim().is_empty())
121+ .unwrap_or(DEFAULT_API_BASE)
122+ .to_string();
123+ let template_id = options
124+ .template_id
125+ .as_deref()
126+ .filter(|value| !value.trim().is_empty())
127+ .unwrap_or(DEFAULT_TEMPLATE_ID)
128+ .to_string();
129+ let workspace_root = backend_path(
130+ options
131+ .workspace_root
132+ .as_deref()
133+ .unwrap_or(DEFAULT_WORKSPACE_ROOT),
134+ )?;
135+ let home_dir = options
136+ .home_dir
137+ .as_deref()
138+ .map(backend_path)
139+ .transpose()?
140+ .or_else(|| Some(BackendPath(DEFAULT_HOME_DIR.to_string())));
141+ let temp_root = backend_path(options.temp_root.as_deref().unwrap_or(DEFAULT_TEMP_ROOT))?;
142+ let envd_port = options.envd_port.unwrap_or(DEFAULT_ENVD_PORT);
143+ let envd_scheme = options
144+ .envd_scheme
145+ .clone()
146+ .filter(|value| !value.trim().is_empty())
147+ .unwrap_or_else(|| "https".to_string());
148+ 
149+ let created = create_e2b_sandbox(
150+ &http,
151+ api_base.as_str(),
152+ api_key.as_str(),
153+ &template_id,
154+ &options,
155+ &input,
156+ )
157+ .await?;
158+ 
159+ let now = current_time_ms();
160+ let backend_id = input.backend_id;
161+ let endpoint = provider_handle(&created, envd_port, envd_scheme.as_str());
162+ let instance = BackendInstance {
163+ backend_id: backend_id.clone(),
164+ provider: BackendProviderKind(E2B_PROVIDER_KIND.to_string()),
165+ instance_id: BackendInstanceId(created.sandbox_id.clone()),
166+ session_id: input.session_id_for_instance,
167+ state: BackendLifecycleState::Active,
168+ workspace_root: workspace_root.clone(),
169+ endpoint: Some(endpoint),
170+ snapshot: None,
171+ capabilities: BackendRuntimeCapabilities {
172+ supports_exec: true,
173+ supports_file_read: true,
174+ supports_file_write: true,
175+ supports_search: true,
176+ supports_export_file: true,
177+ supports_lsp: false,
178+ supports_pause: false,
179+ supports_snapshot: true,
180+ supports_delete: true,
181+ },
182+ resources: BackendResourceAllocation {
183+ vcpu_count: input.resource_limits.vcpu_count,
184+ memory_mb: input.resource_limits.memory_mb,
185+ disk_mb: input.resource_limits.disk_mb,
186+ },
187+ metadata: metadata_for_instance(
188+ input.metadata,
189+ &input.provider_options,
190+ &created,
191+ &options,
192+ ),
193+ created_at_ms: now,
194+ updated_at_ms: now,
195+ };
196+ 
197+ let state = Arc::new(E2bBackendState {
198+ backend_id: backend_id.0,
199+ api_base,
200+ api_key,
201+ sandbox_id: created.sandbox_id,
202+ envd_access_token: created.envd_access_token,
203+ envd_port,
204+ envd_scheme,
205+ workspace_root,
206+ home_dir,
207+ temp_root,
208+ default_shell: Some(
209+ options
210+ .default_shell
211+ .filter(|value| !value.trim().is_empty())
212+ .unwrap_or_else(|| DEFAULT_SHELL.to_string()),
213+ ),
214+ username: options.username.filter(|value| !value.trim().is_empty()),
215+ http,
216+ lifecycle: Mutex::new(E2bLifecycle::Active),
217+ });
218+ let backend = Arc::new(E2bOperationBackend::new(Arc::clone(&state)));
219+ 
220+ if let Err(error) = ensure_remote_roots(&state).await {
221+ let _ = state.delete_sandbox().await;
222+ return Err(BackendError::BuildFailed {
223+ message: format!("e2b sandbox created but workspace initialization failed: {error}"),
224+ });
225+ }
226+ 
227+ Ok(E2bCreatedBackend { instance, backend })
228+}
229+ 
230+pub(crate) async fn create_snapshot(
231+ input: E2bSnapshotInput,
232+) -> Result<E2bSnapshotResult, BackendError> {
233+ let options = parse_options(&input.provider_options)?;
234+ let api_key = resolve_api_key(&options)?;
235+ let api_base = options
236+ .api_base
237+ .as_deref()
238+ .filter(|value| !value.trim().is_empty())
239+ .unwrap_or(DEFAULT_API_BASE)
240+ .to_string();
241+ let http = reqwest::Client::new();
242+ 
243+ let mut body = Map::new();
244+ if let Some(name) = input
245+ .name
246+ .as_deref()
247+ .map(str::trim)
248+ .filter(|name| !name.is_empty())
249+ {
250+ body.insert("name".to_string(), Value::String(name.to_string()));
251+ }
252+ 
253+ let response = http
254+ .post(join_url(
255+ api_base.as_str(),
256+ format!("/sandboxes/{}/snapshots", input.sandbox_id).as_str(),
257+ ))
258+ .header("X-API-Key", api_key)
259+ .json(&Value::Object(body))
260+ .send()
261+ .await
262+ .map_err(|error| BackendError::BuildFailed {
263+ message: format!("failed to create e2b snapshot: {error}"),
264+ })?;
265+ 
266+ if response.status() != reqwest::StatusCode::CREATED {
267+ let status = response.status();
268+ let text = response.text().await.unwrap_or_default();
269+ let message = super::backend::parse_error_message(text.as_str()).unwrap_or(text);
270+ return Err(BackendError::BuildFailed {
271+ message: format!("e2b create snapshot failed with HTTP {status}: {message}"),
272+ });
273+ }
274+ 
275+ let parsed = response
276+ .json::<CreateSnapshotResponse>()
277+ .await
278+ .map_err(|error| BackendError::BuildFailed {
279+ message: format!("failed to decode e2b create snapshot response: {error}"),
280+ })?;
281+ 
282+ Ok(E2bSnapshotResult {
283+ snapshot_id: parsed.snapshot_id,
284+ names: parsed.names,
285+ })
286+}
287+ 
288+pub(crate) async fn delete_snapshot(input: E2bDeleteSnapshotInput) -> Result<bool, BackendError> {
289+ let snapshot_id = input.snapshot_id.trim();
290+ if snapshot_id.is_empty() {
291+ return Err(BackendError::InvalidRequest {
292+ message: "e2b snapshot id cannot be empty".to_string(),
293+ });
294+ }
295+ 
296+ let options = parse_options(&input.provider_options)?;
297+ let api_key = resolve_api_key(&options)?;
298+ let api_base = options
299+ .api_base
300+ .as_deref()
301+ .filter(|value| !value.trim().is_empty())
302+ .unwrap_or(DEFAULT_API_BASE)
303+ .to_string();
304+ let http = reqwest::Client::new();
305+ 
306+ let response = http
307+ .delete(join_url(
308+ api_base.as_str(),
309+ format!("/templates/{}", encode_path_segment(snapshot_id)).as_str(),
310+ ))
311+ .header("X-API-Key", api_key)
312+ .send()
313+ .await
314+ .map_err(|error| BackendError::BuildFailed {
315+ message: format!("failed to delete e2b snapshot template: {error}"),
316+ })?;
317+ 
318+ if response.status() == reqwest::StatusCode::NO_CONTENT {
319+ return Ok(true);
320+ }
321+ if response.status() == reqwest::StatusCode::NOT_FOUND {
322+ return Ok(false);
323+ }
324+ 
325+ let status = response.status();
326+ let text = response.text().await.unwrap_or_default();
327+ let message = super::backend::parse_error_message(text.as_str()).unwrap_or(text);
328+ Err(BackendError::BuildFailed {
329+ message: format!("e2b delete snapshot template failed with HTTP {status}: {message}"),
330+ })
331+}
332+ 
333+fn parse_options(value: &Value) -> Result<E2bProviderOptions, BackendError> {
334+ let value = if value.is_null() {
335+ Value::Object(Map::new())
336+ } else {
337+ value.clone()
338+ };
339+ serde_json::from_value(value).map_err(|error| BackendError::InvalidRequest {
340+ message: format!("invalid e2b backend options: {error}"),
341+ })
342+}
343+ 
344+fn resolve_api_key(options: &E2bProviderOptions) -> Result<String, BackendError> {
345+ if let Some(api_key) = options
346+ .api_key
347+ .as_deref()
348+ .filter(|value| !value.trim().is_empty())
349+ {
350+ return Ok(api_key.to_string());
351+ }
352+ 
353+ let env_name = options
354+ .api_key_env
355+ .as_deref()
356+ .filter(|value| !value.trim().is_empty())
357+ .unwrap_or("E2B_API_KEY");
358+ std::env::var(env_name)
359+ .ok()
360+ .filter(|value| !value.trim().is_empty())
361+ .ok_or_else(|| BackendError::InvalidRequest {
362+ message: format!("e2b backend requires api_key or non-empty env var {env_name}"),
363+ })
364+}
365+ 
366+fn backend_path(value: &str) -> Result<BackendPath, BackendError> {
367+ normalize_backend_path(std::path::Path::new(value)).map_err(|error| {
368+ BackendError::InvalidRequest {
369+ message: error.to_string(),
370+ }
371+ })
372+}
373+ 
374+async fn create_e2b_sandbox(
375+ http: &reqwest::Client,
376+ api_base: &str,
377+ api_key: &str,
378+ template_id: &str,
379+ options: &E2bProviderOptions,
380+ input: &E2bCreateBackendInput,
381+) -> Result<CreateSandboxResponse, BackendError> {
382+ let timeout_secs = options
383+ .timeout_secs
384+ .or_else(|| input.resource_limits.timeout_ms.map(|ms| ms / 1000))
385+ .unwrap_or(DEFAULT_TIMEOUT_SECS);
386+ 
387+ let mut body = Map::new();
388+ body.insert(
389+ "templateID".to_string(),
390+ Value::String(template_id.to_string()),
391+ );
392+ body.insert("timeout".to_string(), json!(timeout_secs));
393+ body.insert("secure".to_string(), json!(options.secure.unwrap_or(true)));
394+ if let Some(value) = options.allow_internet_access {
395+ body.insert("allow_internet_access".to_string(), json!(value));
396+ }
397+ if let Some(value) = options.auto_pause {
398+ body.insert("autoPause".to_string(), json!(value));
399+ }
400+ if let Some(value) = options.auto_resume {
401+ body.insert("autoResume".to_string(), json!({ "enabled": value }));
402+ }
403+ let metadata = platform_metadata(input, options);
404+ if !metadata.is_empty() {
405+ body.insert(
406+ "metadata".to_string(),
407+ serde_json::to_value(metadata).unwrap(),
408+ );
409+ }
410+ if let Some(env_vars) = options
411+ .env_vars
412+ .as_ref()
413+ .filter(|values| !values.is_empty())
414+ {
415+ body.insert(
416+ "envVars".to_string(),
417+ serde_json::to_value(env_vars).unwrap(),
418+ );
419+ }
420+ if let Some(network) = options.network.clone() {
421+ body.insert("network".to_string(), network);
422+ }
423+ if let Some(mcp) = options.mcp.clone() {
424+ body.insert("mcp".to_string(), mcp);
425+ }
426+ if let Some(volume_mounts) = options.volume_mounts.clone() {
427+ body.insert("volumeMounts".to_string(), volume_mounts);
428+ }
429+ 
430+ let response = http
431+ .post(join_url(api_base, "/sandboxes"))
432+ .header("X-API-Key", api_key)
433+ .json(&Value::Object(body))
434+ .send()
435+ .await
436+ .map_err(|error| BackendError::BuildFailed {
437+ message: format!("failed to create e2b sandbox: {error}"),
438+ })?;
439+ 
440+ if response.status() != reqwest::StatusCode::CREATED {
441+ let status = response.status();
442+ let text = response.text().await.unwrap_or_default();
443+ let message = super::backend::parse_error_message(text.as_str()).unwrap_or(text);
444+ return Err(BackendError::BuildFailed {
445+ message: format!("e2b create sandbox failed with HTTP {status}: {message}"),
446+ });
447+ }
448+ 
449+ response
450+ .json::<CreateSandboxResponse>()
451+ .await
452+ .map_err(|error| BackendError::BuildFailed {
453+ message: format!("failed to decode e2b create sandbox response: {error}"),
454+ })
455+}
456+ 
457+fn platform_metadata(
458+ input: &E2bCreateBackendInput,
459+ options: &E2bProviderOptions,
460+) -> BTreeMap<String, String> {
461+ let mut metadata = BTreeMap::new();
462+ if let Some(values) = &options.metadata {
463+ metadata.extend(values.clone());
464+ }
465+ if let Some(object) = input.metadata.as_object() {
466+ for (key, value) in object {
467+ if let Some(value) = value.as_str() {
468+ metadata.insert(key.clone(), value.to_string());
469+ }
470+ }
471+ }
472+ metadata.insert("xiaoo_backend_id".to_string(), input.backend_id.0.clone());
473+ metadata.insert(
474+ "xiaoo_session_id".to_string(),
475+ input.session_id_for_instance.clone(),
476+ );
477+ metadata.insert(
478+ "xiaoo_host_workspace".to_string(),
479+ input.workspace_root_text.clone(),
480+ );
481+ metadata
482+}
483+ 
484+fn metadata_for_instance(
485+ metadata: Value,
486+ provider_options: &Value,
487+ sandbox: &CreateSandboxResponse,
488+ options: &E2bProviderOptions,
489+) -> Value {
490+ let mut object = match metadata {
491+ Value::Object(object) => object,
492+ Value::Null => Map::new(),
493+ other => {
494+ let mut object = Map::new();
495+ object.insert("user_metadata".to_string(), other);
496+ object
497+ }
498+ };
499+ object.insert("provider".to_string(), Value::String("e2b".to_string()));
500+ object.insert(
501+ "sandbox_id".to_string(),
502+ Value::String(sandbox.sandbox_id.clone()),
503+ );
504+ object.insert(
505+ "template_id".to_string(),
506+ Value::String(sandbox.template_id.clone()),
507+ );
508+ if sandbox.traffic_access_token.is_some() {
509+ object.insert(
510+ "traffic_access_token_present".to_string(),
511+ Value::Bool(true),
512+ );
513+ }
514+ object.insert(
515+ "provider_options".to_string(),
516+ redacted_provider_options(provider_options, options),
517+ );
518+ Value::Object(object)
519+}
520+ 
521+fn redacted_provider_options(provider_options: &Value, options: &E2bProviderOptions) -> Value {
522+ let mut object = provider_options.as_object().cloned().unwrap_or_default();
523+ object.remove("api_key");
524+ object.remove("env_vars");
525+ object.remove("envVars");
526+ if options.api_key.is_some() {
527+ object.insert("api_key_configured".to_string(), Value::Bool(true));
528+ }
529+ Value::Object(object)
530+}
531+ 
532+fn provider_handle(
533+ sandbox: &CreateSandboxResponse,
534+ envd_port: u16,
535+ envd_scheme: &str,
536+) -> BackendEndpoint {
537+ BackendEndpoint::ProviderHandle {
538+ value: json!({
539+ "provider": "e2b",
540+ "sandbox_id": sandbox.sandbox_id.clone(),
541+ "envd_host": format!("{}-{}.e2b.app", envd_port, sandbox.sandbox_id),
542+ "envd_port": envd_port,
543+ "envd_scheme": envd_scheme,
544+ }),
545+ }
546+}
547+ 
548+async fn ensure_remote_roots(state: &Arc<E2bBackendState>) -> Result<(), OperationError> {
549+ let exec = E2bExec::new(Arc::clone(state));
550+ let script = format!(
551+ "mkdir -p {} {}",
552+ super::backend::shell_quote(state.workspace_root.0.as_str()),
553+ super::backend::shell_quote(state.temp_root.0.as_str())
554+ );
555+ let output = exec.run_shell_script(script.as_str(), None).await?;
556+ if output.exit_code == Some(0) {
557+ return Ok(());
558+ }
559+ Err(OperationError::ExecutionFailed {
560+ message: String::from_utf8_lossy(output.stderr.as_slice()).to_string(),
561+ })
562+}
563+ 
564+fn current_time_ms() -> u64 {
565+ std::time::SystemTime::now()
566+ .duration_since(std::time::UNIX_EPOCH)
567+ .map(|duration| duration.as_millis() as u64)
568+ .unwrap_or(0)
569+}
570+ 
571+fn encode_path_segment(value: &str) -> String {
572+ let mut encoded = String::new();
573+ for byte in value.as_bytes() {
574+ match *byte {
575+ b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
576+ encoded.push(*byte as char)
577+ }
578+ _ => encoded.push_str(format!("%{byte:02X}").as_str()),
579+ }
580+ }
581+ encoded
582+}
583+ 
584+#[cfg(test)]
585+mod tests {
586+ use super::*;
587+ 
588+ #[test]
589+ fn redacts_direct_api_key_from_metadata() {
590+ let options = parse_options(&json!({
591+ "api_key": "secret",
592+ "template_id": "base",
593+ "envVars": {"TOKEN": "secret"}
594+ }))
595+ .expect("options");
596+ 
597+ let redacted = redacted_provider_options(
598+ &json!({
599+ "api_key": "secret",
600+ "template_id": "base",
601+ "envVars": {"TOKEN": "secret"}
602+ }),
603+ &options,
604+ );
605+ 
606+ let object = redacted.as_object().expect("object");
607+ assert!(!object.contains_key("api_key"));
608+ assert!(!object.contains_key("envVars"));
609+ assert_eq!(object.get("api_key_configured"), Some(&Value::Bool(true)));
610+ }
611+ 
612+ #[test]
613+ fn default_template_is_base() {
614+ let options = parse_options(&json!({})).expect("options");
615+ assert_eq!(
616+ options
617+ .template_id
618+ .as_deref()
619+ .unwrap_or(DEFAULT_TEMPLATE_ID),
620+ "base"
621+ );
622+ }
623+ 
624+ #[test]
625+ fn encodes_template_id_as_single_path_segment() {
626+ assert_eq!(
627+ encode_path_segment("team/fork-test:default"),
628+ "team%2Ffork-test%3Adefault"
629+ );
630+ }
631+}
Aapps/shared/src/backend/e2b/search.rs+120-0
@@ -0,0 +1,120 @@
1+use agent_contracts::backend::{
2+ capability::{
3+ search::{GlobRequest, GrepMode, GrepRequest, GrepResult},
4+ OperationSearch,
5+ },
6+ BackendPath, OperationError,
7+};
8+use async_trait::async_trait;
9+use glob::Pattern;
10+use std::path::Path;
11+use std::sync::Arc;
12+ 
13+use super::backend::{shell_quote, E2bBackendState};
14+use super::exec::E2bExec;
15+ 
16+pub(crate) struct E2bSearch {
17+ exec: E2bExec,
18+}
19+ 
20+impl E2bSearch {
21+ pub(crate) fn new(state: Arc<E2bBackendState>) -> Self {
22+ Self {
23+ exec: E2bExec::new(state),
24+ }
25+ }
26+}
27+ 
28+#[async_trait]
29+impl OperationSearch for E2bSearch {
30+ async fn glob(&self, request: GlobRequest) -> Result<Vec<BackendPath>, OperationError> {
31+ let base_dir = request
32+ .base_dir
33+ .unwrap_or_else(|| self.exec.state().workspace_root.clone());
34+ let pattern = Pattern::new(request.pattern.as_str()).map_err(|error| {
35+ OperationError::InvalidPath {
36+ message: format!("invalid glob pattern: {error}"),
37+ }
38+ })?;
39+ let script = format!(
40+ "if [ -d {base} ]; then find {base} -mindepth 1 -print; fi",
41+ base = shell_quote(base_dir.0.as_str()),
42+ );
43+ let output = self.exec.run_shell_script(script.as_str(), None).await?;
44+ if output.exit_code != Some(0) {
45+ return Err(OperationError::ExecutionFailed {
46+ message: String::from_utf8_lossy(output.stderr.as_slice()).to_string(),
47+ });
48+ }
49+ 
50+ let mut paths = String::from_utf8_lossy(output.stdout.as_slice())
51+ .lines()
52+ .filter(|line| !line.is_empty())
53+ .filter_map(|line| {
54+ let path = Path::new(line);
55+ let relative = path
56+ .strip_prefix(base_dir.0.as_str())
57+ .ok()
58+ .and_then(|value| value.to_str())
59+ .unwrap_or("")
60+ .trim_start_matches('/');
61+ if pattern.matches(relative) || pattern.matches_path(path) {
62+ Some(BackendPath(line.to_string()))
63+ } else {
64+ None
65+ }
66+ })
67+ .collect::<Vec<_>>();
68+ paths.sort_by(|a, b| a.0.cmp(&b.0));
69+ if let Some(limit) = request.limit {
70+ paths.truncate(limit);
71+ }
72+ Ok(paths)
73+ }
74+ 
75+ async fn grep(&self, request: GrepRequest) -> Result<GrepResult, OperationError> {
76+ let mut cmd = vec!["grep -r -H".to_string()];
77+ 
78+ match &request.mode {
79+ GrepMode::FilesWithMatches => {
80+ cmd.push("-l".to_string());
81+ }
82+ GrepMode::Content => {}
83+ GrepMode::Count => {
84+ cmd.push("-c".to_string());
85+ }
86+ }
87+ 
88+ if let Some(include) = &request.include {
89+ cmd.push(format!("--include={}", shell_quote(include)));
90+ }
91+ 
92+ cmd.push("--".to_string());
93+ cmd.push(shell_quote(&request.query));
94+ cmd.push(shell_quote(request.base_dir.0.as_str()));
95+ 
96+ let output = self
97+ .exec
98+ .run_shell_script(cmd.join(" ").as_str(), None)
99+ .await?;
100+ let exit_code = output.exit_code.unwrap_or(-1);
101+ if exit_code != 0 && exit_code != 1 {
102+ return Err(OperationError::ExecutionFailed {
103+ message: String::from_utf8_lossy(output.stderr.as_slice()).to_string(),
104+ });
105+ }
106+ 
107+ let stdout_text = String::from_utf8_lossy(output.stdout.as_slice());
108+ let mut entries = stdout_text
109+ .lines()
110+ .filter(|line| !line.is_empty())
111+ .map(|line| line.to_string())
112+ .collect::<Vec<_>>();
113+ 
114+ if let Some(limit) = request.head_limit {
115+ entries.truncate(limit);
116+ }
117+ 
118+ Ok(GrepResult { entries })
119+ }
120+}
Aapps/shared/src/backend/mod.rs+937-0
@@ -0,0 +1,937 @@
1+use agent_contracts::backend::{
2+ BackendControlError, BackendCreateRequest as ProviderBackendCreateRequest,
3+ BackendDeleteRequest, BackendId, BackendInstance, BackendLifecycle, BackendLifecycleReason,
4+ BackendPath, BackendProvider, BackendResourceLimits, OperationBackend,
5+ OperationBackendBuildError, OperationError,
6+};
7+use serde_json::{Map, Value};
8+use std::collections::BTreeMap;
9+use std::hash::{Hash, Hasher};
10+use std::path::PathBuf;
11+use std::sync::Arc;
12+ 
13+mod backend_manager;
14+mod base;
15+mod dirty_write;
16+mod e2b;
17+ 
18+pub use backend_manager::BackendManager;
19+use backend_manager::BackendManagerState;
20+pub use base::{
21+ BackendCheckoutRequest, BackendCheckoutResult, BackendCheckpointRef, BackendCheckpointRequest,
22+ BackendCheckpointResult, BackendCheckpointSnapshotDeleteRequest,
23+ BackendCheckpointSnapshotDeleteResult, BackendConnectRequest, BackendCreateRequest,
24+ BackendEnsureSessionRequest, BackendError, BackendForkRequest, BackendForkResult, BackendInfo,
25+ BackendLease, BackendLineageInfo, BackendListFilter, BackendTreeNode, GatewayBackendConfig,
26+};
27+use dirty_write::{BackendDirtyTracker, DirtyTrackedOperationBackend};
28+ 
29+struct BackendInstanceEntry {
30+ backend: Arc<dyn OperationBackend>,
31+ instance: BackendInstance,
32+ config: GatewayBackendConfig,
33+ workspace_root: String,
34+ config_hash: u64,
35+ session_ids: BTreeMap<String, ()>,
36+ expires_at_ms: Option<u64>,
37+ lineage: BackendLineageEntry,
38+ dirty_tracker: Arc<BackendDirtyTracker>,
39+}
40+ 
41+#[derive(Debug, Clone, Default)]
42+struct BackendLineageEntry {
43+ parent_backend_id: Option<BackendId>,
44+ children_backend_ids: BTreeMap<String, ()>,
45+ forked_from_snapshot_id: Option<String>,
46+ forked_snapshot_names: Vec<String>,
47+ forked_at_ms: Option<u64>,
48+}
49+ 
50+fn workspace_root_string(path: &PathBuf) -> Result<String, OperationBackendBuildError> {
51+ path.to_str()
52+ .map(str::to_string)
53+ .ok_or_else(|| OperationBackendBuildError::InvalidConfig {
54+ message: format!("workspace_root is not valid utf-8: {}", path.display()),
55+ })
56+}
57+ 
58+fn hash_config(config: &GatewayBackendConfig) -> u64 {
59+ let mut hasher = std::collections::hash_map::DefaultHasher::new();
60+ config.kind.hash(&mut hasher);
61+ canonical_json(&config.options).hash(&mut hasher);
62+ hasher.finish()
63+}
64+ 
65+fn canonical_json(value: &Value) -> String {
66+ fn normalize(value: &Value) -> Value {
67+ match value {
68+ Value::Object(map) => Value::Object(
69+ map.iter()
70+ .map(|(key, value)| (key.clone(), normalize(value)))
71+ .collect::<BTreeMap<_, _>>()
72+ .into_iter()
73+ .collect(),
74+ ),
75+ Value::Array(values) => Value::Array(values.iter().map(normalize).collect()),
76+ other => other.clone(),
77+ }
78+ }
79+ 
80+ serde_json::to_string(&normalize(value)).unwrap_or_else(|_| "null".to_string())
81+}
82+ 
83+fn resolve_session_backend_config(
84+ config: Option<GatewayBackendConfig>,
85+) -> Result<GatewayBackendConfig, OperationBackendBuildError> {
86+ match config {
87+ Some(config) if config.kind == "local" || config.kind == "e2b" => Ok(config),
88+ Some(config) => Err(OperationBackendBuildError::UnsupportedBackend { kind: config.kind }),
89+ None => Ok(GatewayBackendConfig::new(
90+ "local",
91+ default_local_provider_options(),
92+ )),
93+ }
94+}
95+ 
96+fn default_local_provider_options() -> Value {
97+ let mut options = Map::new();
98+ if let Some(home_dir) = std::env::var_os("HOME") {
99+ options.insert(
100+ "home_dir".to_string(),
101+ Value::String(home_dir.to_string_lossy().to_string()),
102+ );
103+ }
104+ options.insert(
105+ "temp_root".to_string(),
106+ Value::String(std::env::temp_dir().to_string_lossy().to_string()),
107+ );
108+ Value::Object(options)
109+}
110+ 
111+fn resolve_backend_config(
112+ provider: Option<String>,
113+ options: Option<Value>,
114+) -> Result<GatewayBackendConfig, BackendError> {
115+ let kind = provider.unwrap_or_else(|| "local".to_string());
116+ let options = options.unwrap_or_else(|| {
117+ if kind == "local" {
118+ default_local_provider_options()
119+ } else {
120+ Value::Object(Map::new())
121+ }
122+ });
123+ resolve_session_backend_config(Some(GatewayBackendConfig::new(kind, options)))
124+ .map_err(BackendError::from_build_error)
125+}
126+ 
127+fn requested_backend_id(backend_id: Option<String>) -> Result<BackendId, BackendError> {
128+ match backend_id {
129+ Some(backend_id) if backend_id.trim().is_empty() => Err(BackendError::InvalidRequest {
130+ message: "backend_id cannot be empty".to_string(),
131+ }),
132+ Some(backend_id) => Ok(BackendId(backend_id)),
133+ None => Ok(new_backend_id()),
134+ }
135+}
136+ 
137+fn new_backend_id() -> BackendId {
138+ BackendId(format!("bkd_{}", uuid::Uuid::new_v4().simple()))
139+}
140+ 
141+fn expires_at_ms_from_timeout(timeout_secs: u64) -> u64 {
142+ current_time_ms().saturating_add(timeout_secs.saturating_mul(1000))
143+}
144+ 
145+fn current_time_ms() -> u64 {
146+ std::time::SystemTime::now()
147+ .duration_since(std::time::UNIX_EPOCH)
148+ .map(|duration| duration.as_millis() as u64)
149+ .unwrap_or(0)
150+}
151+ 
152+fn metadata_matches_filter(metadata: &Value, filter: &BTreeMap<String, String>) -> bool {
153+ if filter.is_empty() {
154+ return true;
155+ }
156+ let Some(object) = metadata.as_object() else {
157+ return false;
158+ };
159+ filter.iter().all(|(key, expected)| {
160+ object
161+ .get(key)
162+ .and_then(Value::as_str)
163+ .is_some_and(|actual| actual == expected)
164+ })
165+}
166+ 
167+fn forked_provider_options(
168+ parent_options: &Value,
169+ override_options: Option<&Value>,
170+ snapshot_id: &str,
171+) -> Value {
172+ let mut options = parent_options.as_object().cloned().unwrap_or_default();
173+ if let Some(overrides) = override_options.and_then(Value::as_object) {
174+ for (key, value) in overrides {
175+ options.insert(key.clone(), value.clone());
176+ }
177+ }
178+ options.insert(
179+ "template_id".to_string(),
180+ Value::String(snapshot_id.to_string()),
181+ );
182+ Value::Object(options)
183+}
184+ 
185+fn detach_from_parent(
186+ state: &mut BackendManagerState,
187+ backend_id: &BackendId,
188+ entry: &BackendInstanceEntry,
189+) {
190+ let Some(parent_backend_id) = entry.lineage.parent_backend_id.as_ref() else {
191+ return;
192+ };
193+ if let Some(parent) = state.backends.get_mut(parent_backend_id) {
194+ parent.lineage.children_backend_ids.remove(&backend_id.0);
195+ }
196+}
197+ 
198+fn backend_tree_node(
199+ state: &BackendManagerState,
200+ backend_id: &BackendId,
201+) -> Option<BackendTreeNode> {
202+ let entry = state.backends.get(backend_id)?;
203+ let mut child_ids = entry
204+ .lineage
205+ .children_backend_ids
206+ .keys()
207+ .filter_map(|id| {
208+ state
209+ .backends
210+ .contains_key(&BackendId(id.clone()))
211+ .then(|| id.clone())
212+ })
213+ .collect::<Vec<_>>();
214+ child_ids.sort();
215+ let children = child_ids
216+ .into_iter()
217+ .filter_map(|id| backend_tree_node(state, &BackendId(id)))
218+ .collect();
219+ Some(BackendTreeNode {
220+ backend: entry.info(),
221+ children,
222+ })
223+}
224+ 
225+struct BuildBackendInput {
226+ backend_id: BackendId,
227+ config: GatewayBackendConfig,
228+ workspace_root_text: String,
229+ config_hash: u64,
230+ session_id_for_instance: String,
231+ session_id: Option<String>,
232+ resource_limits: BackendResourceLimits,
233+ metadata: Value,
234+ expires_at_ms: Option<u64>,
235+ lineage: BackendLineageEntry,
236+ backend_checkpoint: Option<BackendCheckpointRef>,
237+}
238+ 
239+async fn build_backend(input: BuildBackendInput) -> Result<BackendInstanceEntry, BackendError> {
240+ if input.config.kind == "e2b" {
241+ let created = e2b::create_backend(e2b::E2bCreateBackendInput {
242+ backend_id: input.backend_id,
243+ session_id_for_instance: input.session_id_for_instance,
244+ workspace_root_text: input.workspace_root_text.clone(),
245+ provider_options: input.config.options.clone(),
246+ resource_limits: input.resource_limits,
247+ metadata: input.metadata,
248+ })
249+ .await?;
250+ let dirty_tracker = Arc::new(BackendDirtyTracker::default());
251+ if let Some(checkpoint) = input.backend_checkpoint {
252+ dirty_tracker.set_checkpoint(checkpoint);
253+ }
254+ return Ok(BackendInstanceEntry {
255+ backend: DirtyTrackedOperationBackend::wrap(
256+ created.backend,
257+ Arc::clone(&dirty_tracker),
258+ ),
259+ instance: created.instance,
260+ config: input.config,
261+ workspace_root: input.workspace_root_text,
262+ config_hash: input.config_hash,
263+ session_ids: input
264+ .session_id
265+ .map(|session_id| BTreeMap::from([(session_id, ())]))
266+ .unwrap_or_default(),
267+ expires_at_ms: input.expires_at_ms,
268+ lineage: input.lineage,
269+ dirty_tracker,
270+ });
271+ }
272+ 
273+ let provider = local_provider_for_kind(&input.config.kind)?;
274+ let lifecycle = provider.lifecycle();
275+ let instance = lifecycle
276+ .create_sandbox(ProviderBackendCreateRequest {
277+ requested_backend_id: Some(input.backend_id),
278+ session_id: input.session_id_for_instance,
279+ conversation_id: None,
280+ workspace_root: BackendPath(input.workspace_root_text.clone()),
281+ provider_options: input.config.options.clone(),
282+ resource_limits: input.resource_limits,
283+ metadata: input.metadata,
284+ })
285+ .await
286+ .map_err(BackendError::from_control_error)?;
287+ let backend = provider
288+ .attach(instance.clone())
289+ .await
290+ .map_err(BackendError::from_control_error)?;
291+ let dirty_tracker = Arc::new(BackendDirtyTracker::default());
292+ if let Some(checkpoint) = input.backend_checkpoint {
293+ dirty_tracker.set_checkpoint(checkpoint);
294+ }
295+ Ok(BackendInstanceEntry {
296+ backend: DirtyTrackedOperationBackend::wrap(backend, Arc::clone(&dirty_tracker)),
297+ instance,
298+ config: input.config,
299+ workspace_root: input.workspace_root_text,
300+ config_hash: input.config_hash,
301+ session_ids: input
302+ .session_id
303+ .map(|session_id| BTreeMap::from([(session_id, ())]))
304+ .unwrap_or_default(),
305+ expires_at_ms: input.expires_at_ms,
306+ lineage: input.lineage,
307+ dirty_tracker,
308+ })
309+}
310+ 
311+fn local_provider_for_kind(
312+ kind: &str,
313+) -> Result<operation_backend::LocalBackendProvider, BackendError> {
314+ match kind {
315+ "local" => Ok(operation_backend::local_backend_provider()),
316+ other => Err(BackendError::UnsupportedBackend {
317+ kind: other.to_string(),
318+ }),
319+ }
320+}
321+ 
322+async fn delete_backend_instance(
323+ instance: BackendInstanceEntry,
324+ reason: BackendLifecycleReason,
325+) -> Result<(), OperationError> {
326+ if instance.config.kind == "e2b" {
327+ instance.backend.shutdown().await?;
328+ return Ok(());
329+ }
330+ 
331+ let provider = local_provider_for_kind(&instance.config.kind)
332+ .map_err(BackendError::into_operation_error)?;
333+ provider
334+ .delete(BackendDeleteRequest {
335+ backend_id: instance.instance.backend_id,
336+ instance_id: Some(instance.instance.instance_id),
337+ snapshot_id: instance
338+ .instance
339+ .snapshot
340+ .map(|snapshot| snapshot.snapshot_id),
341+ force: false,
342+ reason,
343+ metadata: instance.instance.metadata,
344+ })
345+ .await
346+ .map_err(control_error_to_operation_error)?;
347+ Ok(())
348+}
349+ 
350+fn control_error_to_operation_error(error: BackendControlError) -> OperationError {
351+ OperationError::Transport {
352+ message: error.to_string(),
353+ }
354+}
355+ 
356+impl BackendInstanceEntry {
357+ fn info(&self) -> BackendInfo {
358+ let session_ids = self.session_ids.keys().cloned().collect::<Vec<_>>();
359+ BackendInfo {
360+ backend_id: self.instance.backend_id.0.clone(),
361+ provider: self.instance.provider.0.clone(),
362+ instance_id: self.instance.instance_id.0.clone(),
363+ state: self.instance.state,
364+ workspace_root: self.instance.workspace_root.0.clone(),
365+ endpoint: self.instance.endpoint.clone(),
366+ metadata: self.instance.metadata.clone(),
367+ resources: self.instance.resources,
368+ session_id: if session_ids.len() == 1 {
369+ session_ids.first().cloned()
370+ } else {
371+ None
372+ },
373+ session_ids,
374+ expires_at_ms: self.expires_at_ms,
375+ lineage: BackendLineageInfo {
376+ parent_backend_id: self
377+ .lineage
378+ .parent_backend_id
379+ .as_ref()
380+ .map(|id| id.0.clone()),
381+ children_backend_ids: self.lineage.children_backend_ids.keys().cloned().collect(),
382+ forked_from_snapshot_id: self.lineage.forked_from_snapshot_id.clone(),
383+ forked_snapshot_names: self.lineage.forked_snapshot_names.clone(),
384+ forked_at_ms: self.lineage.forked_at_ms,
385+ },
386+ }
387+ }
388+}
389+ 
390+#[cfg(test)]
391+mod tests {
392+ use super::*;
393+ use agent_contracts::backend::{
394+ capability::{
395+ exec::ExecRequest,
396+ filesystem::{WriteBytesRequest, WriteMode},
397+ },
398+ BackendLifecycle, BackendLifecycleState, BackendPath, BackendPauseMode,
399+ BackendPauseRequest,
400+ };
401+ use serde_json::json;
402+ use tempfile::TempDir;
403+ 
404+ fn local_request(
405+ session_id: &str,
406+ workspace_root: PathBuf,
407+ options: Value,
408+ ) -> BackendEnsureSessionRequest {
409+ BackendEnsureSessionRequest {
410+ config: Some(GatewayBackendConfig::new("local", options)),
411+ workspace_root,
412+ session_id: session_id.to_string(),
413+ }
414+ }
415+ 
416+ fn temp_options(workspace: &TempDir) -> Value {
417+ json!({"temp_root": workspace.path().to_string_lossy().to_string()})
418+ }
419+ 
420+ #[test]
421+ fn backend_config_hash_is_stable_for_reordered_config() {
422+ let left = GatewayBackendConfig::new(
423+ "local",
424+ json!({"home_dir": "/home/user", "temp_root": "/tmp/xiaoo"}),
425+ );
426+ let right = GatewayBackendConfig::new(
427+ "local",
428+ json!({"temp_root": "/tmp/xiaoo", "home_dir": "/home/user"}),
429+ );
430+ 
431+ assert_eq!(hash_config(&left), hash_config(&right));
432+ }
433+ 
434+ #[test]
435+ fn non_local_backend_is_unsupported() {
436+ let config =
437+ resolve_session_backend_config(Some(GatewayBackendConfig::new("docker", Value::Null)));
438+ 
439+ assert!(matches!(
440+ config,
441+ Err(OperationBackendBuildError::UnsupportedBackend { kind }) if kind == "docker"
442+ ));
443+ }
444+ 
445+ #[tokio::test]
446+ async fn manager_reuses_backend_for_same_session_and_config() {
447+ let workspace = TempDir::new().expect("workspace");
448+ let manager = BackendManager::new();
449+ let request = local_request(
450+ "s1",
451+ workspace.path().to_path_buf(),
452+ temp_options(&workspace),
453+ );
454+ 
455+ let first = manager
456+ .ensure_session_backend(request.clone())
457+ .await
458+ .expect("first lease");
459+ let second = manager
460+ .ensure_session_backend(request)
461+ .await
462+ .expect("second lease");
463+ 
464+ let first_backend = first.backend();
465+ let second_backend = second.backend();
466+ assert_eq!(first.instance(), second.instance());
467+ assert!(first.instance().backend_id.0.starts_with("bkd_"));
468+ assert_ne!(first.instance().backend_id.0, "s1");
469+ assert!(Arc::ptr_eq(&first_backend, &second_backend));
470+ }
471+ 
472+ #[tokio::test]
473+ async fn manager_does_not_implicitly_reuse_backend_across_sessions() {
474+ let workspace = TempDir::new().expect("workspace");
475+ let manager = BackendManager::new();
476+ let root = workspace.path().to_path_buf();
477+ let base = local_request("s1", root.clone(), temp_options(&workspace));
478+ let other_session = local_request("s2", root.clone(), temp_options(&workspace));
479+ let other_config = local_request(
480+ "s1",
481+ root,
482+ json!({
483+ "temp_root": workspace.path().to_string_lossy().to_string(),
484+ "default_shell": "/bin/sh"
485+ }),
486+ );
487+ 
488+ let base_backend = manager
489+ .ensure_session_backend(base)
490+ .await
491+ .expect("base lease")
492+ .backend();
493+ let other_session_backend = manager
494+ .ensure_session_backend(other_session)
495+ .await
496+ .expect("other session lease")
497+ .backend();
498+ let other_config = manager.ensure_session_backend(other_config).await;
499+ 
500+ assert!(!Arc::ptr_eq(&base_backend, &other_session_backend));
501+ assert!(matches!(
502+ other_config,
503+ Err(OperationBackendBuildError::InvalidConfig { .. })
504+ | Err(OperationBackendBuildError::BuildFailed { .. })
505+ ));
506+ }
507+ 
508+ #[tokio::test]
509+ async fn independent_backend_create_generates_backend_id_and_supports_get_list_delete() {
510+ let workspace = TempDir::new().expect("workspace");
511+ let manager = BackendManager::new();
512+ let created = manager
513+ .create_backend(BackendCreateRequest {
514+ workspace_root: workspace.path().to_path_buf(),
515+ backend_id: None,
516+ provider: None,
517+ session_id: None,
518+ timeout: Some(60),
519+ metadata: json!({"user": "abc", "app": "prod"}),
520+ resource_limits: BackendResourceLimits::default(),
521+ options: Some(temp_options(&workspace)),
522+ })
523+ .await
524+ .expect("create managed backend");
525+ 
526+ assert!(created.backend_id.starts_with("bkd_"));
527+ assert_eq!(created.session_id, None);
528+ assert!(created.expires_at_ms.is_some());
529+ 
530+ let fetched = manager
531+ .get_backend(&created.backend_id)
532+ .await
533+ .expect("get managed backend");
534+ assert_eq!(fetched.backend_id, created.backend_id);
535+ 
536+ let listed = manager
537+ .list_backends(BackendListFilter {
538+ metadata: BTreeMap::from([("user".to_string(), "abc".to_string())]),
539+ })
540+ .await;
541+ assert_eq!(listed.len(), 1);
542+ assert_eq!(listed[0].backend_id, created.backend_id);
543+ 
544+ manager
545+ .delete_backend(&created.backend_id)
546+ .await
547+ .expect("delete managed backend");
548+ assert!(matches!(
549+ manager.get_backend(&created.backend_id).await,
550+ Err(BackendError::NotFound { .. })
551+ ));
552+ }
553+ 
554+ #[tokio::test]
555+ async fn backend_create_allows_session_id_and_independent_backend_id() {
556+ let workspace = TempDir::new().expect("workspace");
557+ let manager = BackendManager::new();
558+ let created = manager
559+ .create_backend(BackendCreateRequest {
560+ workspace_root: workspace.path().to_path_buf(),
561+ backend_id: Some("backend-a".to_string()),
562+ provider: None,
563+ session_id: Some("session-a".to_string()),
564+ timeout: None,
565+ metadata: Value::Null,
566+ resource_limits: BackendResourceLimits::default(),
567+ options: Some(temp_options(&workspace)),
568+ })
569+ .await
570+ .expect("create managed backend");
571+ 
572+ assert_eq!(created.backend_id, "backend-a");
573+ assert_eq!(created.session_id.as_deref(), Some("session-a"));
574+ assert_eq!(created.session_ids, vec!["session-a".to_string()]);
575+ 
576+ let lease = manager
577+ .ensure_session_backend(local_request(
578+ "session-a",
579+ workspace.path().to_path_buf(),
580+ temp_options(&workspace),
581+ ))
582+ .await
583+ .expect("session lease");
584+ assert_eq!(lease.instance().backend_id.0, "backend-a");
585+ }
586+ 
587+ #[tokio::test]
588+ async fn connect_backend_explicitly_attaches_session_to_existing_backend() {
589+ let workspace = TempDir::new().expect("workspace");
590+ let manager = BackendManager::new();
591+ let root = workspace.path().to_path_buf();
592+ let first = manager
593+ .ensure_session_backend(local_request("s1", root.clone(), temp_options(&workspace)))
594+ .await
595+ .expect("first lease");
596+ let backend_id = first.instance().backend_id.0;
597+ 
598+ let connected = manager
599+ .connect_backend(
600+ &backend_id,
601+ BackendConnectRequest {
602+ timeout: None,
603+ session_id: Some("s2".to_string()),
604+ },
605+ )
606+ .await
607+ .expect("connect managed backend");
608+ assert_eq!(
609+ connected.session_ids,
610+ vec!["s1".to_string(), "s2".to_string()]
611+ );
612+ 
613+ let second = manager
614+ .ensure_session_backend(local_request("s2", root, temp_options(&workspace)))
615+ .await
616+ .expect("second lease");
617+ assert_eq!(first.instance(), second.instance());
618+ assert!(Arc::ptr_eq(&first.backend(), &second.backend()));
619+ }
620+ 
621+ #[tokio::test]
622+ async fn fork_backend_rejects_non_e2b_parent() {
623+ let workspace = TempDir::new().expect("workspace");
624+ let manager = BackendManager::new();
625+ let parent = manager
626+ .ensure_session_backend(local_request(
627+ "s1",
628+ workspace.path().to_path_buf(),
629+ temp_options(&workspace),
630+ ))
631+ .await
632+ .expect("local backend");
633+ 
634+ let forked = manager
635+ .fork_backend(BackendForkRequest {
636+ parent_backend_id: Some(parent.instance().backend_id.0),
637+ parent_session_id: Some("s1".to_string()),
638+ backend_id: Some("child".to_string()),
639+ session_id: Some("s2".to_string()),
640+ ..Default::default()
641+ })
642+ .await;
643+ 
644+ assert!(matches!(
645+ forked,
646+ Err(BackendError::UnsupportedBackend { kind }) if kind == "local:checkout"
647+ ));
648+ }
649+ 
650+ #[tokio::test]
651+ async fn clean_backend_checkpoint_reuses_previous_ref() {
652+ let workspace = TempDir::new().expect("workspace");
653+ let manager = BackendManager::new();
654+ let lease = manager
655+ .ensure_session_backend(local_request(
656+ "s-checkpoint",
657+ workspace.path().to_path_buf(),
658+ temp_options(&workspace),
659+ ))
660+ .await
661+ .expect("local backend");
662+ 
663+ let first = manager
664+ .checkpoint_backend(BackendCheckpointRequest {
665+ backend_id: Some(lease.instance().backend_id.0.clone()),
666+ session_id: Some("s-checkpoint".to_string()),
667+ name: Some("first".to_string()),
668+ metadata: json!({"step": 1}),
669+ })
670+ .await
671+ .expect("first checkpoint");
672+ let second = manager
673+ .checkpoint_backend(BackendCheckpointRequest {
674+ backend_id: Some(lease.instance().backend_id.0),
675+ session_id: Some("s-checkpoint".to_string()),
676+ name: Some("second".to_string()),
677+ metadata: json!({"step": 2}),
678+ })
679+ .await
680+ .expect("second checkpoint");
681+ 
682+ assert!(!first.reused);
683+ assert!(second.reused);
684+ assert_eq!(
685+ second.checkpoint.checkpoint_id,
686+ first.checkpoint.checkpoint_id
687+ );
688+ assert_eq!(second.checkpoint.provider, "local");
689+ assert_eq!(second.checkpoint.provider_snapshot_id, None);
690+ }
691+ 
692+ #[tokio::test]
693+ async fn write_and_exec_mark_backend_dirty_for_next_checkpoint() {
694+ let workspace = TempDir::new().expect("workspace");
695+ let manager = BackendManager::new();
696+ let lease = manager
697+ .ensure_session_backend(local_request(
698+ "s-dirty",
699+ workspace.path().to_path_buf(),
700+ temp_options(&workspace),
701+ ))
702+ .await
703+ .expect("local backend");
704+ let backend_id = lease.instance().backend_id.0.clone();
705+ let baseline = manager
706+ .checkpoint_backend(BackendCheckpointRequest {
707+ backend_id: Some(backend_id.clone()),
708+ session_id: Some("s-dirty".to_string()),
709+ name: None,
710+ metadata: Value::Null,
711+ })
712+ .await
713+ .expect("baseline checkpoint");
714+ 
715+ lease
716+ .backend()
717+ .files()
718+ .write_bytes(WriteBytesRequest {
719+ path: BackendPath(
720+ workspace
721+ .path()
722+ .join("dirty.txt")
723+ .to_string_lossy()
724+ .to_string(),
725+ ),
726+ content: b"changed".to_vec(),
727+ mode: WriteMode::Overwrite,
728+ })
729+ .await
730+ .expect("write file");
731+ let after_write = manager
732+ .checkpoint_backend(BackendCheckpointRequest {
733+ backend_id: Some(backend_id.clone()),
734+ session_id: Some("s-dirty".to_string()),
735+ name: None,
736+ metadata: Value::Null,
737+ })
738+ .await
739+ .expect("checkpoint after write");
740+ 
741+ assert!(!after_write.reused);
742+ assert_ne!(
743+ after_write.checkpoint.checkpoint_id,
744+ baseline.checkpoint.checkpoint_id
745+ );
746+ 
747+ lease
748+ .backend()
749+ .exec()
750+ .exec(ExecRequest {
751+ command: "printf ok".to_string(),
752+ args: Vec::new(),
753+ shell: Some("/bin/sh".to_string()),
754+ cwd: Some(BackendPath(workspace.path().to_string_lossy().to_string())),
755+ timeout_ms: Some(5_000),
756+ env: None,
757+ })
758+ .await
759+ .expect("exec command");
760+ let after_exec = manager
761+ .checkpoint_backend(BackendCheckpointRequest {
762+ backend_id: Some(backend_id),
763+ session_id: Some("s-dirty".to_string()),
764+ name: None,
765+ metadata: Value::Null,
766+ })
767+ .await
768+ .expect("checkpoint after exec");
769+ 
770+ assert!(!after_exec.reused);
771+ assert_ne!(
772+ after_exec.checkpoint.checkpoint_id,
773+ after_write.checkpoint.checkpoint_id
774+ );
775+ }
776+ 
777+ #[tokio::test]
778+ async fn non_snapshot_provider_checkout_is_unsupported() {
779+ let workspace = TempDir::new().expect("workspace");
780+ let manager = BackendManager::new();
781+ manager
782+ .ensure_session_backend(local_request(
783+ "s-local-checkout",
784+ workspace.path().to_path_buf(),
785+ temp_options(&workspace),
786+ ))
787+ .await
788+ .expect("local backend");
789+ let checkpoint = manager
790+ .checkpoint_backend(BackendCheckpointRequest {
791+ backend_id: None,
792+ session_id: Some("s-local-checkout".to_string()),
793+ name: None,
794+ metadata: Value::Null,
795+ })
796+ .await
797+ .expect("checkpoint");
798+ 
799+ let checkout = manager
800+ .checkout_backend(BackendCheckoutRequest {
801+ checkpoint: checkpoint.checkpoint,
802+ backend_id: None,
803+ session_id: Some("child".to_string()),
804+ timeout: None,
805+ metadata: Value::Null,
806+ resource_limits: BackendResourceLimits::default(),
807+ options: None,
808+ })
809+ .await;
810+ 
811+ assert!(matches!(
812+ checkout,
813+ Err(BackendError::UnsupportedBackend { kind }) if kind == "local:checkout"
814+ ));
815+ }
816+ 
817+ #[tokio::test]
818+ async fn list_backend_trees_uses_recorded_lineage() {
819+ let workspace = TempDir::new().expect("workspace");
820+ let manager = BackendManager::new();
821+ let root = workspace.path().to_path_buf();
822+ let parent = manager
823+ .ensure_session_backend(local_request(
824+ "parent",
825+ root.clone(),
826+ temp_options(&workspace),
827+ ))
828+ .await
829+ .expect("parent backend")
830+ .instance()
831+ .backend_id;
832+ let child = manager
833+ .ensure_session_backend(local_request("child", root, temp_options(&workspace)))
834+ .await
835+ .expect("child backend")
836+ .instance()
837+ .backend_id;
838+ 
839+ {
840+ let mut state = manager.state.lock().await;
841+ state
842+ .backends
843+ .get_mut(&parent)
844+ .expect("parent entry")
845+ .lineage
846+ .children_backend_ids
847+ .insert(child.0.clone(), ());
848+ let child_entry = state.backends.get_mut(&child).expect("child entry");
849+ child_entry.lineage.parent_backend_id = Some(parent.clone());
850+ child_entry.lineage.forked_from_snapshot_id = Some("snap:default".to_string());
851+ }
852+ 
853+ let forest = manager.list_backend_trees().await;
854+ assert_eq!(forest.len(), 1);
855+ assert_eq!(forest[0].backend.backend_id, parent.0);
856+ assert_eq!(forest[0].children.len(), 1);
857+ assert_eq!(forest[0].children[0].backend.backend_id, child.0);
858+ assert_eq!(
859+ forest[0].children[0]
860+ .backend
861+ .lineage
862+ .forked_from_snapshot_id
863+ .as_deref(),
864+ Some("snap:default")
865+ );
866+ }
867+ 
868+ #[tokio::test]
869+ async fn release_session_deletes_local_backend_cache() {
870+ let workspace = TempDir::new().expect("workspace");
871+ let manager = BackendManager::new();
872+ let request = local_request(
873+ "s1",
874+ workspace.path().to_path_buf(),
875+ temp_options(&workspace),
876+ );
877+ 
878+ let first_backend: Arc<_> = manager
879+ .ensure_session_backend(request.clone())
880+ .await
881+ .expect("first lease")
882+ .backend();
883+ manager.release_session("s1").await.expect("release");
884+ let second_backend = manager
885+ .ensure_session_backend(request)
886+ .await
887+ .expect("second lease")
888+ .backend();
889+ 
890+ assert!(!Arc::ptr_eq(&first_backend, &second_backend));
891+ }
892+ 
893+ #[tokio::test]
894+ async fn local_provider_delete_succeeds_and_pause_is_unsupported() {
895+ let workspace = TempDir::new().expect("workspace");
896+ let provider = operation_backend::local_backend_provider();
897+ let lease = BackendManager::new()
898+ .ensure_session_backend(local_request(
899+ "s1",
900+ workspace.path().to_path_buf(),
901+ temp_options(&workspace),
902+ ))
903+ .await
904+ .expect("local backend");
905+ let instance = lease.instance();
906+ 
907+ let pause = provider
908+ .pause(BackendPauseRequest {
909+ backend_id: instance.backend_id.clone(),
910+ instance_id: instance.instance_id.clone(),
911+ mode: BackendPauseMode::BestEffort,
912+ reason: BackendLifecycleReason::UserRequested,
913+ metadata: Value::Null,
914+ })
915+ .await;
916+ assert!(matches!(
917+ pause,
918+ Err(agent_contracts::backend::BackendControlError::UnsupportedCapability { .. })
919+ ));
920+ 
921+ let outcome = provider
922+ .delete(BackendDeleteRequest {
923+ backend_id: instance.backend_id.clone(),
924+ instance_id: Some(instance.instance_id.clone()),
925+ snapshot_id: None,
926+ force: false,
927+ reason: BackendLifecycleReason::SessionClose,
928+ metadata: instance.metadata.clone(),
929+ })
930+ .await
931+ .expect("delete");
932+ 
933+ assert_eq!(outcome.backend_id, instance.backend_id);
934+ assert_eq!(outcome.instance_id, Some(instance.instance_id));
935+ assert_eq!(outcome.state, BackendLifecycleState::Deleted);
936+ }
937+}
Rapps/xiaoo-app/src/builtin_agent_roles.rsapps/shared/src/builtin_agent_roles.rs+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/channels/adapter.rsapps/shared/src/channels/adapter.rs+1-1
@@ -1,5 +1,5 @@
1use async_trait::async_trait;1use async_trait::async_trait;
2-use axum::http::HeaderMap;2+use http::HeaderMap;
3use serde::{Deserialize, Serialize};3use serde::{Deserialize, Serialize};
4use serde_json::Value;4use serde_json::Value;
5use std::collections::HashMap;5use std::collections::HashMap;
Aapps/shared/src/channels/mod.rs+8-0
@@ -0,0 +1,8 @@
1+pub mod adapter;
2+ 
3+pub use adapter::{
4+ AdapterResponse, ChannelAdapter, ChannelAttachment, ChannelCapabilities, ChannelError,
5+ ChannelMember, ChannelMention, ChannelMessage, ChannelMeta, ChannelOutboundAttachment,
6+ ChannelOutboundAttachmentKind, ChannelProgressSection, ChannelProgressState,
7+ ChannelProgressUpdate, ChannelResult, ChannelRuntime, ChannelTextFormat,
8+};
Rapps/xiaoo-app/src/gateway/bootstrap.rsapps/shared/src/gateway/bootstrap.rs+11-10
@@ -1,6 +1,8 @@
1+use crate::backend::BackendManager;
1use crate::gateway::{2use crate::gateway::{
2- backend::ExternalBackendManager, CoreBackedSessionService, SessionControlPlane,3+ CoreBackedSessionService, ResolvedSessionRuntime, SessionControlPlane,
3- SessionRuntimeResolver, SessionService, SessionStore,4+ SessionRuntimeBuildInput, SessionRuntimeResolveError, SessionRuntimeResolver, SessionService,
5+ SessionStore,
4};6};
5use agent_types::hook::HookerRegistryConfig;7use agent_types::hook::HookerRegistryConfig;
6use async_trait::async_trait;8use async_trait::async_trait;
@@ -12,7 +14,7 @@ use thiserror::Error;
12pub struct AppDependencies {14pub struct AppDependencies {
13 pub session_service: Arc<dyn SessionService>,15 pub session_service: Arc<dyn SessionService>,
14 pub session_control_plane: Arc<dyn SessionControlPlane>,16 pub session_control_plane: Arc<dyn SessionControlPlane>,
15- pub backend_manager: Arc<ExternalBackendManager>,17+ pub backend_manager: Arc<BackendManager>,
16}18}
17 19 
18pub struct AppBootstrap;20pub struct AppBootstrap;
@@ -33,11 +35,10 @@ struct NoopSessionRuntimeResolver;
33impl SessionRuntimeResolver for NoopSessionRuntimeResolver {35impl SessionRuntimeResolver for NoopSessionRuntimeResolver {
34 async fn resolve(36 async fn resolve(
35 &self,37 &self,
36- _request: &crate::gateway::SessionRuntimeBuildInput,38+ _request: &SessionRuntimeBuildInput,
37 _existing: Option<&crate::gateway::SessionRecord>,39 _existing: Option<&crate::gateway::SessionRecord>,
38- ) -> Result<crate::gateway::ResolvedSessionRuntime, crate::gateway::SessionRuntimeResolveError>40+ ) -> Result<ResolvedSessionRuntime, SessionRuntimeResolveError> {
39- {41+ Err(SessionRuntimeResolveError::ResolveFailed {
40- Err(crate::gateway::SessionRuntimeResolveError::ResolveFailed {
41 message: "lifecycle-only control plane cannot resolve runtimes".to_string(),42 message: "lifecycle-only control plane cannot resolve runtimes".to_string(),
42 })43 })
43 }44 }
@@ -50,7 +51,7 @@ impl AppBootstrap {
50 pub fn lifecycle_only(51 pub fn lifecycle_only(
51 session_store: Arc<dyn SessionStore>,52 session_store: Arc<dyn SessionStore>,
52 hooker_config: HookerRegistryConfig,53 hooker_config: HookerRegistryConfig,
53- backend_manager: Arc<ExternalBackendManager>,54+ backend_manager: Arc<BackendManager>,
54 ) -> Result<AppDependencies, AppBootstrapError> {55 ) -> Result<AppDependencies, AppBootstrapError> {
55 let resolver: Arc<dyn SessionRuntimeResolver> = Arc::new(NoopSessionRuntimeResolver);56 let resolver: Arc<dyn SessionRuntimeResolver> = Arc::new(NoopSessionRuntimeResolver);
56 Self::from_session_components_with_hooks_and_backend_manager(57 Self::from_session_components_with_hooks_and_backend_manager(
@@ -81,7 +82,7 @@ impl AppBootstrap {
81 session_store,82 session_store,
82 runtime_resolver,83 runtime_resolver,
83 hooker_config,84 hooker_config,
84- Arc::new(ExternalBackendManager::new()),85+ Arc::new(BackendManager::new()),
85 )86 )
86 }87 }
87 88 
@@ -89,7 +90,7 @@ impl AppBootstrap {
89 session_store: Arc<dyn SessionStore>,90 session_store: Arc<dyn SessionStore>,
90 runtime_resolver: Arc<dyn SessionRuntimeResolver>,91 runtime_resolver: Arc<dyn SessionRuntimeResolver>,
91 hooker_config: HookerRegistryConfig,92 hooker_config: HookerRegistryConfig,
92- backend_manager: Arc<ExternalBackendManager>,93+ backend_manager: Arc<BackendManager>,
93 ) -> Result<AppDependencies, AppBootstrapError> {94 ) -> Result<AppDependencies, AppBootstrapError> {
94 let hooker_registry = HookerRegistryBuilderImpl::new()95 let hooker_registry = HookerRegistryBuilderImpl::new()
95 .with_config(hooker_config)96 .with_config(hooker_config)
Rapps/xiaoo-app/src/gateway/channel_interaction.rsapps/shared/src/gateway/channel_interaction.rs+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/gateway/decrypted_api_keys.rsapps/shared/src/gateway/decrypted_api_keys.rs+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/gateway/hosted_runtime_resolver.rsapps/shared/src/gateway/hosted_runtime_resolver.rs+3-2
@@ -1,4 +1,4 @@
1-use crate::gateway::backend::GatewayBackendConfig;1+use crate::backend::GatewayBackendConfig;
2use crate::gateway::prompt_utils::{compose_subagent_delegation_rules, generate_skills_dirs_table};2use crate::gateway::prompt_utils::{compose_subagent_delegation_rules, generate_skills_dirs_table};
3use crate::gateway::{3use crate::gateway::{
4 compose_repo_map, compose_workspace_system_prompt, ResolvedSessionRuntime, SessionRecord,4 compose_repo_map, compose_workspace_system_prompt, ResolvedSessionRuntime, SessionRecord,
@@ -37,7 +37,8 @@ pub struct SubagentRoleConfigEntry {
37/// Slim system prompt for spawned exploration subagents, instead of the parent's37/// Slim system prompt for spawned exploration subagents, instead of the parent's
38/// full composed prompt (identity, output-economy rules, delegation guidance the38/// full composed prompt (identity, output-economy rules, delegation guidance the
39/// child cannot act on, skills catalog), which would be re-sent every child turn.39/// child cannot act on, skills catalog), which would be re-sent every child turn.
40-const SUBAGENT_SYSTEM_PROMPT: &str = include_str!("../prompts/subagent_system_prompt.txt");40+const SUBAGENT_SYSTEM_PROMPT: &str =
41+ include_str!("../../../endside/src/prompts/subagent_system_prompt.txt");
41 42 
42/// Tools a read-only exploration subagent receives: the minimal search/read set.43/// Tools a read-only exploration subagent receives: the minimal search/read set.
43/// Allowlist, not blocklist — every other tool (mutation, side effects, user44/// Allowlist, not blocklist — every other tool (mutation, side effects, user
Rapps/xiaoo-app/src/gateway/mod.rsapps/shared/src/gateway/mod.rs+21-23
@@ -1,51 +1,49 @@
1-pub mod backend;
2pub mod bootstrap;1pub mod bootstrap;
3pub mod channel_interaction;2pub mod channel_interaction;
4-pub mod core_session_service;
5pub mod decrypted_api_keys;3pub mod decrypted_api_keys;
6pub mod hosted_runtime_resolver;4pub mod hosted_runtime_resolver;
7pub mod pending_interaction;5pub mod pending_interaction;
8pub mod permission_backend;6pub mod permission_backend;
9pub mod progress_updates;7pub mod progress_updates;
10pub mod prompt_utils;8pub mod prompt_utils;
11-pub mod runtime_bindings;
12-pub mod runtime_factory;
13-pub mod runtime_resolver;
14mod session_backend;9mod session_backend;
15-pub mod session_keys;10+pub mod session_base;
16-pub mod session_protocol;11+mod session_handle;
17pub mod session_record;12pub mod session_record;
13+pub mod session_runtime;
18pub mod session_service;14pub mod session_service;
15+pub mod session_service_impl;
19pub mod session_store;16pub mod session_store;
20pub mod session_supervisor;17pub mod session_supervisor;
21pub mod session_worker;18pub mod session_worker;
22pub mod subagent_interaction;19pub mod subagent_interaction;
23-pub mod turn_request;20+pub mod turns;
24-pub mod turn_result;
25pub mod workspace_prompt;21pub mod workspace_prompt;
26 22 
27-pub use decrypted_api_keys::{get_decrypted_api_key, init_secret_provider, SecretProvider};23+pub use decrypted_api_keys::{SecretProvider, get_decrypted_api_key, init_secret_provider};
28 24 
29pub use bootstrap::{AppBootstrap, AppBootstrapError, AppDependencies};25pub use bootstrap::{AppBootstrap, AppBootstrapError, AppDependencies};
30-pub use core_session_service::CoreBackedSessionService;
31pub use hosted_runtime_resolver::{26pub use hosted_runtime_resolver::{
32 HostedSessionRuntimeConfig, HostedSessionRuntimeResolver, SubagentRoleConfigEntry,27 HostedSessionRuntimeConfig, HostedSessionRuntimeResolver, SubagentRoleConfigEntry,
33};28};
34pub use progress_updates::ChannelProgressRelayHandle;29pub use progress_updates::ChannelProgressRelayHandle;
35-pub use runtime_bindings::SessionRuntimeBindings;30+pub use session_base::{
36-pub use runtime_factory::{AppRuntimeAssembly, AppRuntimeFactory, AppRuntimeFactoryError};31+ RuntimeCancelRequest, RuntimeCloseRequest, RuntimeInteractionRequest, RuntimeOpenRequest,
37-pub use runtime_resolver::{32+ SessionCancelRequest, SessionCloseRequest, SessionForkRequest, SessionForkResult, SessionInput,
38- ResolvedSessionRuntime, SessionRuntimeBuildInput, SessionRuntimeDescriptor,33+ SessionInputKind, SessionInteractionRequest, SessionOpenRequest, SessionSubmitReceipt,
39- SessionRuntimeResolveError, SessionRuntimeResolver,34+ channel_session_id,
40-};
41-pub use session_keys::channel_session_id;
42-pub use session_protocol::{
43- SessionEvent, SessionInput, SessionInputKind, SessionOpenRequest, SessionStreamMode,
44- SessionSubmitReceipt, SessionSubscription,
45};35};
46pub use session_record::{SessionLifecycleStatus, SessionRecord};36pub use session_record::{SessionLifecycleStatus, SessionRecord};
37+pub use session_runtime::{
38+ AppRuntimeAssembly, AppRuntimeFactory, AppRuntimeFactoryError, ResolvedSessionRuntime,
39+ SessionRuntimeBindings, SessionRuntimeBuildInput, SessionRuntimeDescriptor,
40+ SessionRuntimeResolveError, SessionRuntimeResolver,
41+};
47pub use session_service::{SessionControlPlane, SessionService, SessionServiceError};42pub use session_service::{SessionControlPlane, SessionService, SessionServiceError};
43+pub use session_service_impl::CoreBackedSessionService;
48pub use session_store::{InMemorySessionStore, SessionStore, SessionStoreError};44pub use session_store::{InMemorySessionStore, SessionStore, SessionStoreError};
49-pub use turn_request::{AppTurnRequest, GatewayEntryContext, GatewayEntryKind, TurnMention};45+pub use turns::{
50-pub use turn_result::AppTurnResult;46+ AppTurnRequest, AppTurnResult, GatewayEntryContext, GatewayEntryKind, LlmRuntimeConfig,
47+ RuntimeTurnRequest, TurnMention,
48+};
51pub use workspace_prompt::{compose_repo_map, compose_workspace_system_prompt};49pub use workspace_prompt::{compose_repo_map, compose_workspace_system_prompt};
Rapps/xiaoo-app/src/gateway/pending_interaction.rsapps/shared/src/gateway/pending_interaction.rs+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/gateway/permission_backend.rsapps/shared/src/gateway/permission_backend.rs+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/gateway/progress_updates.rsapps/shared/src/gateway/progress_updates.rs+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/gateway/prompt_utils.rsapps/shared/src/gateway/prompt_utils.rs+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/gateway/session_backend.rsapps/shared/src/gateway/session_backend.rs+2-2
@@ -1,8 +1,8 @@
1-use crate::gateway::backend::{BackendEnsureSessionRequest, BackendLease, ExternalBackendManager};1+use crate::backend::{BackendEnsureSessionRequest, BackendLease, BackendManager};
2use crate::gateway::{ResolvedSessionRuntime, SessionRecord, SessionServiceError};2use crate::gateway::{ResolvedSessionRuntime, SessionRecord, SessionServiceError};
3 3 
4pub(super) async fn lease_session_backend(4pub(super) async fn lease_session_backend(
5- backend_manager: &ExternalBackendManager,5+ backend_manager: &BackendManager,
6 session: &SessionRecord,6 session: &SessionRecord,
7 resolved: &ResolvedSessionRuntime,7 resolved: &ResolvedSessionRuntime,
8) -> Result<BackendLease, SessionServiceError> {8) -> Result<BackendLease, SessionServiceError> {
Rapps/xiaoo-app/src/gateway/session_protocol.rsapps/shared/src/gateway/session_base.rs+80-48
@@ -1,9 +1,20 @@
1-use crate::gateway::{AppTurnRequest, AppTurnResult, GatewayEntryContext, SessionRecord};1+use crate::backend::BackendForkResult;
2-use agent_types::interaction::{InteractionRequest, InteractionResponse};2+use crate::gateway::{AppTurnRequest, GatewayEntryContext, LlmRuntimeConfig, SessionRecord};
3+use agent_types::interaction::InteractionResponse;
3use serde::{Deserialize, Serialize};4use serde::{Deserialize, Serialize};
4 5 
6+pub fn channel_session_id(
7+ channel: &str,
8+ channel_instance_id: Option<&str>,
9+ conversation_id: &str,
10+) -> String {
11+ let scope = channel_instance_id.unwrap_or(channel);
12+ format!("{scope}:{conversation_id}")
13+}
14+ 
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
6pub struct SessionOpenRequest {16pub struct SessionOpenRequest {
17+ #[serde(rename = "runtime_id", alias = "session_id")]
7 pub session_id: String,18 pub session_id: String,
8 pub conversation_id: String,19 pub conversation_id: String,
9 pub sender_id: String,20 pub sender_id: String,
@@ -13,6 +24,8 @@ pub struct SessionOpenRequest {
13 pub channel: Option<String>,24 pub channel: Option<String>,
14 #[serde(default)]25 #[serde(default)]
15 pub channel_instance_id: Option<String>,26 pub channel_instance_id: Option<String>,
27+ #[serde(default)]
28+ pub llm: Option<LlmRuntimeConfig>,
16}29}
17 30 
18impl SessionOpenRequest {31impl SessionOpenRequest {
@@ -31,17 +44,53 @@ impl SessionOpenRequest {
31 root_message_id: None,44 root_message_id: None,
32 mentions: Vec::new(),45 mentions: Vec::new(),
33 reasoning_effort: Default::default(),46 reasoning_effort: Default::default(),
47+ llm: self.llm,
34 }48 }
35 }49 }
36}50}
37 51 
38-#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]52+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
39-#[serde(rename_all = "snake_case")]53+pub struct SessionCloseRequest {
40-pub enum SessionStreamMode {54+ #[serde(rename = "runtime_id", alias = "session_id")]
41- StructuredEvents,55+ pub session_id: String,
42- TextDeltas,
43}56}
44 57 
58+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
59+pub struct SessionCancelRequest {
60+ #[serde(rename = "runtime_id", alias = "session_id")]
61+ pub session_id: String,
62+}
63+ 
64+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
65+pub struct SessionForkRequest {
66+ pub parent_session_id: String,
67+ #[serde(default)]
68+ pub conversation_id: Option<String>,
69+ #[serde(default)]
70+ pub sender_id: Option<String>,
71+ #[serde(default)]
72+ pub snapshot_name: Option<String>,
73+}
74+ 
75+#[derive(Debug, Clone, Serialize, Deserialize)]
76+pub struct SessionForkResult {
77+ pub parent: SessionRecord,
78+ pub child: SessionRecord,
79+ pub backend_fork: BackendForkResult,
80+}
81+ 
82+#[derive(Debug, Clone, Serialize, Deserialize)]
83+pub struct SessionInteractionRequest {
84+ #[serde(rename = "runtime_id", alias = "session_id")]
85+ pub session_id: String,
86+ pub response: InteractionResponse,
87+}
88+ 
89+pub type RuntimeOpenRequest = SessionOpenRequest;
90+pub type RuntimeCloseRequest = SessionCloseRequest;
91+pub type RuntimeCancelRequest = SessionCancelRequest;
92+pub type RuntimeInteractionRequest = SessionInteractionRequest;
93+ 
45#[derive(Debug, Clone, Serialize, Deserialize)]94#[derive(Debug, Clone, Serialize, Deserialize)]
46#[serde(tag = "kind", rename_all = "snake_case")]95#[serde(tag = "kind", rename_all = "snake_case")]
47pub enum SessionInput {96pub enum SessionInput {
@@ -75,46 +124,29 @@ pub struct SessionSubmitReceipt {
75 pub accepted_kind: SessionInputKind,124 pub accepted_kind: SessionInputKind,
76}125}
77 126 
78-#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]127+#[cfg(test)]
79-pub struct SessionSubscription {128+mod tests {
80- pub session_id: String,129+ use super::*;
81- pub subscription_id: String,
82- pub stream_mode: SessionStreamMode,
83-}
84 130 
85-#[derive(Debug, Clone)]131+ #[test]
86-pub enum SessionEvent {132+ fn runtime_open_request_serializes_runtime_id_and_accepts_legacy_session_id() {
87- SessionOpened {133+ let request = RuntimeOpenRequest {
88- record: SessionRecord,134+ session_id: "runtime-1".to_string(),
89- },135+ conversation_id: "conv-1".to_string(),
90- SessionResumed {136+ sender_id: "user-1".to_string(),
91- record: SessionRecord,137+ entry: GatewayEntryContext::default(),
92- },138+ channel: None,
93- SessionStatusChanged {139+ channel_instance_id: None,
94- session_id: String,140+ llm: None,
95- status: crate::gateway::SessionLifecycleStatus,141+ };
96- },142+ 
97- TurnAccepted {143+ let value = serde_json::to_value(&request).expect("request should serialize");
98- session_id: String,144+ assert_eq!(value["runtime_id"], "runtime-1");
99- },145+ assert!(value.get("session_id").is_none());
100- TextDelta {146+ 
101- session_id: String,147+ let legacy: RuntimeCloseRequest =
102- delta: String,148+ serde_json::from_str(r#"{"session_id":"legacy-runtime"}"#)
103- },149+ .expect("legacy session_id should deserialize");
104- InteractionRequested {150+ assert_eq!(legacy.session_id, "legacy-runtime");
105- session_id: String,151+ }
106- request: InteractionRequest,
107- },
108- TurnCompleted {
109- session_id: String,
110- result: AppTurnResult,
111- },
112- TurnFailed {
113- session_id: String,
114- error: String,
115- },
116- SessionClosed {
117- record: SessionRecord,
118- forced: bool,
119- },
120}152}
Aapps/shared/src/gateway/session_handle.rs+464-0
@@ -0,0 +1,464 @@
1+use crate::gateway::{
2+ AppTurnRequest, AppTurnResult, ResolvedSessionRuntime, SessionInputKind,
3+ SessionLifecycleStatus, SessionRecord, SessionServiceError, SessionSubmitReceipt,
4+};
5+use agent_contracts::{ChannelFileSender, InteractionHandle, LoopEventSink};
6+use std::collections::VecDeque;
7+use std::sync::atomic::{AtomicUsize, Ordering};
8+use std::sync::Arc;
9+use tokio::sync::{mpsc, oneshot, watch};
10+use tokio_util::sync::CancellationToken;
11+ 
12+use super::session_supervisor::SessionSupervisor;
13+ 
14+const SESSION_COMMAND_QUEUE_CAPACITY: usize = 16;
15+ 
16+#[derive(Clone)]
17+pub(crate) struct SessionHandle {
18+ session_id: String,
19+ tx: mpsc::Sender<SessionCommand>,
20+ supervisor: Arc<SessionSupervisor>,
21+ status_rx: watch::Receiver<SessionHandleStatus>,
22+ queue_depth: Arc<AtomicUsize>,
23+}
24+ 
25+#[derive(Debug, Clone, PartialEq, Eq)]
26+pub(crate) struct SessionHandleStatus {
27+ pub session_id: String,
28+ pub lifecycle: SessionLifecycleStatus,
29+ pub phase: SessionPhase,
30+ pub active_turn_id: Option<uuid::Uuid>,
31+ pub queue_depth: usize,
32+ pub last_active_at_ms: u64,
33+}
34+ 
35+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36+pub(crate) enum SessionPhase {
37+ Idle,
38+ Running,
39+ Closing,
40+ Closed,
41+}
42+ 
43+pub(crate) enum SessionCommand {
44+ RunTurn {
45+ request: AppTurnRequest,
46+ resolved_runtime: ResolvedSessionRuntime,
47+ event_sink: Option<Arc<dyn LoopEventSink>>,
48+ interaction_handle: Option<Arc<dyn InteractionHandle>>,
49+ channel_file_sender: Option<Arc<dyn ChannelFileSender>>,
50+ reply: oneshot::Sender<Result<AppTurnResult, SessionServiceError>>,
51+ },
52+ Snapshot {
53+ reply: oneshot::Sender<Result<SessionRecord, SessionServiceError>>,
54+ },
55+ CancelActiveTurn {
56+ reply: oneshot::Sender<Result<SessionSubmitReceipt, SessionServiceError>>,
57+ },
58+ ForceClose {
59+ reply: oneshot::Sender<Result<SessionRecord, SessionServiceError>>,
60+ },
61+}
62+ 
63+struct RunTurnCommand {
64+ request: AppTurnRequest,
65+ resolved_runtime: ResolvedSessionRuntime,
66+ event_sink: Option<Arc<dyn LoopEventSink>>,
67+ interaction_handle: Option<Arc<dyn InteractionHandle>>,
68+ channel_file_sender: Option<Arc<dyn ChannelFileSender>>,
69+ reply: oneshot::Sender<Result<AppTurnResult, SessionServiceError>>,
70+}
71+ 
72+struct ActiveTurn {
73+ turn_id: uuid::Uuid,
74+ cancel: CancellationToken,
75+}
76+ 
77+enum ActorEvent {
78+ Command(Option<SessionCommand>),
79+ ActiveDone,
80+}
81+ 
82+impl SessionHandle {
83+ pub(crate) async fn new(session_id: String, supervisor: Arc<SessionSupervisor>) -> Self {
84+ let snapshot = supervisor.snapshot().await;
85+ let queue_depth = Arc::new(AtomicUsize::new(0));
86+ let initial_status = SessionHandleStatus {
87+ session_id: session_id.clone(),
88+ lifecycle: snapshot.status,
89+ phase: SessionPhase::Idle,
90+ active_turn_id: None,
91+ queue_depth: 0,
92+ last_active_at_ms: snapshot.updated_at_ms,
93+ };
94+ let (tx, rx) = mpsc::channel(SESSION_COMMAND_QUEUE_CAPACITY);
95+ let (status_tx, status_rx) = watch::channel(initial_status);
96+ let actor = SessionActor {
97+ session_id: session_id.clone(),
98+ supervisor: supervisor.clone(),
99+ rx,
100+ status_tx,
101+ queue_depth: queue_depth.clone(),
102+ pending_turns: VecDeque::new(),
103+ active_turn: None,
104+ active_done_rx: None,
105+ phase: SessionPhase::Idle,
106+ close_reply: None,
107+ };
108+ tokio::spawn(actor.run());
109+ 
110+ Self {
111+ session_id,
112+ tx,
113+ supervisor,
114+ status_rx,
115+ queue_depth,
116+ }
117+ }
118+ 
119+ pub(crate) fn supervisor(&self) -> Arc<SessionSupervisor> {
120+ Arc::clone(&self.supervisor)
121+ }
122+ 
123+ pub(crate) async fn run_turn(
124+ &self,
125+ request: AppTurnRequest,
126+ resolved_runtime: ResolvedSessionRuntime,
127+ event_sink: Option<Arc<dyn LoopEventSink>>,
128+ interaction_handle: Option<Arc<dyn InteractionHandle>>,
129+ channel_file_sender: Option<Arc<dyn ChannelFileSender>>,
130+ ) -> Result<AppTurnResult, SessionServiceError> {
131+ self.try_increment_queue_depth()?;
132+ let (reply_tx, reply_rx) = oneshot::channel();
133+ let command = SessionCommand::RunTurn {
134+ request,
135+ resolved_runtime,
136+ event_sink,
137+ interaction_handle,
138+ channel_file_sender,
139+ reply: reply_tx,
140+ };
141+ 
142+ if let Err(error) = self.tx.try_send(command) {
143+ self.decrement_queue_depth();
144+ return match error {
145+ mpsc::error::TrySendError::Full(_) => Err(SessionServiceError::SessionBusy {
146+ session_id: self.session_id.clone(),
147+ message: "session command queue is full".to_string(),
148+ }),
149+ mpsc::error::TrySendError::Closed(_) => Err(SessionServiceError::SessionClosed {
150+ session_id: self.session_id.clone(),
151+ }),
152+ };
153+ }
154+ 
155+ reply_rx
156+ .await
157+ .map_err(|_| SessionServiceError::SessionClosed {
158+ session_id: self.session_id.clone(),
159+ })?
160+ }
161+ 
162+ pub(crate) async fn snapshot(&self) -> Result<SessionRecord, SessionServiceError> {
163+ let (reply, rx) = oneshot::channel();
164+ self.tx
165+ .send(SessionCommand::Snapshot { reply })
166+ .await
167+ .map_err(|_| SessionServiceError::SessionClosed {
168+ session_id: self.session_id.clone(),
169+ })?;
170+ rx.await.map_err(|_| SessionServiceError::SessionClosed {
171+ session_id: self.session_id.clone(),
172+ })?
173+ }
174+ 
175+ pub(crate) async fn cancel_active_turn(
176+ &self,
177+ ) -> Result<SessionSubmitReceipt, SessionServiceError> {
178+ let (reply, rx) = oneshot::channel();
179+ self.tx
180+ .send(SessionCommand::CancelActiveTurn { reply })
181+ .await
182+ .map_err(|_| SessionServiceError::SessionClosed {
183+ session_id: self.session_id.clone(),
184+ })?;
185+ rx.await.map_err(|_| SessionServiceError::SessionClosed {
186+ session_id: self.session_id.clone(),
187+ })?
188+ }
189+ 
190+ pub(crate) async fn force_close(&self) -> Result<SessionRecord, SessionServiceError> {
191+ let (reply, rx) = oneshot::channel();
192+ self.tx
193+ .send(SessionCommand::ForceClose { reply })
194+ .await
195+ .map_err(|_| SessionServiceError::SessionClosed {
196+ session_id: self.session_id.clone(),
197+ })?;
198+ rx.await.map_err(|_| SessionServiceError::SessionClosed {
199+ session_id: self.session_id.clone(),
200+ })?
201+ }
202+ 
203+ #[allow(dead_code)]
204+ pub(crate) fn status(&self) -> SessionHandleStatus {
205+ self.status_rx.borrow().clone()
206+ }
207+ 
208+ fn try_increment_queue_depth(&self) -> Result<(), SessionServiceError> {
209+ let mut current = self.queue_depth.load(Ordering::SeqCst);
210+ loop {
211+ if current >= SESSION_COMMAND_QUEUE_CAPACITY {
212+ return Err(SessionServiceError::SessionBusy {
213+ session_id: self.session_id.clone(),
214+ message: "session root turn queue is full".to_string(),
215+ });
216+ }
217+ match self.queue_depth.compare_exchange(
218+ current,
219+ current + 1,
220+ Ordering::SeqCst,
221+ Ordering::SeqCst,
222+ ) {
223+ Ok(_) => return Ok(()),
224+ Err(next) => current = next,
225+ }
226+ }
227+ }
228+ 
229+ fn decrement_queue_depth(&self) {
230+ self.queue_depth.fetch_sub(1, Ordering::SeqCst);
231+ }
232+}
233+ 
234+struct SessionActor {
235+ session_id: String,
236+ supervisor: Arc<SessionSupervisor>,
237+ rx: mpsc::Receiver<SessionCommand>,
238+ status_tx: watch::Sender<SessionHandleStatus>,
239+ queue_depth: Arc<AtomicUsize>,
240+ pending_turns: VecDeque<RunTurnCommand>,
241+ active_turn: Option<ActiveTurn>,
242+ active_done_rx: Option<oneshot::Receiver<()>>,
243+ phase: SessionPhase,
244+ close_reply: Option<oneshot::Sender<Result<SessionRecord, SessionServiceError>>>,
245+}
246+ 
247+impl SessionActor {
248+ async fn run(mut self) {
249+ loop {
250+ self.start_next_turn_if_possible().await;
251+ 
252+ if self.active_done_rx.is_some() {
253+ let event = {
254+ let active_done = self
255+ .active_done_rx
256+ .as_mut()
257+ .expect("active_done_rx should be present");
258+ tokio::select! {
259+ done = active_done => {
260+ if done.is_err() {
261+ tracing::warn!(
262+ session_id = %self.session_id,
263+ "active turn completion sender dropped"
264+ );
265+ }
266+ ActorEvent::ActiveDone
267+ }
268+ command = self.rx.recv() => ActorEvent::Command(command),
269+ }
270+ };
271+ if !self.handle_event(event).await {
272+ break;
273+ }
274+ } else {
275+ match self.rx.recv().await {
276+ Some(command) => {
277+ if !self.handle_event(ActorEvent::Command(Some(command))).await {
278+ break;
279+ }
280+ }
281+ None => break,
282+ }
283+ }
284+ }
285+ }
286+ 
287+ async fn handle_event(&mut self, event: ActorEvent) -> bool {
288+ match event {
289+ ActorEvent::Command(Some(command)) => {
290+ self.handle_command(command).await;
291+ true
292+ }
293+ ActorEvent::Command(None) => false,
294+ ActorEvent::ActiveDone => {
295+ self.active_turn = None;
296+ self.active_done_rx = None;
297+ if let Some(reply) = self.close_reply.take() {
298+ self.finish_force_close(reply).await;
299+ } else if self.phase != SessionPhase::Closed {
300+ self.phase = SessionPhase::Idle;
301+ self.publish_status(None).await;
302+ }
303+ true
304+ }
305+ }
306+ }
307+ 
308+ async fn handle_command(&mut self, command: SessionCommand) {
309+ match command {
310+ SessionCommand::RunTurn {
311+ request,
312+ resolved_runtime,
313+ event_sink,
314+ interaction_handle,
315+ channel_file_sender,
316+ reply,
317+ } => {
318+ if matches!(self.phase, SessionPhase::Closing | SessionPhase::Closed) {
319+ self.queue_depth.fetch_sub(1, Ordering::SeqCst);
320+ let _ = reply.send(Err(SessionServiceError::SessionClosed {
321+ session_id: self.session_id.clone(),
322+ }));
323+ self.publish_status(self.active_turn.as_ref().map(|turn| turn.turn_id))
324+ .await;
325+ return;
326+ }
327+ self.pending_turns.push_back(RunTurnCommand {
328+ request,
329+ resolved_runtime,
330+ event_sink,
331+ interaction_handle,
332+ channel_file_sender,
333+ reply,
334+ });
335+ self.publish_status(self.active_turn.as_ref().map(|turn| turn.turn_id))
336+ .await;
337+ }
338+ SessionCommand::Snapshot { reply } => {
339+ let _ = reply.send(Ok(self.supervisor.snapshot().await));
340+ }
341+ SessionCommand::CancelActiveTurn { reply } => {
342+ if let Some(active) = self.active_turn.as_ref() {
343+ active.cancel.cancel();
344+ }
345+ let _ = reply.send(Ok(SessionSubmitReceipt {
346+ session_id: self.session_id.clone(),
347+ accepted_kind: SessionInputKind::CancelActiveTurn,
348+ }));
349+ }
350+ SessionCommand::ForceClose { reply } => {
351+ self.begin_force_close(reply).await;
352+ }
353+ }
354+ }
355+ 
356+ async fn start_next_turn_if_possible(&mut self) {
357+ if self.active_turn.is_some()
358+ || matches!(self.phase, SessionPhase::Closing | SessionPhase::Closed)
359+ {
360+ return;
361+ }
362+ 
363+ let Some(turn) = self.pending_turns.pop_front() else {
364+ return;
365+ };
366+ 
367+ self.queue_depth.fetch_sub(1, Ordering::SeqCst);
368+ let turn_id = uuid::Uuid::new_v4();
369+ let cancel = CancellationToken::new();
370+ let (done_tx, done_rx) = oneshot::channel();
371+ let supervisor = self.supervisor.clone();
372+ let cancel_for_task = cancel.clone();
373+ self.active_turn = Some(ActiveTurn { turn_id, cancel });
374+ self.active_done_rx = Some(done_rx);
375+ self.phase = SessionPhase::Running;
376+ self.publish_status(Some(turn_id)).await;
377+ 
378+ tokio::spawn(async move {
379+ supervisor
380+ .prepare_root_turn(&turn.request, &turn.resolved_runtime)
381+ .await;
382+ let result = supervisor
383+ .run_root_turn(
384+ turn.request,
385+ turn.resolved_runtime,
386+ turn.event_sink,
387+ turn.interaction_handle,
388+ turn.channel_file_sender,
389+ Some(cancel_for_task),
390+ )
391+ .await;
392+ let _ = turn.reply.send(result);
393+ let _ = done_tx.send(());
394+ });
395+ }
396+ 
397+ async fn begin_force_close(
398+ &mut self,
399+ reply: oneshot::Sender<Result<SessionRecord, SessionServiceError>>,
400+ ) {
401+ if self.phase == SessionPhase::Closed {
402+ let _ = reply.send(Ok(self.supervisor.snapshot().await));
403+ return;
404+ }
405+ 
406+ self.phase = SessionPhase::Closing;
407+ if let Some(active) = self.active_turn.as_ref() {
408+ active.cancel.cancel();
409+ }
410+ self.fail_pending_turns();
411+ self.publish_status(self.active_turn.as_ref().map(|turn| turn.turn_id))
412+ .await;
413+ 
414+ if self.active_turn.is_some() {
415+ if self.close_reply.is_some() {
416+ let _ = reply.send(Err(SessionServiceError::SessionBusy {
417+ session_id: self.session_id.clone(),
418+ message: "session is already closing".to_string(),
419+ }));
420+ } else {
421+ self.close_reply = Some(reply);
422+ }
423+ return;
424+ }
425+ 
426+ self.finish_force_close(reply).await;
427+ }
428+ 
429+ async fn finish_force_close(
430+ &mut self,
431+ reply: oneshot::Sender<Result<SessionRecord, SessionServiceError>>,
432+ ) {
433+ let closed = self.supervisor.force_close().await;
434+ self.phase = SessionPhase::Closed;
435+ self.publish_status(None).await;
436+ let _ = reply.send(Ok(closed));
437+ }
438+ 
439+ fn fail_pending_turns(&mut self) {
440+ while let Some(turn) = self.pending_turns.pop_front() {
441+ self.queue_depth.fetch_sub(1, Ordering::SeqCst);
442+ let _ = turn.reply.send(Err(SessionServiceError::SessionClosed {
443+ session_id: self.session_id.clone(),
444+ }));
445+ }
446+ }
447+ 
448+ async fn publish_status(&self, active_turn_id: Option<uuid::Uuid>) {
449+ let snapshot = self.supervisor.snapshot().await;
450+ let lifecycle = match self.phase {
451+ SessionPhase::Idle => snapshot.status.clone(),
452+ SessionPhase::Running | SessionPhase::Closing => SessionLifecycleStatus::Running,
453+ SessionPhase::Closed => SessionLifecycleStatus::Closed,
454+ };
455+ let _ = self.status_tx.send(SessionHandleStatus {
456+ session_id: self.session_id.clone(),
457+ lifecycle,
458+ phase: self.phase,
459+ active_turn_id,
460+ queue_depth: self.queue_depth.load(Ordering::SeqCst),
461+ last_active_at_ms: snapshot.updated_at_ms,
462+ });
463+ }
464+}
Rapps/xiaoo-app/src/gateway/session_record.rsapps/shared/src/gateway/session_record.rs+4-1
@@ -1,4 +1,4 @@
1-use crate::gateway::GatewayEntryContext;1+use crate::gateway::{GatewayEntryContext, LlmRuntimeConfig};
2use agent_contracts::backend::BackendInstance;2use agent_contracts::backend::BackendInstance;
3use agent_types::common::ids::AgentId;3use agent_types::common::ids::AgentId;
4use agent_types::context::{FeatureFlags, TokenBudgetConfig};4use agent_types::context::{FeatureFlags, TokenBudgetConfig};
@@ -32,6 +32,8 @@ pub struct SubagentRoleRecord {
32pub struct SessionRuntimeSnapshot {32pub struct SessionRuntimeSnapshot {
33 pub agent_id: AgentId,33 pub agent_id: AgentId,
34 pub model: String,34 pub model: String,
35+ #[serde(default)]
36+ pub llm: Option<LlmRuntimeConfig>,
35 pub system_prompt: String,37 pub system_prompt: String,
36 pub feature_flags: FeatureFlags,38 pub feature_flags: FeatureFlags,
37 pub token_budget: TokenBudgetConfig,39 pub token_budget: TokenBudgetConfig,
@@ -46,6 +48,7 @@ pub struct SessionRuntimeSnapshot {
46 48 
47#[derive(Debug, Clone, Serialize, Deserialize)]49#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct SessionRecord {50pub struct SessionRecord {
51+ #[serde(rename = "runtime_id", alias = "session_id")]
49 pub session_id: String,52 pub session_id: String,
50 pub conversation_id: String,53 pub conversation_id: String,
51 pub sender_id: String,54 pub sender_id: String,
Rapps/xiaoo-app/src/gateway/runtime_bindings.rsapps/shared/src/gateway/session_runtime/bindings.rs+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/gateway/runtime_factory.rsapps/shared/src/gateway/session_runtime/factory.rs+5-3
@@ -28,11 +28,13 @@ use xiaoo_core::{
28 NoopInteractionHandle, NoopToolEventSink,28 NoopInteractionHandle, NoopToolEventSink,
29};29};
30 30 
31-use crate::gateway::permission_backend::PermissionAwareOperationBackend;
32-use crate::gateway::{GatewayEntryKind, ResolvedSessionRuntime, SessionRecord};
33use llm_client::LlmProviderWrapper;31use llm_client::LlmProviderWrapper;
34use parking_lot::RwLock;32use parking_lot::RwLock;
35 33 
34+use super::ResolvedSessionRuntime;
35+use crate::gateway::permission_backend::PermissionAwareOperationBackend;
36+use crate::gateway::{GatewayEntryKind, SessionRecord};
37+ 
36pub struct AppRuntimeAssembly {38pub struct AppRuntimeAssembly {
37 pub runtime: AgentRuntime,39 pub runtime: AgentRuntime,
38 pub runtime_view: Option<Arc<dyn RuntimeView>>,40 pub runtime_view: Option<Arc<dyn RuntimeView>>,
@@ -244,7 +246,7 @@ impl ToolEventSink for SharedToolEventSink {
244}246}
245 247 
246fn operation_backend_exec_isolation(248fn operation_backend_exec_isolation(
247- config: Option<&crate::gateway::backend::GatewayBackendConfig>,249+ config: Option<&crate::backend::GatewayBackendConfig>,
248) -> Option<&'static str> {250) -> Option<&'static str> {
249 let config = config?;251 let config = config?;
250 if config.kind != "local" {252 if config.kind != "local" {
Aapps/shared/src/gateway/session_runtime/mod.rs+10-0
@@ -0,0 +1,10 @@
1+pub mod bindings;
2+pub mod factory;
3+pub mod resolver;
4+ 
5+pub use bindings::SessionRuntimeBindings;
6+pub use factory::{AppRuntimeAssembly, AppRuntimeFactory, AppRuntimeFactoryError};
7+pub use resolver::{
8+ ResolvedSessionRuntime, SessionRuntimeBuildInput, SessionRuntimeDescriptor,
9+ SessionRuntimeResolveError, SessionRuntimeResolver,
10+};
Rapps/xiaoo-app/src/gateway/runtime_resolver.rsapps/shared/src/gateway/session_runtime/resolver.rs+10-3
@@ -1,8 +1,8 @@
1-use crate::gateway::backend::GatewayBackendConfig;1+use crate::backend::GatewayBackendConfig;
2use crate::gateway::session_record::SubagentRoleRecord;2use crate::gateway::session_record::SubagentRoleRecord;
3use crate::gateway::{3use crate::gateway::{
4- AppTurnRequest, GatewayEntryContext, GatewayEntryKind, SessionOpenRequest, SessionRecord,4+ AppTurnRequest, GatewayEntryContext, GatewayEntryKind, LlmRuntimeConfig, SessionOpenRequest,
5- SessionRuntimeBindings,5+ SessionRecord,
6};6};
7use agent_contracts::{CompressionPipeline, SkillRegistry, ToolRegistry};7use agent_contracts::{CompressionPipeline, SkillRegistry, ToolRegistry};
8use agent_types::common::ids::AgentId;8use agent_types::common::ids::AgentId;
@@ -18,10 +18,14 @@ use std::sync::Arc;
18use subagent::SubagentControl;18use subagent::SubagentControl;
19use thiserror::Error;19use thiserror::Error;
20 20 
21+use super::SessionRuntimeBindings;
22+ 
21#[derive(Debug, Clone, Serialize, Deserialize)]23#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct SessionRuntimeDescriptor {24pub struct SessionRuntimeDescriptor {
23 pub agent_id: AgentId,25 pub agent_id: AgentId,
24 pub model: String,26 pub model: String,
27+ #[serde(default)]
28+ pub llm: Option<LlmRuntimeConfig>,
25 pub system_prompt: String,29 pub system_prompt: String,
26 pub feature_flags: FeatureFlags,30 pub feature_flags: FeatureFlags,
27 pub token_budget: TokenBudgetConfig,31 pub token_budget: TokenBudgetConfig,
@@ -58,6 +62,7 @@ pub struct SessionRuntimeBuildInput {
58 pub max_turns_override: Option<u32>,62 pub max_turns_override: Option<u32>,
59 #[serde(default)]63 #[serde(default)]
60 pub subagent_role_id: Option<String>,64 pub subagent_role_id: Option<String>,
65+ pub llm: Option<LlmRuntimeConfig>,
61}66}
62 67 
63impl SessionRuntimeBuildInput {68impl SessionRuntimeBuildInput {
@@ -73,6 +78,7 @@ impl SessionRuntimeBuildInput {
73 agent_id_override: None,78 agent_id_override: None,
74 max_turns_override: None,79 max_turns_override: None,
75 subagent_role_id: None,80 subagent_role_id: None,
81+ llm: request.llm.clone(),
76 }82 }
77 }83 }
78 84 
@@ -88,6 +94,7 @@ impl SessionRuntimeBuildInput {
88 agent_id_override: None,94 agent_id_override: None,
89 max_turns_override: None,95 max_turns_override: None,
90 subagent_role_id: None,96 subagent_role_id: None,
97+ llm: request.llm.clone(),
91 }98 }
92 }99 }
93}100}
Rapps/xiaoo-app/src/gateway/session_service.rsapps/shared/src/gateway/session_service.rs+37-12
@@ -1,6 +1,10 @@
1use crate::gateway::{1use crate::gateway::{
2- AppTurnRequest, AppTurnResult, SessionOpenRequest, SessionRecord, SessionStreamMode,2+ AppTurnRequest, AppTurnResult, SessionOpenRequest, SessionRecord, SessionSubmitReceipt,
3- SessionSubmitReceipt, SessionSubscription,3+};
4+use crate::{
5+ RuntimeCheckoutRequest, RuntimeCheckoutResult, RuntimeCheckpointRequest,
6+ RuntimeCheckpointResult, RuntimeCheckpointSnapshotDeleteRequest,
7+ RuntimeCheckpointSnapshotDeleteResult,
4};8};
5use agent_contracts::{ChannelFileSender, InteractionHandle, LoopEventSink};9use agent_contracts::{ChannelFileSender, InteractionHandle, LoopEventSink};
6use async_trait::async_trait;10use async_trait::async_trait;
@@ -35,6 +39,10 @@ pub enum SessionServiceError {
35 UnsupportedCapability { capability: String },39 UnsupportedCapability { capability: String },
36 #[error("session not found: {session_id}")]40 #[error("session not found: {session_id}")]
37 SessionNotFound { session_id: String },41 SessionNotFound { session_id: String },
42+ #[error("session busy: {session_id}: {message}")]
43+ SessionBusy { session_id: String, message: String },
44+ #[error("session closed: {session_id}")]
45+ SessionClosed { session_id: String },
38}46}
39 47 
40#[async_trait]48#[async_trait]
@@ -90,6 +98,33 @@ pub trait SessionControlPlane: Send + Sync {
90 })98 })
91 }99 }
92 100 
101+ async fn checkpoint_runtime(
102+ &self,
103+ _request: RuntimeCheckpointRequest,
104+ ) -> Result<RuntimeCheckpointResult, SessionServiceError> {
105+ Err(SessionServiceError::UnsupportedCapability {
106+ capability: "checkpoint_runtime".to_string(),
107+ })
108+ }
109+ 
110+ async fn checkout_runtime(
111+ &self,
112+ _request: RuntimeCheckoutRequest,
113+ ) -> Result<RuntimeCheckoutResult, SessionServiceError> {
114+ Err(SessionServiceError::UnsupportedCapability {
115+ capability: "checkout_runtime".to_string(),
116+ })
117+ }
118+ 
119+ async fn delete_checkpoint_snapshot(
120+ &self,
121+ _request: RuntimeCheckpointSnapshotDeleteRequest,
122+ ) -> Result<RuntimeCheckpointSnapshotDeleteResult, SessionServiceError> {
123+ Err(SessionServiceError::UnsupportedCapability {
124+ capability: "delete_checkpoint_snapshot".to_string(),
125+ })
126+ }
127+ 
93 async fn submit_input(128 async fn submit_input(
94 &self,129 &self,
95 _session_id: &str,130 _session_id: &str,
@@ -99,14 +134,4 @@ pub trait SessionControlPlane: Send + Sync {
99 capability: "submit_input".to_string(),134 capability: "submit_input".to_string(),
100 })135 })
101 }136 }
102- 
103- async fn subscribe(
104- &self,
105- _session_id: &str,
106- _mode: SessionStreamMode,
107- ) -> Result<SessionSubscription, SessionServiceError> {
108- Err(SessionServiceError::UnsupportedCapability {
109- capability: "subscribe".to_string(),
110- })
111- }
112}137}
Rapps/xiaoo-app/src/gateway/core_session_service.rsapps/shared/src/gateway/session_service_impl.rs+777-56
@@ -1,9 +1,14 @@
1use crate::gateway::{1use crate::gateway::{
2- AppTurnRequest, AppTurnResult, ResolvedSessionRuntime, SessionControlPlane,2+ AppTurnRequest, AppTurnResult, ResolvedSessionRuntime, SessionControlPlane, SessionInput,
3 SessionLifecycleStatus, SessionOpenRequest, SessionRecord, SessionRuntimeBuildInput,3 SessionLifecycleStatus, SessionOpenRequest, SessionRecord, SessionRuntimeBuildInput,
4 SessionRuntimeResolveError, SessionRuntimeResolver, SessionService, SessionServiceError,4 SessionRuntimeResolveError, SessionRuntimeResolver, SessionService, SessionServiceError,
5 SessionStore, SessionStoreError,5 SessionStore, SessionStoreError,
6};6};
7+use crate::{
8+ RuntimeCheckoutRequest, RuntimeCheckoutResult, RuntimeCheckpointRequest,
9+ RuntimeCheckpointResult, RuntimeCheckpointSnapshotDeleteRequest,
10+ RuntimeCheckpointSnapshotDeleteResult, RuntimeRecord,
11+};
7use agent_contracts::{ChannelFileSender, HookerRegistry, InteractionHandle, LoopEventSink};12use agent_contracts::{ChannelFileSender, HookerRegistry, InteractionHandle, LoopEventSink};
8use agent_types::hook::{HookInvokeInput, HookInvokeMetadata, HookPointId};13use agent_types::hook::{HookInvokeInput, HookInvokeMetadata, HookPointId};
9use agent_types::session::{SessionClosedHookInput, SessionCreatedHookInput};14use agent_types::session::{SessionClosedHookInput, SessionCreatedHookInput};
@@ -18,15 +23,37 @@ use tokio::sync::Mutex;
18use xiaoo_core::NoopRuntimeView;23use xiaoo_core::NoopRuntimeView;
19 24 
20use super::session_backend::{lease_session_backend, sync_session_backend_instance};25use super::session_backend::{lease_session_backend, sync_session_backend_instance};
26+use super::session_handle::SessionHandle;
21use super::session_supervisor::SessionSupervisor;27use super::session_supervisor::SessionSupervisor;
22-use crate::gateway::backend::ExternalBackendManager;28+use crate::backend::{
29+ BackendCheckoutRequest, BackendCheckpointRequest, BackendCheckpointSnapshotDeleteRequest,
30+ BackendError, BackendManager,
31+};
32+use crate::runtime_checkpoint::{InMemoryRuntimeCheckpointStore, RuntimeCheckpoint};
23 33 
24pub struct CoreBackedSessionService {34pub struct CoreBackedSessionService {
25 session_store: Arc<dyn SessionStore>,35 session_store: Arc<dyn SessionStore>,
26 runtime_resolver: Arc<dyn SessionRuntimeResolver>,36 runtime_resolver: Arc<dyn SessionRuntimeResolver>,
27- supervisors: Mutex<HashMap<String, Arc<SessionSupervisor>>>,37+ sessions_handler: Mutex<HashMap<String, SessionHandle>>,
28 hooker_registry: Arc<dyn HookerRegistry>,38 hooker_registry: Arc<dyn HookerRegistry>,
29- backend_manager: Arc<ExternalBackendManager>,39+ backend_manager: Arc<BackendManager>,
40+ runtime_checkpoints: InMemoryRuntimeCheckpointStore,
41+}
42+ 
43+struct RuntimeCheckpointInternal {
44+ result: RuntimeCheckpointResult,
45+ // session: SessionRecord,
46+ // backend_checkpoint: Option<BackendCheckpointResult>,
47+}
48+ 
49+struct RuntimeCheckoutInternal {
50+ result: RuntimeCheckoutResult,
51+ // session: SessionRecord,
52+ // backend_checkout: Option<BackendCheckoutResult>,
53+}
54+ 
55+struct RuntimeCheckpointSnapshotDeleteInternal {
56+ result: RuntimeCheckpointSnapshotDeleteResult,
30}57}
31 58 
32impl CoreBackedSessionService {59impl CoreBackedSessionService {
@@ -34,14 +61,15 @@ impl CoreBackedSessionService {
34 session_store: Arc<dyn SessionStore>,61 session_store: Arc<dyn SessionStore>,
35 runtime_resolver: Arc<dyn SessionRuntimeResolver>,62 runtime_resolver: Arc<dyn SessionRuntimeResolver>,
36 hooker_registry: Arc<dyn HookerRegistry>,63 hooker_registry: Arc<dyn HookerRegistry>,
37- backend_manager: Arc<ExternalBackendManager>,64+ backend_manager: Arc<BackendManager>,
38 ) -> Self {65 ) -> Self {
39 Self {66 Self {
40 session_store,67 session_store,
41 runtime_resolver,68 runtime_resolver,
42- supervisors: Mutex::new(HashMap::new()),69+ sessions_handler: Mutex::new(HashMap::new()),
43 hooker_registry,70 hooker_registry,
44 backend_manager,71 backend_manager,
72+ runtime_checkpoints: InMemoryRuntimeCheckpointStore::default(),
45 }73 }
46 }74 }
47 75 
@@ -69,9 +97,14 @@ impl CoreBackedSessionService {
69 }97 }
70 }98 }
71 99 
72- async fn get_or_create_supervisor(&self, session: SessionRecord) -> Arc<SessionSupervisor> {100+ async fn get_or_create_session_handle(&self, session: SessionRecord) -> SessionHandle {
73- let mut supervisors = self.supervisors.lock().await;101+ if let Some(existing) = self
74- if let Some(existing) = supervisors.get(&session.session_id) {102+ .sessions_handler
103+ .lock()
104+ .await
105+ .get(&session.session_id)
106+ .cloned()
107+ {
75 return existing.clone();108 return existing.clone();
76 }109 }
77 110 
@@ -81,17 +114,278 @@ impl CoreBackedSessionService {
81 Arc::clone(&self.backend_manager),114 Arc::clone(&self.backend_manager),
82 session.clone(),115 session.clone(),
83 ));116 ));
84- supervisors.insert(session.session_id.clone(), supervisor.clone());117+ let handle = SessionHandle::new(session.session_id.clone(), supervisor).await;
85- supervisor118+ let mut sessions = self.sessions_handler.lock().await;
119+ if let Some(existing) = sessions.get(&session.session_id) {
120+ return existing.clone();
121+ }
122+ sessions.insert(session.session_id.clone(), handle.clone());
123+ handle
86 }124 }
87 125 
88- async fn supervisor_for_session(&self, session_id: &str) -> Option<Arc<SessionSupervisor>> {126+ async fn handle_for_session(&self, session_id: &str) -> Option<SessionHandle> {
89- if let Some(existing) = self.supervisors.lock().await.get(session_id).cloned() {127+ if let Some(existing) = self.sessions_handler.lock().await.get(session_id).cloned() {
90 return Some(existing);128 return Some(existing);
91 }129 }
92 130 
93 let session = self.session_store.load(session_id).await?;131 let session = self.session_store.load(session_id).await?;
94- Some(self.get_or_create_supervisor(session).await)132+ Some(self.get_or_create_session_handle(session).await)
133+ }
134+ 
135+ async fn idle_session_snapshot(
136+ &self,
137+ session_id: &str,
138+ ) -> Result<SessionRecord, SessionServiceError> {
139+ if let Some(handle) = self.sessions_handler.lock().await.get(session_id).cloned() {
140+ let status = handle.status();
141+ if status.phase != super::session_handle::SessionPhase::Idle || status.queue_depth > 0 {
142+ return Err(SessionServiceError::SessionBusy {
143+ session_id: session_id.to_string(),
144+ message: "runtime must be idle before checkpoint or checkout".to_string(),
145+ });
146+ }
147+ return handle.snapshot().await;
148+ }
149+ 
150+ let Some(session) = self.session_store.load(session_id).await else {
151+ return Err(SessionServiceError::SessionNotFound {
152+ session_id: session_id.to_string(),
153+ });
154+ };
155+ if session.status == SessionLifecycleStatus::Running {
156+ return Err(SessionServiceError::SessionBusy {
157+ session_id: session_id.to_string(),
158+ message: "runtime must be idle before checkpoint or checkout".to_string(),
159+ });
160+ }
161+ Ok(session)
162+ }
163+ 
164+ async fn checkpoint_runtime_internal(
165+ &self,
166+ request: RuntimeCheckpointRequest,
167+ ) -> Result<RuntimeCheckpointInternal, SessionServiceError> {
168+ let session = self.idle_session_snapshot(&request.runtime_id).await?;
169+ let backend_checkpoint = if let Some(parent_backend) = session.backend_instance.as_ref() {
170+ Some(
171+ self.backend_manager
172+ .checkpoint_backend(BackendCheckpointRequest {
173+ backend_id: Some(parent_backend.backend_id.0.clone()),
174+ session_id: Some(request.runtime_id.clone()),
175+ name: request.name.clone(),
176+ metadata: request.metadata.clone(),
177+ })
178+ .await
179+ .map_err(|error| SessionServiceError::RuntimeBuild {
180+ message: format!("failed to checkpoint runtime backend: {error}"),
181+ })?,
182+ )
183+ } else {
184+ None
185+ };
186+ 
187+ let parent_checkpoint_id = self
188+ .runtime_checkpoints
189+ .latest_for_runtime(&request.runtime_id)
190+ .await;
191+ let checkpoint_id = format!("rtcp_{}", uuid::Uuid::new_v4().simple());
192+ let created_at_ms = current_time_ms();
193+ let checkpoint = RuntimeCheckpoint {
194+ checkpoint_id: checkpoint_id.clone(),
195+ runtime_id: request.runtime_id.clone(),
196+ parent_checkpoint_id: parent_checkpoint_id.clone(),
197+ session: session.clone(),
198+ backend_checkpoint: backend_checkpoint
199+ .as_ref()
200+ .map(|result| result.checkpoint.clone()),
201+ created_at_ms,
202+ metadata: request.metadata.clone(),
203+ name: request.name.clone(),
204+ };
205+ self.runtime_checkpoints.save(checkpoint).await;
206+ 
207+ Ok(RuntimeCheckpointInternal {
208+ result: RuntimeCheckpointResult {
209+ checkpoint_id,
210+ runtime: RuntimeRecord::from_session(&session),
211+ parent_checkpoint_id,
212+ created_at_ms,
213+ metadata: request.metadata,
214+ name: request.name,
215+ },
216+ // session,
217+ // backend_checkpoint,
218+ })
219+ }
220+ 
221+ async fn checkout_runtime_internal(
222+ &self,
223+ request: RuntimeCheckoutRequest,
224+ ) -> Result<RuntimeCheckoutInternal, SessionServiceError> {
225+ let checkpoint = self
226+ .runtime_checkpoints
227+ .load(&request.checkpoint_id)
228+ .await
229+ .ok_or_else(|| SessionServiceError::SessionNotFound {
230+ session_id: format!("checkpoint:{}", request.checkpoint_id),
231+ })?;
232+ let _ = self.idle_session_snapshot(&checkpoint.runtime_id).await?;
233+ 
234+ let child_runtime_id = format!(
235+ "{}:checkout:{}",
236+ checkpoint.runtime_id,
237+ uuid::Uuid::new_v4().simple()
238+ );
239+ if self.session_store.load(&child_runtime_id).await.is_some() {
240+ return Err(SessionServiceError::SessionBusy {
241+ session_id: child_runtime_id,
242+ message: "generated runtime already exists".to_string(),
243+ });
244+ }
245+ 
246+ let backend_checkout =
247+ if let Some(backend_checkpoint) = checkpoint.backend_checkpoint.clone() {
248+ Some(
249+ self.backend_manager
250+ .checkout_backend(BackendCheckoutRequest {
251+ checkpoint: backend_checkpoint,
252+ backend_id: None,
253+ session_id: Some(child_runtime_id.clone()),
254+ timeout: None,
255+ metadata: request.metadata.clone(),
256+ resource_limits: Default::default(),
257+ options: None,
258+ })
259+ .await
260+ .map_err(|error| SessionServiceError::RuntimeBuild {
261+ message: format!("failed to checkout runtime backend: {error}"),
262+ })?,
263+ )
264+ } else {
265+ None
266+ };
267+ let backend_lease = if backend_checkout.is_some() {
268+ Some(
269+ self.backend_manager
270+ .lease_bound_session(&child_runtime_id)
271+ .await
272+ .map_err(|error| SessionServiceError::RuntimeBuild {
273+ message: format!("failed to lease checked out backend: {error}"),
274+ })?,
275+ )
276+ } else {
277+ None
278+ };
279+ 
280+ let now_ms = current_time_ms();
281+ let mut child = checkpoint.session.clone();
282+ child.session_id = child_runtime_id.clone();
283+ if let Some(conversation_id) = request.conversation_id {
284+ child.conversation_id = conversation_id;
285+ }
286+ if let Some(sender_id) = request.sender_id {
287+ child.sender_id = sender_id;
288+ }
289+ child.status = SessionLifecycleStatus::Idle;
290+ child.backend_instance = backend_lease.map(|lease| lease.instance());
291+ child.last_error = None;
292+ child.created_at_ms = now_ms;
293+ child.updated_at_ms = now_ms;
294+ 
295+ self.session_store.save(child.clone()).await;
296+ self.runtime_checkpoints
297+ .register_runtime_head(child.session_id.clone(), checkpoint.checkpoint_id.clone())
298+ .await;
299+ 
300+ let hook_point = HookPointId(format!(
301+ "{}.Session.lifecycle.created",
302+ child.runtime.agent_id.0
303+ ));
304+ self.fire_session_hooks(
305+ HookInvokeInput::SessionCreated {
306+ input: SessionCreatedHookInput {
307+ session_id: child.session_id.clone(),
308+ sender_id: child.sender_id.clone(),
309+ },
310+ metadata: HookInvokeMetadata::default(),
311+ },
312+ hook_point,
313+ )
314+ .await;
315+ self.get_or_create_session_handle(child.clone()).await;
316+ 
317+ Ok(RuntimeCheckoutInternal {
318+ result: RuntimeCheckoutResult {
319+ checkpoint_id: checkpoint.checkpoint_id,
320+ source_runtime_id: checkpoint.runtime_id,
321+ runtime: RuntimeRecord::from_session(&child),
322+ },
323+ // session: child,
324+ // backend_checkout,
325+ })
326+ }
327+ 
328+ async fn delete_checkpoint_snapshot_internal(
329+ &self,
330+ request: RuntimeCheckpointSnapshotDeleteRequest,
331+ ) -> Result<RuntimeCheckpointSnapshotDeleteInternal, SessionServiceError> {
332+ let checkpoint = self
333+ .runtime_checkpoints
334+ .load(&request.checkpoint_id)
335+ .await
336+ .ok_or_else(|| SessionServiceError::SessionNotFound {
337+ session_id: format!("checkpoint:{}", request.checkpoint_id),
338+ })?;
339+ 
340+ let Some(backend_checkpoint) = checkpoint.backend_checkpoint.clone() else {
341+ return Ok(RuntimeCheckpointSnapshotDeleteInternal {
342+ result: RuntimeCheckpointSnapshotDeleteResult {
343+ checkpoint_id: checkpoint.checkpoint_id,
344+ runtime_id: checkpoint.runtime_id,
345+ provider: None,
346+ provider_snapshot_id: None,
347+ provider_snapshot_names: Vec::new(),
348+ deleted_provider_snapshot: false,
349+ deleted_at_ms: current_time_ms(),
350+ },
351+ });
352+ };
353+ 
354+ let provider = backend_checkpoint.provider.clone();
355+ let provider_snapshot_id = backend_checkpoint.provider_snapshot_id.clone();
356+ let provider_snapshot_names = backend_checkpoint.provider_snapshot_names.clone();
357+ let delete = self
358+ .backend_manager
359+ .delete_checkpoint_snapshot(BackendCheckpointSnapshotDeleteRequest {
360+ checkpoint: backend_checkpoint,
361+ })
362+ .await
363+ .map_err(|error| match error {
364+ BackendError::UnsupportedBackend { kind } => {
365+ SessionServiceError::UnsupportedCapability {
366+ capability: format!("delete_checkpoint_snapshot:{kind}"),
367+ }
368+ }
369+ error => SessionServiceError::RuntimeBuild {
370+ message: format!("failed to delete checkpoint backend snapshot: {error}"),
371+ },
372+ })?;
373+ 
374+ self.runtime_checkpoints
375+ .clear_backend_snapshot(&request.checkpoint_id)
376+ .await;
377+ 
378+ Ok(RuntimeCheckpointSnapshotDeleteInternal {
379+ result: RuntimeCheckpointSnapshotDeleteResult {
380+ checkpoint_id: request.checkpoint_id,
381+ runtime_id: checkpoint.runtime_id,
382+ provider: Some(provider),
383+ provider_snapshot_id,
384+ provider_snapshot_names,
385+ deleted_provider_snapshot: delete.deleted,
386+ deleted_at_ms: current_time_ms(),
387+ },
388+ })
95 }389 }
96 390 
97 fn build_session_for_turn(391 fn build_session_for_turn(
@@ -110,6 +404,7 @@ impl CoreBackedSessionService {
110 runtime: crate::gateway::session_record::SessionRuntimeSnapshot {404 runtime: crate::gateway::session_record::SessionRuntimeSnapshot {
111 agent_id: resolved.descriptor.agent_id.clone(),405 agent_id: resolved.descriptor.agent_id.clone(),
112 model: resolved.descriptor.model.clone(),406 model: resolved.descriptor.model.clone(),
407+ llm: resolved.descriptor.llm.clone(),
113 system_prompt: resolved.descriptor.system_prompt.clone(),408 system_prompt: resolved.descriptor.system_prompt.clone(),
114 feature_flags: resolved.descriptor.feature_flags.clone(),409 feature_flags: resolved.descriptor.feature_flags.clone(),
115 token_budget: resolved.descriptor.token_budget.clone(),410 token_budget: resolved.descriptor.token_budget.clone(),
@@ -145,6 +440,7 @@ impl CoreBackedSessionService {
145 runtime: crate::gateway::session_record::SessionRuntimeSnapshot {440 runtime: crate::gateway::session_record::SessionRuntimeSnapshot {
146 agent_id: resolved.descriptor.agent_id.clone(),441 agent_id: resolved.descriptor.agent_id.clone(),
147 model: resolved.descriptor.model.clone(),442 model: resolved.descriptor.model.clone(),
443+ llm: resolved.descriptor.llm.clone(),
148 system_prompt: resolved.descriptor.system_prompt.clone(),444 system_prompt: resolved.descriptor.system_prompt.clone(),
149 feature_flags: resolved.descriptor.feature_flags.clone(),445 feature_flags: resolved.descriptor.feature_flags.clone(),
150 token_budget: resolved.descriptor.token_budget.clone(),446 token_budget: resolved.descriptor.token_budget.clone(),
@@ -183,9 +479,13 @@ impl CoreBackedSessionService {
183 existing.unwrap_or_else(|| Self::build_session_for_turn(&request, &resolved));479 existing.unwrap_or_else(|| Self::build_session_for_turn(&request, &resolved));
184 let backend_lease =480 let backend_lease =
185 lease_session_backend(self.backend_manager.as_ref(), &seed_session, &resolved).await?;481 lease_session_backend(self.backend_manager.as_ref(), &seed_session, &resolved).await?;
186- if sync_session_backend_instance(&mut seed_session, &backend_lease) {482+ let backend_updated = sync_session_backend_instance(&mut seed_session, &backend_lease);
483+ if backend_updated {
187 seed_session.updated_at_ms = current_time_ms();484 seed_session.updated_at_ms = current_time_ms();
188 }485 }
486+ if is_new_session || backend_updated {
487+ self.session_store.save(seed_session.clone()).await;
488+ }
189 489 
190 if is_new_session {490 if is_new_session {
191 let hook_point = HookPointId(format!(491 let hook_point = HookPointId(format!(
@@ -205,10 +505,9 @@ impl CoreBackedSessionService {
205 .await;505 .await;
206 }506 }
207 507 
208- let supervisor = self.get_or_create_supervisor(seed_session).await;508+ let handle = self.get_or_create_session_handle(seed_session).await;
209- supervisor.prepare_root_turn(&request, &resolved).await;509+ handle
210- supervisor510+ .run_turn(
211- .run_root_turn(
212 request,511 request,
213 resolved,512 resolved,
214 event_sink,513 event_sink,
@@ -254,8 +553,8 @@ impl SessionControlPlane for CoreBackedSessionService {
254 &self,553 &self,
255 request: SessionOpenRequest,554 request: SessionOpenRequest,
256 ) -> Result<SessionRecord, SessionServiceError> {555 ) -> Result<SessionRecord, SessionServiceError> {
257- if let Some(supervisor) = self.supervisor_for_session(&request.session_id).await {556+ if let Some(handle) = self.handle_for_session(&request.session_id).await {
258- return Ok(supervisor.snapshot().await);557+ return handle.snapshot().await;
259 }558 }
260 559 
261 let runtime_input = SessionRuntimeBuildInput::from_open_request(&request);560 let runtime_input = SessionRuntimeBuildInput::from_open_request(&request);
@@ -284,19 +583,18 @@ impl SessionControlPlane for CoreBackedSessionService {
284 )583 )
285 .await;584 .await;
286 585 
287- Ok(self586+ self.get_or_create_session_handle(session)
288- .get_or_create_supervisor(session)
289 .await587 .await
290 .snapshot()588 .snapshot()
291- .await)589+ .await
292 }590 }
293 591 
294 async fn resume_session(592 async fn resume_session(
295 &self,593 &self,
296 session_id: &str,594 session_id: &str,
297 ) -> Result<Option<SessionRecord>, SessionServiceError> {595 ) -> Result<Option<SessionRecord>, SessionServiceError> {
298- match self.supervisor_for_session(session_id).await {596+ match self.handle_for_session(session_id).await {
299- Some(supervisor) => Ok(Some(supervisor.snapshot().await)),597+ Some(handle) => Ok(Some(handle.snapshot().await?)),
300 None => Ok(None),598 None => Ok(None),
301 }599 }
302 }600 }
@@ -305,19 +603,23 @@ impl SessionControlPlane for CoreBackedSessionService {
305 &self,603 &self,
306 session_id: &str,604 session_id: &str,
307 ) -> Result<SessionRecord, SessionServiceError> {605 ) -> Result<SessionRecord, SessionServiceError> {
308- let closed = if let Some(supervisor) = self.supervisor_for_session(session_id).await {606+ let (closed, was_already_closed) =
309- supervisor.force_close().await607+ if let Some(handle) = self.handle_for_session(session_id).await {
310- } else {608+ let before = handle.snapshot().await?;
311- let Some(mut existing) = self.session_store.load(session_id).await else {609+ let was_already_closed = before.status == SessionLifecycleStatus::Closed;
312- return Err(SessionServiceError::SessionNotFound {610+ (handle.force_close().await?, was_already_closed)
313- session_id: session_id.to_string(),611+ } else {
314- });612+ let Some(mut existing) = self.session_store.load(session_id).await else {
613+ return Err(SessionServiceError::SessionNotFound {
614+ session_id: session_id.to_string(),
615+ });
616+ };
617+ let was_already_closed = existing.status == SessionLifecycleStatus::Closed;
618+ existing.status = SessionLifecycleStatus::Closed;
619+ existing.updated_at_ms = current_time_ms();
620+ self.session_store.save(existing.clone()).await;
621+ (existing, was_already_closed)
315 };622 };
316- existing.status = SessionLifecycleStatus::Closed;
317- existing.updated_at_ms = current_time_ms();
318- self.session_store.save(existing.clone()).await;
319- existing
320- };
321 if let Err(error) = self.backend_manager.release_session(session_id).await {623 if let Err(error) = self.backend_manager.release_session(session_id).await {
322 tracing::warn!(624 tracing::warn!(
323 session_id = %session_id,625 session_id = %session_id,
@@ -326,24 +628,82 @@ impl SessionControlPlane for CoreBackedSessionService {
326 );628 );
327 }629 }
328 630 
329- let hook_point = HookPointId(format!(631+ if !was_already_closed {
330- "{}.Session.lifecycle.closed",632+ let hook_point = HookPointId(format!(
331- closed.runtime.agent_id.0633+ "{}.Session.lifecycle.closed",
332- ));634+ closed.runtime.agent_id.0
333- self.fire_session_hooks(635+ ));
334- HookInvokeInput::SessionClosed {636+ self.fire_session_hooks(
335- input: SessionClosedHookInput {637+ HookInvokeInput::SessionClosed {
336- session_id: closed.session_id.clone(),638+ input: SessionClosedHookInput {
337- sender_id: closed.sender_id.clone(),639+ session_id: closed.session_id.clone(),
640+ sender_id: closed.sender_id.clone(),
641+ },
642+ metadata: HookInvokeMetadata::default(),
338 },643 },
339- metadata: HookInvokeMetadata::default(),644+ hook_point,
340- },645+ )
341- hook_point,646+ .await;
342- )647+ }
343- .await;648+ 
649+ self.session_store.delete(session_id).await;
650+ self.sessions_handler.lock().await.remove(session_id);
344 651 
345 Ok(closed)652 Ok(closed)
346 }653 }
654+ 
655+ async fn checkpoint_runtime(
656+ &self,
657+ request: RuntimeCheckpointRequest,
658+ ) -> Result<RuntimeCheckpointResult, SessionServiceError> {
659+ self.checkpoint_runtime_internal(request)
660+ .await
661+ .map(|internal| internal.result)
662+ }
663+ 
664+ async fn checkout_runtime(
665+ &self,
666+ request: RuntimeCheckoutRequest,
667+ ) -> Result<RuntimeCheckoutResult, SessionServiceError> {
668+ self.checkout_runtime_internal(request)
669+ .await
670+ .map(|internal| internal.result)
671+ }
672+ 
673+ async fn delete_checkpoint_snapshot(
674+ &self,
675+ request: RuntimeCheckpointSnapshotDeleteRequest,
676+ ) -> Result<RuntimeCheckpointSnapshotDeleteResult, SessionServiceError> {
677+ self.delete_checkpoint_snapshot_internal(request)
678+ .await
679+ .map(|internal| internal.result)
680+ }
681+ 
682+ async fn submit_input(
683+ &self,
684+ session_id: &str,
685+ input: SessionInput,
686+ ) -> Result<crate::gateway::SessionSubmitReceipt, SessionServiceError> {
687+ match input {
688+ SessionInput::CancelActiveTurn => {
689+ let Some(handle) = self.handle_for_session(session_id).await else {
690+ return Err(SessionServiceError::SessionNotFound {
691+ session_id: session_id.to_string(),
692+ });
693+ };
694+ handle.cancel_active_turn().await
695+ }
696+ SessionInput::Turn { .. } => Err(SessionServiceError::UnsupportedCapability {
697+ capability: "submit_input.turn".to_string(),
698+ }),
699+ SessionInput::Interaction { .. } => Err(SessionServiceError::UnsupportedCapability {
700+ capability: "submit_input.interaction".to_string(),
701+ }),
702+ SessionInput::InputChunk { .. } => Err(SessionServiceError::UnsupportedCapability {
703+ capability: "submit_input.input_chunk".to_string(),
704+ }),
705+ }
706+ }
347}707}
348 708 
349#[async_trait]709#[async_trait]
@@ -352,11 +712,12 @@ impl SubagentControl for CoreBackedSessionService {
352 &self,712 &self,
353 request: SpawnSubagentRequest,713 request: SpawnSubagentRequest,
354 ) -> Result<SpawnSubagentResult, SubagentControlError> {714 ) -> Result<SpawnSubagentResult, SubagentControlError> {
355- let Some(supervisor) = self.supervisor_for_session(&request.session_id).await else {715+ let Some(handle) = self.handle_for_session(&request.session_id).await else {
356 return Err(SubagentControlError::Unavailable {716 return Err(SubagentControlError::Unavailable {
357 message: format!("session '{}' is not available", request.session_id),717 message: format!("session '{}' is not available", request.session_id),
358 });718 });
359 };719 };
720+ let supervisor = handle.supervisor();
360 supervisor.spawn_subagent(request).await721 supervisor.spawn_subagent(request).await
361 }722 }
362 723 
@@ -364,11 +725,12 @@ impl SubagentControl for CoreBackedSessionService {
364 &self,725 &self,
365 request: JoinSubagentRequest,726 request: JoinSubagentRequest,
366 ) -> Result<JoinSubagentResult, SubagentControlError> {727 ) -> Result<JoinSubagentResult, SubagentControlError> {
367- let Some(supervisor) = self.supervisor_for_session(&request.session_id).await else {728+ let Some(handle) = self.handle_for_session(&request.session_id).await else {
368 return Err(SubagentControlError::Unavailable {729 return Err(SubagentControlError::Unavailable {
369 message: format!("session '{}' is not available", request.session_id),730 message: format!("session '{}' is not available", request.session_id),
370 });731 });
371 };732 };
733+ let supervisor = handle.supervisor();
372 supervisor.join_subagent(request).await734 supervisor.join_subagent(request).await
373 }735 }
374}736}
@@ -399,8 +761,9 @@ fn current_time_ms() -> u64 {
399#[cfg(test)]761#[cfg(test)]
400mod tests {762mod tests {
401 use super::*;763 use super::*;
764+ use crate::backend::GatewayBackendConfig;
402 use crate::gateway::{765 use crate::gateway::{
403- backend::GatewayBackendConfig, AppBootstrap, GatewayEntryContext, InMemorySessionStore,766+ AppBootstrap, GatewayEntryContext, InMemorySessionStore, SessionInput, SessionInputKind,
404 SessionRuntimeBindings, SessionRuntimeDescriptor,767 SessionRuntimeBindings, SessionRuntimeDescriptor,
405 };768 };
406 use agent_contracts::backend::BackendLifecycleState;769 use agent_contracts::backend::BackendLifecycleState;
@@ -457,6 +820,7 @@ mod tests {
457 descriptor: SessionRuntimeDescriptor {820 descriptor: SessionRuntimeDescriptor {
458 agent_id: AgentId("test-agent".to_string()),821 agent_id: AgentId("test-agent".to_string()),
459 model: "stub-model".to_string(),822 model: "stub-model".to_string(),
823+ llm: None,
460 system_prompt: "test system".to_string(),824 system_prompt: "test system".to_string(),
461 feature_flags: FeatureFlags::default(),825 feature_flags: FeatureFlags::default(),
462 token_budget: TokenBudgetConfig {826 token_budget: TokenBudgetConfig {
@@ -501,6 +865,37 @@ mod tests {
501 ))865 ))
502 }866 }
503 867 
868+ fn test_open_request(session_id: &str) -> SessionOpenRequest {
869+ SessionOpenRequest {
870+ session_id: session_id.to_string(),
871+ conversation_id: format!("{session_id}-conversation"),
872+ sender_id: "user-1".to_string(),
873+ entry: GatewayEntryContext::tui(None),
874+ channel: None,
875+ channel_instance_id: None,
876+ llm: None,
877+ }
878+ }
879+ 
880+ async fn save_session_without_backend(
881+ store: &Arc<InMemorySessionStore>,
882+ resolver: &Arc<StubRuntimeResolver>,
883+ session_id: &str,
884+ status: SessionLifecycleStatus,
885+ ) -> SessionRecord {
886+ let request = test_open_request(session_id);
887+ let runtime_input = SessionRuntimeBuildInput::from_open_request(&request);
888+ let resolved = resolver
889+ .resolve(&runtime_input, None)
890+ .await
891+ .expect("resolve runtime");
892+ let mut session = CoreBackedSessionService::build_session_for_open(&request, &resolved);
893+ session.status = status;
894+ session.backend_instance = None;
895+ store.save(session.clone()).await;
896+ session
897+ }
898+ 
504 #[tokio::test]899 #[tokio::test]
505 async fn open_session_persists_active_backend_instance() {900 async fn open_session_persists_active_backend_instance() {
506 let workspace = TempDir::new().expect("workspace");901 let workspace = TempDir::new().expect("workspace");
@@ -514,7 +909,7 @@ mod tests {
514 store.clone(),909 store.clone(),
515 resolver,910 resolver,
516 HookerRegistryConfig::default(),911 HookerRegistryConfig::default(),
517- Arc::new(ExternalBackendManager::new()),912+ Arc::new(BackendManager::new()),
518 )913 )
519 .expect("dependencies");914 .expect("dependencies");
520 915 
@@ -527,16 +922,342 @@ mod tests {
527 entry: GatewayEntryContext::tui(None),922 entry: GatewayEntryContext::tui(None),
528 channel: None,923 channel: None,
529 channel_instance_id: None,924 channel_instance_id: None,
925+ llm: None,
530 })926 })
531 .await927 .await
532 .expect("open session");928 .expect("open session");
533 let instance = record.backend_instance.expect("backend instance");929 let instance = record.backend_instance.expect("backend instance");
534 assert_eq!(instance.state, BackendLifecycleState::Active);930 assert_eq!(instance.state, BackendLifecycleState::Active);
535 assert_eq!(instance.session_id, "s1");931 assert_eq!(instance.session_id, "s1");
932+ assert!(instance.backend_id.0.starts_with("bkd_"));
933+ assert_ne!(instance.backend_id.0, "s1");
536 934 
537 let saved = store.load("s1").await.expect("saved session");935 let saved = store.load("s1").await.expect("saved session");
538 let saved_instance = saved.backend_instance.expect("saved backend instance");936 let saved_instance = saved.backend_instance.expect("saved backend instance");
539 assert_eq!(saved_instance.state, BackendLifecycleState::Active);937 assert_eq!(saved_instance.state, BackendLifecycleState::Active);
540 assert_eq!(saved_instance.backend_id, instance.backend_id);938 assert_eq!(saved_instance.backend_id, instance.backend_id);
541 }939 }
940+ 
941+ #[tokio::test]
942+ async fn submit_cancel_active_turn_routes_through_session_handle() {
943+ let workspace = TempDir::new().expect("workspace");
944+ let store = Arc::new(InMemorySessionStore::default());
945+ let resolver = Arc::new(StubRuntimeResolver {
946+ workspace_root: workspace.path().to_path_buf(),
947+ backend_options: json!({"temp_root": workspace.path().to_string_lossy().to_string()}),
948+ llm_provider: stub_llm_provider(),
949+ });
950+ let dependencies = AppBootstrap::from_session_components_with_hooks_and_backend_manager(
951+ store,
952+ resolver,
953+ HookerRegistryConfig::default(),
954+ Arc::new(BackendManager::new()),
955+ )
956+ .expect("dependencies");
957+ 
958+ dependencies
959+ .session_control_plane
960+ .open_session(SessionOpenRequest {
961+ session_id: "s-cancel".to_string(),
962+ conversation_id: "c1".to_string(),
963+ sender_id: "u1".to_string(),
964+ entry: GatewayEntryContext::tui(None),
965+ channel: None,
966+ channel_instance_id: None,
967+ llm: None,
968+ })
969+ .await
970+ .expect("open session");
971+ 
972+ let receipt = dependencies
973+ .session_control_plane
974+ .submit_input("s-cancel", SessionInput::CancelActiveTurn)
975+ .await
976+ .expect("cancel should be accepted");
977+ 
978+ assert_eq!(receipt.session_id, "s-cancel");
979+ assert_eq!(receipt.accepted_kind, SessionInputKind::CancelActiveTurn);
980+ }
981+ 
982+ #[tokio::test]
983+ async fn force_close_session_removes_session_record() {
984+ let workspace = TempDir::new().expect("workspace");
985+ let store = Arc::new(InMemorySessionStore::default());
986+ let resolver = Arc::new(StubRuntimeResolver {
987+ workspace_root: workspace.path().to_path_buf(),
988+ backend_options: json!({"temp_root": workspace.path().to_string_lossy().to_string()}),
989+ llm_provider: stub_llm_provider(),
990+ });
991+ let dependencies = AppBootstrap::from_session_components_with_hooks_and_backend_manager(
992+ store,
993+ resolver,
994+ HookerRegistryConfig::default(),
995+ Arc::new(BackendManager::new()),
996+ )
997+ .expect("dependencies");
998+ 
999+ dependencies
1000+ .session_control_plane
1001+ .open_session(SessionOpenRequest {
1002+ session_id: "s-close".to_string(),
1003+ conversation_id: "c1".to_string(),
1004+ sender_id: "u1".to_string(),
1005+ entry: GatewayEntryContext::tui(None),
1006+ channel: None,
1007+ channel_instance_id: None,
1008+ llm: None,
1009+ })
1010+ .await
1011+ .expect("open session");
1012+ 
1013+ let closed = dependencies
1014+ .session_control_plane
1015+ .force_close_session("s-close")
1016+ .await
1017+ .expect("close session");
1018+ assert_eq!(closed.status, SessionLifecycleStatus::Closed);
1019+ 
1020+ let resumed = dependencies
1021+ .session_control_plane
1022+ .resume_session("s-close")
1023+ .await
1024+ .expect("resume closed session");
1025+ assert!(resumed.is_none());
1026+ }
1027+ 
1028+ #[tokio::test]
1029+ async fn checkpoint_runtime_idle_session_without_backend_succeeds() {
1030+ let workspace = TempDir::new().expect("workspace");
1031+ let store = Arc::new(InMemorySessionStore::default());
1032+ let resolver = Arc::new(StubRuntimeResolver {
1033+ workspace_root: workspace.path().to_path_buf(),
1034+ backend_options: json!({"temp_root": workspace.path().to_string_lossy().to_string()}),
1035+ llm_provider: stub_llm_provider(),
1036+ });
1037+ save_session_without_backend(&store, &resolver, "runtime-1", SessionLifecycleStatus::Idle)
1038+ .await;
1039+ let dependencies = AppBootstrap::from_session_components_with_hooks_and_backend_manager(
1040+ store,
1041+ resolver,
1042+ HookerRegistryConfig::default(),
1043+ Arc::new(BackendManager::new()),
1044+ )
1045+ .expect("dependencies");
1046+ 
1047+ let result = dependencies
1048+ .session_control_plane
1049+ .checkpoint_runtime(RuntimeCheckpointRequest {
1050+ runtime_id: "runtime-1".to_string(),
1051+ metadata: json!({"kind": "test"}),
1052+ name: Some("checkpoint-a".to_string()),
1053+ })
1054+ .await
1055+ .expect("checkpoint runtime");
1056+ 
1057+ assert!(result.checkpoint_id.starts_with("rtcp_"));
1058+ assert_eq!(result.runtime.runtime_id, "runtime-1");
1059+ assert_eq!(result.parent_checkpoint_id, None);
1060+ assert_eq!(result.metadata, json!({"kind": "test"}));
1061+ assert_eq!(result.name.as_deref(), Some("checkpoint-a"));
1062+ }
1063+ 
1064+ #[tokio::test]
1065+ async fn delete_checkpoint_snapshot_without_backend_snapshot_is_noop() {
1066+ let workspace = TempDir::new().expect("workspace");
1067+ let store = Arc::new(InMemorySessionStore::default());
1068+ let resolver = Arc::new(StubRuntimeResolver {
1069+ workspace_root: workspace.path().to_path_buf(),
1070+ backend_options: json!({"temp_root": workspace.path().to_string_lossy().to_string()}),
1071+ llm_provider: stub_llm_provider(),
1072+ });
1073+ save_session_without_backend(&store, &resolver, "runtime-1", SessionLifecycleStatus::Idle)
1074+ .await;
1075+ let dependencies = AppBootstrap::from_session_components_with_hooks_and_backend_manager(
1076+ store,
1077+ resolver,
1078+ HookerRegistryConfig::default(),
1079+ Arc::new(BackendManager::new()),
1080+ )
1081+ .expect("dependencies");
1082+ let checkpoint = dependencies
1083+ .session_control_plane
1084+ .checkpoint_runtime(RuntimeCheckpointRequest {
1085+ runtime_id: "runtime-1".to_string(),
1086+ metadata: Value::Null,
1087+ name: None,
1088+ })
1089+ .await
1090+ .expect("checkpoint runtime");
1091+ 
1092+ let result = dependencies
1093+ .session_control_plane
1094+ .delete_checkpoint_snapshot(RuntimeCheckpointSnapshotDeleteRequest {
1095+ checkpoint_id: checkpoint.checkpoint_id.clone(),
1096+ })
1097+ .await
1098+ .expect("delete checkpoint snapshot");
1099+ 
1100+ assert_eq!(result.checkpoint_id, checkpoint.checkpoint_id);
1101+ assert_eq!(result.runtime_id, "runtime-1");
1102+ assert_eq!(result.provider, None);
1103+ assert_eq!(result.provider_snapshot_id, None);
1104+ assert!(!result.deleted_provider_snapshot);
1105+ }
1106+ 
1107+ #[tokio::test]
1108+ async fn checkout_runtime_creates_new_runtime_from_checkpoint() {
1109+ let workspace = TempDir::new().expect("workspace");
1110+ let store = Arc::new(InMemorySessionStore::default());
1111+ let resolver = Arc::new(StubRuntimeResolver {
1112+ workspace_root: workspace.path().to_path_buf(),
1113+ backend_options: json!({"temp_root": workspace.path().to_string_lossy().to_string()}),
1114+ llm_provider: stub_llm_provider(),
1115+ });
1116+ let parent = save_session_without_backend(
1117+ &store,
1118+ &resolver,
1119+ "runtime-parent",
1120+ SessionLifecycleStatus::Idle,
1121+ )
1122+ .await;
1123+ let dependencies = AppBootstrap::from_session_components_with_hooks_and_backend_manager(
1124+ store.clone(),
1125+ resolver,
1126+ HookerRegistryConfig::default(),
1127+ Arc::new(BackendManager::new()),
1128+ )
1129+ .expect("dependencies");
1130+ let checkpoint = dependencies
1131+ .session_control_plane
1132+ .checkpoint_runtime(RuntimeCheckpointRequest {
1133+ runtime_id: parent.session_id.clone(),
1134+ metadata: Value::Null,
1135+ name: None,
1136+ })
1137+ .await
1138+ .expect("checkpoint runtime");
1139+ 
1140+ let checkout = dependencies
1141+ .session_control_plane
1142+ .checkout_runtime(RuntimeCheckoutRequest {
1143+ checkpoint_id: checkpoint.checkpoint_id.clone(),
1144+ conversation_id: Some("child-conversation".to_string()),
1145+ sender_id: Some("child-user".to_string()),
1146+ metadata: json!({"branch": "a"}),
1147+ })
1148+ .await
1149+ .expect("checkout runtime");
1150+ 
1151+ assert_eq!(checkout.checkpoint_id, checkpoint.checkpoint_id);
1152+ assert_eq!(checkout.source_runtime_id, parent.session_id);
1153+ assert_ne!(checkout.runtime.runtime_id, parent.session_id);
1154+ assert!(checkout
1155+ .runtime
1156+ .runtime_id
1157+ .starts_with("runtime-parent:checkout:"));
1158+ assert_eq!(checkout.runtime.conversation_id, "child-conversation");
1159+ assert_eq!(checkout.runtime.sender_id, "child-user");
1160+ 
1161+ let saved = store
1162+ .load(&checkout.runtime.runtime_id)
1163+ .await
1164+ .expect("checked out runtime saved");
1165+ assert_eq!(saved.conversation_id, "child-conversation");
1166+ assert_eq!(saved.sender_id, "child-user");
1167+ assert_eq!(saved.status, SessionLifecycleStatus::Idle);
1168+ assert!(saved.backend_instance.is_none());
1169+ assert_eq!(saved.runtime.agent_id, parent.runtime.agent_id);
1170+ }
1171+ 
1172+ #[tokio::test]
1173+ async fn checkpoint_runtime_rejects_running_session() {
1174+ let workspace = TempDir::new().expect("workspace");
1175+ let store = Arc::new(InMemorySessionStore::default());
1176+ let resolver = Arc::new(StubRuntimeResolver {
1177+ workspace_root: workspace.path().to_path_buf(),
1178+ backend_options: json!({"temp_root": workspace.path().to_string_lossy().to_string()}),
1179+ llm_provider: stub_llm_provider(),
1180+ });
1181+ save_session_without_backend(
1182+ &store,
1183+ &resolver,
1184+ "runtime-running",
1185+ SessionLifecycleStatus::Running,
1186+ )
1187+ .await;
1188+ let dependencies = AppBootstrap::from_session_components_with_hooks_and_backend_manager(
1189+ store,
1190+ resolver,
1191+ HookerRegistryConfig::default(),
1192+ Arc::new(BackendManager::new()),
1193+ )
1194+ .expect("dependencies");
1195+ 
1196+ let result = dependencies
1197+ .session_control_plane
1198+ .checkpoint_runtime(RuntimeCheckpointRequest {
1199+ runtime_id: "runtime-running".to_string(),
1200+ metadata: Value::Null,
1201+ name: None,
1202+ })
1203+ .await;
1204+ 
1205+ assert!(matches!(
1206+ result,
1207+ Err(SessionServiceError::SessionBusy { session_id, .. })
1208+ if session_id == "runtime-running"
1209+ ));
1210+ }
1211+ 
1212+ #[tokio::test]
1213+ async fn checkout_runtime_rejects_running_source_runtime() {
1214+ let workspace = TempDir::new().expect("workspace");
1215+ let store = Arc::new(InMemorySessionStore::default());
1216+ let resolver = Arc::new(StubRuntimeResolver {
1217+ workspace_root: workspace.path().to_path_buf(),
1218+ backend_options: json!({"temp_root": workspace.path().to_string_lossy().to_string()}),
1219+ llm_provider: stub_llm_provider(),
1220+ });
1221+ let mut parent = save_session_without_backend(
1222+ &store,
1223+ &resolver,
1224+ "runtime-source",
1225+ SessionLifecycleStatus::Idle,
1226+ )
1227+ .await;
1228+ let dependencies = AppBootstrap::from_session_components_with_hooks_and_backend_manager(
1229+ store.clone(),
1230+ resolver,
1231+ HookerRegistryConfig::default(),
1232+ Arc::new(BackendManager::new()),
1233+ )
1234+ .expect("dependencies");
1235+ let checkpoint = dependencies
1236+ .session_control_plane
1237+ .checkpoint_runtime(RuntimeCheckpointRequest {
1238+ runtime_id: "runtime-source".to_string(),
1239+ metadata: Value::Null,
1240+ name: None,
1241+ })
1242+ .await
1243+ .expect("checkpoint runtime");
1244+ parent.status = SessionLifecycleStatus::Running;
1245+ store.save(parent).await;
1246+ 
1247+ let result = dependencies
1248+ .session_control_plane
1249+ .checkout_runtime(RuntimeCheckoutRequest {
1250+ checkpoint_id: checkpoint.checkpoint_id,
1251+ conversation_id: None,
1252+ sender_id: None,
1253+ metadata: Value::Null,
1254+ })
1255+ .await;
1256+ 
1257+ assert!(matches!(
1258+ result,
1259+ Err(SessionServiceError::SessionBusy { session_id, .. })
1260+ if session_id == "runtime-source"
1261+ ));
1262+ }
542}1263}
Rapps/xiaoo-app/src/gateway/session_store.rsapps/shared/src/gateway/session_store.rs+5-0
@@ -15,6 +15,7 @@ pub enum SessionStoreError {
15pub trait SessionStore: Send + Sync {15pub trait SessionStore: Send + Sync {
16 async fn load(&self, session_id: &str) -> Option<SessionRecord>;16 async fn load(&self, session_id: &str) -> Option<SessionRecord>;
17 async fn save(&self, record: SessionRecord);17 async fn save(&self, record: SessionRecord);
18+ async fn delete(&self, session_id: &str) -> Option<SessionRecord>;
18 async fn transition(19 async fn transition(
19 &self,20 &self,
20 session_id: &str,21 session_id: &str,
@@ -42,6 +43,10 @@ impl SessionStore for InMemorySessionStore {
42 .insert(record.session_id.clone(), record);43 .insert(record.session_id.clone(), record);
43 }44 }
44 45 
46+ async fn delete(&self, session_id: &str) -> Option<SessionRecord> {
47+ self.records.write().await.remove(session_id)
48+ }
49+ 
45 async fn transition(50 async fn transition(
46 &self,51 &self,
47 session_id: &str,52 session_id: &str,
Rapps/xiaoo-app/src/gateway/session_supervisor.rsapps/shared/src/gateway/session_supervisor.rs+11-3
@@ -1,4 +1,4 @@
1-use crate::gateway::backend::ExternalBackendManager;1+use crate::backend::BackendManager;
2use crate::gateway::session_backend::{lease_session_backend, sync_session_backend_instance};2use crate::gateway::session_backend::{lease_session_backend, sync_session_backend_instance};
3use crate::gateway::session_record::SessionAgentRecord;3use crate::gateway::session_record::SessionAgentRecord;
4use crate::gateway::{4use crate::gateway::{
@@ -22,6 +22,7 @@ use subagent::{
22 SubagentControlError, SubagentCoordinator, SubagentTerminalKind, SubagentTerminalSnapshot,22 SubagentControlError, SubagentCoordinator, SubagentTerminalKind, SubagentTerminalSnapshot,
23};23};
24use tokio::sync::{oneshot, Mutex};24use tokio::sync::{oneshot, Mutex};
25+use tokio_util::sync::CancellationToken;
25use tool::ToolSpecSnapshot;26use tool::ToolSpecSnapshot;
26use xiaoo_core::agent_loop::build_tool_result_message;27use xiaoo_core::agent_loop::build_tool_result_message;
27use xiaoo_core::{LoopRunResult, LoopStateSnapshot, LoopSuspendReason, SuspendedToolCall};28use xiaoo_core::{LoopRunResult, LoopStateSnapshot, LoopSuspendReason, SuspendedToolCall};
@@ -54,6 +55,7 @@ struct LaneRunInput {
54 loop_event_sink_override: Option<Arc<dyn LoopEventSink>>,55 loop_event_sink_override: Option<Arc<dyn LoopEventSink>>,
55 interaction_handle_override: Option<Arc<dyn InteractionHandle>>,56 interaction_handle_override: Option<Arc<dyn InteractionHandle>>,
56 channel_file_sender_override: Option<Arc<dyn ChannelFileSender>>,57 channel_file_sender_override: Option<Arc<dyn ChannelFileSender>>,
58+ cancellation_token: Option<CancellationToken>,
57}59}
58 60 
59struct LaneTerminal {61struct LaneTerminal {
@@ -66,7 +68,7 @@ struct LaneTerminal {
66pub struct SessionSupervisor {68pub struct SessionSupervisor {
67 session_store: Arc<dyn SessionStore>,69 session_store: Arc<dyn SessionStore>,
68 runtime_resolver: Arc<dyn SessionRuntimeResolver>,70 runtime_resolver: Arc<dyn SessionRuntimeResolver>,
69- backend_manager: Arc<ExternalBackendManager>,71+ backend_manager: Arc<BackendManager>,
70 coordinator: SubagentCoordinator,72 coordinator: SubagentCoordinator,
71 session: Mutex<SessionRecord>,73 session: Mutex<SessionRecord>,
72 pending_joins: Mutex<HashMap<String, PendingJoinWaiter>>,74 pending_joins: Mutex<HashMap<String, PendingJoinWaiter>>,
@@ -79,7 +81,7 @@ impl SessionSupervisor {
79 pub fn new(81 pub fn new(
80 session_store: Arc<dyn SessionStore>,82 session_store: Arc<dyn SessionStore>,
81 runtime_resolver: Arc<dyn SessionRuntimeResolver>,83 runtime_resolver: Arc<dyn SessionRuntimeResolver>,
82- backend_manager: Arc<ExternalBackendManager>,84+ backend_manager: Arc<BackendManager>,
83 session: SessionRecord,85 session: SessionRecord,
84 ) -> Self {86 ) -> Self {
85 Self {87 Self {
@@ -112,6 +114,7 @@ impl SessionSupervisor {
112 session.channel_instance_id = request.channel_instance_id.clone();114 session.channel_instance_id = request.channel_instance_id.clone();
113 session.runtime.agent_id = resolved.descriptor.agent_id.clone();115 session.runtime.agent_id = resolved.descriptor.agent_id.clone();
114 session.runtime.model = resolved.descriptor.model.clone();116 session.runtime.model = resolved.descriptor.model.clone();
117+ session.runtime.llm = resolved.descriptor.llm.clone();
115 session.runtime.system_prompt = resolved.descriptor.system_prompt.clone();118 session.runtime.system_prompt = resolved.descriptor.system_prompt.clone();
116 session.runtime.feature_flags = resolved.descriptor.feature_flags.clone();119 session.runtime.feature_flags = resolved.descriptor.feature_flags.clone();
117 session.runtime.token_budget = resolved.descriptor.token_budget.clone();120 session.runtime.token_budget = resolved.descriptor.token_budget.clone();
@@ -301,6 +304,7 @@ impl SessionSupervisor {
301 loop_event_sink_override: Option<Arc<dyn LoopEventSink>>,304 loop_event_sink_override: Option<Arc<dyn LoopEventSink>>,
302 interaction_handle_override: Option<Arc<dyn InteractionHandle>>,305 interaction_handle_override: Option<Arc<dyn InteractionHandle>>,
303 channel_file_sender_override: Option<Arc<dyn ChannelFileSender>>,306 channel_file_sender_override: Option<Arc<dyn ChannelFileSender>>,
307+ cancellation_token: Option<CancellationToken>,
304 ) -> Result<AppTurnResult, SessionServiceError> {308 ) -> Result<AppTurnResult, SessionServiceError> {
305 let _guard = self.root_turn_lock.lock().await;309 let _guard = self.root_turn_lock.lock().await;
306 self.set_session_status(SessionLifecycleStatus::Running, None)310 self.set_session_status(SessionLifecycleStatus::Running, None)
@@ -322,6 +326,7 @@ impl SessionSupervisor {
322 loop_event_sink_override,326 loop_event_sink_override,
323 interaction_handle_override,327 interaction_handle_override,
324 channel_file_sender_override,328 channel_file_sender_override,
329+ cancellation_token,
325 })330 })
326 .await;331 .await;
327 332 
@@ -404,6 +409,7 @@ impl SessionSupervisor {
404 loop_state: loop_state.clone(),409 loop_state: loop_state.clone(),
405 memory_snapshot: memory_snapshot.clone(),410 memory_snapshot: memory_snapshot.clone(),
406 tool_manifest: tool_manifest.clone(),411 tool_manifest: tool_manifest.clone(),
412+ cancellation_token: input.cancellation_token.clone(),
407 })413 })
408 .await?;414 .await?;
409 415 
@@ -617,6 +623,7 @@ impl SessionSupervisor {
617 loop_event_sink_override: None,623 loop_event_sink_override: None,
618 interaction_handle_override: Some(interaction_handle),624 interaction_handle_override: Some(interaction_handle),
619 channel_file_sender_override: None,625 channel_file_sender_override: None,
626+ cancellation_token: None,
620 })627 })
621 .await;628 .await;
622 629 
@@ -887,6 +894,7 @@ fn runtime_input_from_session(
887 agent_id_override: if is_subagent { Some(agent_id) } else { None },894 agent_id_override: if is_subagent { Some(agent_id) } else { None },
888 max_turns_override,895 max_turns_override,
889 subagent_role_id,896 subagent_role_id,
897+ llm: session.runtime.llm.clone(),
890 }898 }
891}899}
892 900 
Rapps/xiaoo-app/src/gateway/session_worker.rsapps/shared/src/gateway/session_worker.rs+6-2
@@ -31,6 +31,7 @@ pub struct SessionWorkerInput {
31 pub loop_state: Option<LoopStateSnapshot>,31 pub loop_state: Option<LoopStateSnapshot>,
32 pub memory_snapshot: Option<MemorySnapshot>,32 pub memory_snapshot: Option<MemorySnapshot>,
33 pub tool_manifest: Option<Vec<ToolSpecSnapshot>>,33 pub tool_manifest: Option<Vec<ToolSpecSnapshot>>,
34+ pub cancellation_token: Option<CancellationToken>,
34}35}
35 36 
36pub struct SessionWorkerResult {37pub struct SessionWorkerResult {
@@ -73,12 +74,15 @@ impl SessionWorker {
73 .as_ref()74 .as_ref()
74 .map(|snapshot| snapshot.session_id)75 .map(|snapshot| snapshot.session_id)
75 .unwrap_or_else(uuid::Uuid::new_v4);76 .unwrap_or_else(uuid::Uuid::new_v4);
76- let cancel = CancellationToken::new();77+ let cancel = input
78+ .cancellation_token
79+ .clone()
80+ .unwrap_or_else(CancellationToken::new);
77 let mut loop_state = input81 let mut loop_state = input
78 .loop_state82 .loop_state
79 .clone()83 .clone()
80 .map(|snapshot| LoopState::from_snapshot(snapshot, cancel.clone()))84 .map(|snapshot| LoopState::from_snapshot(snapshot, cancel.clone()))
81- .unwrap_or_else(|| LoopState::new(loop_session_id));85+ .unwrap_or_else(|| LoopState::new_with_cancel(loop_session_id, cancel));
82 86 
83 // Share message storage with runtime_view87 // Share message storage with runtime_view
84 let messages = loop_state.messages_arc();88 let messages = loop_state.messages_arc();
Rapps/xiaoo-app/src/gateway/subagent_interaction.rsapps/shared/src/gateway/subagent_interaction.rs+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/gateway/turn_request.rsapps/shared/src/gateway/turns.rs+60-0
@@ -1,3 +1,4 @@
1+use agent_types::ChatMessage;
1use agent_types::ReasoningEffort;2use agent_types::ReasoningEffort;
2use serde::{Deserialize, Serialize};3use serde::{Deserialize, Serialize};
3 4 
@@ -11,6 +12,17 @@ pub enum GatewayEntryKind {
11 Cli,12 Cli,
12}13}
13 14 
15+#[derive(Debug, Clone)]
16+pub struct AppTurnResult {
17+ pub raw_reply: String,
18+ pub visible_reply: String,
19+ pub messages: Vec<ChatMessage>,
20+ pub prompt_tokens: u64,
21+ pub completion_tokens: u64,
22+ pub total_tokens: u64,
23+ pub estimated_input_tokens: u64,
24+}
25+ 
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
15pub struct GatewayEntryContext {27pub struct GatewayEntryContext {
16 pub kind: Option<GatewayEntryKind>,28 pub kind: Option<GatewayEntryKind>,
@@ -49,6 +61,20 @@ impl GatewayEntryContext {
49 }61 }
50}62}
51 63 
64+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
65+pub struct LlmRuntimeConfig {
66+ #[serde(default)]
67+ pub provider: Option<String>,
68+ #[serde(default)]
69+ pub model: Option<String>,
70+ #[serde(default)]
71+ pub api_base: Option<String>,
72+ #[serde(default)]
73+ pub api_key_env: Option<String>,
74+ #[serde(default)]
75+ pub api_key: Option<String>,
76+}
77+ 
52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]78#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
53pub struct TurnMention {79pub struct TurnMention {
54 pub id: String,80 pub id: String,
@@ -57,6 +83,7 @@ pub struct TurnMention {
57 83 
58#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]84#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
59pub struct AppTurnRequest {85pub struct AppTurnRequest {
86+ #[serde(rename = "runtime_id", alias = "session_id")]
60 pub session_id: String,87 pub session_id: String,
61 #[serde(default)]88 #[serde(default)]
62 pub entry: GatewayEntryContext,89 pub entry: GatewayEntryContext,
@@ -73,4 +100,37 @@ pub struct AppTurnRequest {
73 pub mentions: Vec<TurnMention>,100 pub mentions: Vec<TurnMention>,
74 #[serde(default)]101 #[serde(default)]
75 pub reasoning_effort: ReasoningEffort,102 pub reasoning_effort: ReasoningEffort,
103+ #[serde(default)]
104+ pub llm: Option<LlmRuntimeConfig>,
105+}
106+ 
107+pub type RuntimeTurnRequest = AppTurnRequest;
108+ 
109+#[cfg(test)]
110+mod tests {
111+ use super::*;
112+ 
113+ #[test]
114+ fn runtime_turn_request_serializes_runtime_id() {
115+ let request = RuntimeTurnRequest {
116+ session_id: "runtime-1".to_string(),
117+ entry: GatewayEntryContext::default(),
118+ channel: None,
119+ message_id: None,
120+ conversation_id: "conv-1".to_string(),
121+ sender_id: "user-1".to_string(),
122+ text: "hello".to_string(),
123+ channel_instance_id: None,
124+ channel_identity_prompt: None,
125+ reply_to_message_id: None,
126+ root_message_id: None,
127+ mentions: Vec::new(),
128+ reasoning_effort: ReasoningEffort::default(),
129+ llm: None,
130+ };
131+ 
132+ let value = serde_json::to_value(&request).expect("request should serialize");
133+ assert_eq!(value["runtime_id"], "runtime-1");
134+ assert!(value.get("session_id").is_none());
135+ }
76}136}
Rapps/xiaoo-app/src/gateway/workspace_prompt.rsapps/shared/src/gateway/workspace_prompt.rs+0-0
文件重命名但无更改。
Aapps/shared/src/lib.rs+13-0
@@ -0,0 +1,13 @@
1+pub mod backend;
2+pub mod builtin_agent_roles;
3+pub mod channels;
4+pub mod gateway;
5+pub mod llm_secrets;
6+pub mod lsp_support;
7+pub mod runtime_checkpoint;
8+ 
9+pub use runtime_checkpoint::{
10+ RuntimeCheckoutRequest, RuntimeCheckoutResult, RuntimeCheckpointRequest,
11+ RuntimeCheckpointResult, RuntimeCheckpointSnapshotDeleteRequest,
12+ RuntimeCheckpointSnapshotDeleteResult, RuntimeRecord,
13+};
Rapps/xiaoo-app/src/llm_secrets.rsapps/shared/src/llm_secrets.rs+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/lsp_support.rsapps/shared/src/lsp_support.rs+18-8
@@ -1,32 +1,42 @@
1-use crate::daemon_config::ExtraServerConfig;
2use lsp::{AutoInstall, ServerConfig};1use lsp::{AutoInstall, ServerConfig};
3 2 
3+pub trait ExtraServerConfigView {
4+ fn id(&self) -> &str;
5+ fn extensions(&self) -> &[String];
6+ fn command(&self) -> &str;
7+ fn args(&self) -> &[String];
8+ fn root_markers(&self) -> &[String];
9+ fn language_id(&self) -> &str;
10+}
11+ 
4/// Convert user-supplied [`ExtraServerConfig`] entries to the lsp crate's12/// Convert user-supplied [`ExtraServerConfig`] entries to the lsp crate's
5/// [`ServerConfig`] format. Strings are leaked to produce `'static` slices,13/// [`ServerConfig`] format. Strings are leaked to produce `'static` slices,
6/// matching the format used by the built-in server table.14/// matching the format used by the built-in server table.
7-pub fn build_extra_server_configs(extra_servers: &[ExtraServerConfig]) -> Vec<ServerConfig> {15+pub fn build_extra_server_configs<T: ExtraServerConfigView>(
16+ extra_servers: &[T],
17+) -> Vec<ServerConfig> {
8 extra_servers18 extra_servers
9 .iter()19 .iter()
10 .map(|c| {20 .map(|c| {
11- let id: &'static str = Box::leak(c.id.clone().into_boxed_str());21+ let id: &'static str = Box::leak(c.id().to_string().into_boxed_str());
12- let command: &'static str = Box::leak(c.command.clone().into_boxed_str());22+ let command: &'static str = Box::leak(c.command().to_string().into_boxed_str());
13- let language_id: &'static str = Box::leak(c.language_id.clone().into_boxed_str());23+ let language_id: &'static str = Box::leak(c.language_id().to_string().into_boxed_str());
14 let extensions: &'static [&'static str] = Box::leak(24 let extensions: &'static [&'static str] = Box::leak(
15- c.extensions25+ c.extensions()
16 .iter()26 .iter()
17 .map(|e| -> &'static str { Box::leak(e.clone().into_boxed_str()) })27 .map(|e| -> &'static str { Box::leak(e.clone().into_boxed_str()) })
18 .collect::<Vec<_>>()28 .collect::<Vec<_>>()
19 .into_boxed_slice(),29 .into_boxed_slice(),
20 );30 );
21 let args: &'static [&'static str] = Box::leak(31 let args: &'static [&'static str] = Box::leak(
22- c.args32+ c.args()
23 .iter()33 .iter()
24 .map(|a| -> &'static str { Box::leak(a.clone().into_boxed_str()) })34 .map(|a| -> &'static str { Box::leak(a.clone().into_boxed_str()) })
25 .collect::<Vec<_>>()35 .collect::<Vec<_>>()
26 .into_boxed_slice(),36 .into_boxed_slice(),
27 );37 );
28 let root_markers: &'static [&'static str] = Box::leak(38 let root_markers: &'static [&'static str] = Box::leak(
29- c.root_markers39+ c.root_markers()
30 .iter()40 .iter()
31 .map(|m| -> &'static str { Box::leak(m.clone().into_boxed_str()) })41 .map(|m| -> &'static str { Box::leak(m.clone().into_boxed_str()) })
32 .collect::<Vec<_>>()42 .collect::<Vec<_>>()
Aapps/shared/src/runtime_checkpoint.rs+165-0
@@ -0,0 +1,165 @@
1+use crate::backend::BackendCheckpointRef;
2+use crate::gateway::{SessionLifecycleStatus, SessionRecord};
3+use serde::{Deserialize, Serialize};
4+use serde_json::Value;
5+use std::collections::HashMap;
6+use std::sync::Arc;
7+use tokio::sync::RwLock;
8+ 
9+#[derive(Debug, Clone, Serialize, Deserialize)]
10+pub struct RuntimeRecord {
11+ pub runtime_id: String,
12+ pub conversation_id: String,
13+ pub sender_id: String,
14+ pub status: SessionLifecycleStatus,
15+ pub created_at_ms: u64,
16+ pub updated_at_ms: u64,
17+}
18+ 
19+impl RuntimeRecord {
20+ pub(crate) fn from_session(session: &SessionRecord) -> Self {
21+ Self {
22+ runtime_id: session.session_id.clone(),
23+ conversation_id: session.conversation_id.clone(),
24+ sender_id: session.sender_id.clone(),
25+ status: session.status.clone(),
26+ created_at_ms: session.created_at_ms,
27+ updated_at_ms: session.updated_at_ms,
28+ }
29+ }
30+}
31+ 
32+#[derive(Debug, Clone, Serialize, Deserialize)]
33+pub struct RuntimeCheckpointRequest {
34+ pub runtime_id: String,
35+ #[serde(default)]
36+ pub metadata: Value,
37+ #[serde(default)]
38+ pub name: Option<String>,
39+}
40+ 
41+#[derive(Debug, Clone, Serialize, Deserialize)]
42+pub struct RuntimeCheckpointResult {
43+ pub checkpoint_id: String,
44+ pub runtime: RuntimeRecord,
45+ #[serde(default)]
46+ pub parent_checkpoint_id: Option<String>,
47+ pub created_at_ms: u64,
48+ #[serde(default)]
49+ pub metadata: Value,
50+ #[serde(default)]
51+ pub name: Option<String>,
52+}
53+ 
54+#[derive(Debug, Clone, Serialize, Deserialize)]
55+pub struct RuntimeCheckoutRequest {
56+ pub checkpoint_id: String,
57+ #[serde(default)]
58+ pub conversation_id: Option<String>,
59+ #[serde(default)]
60+ pub sender_id: Option<String>,
61+ #[serde(default)]
62+ pub metadata: Value,
63+}
64+ 
65+#[derive(Debug, Clone, Serialize, Deserialize)]
66+pub struct RuntimeCheckoutResult {
67+ pub checkpoint_id: String,
68+ pub source_runtime_id: String,
69+ pub runtime: RuntimeRecord,
70+}
71+ 
72+#[derive(Debug, Clone, Serialize, Deserialize)]
73+pub struct RuntimeCheckpointSnapshotDeleteRequest {
74+ pub checkpoint_id: String,
75+}
76+ 
77+#[derive(Debug, Clone, Serialize, Deserialize)]
78+pub struct RuntimeCheckpointSnapshotDeleteResult {
79+ pub checkpoint_id: String,
80+ pub runtime_id: String,
81+ #[serde(default)]
82+ pub provider: Option<String>,
83+ #[serde(default)]
84+ pub provider_snapshot_id: Option<String>,
85+ #[serde(default)]
86+ pub provider_snapshot_names: Vec<String>,
87+ pub deleted_provider_snapshot: bool,
88+ pub deleted_at_ms: u64,
89+}
90+ 
91+#[derive(Debug, Clone)]
92+#[allow(dead_code)]
93+pub(crate) struct RuntimeCheckpoint {
94+ pub checkpoint_id: String,
95+ pub runtime_id: String,
96+ pub parent_checkpoint_id: Option<String>,
97+ pub session: SessionRecord,
98+ pub backend_checkpoint: Option<BackendCheckpointRef>,
99+ pub created_at_ms: u64,
100+ pub metadata: Value,
101+ pub name: Option<String>,
102+}
103+ 
104+#[derive(Clone, Default)]
105+pub(crate) struct InMemoryRuntimeCheckpointStore {
106+ state: Arc<RwLock<RuntimeCheckpointStoreState>>,
107+}
108+ 
109+#[derive(Default)]
110+struct RuntimeCheckpointStoreState {
111+ checkpoints: HashMap<String, RuntimeCheckpoint>,
112+ runtime_heads: HashMap<String, String>,
113+}
114+ 
115+impl InMemoryRuntimeCheckpointStore {
116+ pub(crate) async fn latest_for_runtime(&self, runtime_id: &str) -> Option<String> {
117+ self.state
118+ .read()
119+ .await
120+ .runtime_heads
121+ .get(runtime_id)
122+ .cloned()
123+ }
124+ 
125+ pub(crate) async fn save(&self, checkpoint: RuntimeCheckpoint) {
126+ let mut state = self.state.write().await;
127+ state.runtime_heads.insert(
128+ checkpoint.runtime_id.clone(),
129+ checkpoint.checkpoint_id.clone(),
130+ );
131+ state
132+ .checkpoints
133+ .insert(checkpoint.checkpoint_id.clone(), checkpoint);
134+ }
135+ 
136+ pub(crate) async fn load(&self, checkpoint_id: &str) -> Option<RuntimeCheckpoint> {
137+ self.state
138+ .read()
139+ .await
140+ .checkpoints
141+ .get(checkpoint_id)
142+ .cloned()
143+ }
144+ 
145+ pub(crate) async fn register_runtime_head(&self, runtime_id: String, checkpoint_id: String) {
146+ self.state
147+ .write()
148+ .await
149+ .runtime_heads
150+ .insert(runtime_id, checkpoint_id);
151+ }
152+ 
153+ pub(crate) async fn clear_backend_snapshot(
154+ &self,
155+ checkpoint_id: &str,
156+ ) -> Option<RuntimeCheckpoint> {
157+ let mut state = self.state.write().await;
158+ let checkpoint = state.checkpoints.get_mut(checkpoint_id)?;
159+ if let Some(backend_checkpoint) = checkpoint.backend_checkpoint.as_mut() {
160+ backend_checkpoint.provider_snapshot_id = None;
161+ backend_checkpoint.provider_snapshot_names.clear();
162+ }
163+ Some(checkpoint.clone())
164+ }
165+}
Dapps/xiaoo-app/README.md+0-9
@@ -1,9 +0,0 @@
1-# xiaoo-app
2- 
3-Application-layer package for XiaoO.
4- 
5-## Current role
6- 
7-- Own the app assembly layer for gateway, TUI, channel ingress, and process bootstrap.
8-- Depend on `crates/*` for runtime, memory, contracts, and shared types.
9-- Keep transport concerns out of `crates/core`.
Dapps/xiaoo-app/src/gateway/backend/mod.rs+0-487
@@ -1,487 +0,0 @@
1-use agent_contracts::backend::{
2- BackendCreateRequest, BackendDeleteRequest, BackendInstance, BackendLifecycle,
3- BackendLifecycleReason, BackendPath, BackendProvider, BackendResourceLimits, OperationBackend,
4- OperationBackendBuildError, OperationError,
5-};
6-use serde::{Deserialize, Serialize};
7-use serde_json::{Map, Value};
8-use std::collections::{BTreeMap, HashMap};
9-use std::hash::{Hash, Hasher};
10-use std::path::PathBuf;
11-use std::sync::Arc;
12-use tokio::sync::Mutex;
13- 
14-#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
15-pub struct GatewayBackendConfig {
16- pub kind: String,
17- pub options: Value,
18-}
19- 
20-impl GatewayBackendConfig {
21- pub fn new(kind: impl Into<String>, options: Value) -> Self {
22- Self {
23- kind: kind.into(),
24- options,
25- }
26- }
27-}
28- 
29-#[derive(Debug, Clone)]
30-pub struct BackendEnsureSessionRequest {
31- pub config: Option<GatewayBackendConfig>,
32- pub workspace_root: PathBuf,
33- pub session_id: String,
34-}
35- 
36-#[derive(Clone)]
37-pub struct BackendLease {
38- backend: Arc<dyn OperationBackend>,
39- instance: BackendInstance,
40-}
41- 
42-impl BackendLease {
43- pub fn backend(&self) -> Arc<dyn OperationBackend> {
44- Arc::clone(&self.backend)
45- }
46- 
47- pub fn instance(&self) -> BackendInstance {
48- self.instance.clone()
49- }
50-}
51- 
52-#[derive(Debug, Clone, PartialEq, Eq, Hash)]
53-struct BackendInstanceKey {
54- session_id: String,
55- workspace_root: String,
56- config_hash: u64,
57-}
58- 
59-struct BackendInstanceEntry {
60- backend: Arc<dyn OperationBackend>,
61- instance: BackendInstance,
62-}
63- 
64-#[derive(Default)]
65-pub struct ExternalBackendManager {
66- instances: Mutex<HashMap<BackendInstanceKey, BackendInstanceEntry>>,
67-}
68- 
69-pub type BackendManager = ExternalBackendManager;
70- 
71-impl ExternalBackendManager {
72- pub fn new() -> Self {
73- Self::default()
74- }
75- 
76- pub async fn ensure_session_backend(
77- &self,
78- request: BackendEnsureSessionRequest,
79- ) -> Result<BackendLease, OperationBackendBuildError> {
80- let config = resolve_session_backend_config(request.config.clone())?;
81- let key = BackendInstanceKey::from_request(&request, &config)?;
82- let mut instances = self.instances.lock().await;
83- if let Some(entry) = instances.get(&key) {
84- return Ok(BackendLease {
85- backend: Arc::clone(&entry.backend),
86- instance: entry.instance.clone(),
87- });
88- }
89- 
90- let (backend, instance) = build_local_managed_backend(&request, &config).await?;
91- 
92- instances.insert(
93- key,
94- BackendInstanceEntry {
95- backend: Arc::clone(&backend),
96- instance: instance.clone(),
97- },
98- );
99- Ok(BackendLease { backend, instance })
100- }
101- 
102- pub async fn release_session(&self, session_id: &str) -> Result<(), OperationError> {
103- let removed = {
104- let mut instances = self.instances.lock().await;
105- let keys: Vec<_> = instances
106- .keys()
107- .filter(|key| key.session_id == session_id)
108- .cloned()
109- .collect();
110- keys.into_iter()
111- .filter_map(|key| instances.remove(&key))
112- .collect::<Vec<_>>()
113- };
114- 
115- for instance in removed {
116- delete_backend_instance(instance).await?;
117- }
118- Ok(())
119- }
120- 
121- pub async fn shutdown_all(&self) -> Result<(), OperationError> {
122- let removed = {
123- let mut instances = self.instances.lock().await;
124- instances
125- .drain()
126- .map(|(_, instance)| instance)
127- .collect::<Vec<_>>()
128- };
129- 
130- for instance in removed {
131- delete_backend_instance(instance).await?;
132- }
133- Ok(())
134- }
135-}
136- 
137-impl BackendInstanceKey {
138- fn from_request(
139- request: &BackendEnsureSessionRequest,
140- config: &GatewayBackendConfig,
141- ) -> Result<Self, OperationBackendBuildError> {
142- let workspace_root = workspace_root_string(&request.workspace_root)?;
143- Ok(Self {
144- session_id: request.session_id.clone(),
145- workspace_root,
146- config_hash: hash_config(&config),
147- })
148- }
149-}
150- 
151-fn workspace_root_string(path: &PathBuf) -> Result<String, OperationBackendBuildError> {
152- path.to_str()
153- .map(str::to_string)
154- .ok_or_else(|| OperationBackendBuildError::InvalidConfig {
155- message: format!("workspace_root is not valid utf-8: {}", path.display()),
156- })
157-}
158- 
159-fn hash_config(config: &GatewayBackendConfig) -> u64 {
160- let mut hasher = std::collections::hash_map::DefaultHasher::new();
161- config.kind.hash(&mut hasher);
162- canonical_json(&config.options).hash(&mut hasher);
163- hasher.finish()
164-}
165- 
166-fn canonical_json(value: &Value) -> String {
167- fn normalize(value: &Value) -> Value {
168- match value {
169- Value::Object(map) => Value::Object(
170- map.iter()
171- .map(|(key, value)| (key.clone(), normalize(value)))
172- .collect::<BTreeMap<_, _>>()
173- .into_iter()
174- .collect(),
175- ),
176- Value::Array(values) => Value::Array(values.iter().map(normalize).collect()),
177- other => other.clone(),
178- }
179- }
180- 
181- serde_json::to_string(&normalize(value)).unwrap_or_else(|_| "null".to_string())
182-}
183- 
184-fn resolve_session_backend_config(
185- config: Option<GatewayBackendConfig>,
186-) -> Result<GatewayBackendConfig, OperationBackendBuildError> {
187- match config {
188- Some(config) if config.kind == "local" => Ok(config),
189- Some(config) => Err(OperationBackendBuildError::UnsupportedBackend { kind: config.kind }),
190- None => Ok(GatewayBackendConfig::new(
191- "local",
192- default_local_provider_options(),
193- )),
194- }
195-}
196- 
197-fn default_local_provider_options() -> Value {
198- let mut options = Map::new();
199- if let Some(home_dir) = std::env::var_os("HOME") {
200- options.insert(
201- "home_dir".to_string(),
202- Value::String(home_dir.to_string_lossy().to_string()),
203- );
204- }
205- options.insert(
206- "temp_root".to_string(),
207- Value::String(std::env::temp_dir().to_string_lossy().to_string()),
208- );
209- Value::Object(options)
210-}
211- 
212-fn backend_path_from_workspace(path: &PathBuf) -> Result<BackendPath, OperationBackendBuildError> {
213- workspace_root_string(path).map(BackendPath)
214-}
215- 
216-async fn build_local_managed_backend(
217- request: &BackendEnsureSessionRequest,
218- config: &GatewayBackendConfig,
219-) -> Result<(Arc<dyn OperationBackend>, BackendInstance), OperationBackendBuildError> {
220- let provider = operation_backend::local_backend_provider();
221- let workspace_root = backend_path_from_workspace(&request.workspace_root)?;
222- let lifecycle = provider.lifecycle();
223- let instance = lifecycle
224- .create_sandbox(BackendCreateRequest {
225- session_id: request.session_id.clone(),
226- conversation_id: None,
227- workspace_root,
228- provider_options: config.options.clone(),
229- resource_limits: BackendResourceLimits::default(),
230- metadata: Value::Null,
231- })
232- .await
233- .map_err(control_error_to_build_error)?;
234- let backend = provider
235- .attach(instance.clone())
236- .await
237- .map_err(control_error_to_build_error)?;
238- Ok((backend, instance))
239-}
240- 
241-async fn delete_backend_instance(instance: BackendInstanceEntry) -> Result<(), OperationError> {
242- let provider = operation_backend::local_backend_provider();
243- provider
244- .delete(BackendDeleteRequest {
245- backend_id: instance.instance.backend_id,
246- instance_id: Some(instance.instance.instance_id),
247- snapshot_id: instance
248- .instance
249- .snapshot
250- .map(|snapshot| snapshot.snapshot_id),
251- force: false,
252- reason: BackendLifecycleReason::SessionClose,
253- metadata: instance.instance.metadata,
254- })
255- .await
256- .map_err(control_error_to_operation_error)?;
257- Ok(())
258-}
259- 
260-fn control_error_to_build_error(
261- error: agent_contracts::backend::BackendControlError,
262-) -> OperationBackendBuildError {
263- OperationBackendBuildError::BuildFailed {
264- message: error.to_string(),
265- }
266-}
267- 
268-fn control_error_to_operation_error(
269- error: agent_contracts::backend::BackendControlError,
270-) -> OperationError {
271- OperationError::Transport {
272- message: error.to_string(),
273- }
274-}
275- 
276-#[cfg(test)]
277-mod tests {
278- use super::*;
279- use agent_contracts::backend::{
280- BackendLifecycle, BackendLifecycleState, BackendPauseMode, BackendPauseRequest,
281- };
282- use serde_json::json;
283- use tempfile::TempDir;
284- 
285- fn local_request(
286- session_id: &str,
287- workspace_root: PathBuf,
288- options: Value,
289- ) -> BackendEnsureSessionRequest {
290- BackendEnsureSessionRequest {
291- config: Some(GatewayBackendConfig::new("local", options)),
292- workspace_root,
293- session_id: session_id.to_string(),
294- }
295- }
296- 
297- fn temp_options(workspace: &TempDir) -> Value {
298- json!({"temp_root": workspace.path().to_string_lossy().to_string()})
299- }
300- 
301- fn local_key(request: &BackendEnsureSessionRequest) -> BackendInstanceKey {
302- let config = resolve_session_backend_config(request.config.clone()).expect("config");
303- BackendInstanceKey::from_request(request, &config).expect("key")
304- }
305- 
306- #[test]
307- fn backend_key_is_stable_for_reordered_config() {
308- let left = local_key(&local_request(
309- "s1",
310- PathBuf::from("/workspace"),
311- json!({"home_dir": "/home/user", "temp_root": "/tmp/xiaoo"}),
312- ));
313- let right = local_key(&local_request(
314- "s1",
315- PathBuf::from("/workspace"),
316- json!({"temp_root": "/tmp/xiaoo", "home_dir": "/home/user"}),
317- ));
318- 
319- assert_eq!(left, right);
320- }
321- 
322- #[test]
323- fn backend_key_splits_by_session_and_config() {
324- let base = local_key(&local_request(
325- "s1",
326- PathBuf::from("/workspace"),
327- json!({"temp_root": "/tmp/xiaoo"}),
328- ));
329- let other_session = local_key(&local_request(
330- "s2",
331- PathBuf::from("/workspace"),
332- json!({"temp_root": "/tmp/xiaoo"}),
333- ));
334- let other_config = local_key(&local_request(
335- "s1",
336- PathBuf::from("/workspace"),
337- json!({"temp_root": "/tmp/other"}),
338- ));
339- 
340- assert_ne!(base, other_session);
341- assert_ne!(base, other_config);
342- }
343- 
344- #[test]
345- fn non_local_backend_is_unsupported() {
346- let config =
347- resolve_session_backend_config(Some(GatewayBackendConfig::new("docker", Value::Null)));
348- 
349- assert!(matches!(
350- config,
351- Err(OperationBackendBuildError::UnsupportedBackend { kind }) if kind == "docker"
352- ));
353- }
354- 
355- #[tokio::test]
356- async fn manager_reuses_backend_for_same_session_and_config() {
357- let workspace = TempDir::new().expect("workspace");
358- let manager = ExternalBackendManager::new();
359- let request = local_request(
360- "s1",
361- workspace.path().to_path_buf(),
362- temp_options(&workspace),
363- );
364- 
365- let first = manager
366- .ensure_session_backend(request.clone())
367- .await
368- .expect("first lease");
369- let second = manager
370- .ensure_session_backend(request)
371- .await
372- .expect("second lease");
373- 
374- let first_backend = first.backend();
375- let second_backend = second.backend();
376- assert_eq!(first.instance(), second.instance());
377- assert!(Arc::ptr_eq(&first_backend, &second_backend));
378- }
379- 
380- #[tokio::test]
381- async fn manager_splits_backend_by_session_and_config_hash() {
382- let workspace = TempDir::new().expect("workspace");
383- let manager = ExternalBackendManager::new();
384- let root = workspace.path().to_path_buf();
385- let base = local_request("s1", root.clone(), temp_options(&workspace));
386- let other_session = local_request("s2", root.clone(), temp_options(&workspace));
387- let other_config = local_request(
388- "s1",
389- root,
390- json!({
391- "temp_root": workspace.path().to_string_lossy().to_string(),
392- "default_shell": "/bin/sh"
393- }),
394- );
395- 
396- let base_backend = manager
397- .ensure_session_backend(base)
398- .await
399- .expect("base lease")
400- .backend();
401- let other_session_backend = manager
402- .ensure_session_backend(other_session)
403- .await
404- .expect("other session lease")
405- .backend();
406- let other_config_backend = manager
407- .ensure_session_backend(other_config)
408- .await
409- .expect("other config lease")
410- .backend();
411- 
412- assert!(!Arc::ptr_eq(&base_backend, &other_session_backend));
413- assert!(!Arc::ptr_eq(&base_backend, &other_config_backend));
414- }
415- 
416- #[tokio::test]
417- async fn release_session_deletes_local_backend_cache() {
418- let workspace = TempDir::new().expect("workspace");
419- let manager = ExternalBackendManager::new();
420- let request = local_request(
421- "s1",
422- workspace.path().to_path_buf(),
423- temp_options(&workspace),
424- );
425- 
426- let first_backend = manager
427- .ensure_session_backend(request.clone())
428- .await
429- .expect("first lease")
430- .backend();
431- manager.release_session("s1").await.expect("release");
432- let second_backend = manager
433- .ensure_session_backend(request)
434- .await
435- .expect("second lease")
436- .backend();
437- 
438- assert!(!Arc::ptr_eq(&first_backend, &second_backend));
439- }
440- 
441- #[tokio::test]
442- async fn local_provider_delete_succeeds_and_pause_is_unsupported() {
443- let workspace = TempDir::new().expect("workspace");
444- let provider = operation_backend::local_backend_provider();
445- let lease_request = local_request(
446- "s1",
447- workspace.path().to_path_buf(),
448- temp_options(&workspace),
449- );
450- let (_backend, instance) = build_local_managed_backend(
451- &lease_request,
452- &resolve_session_backend_config(lease_request.config.clone()).expect("config"),
453- )
454- .await
455- .expect("local backend");
456- 
457- let pause = provider
458- .pause(BackendPauseRequest {
459- backend_id: instance.backend_id.clone(),
460- instance_id: instance.instance_id.clone(),
461- mode: BackendPauseMode::BestEffort,
462- reason: BackendLifecycleReason::UserRequested,
463- metadata: Value::Null,
464- })
465- .await;
466- assert!(matches!(
467- pause,
468- Err(agent_contracts::backend::BackendControlError::UnsupportedCapability { .. })
469- ));
470- 
471- let outcome = provider
472- .delete(BackendDeleteRequest {
473- backend_id: instance.backend_id.clone(),
474- instance_id: Some(instance.instance_id.clone()),
475- snapshot_id: None,
476- force: false,
477- reason: BackendLifecycleReason::SessionClose,
478- metadata: instance.metadata.clone(),
479- })
480- .await
481- .expect("delete");
482- 
483- assert_eq!(outcome.backend_id, instance.backend_id);
484- assert_eq!(outcome.instance_id, Some(instance.instance_id));
485- assert_eq!(outcome.state, BackendLifecycleState::Deleted);
486- }
487-}
Dapps/xiaoo-app/src/gateway/session_keys.rs+0-8
@@ -1,8 +0,0 @@
1-pub fn channel_session_id(
2- channel: &str,
3- channel_instance_id: Option<&str>,
4- conversation_id: &str,
5-) -> String {
6- let scope = channel_instance_id.unwrap_or(channel);
7- format!("{scope}:{conversation_id}")
8-}
Dapps/xiaoo-app/src/gateway/turn_result.rs+0-12
@@ -1,12 +0,0 @@
1-use agent_types::ChatMessage;
2- 
3-#[derive(Debug, Clone)]
4-pub struct AppTurnResult {
5- pub raw_reply: String,
6- pub visible_reply: String,
7- pub messages: Vec<ChatMessage>,
8- pub prompt_tokens: u64,
9- pub completion_tokens: u64,
10- pub total_tokens: u64,
11- pub estimated_input_tokens: u64,
12-}
Dapps/xiaoo-app/src/httpserver/mod.rs+0-22
@@ -1,22 +0,0 @@
1-pub mod channel_ingress;
2-pub mod channel_runtime;
3-pub mod rate_limit;
4-pub mod router;
5-pub mod service;
6-pub mod sse_sink;
7- 
8-pub use channel_ingress::{
9- build_channel_turn_request, build_gateway_channel_message, GatewayChannelIngressError,
10- GatewayChannelMention, GatewayChannelMessage,
11-};
12-pub use channel_runtime::{ChannelMessageProcessingError, ChannelRuntimeProcessor};
13-pub use router::{
14- create_router, create_router_with_auth,
15- create_router_with_channel_runtimes_control_plane_and_timeout_and_auth,
16- create_router_with_control_plane_and_auth, create_router_with_feishu_and_timeout,
17- create_router_with_feishu_and_timeout_and_auth,
18- create_router_with_feishu_control_plane_and_timeout_and_auth, GatewayAppState,
19- GatewayErrorResponse, GatewayHealthResponse, HttpBearerAuthConfig, TestChatRequest,
20- TestChatResponse, TestChatTurnRequest,
21-};
22-pub use service::{GatewayService, GatewayServiceError, GatewayTurnResponse};
Dapps/xiaoo-app/src/lib.rs+0-6
@@ -1,6 +0,0 @@
1-pub mod builtin_agent_roles;
2-pub mod channels;
3-pub mod cli;
4-pub mod gateway;
5-pub mod httpserver;
6-pub mod llm_secrets;
Dapps/xiaoo-app/src/secrets.rs+0-275
@@ -1,275 +0,0 @@
1-//! Secrets Manager
2-//!
3-//! 统一管理 API Key 和 Verification Token
4-//! 本地加密存储到 llm_secrets.json
5-//! - use_sdf=true: 调用 SDF 国密接口加密/解密
6-//! - use_sdf=false: 使用 WhiteBox 密钥 + AES-GCM 加密/解密
7-//!
8-//! 优先级:
9-//! - API Key: 环境变量 > 本地存储
10-//! - Verification Token: config.toml > 本地存储
11- 
12-use anyhow::{bail, Context, Result};
13-use serde::{Deserialize, Serialize};
14-use std::collections::BTreeMap;
15-use std::fs;
16-use std::path::{Path, PathBuf};
17-use std::sync::Arc;
18-use tokio::sync::RwLock;
19-use vault::{KeyMaterial, KeyProvider, KeyProviderConfig, WhiteBoxKeyProvider, TeeKeyProvider, TeeType, HsmKeyProvider, encrypt_secret, decrypt_secret};
20- 
21-const LLM_SECRETS_FILE: &str = "llm_secrets.json";
22- 
23-#[derive(Clone, Debug, Default, Serialize, Deserialize)]
24-pub struct SecretsStore {
25- #[serde(default)]
26- pub api_keys: BTreeMap<String, String>,
27- #[serde(default)]
28- pub verification_token: Option<String>,
29-}
30- 
31-pub struct KeyProviderFactory;
32- 
33-impl KeyProviderFactory {
34- pub fn create(config: &KeyProviderConfig) -> Result<Arc<dyn KeyProvider>> {
35- match config {
36- KeyProviderConfig::WhiteBox { name } => {
37- Ok(Arc::new(WhiteBoxKeyProvider::new(name)))
38- }
39- KeyProviderConfig::Tee { name, tee_type, slot } => {
40- let tee_type = TeeType::from(tee_type.as_str());
41- let provider = TeeKeyProvider::new(name, tee_type);
42- let provider = if let Some(slot) = slot {
43- provider.with_slot(slot)
44- } else {
45- provider
46- };
47- Ok(Arc::new(provider))
48- }
49- KeyProviderConfig::Hsm { name, library_path, slot_id, key_label } => {
50- Ok(Arc::new(HsmKeyProvider::new(
51- name,
52- library_path,
53- slot_id,
54- key_label,
55- )))
56- }
57- }
58- }
59-}
60- 
61-pub struct SecretsManager {
62- config_path: PathBuf,
63- store: Arc<RwLock<SecretsStore>>,
64- master_key: Option<[u8; 32]>,
65- key_provider: Arc<dyn KeyProvider>,
66- use_sdf: bool,
67-}
68- 
69-impl SecretsManager {
70- pub async fn new(
71- config_path: PathBuf,
72- key_provider_config: KeyProviderConfig,
73- use_sdf: bool,
74- ) -> Result<Self> {
75- let key_provider = KeyProviderFactory::create(&key_provider_config)?;
76- let master_key = key_provider.provide().await
77- .map(|km| km.key_bytes)
78- .ok();
79- 
80- let store = Arc::new(RwLock::new(SecretsStore::default()));
81- let mut manager = Self {
82- config_path,
83- store,
84- master_key,
85- key_provider,
86- use_sdf,
87- };
88- 
89- manager.load_secrets().await?;
90- Ok(manager)
91- }
92- 
93- fn llm_secrets_path(&self) -> PathBuf {
94- self.config_path
95- .parent()
96- .unwrap_or_else(|| Path::new("."))
97- .join(LLM_SECRETS_FILE)
98- }
99- 
100- pub async fn save_llm_secret(&self, env_name: &str, secret: &str) -> Result<()> {
101- {
102- let mut store = self.store.write().await;
103- store.api_keys.insert(env_name.to_string(), secret.to_string());
104- }
105- self.persist_secrets().await
106- }
107- 
108- pub async fn save_verification_token(&self, token: &str) -> Result<()> {
109- {
110- let mut store = self.store.write().await;
111- store.verification_token = Some(token.to_string());
112- }
113- self.persist_secrets().await
114- }
115- 
116- async fn persist_secrets(&self) -> Result<()> {
117- let store = self.store.read().await;
118- let json = serde_json::to_vec_pretty(&*store)
119- .context("failed to serialize secrets")?;
120- 
121- let secrets_path = self.llm_secrets_path();
122- if let Some(parent) = secrets_path.parent() {
123- fs::create_dir_all(parent)
124- .with_context(|| format!("failed to create secrets directory {}", parent.display()))?;
125- }
126- 
127- let encrypted = self.encrypt_data(&json)?;
128- fs::write(&secrets_path, encrypted)
129- .with_context(|| format!("failed to write encrypted secrets file {}", secrets_path.display()))?;
130- 
131- Ok(())
132- }
133- 
134- fn encrypt_data(&self, plaintext: &[u8]) -> Result<Vec<u8>> {
135- if self.use_sdf {
136- encrypt_secret(plaintext).map_err(|e| anyhow::anyhow!("SDF encrypt failed: {}", e))
137- } else {
138- self.encrypt_data_aes(plaintext)
139- }
140- }
141- 
142- fn encrypt_data_aes(&self, plaintext: &[u8]) -> Result<Vec<u8>> {
143- use aes_gcm::{
144- aead::{Aead, KeyInit},
145- Aes256Gcm, Nonce,
146- };
147- use rand::RngCore;
148- 
149- let key = self.master_key
150- .context("master key not loaded")?;
151- 
152- let cipher = Aes256Gcm::new_from_slice(&key)
153- .map_err(|_| anyhow::anyhow!("invalid master key"))?;
154- 
155- let mut nonce_bytes = [0u8; 12];
156- rand::thread_rng().fill_bytes(&mut nonce_bytes);
157- let nonce = Nonce::from_slice(&nonce_bytes);
158- 
159- let ciphertext = cipher
160- .encrypt(nonce, plaintext)
161- .map_err(|_| anyhow::anyhow!("encryption failed"))?;
162- 
163- let mut result = vec![1u8];
164- result.extend(&nonce_bytes);
165- result.extend(ciphertext);
166- 
167- Ok(result)
168- }
169- 
170- fn decrypt_data(&self, encrypted: &[u8]) -> Result<Vec<u8>> {
171- if self.use_sdf {
172- decrypt_secret(encrypted).map_err(|e| anyhow::anyhow!("SDF decrypt failed: {}", e))
173- } else {
174- self.decrypt_data_aes(encrypted)
175- }
176- }
177- 
178- fn decrypt_data_aes(&self, encrypted: &[u8]) -> Result<Vec<u8>> {
179- use aes_gcm::{
180- aead::{Aead, KeyInit},
181- Aes256Gcm, Nonce,
182- };
183- 
184- if encrypted.len() < 13 {
185- bail!("encrypted data too short");
186- }
187- 
188- let version = encrypted[0];
189- if version != 1 {
190- bail!("unsupported encryption version: {}", version);
191- }
192- 
193- let key = self.master_key
194- .context("master key not loaded")?;
195- 
196- let cipher = Aes256Gcm::new_from_slice(&key)
197- .map_err(|_| anyhow::anyhow!("invalid master key"))?;
198- 
199- let nonce = Nonce::from_slice(&encrypted[1..13]);
200- let ciphertext = &encrypted[13..];
201- 
202- cipher
203- .decrypt(nonce, ciphertext)
204- .map_err(|_| anyhow::anyhow!("decryption failed"))
205- }
206- 
207- pub async fn load_secrets(&self) -> Result<()> {
208- let secrets_path = self.llm_secrets_path();
209- let store = self.load_local_encrypted(&secrets_path).await?;
210- *self.store.write().await = store;
211- Ok(())
212- }
213- 
214- async fn load_local_encrypted(&self, path: &Path) -> Result<SecretsStore> {
215- if !path.exists() {
216- return Ok(SecretsStore::default());
217- }
218- let encrypted = fs::read(path)
219- .with_context(|| format!("failed to read encrypted secrets file {}", path.display()))?;
220- let decrypted = self.decrypt_data(&encrypted)?;
221- serde_json::from_slice(&decrypted)
222- .with_context(|| format!("failed to parse secrets file {}", path.display()))
223- }
224- 
225- pub async fn resolve_api_key(&self, api_key_env: Option<&str>) -> Result<String> {
226- if let Some(env_name) = api_key_env {
227- if let Ok(value) = std::env::var(env_name) {
228- if !value.trim().is_empty() {
229- tracing::debug!("using API key from environment variable: {}", env_name);
230- return Ok(value);
231- }
232- }
233- }
234- 
235- let store = self.store.read().await;
236- if let Some(env_name) = api_key_env {
237- if let Some(key) = store.api_keys.get(env_name) {
238- tracing::debug!("using API key from local storage: {}", env_name);
239- return Ok(key.clone());
240- }
241- }
242- 
243- let env_name_display = api_key_env.unwrap_or("<not configured>");
244- bail!(
245- "API key not found: environment variable '{}' is not set, and no key found in local storage. \
246- Please either:\n 1. Set the environment variable '{}', or\n 2. Configure the API key in TUI",
247- env_name_display,
248- env_name_display
249- );
250- }
251- 
252- pub async fn resolve_verification_token(
253- &self,
254- config_token: Option<&str>,
255- ) -> Result<String> {
256- if let Some(token) = config_token {
257- let trimmed = token.trim();
258- if !trimmed.is_empty() {
259- tracing::debug!("using verification_token from config.toml");
260- return Ok(trimmed.to_string());
261- }
262- }
263- 
264- let store = self.store.read().await;
265- if let Some(token) = &store.verification_token {
266- tracing::debug!("using verification_token from local storage");
267- return Ok(token.clone());
268- }
269- 
270- bail!(
271- "verification_token not found: not configured in config.toml, and no token found in local storage. \
272- Please either:\n 1. Set 'verification_token' in [channels.feishu] section of config.toml, or\n 2. Store the token via TUI or CLI"
273- );
274- }
275-}
Mcrates/agent-contracts/docs/backend-lifecycle-contract.md+17-12
@@ -1,8 +1,8 @@
1# Backend Lifecycle Contract1# Backend Lifecycle Contract
2 2 
3-Status: design fixed. Shared lifecycle interfaces and the lifecycle state3+Status: partially implemented. Shared lifecycle interfaces and the lifecycle
4-machine are implemented in `src/backend/lifecycle.rs`; provider integrations are4+state machine live in `src/backend/lifecycle.rs`; the current manager-side
5-not implemented yet.5+runtime/backend control implementation lives in `apps/shared/src/backend`.
6 6 
7This document defines the unified lifecycle control contract for xiaoO7This document defines the unified lifecycle control contract for xiaoO
8operation backends. It intentionally lives next to `agent-contracts` because it8operation backends. It intentionally lives next to `agent-contracts` because it
@@ -12,8 +12,8 @@ runtime tools can use the backend's operation plane.
12## Goals12## Goals
13 13 
14- Make backend lifecycle management provider-neutral.14- Make backend lifecycle management provider-neutral.
15-- Move provider-specific actions such as Conch `create_sandbox` and15+- Move provider-specific actions such as Conch/E2B sandbox create and delete
16- `delete_sandbox` behind a shared lifecycle contract.16+ behind a shared backend lifecycle contract.
17- Keep lifecycle control separate from active operation execution.17- Keep lifecycle control separate from active operation execution.
18- Provide enough state, identity, capability, and error information for session18- Provide enough state, identity, capability, and error information for session
19 resume, pause, cleanup, and diagnostics.19 resume, pause, cleanup, and diagnostics.
@@ -81,13 +81,17 @@ truth after process crashes, network failures, or partial lifecycle operations.
81 81 
82## Naming82## Naming
83 83 
84-The public lifecycle method is named `create_sandbox` for now because the84+The low-level `agent-contracts` lifecycle trait still exposes the historical
85-current product language and Conch implementation are sandbox-oriented. The85+method name `create_sandbox`. At the manager-facing layer, the current xiaoO API
86-request and response types are intentionally backend-neutral:86+uses backend names such as `create_backend`, `checkpoint_backend`,
87-`BackendCreateRequest`, `BackendInstance`, `BackendSnapshot`.87+`checkout_backend`, `delete_backend`, `BackendInfo`, and
88+`BackendCheckpointRef`.
88 89 
89-Future cleanup can rename `create_sandbox` to `create` if providers that are not90+Treat `create_sandbox` here as a provider-lifecycle boundary name, not as the
90-sandbox-like become first-class.91+runtime control-plane vocabulary. Provider adapters may also use sandbox
92+terminology internally when the provider API itself is sandbox-shaped, such as
93+E2B snapshots or Conch sandbox handles. That provider-native vocabulary should
94+not leak into runtime checkpoint request/response shapes.
91 95 
92## Lifecycle State96## Lifecycle State
93 97 
@@ -484,6 +488,7 @@ pub struct BackendManagerRecord {
484| --- | --- | --- | --- | --- | --- |488| --- | --- | --- | --- | --- | --- |
485| `local` | Return an active local instance record | Return active local instance if workspace is valid | Return `UnsupportedCapability` | No-op successful delete | Check workspace path and report active/failed |489| `local` | Return an active local instance record | Return active local instance if workspace is valid | Return `UnsupportedCapability` | No-op successful delete | Check workspace path and report active/failed |
486| `conch` | Call `/api/sandbox/create`, then health check agent | Restore from `snapshot_id`, `sandbox_id`, or serialized handle | Create snapshot/freeze sandbox when supported | Call `/api/sandbox/delete` | Query sandbox status from Conch control plane |490| `conch` | Call `/api/sandbox/create`, then health check agent | Restore from `snapshot_id`, `sandbox_id`, or serialized handle | Create snapshot/freeze sandbox when supported | Call `/api/sandbox/delete` | Query sandbox status from Conch control plane |
491+| `e2b` | Create or connect an E2B sandbox-backed backend | Checkout from a provider snapshot id where supported | Create provider snapshot via the E2B snapshot API | Delete provider sandbox resources where supported | Query provider status where supported |
487| `docker` future | Create container | Start container or restore named container | Stop or commit container | Remove container | Inspect container |492| `docker` future | Create container | Start container or restore named container | Stop or commit container | Remove container | Inspect container |
488| `ssh` future | Establish session record | Reattach to host/session | Usually unsupported | No-op or close session lease | Probe host/session |493| `ssh` future | Establish session record | Reattach to host/session | Usually unsupported | No-op or close session lease | Probe host/session |
489 494 
@@ -553,7 +558,7 @@ These are intentionally left for implementation planning.
553 558 
554| Decision | Options |559| Decision | Options |
555| --- | --- |560| --- | --- |
556-| Method name | Keep `create_sandbox` now, or rename to provider-neutral `create` before implementation |561+| Method name | Keep `create_sandbox` at the low-level provider lifecycle boundary, or rename it to provider-neutral `create` in a later code cleanup |
557| Local pause | Fixed as `UnsupportedCapability` |562| Local pause | Fixed as `UnsupportedCapability` |
558| Snapshot storage | Provider-owned, manager-owned metadata, or mixed |563| Snapshot storage | Provider-owned, manager-owned metadata, or mixed |
559| Manager persistence | In-memory only, session store extension, or dedicated backend store |564| Manager persistence | In-memory only, session store extension, or dedicated backend store |
Mcrates/agent-contracts/src/backend/lifecycle.rs+1-0
@@ -333,6 +333,7 @@ pub struct BackendSnapshot {
333 333 
334#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]334#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
335pub struct BackendCreateRequest {335pub struct BackendCreateRequest {
336+ pub requested_backend_id: Option<BackendId>,
336 pub session_id: String,337 pub session_id: String,
337 pub conversation_id: Option<String>,338 pub conversation_id: Option<String>,
338 pub workspace_root: BackendPath,339 pub workspace_root: BackendPath,
Mcrates/core/src/agent_loop.rs+2-2
@@ -3380,10 +3380,10 @@ mod tests {
3380 },3380 },
3381 ],3381 ],
3382 usage: Usage {3382 usage: Usage {
3383+ cached_tokens: 0,
3383 prompt_tokens: 5,3384 prompt_tokens: 5,
3384 completion_tokens: 3,3385 completion_tokens: 3,
3385 total_tokens: 8,3386 total_tokens: 8,
3386- cached_tokens:0,
3387 },3387 },
3388 stop_reason: StopReason::ToolUse,3388 stop_reason: StopReason::ToolUse,
3389 },3389 },
@@ -3397,10 +3397,10 @@ mod tests {
3397 reasoning_content: None,3397 reasoning_content: None,
3398 tool_calls: Vec::new(),3398 tool_calls: Vec::new(),
3399 usage: Usage {3399 usage: Usage {
3400+ cached_tokens: 0,
3400 prompt_tokens: 5,3401 prompt_tokens: 5,
3401 completion_tokens: 1,3402 completion_tokens: 1,
3402 total_tokens: 6,3403 total_tokens: 6,
3403- cached_tokens:0,
3404 },3404 },
3405 stop_reason: StopReason::EndTurn,3405 stop_reason: StopReason::EndTurn,
3406 },3406 },
Mcrates/core/src/loop_state.rs+20-1
@@ -37,6 +37,10 @@ pub struct LoopStateSnapshot {
37 37 
38impl LoopState {38impl LoopState {
39 pub fn new(session_id: uuid::Uuid) -> Self {39 pub fn new(session_id: uuid::Uuid) -> Self {
40+ Self::new_with_cancel(session_id, CancellationToken::new())
41+ }
42+ 
43+ pub fn new_with_cancel(session_id: uuid::Uuid, cancel: CancellationToken) -> Self {
40 Self {44 Self {
41 session_id,45 session_id,
42 messages: Arc::new(RwLock::new(Vec::new())),46 messages: Arc::new(RwLock::new(Vec::new())),
@@ -44,7 +48,7 @@ impl LoopState {
44 token_usage: TokenUsage::default(),48 token_usage: TokenUsage::default(),
45 compression_meta: CompressionMeta::default(),49 compression_meta: CompressionMeta::default(),
46 kv_cache_map: KvCacheMap::default(),50 kv_cache_map: KvCacheMap::default(),
47- cancel: CancellationToken::new(),51+ cancel,
48 plan_nudged: false,52 plan_nudged: false,
49 last_failure_sig: None,53 last_failure_sig: None,
50 repeated_failure_count: 0,54 repeated_failure_count: 0,
@@ -90,3 +94,18 @@ impl LoopState {
90 Arc::clone(&self.messages)94 Arc::clone(&self.messages)
91 }95 }
92}96}
97+ 
98+#[cfg(test)]
99+mod tests {
100+ use super::*;
101+ 
102+ #[test]
103+ fn new_with_cancel_uses_provided_token() {
104+ let cancel = CancellationToken::new();
105+ let state = LoopState::new_with_cancel(uuid::Uuid::new_v4(), cancel.clone());
106+ 
107+ cancel.cancel();
108+ 
109+ assert!(state.cancel.is_cancelled());
110+ }
111+}
Mcrates/lsp/src/manager.rs+51-3
@@ -1,4 +1,4 @@
1-use std::collections::HashMap;1+use std::collections::{HashMap, HashSet};
2use std::path::{Path, PathBuf};2use std::path::{Path, PathBuf};
3use std::sync::Arc;3use std::sync::Arc;
4 4 
@@ -21,8 +21,15 @@ pub struct LspServerManager {
21 21 
22impl LspServerManager {22impl LspServerManager {
23 pub fn new(extra_configs: Vec<ServerConfig>, env: Arc<dyn LspEnv>) -> Self {23 pub fn new(extra_configs: Vec<ServerConfig>, env: Arc<dyn LspEnv>) -> Self {
24- let mut configs = extra_configs;24+ Self::new_with_disabled(extra_configs, Vec::new(), env)
25- configs.extend(builtin_servers());25+ }
26+ 
27+ pub fn new_with_disabled(
28+ extra_configs: Vec<ServerConfig>,
29+ disabled_servers: Vec<String>,
30+ env: Arc<dyn LspEnv>,
31+ ) -> Self {
32+ let configs = server_configs_with_disabled(extra_configs, &disabled_servers);
26 Self {33 Self {
27 configs,34 configs,
28 instances: HashMap::new(),35 instances: HashMap::new(),
@@ -326,8 +333,49 @@ impl LspServerManager {
326 }333 }
327}334}
328 335 
336+fn server_configs_with_disabled(
337+ extra_configs: Vec<ServerConfig>,
338+ disabled_servers: &[String],
339+) -> Vec<ServerConfig> {
340+ let disabled_servers: HashSet<&str> = disabled_servers.iter().map(String::as_str).collect();
341+ let mut configs = extra_configs;
342+ configs.extend(builtin_servers());
343+ configs.retain(|config| !disabled_servers.contains(config.id));
344+ configs
345+}
346+ 
329fn deduplicate_locations(mut locs: Vec<LspLocation>) -> Vec<LspLocation> {347fn deduplicate_locations(mut locs: Vec<LspLocation>) -> Vec<LspLocation> {
330 locs.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));348 locs.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
331 locs.dedup_by(|a, b| a.file == b.file && a.line == b.line && a.col == b.col);349 locs.dedup_by(|a, b| a.file == b.file && a.line == b.line && a.col == b.col);
332 locs350 locs
333}351}
352+ 
353+#[cfg(test)]
354+mod tests {
355+ use super::server_configs_with_disabled;
356+ use crate::servers::{AutoInstall, ServerConfig};
357+ 
358+ #[test]
359+ fn server_configs_filter_disabled_builtin_and_extra_servers() {
360+ let extra = ServerConfig {
361+ id: "custom-python",
362+ extensions: &["py"],
363+ command: "custom-python-lsp",
364+ args: &[],
365+ root_markers: &["pyproject.toml"],
366+ language_id: "python",
367+ initialization_options: None,
368+ auto_install: AutoInstall::None,
369+ };
370+ 
371+ let configs = server_configs_with_disabled(
372+ vec![extra],
373+ &["pyright".to_string(), "custom-python".to_string()],
374+ );
375+ let ids = configs.iter().map(|config| config.id).collect::<Vec<_>>();
376+ 
377+ assert!(!ids.contains(&"pyright"));
378+ assert!(!ids.contains(&"custom-python"));
379+ assert!(ids.contains(&"rust-analyzer"));
380+ }
381+}
Mcrates/lsp/src/registry.rs+14-2
@@ -17,13 +17,22 @@ use crate::service::LspService;
17/// while a conch backend would have its own set (once supported).17/// while a conch backend would have its own set (once supported).
18pub struct LspServiceRegistry {18pub struct LspServiceRegistry {
19 extra_configs: Vec<ServerConfig>,19 extra_configs: Vec<ServerConfig>,
20+ disabled_servers: Vec<String>,
20 services: Mutex<HashMap<String, Arc<dyn LspProvider>>>,21 services: Mutex<HashMap<String, Arc<dyn LspProvider>>>,
21}22}
22 23 
23impl LspServiceRegistry {24impl LspServiceRegistry {
24 pub fn new(extra_configs: Vec<ServerConfig>) -> Self {25 pub fn new(extra_configs: Vec<ServerConfig>) -> Self {
26+ Self::new_with_disabled(extra_configs, Vec::new())
27+ }
28+ 
29+ pub fn new_with_disabled(
30+ extra_configs: Vec<ServerConfig>,
31+ disabled_servers: Vec<String>,
32+ ) -> Self {
25 Self {33 Self {
26 extra_configs,34 extra_configs,
35+ disabled_servers,
27 services: Mutex::new(HashMap::new()),36 services: Mutex::new(HashMap::new()),
28 }37 }
29 }38 }
@@ -47,8 +56,11 @@ impl LspServiceRegistry {
47 }56 }
48 57 
49 let env = Arc::new(LocalLspEnv::new(Arc::clone(&backend)));58 let env = Arc::new(LocalLspEnv::new(Arc::clone(&backend)));
50- let svc =59+ let svc = Arc::new(LspService::new_with_disabled(
51- Arc::new(LspService::new(self.extra_configs.clone(), env)) as Arc<dyn LspProvider>;60+ self.extra_configs.clone(),
61+ self.disabled_servers.clone(),
62+ env,
63+ )) as Arc<dyn LspProvider>;
52 services.insert(backend_id, Arc::clone(&svc));64 services.insert(backend_id, Arc::clone(&svc));
53 Some(svc)65 Some(svc)
54 }66 }
Mcrates/lsp/src/service.rs+14-0
@@ -25,6 +25,20 @@ impl LspService {
25 }25 }
26 }26 }
27 27 
28+ pub fn new_with_disabled(
29+ extra_configs: Vec<ServerConfig>,
30+ disabled_servers: Vec<String>,
31+ env: Arc<dyn LspEnv>,
32+ ) -> Self {
33+ Self {
34+ manager: Arc::new(Mutex::new(LspServerManager::new_with_disabled(
35+ extra_configs,
36+ disabled_servers,
37+ env,
38+ ))),
39+ }
40+ }
41+ 
28 /// Use exactly the provided configs without adding built-in language servers.42 /// Use exactly the provided configs without adding built-in language servers.
29 pub fn new_custom(configs: Vec<ServerConfig>, env: Arc<dyn LspEnv>) -> Self {43 pub fn new_custom(configs: Vec<ServerConfig>, env: Arc<dyn LspEnv>) -> Self {
30 Self {44 Self {
Mcrates/operation_backend/src/backends/local/provider.rs+7-1
@@ -124,6 +124,7 @@ impl LocalBackendProvider {
124 }124 }
125 125 
126 fn active_instance(126 fn active_instance(
127+ backend_id: BackendId,
127 session_id: String,128 session_id: String,
128 workspace_root: agent_contracts::backend::BackendPath,129 workspace_root: agent_contracts::backend::BackendPath,
129 provider_options: Value,130 provider_options: Value,
@@ -133,7 +134,7 @@ impl LocalBackendProvider {
133 updated_at_ms: u64,134 updated_at_ms: u64,
134 ) -> BackendInstance {135 ) -> BackendInstance {
135 BackendInstance {136 BackendInstance {
136- backend_id: Self::backend_id(session_id.as_str()),137+ backend_id,
137 provider: Self::provider_kind(),138 provider: Self::provider_kind(),
138 instance_id: Self::instance_id(session_id.as_str()),139 instance_id: Self::instance_id(session_id.as_str()),
139 session_id,140 session_id,
@@ -175,6 +176,9 @@ impl BackendLifecycle for LocalBackendProvider {
175 debug_assert_eq!(state, BackendLifecycleState::Active);176 debug_assert_eq!(state, BackendLifecycleState::Active);
176 let now = current_time_ms();177 let now = current_time_ms();
177 Ok(Self::active_instance(178 Ok(Self::active_instance(
179+ request
180+ .requested_backend_id
181+ .unwrap_or_else(|| Self::backend_id(request.session_id.as_str())),
178 request.session_id,182 request.session_id,
179 request.workspace_root,183 request.workspace_root,
180 request.provider_options,184 request.provider_options,
@@ -203,6 +207,7 @@ impl BackendLifecycle for LocalBackendProvider {
203 debug_assert_eq!(state, BackendLifecycleState::Active);207 debug_assert_eq!(state, BackendLifecycleState::Active);
204 let now = current_time_ms();208 let now = current_time_ms();
205 Ok(Self::active_instance(209 Ok(Self::active_instance(
210+ Self::backend_id(request.session_id.as_str()),
206 request.session_id,211 request.session_id,
207 request.workspace_root,212 request.workspace_root,
208 request.provider_options,213 request.provider_options,
@@ -401,6 +406,7 @@ mod tests {
401 406 
402 let instance = runtime407 let instance = runtime
403 .block_on(provider.create_sandbox(BackendCreateRequest {408 .block_on(provider.create_sandbox(BackendCreateRequest {
409+ requested_backend_id: None,
404 session_id: "session".to_string(),410 session_id: "session".to_string(),
405 conversation_id: None,411 conversation_id: None,
406 workspace_root,412 workspace_root,
Mcrates/prompt/src/compose.rs+1-1
@@ -6,7 +6,7 @@ pub struct ChannelPromptSections<'a> {
6 pub group_session_context: Option<&'a str>,6 pub group_session_context: Option<&'a str>,
7}7}
8 8 
9-// Keep these markers in sync with apps/xiaoo-app/src/gateway/workspace_prompt.rs.9+// Keep these markers in sync with apps/shared/src/gateway/workspace_prompt.rs.
10const WORKSPACE_PROMPT_MARKER_BEGIN: &str = "<xiaoo_workspace_prompt>";10const WORKSPACE_PROMPT_MARKER_BEGIN: &str = "<xiaoo_workspace_prompt>";
11const WORKSPACE_PROMPT_MARKER_END: &str = "</xiaoo_workspace_prompt>";11const WORKSPACE_PROMPT_MARKER_END: &str = "</xiaoo_workspace_prompt>";
12 12 
Mdocs/cli_config.md+13-13
@@ -1,6 +1,6 @@
1# CLI Configuration Guide1# CLI Configuration Guide
2 2 
3-> **Note**: This document focuses on CLI (`xiaoo run`) configuration options and usage.3+> **Note**: This document focuses on CLI (`xiaoo --cli run`) configuration options and usage.
4>4>
5> For **common configuration items** (llm, subagent, skills, compact, trace, hooker, etc.), please refer to [Configuration File Guide](./config_file_guide.md).5> For **common configuration items** (llm, subagent, skills, compact, trace, hooker, etc.), please refer to [Configuration File Guide](./config_file_guide.md).
6 6 
@@ -78,16 +78,16 @@ default = "audit_agent"
78 78 
79```bash79```bash
80# Single execution80# Single execution
81-xiaoo run -p "Count the characters in hello world"81+xiaoo --cli run -p "Count the characters in hello world"
82 82 
83# Use specific configuration file83# Use specific configuration file
84-xiaoo run --config /path/to/config.toml -p "Your prompt"84+xiaoo --cli run --config /path/to/config.toml -p "Your prompt"
85 85 
86# Show debug information86# Show debug information
87-xiaoo run --debug -p "Your prompt"87+xiaoo --cli run --debug -p "Your prompt"
88 88 
89# Disable tool execution89# Disable tool execution
90-xiaoo run --no-tools -p "Just answer this question"90+xiaoo --cli run --no-tools -p "Just answer this question"
91```91```
92 92 
93### Parameter Description93### Parameter Description
@@ -133,10 +133,10 @@ grep = true
133 133 
134```bash134```bash
135# Code review task (main agent will automatically delegate to code_reviewer subagent)135# Code review task (main agent will automatically delegate to code_reviewer subagent)
136-xiaoo run -p "Review my authentication module for security issues"136+xiaoo --cli run -p "Review my authentication module for security issues"
137 137 
138# Test writing task (main agent will automatically delegate to test_writer subagent)138# Test writing task (main agent will automatically delegate to test_writer subagent)
139-xiaoo run -p "Write comprehensive tests for user registration API"139+xiaoo --cli run -p "Write comprehensive tests for user registration API"
140```140```
141 141 
142### How It Works142### How It Works
@@ -172,7 +172,7 @@ When CLI has subagent configured:
172**A**: ❌ No. `[agent]` configuration only takes effect in TUI and Daemon. CLI does not support multi-role switching.172**A**: ❌ No. `[agent]` configuration only takes effect in TUI and Daemon. CLI does not support multi-role switching.
173 173 
174For multi-role functionality, use:174For multi-role functionality, use:
175-- **TUI**: `xiaoo-tui` + Tab key switching175+- **TUI**: `xiaoo` + Tab key switching
176- **Daemon**: HTTP API + agent role configuration176- **Daemon**: HTTP API + agent role configuration
177 177 
178### Q: Will CLI-configured subagents take effect?178### Q: Will CLI-configured subagents take effect?
@@ -183,7 +183,7 @@ For multi-role functionality, use:
183 183 
184**A**: Use the `--debug` parameter:184**A**: Use the `--debug` parameter:
185```bash185```bash
186-xiaoo run --debug -p "test"186+xiaoo --cli run --debug -p "test"
187```187```
188Output will show:188Output will show:
189- Configuration file path189- Configuration file path
@@ -248,16 +248,16 @@ grep = true
248 248 
249```bash249```bash
250# Quick code review250# Quick code review
251-xiaoo run -p "Review src/auth.rs for security issues"251+xiaoo --cli run -p "Review src/auth.rs for security issues"
252 252 
253# Quick test generation253# Quick test generation
254-xiaoo run -p "Generate unit tests for user.rs"254+xiaoo --cli run -p "Generate unit tests for user.rs"
255 255 
256# Simple Q&A (disable tools)256# Simple Q&A (disable tools)
257-xiaoo run --no-tools -p "Explain the difference between TCP and UDP"257+xiaoo --cli run --no-tools -p "Explain the difference between TCP and UDP"
258 258 
259# Debug mode to view execution process259# Debug mode to view execution process
260-xiaoo run --debug -p "List all Python files in the project"260+xiaoo --cli run --debug -p "List all Python files in the project"
261```261```
262 262 
263---263---
Mdocs/config_file_guide.md+8-5
@@ -2,7 +2,7 @@
2 2 
3Configuration file location: `~/.config/xiaoo/config.toml`3Configuration file location: `~/.config/xiaoo/config.toml`
4 4 
5-This document focuses on **common configuration items** applicable to CLI, TUI, and Daemon modes.5+This document focuses on shared configuration items plus local client runtime settings.
6 6 
7> **Mode-specific Configuration**:7> **Mode-specific Configuration**:
8> - CLI: [cli_config.md](./cli_config.md)8> - CLI: [cli_config.md](./cli_config.md)
@@ -13,7 +13,7 @@ This document focuses on **common configuration items** applicable to CLI, TUI,
13 13 
14## Common Configuration Items Overview14## Common Configuration Items Overview
15 15 
16-Configuration items supported by all modes:16+Configuration items covered in this guide:
17 17 
18| Configuration | Description | Details |18| Configuration | Description | Details |
19|--------|------|----------|19|--------|------|----------|
@@ -23,7 +23,7 @@ Configuration items supported by all modes:
23| `[compact]` | Context compression strategy | [View Details](#compact---context-compression-strategy) |23| `[compact]` | Context compression strategy | [View Details](#compact---context-compression-strategy) |
24| `[trace]` | Tracing/Observability | [View Details](#trace---tracingobservability) |24| `[trace]` | Tracing/Observability | [View Details](#trace---tracingobservability) |
25| `[hooker]` | Hooker configuration | [View Details](#hooker---hooker-configuration) |25| `[hooker]` | Hooker configuration | [View Details](#hooker---hooker-configuration) |
26-| `[operation_backend]` | Operation backend configuration | [View Details](#operation_backend---operation-backend-configuration) |26+| `[operation_backend]` | CLI/TUI operation backend configuration | [View Details](#operation_backend---operation-backend-configuration) |
27 27 
28---28---
29 29 
@@ -252,11 +252,14 @@ For detailed hooker configuration and plugin instructions, please refer to [plug
252 252 
253## [operation_backend] - Operation Backend Configuration253## [operation_backend] - Operation Backend Configuration
254 254 
255-**Applicable to**: CLI ✅ | TUI ✅ | Daemon 255+**Applicable to**: CLI ✅ | TUI ✅ | Daemon
256+ 
257+Daemon mode reads operation backend configuration from
258+`[server.operation_backend]`; see [Daemon Configuration](./daemon_config.md).
256 259 
257```toml260```toml
258[operation_backend]261[operation_backend]
259-kind = "local" # Operation backend kind: local, conch262+kind = "local" # Operation backend kind for local clients
260options = { ... } # Backend-specific options263options = { ... } # Backend-specific options
261```264```
262 265 
Mdocs/daemon_config.md+327-107
@@ -112,6 +112,41 @@ data_dir = "~/.xiaoo" # Optional, root directory for data storage
112 112 
113---113---
114 114 
115+### [server.operation_backend] - Daemon Operation Backend
116+ 
117+The daemon reads operation backend configuration only from the `server`
118+namespace. Top-level `[operation_backend]` is reserved for CLI/TUI-side
119+configuration and is ignored by the daemon.
120+ 
121+```toml
122+[server.operation_backend]
123+kind = "e2b"
124+ 
125+[server.operation_backend.options]
126+api_key_env = "E2B_API_KEY" # Or api_key = "..."
127+template_id = "base"
128+timeout_secs = 3600
129+secure = true
130+workspace_root = "/home/user/workspace"
131+home_dir = "/home/user"
132+temp_root = "/tmp"
133+default_shell = "/bin/sh"
134+```
135+ 
136+Use the local backend in daemon mode by setting `kind = "local"` under the same
137+`[server.operation_backend]` namespace.
138+ 
139+```toml
140+[server.operation_backend]
141+kind = "local"
142+ 
143+[server.operation_backend.options.isolation]
144+kind = "linux_bubblewrap"
145+allow_network = false
146+```
147+ 
148+---
149+ 
115> **Note**: Common configuration items (llm, subagent, trace, compact, etc.) are shown in the "Complete Daemon Configuration Example" below. For detailed descriptions, please refer to [Configuration File Guide](./config_file_guide.md).150> **Note**: Common configuration items (llm, subagent, trace, compact, etc.) are shown in the "Complete Daemon Configuration Example" below. For detailed descriptions, please refer to [Configuration File Guide](./config_file_guide.md).
116 151 
117## Complete Daemon Configuration Example152## Complete Daemon Configuration Example
@@ -190,6 +225,15 @@ webhook_secret_token = "your-token"
190# Data storage path (Daemon-specific)225# Data storage path (Daemon-specific)
191[paths]226[paths]
192data_dir = "~/.xiaoo"227data_dir = "~/.xiaoo"
228+ 
229+# Operation backend (Daemon-specific)
230+[server.operation_backend]
231+kind = "e2b"
232+ 
233+[server.operation_backend.options]
234+api_key_env = "E2B_API_KEY"
235+template_id = "base"
236+timeout_secs = 3600
193```237```
194 238 
195### API Endpoints239### API Endpoints
@@ -209,147 +253,323 @@ Health check endpoint for liveness probes and load balancing.
209 253 
210---254---
211 255 
212-#### `POST /api/v1/chat`256+#### Session And Runtime Control Plane
213 257 
214-Chat endpoint. Send messages to the Gateway and receive responses.258+The daemon exposes runtime APIs for remote TUI and other first-class clients,
259+plus checkpoint APIs for callers that need branching execution state.
260+These endpoints are protected by HTTP Bearer auth when `[http]` auth is configured.
261+LLM provider settings are resolved per runtime/turn: request payloads may pass an
262+optional `llm` object, and omitted fields fall back to `[llm]` in the daemon
263+config. The daemon does not require the LLM API key at process startup.
215 264 
216-**Request Body:**265+| Endpoint | Description |
266+|----------|-------------|
267+| `POST /api/v1/runtimes/open` | Open or resume a runtime using `RuntimeOpenRequest` |
268+| `POST /api/v1/runtimes/input` | Submit one user input and stream SSE events |
269+| `POST /api/v1/runtimes/interaction` | Send a user interaction response back to the daemon |
270+| `POST /api/v1/runtimes/cancel` | Request cancellation of the current turn |
271+| `POST /api/v1/runtimes/close` | Close the runtime, remove its record, and fire lifecycle hooks |
272+| `POST /api/v1/runtimes/checkpoint` | Capture an idle runtime as a checkpoint using `RuntimeCheckpointRequest` |
273+| `POST /api/v1/runtimes/checkpoint/delete-snapshot` | Delete the provider snapshot/template referenced by a checkpoint |
274+| `POST /api/v1/runtimes/checkout` | Create a new runtime from a checkpoint using `RuntimeCheckoutRequest` |
217 275 
218-| Field | Type | Required | Description |276+Runtime APIs use `runtime_id` and `checkpoint_id` as the public vocabulary. In
219-|-------|------|----------|-------------|277+the current v1 implementation, `runtime_id` is backed by the same value as the
220-| `text` | string | | Message text (must not be empty) |278+internal `session_id`; backend ids remain internal and are not returned in
221-| `channel` | string | *Either `channel` or `channel_instance_id`* | Channel identifier (e.g., `feishu`, `telegram`) |279+`RuntimeRecord`. See [Runtime Checkpoint Control](./runtime_checkpoint.md)
222-| `channel_instance_id` | string | *Either `channel` or `channel_instance_id`* | Channel instance ID (for multi-instance session isolation) |280+for the current layering and checkpoint semantics.
223-| `sender_id` | string | ✅ | Sender ID |
224-| `conversation_id` | string | ✅ | Conversation/group ID (same value reuses the same session) |
225-| `message_id` | string | — | Unique message ID (auto-generated if not specified) |
226-| `reply_to_message_id` | string | — | Target message ID being replied to |
227-| `root_message_id` | string | — | Root message ID of the thread |
228-| `mentions` | array | — | List of @mentions |
229 281 
230-`mentions` element structure:282+**Open runtime example:**
231- 
232-```json
233-{
234- "id": "user-or-bot-id",
235- "display_name": "Display name (optional)"
236-}
237-```
238- 
239-**Example Request:**
240 283 
241```bash284```bash
242-curl -X POST http://localhost:18080/api/v1/chat \285+curl -X POST http://localhost:18080/api/v1/runtimes/open \
243 -H "Authorization: Bearer $XIAOO_HTTP_BEARER_TOKEN" \286 -H "Authorization: Bearer $XIAOO_HTTP_BEARER_TOKEN" \
244 -H "Content-Type: application/json" \287 -H "Content-Type: application/json" \
245 -d '{288 -d '{
246- "text": "Hello",289+ "runtime_id": "tui-demo",
247- "channel": "test",
248- "sender_id": "user-1",
249 "conversation_id": "conv-demo",290 "conversation_id": "conv-demo",
250- "mentions": [{"id": "bot", "display_name": "XiaoO"}]291+ "sender_id": "user-1",
292+ "entry": { "kind": "tui" },
293+ "channel": "tui",
294+ "llm": {
295+ "provider": "deepseek",
296+ "model": "deepseek-v4-pro",
297+ "api_key_env": "DEEPSEEK_API_KEY"
298+ }
251 }'299 }'
252```300```
253 301 
254-**Response `200 OK`:**302+**Close runtime example:**
255 303 
256-```json304+```bash
257-{305+curl -X POST http://localhost:18080/api/v1/runtimes/close \
258- "reply": "Hello! How can I help you?",306+ -H "Authorization: Bearer $XIAOO_HTTP_BEARER_TOKEN" \
259- "raw_reply": "Hello! How can I help you?",307+ -H "Content-Type: application/json" \
260- "conversation_id": "conv-demo",308+ -d '{
261- "session_id": "test:conv-demo"309+ "runtime_id": "tui-demo"
262-}310+ }'
263```311```
264 312 
265-| Field | Description |313+**Submit input stream example:**
266-|-------|-------------|
267-| `reply` | Final visible reply text (after post-processing) |
268-| `raw_reply` | Raw reply text |
269-| `conversation_id` | Conversation ID (same as request) |
270-| `session_id` | Internal session identifier (format: `{channel_or_instance}:{conversation_id}`) |
271 314 
272-**Error Responses:**315+```bash
316+curl -N -X POST http://localhost:18080/api/v1/runtimes/input \
317+ -H "Authorization: Bearer $XIAOO_HTTP_BEARER_TOKEN" \
318+ -H "Content-Type: application/json" \
319+ -d '{
320+ "runtime_id": "tui-demo",
321+ "entry": { "kind": "tui" },
322+ "channel": "tui",
323+ "conversation_id": "conv-demo",
324+ "sender_id": "user-1",
325+ "text": "Hello",
326+ "mentions": [],
327+ "llm": {
328+ "provider": "openai",
329+ "model": "gpt-4o",
330+ "api_key_env": "OPENAI_API_KEY"
331+ }
332+ }'
333+```
273 334 
274-- `400 Bad Request` Missing required fields or validation failure:335+**Runtime checkpoint / checkout example with timing:**
275- ```json
276- { "error": "channel or channel_instance_id is required" }
277- ```
278- ```json
279- { "error": "text must not be empty" }
280- ```
281-- `500 Internal Server Error` — Session service internal error
282-- `401 Unauthorized` — Missing or invalid Bearer token when `[http]` auth is configured
283-- `429 Too Many Requests` — Rate limit exceeded when `[http.rate_limit]` is enabled
284 336 
285-> **Rate limiting applies globally** to all endpoints (`/api/v1/health`, `/api/v1/chat`, `/api/v1/chat/stream`, `/api/v1/channels/{channel_id}/events`). Client identity is extracted from the `X-Forwarded-For` header (first IP) or `X-Real-Ip`, falling back to a shared `"unknown"` bucket. Ensure your reverse proxy (nginx / Caddy) forwards these headers.337+The runtime checkpoint APIs require the source runtime to be idle. In the
338+current v1 implementation, the `runtime_id` is the same value as the runtime id
339+returned by `/api/v1/runtimes/open`. The following flow creates a runtime, runs
340+one turn to make the backend dirty, captures a checkpoint, checks out a child
341+runtime, runs both branches, and closes both runtimes.
286 342 
287----343+The example requires Bash, `curl`, and `jq`. It only adds the `Authorization`
344+header when `XIAOO_HTTP_BEARER_TOKEN` is present. It uses
345+`curl -w '%{time_total}'` to capture end-to-end HTTP request latency for the
346+checkpoint and checkout control-plane calls.
288 347 
289-#### `POST /api/v1/chat/stream`348+```bash
349+BASE_URL="http://localhost:18080"
350+RUNTIME="checkpoint-demo-$(date +%Y%m%d%H%M%S)"
351+CONV="conv-${RUNTIME}"
352+SENDER="checkpoint-demo-user"
290 353 
291-Streaming chat endpoint. Same request format as `/api/v1/chat`, but returns a **Server-Sent Events (SSE)** stream with real-time updates for LLM text generation and tool execution.354+AUTH_HEADER=()
355+if [ -n "${XIAOO_HTTP_BEARER_TOKEN:-}" ]; then
356+ AUTH_HEADER=(-H "Authorization: Bearer ${XIAOO_HTTP_BEARER_TOKEN}")
357+fi
292 358 
293-**Request Body:** Same as `/api/v1/chat`.359+jq -n --arg runtime "$RUNTIME" --arg conv "$CONV" --arg sender "$SENDER" \
360+ '{
361+ runtime_id: $runtime,
362+ conversation_id: $conv,
363+ sender_id: $sender,
364+ entry: { kind: "http_api", instance_id: "checkpoint-demo" }
365+ }' > /tmp/xiaoo_open.json
294 366 
295-**Response:** `200 OK`, Content-Type `text/event-stream`367+curl -sS -X POST "$BASE_URL/api/v1/runtimes/open" \
368+ "${AUTH_HEADER[@]}" \
369+ -H "Content-Type: application/json" \
370+ --data @/tmp/xiaoo_open.json \
371+ > /tmp/xiaoo_open.out
372+```
373+ 
374+```bash
375+INIT_TEXT="请在当前 agent runtime 的工作区创建文件 /home/user/workspace/checkpoint_demo.txt,内容为两行:第一行 checkpoint base,第二行 runtime parent initialized。完成后读取该文件并回复其完整内容。"
376+ 
377+jq -n \
378+ --arg runtime "$RUNTIME" \
379+ --arg conv "$CONV" \
380+ --arg sender "$SENDER" \
381+ --arg text "$INIT_TEXT" \
382+ '{
383+ runtime_id: $runtime,
384+ entry: { kind: "http_api", instance_id: "checkpoint-demo" },
385+ channel: null,
386+ message_id: null,
387+ conversation_id: $conv,
388+ sender_id: $sender,
389+ text: $text,
390+ channel_instance_id: null,
391+ channel_identity_prompt: null,
392+ reply_to_message_id: null,
393+ root_message_id: null,
394+ mentions: [],
395+ reasoning_effort: "off",
396+ llm: null
397+ }' > /tmp/xiaoo_initial_turn.json
398+ 
399+curl -sS -N -X POST "$BASE_URL/api/v1/runtimes/input" \
400+ "${AUTH_HEADER[@]}" \
401+ -H "Content-Type: application/json" \
402+ --data @/tmp/xiaoo_initial_turn.json \
403+ > /tmp/xiaoo_initial_turn.sse
404+```
405+ 
406+```bash
407+jq -n --arg runtime "$RUNTIME" \
408+ '{
409+ runtime_id: $runtime,
410+ name: "fork-test-base",
411+ metadata: {
412+ purpose: "checkpoint checkout smoke test",
413+ requested_line: "测试fork"
414+ }
415+ }' > /tmp/xiaoo_checkpoint.json
416+ 
417+curl -sS \
418+ -o /tmp/xiaoo_checkpoint.out \
419+ -w "%{time_total}\n" \
420+ -X POST "$BASE_URL/api/v1/runtimes/checkpoint" \
421+ "${AUTH_HEADER[@]}" \
422+ -H "Content-Type: application/json" \
423+ --data @/tmp/xiaoo_checkpoint.json \
424+ > /tmp/xiaoo_checkpoint.time
425+ 
426+CHECKPOINT_ID="$(jq -r '.checkpoint_id' /tmp/xiaoo_checkpoint.out)"
427+printf "checkpoint_time_total_seconds=%s\n" "$(cat /tmp/xiaoo_checkpoint.time)"
428+```
429+ 
430+```bash
431+jq -n \
432+ --arg checkpoint "$CHECKPOINT_ID" \
433+ --arg child_conv "conv-${RUNTIME}-child" \
434+ '{
435+ checkpoint_id: $checkpoint,
436+ conversation_id: $child_conv,
437+ sender_id: "checkpoint-demo-child",
438+ metadata: {
439+ branch: "child",
440+ requested_line: "测试fork"
441+ }
442+ }' > /tmp/xiaoo_checkout.json
443+ 
444+curl -sS \
445+ -o /tmp/xiaoo_checkout.out \
446+ -w "%{time_total}\n" \
447+ -X POST "$BASE_URL/api/v1/runtimes/checkout" \
448+ "${AUTH_HEADER[@]}" \
449+ -H "Content-Type: application/json" \
450+ --data @/tmp/xiaoo_checkout.json \
451+ > /tmp/xiaoo_checkout.time
452+ 
453+CHILD_RUNTIME="$(jq -r '.runtime.runtime_id' /tmp/xiaoo_checkout.out)"
454+printf "checkout_time_total_seconds=%s\n" "$(cat /tmp/xiaoo_checkout.time)"
455+```
456+ 
457+If a checkpoint's provider snapshot is no longer needed for future checkout,
458+delete it explicitly. The checkpoint record remains in daemon memory for lineage
459+metadata, but after this call it no longer has an E2B provider snapshot and
460+cannot be used to create another checkout branch.
461+ 
462+```bash
463+jq -n --arg checkpoint "$CHECKPOINT_ID" \
464+ '{ checkpoint_id: $checkpoint }' \
465+ > /tmp/xiaoo_delete_snapshot.json
466+ 
467+curl -sS -X POST "$BASE_URL/api/v1/runtimes/checkpoint/delete-snapshot" \
468+ "${AUTH_HEADER[@]}" \
469+ -H "Content-Type: application/json" \
470+ --data @/tmp/xiaoo_delete_snapshot.json
471+```
472+ 
473+```bash
474+PARENT_TEXT="你是父 runtime。请不要写入“测试fork”。请在 /home/user/workspace/checkpoint_demo.txt 末尾追加一行:parent runtime complete。完成后读取该文件并回复完整内容。"
475+CHILD_TEXT="你是 checkpoint checkout 出来的子 runtime。请在 /home/user/workspace/checkpoint_demo.txt 末尾追加一行:测试fork。完成后读取该文件并回复完整内容。"
476+ 
477+jq -n --arg runtime "$RUNTIME" --arg conv "$CONV" --arg text "$PARENT_TEXT" \
478+ '{
479+ runtime_id: $runtime,
480+ entry: { kind: "http_api", instance_id: "checkpoint-demo" },
481+ channel: null,
482+ message_id: null,
483+ conversation_id: $conv,
484+ sender_id: "checkpoint-demo-user",
485+ text: $text,
486+ channel_instance_id: null,
487+ channel_identity_prompt: null,
488+ reply_to_message_id: null,
489+ root_message_id: null,
490+ mentions: [],
491+ reasoning_effort: "off",
492+ llm: null
493+ }' > /tmp/xiaoo_parent_final.json
494+ 
495+curl -sS -N -X POST "$BASE_URL/api/v1/runtimes/input" \
496+ "${AUTH_HEADER[@]}" \
497+ -H "Content-Type: application/json" \
498+ --data @/tmp/xiaoo_parent_final.json \
499+ > /tmp/xiaoo_parent_final.sse
500+ 
501+jq -n --arg runtime "$CHILD_RUNTIME" --arg text "$CHILD_TEXT" \
502+ '{
503+ runtime_id: $runtime,
504+ entry: { kind: "http_api", instance_id: "checkpoint-demo-child" },
505+ channel: null,
506+ message_id: null,
507+ conversation_id: "conv-checkpoint-demo-child",
508+ sender_id: "checkpoint-demo-child",
509+ text: $text,
510+ channel_instance_id: null,
511+ channel_identity_prompt: null,
512+ reply_to_message_id: null,
513+ root_message_id: null,
514+ mentions: [],
515+ reasoning_effort: "off",
516+ llm: null
517+ }' > /tmp/xiaoo_child_final.json
518+ 
519+curl -sS -N -X POST "$BASE_URL/api/v1/runtimes/input" \
520+ "${AUTH_HEADER[@]}" \
521+ -H "Content-Type: application/json" \
522+ --data @/tmp/xiaoo_child_final.json \
523+ > /tmp/xiaoo_child_final.sse
524+```
525+ 
526+```bash
527+for id in "$RUNTIME" "$CHILD_RUNTIME"; do
528+ jq -n --arg runtime "$id" '{ runtime_id: $runtime }' \
529+ > "/tmp/xiaoo_close_${id//[^A-Za-z0-9_]/_}.json"
530+ curl -sS -X POST "$BASE_URL/api/v1/runtimes/close" \
531+ "${AUTH_HEADER[@]}" \
532+ -H "Content-Type: application/json" \
533+ --data @"/tmp/xiaoo_close_${id//[^A-Za-z0-9_]/_}.json"
534+done
535+```
536+ 
537+With E2B as `[server.operation_backend]`, a local smoke run on 2026-06-13
538+measured:
539+ 
540+| Operation | Measured `curl` `time_total` | E2B work included |
541+|-----------|-------------------------------|-------------------|
542+| Runtime checkpoint | `1.516061s` | Create an E2B provider snapshot for the dirty parent sandbox |
543+| Runtime checkout | `2.335461s` | Start a new E2B sandbox from the provider snapshot and bind it to the child runtime |
544+ 
545+These values are examples, not guarantees. They vary with E2B provider latency,
546+network path, snapshot size, template cold/warm state, and daemon host load. The
547+numbers above do not include the LLM turns before or after the checkpoint, and
548+they do not include closing the runtimes. Closing an E2B-backed runtime calls
549+backend release, which deletes the corresponding E2B sandbox.
296 550 
297**SSE Event Types:**551**SSE Event Types:**
298 552 
299| Event | Fields | Description |553| Event | Fields | Description |
300|-------|--------|-------------|554|-------|--------|-------------|
301| `turn_start` | `agent_id`, `turn` | Emitted at the start of each agent loop turn |555| `turn_start` | `agent_id`, `turn` | Emitted at the start of each agent loop turn |
302-| `text_delta` | `delta`, `snapshot` | Emitted for each LLM text chunk. `delta` is the incremental text, `snapshot` is the cumulative text so far |556+| `text_delta` | `delta`, `snapshot` | Emitted for assistant text updates |
557+| `thinking_delta` | `delta`, `snapshot` | Emitted for assistant reasoning updates |
303| `tool_result` | `call_id`, `tool_name`, `output_preview`, `is_error` | Emitted after each tool execution completes |558| `tool_result` | `call_id`, `tool_name`, `output_preview`, `is_error` | Emitted after each tool execution completes |
304-| `done` | `reply`, `raw_reply`, `conversation_id`, `session_id`, `turn_count`, `total_tokens`, `stop_reason` | Emitted when the agent loop finishes. Stream closes after this event |559+| `interaction_requested` | `request` | Emitted when the daemon needs a user confirmation/input/choice |
305-| `error` | `error` | Emitted on failure. Stream closes after this event |560+| `done` | `reply`, `raw_reply`, `conversation_id`, `runtime_id`, `turn_count`, `total_tokens`, `messages`, `stop_reason` | Emitted when the agent loop finishes |
561+| `error` | `error` | Emitted on failure |
562+| `cancelled` | `runtime_id` | Emitted as cancellation acknowledgement |
306 563 
307-**Example Request:**564+**Common Error Responses:**
308 565 
309-```bash566+- `400 Bad Request` — malformed request or path/runtime mismatch
310-curl -N -X POST http://localhost:18080/api/v1/chat/stream \567+- `401 Unauthorized` — missing or invalid Bearer token when `[http]` auth is configured
311- -H "Authorization: Bearer $XIAOO_HTTP_BEARER_TOKEN" \568+- `404 Not Found` — runtime not found
312- -H "Content-Type: application/json" \569+- `429 Too Many Requests` — rate limit exceeded when `[http.rate_limit]` is enabled
313- -d '{570+- `500 Internal Server Error` — runtime service internal error
314- "text": "Hello",
315- "channel": "test",
316- "sender_id": "user-1",
317- "conversation_id": "conv-demo"
318- }'
319-```
320 571 
321-**Example SSE Output:**572+> **Rate limiting applies globally** to all endpoints (`/api/v1/health`, `/api/v1/runtimes/*`, `/api/v1/channels/{channel_id}/events`). Client identity is extracted from the `X-Forwarded-For` header (first IP) or `X-Real-Ip`, falling back to a shared `"unknown"` bucket. Ensure your reverse proxy (nginx / Caddy) forwards these headers.
322- 
323-```
324-event: turn_start
325-data: {"type":"turn_start","agent_id":"main","turn":1}
326- 
327-event: text_delta
328-data: {"type":"text_delta","delta":"Hello","snapshot":"Hello"}
329- 
330-event: text_delta
331-data: {"type":"text_delta","delta":"! How can I help you?","snapshot":"Hello! How can I help you?"}
332- 
333-event: done
334-data: {"type":"done","reply":"Hello! How can I help you?","raw_reply":"Hello! How can I help you?","conversation_id":"conv-demo","session_id":"test:conv-demo","turn_count":1,"total_tokens":150,"stop_reason":"complete"}
335-```
336- 
337-**Error Responses:**
338- 
339-- `400 Bad Request` — Same validation errors as `/api/v1/chat`
340-- `401 Unauthorized` — Missing or invalid Bearer token when `[http]` auth is configured
341-- `429 Too Many Requests` — Rate limit exceeded when `[http.rate_limit]` is enabled
342- 
343-**429 Response:**
344- 
345-```json
346-{ "error": "rate limit exceeded; retry after 1s" }
347-```
348- 
349-| Header | Description |
350-|--------|-------------|
351-| `Retry-After` | Seconds until quota resets |
352-| `X-RateLimit-Remaining` | Remaining requests (always `0` when 429) |
353 573 
354---574---
355 575 
Mdocs/feishu_deploy.md+15-15
@@ -73,7 +73,7 @@ In persistent-connection mode:
73- A Feishu tenant with admin access73- A Feishu tenant with admin access
74- A custom Feishu app with the **Bot** capability enabled74- A custom Feishu app with the **Bot** capability enabled
75- A public domain or public server IP reachable by Feishu75- A public domain or public server IP reachable by Feishu
76-- A Linux host where you can install and run `xiaoo-app daemon`76+- A Linux host where you can install and run `xiaoo-daemon`
77- Rust toolchain and Cargo available on that host77- Rust toolchain and Cargo available on that host
78- `systemd` available to manage the daemon process78- `systemd` available to manage the daemon process
79- `nginx` or another reverse proxy available for public ingress79- `nginx` or another reverse proxy available for public ingress
@@ -134,7 +134,7 @@ Feishu
134 134 
135Typical responsibilities:135Typical responsibilities:
136 136 
137-- your local machine runs `xiaoo-app daemon`137+- your local machine runs `xiaoo-daemon`
138- a public server exposes the callback route138- a public server exposes the callback route
139- that public server forwards traffic back to your local machine through a secure relay or tunnel139- that public server forwards traffic back to your local machine through a secure relay or tunnel
140 140 
@@ -303,38 +303,38 @@ Example:
303git clone <your-repo-url> /opt/xiaoo/src303git clone <your-repo-url> /opt/xiaoo/src
304cd /opt/xiaoo/src304cd /opt/xiaoo/src
305git checkout <your-branch>305git checkout <your-branch>
306-cargo build -p xiaoo-app306+cargo build -p xiaoo-serverside
307```307```
308 308 
309After a successful build, the binary will usually be created at:309After a successful build, the binary will usually be created at:
310 310 
311```text311```text
312-target/debug/xiaoo-app312+target/debug/xiaoo-daemon
313```313```
314 314 
315For a long-running service, copy or install that binary to a stable runtime path such as:315For a long-running service, copy or install that binary to a stable runtime path such as:
316 316 
317```text317```text
318-/opt/xiaoo/bin/xiaoo-app318+/opt/xiaoo/bin/xiaoo-daemon
319```319```
320 320 
321Example:321Example:
322 322 
323```bash323```bash
324mkdir -p /opt/xiaoo/bin324mkdir -p /opt/xiaoo/bin
325-install -m 755 target/debug/xiaoo-app /opt/xiaoo/bin/xiaoo-app325+install -m 755 target/debug/xiaoo-daemon /opt/xiaoo/bin/xiaoo-daemon
326```326```
327 327 
328If you prefer release builds, replace the build command with:328If you prefer release builds, replace the build command with:
329 329 
330```bash330```bash
331-cargo build -p xiaoo-app --release331+cargo build -p xiaoo-serverside --release
332```332```
333 333 
334and install:334and install:
335 335 
336```bash336```bash
337-install -m 755 target/release/xiaoo-app /opt/xiaoo/bin/xiaoo-app337+install -m 755 target/release/xiaoo-daemon /opt/xiaoo/bin/xiaoo-daemon
338```338```
339 339 
340## 5. Prepare Runtime Directories340## 5. Prepare Runtime Directories
@@ -354,7 +354,7 @@ mkdir -p /var/lib/xiaoo/agents/main
354Recommended layout:354Recommended layout:
355 355 
356```text356```text
357-/opt/xiaoo/bin/xiaoo-app357+/opt/xiaoo/bin/xiaoo-daemon
358/opt/xiaoo/config/config.toml358/opt/xiaoo/config/config.toml
359/opt/xiaoo/config/xiaoo.env359/opt/xiaoo/config/xiaoo.env
360/opt/xiaoo/app360/opt/xiaoo/app
@@ -769,7 +769,7 @@ Then start xiaoO locally:
769 769 
770```bash770```bash
771cd /path/to/xiaoO771cd /path/to/xiaoO
772-cargo run -p xiaoo-app -- daemon --config /path/to/config.toml --host 127.0.0.1 --port 18080772+cargo run -p xiaoo-serverside -- --config /path/to/config.toml --host 127.0.0.1 --port 18080
773```773```
774 774 
775Notes:775Notes:
@@ -806,7 +806,7 @@ After=network.target
806Type=simple806Type=simple
807WorkingDirectory=/opt/xiaoo807WorkingDirectory=/opt/xiaoo
808EnvironmentFile=/opt/xiaoo/config/xiaoo.env808EnvironmentFile=/opt/xiaoo/config/xiaoo.env
809-ExecStart=/opt/xiaoo/bin/xiaoo-app daemon --config /opt/xiaoo/config/config.toml --host 127.0.0.1 --port 18080809+ExecStart=/opt/xiaoo/bin/xiaoo-daemon --config /opt/xiaoo/config/config.toml --host 127.0.0.1 --port 18080
810Restart=always810Restart=always
811RestartSec=5811RestartSec=5
812 812 
@@ -845,7 +845,7 @@ You can run the daemon directly:
845cd /path/to/xiaoO845cd /path/to/xiaoO
846export FEISHU_APP_SECRET=your-real-feishu-app-secret846export FEISHU_APP_SECRET=your-real-feishu-app-secret
847export OPENROUTER_API_KEY=your-real-model-key847export OPENROUTER_API_KEY=your-real-model-key
848-cargo run -p xiaoo-app -- daemon --config /path/to/config.toml --host 127.0.0.1 --port 18080848+cargo run -p xiaoo-serverside -- --config /path/to/config.toml --host 127.0.0.1 --port 18080
849```849```
850 850 
851This is often the fastest way to validate that:851This is often the fastest way to validate that:
@@ -1018,7 +1018,7 @@ If you are running xiaoO locally from source, use the same idea but watch the lo
1018cd /path/to/xiaoO1018cd /path/to/xiaoO
1019export FEISHU_APP_SECRET=your-real-feishu-app-secret1019export FEISHU_APP_SECRET=your-real-feishu-app-secret
1020export OPENROUTER_API_KEY=your-real-model-key1020export OPENROUTER_API_KEY=your-real-model-key
1021-cargo run -p xiaoo-app -- daemon --config /path/to/config.toml --host 127.0.0.1 --port 180801021+cargo run -p xiaoo-serverside -- --config /path/to/config.toml --host 127.0.0.1 --port 18080
1022```1022```
1023 1023 
1024What you want to see:1024What you want to see:
@@ -1064,7 +1064,7 @@ If you are deploying from source for the first time, one extra check is worth do
1064### 14.9 Confirm the binary you installed is the one you just built1064### 14.9 Confirm the binary you installed is the one you just built
1065 1065 
1066```bash1066```bash
1067-ls -l /opt/xiaoo/bin/xiaoo-app1067+ls -l /opt/xiaoo/bin/xiaoo-daemon
1068```1068```
1069 1069 
1070and compare the timestamp with your latest build result in `target/debug/` or `target/release/`.1070and compare the timestamp with your latest build result in `target/debug/` or `target/release/`.
@@ -1154,7 +1154,7 @@ If Feishu is configured for persistent connection, but xiaoO logs never show web
1154For a clean production deployment, use this structure:1154For a clean production deployment, use this structure:
1155 1155 
1156```text1156```text
1157-/opt/xiaoo/bin/xiaoo-app1157+/opt/xiaoo/bin/xiaoo-daemon
1158/opt/xiaoo/config/config.toml1158/opt/xiaoo/config/config.toml
1159/opt/xiaoo/config/xiaoo.env1159/opt/xiaoo/config/xiaoo.env
1160/opt/xiaoo/app1160/opt/xiaoo/app
Mdocs/memory_context_system.md+2-2
@@ -170,8 +170,8 @@ Important boundary:
170 170 
171If you only need the built-in context-management path, configure `[compact]` and optionally `[llm].context_window`. This is already wired into:171If you only need the built-in context-management path, configure `[compact]` and optionally `[llm].context_window`. This is already wired into:
172 172 
173-- `apps/xiaoo-app/src/daemon_runtime.rs`173+- `apps/serverside/src/daemon_runtime.rs`
174-- `apps/xiaoo-app/src/cli/mod.rs`174+- `apps/endside/src/cli/mod.rs`
175- `crates/core/src/agent_loop.rs`175- `crates/core/src/agent_loop.rs`
176 176 
177Once configured, compression runs automatically before each turn.177Once configured, compression runs automatically before each turn.
Mdocs/plugins.md+7-7
@@ -35,7 +35,7 @@ You can also develop your own hookers and place them in `<your_xiaoO>/plugins/ho
35 35 
36### Built-in Skills36### Built-in Skills
37 37 
38-When you run `cargo install --path apps/xiaoo-app`, builtin skills are automatically installed. They provide security policy enforcement and other built-in capabilities, and are loaded with highest priority by the runtime.38+When you run `cargo install --path apps/endside`, builtin skills are automatically installed. They provide security policy enforcement and other built-in capabilities, and are loaded with highest priority by the runtime.
39 39 
40**Installation locations** (automatic fallback):40**Installation locations** (automatic fallback):
41- **System level** (preferred): `/usr/lib/.xiaoo/skills/` - requires root privileges41- **System level** (preferred): `/usr/lib/.xiaoo/skills/` - requires root privileges
@@ -53,7 +53,7 @@ When you run `cargo install --path apps/xiaoo-app`, builtin skills are automatic
53> - Without these skills, security features and other capabilities may be unavailable.53> - Without these skills, security features and other capabilities may be unavailable.
54>54>
55> **For system-wide installation** (recommended for multi-user environments):55> **For system-wide installation** (recommended for multi-user environments):
56-> - Run `cargo install` with root privileges: `sudo cargo install --path apps/xiaoo-app`56+> - Run `cargo install` with root privileges: `sudo cargo install --path apps/endside`
57 57 
58### Skill Directory Priority (Four Levels)58### Skill Directory Priority (Four Levels)
59 59 
@@ -64,14 +64,14 @@ When you run `cargo install --path apps/xiaoo-app`, builtin skills are automatic
64 64 
65### Custom Skills65### Custom Skills
66 66 
67-Custom skills can be installed to user-level directory using the `xiaoo skill install` command:67+Custom skills can be installed to user-level directory using the `xiaoo --cli skill install` command:
68 68 
69```bash69```bash
70# Install from local directory (installs to ~/.xiaoo/skills by default)70# Install from local directory (installs to ~/.xiaoo/skills by default)
71-xiaoo skill install ./my-skill/71+xiaoo --cli skill install ./my-skill/
72 72 
73# Install from Git repository73# Install from Git repository
74-xiaoo skill install https://github.com/user/my-skill.git74+xiaoo --cli skill install https://github.com/user/my-skill.git
75```75```
76 76 
77> **Note**: User-installed skills go to `~/.xiaoo/skills/` (user level), NOT `/usr/lib/.xiaoo/skills/` (system level is reserved for built-in skills).77> **Note**: User-installed skills go to `~/.xiaoo/skills/` (user level), NOT `/usr/lib/.xiaoo/skills/` (system level is reserved for built-in skills).
@@ -82,7 +82,7 @@ See [skill_usage.md](./skill_usage.md) for detailed skill documentation.
82 82 
83```bash83```bash
84# Remove a user-installed skill84# Remove a user-installed skill
85-xiaoo skill remove <skill-name>85+xiaoo --cli skill remove <skill-name>
86 86 
87# Or manually remove from user level87# Or manually remove from user level
88rm -rf ~/.xiaoo/skills/<skill-name>88rm -rf ~/.xiaoo/skills/<skill-name>
@@ -95,7 +95,7 @@ To completely uninstall xiaoO and all associated skills:
95 95 
96```bash96```bash
97# Uninstall the application97# Uninstall the application
98-cargo uninstall xiaoo-app98+cargo uninstall xiaoo-endside
99 99 
100# Remove system-level skills (requires root)100# Remove system-level skills (requires root)
101sudo rm -rf /usr/lib/.xiaoo/skills101sudo rm -rf /usr/lib/.xiaoo/skills
Mdocs/remote_tui.md+28-21
@@ -2,8 +2,8 @@
2 2 
3Remote TUI lets one machine run the XiaoO gateway daemon while another machine runs the terminal UI.3Remote TUI lets one machine run the XiaoO gateway daemon while another machine runs the terminal UI.
4 4 
5-- **Machine A** runs `xiaoo-app daemon` and owns the runtime, LLM provider, tools, hooks, workspace, and operation backend.5+- **Machine A** runs `xiaoo-daemon` and owns the runtime, LLM provider, tools, hooks, workspace, and operation backend.
6-- **Machine B** runs `xiaoo-tui` and connects to Machine A with `/remote`.6+- **Machine B** runs `xiaoo` and connects to Machine A with `/remote`.
7- Both machines use the same codebase and binaries; only the startup mode is different.7- Both machines use the same codebase and binaries; only the startup mode is different.
8 8 
9---9---
@@ -12,16 +12,16 @@ Remote TUI lets one machine run the XiaoO gateway daemon while another machine r
12 12 
13```13```
14Machine B Machine A14Machine B Machine A
15-xiaoo-tui xiaoo-app daemon15+xiaoo xiaoo-daemon
16--------- ----------------16--------- ----------------
17-TUI input/rendering HTTP/SSE Gateway session APIs17+TUI input/rendering HTTP/SSE Gateway runtime APIs
18/remote commands -----------> Agent loop18/remote commands -----------> Agent loop
19Interaction prompt <----------> Tools / hooks / workspace19Interaction prompt <----------> Tools / hooks / workspace
20```20```
21 21 
22Local TUI remains the default. Remote mode is opt-in:22Local TUI remains the default. Remote mode is opt-in:
23 23 
24-- `Local`: TUI opens sessions and runs the agent loop in the local process.24+- `Local`: TUI opens runtimes and runs the agent loop in the local process.
25- `Remote`: TUI sends turns to the daemon and renders the daemon's SSE events.25- `Remote`: TUI sends turns to the daemon and renders the daemon's SSE events.
26 26 
27In remote mode, all tool execution happens on Machine A. The workspace shown in the TUI status bar is marked as remote to avoid confusing it with Machine B's local directory.27In remote mode, all tool execution happens on Machine A. The workspace shown in the TUI status bar is marked as remote to avoid confusing it with Machine B's local directory.
@@ -33,7 +33,7 @@ In remote mode, all tool execution happens on Machine A. The workspace shown in
33Start the daemon on Machine A:33Start the daemon on Machine A:
34 34 
35```bash35```bash
36-xiaoo-app daemon \36+xiaoo-daemon \
37 --host 0.0.0.0 \37 --host 0.0.0.0 \
38 --port 18080 \38 --port 18080 \
39 --config ~/.config/xiaoo/config.toml39 --config ~/.config/xiaoo/config.toml
@@ -50,7 +50,7 @@ Then export the token before starting the daemon:
50 50 
51```bash51```bash
52export XIAOO_HTTP_BEARER_TOKEN="change-me"52export XIAOO_HTTP_BEARER_TOKEN="change-me"
53-xiaoo-app daemon --host 0.0.0.0 --port 1808053+xiaoo-daemon --host 0.0.0.0 --port 18080
54```54```
55 55 
56Health check:56Health check:
@@ -72,7 +72,7 @@ If bearer auth is configured, protected session/chat routes require:
72Start the TUI normally:72Start the TUI normally:
73 73 
74```bash74```bash
75-xiaoo-tui75+xiaoo
76```76```
77 77 
78Connect to Machine A:78Connect to Machine A:
@@ -94,7 +94,7 @@ Then export the same token value on Machine B:
94 94 
95```bash95```bash
96export XIAOO_REMOTE_TOKEN="change-me"96export XIAOO_REMOTE_TOKEN="change-me"
97-xiaoo-tui97+xiaoo
98```98```
99 99 
100When `auto_connect = true`, TUI enters remote backend mode on startup using the configured URL. When `auto_connect = false`, the config only supplies the bearer token env var and default remote settings; use `/remote <url>` manually.100When `auto_connect = true`, TUI enters remote backend mode on startup using the configured URL. When `auto_connect = false`, the config only supplies the bearer token env var and default remote settings; use `/remote <url>` manually.
@@ -114,17 +114,24 @@ After `/remote <base_url>` succeeds, new turns go through Machine A's daemon. Th
114 114 
115---115---
116 116 
117-## 5. Remote Session API117+## 5. Remote Session And Runtime API
118 118 
119-Remote TUI uses the daemon's session APIs, not the older channel-style `/api/v1/chat` endpoint.119+Remote TUI uses the daemon's runtime control APIs. The same protected route
120+group also contains checkpoint APIs for programmatic clients that need branching
121+runtime state.
120 122 
121| Endpoint | Description |123| Endpoint | Description |
122|----------|-------------|124|----------|-------------|
123-| `POST /api/v1/sessions/open` | Open or resume a gateway session using `SessionOpenRequest` |125+| `POST /api/v1/runtimes/open` | Open or resume a runtime using `RuntimeOpenRequest` |
124-| `POST /api/v1/sessions/{session_id}/turn/stream` | Run one turn and stream SSE events |126+| `POST /api/v1/runtimes/input` | Submit one user input and stream SSE events |
125-| `POST /api/v1/sessions/{session_id}/interaction` | Send a user interaction response back to the daemon |127+| `POST /api/v1/runtimes/interaction` | Send a user interaction response back to the daemon |
126-| `POST /api/v1/sessions/{session_id}/cancel` | Request cancellation of the current turn |128+| `POST /api/v1/runtimes/cancel` | Request cancellation of the current turn |
127-| `POST /api/v1/sessions/{session_id}/close` | Close the session and fire lifecycle hooks |129+| `POST /api/v1/runtimes/close` | Close the runtime, remove its record, and fire lifecycle hooks |
130+| `POST /api/v1/runtimes/checkpoint` | Capture an idle runtime as a checkpoint |
131+| `POST /api/v1/runtimes/checkout` | Create a new runtime from a checkpoint |
132+ 
133+Runtime control payloads use `runtime_id` and `checkpoint_id` as their public
134+vocabulary.
128 135 
129SSE event types:136SSE event types:
130 137 
@@ -134,7 +141,7 @@ SSE event types:
134| `text_delta` | Assistant text update; includes both incremental `delta` and cumulative `snapshot` |141| `text_delta` | Assistant text update; includes both incremental `delta` and cumulative `snapshot` |
135| `tool_result` | Tool execution result summary |142| `tool_result` | Tool execution result summary |
136| `interaction_requested` | Daemon asks the TUI to show an interaction prompt |143| `interaction_requested` | Daemon asks the TUI to show an interaction prompt |
137-| `done` | Turn completed; includes token usage and session messages |144+| `done` | Turn completed; includes token usage and runtime messages |
138| `error` | Turn failed |145| `error` | Turn failed |
139| `cancelled` | Cancellation acknowledgement |146| `cancelled` | Cancellation acknowledgement |
140 147 
@@ -146,7 +153,7 @@ SSE event types:
146- Machine B's local provider/model config is still used for normal local mode and for TUI bootstrap, but remote turns execute with Machine A's daemon config.153- Machine B's local provider/model config is still used for normal local mode and for TUI bootstrap, but remote turns execute with Machine A's daemon config.
147- Use bearer auth for any daemon bound to a non-loopback interface.154- Use bearer auth for any daemon bound to a non-loopback interface.
148- For untrusted networks, prefer an SSH tunnel or TLS-terminating reverse proxy in front of the daemon.155- For untrusted networks, prefer an SSH tunnel or TLS-terminating reverse proxy in front of the daemon.
149-- Remote session state is kept in the daemon's in-memory session store. Restarting Machine A's daemon loses active remote sessions in the current implementation.156+- Remote runtime state is kept in the daemon's in-memory control-plane store. Restarting Machine A's daemon loses active remote runtimes in the current implementation.
150 157 
151---158---
152 159 
@@ -154,16 +161,16 @@ SSE event types:
154 161 
155- `/cancel` is wired through the HTTP/TUI path, but hard cancellation depends on the gateway/core exposing the active loop cancellation token through the session supervisor.162- `/cancel` is wired through the HTTP/TUI path, but hard cancellation depends on the gateway/core exposing the active loop cancellation token through the session supervisor.
156- Remote mode does not sync files from Machine A to Machine B. Tool results and file-change summaries are streamed, but filesystem operations happen only on Machine A.163- Remote mode does not sync files from Machine A to Machine B. Tool results and file-change summaries are streamed, but filesystem operations happen only on Machine A.
157-- Remote TUI is not a separate lightweight client package; it is the same `xiaoo-tui` binary running with a remote backend.164+- Remote TUI is not a separate lightweight client package; it is the same `xiaoo` binary running with a remote backend.
158 165 
159---166---
160 167 
161## 8. Quick Checklist168## 8. Quick Checklist
162 169 
1631. Machine A has daemon config and provider credentials.1701. Machine A has daemon config and provider credentials.
164-2. Machine A starts `xiaoo-app daemon --host 0.0.0.0 --port 18080`.171+2. Machine A starts `xiaoo-daemon --host 0.0.0.0 --port 18080`.
1653. Machine B can reach `http://A:18080/api/v1/health`.1723. Machine B can reach `http://A:18080/api/v1/health`.
1664. If auth is enabled, Machine B exports `XIAOO_REMOTE_TOKEN`.1734. If auth is enabled, Machine B exports `XIAOO_REMOTE_TOKEN`.
167-5. Machine B starts `xiaoo-tui`.174+5. Machine B starts `xiaoo`.
1686. In TUI, run `/remote http://A:18080`.1756. In TUI, run `/remote http://A:18080`.
1697. Send a message and confirm the status bar shows `Remote: http://A:18080`.1767. Send a message and confirm the status bar shows `Remote: http://A:18080`.
Adocs/runtime_checkpoint.md+193-0
@@ -0,0 +1,193 @@
1+# Runtime Checkpoint Control
2+ 
3+This document records the current control-plane model for runtime checkpoint and
4+checkout.
5+ 
6+## Layering
7+ 
8+The external API is runtime-oriented. Callers control a runtime object by using
9+`runtime_id` and `checkpoint_id`; they do not need to know which session or
10+backend instance is carrying that runtime.
11+ 
12+Internally, the current v1 implementation uses this layering:
13+ 
14+| Layer | Current role | External exposure |
15+| --- | --- | --- |
16+| Runtime | Control-plane object used for checkpoint and checkout | Exposed as `runtime_id` and `RuntimeRecord` |
17+| Session | Gateway execution state, conversation identity, lifecycle state, handle state, and resolved agent runtime snapshot | Mostly hidden behind runtime APIs; v1 uses `runtime_id == session_id` |
18+| Backend | Operation substrate for file, exec, search, export, and provider snapshots | Internal; backend ids are not returned by `RuntimeRecord` |
19+ 
20+`RuntimeRecord` is derived from `SessionRecord`:
21+ 
22+```rust
23+pub struct RuntimeRecord {
24+ pub runtime_id: String,
25+ pub conversation_id: String,
26+ pub sender_id: String,
27+ pub status: SessionLifecycleStatus,
28+ pub created_at_ms: u64,
29+ pub updated_at_ms: u64,
30+}
31+```
32+ 
33+In v1, `runtime_id` is the same value as `SessionRecord.session_id`. The
34+backend binding is kept inside `SessionRecord.backend_instance` and the
35+`BackendManager` session index, which maps a session/runtime id to a backend id.
36+This keeps backend placement and provider-native ids out of the public runtime
37+record.
38+ 
39+## Public Runtime API
40+ 
41+The daemon exposes runtime checkpoint control through the protected HTTP API.
42+Bearer authentication applies when `[http]` auth is configured.
43+ 
44+| Endpoint | Request | Response | Meaning |
45+| --- | --- | --- | --- |
46+| `POST /api/v1/runtimes/checkpoint` | `RuntimeCheckpointRequest` | `RuntimeCheckpointResult` | Capture the current idle runtime as a checkpoint |
47+| `POST /api/v1/runtimes/checkpoint/delete-snapshot` | `RuntimeCheckpointSnapshotDeleteRequest` | `RuntimeCheckpointSnapshotDeleteResult` | Delete the provider snapshot/template referenced by a checkpoint |
48+| `POST /api/v1/runtimes/checkout` | `RuntimeCheckoutRequest` | `RuntimeCheckoutResult` | Create a new runtime from an existing checkpoint |
49+ 
50+`RuntimeCheckpointRequest` contains `runtime_id`, optional `name`, and optional
51+`metadata`. The result returns a generated `checkpoint_id`, the public
52+`RuntimeRecord`, and the previous checkpoint head for the same runtime when one
53+exists.
54+ 
55+`RuntimeCheckoutRequest` contains `checkpoint_id`, optional `conversation_id`,
56+optional `sender_id`, and optional `metadata`. Checkout always creates a new
57+runtime branch in v1. The caller cannot provide the child runtime id; the service
58+generates it from the source runtime id plus a UUID suffix.
59+ 
60+`RuntimeCheckpointSnapshotDeleteRequest` contains `checkpoint_id`. The service
61+looks up the checkpoint's internal `BackendCheckpointRef` and deletes only the
62+provider snapshot recorded there; callers cannot provide an arbitrary provider
63+template id. The checkpoint record remains in the process-local store, but its
64+provider snapshot id is cleared after successful or already-deleted provider
65+cleanup.
66+ 
67+## Runtime Checkpoint Shape
68+ 
69+The in-memory checkpoint store records:
70+ 
71+- `checkpoint_id`
72+- `runtime_id`
73+- `parent_checkpoint_id`
74+- a `SessionRecord` snapshot
75+- an optional `BackendCheckpointRef`
76+- creation time, name, and metadata
77+ 
78+The store is process-local in v1. Restarting the daemon loses runtime
79+checkpoints unless a later persistence layer is added.
80+ 
81+Checkpoint and checkout require the source runtime to be idle. A running or busy
82+session returns `SessionBusy` because the session snapshot and backend snapshot
83+must describe a stable point in time.
84+ 
85+## Backend Checkpoint Behavior
86+ 
87+Backend checkpointing is internal to runtime checkpointing.
88+ 
89+`BackendManager::checkpoint_backend` accepts either a `backend_id` or a
90+`session_id`. Runtime checkpoint uses the session/runtime id path so it can stay
91+above the backend layer.
92+ 
93+For E2B backends, checkpoint creates a provider snapshot by calling the E2B
94+snapshot API and stores the returned provider snapshot id inside
95+`BackendCheckpointRef.provider_snapshot_id`. Checkout uses that snapshot id to
96+create a new E2B-backed backend for the child runtime.
97+ 
98+Providers without snapshot checkout support return `UnsupportedBackend` for
99+checkout. The current v1 implementation does not implement local restore.
100+ 
101+## E2B Sandbox Timing
102+ 
103+E2B runtime checkpoint and checkout include provider-side sandbox work:
104+ 
105+| Operation | Provider work | Notes |
106+| --- | --- | --- |
107+| `POST /api/v1/runtimes/checkpoint` | Calls the E2B snapshot API for the source sandbox when the backend is dirty or has no reusable checkpoint | Clean backends with an existing checkpoint can reuse the prior `BackendCheckpointRef` and avoid a new provider snapshot |
108+| `POST /api/v1/runtimes/checkout` | Starts a new E2B sandbox from the provider snapshot and binds it to the child runtime id | The child runtime receives a generated id; callers cannot provide it in v1 |
109+| `POST /api/v1/runtimes/checkpoint/delete-snapshot` | Deletes the E2B snapshot/template by calling the E2B delete-template API | This is explicit cleanup for snapshots the caller no longer needs for future checkout |
110+| `POST /api/v1/runtimes/close` | Releases the runtime backend and deletes the E2B sandbox when no runtimes remain bound to that backend | Close time is separate from checkpoint/checkout timing |
111+ 
112+Use `curl -w '%{time_total}'` around the checkpoint and checkout requests when
113+measuring from a client. That value is end-to-end HTTP latency as observed by the
114+caller; it includes daemon bookkeeping and E2B provider calls, but not the LLM
115+turns needed to prepare or validate the runtime state.
116+ 
117+One local smoke run against an E2B backend on 2026-06-13 measured:
118+ 
119+| Operation | Measured `time_total` |
120+| --- | ---: |
121+| Runtime checkpoint | `1.516061s` |
122+| Runtime checkout | `2.335461s` |
123+ 
124+These numbers are operational examples rather than SLA values. They can vary
125+with E2B service latency, network path, snapshot size, template cold/warm state,
126+and daemon host load.
127+ 
128+## Dirty Tracking
129+ 
130+Backend dirty tracking lives in `apps/shared/src/backend/dirty_write.rs`. It is
131+a conservative operation-level invalidation wrapper, not a file diff engine.
132+ 
133+The backend is marked dirty after these successful operations:
134+ 
135+- `write_bytes`
136+- `create_dir_all`
137+- `temp_path`
138+- `exec`, once it returns `Ok(ExecResult)`, even if the process exit code is
139+ non-zero
140+ 
141+Read-only operations do not mark the backend dirty:
142+ 
143+- `stat`
144+- `read_bytes`
145+- `search`
146+- `export`
147+ 
148+If a backend is clean and already has a `BackendCheckpointRef`, a new backend
149+checkpoint reuses that ref. If it is dirty or has no previous checkpoint ref,
150+the manager creates a new backend checkpoint and clears the dirty flag.
151+ 
152+## Checkout Versus Resume
153+ 
154+`checkout_runtime` and `resume_session` are different operations:
155+ 
156+| Operation | Input | Creates new id | Backend action | Use case |
157+| --- | --- | --- | --- | --- |
158+| `checkout_runtime` | `checkpoint_id` | Yes, a generated child `runtime_id` | Optional backend checkout from `BackendCheckpointRef` | Branch from a stable checkpoint |
159+| `resume_session` | existing `session_id` | No | No checkpoint or backend checkout | Reattach to an existing live session handle |
160+ 
161+Checkout is non-destructive in v1. It copies the checkpointed session context,
162+optionally overrides `conversation_id` and `sender_id`, binds a checked-out
163+backend when the checkpoint has one, saves the child session, and registers the
164+checkpoint as the child runtime head.
165+ 
166+Resume only returns the current snapshot of an existing session handle. It does
167+not create a checkpoint, does not create a branch, and does not change backend
168+placement.
169+ 
170+## Compatibility Notes
171+ 
172+`SessionForkRequest`, `SessionForkResult`, and backend `fork_backend` shapes
173+still exist for compatibility and tests. Conceptually, fork is now modeled as:
174+ 
175+```text
176+checkpoint runtime -> checkout runtime
177+```
178+ 
179+The HTTP router currently exposes the runtime checkpoint/checkout routes. New
180+callers should use runtime checkpoint APIs directly instead of relying on a
181+session-level fork concept.
182+ 
183+## Module Layout
184+ 
185+- Runtime checkpoint API types live at `apps/shared/src/runtime_checkpoint.rs`
186+ and are re-exported from `xiaoo_shared` crate root.
187+- Session-scoped runtime assembly and resolution live under
188+ `apps/shared/src/gateway/session_runtime/`.
189+- Backend manager types live under `apps/shared/src/backend/`; shared backend
190+ request/result shapes are grouped in `apps/shared/src/backend/base.rs`.
191+- Provider-specific code may still use provider-native words such as sandbox
192+ when talking to E2B or Conch. The xiaoO manager-facing layer uses backend and
193+ runtime terminology.
Mdocs/skill_usage.md+9-9
@@ -70,27 +70,27 @@ Use grep and file_read to examine the code, then provide a structured report.
70 70 
71```bash71```bash
72# List installed skills72# List installed skills
73-xiaoo skill list73+xiaoo --cli skill list
74 74 
75# Show skill details and prompt content75# Show skill details and prompt content
76-xiaoo skill show <name>76+xiaoo --cli skill show <name>
77 77 
78# Run a security audit on a skill directory78# Run a security audit on a skill directory
79-xiaoo skill audit <path>79+xiaoo --cli skill audit <path>
80 80 
81# Install from a local directory (auto-audit)81# Install from a local directory (auto-audit)
82-xiaoo skill install ./my-skill/82+xiaoo --cli skill install ./my-skill/
83 83 
84# Install from a Git repository84# Install from a Git repository
85-xiaoo skill install https://github.com/user/my-skill.git85+xiaoo --cli skill install https://github.com/user/my-skill.git
86 86 
87# Remove an installed skill87# Remove an installed skill
88-xiaoo skill remove <name>88+xiaoo --cli skill remove <name>
89```89```
90 90 
91### Built-in Skills91### Built-in Skills
92 92 
93-Builtin skills are automatically installed when you run `cargo install --path apps/xiaoo-app`. They provide security policy enforcement and other built-in capabilities, and are loaded with highest priority by the runtime.93+Builtin skills are automatically installed when you run `cargo install --path apps/endside`. They provide security policy enforcement and other built-in capabilities, and are loaded with highest priority by the runtime.
94 94 
95**Installation locations** (automatic fallback):95**Installation locations** (automatic fallback):
96- **System level** (preferred): `/usr/lib/.xiaoo/skills/` - requires root privileges96- **System level** (preferred): `/usr/lib/.xiaoo/skills/` - requires root privileges
@@ -108,7 +108,7 @@ Builtin skills are automatically installed when you run `cargo install --path ap
108> - Without these skills, security features and other capabilities may be unavailable.108> - Without these skills, security features and other capabilities may be unavailable.
109>109>
110> **For system-wide installation** (recommended for multi-user environments):110> **For system-wide installation** (recommended for multi-user environments):
111-> - Run `cargo install` with root privileges: `sudo cargo install --path apps/xiaoo-app`111+> - Run `cargo install` with root privileges: `sudo cargo install --path apps/endside`
112 112 
113To remove builtin skills:113To remove builtin skills:
114 114 
@@ -125,7 +125,7 @@ rm -rf ~/.xiaoo/skills/block-analyzer
125To completely uninstall all skills along with the application:125To completely uninstall all skills along with the application:
126 126 
127```bash127```bash
128-cargo uninstall xiaoo-app128+cargo uninstall xiaoo-endside
129sudo rm -rf /usr/lib/.xiaoo/skills129sudo rm -rf /usr/lib/.xiaoo/skills
130rm -rf ~/.xiaoo/skills130rm -rf ~/.xiaoo/skills
131```131```
Mdocs/telegram_deploy.md+10-10
@@ -47,7 +47,7 @@ Webhook and polling are mutually exclusive for the same bot token. If a webhook
47 47 
48- A Telegram account48- A Telegram account
49- A Telegram bot created through `@BotFather`49- A Telegram bot created through `@BotFather`
50-- A Linux or macOS host where you can install and run `xiaoo-app daemon`50+- A Linux or macOS host where you can install and run `xiaoo-daemon`
51- Rust toolchain and Cargo available on that host, unless you already have a built binary51- Rust toolchain and Cargo available on that host, unless you already have a built binary
52- Outbound network access from xiaoO to:52- Outbound network access from xiaoO to:
53 - `https://api.telegram.org`53 - `https://api.telegram.org`
@@ -145,27 +145,27 @@ Example:
145git clone <your-repo-url> /opt/xiaoo/src145git clone <your-repo-url> /opt/xiaoo/src
146cd /opt/xiaoo/src146cd /opt/xiaoo/src
147git checkout telegram147git checkout telegram
148-cargo build -p xiaoo-app148+cargo build -p xiaoo-serverside
149```149```
150 150 
151After a successful build, the binary will usually be created at:151After a successful build, the binary will usually be created at:
152 152 
153```text153```text
154-target/debug/xiaoo-app154+target/debug/xiaoo-daemon
155```155```
156 156 
157For a long-running service, install that binary to a stable runtime path:157For a long-running service, install that binary to a stable runtime path:
158 158 
159```bash159```bash
160mkdir -p /opt/xiaoo/bin160mkdir -p /opt/xiaoo/bin
161-install -m 755 target/debug/xiaoo-app /opt/xiaoo/bin/xiaoo-app161+install -m 755 target/debug/xiaoo-daemon /opt/xiaoo/bin/xiaoo-daemon
162```162```
163 163 
164If you prefer release builds:164If you prefer release builds:
165 165 
166```bash166```bash
167-cargo build -p xiaoo-app --release167+cargo build -p xiaoo-serverside --release
168-install -m 755 target/release/xiaoo-app /opt/xiaoo/bin/xiaoo-app168+install -m 755 target/release/xiaoo-daemon /opt/xiaoo/bin/xiaoo-daemon
169```169```
170 170 
171## 5. Prepare Runtime Directories171## 5. Prepare Runtime Directories
@@ -185,7 +185,7 @@ mkdir -p /var/lib/xiaoo/agents/main
185Recommended layout:185Recommended layout:
186 186 
187```text187```text
188-/opt/xiaoo/bin/xiaoo-app188+/opt/xiaoo/bin/xiaoo-daemon
189/opt/xiaoo/config/config.toml189/opt/xiaoo/config/config.toml
190/opt/xiaoo/config/xiaoo.env190/opt/xiaoo/config/xiaoo.env
191/opt/xiaoo/app191/opt/xiaoo/app
@@ -492,7 +492,7 @@ After=network.target
492Type=simple492Type=simple
493WorkingDirectory=/opt/xiaoo493WorkingDirectory=/opt/xiaoo
494EnvironmentFile=/opt/xiaoo/config/xiaoo.env494EnvironmentFile=/opt/xiaoo/config/xiaoo.env
495-ExecStart=/opt/xiaoo/bin/xiaoo-app daemon --config /opt/xiaoo/config/config.toml --host 127.0.0.1 --port 18080495+ExecStart=/opt/xiaoo/bin/xiaoo-daemon --config /opt/xiaoo/config/config.toml --host 127.0.0.1 --port 18080
496Restart=always496Restart=always
497RestartSec=5497RestartSec=5
498 498 
@@ -558,7 +558,7 @@ PY
558)"558)"
559 559 
560cd "/path/to/xiaoO"560cd "/path/to/xiaoO"
561-exec cargo run -p xiaoo-app --bin xiaoo-app -- daemon \561+exec cargo run -p xiaoo-serverside --bin xiaoo-daemon -- \
562 --config "$HOME/.config/xiaoo/config.toml" \562 --config "$HOME/.config/xiaoo/config.toml" \
563 --host 127.0.0.1 \563 --host 127.0.0.1 \
564 --port 18080564 --port 18080
@@ -748,7 +748,7 @@ Non-text updates are ignored.
748For a clean webhook production deployment, use this structure:748For a clean webhook production deployment, use this structure:
749 749 
750```text750```text
751-/opt/xiaoo/bin/xiaoo-app751+/opt/xiaoo/bin/xiaoo-daemon
752/opt/xiaoo/config/config.toml752/opt/xiaoo/config/config.toml
753/opt/xiaoo/config/xiaoo.env753/opt/xiaoo/config/xiaoo.env
754/opt/xiaoo/app754/opt/xiaoo/app
Mdocs/tui_config.md+4-4
@@ -1,6 +1,6 @@
1# TUI Configuration Guide1# TUI Configuration Guide
2 2 
3-> **Note**: This document focuses on TUI (`xiaoo-tui`) specific configuration items.3+> **Note**: This document focuses on TUI (`xiaoo`) specific configuration items.
4>4>
5> For **common configuration items** (llm, subagent, skills, compact, trace, hooker, etc.), please refer to [Configuration File Guide](./config_file_guide.md).5> For **common configuration items** (llm, subagent, skills, compact, trace, hooker, etc.), please refer to [Configuration File Guide](./config_file_guide.md).
6 6 
@@ -293,13 +293,13 @@ write = true
293 293 
294```bash294```bash
295# Local mode295# Local mode
296-xiaoo-tui296+xiaoo
297 297 
298# Use specific configuration file298# Use specific configuration file
299-xiaoo-tui --config /path/to/config.toml299+xiaoo --config /path/to/config.toml
300 300 
301# Debug mode301# Debug mode
302-xiaoo-tui --debug302+xiaoo --debug
303```303```
304 304 
305---305---
Mdocs/vault_secrets_design.md+7-7
@@ -295,12 +295,12 @@ apps/vault/src/
295└── hsm.rs # HSM PKCS#11 接口(预留)295└── hsm.rs # HSM PKCS#11 接口(预留)
296```296```
297 297 
298-### 4.2 apps/xiaoo-app298+### 4.2 apps/endside
299 299 
300Secrets 存储和加载逻辑。300Secrets 存储和加载逻辑。
301 301 
302```302```
303-apps/xiaoo-app/src/303+apps/endside/src/
304├── llm_secrets.rs # 本地加密存储管理304├── llm_secrets.rs # 本地加密存储管理
305│ # - auto_save_from_env()305│ # - auto_save_from_env()
306│ # - load_llm_secrets_to_memory()306│ # - load_llm_secrets_to_memory()
@@ -339,7 +339,7 @@ apps/xiaoo-app/src/
339│ ┌─────────────────────────────────────────────────────────────┐ │339│ ┌─────────────────────────────────────────────────────────────┐ │
340│ │ xiaoo 进程 │ │340│ │ xiaoo 进程 │ │
341│ │ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │ │341│ │ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │ │
342-│ │ │ xiaoo-tui │ │ xiaoo │ │ xiaoo-app │ │ │342+│ │ │ xiaoo │ │ xiaoo --cli │ │ xiaoo-daemon │ │ │
343│ │ └───────────────┘ └───────────────┘ └───────────────┘ │ │343│ │ └───────────────┘ └───────────────┘ └───────────────┘ │ │
344│ │ │ │ │ │ │344│ │ │ │ │ │ │
345│ │ └────────────────┼──────────────────┘ │ │345│ │ └────────────────┼──────────────────┘ │ │
@@ -448,17 +448,17 @@ api_key_env = "OPENROUTER_API_KEY"
448 448 
449```bash449```bash
450# 默认编译 (WhiteBox + AES-GCM,仅测试环境)450# 默认编译 (WhiteBox + AES-GCM,仅测试环境)
451-cargo build --release --bin xiaoo-tui451+cargo build --release -p xiaoo-endside --bin xiaoo
452 452 
453# 启用 SDF 国密 (仅鲲鹏服务器)453# 启用 SDF 国密 (仅鲲鹏服务器)
454-cargo build --release --bin xiaoo-tui --features tee_sdf454+cargo build --release -p xiaoo-endside --bin xiaoo --features tee_sdf
455```455```
456 456 
457#### 单元测试457#### 单元测试
458 458 
459```bash459```bash
460cargo test --package vault460cargo test --package vault
461-cargo test --package xiaoo-app461+cargo test --package xiaoo-shared
462```462```
463 463 
464#### 功能验证464#### 功能验证
@@ -469,7 +469,7 @@ export OPENROUTER_API_KEY='sk-or-v1-xxx'
469export USE_SDF=false469export USE_SDF=false
470 470 
471# 运行 TUI (vault.enabled=true 时自动保存)471# 运行 TUI (vault.enabled=true 时自动保存)
472-./target/release/xiaoo-tui --config config.toml472+./target/release/xiaoo --config config.toml
473 473 
474# 检查加密文件474# 检查加密文件
475hexdump -C ~/.xiaoo/config/llm_secrets.json | head475hexdump -C ~/.xiaoo/config/llm_secrets.json | head
Mplugins/hookers/audit_agent/audit_policy_checker/audit_policy_checker/config.py+2-2
@@ -177,7 +177,7 @@ def get_default_config() -> Config:
177 return _default_config177 return _default_config
178 178 
179 179 
180-# Provider 默认 base_url 映射(与 xiaoo-app/src/tui/services/provider.rs 保持一致)180+# Provider 默认 base_url 映射(与 apps/endside/src/services/provider.rs 保持一致)
181PROVIDER_BASE_URLS: dict[str, str] = {181PROVIDER_BASE_URLS: dict[str, str] = {
182 "openai": "https://api.openai.com/v1",182 "openai": "https://api.openai.com/v1",
183 "openrouter": "https://openrouter.ai/api/v1",183 "openrouter": "https://openrouter.ai/api/v1",
@@ -204,7 +204,7 @@ PROVIDER_BASE_URLS: dict[str, str] = {
204 "glm-cn": "https://open.bigmodel.cn/api/paas/v4",204 "glm-cn": "https://open.bigmodel.cn/api/paas/v4",
205}205}
206 206 
207-# Provider 默认 api_key_env 映射(与 xiaoo-app 保持一致)207+# Provider 默认 api_key_env 映射(与 xiaoo endside 保持一致)
208PROVIDER_API_KEY_ENVS: dict[str, str] = {208PROVIDER_API_KEY_ENVS: dict[str, str] = {
209 "openai": "OPENAI_API_KEY",209 "openai": "OPENAI_API_KEY",
210 "anthropic": "ANTHROPIC_API_KEY",210 "anthropic": "ANTHROPIC_API_KEY",