已合并
Merge feat/super-gateway into dev #210
hypothesier创建于 6月18日
Merge feat/super-gateway into dev #210
已合并
共 168 个文件变更+8502-2079
| @@ -4810,70 +4810,6 @@ version = "0.13.2" | |||
| 4810 | source = "registry+https://github.com/rust-lang/crates.io-index" | 4810 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 4811 | checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" | 4811 | checksum = "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]] |
| 4878 | name = "xiaoo-core" | 4814 | name = "xiaoo-core" |
| 4879 | version = "0.1.0" | 4815 | version = "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]] |
| 4902 | name = "xml5ever" | 4962 | name = "xml5ever" |
| 4903 | version = "0.38.0" | 4963 | version = "0.38.0" |
| @@ -1,6 +1,8 @@ | |||
| 1 | [workspace] | 1 | [workspace] |
| 2 | members = [ | 2 | members = [ |
| 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", |
| @@ -46,7 +46,7 @@ The runtime also includes a layered memory and adaptive context-compression syst | |||
| 46 | ```bash | 46 | ```bash |
| 47 | git clone https://gitcode.com/openeuler/xiaoO.git | 47 | git clone https://gitcode.com/openeuler/xiaoO.git |
| 48 | cd xiaoO | 48 | cd xiaoO |
| 49 | -cargo install --path apps/xiaoo-app | 49 | +cargo install --path apps/endside |
| 50 | ``` | 50 | ``` |
| 51 | 51 | ||
| 52 | This installs the application binaries into `~/.cargo/bin` and attempts to install builtin skills. Make sure `~/.cargo/bin` is in your `PATH`. | 52 | This 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 | ### Uninstallation | 65 | ### Uninstallation |
| 66 | 66 | ||
| 67 | ```bash | 67 | ```bash |
| 68 | # Uninstall binaries | 68 | # Uninstall binaries |
| 69 | -cargo uninstall xiaoo-app | 69 | +cargo uninstall xiaoo-endside |
| 70 | 70 | ||
| 71 | # Remove the guardian skill (system level requires root) | 71 | # Remove the guardian skill (system level requires root) |
| 72 | sudo rm -rf /usr/lib/.xiaoo/skills/xiaoo-guardian | 72 | sudo rm -rf /usr/lib/.xiaoo/skills/xiaoo-guardian |
| @@ -131,10 +131,10 @@ Run xiaoO: | |||
| 131 | 131 | ||
| 132 | ```bash | 132 | ```bash |
| 133 | # Terminal UI | 133 | # Terminal UI |
| 134 | -xiaoo-tui | 134 | +xiaoo |
| 135 | 135 | ||
| 136 | # Single-shot CLI | 136 | # 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 | ||
| 140 | Example CLI output: | 140 | Example CLI output: |
| @@ -175,7 +175,7 @@ More details are available in [Memory & Context Compression](./docs/memory_conte | |||
| 175 | The 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: | 175 | The 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 | ```bash | 177 | ```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 | ||
| 181 | Provider 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. | 181 | Provider 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_ | |||
| 185 | xiaoO loads skills from `~/.xiaoo/skills` by default. Each skill is a reusable instruction pack backed by `SKILL.md` or `SKILL.toml`. | 185 | xiaoO loads skills from `~/.xiaoo/skills` by default. Each skill is a reusable instruction pack backed by `SKILL.md` or `SKILL.toml`. |
| 186 | 186 | ||
| 187 | ```bash | 187 | ```bash |
| 188 | -xiaoo skill list | 188 | +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.git | 192 | +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 | See [docs/skill_usage.md](./docs/skill_usage.md) for the full skill workflow. | 196 | See [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 | ```bash | 202 | ```bash |
| 203 | # Default address: 0.0.0.0:18080 | 203 | # Default address: 0.0.0.0:18080 |
| 204 | -xiaoo-app daemon | 204 | +xiaoo-daemon |
| 205 | 205 | ||
| 206 | # Specify configuration file, host, and port | 206 | # Specify configuration file, host, and port |
| 207 | -xiaoo-app daemon --config /path/to/config.toml --host 127.0.0.1 --port 18080 | 207 | +xiaoo-daemon --config /path/to/config.toml --host 127.0.0.1 --port 18080 |
| 208 | ``` | 208 | ``` |
| 209 | 209 | ||
| 210 | HTTP requests can select an agent role preset by passing `agent` in the JSON body: | 210 | HTTP requests can select an agent role preset by passing `agent` in the JSON body: |
| @@ -46,7 +46,7 @@ xiaoO 的运行时还内置了分层记忆和自适应上下文压缩系统, | |||
| 46 | ```bash | 46 | ```bash |
| 47 | git clone https://gitcode.com/openeuler/xiaoO.git | 47 | git clone https://gitcode.com/openeuler/xiaoO.git |
| 48 | cd xiaoO | 48 | cd xiaoO |
| 49 | -cargo install --path apps/xiaoo-app | 49 | +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 | ```bash | 67 | ```bash |
| 68 | # 卸载二进制文件 | 68 | # 卸载二进制文件 |
| 69 | -cargo uninstall xiaoo-app | 69 | +cargo uninstall xiaoo-endside |
| 70 | 70 | ||
| 71 | # 删除系统级 guardian 技能(需要 root 权限) | 71 | # 删除系统级 guardian 技能(需要 root 权限) |
| 72 | sudo rm -rf /usr/lib/.xiaoo/skills/xiaoo-guardian | 72 | sudo rm -rf /usr/lib/.xiaoo/skills/xiaoo-guardian |
| @@ -131,10 +131,10 @@ api_key_env = "LLM_API_KEY" | |||
| 131 | 131 | ||
| 132 | ```bash | 132 | ```bash |
| 133 | # 终端 UI | 133 | # 终端 UI |
| 134 | -xiaoo-tui | 134 | +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 | ||
| 140 | CLI 输出示例: | 140 | CLI 输出示例: |
| @@ -175,7 +175,7 @@ CLI 输出示例: | |||
| 175 | TUI 状态栏会显示当前值:`Think off/high/max`。按 `Ctrl+T` 可按 `off -> high -> max -> off` 为下一轮切换强度。CLI 模式可使用: | 175 | TUI 状态栏会显示当前值:`Think off/high/max`。按 `Ctrl+T` 可按 `off -> high -> max -> off` 为下一轮切换强度。CLI 模式可使用: |
| 176 | 176 | ||
| 177 | ```bash | 177 | ```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 | ||
| 181 | Provider 映射采用 best-effort 策略:OpenAI-compatible provider 在 `high` 和 `max` 时接收 `reasoning_effort`;Anthropic 接收 `thinking.budget_tokens`;Gemini 接收 `thinkingConfig.thinkingBudget`;不支持该能力的 provider 会忽略此设置。`off` 会省略 provider 专用推理字段,使默认请求保留各 provider 的原生行为。 | 181 | Provider 映射采用 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` | |||
| 185 | xiaoO 默认从 `~/.xiaoo/skills` 加载技能。每个技能都是一个由 `SKILL.md` 或 `SKILL.toml` 描述的可复用指令包。 | 185 | xiaoO 默认从 `~/.xiaoo/skills` 加载技能。每个技能都是一个由 `SKILL.md` 或 `SKILL.toml` 描述的可复用指令包。 |
| 186 | 186 | ||
| 187 | ```bash | 187 | ```bash |
| 188 | -xiaoo skill list | 188 | +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.git | 192 | +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 | ```bash | 202 | ```bash |
| 203 | # 默认监听地址:0.0.0.0:18080 | 203 | # 默认监听地址:0.0.0.0:18080 |
| 204 | -xiaoo-app daemon | 204 | +xiaoo-daemon |
| 205 | 205 | ||
| 206 | # 指定配置文件、监听地址和端口 | 206 | # 指定配置文件、监听地址和端口 |
| 207 | -xiaoo-app daemon --config /path/to/config.toml --host 127.0.0.1 --port 18080 | 207 | +xiaoo-daemon --config /path/to/config.toml --host 127.0.0.1 --port 18080 |
| 208 | ``` | 208 | ``` |
| 209 | 209 | ||
| 210 | HTTP 请求可在 JSON body 中通过 `agent` 选择 Agent 角色预设: | 210 | HTTP 请求可在 JSON body 中通过 `agent` 选择 Agent 角色预设: |
| @@ -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 | ||
| @@ -1,4 +1,4 @@ | |||
| 1 | -use crate::gateway::backend::GatewayBackendConfig; | 1 | +use crate::backend::GatewayBackendConfig; |
| 2 | use agent_types::hook::HookerRegistryConfig; | 2 | use agent_types::hook::HookerRegistryConfig; |
| 3 | use agent_types::ReasoningEffort; | 3 | use agent_types::ReasoningEffort; |
| 4 | use serde::de::DeserializeOwned; | 4 | use serde::de::DeserializeOwned; |
| @@ -1,6 +1,11 @@ | |||
| 1 | use std::path::PathBuf; | 1 | use std::path::PathBuf; |
| 2 | use std::sync::Arc; | 2 | use 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 | +}; | ||
| 4 | use agent_contracts::{LoopEventSink, SkillRegistry}; | 9 | use agent_contracts::{LoopEventSink, SkillRegistry}; |
| 5 | use clap::Parser; | 10 | use clap::Parser; |
| 6 | use operation_backend::process_group::ProcessGroupCleanupGuard; | 11 | use operation_backend::process_group::ProcessGroupCleanupGuard; |
| @@ -8,15 +13,11 @@ use serde_json::Value; | |||
| 8 | use skill::audit::{audit_skill_directory, SkillAuditOptions}; | 13 | use skill::audit::{audit_skill_directory, SkillAuditOptions}; |
| 9 | use skill::registry::FileSkillRegistry; | 14 | use skill::registry::FileSkillRegistry; |
| 10 | use skill::types::config::SkillsConfig; | 15 | use 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 | ||
| 22 | use agent_types::common::ids::AgentId; | 23 | use agent_types::common::ids::AgentId; |
| @@ -27,7 +28,7 @@ use agent_types::ReasoningEffort; | |||
| 27 | const DEFAULT_SYSTEM_PROMPT: &str = include_str!("../prompts/cli_default_system_prompt.txt"); | 28 | const DEFAULT_SYSTEM_PROMPT: &str = include_str!("../prompts/cli_default_system_prompt.txt"); |
| 28 | 29 | ||
| 29 | 30 | ||
| 30 | -#[command(name = "xiaoo", about = "XiaoO AgentLoop CLI")] | 31 | +#[command(name = "xiaoo --cli", about = "XiaoO AgentLoop CLI")] |
| 31 | struct Args { | 32 | struct Args { |
| 32 | /// Path to config file (default: ~/.config/xiaoo/config.toml) | 33 | /// Path to config file (default: ~/.config/xiaoo/config.toml) |
| 33 | 34 | ||
| @@ -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: config | 859 | subagent_roles: config |
| 848 | .subagent | 860 | .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 the | 922 | // 7. Run turn via gateway session service, then explicitly close the |
| @@ -1,4 +1,5 @@ | |||
| 1 | pub mod config; | 1 | pub mod config; |
| 2 | +pub mod entry; | ||
| 2 | 3 | ||
| 3 | use std::sync::{Arc, Mutex}; | 4 | use 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 | } |
| @@ -1,14 +1,18 @@ | |||
| 1 | use futures_util::StreamExt; | 1 | use futures_util::StreamExt; |
| 2 | use serde::{Deserialize, Serialize}; | 2 | use serde::{Deserialize, Serialize}; |
| 3 | -use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}; | 3 | +use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel}; |
| 4 | 4 | ||
| 5 | use agent_types::common::ids::AgentId; | 5 | use agent_types::common::ids::AgentId; |
| 6 | use agent_types::interaction::{InteractionRequest, InteractionResponse}; | 6 | use 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}; |
| 9 | use crate::chat::{Message, ToolExecutionStatus, ToolExecutionUpdate}; | 9 | use 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 | +}; | ||
| 11 | use crate::interaction_prompt::{PromptChoice, PromptRequest, PromptResolution, UserPromptResult}; | 14 | use crate::interaction_prompt::{PromptChoice, PromptRequest, PromptResolution, UserPromptResult}; |
| 15 | +use crate::remote_sessions_service::record_remote_session; | ||
| 12 | use crate::session_gateway::SessionTurnUpdate; | 16 | use crate::session_gateway::SessionTurnUpdate; |
| 13 | 17 | ||
| 14 | use super::runtime::GatewayRuntime; | 18 | use super::runtime::GatewayRuntime; |
| @@ -54,6 +58,7 @@ enum RemoteSseEvent { | |||
| 54 | raw_reply: String, | 58 | raw_reply: String, |
| 55 | 59 | ||
| 56 | conversation_id: String, | 60 | conversation_id: String, |
| 61 | + | ||
| 57 | 62 | ||
| 58 | session_id: String, | 63 | session_id: String, |
| 59 | 64 | ||
| @@ -70,6 +75,7 @@ enum RemoteSseEvent { | |||
| 70 | error: String, | 75 | error: String, |
| 71 | }, | 76 | }, |
| 72 | Cancelled { | 77 | Cancelled { |
| 78 | + | ||
| 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 | state | 132 | 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, health | 163 | + 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 | state | 218 | state |
| 203 | .chat_state | 219 | .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 | - | ||
| 489 | async fn post_json<T: Serialize + ?Sized>( | 519 | async 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 | 683 | ||
| 654 | mod tests { | 684 | mod tests { |
| 655 | - use super::{parse_sse_frame, take_sse_frame, RemoteSseEvent}; | 685 | + use super::{RemoteSseEvent, parse_sse_frame, take_sse_frame}; |
| 656 | 686 | ||
| 657 | 687 | ||
| 658 | fn parses_sse_frame_from_split_buffer() { | 688 | fn parses_sse_frame_from_split_buffer() { |
| @@ -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.rs→apps/endside/src/gateway_api/runtime_interaction.rs+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/tui/gateway_api/runtime_request.rs→apps/endside/src/gateway_api/runtime_request.rs+17-3
| @@ -9,8 +9,8 @@ use crate::app_state::AppState; | |||
| 9 | use crate::chat::Message; | 9 | use crate::chat::Message; |
| 10 | use crate::config::Config; | 10 | use crate::config::Config; |
| 11 | use crate::gateway::{ | 11 | use crate::gateway::{ |
| 12 | - AppTurnRequest, GatewayEntryContext, HostedSessionRuntimeConfig, SessionOpenRequest, | 12 | + AppTurnRequest, GatewayEntryContext, HostedSessionRuntimeConfig, LlmRuntimeConfig, |
| 13 | - SessionRuntimeDescriptor, | 13 | + SessionOpenRequest, SessionRuntimeDescriptor, |
| 14 | }; | 14 | }; |
| 15 | use agent_types::common::ids::AgentId; | 15 | use agent_types::common::ids::AgentId; |
| 16 | use agent_types::context::{FeatureFlags, TokenBudgetConfig}; | 16 | use agent_types::context::{FeatureFlags, TokenBudgetConfig}; |
| @@ -19,7 +19,7 @@ use tool::{load_tool_sources_with_services, ToolRuntimeServices}; | |||
| 19 | use super::runtime::GatewayRuntime; | 19 | use super::runtime::GatewayRuntime; |
| 20 | use xiaoo_core::spawn_prefetch; | 20 | use 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"); |
| 23 | const DEFAULT_SYSTEM_TOKEN_RESERVE: usize = 2048; | 23 | const DEFAULT_SYSTEM_TOKEN_RESERVE: usize = 2048; |
| 24 | 24 | ||
| 25 | impl GatewayRuntime { | 25 | impl 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 | + | ||
| 288 | fn tui_entry_context(state: &AppState) -> GatewayEntryContext { | 302 | fn 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.rs→apps/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); |
| @@ -4,8 +4,9 @@ use std::sync::{Arc, Mutex}; | |||
| 4 | use async_trait::async_trait; | 4 | use async_trait::async_trait; |
| 5 | use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender}; | 5 | use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender}; |
| 6 | 6 | ||
| 7 | +use crate::backend::BackendManager; | ||
| 7 | use crate::chat::ToolExecutionUpdate; | 8 | use crate::chat::ToolExecutionUpdate; |
| 8 | -use crate::gateway::{backend::ExternalBackendManager, InMemorySessionStore, SessionControlPlane}; | 9 | +use crate::gateway::{InMemorySessionStore, SessionControlPlane}; |
| 9 | use crate::interaction_prompt::PromptRequest; | 10 | use crate::interaction_prompt::PromptRequest; |
| 10 | 11 | ||
| 11 | use agent_types::common::ids::AgentId; | 12 | use 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 | ||
| 53 | impl Default for SessionGateway { | 54 | impl 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.rs→apps/endside/src/gateway_api/session_core.rs+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/tui/gateway_api/session_interaction.rs→apps/endside/src/gateway_api/session_interaction.rs+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/tui/gateway_api/session_sink.rs→apps/endside/src/gateway_api/session_sink.rs+0-0
文件重命名但无更改。
文件重命名但无更改。
| @@ -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 | +}; | ||
| 17 | use crate::services::turn_delete::DeleteDialog; | 21 | use crate::services::turn_delete::DeleteDialog; |
| 18 | use crate::session_snapshot_service::{ | 22 | use 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 = self | 670 | let token_env = self |
| 654 | .state | 671 | .state |
| 655 | .agent_config | 672 | .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 self | 677 | match self |
| 661 | .gateway | 678 | .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 | .await | 680 | .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_state | 1034 | .chat_state |
| 831 | .messages | 1035 | .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 | + | ||
| 1042 | fn expand_external_command( | 1255 | fn expand_external_command( |
| 1043 | trimmed: &str, | 1256 | trimmed: &str, |
| 1044 | external: &[crate::services::command_loader::ExternalCommand], | 1257 | external: &[crate::services::command_loader::ExternalCommand], |
| @@ -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", |
| @@ -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 | }; |
| 10 | use operation_backend::process_group::ProcessGroupCleanupGuard; | 10 | use operation_backend::process_group::ProcessGroupCleanupGuard; |
| 11 | +use std::env; | ||
| 12 | +use std::ffi::{OsStr, OsString}; | ||
| 11 | use std::io; | 13 | use std::io; |
| 12 | use std::path::PathBuf; | 14 | use std::path::PathBuf; |
| 13 | 15 | ||
| 14 | -pub use xiaoo_app::gateway; | 16 | +pub use xiaoo_shared::{backend, gateway}; |
| 15 | 17 | ||
| 16 | mod app; | 18 | mod app; |
| 19 | +mod cli; | ||
| 17 | mod gateway_api; | 20 | mod gateway_api; |
| 18 | mod input; | 21 | mod input; |
| 19 | mod render; | 22 | mod render; |
| @@ -30,6 +33,7 @@ pub(crate) use render::provider_dialog; | |||
| 30 | pub(crate) use render::status_panel; | 33 | pub(crate) use render::status_panel; |
| 31 | pub(crate) use render::theme; | 34 | pub(crate) use render::theme; |
| 32 | pub(crate) use services::provider as provider_service; | 35 | pub(crate) use services::provider as provider_service; |
| 36 | +pub(crate) use services::remote_sessions as remote_sessions_service; | ||
| 33 | pub(crate) use services::session_snapshot as session_snapshot_service; | 37 | pub(crate) use services::session_snapshot as session_snapshot_service; |
| 34 | pub(crate) use services::skills as skills_service; | 38 | pub(crate) use services::skills as skills_service; |
| 35 | pub(crate) use services::workspace as workspace_service; | 39 | pub(crate) use services::workspace as workspace_service; |
| @@ -41,12 +45,31 @@ pub(crate) use support::debug_log; | |||
| 41 | pub(crate) use support::error_log; | 45 | pub(crate) use support::error_log; |
| 42 | 46 | ||
| 43 | const CONFIG_ENV_VAR: &str = "XIAOO_CONFIG"; | 47 | const CONFIG_ENV_VAR: &str = "XIAOO_CONFIG"; |
| 48 | +const CLI_SWITCH: &str = "--cli"; | ||
| 44 | 49 | ||
| 45 | 50 | ||
| 46 | async fn main() -> Result<()> { | 51 | async 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).await | 81 | run_tui(config, config_arg.path).await |
| 59 | } | 82 | } |
| 60 | 83 | ||
| 84 | + | ||
| 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 | + | ||
| 61 | struct ConfigArg { | 121 | struct 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 = args | 128 | + 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 | + | ||
| 439 | +mod tests { | ||
| 440 | + use super::{classify_args, EntryInvocation}; | ||
| 441 | + use std::ffi::OsString; | ||
| 442 | + | ||
| 443 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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.txt→apps/endside/src/prompts/cli_default_system_prompt.txt+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/prompts/subagent_system_prompt.txt→apps/endside/src/prompts/subagent_system_prompt.txt+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/prompts/tui_default_system_prompt.txt→apps/endside/src/prompts/tui_default_system_prompt.txt+0-0
文件重命名但无更改。
Rapps/xiaoo-app/src/tui/render/interaction_prompt.rs→apps/endside/src/render/interaction_prompt.rs+0-0
文件重命名但无更改。
| @@ -12,10 +12,14 @@ use crate::app::App; | |||
| 12 | use crate::app_state::{ApiKeyDialogState, InputMode, SandboxDialog}; | 12 | use crate::app_state::{ApiKeyDialogState, InputMode, SandboxDialog}; |
| 13 | use crate::interaction_prompt::{interaction_prompt_outer_height, render_interaction_prompt}; | 13 | use crate::interaction_prompt::{interaction_prompt_outer_height, render_interaction_prompt}; |
| 14 | use crate::provider_dialog::ProviderDialog; | 14 | use crate::provider_dialog::ProviderDialog; |
| 15 | +use crate::remote_sessions_service::{ | ||
| 16 | + daemon_display, format_remote_time, RemoteSessionDialog, RemoteSessionDialogEntry, | ||
| 17 | + RemoteSessionDialogMode, | ||
| 18 | +}; | ||
| 15 | use crate::services::turn_delete::DeleteDialog; | 19 | use crate::services::turn_delete::DeleteDialog; |
| 16 | use crate::session_snapshot_service::{format_snapshot_time, SessionSnapshotDialog}; | 20 | use 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::Editing | 370 | InputMode::Editing |
| 364 | | InputMode::ProviderSelection | 371 | | InputMode::ProviderSelection |
| 365 | | InputMode::SandboxSelection | 372 | | InputMode::SandboxSelection |
| 373 | + | InputMode::RemoteSessionSelection | ||
| 366 | | InputMode::SessionSnapshotSelection | 374 | | 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, |
文件重命名但无更改。
| @@ -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 | } |
文件重命名但无更改。
文件重命名但无更改。
| @@ -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 | + | ||
| 23 | fn use_ascii_terminal_symbols() -> bool { | 30 | fn 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(|| { |
文件重命名但无更改。
| @@ -1,5 +1,6 @@ | |||
| 1 | pub(crate) mod command_loader; | 1 | pub(crate) mod command_loader; |
| 2 | pub(crate) mod provider; | 2 | pub(crate) mod provider; |
| 3 | +pub(crate) mod remote_sessions; | ||
| 3 | pub(crate) mod session_snapshot; | 4 | pub(crate) mod session_snapshot; |
| 4 | pub(crate) mod skills; | 5 | pub(crate) mod skills; |
| 5 | pub(crate) mod turn_delete; | 6 | pub(crate) mod turn_delete; |
| @@ -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 | 281 | ||
| @@ -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 | + | ||
| 13 | +pub struct RemoteSessionRecord { | ||
| 14 | + pub session_id: String, | ||
| 15 | + pub base_url: String, | ||
| 16 | + | ||
| 17 | + pub bearer_token_env: Option<String>, | ||
| 18 | + | ||
| 19 | + pub first_message_preview: Option<String>, | ||
| 20 | + pub created_at_ms: u64, | ||
| 21 | + pub last_active_at_ms: u64, | ||
| 22 | +} | ||
| 23 | + | ||
| 24 | + | ||
| 25 | +struct RemoteSessionRegistry { | ||
| 26 | + version: u32, | ||
| 27 | + | ||
| 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 | + | ||
| 41 | +pub enum RemoteSessionDialogEntry { | ||
| 42 | + Existing(RemoteSessionRecord), | ||
| 43 | + New, | ||
| 44 | +} | ||
| 45 | + | ||
| 46 | + | ||
| 47 | +pub enum RemoteSessionDialogMode { | ||
| 48 | + List, | ||
| 49 | + NewUrl, | ||
| 50 | +} | ||
| 51 | + | ||
| 52 | + | ||
| 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(®istry)?; | ||
| 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 | + | ||
| 240 | +mod tests { | ||
| 241 | + use super::{daemon_display, summarize_first_message}; | ||
| 242 | + | ||
| 243 | + | ||
| 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 | + | ||
| 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.rs→apps/endside/src/services/session_snapshot.rs+0-0
文件重命名但无更改。
文件重命名但无更改。
| @@ -6,9 +6,9 @@ use std::fs; | |||
| 6 | use std::path::{Path, PathBuf}; | 6 | use std::path::{Path, PathBuf}; |
| 7 | use std::time::{Duration, Instant}; | 7 | use std::time::{Duration, Instant}; |
| 8 | 8 | ||
| 9 | +use crate::backend::GatewayBackendConfig; | ||
| 9 | use crate::chat::{default_provider_list, merge_config_provider, ChatState, TodoMessageState}; | 10 | use crate::chat::{default_provider_list, merge_config_provider, ChatState, TodoMessageState}; |
| 10 | use crate::config::{AgentRoleConfig, Config}; | 11 | use crate::config::{AgentRoleConfig, Config}; |
| 11 | -use crate::gateway::backend::GatewayBackendConfig; | ||
| 12 | use crate::input::Input; | 12 | use crate::input::Input; |
| 13 | use crate::interaction_prompt::{InteractionPromptState, PromptRequest}; | 13 | use crate::interaction_prompt::{InteractionPromptState, PromptRequest}; |
| 14 | use crate::provider_dialog::ProviderDialog; | 14 | use 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 | } |
| @@ -1,4 +1,4 @@ | |||
| 1 | -use crate::gateway::backend::GatewayBackendConfig; | 1 | +use crate::backend::GatewayBackendConfig; |
| 2 | use agent_types::hook::HookerRegistryConfig; | 2 | use agent_types::hook::HookerRegistryConfig; |
| 3 | use agent_types::ReasoningEffort; | 3 | use agent_types::ReasoningEffort; |
| 4 | use anyhow::{bail, Context, Result}; | 4 | use anyhow::{bail, Context, Result}; |
| @@ -11,7 +11,7 @@ use std::collections::{BTreeMap, BTreeSet}; | |||
| 11 | use std::fs; | 11 | use std::fs; |
| 12 | use std::path::{Path, PathBuf}; | 12 | use std::path::{Path, PathBuf}; |
| 13 | use std::sync::Arc; | 13 | use 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 | ||
| 16 | const DEFAULT_AGENT_ID: &str = "main"; | 16 | const DEFAULT_AGENT_ID: &str = "main"; |
| 17 | const DEFAULT_LLM_MAX_TOKENS: u32 = 16384; | 17 | const 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 | ||
| 404 | pub fn save_llm_secret(config_path: &Path, env_name: &str, secret: &str) -> Result<()> { | 404 | pub 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 | ||
| 408 | pub fn load_llm_secrets_to_memory(config_path: &Path) -> Result<()> { | 408 | pub 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" |
| @@ -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"] } | ||
| @@ -31,11 +31,13 @@ impl FeishuAdapter { | |||
| 31 | }) | 31 | }) |
| 32 | } | 32 | } |
| 33 | 33 | ||
| 34 | + | ||
| 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).await | 36 | self.client.get_chat_info(chat_id).await |
| 36 | } | 37 | } |
| 37 | } | 38 | } |
| 38 | 39 | ||
| 40 | + | ||
| 39 | pub fn runtime(config: FeishuConfig) -> ChannelResult<ChannelRuntime> { | 41 | pub 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)?); |
| @@ -144,6 +144,7 @@ impl FeishuClient { | |||
| 144 | } | 144 | } |
| 145 | } | 145 | } |
| 146 | 146 | ||
| 147 | + | ||
| 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 = self | 150 | 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 | + | ||
| 557 | async fn parse_chat_info_response(response: reqwest::Response) -> ChannelResult<FeishuChatInfo> { | 559 | async fn parse_chat_info_response(response: reqwest::Response) -> ChannelResult<FeishuChatInfo> { |
| 558 | let status = response.status(); | 560 | let status = response.status(); |
| 559 | let body = response | 561 | 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 | + | ||
| 569 | fn parse_chat_info_payload(status: StatusCode, body: &str) -> ChannelResult<FeishuChatInfo> { | 572 | fn 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 { |
文件重命名但无更改。
| @@ -4,10 +4,6 @@ mod ingress; | |||
| 4 | pub mod types; | 4 | pub mod types; |
| 5 | pub mod websocket; | 5 | pub 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 | -}; | ||
| 13 | pub use websocket::{FeishuWebsocketMessageHandler, FeishuWebsocketService}; | 9 | pub use websocket::{FeishuWebsocketMessageHandler, FeishuWebsocketService}; |
| @@ -97,6 +97,7 @@ pub struct FeishuChatMember { | |||
| 97 | pub name: Option<String>, | 97 | pub name: Option<String>, |
| 98 | } | 98 | } |
| 99 | 99 | ||
| 100 | + | ||
| 100 | 101 | ||
| 101 | pub struct FeishuChatInfo { | 102 | pub 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 | + | ||
| 252 | 254 | ||
| 253 | pub(crate) struct ChatInfoEnvelope { | 255 | pub(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 | + | ||
| 259 | 262 | ||
| 260 | pub(crate) struct ChatInfoData { | 263 | pub(crate) struct ChatInfoData { |
| 261 | pub owner_id: Option<String>, | 264 | pub owner_id: Option<String>, |
Rapps/xiaoo-app/src/channels/feishu/websocket.rs→apps/serverside/src/channels/feishu/websocket.rs+0-0
文件重命名但无更改。
| @@ -1,22 +1,19 @@ | |||
| 1 | -pub mod adapter; | ||
| 2 | pub mod feishu; | 1 | pub mod feishu; |
| 3 | pub mod telegram; | 2 | pub 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 | -}; | ||
| 11 | pub use feishu::{ | 4 | pub 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 | }; |
| 16 | pub use telegram::{ | 8 | pub 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 | ||
| 22 | pub fn build_feishu_runtime(config: FeishuConfig) -> ChannelResult<ChannelRuntime> { | 19 | pub fn build_feishu_runtime(config: FeishuConfig) -> ChannelResult<ChannelRuntime> { |
Rapps/xiaoo-app/src/channels/telegram/channel.rs→apps/serverside/src/channels/telegram/channel.rs+0-0
文件重命名但无更改。
文件重命名但无更改。
| @@ -4,6 +4,5 @@ mod polling; | |||
| 4 | mod types; | 4 | mod types; |
| 5 | 5 | ||
| 6 | pub use channel::{capabilities, meta, TelegramAdapter}; | 6 | pub use channel::{capabilities, meta, TelegramAdapter}; |
| 7 | -pub use client::TelegramClient; | ||
| 8 | pub use polling::{TelegramPollingMessageHandler, TelegramPollingService}; | 7 | pub use polling::{TelegramPollingMessageHandler, TelegramPollingService}; |
| 9 | -pub use types::{TelegramConfig, TelegramConfigError, TelegramEventTransport}; | 8 | +pub use types::{TelegramConfig, TelegramEventTransport}; |
Rapps/xiaoo-app/src/channels/telegram/polling.rs→apps/serverside/src/channels/telegram/polling.rs+0-0
文件重命名但无更改。
文件重命名但无更改。
| @@ -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; | ||
| 1 | use agent_types::hook::HookerRegistryConfig; | 6 | use agent_types::hook::HookerRegistryConfig; |
| 2 | use anyhow::{bail, Context, Result}; | 7 | use anyhow::{bail, Context, Result}; |
| 3 | use lsp::LspServiceRegistry; | 8 | use lsp::LspServiceRegistry; |
| @@ -9,13 +14,8 @@ use std::env; | |||
| 9 | use std::fs; | 14 | use std::fs; |
| 10 | use std::path::{Path, PathBuf}; | 15 | use std::path::{Path, PathBuf}; |
| 11 | use std::sync::Arc; | 16 | use 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 | ||
| 20 | const DEFAULT_OUTPUT_TOKENS: usize = 16384; | 20 | const DEFAULT_OUTPUT_TOKENS: usize = 16384; |
| 21 | const DEFAULT_SYSTEM_PROMPT: &str = include_str!("prompts/default_system_prompt.txt"); | 21 | const DEFAULT_SYSTEM_PROMPT: &str = include_str!("prompts/default_system_prompt.txt"); |
| @@ -46,7 +46,7 @@ pub struct AppConfig { | |||
| 46 | 46 | ||
| 47 | pub lsp: Option<LspConfig>, | 47 | pub lsp: Option<LspConfig>, |
| 48 | 48 | ||
| 49 | - pub operation_backend: Option<GatewayBackendConfig>, | 49 | + pub server: ServerConfig, |
| 50 | } | 50 | } |
| 51 | 51 | ||
| 52 | 52 | ||
| @@ -186,6 +186,12 @@ pub struct PathsConfig { | |||
| 186 | pub data_dir: Option<PathBuf>, | 186 | pub data_dir: Option<PathBuf>, |
| 187 | } | 187 | } |
| 188 | 188 | ||
| 189 | + | ||
| 190 | +pub struct ServerConfig { | ||
| 191 | + | ||
| 192 | + pub operation_backend: Option<GatewayBackendConfig>, | ||
| 193 | +} | ||
| 194 | + | ||
| 189 | 195 | ||
| 190 | pub struct TraceConfig { | 196 | pub 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 | 235 | ||
| 230 | - | ||
| 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 | 281 | ||
| 251 | pub struct ResolvedAgentConfig { | 282 | pub 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 = self | 374 | let direct = self |
| 340 | .app | 375 | .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 | + | ||
| 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 | + | ||
| 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 | 867 | ||
| 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"); |
| @@ -1,5 +1,7 @@ | |||
| 1 | use crate::daemon_config::SubagentRoleConfig as ConfigSubagentRole; | 1 | use 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 | +}; | ||
| 3 | use agent_contracts::{CompressionPipeline, SkillRegistry, ToolRegistry, ToolRegistryBuilder}; | 5 | use agent_contracts::{CompressionPipeline, SkillRegistry, ToolRegistry, ToolRegistryBuilder}; |
| 4 | use agent_types::common::ids::{AgentId, ToolName}; | 6 | use agent_types::common::ids::{AgentId, ToolName}; |
| 5 | use agent_types::context::{FeatureFlags, TokenBudgetConfig}; | 7 | use agent_types::context::{FeatureFlags, TokenBudgetConfig}; |
| @@ -13,13 +15,14 @@ use compact::{ | |||
| 13 | }; | 15 | }; |
| 14 | use llm_client::{ | 16 | use 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 | }; |
| 18 | use lsp::LspServiceRegistry; | 20 | use lsp::LspServiceRegistry; |
| 19 | use prompt::{compose_channel_system_prompt, ChannelPromptSections}; | 21 | use prompt::{compose_channel_system_prompt, ChannelPromptSections}; |
| 20 | use serde_json::Value; | 22 | use serde_json::Value; |
| 21 | use skill::FileSkillRegistry; | 23 | use skill::FileSkillRegistry; |
| 22 | use std::collections::{BTreeMap, BTreeSet, HashMap}; | 24 | use std::collections::{BTreeMap, BTreeSet, HashMap}; |
| 25 | +use std::env; | ||
| 23 | use std::path::PathBuf; | 26 | use std::path::PathBuf; |
| 24 | use std::sync::Arc; | 27 | use std::sync::Arc; |
| 25 | use std::{fs, path::Path}; | 28 | use 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 | ||
| 40 | const DEFAULT_SYSTEM_TOKEN_RESERVE: usize = 2048; | 44 | const DEFAULT_SYSTEM_TOKEN_RESERVE: usize = 2048; |
| 41 | const DEFAULT_MIN_PROMPT_TOKEN_RESERVE: usize = 2048; | 45 | const DEFAULT_MIN_PROMPT_TOKEN_RESERVE: usize = 2048; |
| 42 | const DEFAULT_HARD_LIMIT_RATIO: f64 = 0.8; | 46 | const 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 | flags | 149 | 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 | fallback | 338 | 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 | 392 | ||
| 224 | impl SessionRuntimeResolver for ConfiguredRuntimeResolver { | 393 | impl 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_role | 400 | 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 | ||
| 458 | fn build_compression_pipeline( | 629 | fn 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 | 699 | ||
| 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.rs→apps/serverside/src/httpserver/channel_ingress.rs+4-2
| @@ -1,6 +1,6 @@ | |||
| 1 | use crate::channels::ChannelMessage; | 1 | use crate::channels::ChannelMessage; |
| 2 | -use crate::gateway::{channel_session_id, AppTurnRequest, GatewayEntryContext, TurnMention}; | ||
| 3 | use thiserror::Error; | 2 | use thiserror::Error; |
| 3 | +use xiaoo_shared::gateway::{channel_session_id, AppTurnRequest, GatewayEntryContext, TurnMention}; | ||
| 4 | 4 | ||
| 5 | 5 | ||
| 6 | pub struct GatewayChannelMention { | 6 | pub 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 | 115 | ||
| 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.rs→apps/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}; | ||
| 10 | use crate::httpserver::channel_ingress::{ | 5 | use 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; | |||
| 17 | use std::sync::Arc; | 12 | use std::sync::Arc; |
| 18 | use thiserror::Error; | 13 | use thiserror::Error; |
| 19 | use tracing::warn; | 14 | use 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 | 21 | ||
| 22 | pub enum ChannelMessageProcessingError { | 22 | pub enum ChannelMessageProcessingError { |
| @@ -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}; | ||
| @@ -19,14 +19,17 @@ pub struct RateLimitConfig { | |||
| 19 | 19 | ||
| 20 | pub burst: u32, | 20 | pub burst: u32, |
| 21 | 21 | ||
| 22 | + | ||
| 22 | pub routes: BTreeMap<String, RouteRateLimitOverride>, | 23 | pub routes: BTreeMap<String, RouteRateLimitOverride>, |
| 23 | } | 24 | } |
| 24 | 25 | ||
| 25 | 26 | ||
| 26 | pub struct RouteRateLimitOverride { | 27 | pub struct RouteRateLimitOverride { |
| 27 | 28 | ||
| 29 | + | ||
| 28 | pub requests_per_second: u32, | 30 | pub requests_per_second: u32, |
| 29 | 31 | ||
| 32 | + | ||
| 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 | + | ||
| 107 | pub fn effective_limit(&self, route_key: &str) -> (u32, u32) { | 111 | pub fn effective_limit(&self, route_key: &str) -> (u32, u32) { |
| 108 | self.routes | 112 | self.routes |
| 109 | .get(route_key) | 113 | .get(route_key) |
| @@ -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 | -}; | ||
| 9 | use crate::httpserver::channel_runtime::{ChannelMessageProcessingError, ChannelRuntimeProcessor}; | 4 | use crate::httpserver::channel_runtime::{ChannelMessageProcessingError, ChannelRuntimeProcessor}; |
| 10 | use crate::httpserver::rate_limit::RateLimitConfig; | 5 | use 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}; | ||
| 13 | use agent_contracts::InteractionHandle; | 7 | use agent_contracts::InteractionHandle; |
| 14 | use agent_types::interaction::{InteractionRequest, InteractionResponse}; | 8 | use agent_types::interaction::{InteractionRequest, InteractionResponse}; |
| 15 | use async_trait::async_trait; | 9 | use async_trait::async_trait; |
| 16 | use axum::{ | 10 | use 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 | }; |
| 31 | use serde::{Deserialize, Serialize}; | 25 | use serde::{Deserialize, Serialize}; |
| 32 | use std::collections::HashMap; | 26 | use std::collections::HashMap; |
| 33 | use std::sync::Arc; | 27 | use std::sync::Arc; |
| 34 | -use tokio::sync::{oneshot, Mutex}; | 28 | +use tokio::sync::{Mutex, oneshot}; |
| 35 | use tracing::warn; | 29 | use tracing::warn; |
| 30 | +use xiaoo_shared::gateway::SessionService; | ||
| 36 | 31 | ||
| 37 | 32 | ||
| 38 | pub struct GatewayAppState { | 33 | pub 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 { | |||
| 47 | impl GatewayAppState { | 41 | impl 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 | state | 58 | 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 | - | ||
| 234 | - pub channel_instance_id: Option<String>, | ||
| 235 | - pub sender_id: String, | ||
| 236 | - | ||
| 237 | - pub agent: Option<String>, | ||
| 238 | - pub conversation_id: String, | ||
| 239 | - | ||
| 240 | - pub message_id: Option<String>, | ||
| 241 | - | ||
| 242 | - pub reply_to_message_id: Option<String>, | ||
| 243 | - | ||
| 244 | - pub root_message_id: Option<String>, | ||
| 245 | - | ||
| 246 | - pub mentions: Vec<TestChatMention>, | ||
| 247 | -} | ||
| 248 | - | ||
| 249 | - | ||
| 250 | -pub struct TestChatRequest { | ||
| 251 | - pub text: String, | ||
| 252 | - | ||
| 253 | - pub channel: Option<String>, | ||
| 254 | - | ||
| 255 | - pub channel_instance_id: Option<String>, | ||
| 256 | - | ||
| 257 | - pub sender_id: Option<String>, | ||
| 258 | - | ||
| 259 | - pub agent: Option<String>, | ||
| 260 | - | ||
| 261 | - pub conversation_id: Option<String>, | ||
| 262 | - | ||
| 263 | - pub message_id: Option<String>, | ||
| 264 | - | ||
| 265 | - pub reply_to_message_id: Option<String>, | ||
| 266 | - | ||
| 267 | - pub root_message_id: Option<String>, | ||
| 268 | - | ||
| 269 | - pub mentions: Vec<TestChatMention>, | ||
| 270 | -} | ||
| 271 | - | ||
| 272 | - | ||
| 273 | -pub struct TestChatMention { | ||
| 274 | - pub id: String, | ||
| 275 | - | ||
| 276 | - pub display_name: Option<String>, | ||
| 277 | -} | ||
| 278 | - | ||
| 279 | - | ||
| 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 | - | ||
| 291 | pub fn create_router_with_auth( | 202 | pub 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 | - | ||
| 346 | pub fn create_router_with_channel_runtimes_control_plane_and_timeout_and_auth( | 214 | pub 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 | ||
| 412 | pub fn create_router_with_control_plane_and_auth( | 273 | pub 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 | ||
| 497 | async fn handle_session_open( | 358 | async 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 | ||
| 577 | async fn handle_session_interaction( | 435 | async 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 | ||
| 595 | async fn handle_session_cancel( | 456 | async 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 | ||
| 615 | async fn handle_session_close( | 487 | async 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_IMPLEMENTED | 575 | 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 | - | ||
| 779 | async fn handle_channel_events( | 588 | async 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 | - | ||
| 877 | fn map_adapter_response(adapter_response: AdapterResponse) -> Response { | 647 | fn 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 | 708 | ||
| 939 | mod tests { | 709 | mod 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 | - | ||
| 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 | 733 | ||
| 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 | 772 | ||
| 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_service | 797 | + |
| 1072 | - .requests | 798 | + #[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 | - 1 | 802 | + 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 | + | ||
| 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 | + | ||
| 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 | 936 | ||
| @@ -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_service | 1213 | + assert!( |
| 1358 | - .requests | 1214 | + 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 | 1222 | ||
| @@ -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_service | 1269 | + assert!( |
| 1412 | - .requests | 1270 | + 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 | - None | 1349 | + .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; |
| @@ -1,14 +1,11 @@ | |||
| 1 | -use crate::gateway::{AppTurnResult, SessionService, SessionServiceError}; | ||
| 2 | use crate::httpserver::channel_ingress::{build_channel_turn_request, GatewayChannelMessage}; | 1 | use crate::httpserver::channel_ingress::{build_channel_turn_request, GatewayChannelMessage}; |
| 3 | use agent_contracts::{ChannelFileSender, InteractionHandle, LoopEventSink}; | 2 | use agent_contracts::{ChannelFileSender, InteractionHandle, LoopEventSink}; |
| 4 | use std::sync::Arc; | 3 | use std::sync::Arc; |
| 5 | use thiserror::Error; | 4 | use thiserror::Error; |
| 5 | +use xiaoo_shared::gateway::{SessionService, SessionServiceError}; | ||
| 6 | 6 | ||
| 7 | 7 | ||
| 8 | pub struct GatewayTurnResponse { | 8 | pub 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_service | 36 | .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 | + | ||
| 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, |
| @@ -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 | + | ||
| 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 | + | ||
| 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 | + | ||
| 183 | +mod tests { | ||
| 184 | + use super::*; | ||
| 185 | + | ||
| 186 | + | ||
| 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 | +} | ||
| @@ -1,9 +1,19 @@ | |||
| 1 | +mod channels; | ||
| 1 | mod daemon_config; | 2 | mod daemon_config; |
| 2 | mod daemon_runtime; | 3 | mod 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 | +}; | ||
| 5 | use crate::daemon_config::{resolve_config_path, DaemonConfig}; | 11 | use crate::daemon_config::{resolve_config_path, DaemonConfig}; |
| 6 | use crate::daemon_runtime::ConfiguredRuntimeResolver; | 12 | use 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 | +}; | ||
| 7 | use anyhow::{bail, Context, Result}; | 17 | use anyhow::{bail, Context, Result}; |
| 8 | use futures_util::future::BoxFuture; | 18 | use futures_util::future::BoxFuture; |
| 9 | use operation_backend::process_group::ProcessGroupCleanupGuard; | 19 | use operation_backend::process_group::ProcessGroupCleanupGuard; |
| @@ -12,18 +22,8 @@ use std::net::SocketAddr; | |||
| 12 | use std::path::PathBuf; | 22 | use std::path::PathBuf; |
| 13 | use std::sync::Arc; | 23 | use std::sync::Arc; |
| 14 | use tracing_subscriber::EnvFilter; | 24 | use 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 | 28 | ||
| 29 | async fn main() -> Result<()> { | 29 | async 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 | ||
| 39 | async fn run_daemon(config_path: Option<PathBuf>, host: String, port: u16) -> Result<()> { | 41 | async 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 | ||
| 115 | fn spawn_feishu_websocket_service( | 123 | fn 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 | ||
| 140 | fn spawn_telegram_polling_service( | 148 | fn 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 | ||
| 165 | fn init_tracing() { | 173 | fn 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 | ||
| 174 | struct Cli { | 182 | struct 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 | ||
| 186 | impl Cli { | 189 | impl Cli { |
| @@ -188,59 +191,66 @@ impl Cli { | |||
| 188 | where | 191 | 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 | 245 | ||
| 236 | mod tests { | 246 | mod tests { |
| 237 | use super::Cli; | 247 | use super::Cli; |
| 248 | + use std::path::PathBuf; | ||
| 238 | 249 | ||
| 239 | 250 | ||
| 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 | 272 | ||
| 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.txt→apps/serverside/src/prompts/default_system_prompt.txt+0-0
文件重命名但无更改。
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 | - | ||
| 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 | - | ||
| 30 | -pub struct BackendEnsureSessionRequest { | ||
| 31 | - pub config: Option<GatewayBackendConfig>, | ||
| 32 | - pub workspace_root: PathBuf, | ||
| 33 | - pub session_id: String, | ||
| 34 | -} | ||
| 35 | - | ||
| 36 | - | ||
| 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 | - | ||
| 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 | - | ||
| 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 | - | ||
| 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 | - | ||
| 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 | - | ||
| 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 | - | ||
| 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 | - | ||
| 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 | - | ||
| 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 | - | ||
| 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 | - | ||
| 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 | - | ||
| 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 | - | ||
| 24 | -pub struct SecretsStore { | ||
| 25 | - | ||
| 26 | - pub api_keys: BTreeMap<String, String>, | ||
| 27 | - | ||
| 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 | -} | ||
| @@ -1,8 +1,8 @@ | |||
| 1 | # Backend Lifecycle Contract | 1 | # Backend Lifecycle Contract |
| 2 | 2 | ||
| 3 | -Status: design fixed. Shared lifecycle interfaces and the lifecycle state | 3 | +Status: partially implemented. Shared lifecycle interfaces and the lifecycle |
| 4 | -machine are implemented in `src/backend/lifecycle.rs`; provider integrations are | 4 | +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 | ||
| 7 | This document defines the unified lifecycle control contract for xiaoO | 7 | This document defines the unified lifecycle control contract for xiaoO |
| 8 | operation backends. It intentionally lives next to `agent-contracts` because it | 8 | operation backends. It intentionally lives next to `agent-contracts` because it |
| @@ -12,8 +12,8 @@ runtime tools can use the backend's operation plane. | |||
| 12 | ## Goals | 12 | ## 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` and | 15 | +- 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 session | 18 | - 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 | ## Naming | 82 | ## Naming |
| 83 | 83 | ||
| 84 | -The public lifecycle method is named `create_sandbox` for now because the | 84 | +The low-level `agent-contracts` lifecycle trait still exposes the historical |
| 85 | -current product language and Conch implementation are sandbox-oriented. The | 85 | +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 not | 90 | +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 State | 96 | ## 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 | |
| @@ -333,6 +333,7 @@ pub struct BackendSnapshot { | |||
| 333 | 333 | ||
| 334 | 334 | ||
| 335 | pub struct BackendCreateRequest { | 335 | pub 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, |
| @@ -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 | }, |
| @@ -37,6 +37,10 @@ pub struct LoopStateSnapshot { | |||
| 37 | 37 | ||
| 38 | impl LoopState { | 38 | impl 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 | + | ||
| 99 | +mod tests { | ||
| 100 | + use super::*; | ||
| 101 | + | ||
| 102 | + | ||
| 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 | +} | ||
| @@ -1,4 +1,4 @@ | |||
| 1 | -use std::collections::HashMap; | 1 | +use std::collections::{HashMap, HashSet}; |
| 2 | use std::path::{Path, PathBuf}; | 2 | use std::path::{Path, PathBuf}; |
| 3 | use std::sync::Arc; | 3 | use std::sync::Arc; |
| 4 | 4 | ||
| @@ -21,8 +21,15 @@ pub struct LspServerManager { | |||
| 21 | 21 | ||
| 22 | impl LspServerManager { | 22 | impl 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 | + | ||
| 329 | fn deduplicate_locations(mut locs: Vec<LspLocation>) -> Vec<LspLocation> { | 347 | fn 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 | locs | 350 | locs |
| 333 | } | 351 | } |
| 352 | + | ||
| 353 | + | ||
| 354 | +mod tests { | ||
| 355 | + use super::server_configs_with_disabled; | ||
| 356 | + use crate::servers::{AutoInstall, ServerConfig}; | ||
| 357 | + | ||
| 358 | + | ||
| 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 | +} | ||
| @@ -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). |
| 18 | pub struct LspServiceRegistry { | 18 | pub 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 | ||
| 23 | impl LspServiceRegistry { | 24 | impl 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 | } |
| @@ -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 { |
| @@ -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 = runtime | 407 | 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, |
| @@ -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. |
| 10 | const WORKSPACE_PROMPT_MARKER_BEGIN: &str = "<xiaoo_workspace_prompt>"; | 10 | const WORKSPACE_PROMPT_MARKER_BEGIN: &str = "<xiaoo_workspace_prompt>"; |
| 11 | const WORKSPACE_PROMPT_MARKER_END: &str = "</xiaoo_workspace_prompt>"; | 11 | const WORKSPACE_PROMPT_MARKER_END: &str = "</xiaoo_workspace_prompt>"; |
| 12 | 12 | ||
| @@ -1,6 +1,6 @@ | |||
| 1 | # CLI Configuration Guide | 1 | # 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 | ```bash | 79 | ```bash |
| 80 | # Single execution | 80 | # 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 file | 83 | # 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 information | 86 | # Show debug information |
| 87 | -xiaoo run --debug -p "Your prompt" | 87 | +xiaoo --cli run --debug -p "Your prompt" |
| 88 | 88 | ||
| 89 | # Disable tool execution | 89 | # 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 Description | 93 | ### Parameter Description |
| @@ -133,10 +133,10 @@ grep = true | |||
| 133 | 133 | ||
| 134 | ```bash | 134 | ```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 Works | 142 | ### 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 | ||
| 174 | For multi-role functionality, use: | 174 | For multi-role functionality, use: |
| 175 | -- **TUI**: `xiaoo-tui` + Tab key switching | 175 | +- **TUI**: `xiaoo` + Tab key switching |
| 176 | - **Daemon**: HTTP API + agent role configuration | 176 | - **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 | ```bash | 185 | ```bash |
| 186 | -xiaoo run --debug -p "test" | 186 | +xiaoo --cli run --debug -p "test" |
| 187 | ``` | 187 | ``` |
| 188 | Output will show: | 188 | Output will show: |
| 189 | - Configuration file path | 189 | - Configuration file path |
| @@ -248,16 +248,16 @@ grep = true | |||
| 248 | 248 | ||
| 249 | ```bash | 249 | ```bash |
| 250 | # Quick code review | 250 | # 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 generation | 253 | # 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 process | 259 | # 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 | --- |
| @@ -2,7 +2,7 @@ | |||
| 2 | 2 | ||
| 3 | Configuration file location: `~/.config/xiaoo/config.toml` | 3 | Configuration 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 Overview | 14 | ## 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 Configuration | 253 | ## [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 | ```toml | 260 | ```toml |
| 258 | [operation_backend] | 261 | [operation_backend] |
| 259 | -kind = "local" # Operation backend kind: local, conch | 262 | +kind = "local" # Operation backend kind for local clients |
| 260 | options = { ... } # Backend-specific options | 263 | options = { ... } # Backend-specific options |
| 261 | ``` | 264 | ``` |
| 262 | 265 | ||
| @@ -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 Example | 152 | ## 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] |
| 192 | data_dir = "~/.xiaoo" | 227 | data_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 Endpoints | 239 | ### 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 | ```bash | 284 | ```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 | -```json | 304 | +```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 | -```bash | 566 | +- `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 | ||
| @@ -73,7 +73,7 @@ In persistent-connection mode: | |||
| 73 | - A Feishu tenant with admin access | 73 | - A Feishu tenant with admin access |
| 74 | - A custom Feishu app with the **Bot** capability enabled | 74 | - A custom Feishu app with the **Bot** capability enabled |
| 75 | - A public domain or public server IP reachable by Feishu | 75 | - 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 host | 77 | - Rust toolchain and Cargo available on that host |
| 78 | - `systemd` available to manage the daemon process | 78 | - `systemd` available to manage the daemon process |
| 79 | - `nginx` or another reverse proxy available for public ingress | 79 | - `nginx` or another reverse proxy available for public ingress |
| @@ -134,7 +134,7 @@ Feishu | |||
| 134 | 134 | ||
| 135 | Typical responsibilities: | 135 | Typical 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 route | 138 | - a public server exposes the callback route |
| 139 | - that public server forwards traffic back to your local machine through a secure relay or tunnel | 139 | - that public server forwards traffic back to your local machine through a secure relay or tunnel |
| 140 | 140 | ||
| @@ -303,38 +303,38 @@ Example: | |||
| 303 | git clone <your-repo-url> /opt/xiaoo/src | 303 | git clone <your-repo-url> /opt/xiaoo/src |
| 304 | cd /opt/xiaoo/src | 304 | cd /opt/xiaoo/src |
| 305 | git checkout <your-branch> | 305 | git checkout <your-branch> |
| 306 | -cargo build -p xiaoo-app | 306 | +cargo build -p xiaoo-serverside |
| 307 | ``` | 307 | ``` |
| 308 | 308 | ||
| 309 | After a successful build, the binary will usually be created at: | 309 | After a successful build, the binary will usually be created at: |
| 310 | 310 | ||
| 311 | ```text | 311 | ```text |
| 312 | -target/debug/xiaoo-app | 312 | +target/debug/xiaoo-daemon |
| 313 | ``` | 313 | ``` |
| 314 | 314 | ||
| 315 | For a long-running service, copy or install that binary to a stable runtime path such as: | 315 | For a long-running service, copy or install that binary to a stable runtime path such as: |
| 316 | 316 | ||
| 317 | ```text | 317 | ```text |
| 318 | -/opt/xiaoo/bin/xiaoo-app | 318 | +/opt/xiaoo/bin/xiaoo-daemon |
| 319 | ``` | 319 | ``` |
| 320 | 320 | ||
| 321 | Example: | 321 | Example: |
| 322 | 322 | ||
| 323 | ```bash | 323 | ```bash |
| 324 | mkdir -p /opt/xiaoo/bin | 324 | mkdir -p /opt/xiaoo/bin |
| 325 | -install -m 755 target/debug/xiaoo-app /opt/xiaoo/bin/xiaoo-app | 325 | +install -m 755 target/debug/xiaoo-daemon /opt/xiaoo/bin/xiaoo-daemon |
| 326 | ``` | 326 | ``` |
| 327 | 327 | ||
| 328 | If you prefer release builds, replace the build command with: | 328 | If you prefer release builds, replace the build command with: |
| 329 | 329 | ||
| 330 | ```bash | 330 | ```bash |
| 331 | -cargo build -p xiaoo-app --release | 331 | +cargo build -p xiaoo-serverside --release |
| 332 | ``` | 332 | ``` |
| 333 | 333 | ||
| 334 | and install: | 334 | and install: |
| 335 | 335 | ||
| 336 | ```bash | 336 | ```bash |
| 337 | -install -m 755 target/release/xiaoo-app /opt/xiaoo/bin/xiaoo-app | 337 | +install -m 755 target/release/xiaoo-daemon /opt/xiaoo/bin/xiaoo-daemon |
| 338 | ``` | 338 | ``` |
| 339 | 339 | ||
| 340 | ## 5. Prepare Runtime Directories | 340 | ## 5. Prepare Runtime Directories |
| @@ -354,7 +354,7 @@ mkdir -p /var/lib/xiaoo/agents/main | |||
| 354 | Recommended layout: | 354 | Recommended layout: |
| 355 | 355 | ||
| 356 | ```text | 356 | ```text |
| 357 | -/opt/xiaoo/bin/xiaoo-app | 357 | +/opt/xiaoo/bin/xiaoo-daemon |
| 358 | /opt/xiaoo/config/config.toml | 358 | /opt/xiaoo/config/config.toml |
| 359 | /opt/xiaoo/config/xiaoo.env | 359 | /opt/xiaoo/config/xiaoo.env |
| 360 | /opt/xiaoo/app | 360 | /opt/xiaoo/app |
| @@ -769,7 +769,7 @@ Then start xiaoO locally: | |||
| 769 | 769 | ||
| 770 | ```bash | 770 | ```bash |
| 771 | cd /path/to/xiaoO | 771 | cd /path/to/xiaoO |
| 772 | -cargo run -p xiaoo-app -- daemon --config /path/to/config.toml --host 127.0.0.1 --port 18080 | 772 | +cargo run -p xiaoo-serverside -- --config /path/to/config.toml --host 127.0.0.1 --port 18080 |
| 773 | ``` | 773 | ``` |
| 774 | 774 | ||
| 775 | Notes: | 775 | Notes: |
| @@ -806,7 +806,7 @@ After=network.target | |||
| 806 | Type=simple | 806 | Type=simple |
| 807 | WorkingDirectory=/opt/xiaoo | 807 | WorkingDirectory=/opt/xiaoo |
| 808 | EnvironmentFile=/opt/xiaoo/config/xiaoo.env | 808 | EnvironmentFile=/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 18080 | 809 | +ExecStart=/opt/xiaoo/bin/xiaoo-daemon --config /opt/xiaoo/config/config.toml --host 127.0.0.1 --port 18080 |
| 810 | Restart=always | 810 | Restart=always |
| 811 | RestartSec=5 | 811 | RestartSec=5 |
| 812 | 812 | ||
| @@ -845,7 +845,7 @@ You can run the daemon directly: | |||
| 845 | cd /path/to/xiaoO | 845 | cd /path/to/xiaoO |
| 846 | export FEISHU_APP_SECRET=your-real-feishu-app-secret | 846 | export FEISHU_APP_SECRET=your-real-feishu-app-secret |
| 847 | export OPENROUTER_API_KEY=your-real-model-key | 847 | export 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 18080 | 848 | +cargo run -p xiaoo-serverside -- --config /path/to/config.toml --host 127.0.0.1 --port 18080 |
| 849 | ``` | 849 | ``` |
| 850 | 850 | ||
| 851 | This is often the fastest way to validate that: | 851 | This 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 | |||
| 1018 | cd /path/to/xiaoO | 1018 | cd /path/to/xiaoO |
| 1019 | export FEISHU_APP_SECRET=your-real-feishu-app-secret | 1019 | export FEISHU_APP_SECRET=your-real-feishu-app-secret |
| 1020 | export OPENROUTER_API_KEY=your-real-model-key | 1020 | export 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 18080 | 1021 | +cargo run -p xiaoo-serverside -- --config /path/to/config.toml --host 127.0.0.1 --port 18080 |
| 1022 | ``` | 1022 | ``` |
| 1023 | 1023 | ||
| 1024 | What you want to see: | 1024 | What 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 built | 1064 | ### 14.9 Confirm the binary you installed is the one you just built |
| 1065 | 1065 | ||
| 1066 | ```bash | 1066 | ```bash |
| 1067 | -ls -l /opt/xiaoo/bin/xiaoo-app | 1067 | +ls -l /opt/xiaoo/bin/xiaoo-daemon |
| 1068 | ``` | 1068 | ``` |
| 1069 | 1069 | ||
| 1070 | and compare the timestamp with your latest build result in `target/debug/` or `target/release/`. | 1070 | and 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 | |||
| 1154 | For a clean production deployment, use this structure: | 1154 | For a clean production deployment, use this structure: |
| 1155 | 1155 | ||
| 1156 | ```text | 1156 | ```text |
| 1157 | -/opt/xiaoo/bin/xiaoo-app | 1157 | +/opt/xiaoo/bin/xiaoo-daemon |
| 1158 | /opt/xiaoo/config/config.toml | 1158 | /opt/xiaoo/config/config.toml |
| 1159 | /opt/xiaoo/config/xiaoo.env | 1159 | /opt/xiaoo/config/xiaoo.env |
| 1160 | /opt/xiaoo/app | 1160 | /opt/xiaoo/app |
| @@ -170,8 +170,8 @@ Important boundary: | |||
| 170 | 170 | ||
| 171 | If you only need the built-in context-management path, configure `[compact]` and optionally `[llm].context_window`. This is already wired into: | 171 | If 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 | ||
| 177 | Once configured, compression runs automatically before each turn. | 177 | Once configured, compression runs automatically before each turn. |
| @@ -35,7 +35,7 @@ You can also develop your own hookers and place them in `<your_xiaoO>/plugins/ho | |||
| 35 | 35 | ||
| 36 | ### Built-in Skills | 36 | ### 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 privileges | 41 | - **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 Skills | 65 | ### 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 | ```bash | 69 | ```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 repository | 73 | # Install from Git repository |
| 74 | -xiaoo skill install https://github.com/user/my-skill.git | 74 | +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 | ```bash | 83 | ```bash |
| 84 | # Remove a user-installed skill | 84 | # 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 level | 87 | # Or manually remove from user level |
| 88 | rm -rf ~/.xiaoo/skills/<skill-name> | 88 | rm -rf ~/.xiaoo/skills/<skill-name> |
| @@ -95,7 +95,7 @@ To completely uninstall xiaoO and all associated skills: | |||
| 95 | 95 | ||
| 96 | ```bash | 96 | ```bash |
| 97 | # Uninstall the application | 97 | # Uninstall the application |
| 98 | -cargo uninstall xiaoo-app | 98 | +cargo uninstall xiaoo-endside |
| 99 | 99 | ||
| 100 | # Remove system-level skills (requires root) | 100 | # Remove system-level skills (requires root) |
| 101 | sudo rm -rf /usr/lib/.xiaoo/skills | 101 | sudo rm -rf /usr/lib/.xiaoo/skills |
| @@ -2,8 +2,8 @@ | |||
| 2 | 2 | ||
| 3 | Remote TUI lets one machine run the XiaoO gateway daemon while another machine runs the terminal UI. | 3 | Remote 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 | ``` |
| 14 | Machine B Machine A | 14 | Machine B Machine A |
| 15 | -xiaoo-tui xiaoo-app daemon | 15 | +xiaoo xiaoo-daemon |
| 16 | --------- ---------------- | 16 | --------- ---------------- |
| 17 | -TUI input/rendering HTTP/SSE Gateway session APIs | 17 | +TUI input/rendering HTTP/SSE Gateway runtime APIs |
| 18 | /remote commands -----------> Agent loop | 18 | /remote commands -----------> Agent loop |
| 19 | Interaction prompt <----------> Tools / hooks / workspace | 19 | Interaction prompt <----------> Tools / hooks / workspace |
| 20 | ``` | 20 | ``` |
| 21 | 21 | ||
| 22 | Local TUI remains the default. Remote mode is opt-in: | 22 | Local 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 | ||
| 27 | In 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. | 27 | In 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 | |||
| 33 | Start the daemon on Machine A: | 33 | Start the daemon on Machine A: |
| 34 | 34 | ||
| 35 | ```bash | 35 | ```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.toml | 39 | --config ~/.config/xiaoo/config.toml |
| @@ -50,7 +50,7 @@ Then export the token before starting the daemon: | |||
| 50 | 50 | ||
| 51 | ```bash | 51 | ```bash |
| 52 | export XIAOO_HTTP_BEARER_TOKEN="change-me" | 52 | export XIAOO_HTTP_BEARER_TOKEN="change-me" |
| 53 | -xiaoo-app daemon --host 0.0.0.0 --port 18080 | 53 | +xiaoo-daemon --host 0.0.0.0 --port 18080 |
| 54 | ``` | 54 | ``` |
| 55 | 55 | ||
| 56 | Health check: | 56 | Health check: |
| @@ -72,7 +72,7 @@ If bearer auth is configured, protected session/chat routes require: | |||
| 72 | Start the TUI normally: | 72 | Start the TUI normally: |
| 73 | 73 | ||
| 74 | ```bash | 74 | ```bash |
| 75 | -xiaoo-tui | 75 | +xiaoo |
| 76 | ``` | 76 | ``` |
| 77 | 77 | ||
| 78 | Connect to Machine A: | 78 | Connect to Machine A: |
| @@ -94,7 +94,7 @@ Then export the same token value on Machine B: | |||
| 94 | 94 | ||
| 95 | ```bash | 95 | ```bash |
| 96 | export XIAOO_REMOTE_TOKEN="change-me" | 96 | export XIAOO_REMOTE_TOKEN="change-me" |
| 97 | -xiaoo-tui | 97 | +xiaoo |
| 98 | ``` | 98 | ``` |
| 99 | 99 | ||
| 100 | When `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. | 100 | When `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 API | 117 | +## 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 | ||
| 129 | SSE event types: | 136 | SSE 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 Checklist | 168 | ## 8. Quick Checklist |
| 162 | 169 | ||
| 163 | 1. Machine A has daemon config and provider credentials. | 170 | 1. 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`. |
| 165 | 3. Machine B can reach `http://A:18080/api/v1/health`. | 172 | 3. Machine B can reach `http://A:18080/api/v1/health`. |
| 166 | 4. If auth is enabled, Machine B exports `XIAOO_REMOTE_TOKEN`. | 173 | 4. If auth is enabled, Machine B exports `XIAOO_REMOTE_TOKEN`. |
| 167 | -5. Machine B starts `xiaoo-tui`. | 174 | +5. Machine B starts `xiaoo`. |
| 168 | 6. In TUI, run `/remote http://A:18080`. | 175 | 6. In TUI, run `/remote http://A:18080`. |
| 169 | 7. Send a message and confirm the status bar shows `Remote: http://A:18080`. | 176 | 7. Send a message and confirm the status bar shows `Remote: http://A:18080`. |
| @@ -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. | ||
| @@ -70,27 +70,27 @@ Use grep and file_read to examine the code, then provide a structured report. | |||
| 70 | 70 | ||
| 71 | ```bash | 71 | ```bash |
| 72 | # List installed skills | 72 | # List installed skills |
| 73 | -xiaoo skill list | 73 | +xiaoo --cli skill list |
| 74 | 74 | ||
| 75 | # Show skill details and prompt content | 75 | # 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 directory | 78 | # 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 repository | 84 | # Install from a Git repository |
| 85 | -xiaoo skill install https://github.com/user/my-skill.git | 85 | +xiaoo --cli skill install https://github.com/user/my-skill.git |
| 86 | 86 | ||
| 87 | # Remove an installed skill | 87 | # Remove an installed skill |
| 88 | -xiaoo skill remove <name> | 88 | +xiaoo --cli skill remove <name> |
| 89 | ``` | 89 | ``` |
| 90 | 90 | ||
| 91 | ### Built-in Skills | 91 | ### 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 privileges | 96 | - **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 | ||
| 113 | To remove builtin skills: | 113 | To remove builtin skills: |
| 114 | 114 | ||
| @@ -125,7 +125,7 @@ rm -rf ~/.xiaoo/skills/block-analyzer | |||
| 125 | To completely uninstall all skills along with the application: | 125 | To completely uninstall all skills along with the application: |
| 126 | 126 | ||
| 127 | ```bash | 127 | ```bash |
| 128 | -cargo uninstall xiaoo-app | 128 | +cargo uninstall xiaoo-endside |
| 129 | sudo rm -rf /usr/lib/.xiaoo/skills | 129 | sudo rm -rf /usr/lib/.xiaoo/skills |
| 130 | rm -rf ~/.xiaoo/skills | 130 | rm -rf ~/.xiaoo/skills |
| 131 | ``` | 131 | ``` |
| @@ -47,7 +47,7 @@ Webhook and polling are mutually exclusive for the same bot token. If a webhook | |||
| 47 | 47 | ||
| 48 | - A Telegram account | 48 | - 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 binary | 51 | - 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: | |||
| 145 | git clone <your-repo-url> /opt/xiaoo/src | 145 | git clone <your-repo-url> /opt/xiaoo/src |
| 146 | cd /opt/xiaoo/src | 146 | cd /opt/xiaoo/src |
| 147 | git checkout telegram | 147 | git checkout telegram |
| 148 | -cargo build -p xiaoo-app | 148 | +cargo build -p xiaoo-serverside |
| 149 | ``` | 149 | ``` |
| 150 | 150 | ||
| 151 | After a successful build, the binary will usually be created at: | 151 | After a successful build, the binary will usually be created at: |
| 152 | 152 | ||
| 153 | ```text | 153 | ```text |
| 154 | -target/debug/xiaoo-app | 154 | +target/debug/xiaoo-daemon |
| 155 | ``` | 155 | ``` |
| 156 | 156 | ||
| 157 | For a long-running service, install that binary to a stable runtime path: | 157 | For a long-running service, install that binary to a stable runtime path: |
| 158 | 158 | ||
| 159 | ```bash | 159 | ```bash |
| 160 | mkdir -p /opt/xiaoo/bin | 160 | mkdir -p /opt/xiaoo/bin |
| 161 | -install -m 755 target/debug/xiaoo-app /opt/xiaoo/bin/xiaoo-app | 161 | +install -m 755 target/debug/xiaoo-daemon /opt/xiaoo/bin/xiaoo-daemon |
| 162 | ``` | 162 | ``` |
| 163 | 163 | ||
| 164 | If you prefer release builds: | 164 | If you prefer release builds: |
| 165 | 165 | ||
| 166 | ```bash | 166 | ```bash |
| 167 | -cargo build -p xiaoo-app --release | 167 | +cargo build -p xiaoo-serverside --release |
| 168 | -install -m 755 target/release/xiaoo-app /opt/xiaoo/bin/xiaoo-app | 168 | +install -m 755 target/release/xiaoo-daemon /opt/xiaoo/bin/xiaoo-daemon |
| 169 | ``` | 169 | ``` |
| 170 | 170 | ||
| 171 | ## 5. Prepare Runtime Directories | 171 | ## 5. Prepare Runtime Directories |
| @@ -185,7 +185,7 @@ mkdir -p /var/lib/xiaoo/agents/main | |||
| 185 | Recommended layout: | 185 | Recommended layout: |
| 186 | 186 | ||
| 187 | ```text | 187 | ```text |
| 188 | -/opt/xiaoo/bin/xiaoo-app | 188 | +/opt/xiaoo/bin/xiaoo-daemon |
| 189 | /opt/xiaoo/config/config.toml | 189 | /opt/xiaoo/config/config.toml |
| 190 | /opt/xiaoo/config/xiaoo.env | 190 | /opt/xiaoo/config/xiaoo.env |
| 191 | /opt/xiaoo/app | 191 | /opt/xiaoo/app |
| @@ -492,7 +492,7 @@ After=network.target | |||
| 492 | Type=simple | 492 | Type=simple |
| 493 | WorkingDirectory=/opt/xiaoo | 493 | WorkingDirectory=/opt/xiaoo |
| 494 | EnvironmentFile=/opt/xiaoo/config/xiaoo.env | 494 | EnvironmentFile=/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 18080 | 495 | +ExecStart=/opt/xiaoo/bin/xiaoo-daemon --config /opt/xiaoo/config/config.toml --host 127.0.0.1 --port 18080 |
| 496 | Restart=always | 496 | Restart=always |
| 497 | RestartSec=5 | 497 | RestartSec=5 |
| 498 | 498 | ||
| @@ -558,7 +558,7 @@ PY | |||
| 558 | )" | 558 | )" |
| 559 | 559 | ||
| 560 | cd "/path/to/xiaoO" | 560 | cd "/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 18080 | 564 | --port 18080 |
| @@ -748,7 +748,7 @@ Non-text updates are ignored. | |||
| 748 | For a clean webhook production deployment, use this structure: | 748 | For a clean webhook production deployment, use this structure: |
| 749 | 749 | ||
| 750 | ```text | 750 | ```text |
| 751 | -/opt/xiaoo/bin/xiaoo-app | 751 | +/opt/xiaoo/bin/xiaoo-daemon |
| 752 | /opt/xiaoo/config/config.toml | 752 | /opt/xiaoo/config/config.toml |
| 753 | /opt/xiaoo/config/xiaoo.env | 753 | /opt/xiaoo/config/xiaoo.env |
| 754 | /opt/xiaoo/app | 754 | /opt/xiaoo/app |
| @@ -1,6 +1,6 @@ | |||
| 1 | # TUI Configuration Guide | 1 | # 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 | ```bash | 294 | ```bash |
| 295 | # Local mode | 295 | # Local mode |
| 296 | -xiaoo-tui | 296 | +xiaoo |
| 297 | 297 | ||
| 298 | # Use specific configuration file | 298 | # Use specific configuration file |
| 299 | -xiaoo-tui --config /path/to/config.toml | 299 | +xiaoo --config /path/to/config.toml |
| 300 | 300 | ||
| 301 | # Debug mode | 301 | # Debug mode |
| 302 | -xiaoo-tui --debug | 302 | +xiaoo --debug |
| 303 | ``` | 303 | ``` |
| 304 | 304 | ||
| 305 | --- | 305 | --- |
| @@ -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-app | 298 | +### 4.2 apps/endside |
| 299 | 299 | ||
| 300 | Secrets 存储和加载逻辑。 | 300 | Secrets 存储和加载逻辑。 |
| 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 | ```bash | 449 | ```bash |
| 450 | # 默认编译 (WhiteBox + AES-GCM,仅测试环境) | 450 | # 默认编译 (WhiteBox + AES-GCM,仅测试环境) |
| 451 | -cargo build --release --bin xiaoo-tui | 451 | +cargo build --release -p xiaoo-endside --bin xiaoo |
| 452 | 452 | ||
| 453 | # 启用 SDF 国密 (仅鲲鹏服务器) | 453 | # 启用 SDF 国密 (仅鲲鹏服务器) |
| 454 | -cargo build --release --bin xiaoo-tui --features tee_sdf | 454 | +cargo build --release -p xiaoo-endside --bin xiaoo --features tee_sdf |
| 455 | ``` | 455 | ``` |
| 456 | 456 | ||
| 457 | #### 单元测试 | 457 | #### 单元测试 |
| 458 | 458 | ||
| 459 | ```bash | 459 | ```bash |
| 460 | cargo test --package vault | 460 | cargo test --package vault |
| 461 | -cargo test --package xiaoo-app | 461 | +cargo test --package xiaoo-shared |
| 462 | ``` | 462 | ``` |
| 463 | 463 | ||
| 464 | #### 功能验证 | 464 | #### 功能验证 |
| @@ -469,7 +469,7 @@ export OPENROUTER_API_KEY='sk-or-v1-xxx' | |||
| 469 | export USE_SDF=false | 469 | export USE_SDF=false |
| 470 | 470 | ||
| 471 | # 运行 TUI (vault.enabled=true 时自动保存) | 471 | # 运行 TUI (vault.enabled=true 时自动保存) |
| 472 | -./target/release/xiaoo-tui --config config.toml | 472 | +./target/release/xiaoo --config config.toml |
| 473 | 473 | ||
| 474 | # 检查加密文件 | 474 | # 检查加密文件 |
| 475 | hexdump -C ~/.xiaoo/config/llm_secrets.json | head | 475 | hexdump -C ~/.xiaoo/config/llm_secrets.json | head |
| @@ -177,7 +177,7 @@ def get_default_config() -> Config: | |||
| 177 | return _default_config | 177 | 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 保持一致) |
| 181 | PROVIDER_BASE_URLS: dict[str, str] = { | 181 | PROVIDER_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 保持一致) |
| 208 | PROVIDER_API_KEY_ENVS: dict[str, str] = { | 208 | PROVIDER_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", |