Compare commits
6 Commits
e5ad2e9ced
...
1acab7f890
| Author | SHA1 | Date | |
|---|---|---|---|
| 1acab7f890 | |||
| f78347b600 | |||
| ac0a0c42ad | |||
| c02993ae2c | |||
| e45980a282 | |||
| da5ee05311 |
@ -87,16 +87,17 @@ Scheduler → SessionManager scheduled execution → AgentLoop → Scheduler del
|
||||
- **Channels** publish inbound messages through `MessageBus`; outbound writes arrive through `OutboundDispatcher` or a per-turn `TurnSink`. They know nothing about sessions or LLM
|
||||
- **Inbound contract** carries normalized sender/time/media plus `ChannelContext`; core routing may interpret `reply_to` but must treat platform-private context as opaque reply data
|
||||
- **MessageBus** owns bounded inbound/outbound/control queues; outbound routing and retries belong to `OutboundDispatcher`, not the queue
|
||||
- **SessionManager** owns session state, dialog operations, context construction, per-session work queues, and persistence coordination
|
||||
- **SessionManager** owns session state, dialog operations, context construction, per-session work queues, active-Turn steering admission, and persistence coordination
|
||||
- **TurnController** is the only owner of active Turn state; snapshots are complete latest-wins values, not token queues, and `Completed` is published only after atomic persistence succeeds
|
||||
- **DeliveryCoordinator** projects active Turn snapshots without mutating history; it owns `TurnSink` lifecycle but no platform message IDs, which remain private to each sink
|
||||
- **WorkManager** owns the single active plan per session, item state transitions, plan versions, and plan-change events; plans are optional and absent from ordinary chat context
|
||||
- **Scheduler** supports legacy direct-delivery jobs and managed `task`/`monitor` jobs; managed agents cannot call `send_message`, and `on_alert` suppresses only healthy informational results
|
||||
- **AgentLoop** is stateless across turns; it receives prepared history, calls LLM providers, executes tools, and returns one result
|
||||
- **AgentLoop** is stateless across turns; it receives prepared history, drains same-Turn steering only at safe model boundaries, calls LLM providers, executes tools, and returns one result
|
||||
- **WebUI management APIs** only expose allowlisted config/profile/log/storage operations; keep response limits, secret redaction, atomic config writes, and profile path allowlists intact
|
||||
- **WebUI styling** uses the local Fluent 2 semantic tokens in `webui/src/styles.css`; page and component styles should consume the aliases instead of introducing independent hard-coded palettes, and must preserve selectable light/dark and brand-color themes, keyboard focus, responsive layout, and reduced-motion behavior. Browser-only appearance settings belong in `lib/theme.js`/`localStorage`, and `public/theme-init.js` must restore them before Svelte mounts
|
||||
- **Gateway config reload** validates and prepares a complete next runtime generation before retiring the old one; close runtime admission before draining inbound lanes, Sessions, Scheduler jobs, and background sub-agents; MCP connects only during activation; host/port, workspace, and effective storage path remain restart-only, and runtime reload must never mutate process environment variables
|
||||
- **WebUI slash completion** must consume the existing `get_slash_commands` WebSocket response; do not duplicate the backend command list in frontend source
|
||||
- **Session token statistics** persist Provider-reported usage atomically with each completed Turn; WebUI `session_stats` and `/info [--json]` must consume the same SessionStats projection, and context occupancy must use the final request's prompt usage rather than accumulated Turn totals
|
||||
- **WebUI chat rendering** sanitizes Markdown before inserting HTML; durable `turn_committed` deltas calibrate normal terminal Turns without a full history reload, and history must preserve structured tool-call metadata so calls and results remain independently collapsible
|
||||
- **WebUI/TUI file transfer** streams bytes over authenticated HTTP and sends only short-lived upload IDs/attachment metadata over WebSocket; messages persist local media paths without guaranteeing later availability, and client responses must never expose those paths
|
||||
- **WebUI/TUI same-turn media delivery** stages same-session `send_message(files=...)` media on the active Turn and commits it on the final assistant message, after durable tool-call history; safe raster formats should render as an inline preview with download fallback
|
||||
@ -104,12 +105,14 @@ Scheduler → SessionManager scheduled execution → AgentLoop → Scheduler del
|
||||
- **Providers** are pure HTTP clients; no bus/session/channel awareness
|
||||
- **Provider reasoning state** is private replay data: persist it, replay it only to the matching provider, and never expose it to clients, channels, or logs
|
||||
- **Tools** are executed by `AgentLoop`; every invocation is normalized to `ToolOutput` and passes through `ToolOutputProcessor`. Plain `ToolResult` implementations use the default conversion, while artifact-producing tools declare model/user audience explicitly; model capability checks, final-reply attachment, channel delivery, and Provider serialization stay outside tools
|
||||
- **Sleep tool** is a cancellable foreground wait bounded to 24 hours; longer or durable delays belong to Scheduler/background work, and cancelling a Turn must normalize active tool blocks to `Cancelled`
|
||||
- **Stateful tools** receive `ToolExecutionContext`; browser calls without `persistent_id` map each PicoBot dialog to an opaque transient agent-browser session. For long-running work an Agent may autonomously create a persistent identity and must pass its validated ID on every related action; the same ID shares one agent-browser session and serialization gate across dialogs, while different IDs have independent sessions/gates. `browser_profiles` may create IDs, persist bounded semantic labels, list, or delete only validated IDs beneath the configured profile root. Do not introduce a global persistence mode switch, a default persistent ID, automatic per-dialog persistent Profiles, Fantoccini, ChromeDriver, WebDriver, model-controlled raw agent-browser session IDs, or arbitrary Profile paths
|
||||
- **Health diagnostics** are read-only and share `HealthService` across `picobot health`, the `health` tool, and `/health`; checks must not install/fix dependencies, call Provider APIs, or expose secrets
|
||||
|
||||
### Concurrency and Lifecycle Invariants
|
||||
|
||||
- Messages in one session are processed serially through a bounded queue; different sessions may run concurrently
|
||||
- One session runs at most one Turn; ordinary input steers its active Turn by default, `/queue` explicitly waits for the next Turn, and different sessions may run concurrently
|
||||
- Steering admission, final close, fallback, and `/stop` must be lossless and mutually exclusive: an input belongs to exactly the active Turn or the next-Turn FIFO, while `/stop` intentionally discards both
|
||||
- Outbound messages are ordered per `(channel, chat_id)`; a slow destination must not block unrelated destinations
|
||||
- Active Turn delivery and ordinary outbound delivery share the same per-`(channel, chat_id)` write lock; never enqueue token deltas into MessageBus
|
||||
- Slow Turn consumers may skip intermediate snapshots but must receive an explicit bounded terminal delivery; shutdown must call sink abort so platform cleanup remains possible
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "picobot"
|
||||
version = "1.4.0"
|
||||
version = "1.5.1"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
@ -55,6 +55,7 @@ portable-pty = "0.9"
|
||||
[dev-dependencies]
|
||||
dotenv = "0.15"
|
||||
tower = "0.5"
|
||||
tokio = { version = "1.53", features = ["test-util"] }
|
||||
|
||||
[build-dependencies]
|
||||
zstd = "0.13"
|
||||
|
||||
@ -12,6 +12,7 @@ PicoBot 是一个用 Rust 编写的个人 AI 助手运行时。它在本地启
|
||||
- 从脚本或命令行发送一条任务,等待完整的模型/工具循环后只输出最终结果。
|
||||
- 在 TUI 或浏览器中实时查看正文、思考过程和工具执行状态,并在完成后收敛到持久化历史。
|
||||
- 在浏览器中查看日志、任务和记忆,修改运行配置与助手档案。
|
||||
- 在 WebUI 顶栏查看当前会话的累计输入/输出 Token、上下文窗口和占用比例。
|
||||
- 复杂任务可创建 session 级 Todo 计划,把不同子项并行委托给多个子 Agent;聊天页侧栏实时显示进度。
|
||||
- 将同一套 Agent 能力接入飞书/Lark,并可选用单张卡片实时更新回复。
|
||||
- 让 Agent 使用本地文件、Shell、搜索、HTTP、浏览器、MCP 工具完成任务。
|
||||
@ -255,7 +256,7 @@ WebUI/TUI 上传文件默认保存到 `~/.picobot/media/cli_chat`,单文件上
|
||||
|
||||
详细时序和失败语义见 [架构文档:消息与控制数据流](docs/ARCHITECTURE.md#4-消息与控制数据流)。
|
||||
|
||||
同一 session 的普通消息由专属有界队列串行处理,不同 session 可以并发;活动 Turn 使用 latest-wins 快照,慢展示端只跳过中间状态,不反压模型。普通出站消息按 `(channel, chat_id)` 分 lane 保序,两条投递路径共享目标写锁。Gateway 的长生命周期任务统一由 `TaskSupervisor` 取消和限时回收。
|
||||
同一 session 始终只运行一个 Turn,不同 session 可以并发。Turn 执行期间新发的普通消息默认 steering 当前工作:系统在完整工具批次后或最终回复边界把它作为真实用户消息加入下一次模型调用;使用 `/queue <message>` 可明确等当前 Turn 完成后再处理,使用 `/stop` 可中断当前 Turn 并清空等待输入。Steering mailbox 和 session 队列都有界且带可靠回退。活动 Turn 使用 latest-wins 快照,慢展示端只跳过中间状态,不反压模型。普通出站消息按 `(channel, chat_id)` 分 lane 保序,两条投递路径共享目标写锁。
|
||||
|
||||
核心边界:
|
||||
|
||||
@ -306,10 +307,11 @@ Session ID 使用三段式:
|
||||
| `/rename <title>` | 重命名当前 dialog |
|
||||
| `/delete` | 删除当前 dialog 并创建新 dialog |
|
||||
| `/compact` | 手动压缩上下文 |
|
||||
| `/info` | 查看当前 dialog 信息 |
|
||||
| `/info [--json]` | 查看当前 dialog、累计 Token 与上下文窗口信息;可选 JSON 输出 |
|
||||
| `/dump` | 导出当前 dialog 为 Markdown |
|
||||
| `/mcp` | 查看 MCP 服务器和工具状态 |
|
||||
| `/health` | 检查 PicoBot 运行依赖 |
|
||||
| `/queue <message>` | 等当前 Turn 完成后再把消息作为下一 Turn 处理 |
|
||||
| `/stop` | 停止当前任务并清空队列 |
|
||||
| `/todo [done\|cancel]` | 查看、完成或取消当前 session 的任务计划 |
|
||||
| `/reload` | 校验并重新加载 Gateway 配置 |
|
||||
@ -333,6 +335,7 @@ PicoBot 有两类记忆:
|
||||
| 工具 | 说明 |
|
||||
|------|------|
|
||||
| `calculator` | 数学表达式和统计计算 |
|
||||
| `sleep` | 暂停当前 Agent 工具调用 0~86400 秒;可由用户停止,不用于持久调度 |
|
||||
| `file_read` / `file_write` / `file_edit` | 文件读写和编辑;`file_read` 读取受支持图片时可将图片直接提供给多模态模型 |
|
||||
| `file_search` / `content_search` | 文件名和内容搜索 |
|
||||
| `bash` | 在 workspace 中执行 Shell 命令 |
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
本文档描述 PicoBot 当前实现的运行时边界、数据流、并发模型和演进约束。它面向维护者和后续参与改进的 Agent,是代码架构的主入口;行为细节仍以代码和测试为最终依据。
|
||||
|
||||
流式模型输出、reasoning 展示、活动 Turn 快照和 Channel 实时投递的详细设计与取舍见 [STREAMING_TURN_DESIGN.md](STREAMING_TURN_DESIGN.md)。用户输入路由、Session 执行拆分、终态投递确认和历史增量校准的重构方案见 [MESSAGE_FLOW_REFACTOR_DESIGN.md](MESSAGE_FLOW_REFACTOR_DESIGN.md)。配置运行代、重载边界和失败语义见 [CONFIG_HOT_RELOAD_DESIGN.md](CONFIG_HOT_RELOAD_DESIGN.md)。
|
||||
流式模型输出、reasoning 展示、活动 Turn 快照和 Channel 实时投递的详细设计与取舍见 [STREAMING_TURN_DESIGN.md](STREAMING_TURN_DESIGN.md)。用户输入路由、Session 执行拆分、终态投递确认和历史增量校准的重构方案见 [MESSAGE_FLOW_REFACTOR_DESIGN.md](MESSAGE_FLOW_REFACTOR_DESIGN.md)。配置运行代、重载边界和失败语义见 [CONFIG_HOT_RELOAD_DESIGN.md](CONFIG_HOT_RELOAD_DESIGN.md)。具名子 Agent、委托图、后台收件箱、`queue`/`steer` 信号和可唤醒 `sleep` 的升级提案见 [SUB_AGENT_ORCHESTRATION_DESIGN.md](SUB_AGENT_ORCHESTRATION_DESIGN.md)。
|
||||
|
||||
## 1. 设计目标
|
||||
|
||||
@ -107,11 +107,16 @@ sequenceDiagram
|
||||
C->>B: publish InboundMessage
|
||||
B->>G: consume inbound
|
||||
G->>S: handle_message
|
||||
S->>W: try_send AgentTask
|
||||
S->>W: try_send AgentTask (idle or /queue)
|
||||
S-->>G: AgentProcessing
|
||||
W->>T: start Turn
|
||||
W->>L: subscribe latest snapshots
|
||||
W->>A: process_streaming(history)
|
||||
C->>B: ordinary input during active Turn
|
||||
B->>G: consume inbound
|
||||
G->>S: handle_message
|
||||
S->>A: bounded steering mailbox
|
||||
A->>A: drain after tool batch / before final
|
||||
A-->>T: reasoning/text/tool events
|
||||
T-->>L: complete TurnSnapshot
|
||||
L->>C: TurnSink update (best effort)
|
||||
@ -127,11 +132,14 @@ sequenceDiagram
|
||||
|
||||
关键语义:
|
||||
|
||||
- Gateway inbound router 按 `(channel, chat_id)` 使用容量 32 的短生命周期 lane 保持入口顺序,不同聊天可并发路由;普通消息进入对应 session worker 后立即返回 `AgentProcessing`。
|
||||
- Gateway inbound router 按 `(channel, chat_id)` 使用容量 32 的短生命周期 lane 保持入口顺序,不同聊天可并发路由;没有活动 Turn 时,普通消息进入对应 session worker 后立即返回 `AgentProcessing`。
|
||||
- `/stop` 绕过同聊天的 inbound lane,直接使正在运行的 worker/Turn 失效;其他 slash command 仍在聊天 lane 内有序执行。
|
||||
- 每个 session 有一条容量为 32 的队列,同一 session 串行处理,不同 session 的 worker 可并发执行。
|
||||
- 活动 Turn 存在时,普通消息默认作为 steering 进入本 Turn 的有界 mailbox;`/queue <message>` 明确进入下一 Turn。AgentLoop 只在完整工具批次结束后、或准备接受无工具最终回复时排空 mailbox,并把输入作为真实、可持久化的 `role=user` 消息加入下一次模型请求。
|
||||
- Steering mailbox 最多容纳 32 条、合计 64 KiB 文本与元数据。mailbox 已关闭或满时,输入可靠回退到 session 队列;若 session 队列也满则明确拒绝。Session 在入站时分配单调序号,Turn 结束时未消费的 steering 由 worker 本地恢复队列接管,并与 `/queue` 输入按该序号合并选择,不能丢失或互相超越。
|
||||
- 每个 session 有一条容量为 32 的普通队列,同一 session 仍只运行一个 Turn,不同 session 的 worker 可并发执行。
|
||||
- 队列满时明确拒绝新消息,不允许无界积压。
|
||||
- Slash command 不进入 Agent 队列,由 `SessionManager` 直接执行,因此 `/stop` 等控制操作不会排在长模型调用之后。
|
||||
- Slash command 通常不进入 Agent 队列,由 `SessionManager` 直接执行;`/queue` 是显式排队输入,`/stop` 是显式中断并清空当前 mailbox 与队列。
|
||||
- WebSocket `user_input.client_message_id` 只用于让 `turn_committed` 以同一消息 ID 替换 WebUI 的乐观用户气泡;它不改变入站顺序或 steering/queue 决策。
|
||||
- `InboundMessage` 只保存规范化输入:`sender_id`、`received_at`、媒体和一个 `ChannelContext`。核心只解释其中的 `reply_to`,其语义是本轮出站应回复的当前入站消息;被用户引用的父消息只用于补充模型上下文。reaction/message ID、话题 root/thread 等平台字段作为 `private` 不透明传到对应 Turn/普通回复,不能散落为核心层 magic key。持久化的用户消息保留真实接收时间和 `UserInput` 来源,客户端历史投影不暴露来源中的平台用户 ID。
|
||||
- Session 为每个 Agent 请求创建一个 `TurnController`。Provider 向 AgentLoop 发 delta,AgentLoop 发结构化 TurnEvent,只有 TurnController 能把事件归约为有序 block 和单调 revision 的完整快照。
|
||||
- Turn 快照经 Tokio `watch` 发布,语义为 latest-wins;慢客户端或慢渠道跳过中间状态,不反压 Provider。终态明确编码在快照中,不依赖 sender 关闭。
|
||||
@ -192,10 +200,11 @@ Session ID 格式为:
|
||||
4. 慢操作开始前记录 `state_version`,提交前重新验证,防止旧快照覆盖 `/clear`、`/delete` 等并发修改。
|
||||
5. 持久化写入由 `persistence_lock` 串行化;多条相关记录应使用 Storage 的原子接口。
|
||||
6. 内存先变更但持久化失败时,必须回滚精确匹配的消息后缀,不能删除无关的新状态。
|
||||
7. Steering 的接收、最终边界关闭和 `/stop` 必须通过同一个 mailbox 状态串行化;每条输入只能落入当前 Turn 或下一 Turn 之一。
|
||||
|
||||
当前 WebUI/TUI Turn 通过 `send_message(files=...)` 向自身 session 投递文件时,文件先进入 task-local Turn delivery 暂存区,成功结束后附加到最终 assistant 消息,与工具链一起原子提交;因此持久化和刷新后的顺序都是工具调用/结果在前、携带附件的最终回复在后,也不会生成带 `[message from ...]` 的自投递气泡。其他同 Turn 自投递仍是受控例外:只有 task-local Turn ID 仍匹配该 session 的 active Turn,写入才允许不递增 `state_version`。跨 Turn、跨 session 以及无法证明所有权的写入仍必须递增版本。Provider 回放历史附件时,只有 user 输入和当前工具结果可生成模型原生媒体块;assistant/system 附件只回放文本清单,避免把图片放到供应商不接受的角色。
|
||||
|
||||
SessionManager 负责组装会话上下文:系统提示、Skills、召回的 Knowledge、压缩后的 Timeline、可选的 active plan 摘要和当前消息历史。`session::turn_input` 在 Session 锁外并行读取 Knowledge、active plan 并压缩历史,然后通过同一个 assembly 路径生成首次请求和 context-overflow 重试输入;重试不得复制系统提示或 runtime context 拼接逻辑。普通闲聊 session 没有 plan 摘要;计划状态由 `WorkManager` 从 SQLite 读取,因此不以自然语言摘要作为权威来源。`AgentLoop` 接收完整输入执行一次模型/工具循环,本身不拥有会话状态。执行工具时额外传递只包含 session/turn 身份的 `ToolExecutionContext`;无状态工具使用默认实现忽略它,有状态外部适配器用它路由资源,但可按明确的单用户配置跨 dialog 共享,且不能自行反向查询 SessionManager。
|
||||
SessionManager 负责组装会话上下文:系统提示、Skills、召回的 Knowledge、压缩后的 Timeline、可选的 active plan 摘要和当前消息历史。`session::turn_input` 在 Session 锁外并行读取 Knowledge、active plan 并压缩历史,然后通过同一个 assembly 路径生成首次请求和 context-overflow 重试输入;重试不得复制系统提示或 runtime context 拼接逻辑,也不得丢失已经从 mailbox 取出的 steering。普通闲聊 session 没有 plan 摘要;计划状态由 `WorkManager` 从 SQLite 读取,因此不以自然语言摘要作为权威来源。`AgentLoop` 接收完整输入执行一次模型/工具循环,本身不拥有会话状态,但通过本 Turn 的 mailbox 在安全边界接收追加用户输入。执行工具时额外传递只包含 session/turn 身份的 `ToolExecutionContext`;无状态工具使用默认实现忽略它,有状态外部适配器用它路由资源,但可按明确的单用户配置跨 dialog 共享,且不能自行反向查询 SessionManager。
|
||||
|
||||
当前 Turn 的工具进度只从 `AgentLoop` 的结构化 `TurnEvent` 进入 `TurnController`,不能另建字符串 notification 通道重复投递。后台子 Agent 的 `TaskNotification` 表达跨 Turn 的任务完成,仍由独立的受监督消费者投递。自动标题属于非关键派生工作:Turn 持久化完成后由 `TaskSupervisor` 调度,Session worker 不等待模型生成;同一 Session 同时最多有一个标题任务,提交时仍校验标题保持默认值,避免覆盖用户改名。
|
||||
|
||||
@ -210,7 +219,7 @@ SessionManager 负责组装会话上下文:系统提示、Skills、召回的 K
|
||||
- 5 秒 busy timeout。
|
||||
- schema version 迁移。
|
||||
|
||||
持久化范围包括 sessions、messages、memories、task plans/items、scheduled jobs、job runs 和 background tasks。消息保存可展示 `reasoning_content`、Provider 私有回放状态、`turn_id`、iteration 和 completion status;私有 Provider 状态不进入 WebSocket/Channel,且只允许回放给同一 Provider。成功持久化一个 Turn 后,交互 Channel 收到只包含公开字段的 `CommittedTurnDelta`,其中 `history_revision` 是本批次最高 durable sequence;客户端按 revision 幂等合并,正常完成不重新加载整段历史,断线重连和失败/取消仍使用 `SessionHistory` 校准。Provider 诊断和失败审计只记录模型、消息数、工具数等请求摘要,不保存正文、reasoning 或签名 payload。修改 schema 时应:
|
||||
持久化范围包括 sessions、messages、session turn usage、memories、task plans/items、scheduled jobs、job runs 和 background tasks。消息保存可展示 `reasoning_content`、Provider 私有回放状态、`turn_id`、iteration 和 completion status;私有 Provider 状态不进入 WebSocket/Channel,且只允许回放给同一 Provider。成功 Turn 的 Provider usage 与消息批次在同一事务中写入 `session_turn_usage`,以 `turn_id` 幂等累计会话输入、输出、缓存输入和请求数;升级前的历史没有可归属 usage,统计起点必须显式呈现。成功持久化一个 Turn 后,交互 Channel 收到只包含公开字段的 `CommittedTurnDelta`,其中 `history_revision` 是本批次最高 durable sequence;客户端按 revision 幂等合并,正常完成不重新加载整段历史,断线重连和失败/取消仍使用 `SessionHistory` 校准。Provider 诊断和失败审计只记录模型、消息数、工具数等请求摘要,不保存正文、reasoning 或签名 payload。修改 schema 时应:
|
||||
|
||||
1. 更新集中式 schema/迁移逻辑。
|
||||
2. 保留已有数据库的升级路径。
|
||||
@ -247,11 +256,11 @@ Turn delivery 使用 `spawn_graceful`。全局取消发生时先停止读取快
|
||||
|
||||
### WebUI 与管理 API
|
||||
|
||||
Gateway 在 `/` 提供随二进制编译的 HTML/CSS/JavaScript,不依赖外部 CDN 或前端运行服务。前端源码位于 `webui/`,由 Svelte 5 + Vite 构建,Bits UI 提供无样式的可访问组件原语。视觉层通过 `webui/src/styles.css` 中的本地 Fluent 2 语义令牌实现浅色/深色表面、六套品牌色、状态色、层级和控件状态;页面组件必须复用语义别名,不能把独立硬编码调色板或外部 Fluent 运行库引入发布产物。明暗模式和品牌色只保存在浏览器 `localStorage`,`theme-init.js` 必须在 Svelte 挂载前恢复 `data-theme` 与 `data-accent`,防止首屏颜色闪烁;这些外观选项不属于 Gateway 配置,也不跨设备同步。`build.rs` 监听前端源码、锁文件和构建配置,增量地将生产资源生成到 Cargo `OUT_DIR`,Rust 再从该目录编译嵌入;前端产物不进入仓库,最终用户使用发布二进制时不需要 Node.js。浏览器聊天继续使用 `/ws` 和 `cli_chat` 渠道,因此复用现有 dialog scope、每会话串行 worker、历史持久化和出站 lane;会话历史帧保留工具调用 ID、名称、参数和工具结果角色,WebUI 在正常完成时合并 `turn_committed` 增量并将工具信息渲染为默认折叠的工具卡片;聊天页的 Todo 侧栏默认隐藏,按 session 保存快照和未读状态,并通过结构化 `session_plan`/`plan_updated` 帧刷新,计划变化不会写入聊天历史;斜杠命令补全通过 `get_slash_commands` 获取 Gateway 的实时命令与别名清单,不在前端重复定义;WebUI 不直接调用 Provider 或 SessionManager。
|
||||
Gateway 在 `/` 提供随二进制编译的 HTML/CSS/JavaScript,不依赖外部 CDN 或前端运行服务。前端源码位于 `webui/`,由 Svelte 5 + Vite 构建,Bits UI 提供无样式的可访问组件原语。视觉层通过 `webui/src/styles.css` 中的本地 Fluent 2 语义令牌实现浅色/深色表面、六套品牌色、状态色、层级和控件状态;页面组件必须复用语义别名,不能把独立硬编码调色板或外部 Fluent 运行库引入发布产物。明暗模式和品牌色只保存在浏览器 `localStorage`,`theme-init.js` 必须在 Svelte 挂载前恢复 `data-theme` 与 `data-accent`,防止首屏颜色闪烁;这些外观选项不属于 Gateway 配置,也不跨设备同步。`build.rs` 监听前端源码、锁文件和构建配置,增量地将生产资源生成到 Cargo `OUT_DIR`,Rust 再从该目录编译嵌入;前端产物不进入仓库,最终用户使用发布二进制时不需要 Node.js。浏览器聊天继续使用 `/ws` 和 `cli_chat` 渠道,因此复用现有 dialog scope、每会话串行 worker、历史持久化和出站 lane;会话历史帧保留工具调用 ID、名称、参数和工具结果角色,WebUI 在正常完成时合并 `turn_committed` 增量并将工具信息渲染为默认折叠的工具卡片;聊天页的 Todo 侧栏默认隐藏,按 session 保存快照和未读状态,并通过结构化 `session_plan`/`plan_updated` 帧刷新,计划变化不会写入聊天历史;活动状态栏通过结构化 `session_stats` 展示当前 session 的已提交 Turn 用量与上下文占用,累计量来自 Provider usage,窗口占用明确区分 API 基准上的混合估算与纯字符估算;斜杠命令补全通过 `get_slash_commands` 获取 Gateway 的实时命令与别名清单,不在前端重复定义;WebUI 不直接调用 Provider 或 SessionManager。
|
||||
|
||||
WebUI/TUI 文件字节通过受鉴权的 HTTP 接口流式传输,WebSocket 只携带短期 `upload_id` 和结构化附件描述。`UploadRegistry` 在内存中按 `cli_chat` chat scope 校验并消费待发送上传;消息继续以 `media_refs` 保存 Gateway 本地路径,不建立永久附件资产。下载接口必须通过 client、session、message 和附件序号反查路径,不能接受客户端路径。历史附件路径失效属于正常状态,不得影响历史文本读取。待发送但未进入消息的上传由 `TaskSupervisor` 所有的限时清理任务回收。Agent 上下文会为所有媒体注入内部路径清单,客户端响应不得暴露该路径。
|
||||
|
||||
所有工具调用统一归一化为 `ToolOutput`,并由 `AgentLoop` 中唯一的 `ToolOutputProcessor` 后处理。普通文本工具仍实现 `ToolResult`,默认转换会将其包装为无产物的 `ToolOutput`;产物工具返回带 `ToolArtifact` 的输出,并用 `Model`、`User` 或 `ModelAndUser` 声明受众。处理器只发布成功工具的产物,去重后分别形成下一轮模型媒体和最终用户回复附件。工具只负责经过自身路径策略校验后声明产物与意图,不感知当前模型、Provider、Session 或 Channel。`AgentLoop` 仅将最新连续工具结果批次的模型媒体交给 `MediaHandlerRegistry`,旧工具媒体只回放文本和路径,避免历史 Base64 膨胀;用户媒体累积到本 Turn 最终 assistant 消息,随工具链原子持久化,并由 committed-history 或普通出站路径呈现。OpenAI-compatible Provider 保持 `tool` 结果为文本,并在完整工具批次后构造仅存在于请求内的临时多模态 `user` 消息;Anthropic Provider 将媒体放入对应 `tool_result.content`。媒体加载、格式或能力检查失败必须降级成文本,不得使历史记录不可读取。
|
||||
所有工具调用统一归一化为 `ToolOutput`,并由 `AgentLoop` 中唯一的 `ToolOutputProcessor` 后处理。普通文本工具仍实现 `ToolResult`,默认转换会将其包装为无产物的 `ToolOutput`;产物工具返回带 `ToolArtifact` 的输出,并用 `Model`、`User` 或 `ModelAndUser` 声明受众。处理器只发布成功工具的产物,去重后分别形成下一轮模型媒体和最终用户回复附件。工具只负责经过自身路径策略校验后声明产物与意图,不感知当前模型、Provider、Session 或 Channel。`AgentLoop` 仅将最新连续工具结果批次的模型媒体交给 `MediaHandlerRegistry`,紧随工具结果的 steering 不会使该批次媒体失去可见性,而旧工具媒体只回放文本和路径,避免历史 Base64 膨胀;用户媒体累积到本 Turn 最终 assistant 消息,随工具链原子持久化,并由 committed-history 或普通出站路径呈现。OpenAI-compatible Provider 保持 `tool` 结果为文本,并在完整工具批次后构造仅存在于请求内的临时多模态 `user` 消息,同时保持后续 user steering 的顺序;Anthropic Provider 将同批媒体放入对应 `tool_result.content`,并将紧随的 user steering 合并进 API 所需的同一 `role=user` 内容数组,持久化消息仍彼此独立。媒体加载、格式或能力检查失败必须降级成文本,不得使历史记录不可读取。
|
||||
|
||||
`browser` 是有状态工具适配器:`BrowserTool` 保持模型侧 action schema,`BrowserManager` 按每次调用是否带 `persistent_id` 分流。省略 ID 时把 PicoBot dialog 映射到随机临时 agent-browser session,并用每 session mutex 保证同一页面串行、不同 dialog 并发;长期工作需要保留登录或站点状态时,Agent 可自主创建持久身份并在后续相关 action 中持续传入同一个 ID。Manager 按持久 ID 保存 agent-browser session 和 mutex,同一 ID 跨 dialog 共享且串行,不同 ID 相互独立并可并发,Gateway 重启或 daemon 退出后继续使用原 Profile;没有全局持久化开关、默认 ID 或按 dialog 隐式选择。`browser_profiles` 在受控根目录下创建、设置语义化标签、列出或删除格式合法的 ID;标签只负责识别,选择仍使用不可变 ID,删除活动 ID 时先等待其 action 并关闭浏览器。`AgentBrowserRunner` 以 argv 和 `--json` 调用外部原生 CLI,设置硬超时、输出/content boundaries/domain allowlist,并在持久调用中传入受控 `--profile` 路径,底层 daemon 通过 Chrome CDP 工作。PicoBot 不链接 agent-browser 内部 crate、不直接暴露其 MCP、不使用 Fantoccini/ChromeDriver/WebDriver。持久 Profile 与 `allowed_domains` 因上游安全边界互斥;设置域名限制时临时浏览器仍可用,持久调用会被拒绝。截图只能写入配置的 artifact directory,并作为 `ModelAndUser` 产物返回,默认附到最终用户回复;仅当调用显式设置 `present_to_user=false` 时才作为模型内部观察。完整边界见 [AGENT_BROWSER_INTEGRATION.md](AGENT_BROWSER_INTEGRATION.md)。
|
||||
|
||||
@ -312,6 +321,8 @@ WebUI/TUI 文件字节通过受鉴权的 HTTP 接口流式传输,WebSocket 只
|
||||
5. 长操作应有超时;后台执行应交给 SubAgentManager/TaskSupervisor。
|
||||
6. 需要跨调用保存外部状态时,实现 `execute_with_context` 并按 session 隔离;不得让模型控制底层全局 session ID。
|
||||
|
||||
内置 `sleep` 只暂停当前前台工具 Future,允许 0~86400 秒且不持久化;`/stop`、Scheduler/SubAgent 超时和 Supervisor shutdown 通过丢弃外层 Future 取消计时。Turn 进入 `Cancelled` 时必须把仍为 `Running` 的工具块同步归约为 `Cancelled`,避免终态快照继续显示工具执行中。超过 24 小时或需要跨重启的等待必须使用 Scheduler/后台任务。
|
||||
|
||||
### 新增 Provider
|
||||
|
||||
1. 实现 `LLMProvider`,保持其为纯 HTTP/API 适配器。
|
||||
|
||||
1193
docs/SUB_AGENT_ORCHESTRATION_DESIGN.md
Normal file
1193
docs/SUB_AGENT_ORCHESTRATION_DESIGN.md
Normal file
File diff suppressed because it is too large
Load Diff
201
docs/superpowers/plans/2026-07-28-sleep-tool.md
Normal file
201
docs/superpowers/plans/2026-07-28-sleep-tool.md
Normal file
@ -0,0 +1,201 @@
|
||||
# Sleep Tool Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a model-callable `sleep` tool that asynchronously waits for 0~86400 whole seconds and terminates cleanly when its Turn is cancelled.
|
||||
|
||||
**Architecture:** A focused, stateless `SleepTool` validates its single argument and waits on one bounded Tokio timer. The existing default registry exposes it to agents, dropping the surrounding execution future cancels the timer, and terminal Turn reduction marks active tool blocks cancelled.
|
||||
|
||||
**Tech Stack:** Rust 2024, Tokio timers and paused-time tests, existing `Tool`/`ToolResult` interfaces, Serde JSON.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: Tool And Registration
|
||||
|
||||
### Task 1: Implement And Register `SleepTool`
|
||||
|
||||
**Files:**
|
||||
- Create: `src/tools/sleep.rs`
|
||||
- Modify: `src/tools/mod.rs:1-52`
|
||||
- Modify: `src/tools/mod.rs:74-90`
|
||||
- Test: `src/tools/sleep.rs`
|
||||
|
||||
- [ ] **Step 1: Declare the module and write failing metadata tests**
|
||||
|
||||
Add `pub mod sleep;` and `pub use sleep::SleepTool;` to `src/tools/mod.rs`. Create `src/tools/sleep.rs` with a test module that imports `super::*`, `crate::tools::Tool`, `serde_json::json`, and `std::time::Duration`. Add a synchronous test asserting the name is `sleep`, the schema requires `seconds`, and its property type is `integer` with minimum `0`.
|
||||
|
||||
- [ ] **Step 2: Write failing validation tests**
|
||||
|
||||
Add a zero-second async test asserting exact successful output `Slept for 0 second(s).`. Add a table-driven async test for `{}`, negative, fractional, string, large float, and `86401`; assert each result is unsuccessful with empty output and a populated error. Add a parser boundary test:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn accepts_24_hour_boundary() {
|
||||
assert_eq!(parse_seconds(&json!({"seconds": 86_400})), Ok(86_400));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Write failing timer and cancellation tests**
|
||||
|
||||
Add the following paused-clock elapsed test. Yield after every `advance` so expired timers are polled deterministically:
|
||||
|
||||
```rust
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn waits_for_requested_seconds() {
|
||||
let handle = tokio::spawn(async { SleepTool::new().execute(json!({"seconds": 2})).await });
|
||||
tokio::task::yield_now().await;
|
||||
tokio::time::advance(Duration::from_secs(1)).await;
|
||||
tokio::task::yield_now().await;
|
||||
assert!(!handle.is_finished());
|
||||
tokio::time::advance(Duration::from_secs(1)).await;
|
||||
tokio::task::yield_now().await;
|
||||
assert!(handle.await.unwrap().unwrap().success);
|
||||
}
|
||||
```
|
||||
|
||||
Add a 24-hour boundary test that advances to one second before the deadline, asserts the handle is unfinished, advances the final second, and asserts success.
|
||||
|
||||
Add the cancellation test, which yields before aborting to ensure the timer has been registered:
|
||||
|
||||
```rust
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn cancellation_drops_an_active_sleep() {
|
||||
let handle = tokio::spawn(async {
|
||||
SleepTool::new()
|
||||
.execute(json!({"seconds": MAX_SLEEP_SECONDS}))
|
||||
.await
|
||||
});
|
||||
tokio::task::yield_now().await;
|
||||
assert!(!handle.is_finished());
|
||||
handle.abort();
|
||||
assert!(handle.await.unwrap_err().is_cancelled());
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the focused test target and confirm RED**
|
||||
|
||||
Run: `cargo test --lib tools::sleep::tests`
|
||||
|
||||
Expected: compilation fails because `SleepTool`, `parse_seconds`, and `MAX_SLEEP_SECONDS` are not defined.
|
||||
|
||||
- [ ] **Step 5: Implement the minimal tool**
|
||||
|
||||
Implement `src/tools/sleep.rs` with this shape:
|
||||
|
||||
```rust
|
||||
use super::traits::{Tool, ToolResult};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
use std::time::Duration;
|
||||
|
||||
const MAX_SLEEP_SECONDS: u64 = 86_400;
|
||||
|
||||
pub struct SleepTool;
|
||||
|
||||
impl SleepTool {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SleepTool {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_seconds(args: &serde_json::Value) -> Result<u64, String> {
|
||||
let seconds = args.get("seconds")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.ok_or_else(|| "seconds must be a non-negative integer".to_string())?;
|
||||
if seconds > MAX_SLEEP_SECONDS {
|
||||
return Err("seconds must not exceed 86400 (24 hours)".to_string());
|
||||
}
|
||||
Ok(seconds)
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for SleepTool {
|
||||
fn name(&self) -> &str {
|
||||
"sleep"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Pause the current agent execution for a specified number of whole seconds."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"seconds": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": MAX_SLEEP_SECONDS,
|
||||
"description": "Number of whole seconds to wait, up to 24 hours."
|
||||
}
|
||||
},
|
||||
"required": ["seconds"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let seconds = match parse_seconds(&args) {
|
||||
Ok(seconds) => seconds,
|
||||
Err(error) => {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
tokio::time::sleep(Duration::from_secs(seconds)).await;
|
||||
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output: format!("Slept for {seconds} second(s)."),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Keep the default `read_only`, `concurrency_safe`, and `exclusive` methods unchanged so a batch containing `sleep` executes sequentially.
|
||||
|
||||
- [ ] **Step 6: Register the tool**
|
||||
|
||||
In `create_default_tools`, add:
|
||||
|
||||
```rust
|
||||
registry.register(SleepTool::new());
|
||||
```
|
||||
|
||||
Place it with the other stateless core tools, immediately after `CalculatorTool`.
|
||||
|
||||
- [ ] **Step 7: Run focused tests and confirm GREEN**
|
||||
|
||||
Run: `cargo test --lib tools::sleep::tests`
|
||||
|
||||
Expected: all sleep tests pass, including paused-time and cancellation cases.
|
||||
|
||||
- [ ] **Step 8: Verify the complete Rust change**
|
||||
|
||||
Run: `cargo test --lib`
|
||||
|
||||
Expected: all library tests pass.
|
||||
|
||||
Run: `cargo clippy --all-targets --all-features -- -D warnings`
|
||||
|
||||
Expected: exits successfully with no warnings.
|
||||
|
||||
Run: `cargo build`
|
||||
|
||||
Expected: debug build succeeds, including embedded WebUI build handling.
|
||||
|
||||
- [ ] **Step 9: Inspect the final diff**
|
||||
|
||||
Run: `git diff --check && git status --short && git diff -- src/tools/sleep.rs src/tools/mod.rs docs/superpowers/specs/2026-07-28-sleep-tool-design.md docs/superpowers/plans/2026-07-28-sleep-tool.md`
|
||||
|
||||
Expected: no whitespace errors; only the intended sleep implementation, Turn cancellation handling, public documentation, tests, and patch-version files are changed. Do not commit unless the user explicitly requests it.
|
||||
41
docs/superpowers/specs/2026-07-28-sleep-tool-design.md
Normal file
41
docs/superpowers/specs/2026-07-28-sleep-tool-design.md
Normal file
@ -0,0 +1,41 @@
|
||||
# Sleep Tool Design
|
||||
|
||||
## Goal
|
||||
|
||||
Add a model-callable `sleep` tool that pauses the current agent tool call for a requested number of whole seconds. This first version only waits in process and does not schedule durable or background work.
|
||||
|
||||
## Interface
|
||||
|
||||
- Tool name: `sleep`
|
||||
- Arguments: an object with one required `seconds` field
|
||||
- `seconds` must be an integer from `0` through `86400` inclusive
|
||||
- The maximum foreground wait is 24 hours
|
||||
- `0` is valid and completes immediately
|
||||
- Unknown object fields are ignored consistently with existing native tools
|
||||
|
||||
Invalid, missing, negative, fractional, or values above `86400` return an ordinary unsuccessful `ToolResult`. A successful call returns `Slept for N second(s).`, with the requested duration substituted for `N`.
|
||||
|
||||
## Implementation
|
||||
|
||||
Create a stateless `SleepTool` in `src/tools/sleep.rs`. Its `Tool::execute` implementation validates `seconds`, waits on one bounded Tokio timer, and returns success after the full duration. The asynchronous timer does not block the Gateway runtime.
|
||||
|
||||
Export the type from `src/tools/mod.rs` and register it in `create_default_tools`, making it available to the root agent and to constrained tool registries unless those registries explicitly filter it by name.
|
||||
|
||||
The tool does not persist state, create a background task, or send messages. It retains the `Tool` trait's default non-concurrency-safe classification, so a model response containing `sleep` and other calls executes that batch sequentially. `/stop`, supervisor shutdown, scheduler timeout, and sub-agent timeout cancel work by dropping the surrounding agent execution future; dropping that future also drops the current Tokio sleep timer. When a Turn is cancelled, every still-running tool block is normalized to `ToolStatus::Cancelled` before publishing the terminal snapshot.
|
||||
|
||||
The 24-hour cap bounds foreground resource retention. Longer or restart-durable waits must use Scheduler or background work rather than holding an interactive session worker.
|
||||
|
||||
## Testing
|
||||
|
||||
Unit tests cover the tool metadata and schema, immediate success for zero seconds, elapsed-time behavior using Tokio's paused clock, rejection of missing, negative, fractional, string, and over-24-hour values, acceptance of the 24-hour boundary, cancellation of an active sleeping task, and Turn cancellation normalization.
|
||||
|
||||
Run the targeted tests, `cargo test --lib`, `cargo clippy --all-targets --all-features -- -D warnings`, and `cargo build`.
|
||||
|
||||
## Out Of Scope
|
||||
|
||||
- Slash commands or direct user invocation
|
||||
- Durable sleeps that survive process restart
|
||||
- Delayed or scheduled message delivery
|
||||
- A configurable duration limit
|
||||
|
||||
This change increments only the product patch version.
|
||||
@ -42,9 +42,9 @@ Scheduler → SessionManager.handle_cron_message → AgentLoop → send_message
|
||||
|
||||
- Channels 通过 MessageBus 发布入站消息,通过 OutboundDispatcher 或每 Turn 一个的 TurnSink 接收出站写入,不感知 session 或 LLM
|
||||
- MessageBus 本体持有三条有界队列;出站路由、顺序和重试由 `OutboundDispatcher` 负责
|
||||
- SessionManager 拥有 session 状态、dialog 路由、上下文构建和每 session worker,并通过 worker 创建 AgentLoop
|
||||
- SessionManager 拥有 session 状态、dialog 路由、上下文构建、每 session worker 和活动 Turn 的 steering mailbox,并通过 worker 创建 AgentLoop
|
||||
- TurnController 是活动 Turn 状态的唯一 owner;Session 在消息原子提交成功后才发布 Completed
|
||||
- AgentLoop 跨轮无状态,接收已准备的 history 调用 LLM、执行工具并返回一次结果
|
||||
- AgentLoop 跨轮无状态,接收已准备的 history,并在安全模型边界排空本 Turn steering 后调用 LLM、执行工具并返回一次结果
|
||||
- Providers 是纯 HTTP 流客户端,无 bus/session/channel 感知;签名 reasoning 状态只回放给匹配 Provider,不下发客户端或 Channel
|
||||
- DeliveryCoordinator 只投影完整快照,不修改会话历史;慢消费者跳过中间 revision,终态显式、有界投递
|
||||
- 每个活动 Turn 独占一个 TurnSink;平台 message ID 和 reaction 清理状态只存在于 sink 内
|
||||
@ -54,6 +54,7 @@ Scheduler → SessionManager.handle_cron_message → AgentLoop → send_message
|
||||
- 复杂任务可使用 `todo` 创建 session 级计划;多个子项可通过 `delegate.plan_item_id` 并行委托,子 Agent 不能修改计划
|
||||
- WebUI 聊天复用 `/ws` 与 `cli_chat`;同源管理 API 只读取受限的日志、任务、记忆,并对白名单配置文件做原子写入
|
||||
- WebUI 使用 Svelte 5 + Vite,Bits UI 提供无样式可访问组件;`cargo build` 增量生成前端到 Cargo `OUT_DIR`,再嵌入单二进制,仓库不保存生成产物
|
||||
- WebUI 通过 `get_session_stats`/`session_stats` 显示当前会话累计输入输出 Token 和上下文窗口占用;`/info [--json]` 读取同一份 SessionStats
|
||||
|
||||
## 关键约束
|
||||
|
||||
@ -64,7 +65,8 @@ Scheduler → SessionManager.handle_cron_message → AgentLoop → send_message
|
||||
- 配置目录 `.env` 与 workspace `.env` 仅在单线程启动阶段分层加载,并使用 `unsafe { env::set_var(...) }` 写入进程环境;优先级为既有进程环境 > workspace > 配置目录
|
||||
- `browser` 工具默认启用,只有 `browser.enabled=false` 时不注册;缺少 CLI/Chrome 不阻止 Gateway 启动,但 health 和实际调用会给出安装错误。每次调用按参数分流:不传 `persistent_id` 时按 dialog 使用普通临时浏览器;长期工作时 Agent 可自主创建持久身份,并在后续相关 action 中持续传入同一个 ID。同一 ID 跨 dialog 共享 session/锁,不同 ID 相互独立并可并发。`browser_profiles` 只在受控根目录中创建、设置语义标签、列出或删除合法 ID;没有全局持久化开关、默认 ID,也不自动按 dialog 建立或选择持久 Profile,不依赖 Fantoccini/ChromeDriver/WebDriver
|
||||
- 所有工具调用统一包装为 `ToolOutput` 并经过公共处理器;产物按模型/用户受众分流。浏览器截图默认同时供模型查看并附到最终回复,`file_read` 图片默认仅供模型理解
|
||||
- 同一 session 的普通消息串行处理,不同 session 可并发;session 队列容量为 32,满时明确拒绝
|
||||
- 同一 session 只运行一个 Turn;活动 Turn 期间普通输入默认 steering,`/queue` 明确等待下一 Turn,不同 session 可并发
|
||||
- steering mailbox 容量为 32 条/64 KiB,满或关闭时可靠回退到容量 32 的 session 队列;两者都无法接收时明确拒绝
|
||||
- 出站消息按 `(channel, chat_id)` 分 lane 保序;lane 容量为 64,慢目标不阻塞其他目标
|
||||
- 活动 Turn 与普通出站消息共享 `(channel, chat_id)` 写锁;禁止把 token delta 放入 MessageBus
|
||||
- `cli_chat` 向 TUI/WebUI 发送统一 `turn_updated` 完整快照;飞书默认 FinalOnly,开启 `live_updates` 后编辑同一卡片
|
||||
@ -138,7 +140,7 @@ create → 存入 Storage → 载入 memory → 设为当前 dialog
|
||||
|
||||
### 消息处理与并发
|
||||
|
||||
普通消息先 `try_send` 到该 session 的有界 worker 队列,Gateway 主 processor 随即返回 `AgentProcessing`。Slash command 直接执行,不进入此队列,因此 `/stop` 不会排在长模型调用后。
|
||||
没有活动 Turn 时,普通消息先 `try_send` 到该 session 的有界 worker 队列,Gateway 主 processor 随即返回 `AgentProcessing`。活动 Turn 期间普通消息默认进入有界 steering mailbox,并在完整工具批次结束后或无工具最终回复边界作为真实 `role=user` 消息注入下一次模型调用;`/queue <message>` 绕过 mailbox,明确进入下一 Turn。`/stop` 直接取消当前 Turn 并清空 mailbox 与普通队列,不会排在长模型调用后。
|
||||
|
||||
Worker 的处理原则:
|
||||
|
||||
@ -147,6 +149,7 @@ Worker 的处理原则:
|
||||
3. 提交由旧快照产生的结果前重新验证 generation/version,防止 `/stop`、`/clear` 或 `/delete` 后写回陈旧状态。
|
||||
4. Session 持久化由独立 `persistence_lock` 串行化;批量消息使用原子写入,失败时精确回滚内存后缀。
|
||||
5. 上下文溢出时按 Provider 返回的真实限制重新压缩并重试。
|
||||
6. mailbox 的接收与关闭原子互斥;所有输入在 Session 锁内取得单调序号,未消费 steering 与普通队列按该序号恢复,不能丢失或互相超越。
|
||||
|
||||
WebUI/TUI 的 Active Turn 使用 `send_message(files=...)` 向自身 session 投递附件时,附件暂存到 task-local Turn delivery,成功结束后并入最终 assistant 消息,因此工具链始终排在附件回复之前且不会出现自引用来源前缀。其他自投递要求 task-local Turn ID 与 session 的 active Turn 匹配;历史中的 assistant/system 附件只作为文本清单提供给模型,原生媒体块仅用于 user 输入和当前工具结果。
|
||||
|
||||
@ -294,9 +297,10 @@ Gateway 关停顺序:
|
||||
| `/rename <title>` | 重命名当前对话 |
|
||||
| `/delete` | 删除当前对话 |
|
||||
| `/compact` | 手动触发上下文压缩 |
|
||||
| `/info` | 显示当前对话信息 |
|
||||
| `/info [--json]` | 显示当前对话、累计 Token 与上下文窗口信息;可选 JSON 输出 |
|
||||
| `/dump` | 保存当前对话为 markdown |
|
||||
| `/?`, `/help` | 显示帮助 |
|
||||
| `/mcp` | 显示 MCP 状态 |
|
||||
| `/health` | 检查 PicoBot 运行依赖 |
|
||||
| `/queue <message>` | 等当前 Turn 完成后作为下一 Turn 处理 |
|
||||
| `/stop` | 停止当前任务并清空消息队列 |
|
||||
|
||||
@ -24,6 +24,8 @@
|
||||
| `last_consolidated_at` | INTEGER | 上次记忆归并时间 |
|
||||
| `last_compressed_message_at` | INTEGER | 上次上下文压缩边界时间戳 |
|
||||
|
||||
`session_turn_usage` 以 `turn_id` 幂等保存已提交 Turn 的 Provider usage,包括累计输入、输出、缓存输入、请求数和最后一次请求的 prompt tokens。它与 Turn 消息批次在同一事务中提交,供 WebUI 状态栏和 `/info` 使用;升级前历史无法可靠回填,因此统计起点以首条 usage 记录为准。
|
||||
|
||||
`(channel, chat_id, dialog_id)` 唯一。普通列表排除 `deleted_at`;是否包含归档记录由查询参数决定。
|
||||
|
||||
## messages 表
|
||||
|
||||
@ -202,6 +202,10 @@ Cron 不是一个带 `action` 的统一工具,而是六个独立工具;仅
|
||||
|
||||
用于交互式程序和需要保持状态的长运行命令。`action` 支持 `spawn`、`write`、`read`、`kill`、`list`;`write/read/kill` 需要 `session_id`。Gateway 进程退出时 PTY manager 会清理子进程。
|
||||
|
||||
## sleep — 前台等待
|
||||
|
||||
参数 `seconds` 接受 0~86400 的整数。工具只暂停当前 Agent 工具调用,不持久化、不发送消息,也不保证跨进程重启继续;用户 `/stop`、Scheduler/SubAgent 超时和 Gateway shutdown 都会取消等待。超过 24 小时或需要可靠延迟执行时应使用 Scheduler。
|
||||
|
||||
## http_request / web_fetch — HTTP 和 Web 工具
|
||||
|
||||
`http_request` 支持 GET/POST/PUT/DELETE/PATCH、headers 和字符串 body;`web_fetch` 提取 HTML/JSON 的可读文本。两者校验 URL 与 DNS 解析结果,阻止回环、私网、link-local 和本地域名,并禁用自动重定向,以降低 SSRF 风险。
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
use crate::agent::context_compressor::estimate_tokens;
|
||||
use crate::agent::media_handler::MediaHandlerRegistry;
|
||||
use crate::agent::steering::SteeringDrain;
|
||||
use crate::agent::system_prompt::build_system_prompt;
|
||||
use crate::agent::turn_event::{AgentTurnContext, TurnEvent};
|
||||
use crate::bus::message::ContentBlock;
|
||||
@ -37,11 +38,33 @@ fn should_include_message_media(messages: &[ChatMessage], index: usize) -> bool
|
||||
if message.role != "tool" {
|
||||
return true;
|
||||
}
|
||||
let active_tool_start = messages
|
||||
|
||||
// Tool media is replayed only for the most recent tool-call batch. A
|
||||
// steering message is a real `user` message and therefore breaks the
|
||||
// contiguous `assistant(tool_calls), tool...` shape used by older code.
|
||||
// Find the latest assistant tool-call declaration instead, then keep its
|
||||
// tool results eligible until the next assistant message. This keeps a
|
||||
// screenshot returned by a tool visible when the user steers immediately
|
||||
// after that tool batch.
|
||||
let Some(tool_call_start) = messages.iter().rposition(|candidate| {
|
||||
candidate.role == "assistant"
|
||||
&& candidate
|
||||
.tool_calls
|
||||
.as_ref()
|
||||
.is_some_and(|calls| !calls.is_empty())
|
||||
}) else {
|
||||
return false;
|
||||
};
|
||||
if index <= tool_call_start {
|
||||
return false;
|
||||
}
|
||||
let no_assistant_before = !messages[tool_call_start + 1..index]
|
||||
.iter()
|
||||
.rposition(|candidate| candidate.role != "tool")
|
||||
.map_or(0, |last_non_tool| last_non_tool + 1);
|
||||
index >= active_tool_start
|
||||
.any(|candidate| candidate.role == "assistant");
|
||||
let no_assistant_after = !messages[index + 1..]
|
||||
.iter()
|
||||
.any(|candidate| candidate.role == "assistant");
|
||||
no_assistant_before && no_assistant_after
|
||||
}
|
||||
|
||||
/// Build content blocks from text and media, respecting model input capabilities
|
||||
@ -328,6 +351,10 @@ pub struct AgentProcessResult {
|
||||
pub emitted_messages: Vec<ChatMessage>,
|
||||
pub total_tokens: Option<u32>,
|
||||
pub usage: Option<crate::providers::Usage>,
|
||||
/// Provider usage for the final successful request in this Turn. This is
|
||||
/// the correct basis for context-window occupancy; `usage` is accumulated
|
||||
/// across every tool iteration.
|
||||
pub last_request_usage: Option<crate::providers::Usage>,
|
||||
}
|
||||
|
||||
fn merge_usage(total: &mut crate::providers::Usage, next: &crate::providers::Usage) {
|
||||
@ -582,6 +609,50 @@ impl AgentLoop {
|
||||
}
|
||||
}
|
||||
|
||||
/// Add steering messages to the in-memory transcript in receive order.
|
||||
/// They remain ordinary `role=user` messages so every provider sees the
|
||||
/// same conversation semantics and persistence can commit them alongside
|
||||
/// the rest of this turn.
|
||||
fn append_steering_messages(
|
||||
messages: &mut Vec<ChatMessage>,
|
||||
emitted_messages: &mut Vec<ChatMessage>,
|
||||
consumed_steering: &mut Vec<ChatMessage>,
|
||||
steering_messages: Vec<ChatMessage>,
|
||||
turn: &AgentTurnContext,
|
||||
iteration: u32,
|
||||
) {
|
||||
for mut message in steering_messages {
|
||||
// Session routes only ordinary user input to the mailbox. Keep a
|
||||
// defensive normalization here because the mailbox is public and
|
||||
// can also be used by embedders/tests.
|
||||
message.role = "user".to_string();
|
||||
if message.turn_id.is_none() {
|
||||
message.turn_id = Some(turn.turn_id.clone());
|
||||
}
|
||||
if message.iteration.is_none() {
|
||||
message.iteration = Some(iteration);
|
||||
}
|
||||
consumed_steering.push(message.clone());
|
||||
messages.push(message.clone());
|
||||
emitted_messages.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
fn close_steering(turn: Option<&AgentTurnContext>) {
|
||||
if let Some(mailbox) = turn.and_then(AgentTurnContext::steering) {
|
||||
mailbox.close();
|
||||
}
|
||||
}
|
||||
|
||||
fn restore_steering(turn: Option<&AgentTurnContext>, consumed_steering: Vec<ChatMessage>) {
|
||||
if consumed_steering.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Some(mailbox) = turn.and_then(AgentTurnContext::steering) {
|
||||
mailbox.restore_front(consumed_steering);
|
||||
}
|
||||
}
|
||||
|
||||
fn chat_message_to_llm_message(&self, m: &ChatMessage, include_media: bool) -> Message {
|
||||
let content = if m.media_refs.is_empty() || !include_media {
|
||||
vec![ContentBlock::text(&m.content)]
|
||||
@ -703,13 +774,20 @@ impl AgentLoop {
|
||||
// Track tool calls for loop detection
|
||||
let mut loop_detector = LoopDetector::new(LoopDetectorConfig::default());
|
||||
let mut emitted_messages = Vec::new();
|
||||
// Steering messages are removed from the mailbox only at safe
|
||||
// boundaries. Keep a local copy until this invocation commits; if a
|
||||
// later provider/tool request fails, restore them before Session
|
||||
// retries from persisted history.
|
||||
let mut consumed_steering = Vec::new();
|
||||
let mut reply_media_refs = Vec::new();
|
||||
let mut accumulated_tokens: u32 = 0;
|
||||
let mut accumulated_usage = crate::providers::Usage::default();
|
||||
let mut last_request_usage = None;
|
||||
|
||||
for iteration in 0..self.max_iterations {
|
||||
#[cfg(debug_assertions)]
|
||||
tracing::debug!(iteration, "Agent iteration started");
|
||||
let last_iteration = iteration.saturating_add(1) >= self.max_iterations;
|
||||
|
||||
// Preemptive context check: trim old tool results if token estimate
|
||||
// exceeds 80% of context window to prevent mid-loop overflow.
|
||||
@ -748,14 +826,31 @@ impl AgentLoop {
|
||||
};
|
||||
|
||||
// Call LLM
|
||||
let iteration = u32::try_from(iteration)
|
||||
.map_err(|_| AgentError::Other("tool iteration exceeds u32".to_string()))?;
|
||||
let response = self
|
||||
let iteration = match u32::try_from(iteration) {
|
||||
Ok(iteration) => iteration,
|
||||
Err(_) => {
|
||||
Self::restore_steering(turn.as_ref(), consumed_steering);
|
||||
return Err(AgentError::Other("tool iteration exceeds u32".to_string()));
|
||||
}
|
||||
};
|
||||
let response = match self
|
||||
.stream_completion(request, iteration, turn.as_ref())
|
||||
.await?;
|
||||
.await
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(error) => {
|
||||
// The invocation may be retried from persisted history.
|
||||
// Restore every steering message consumed by an earlier
|
||||
// boundary; Session decides whether to retry or close
|
||||
// and queue them after receiving this error.
|
||||
Self::restore_steering(turn.as_ref(), consumed_steering);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
|
||||
accumulated_tokens = accumulated_tokens.saturating_add(response.usage.total_tokens);
|
||||
merge_usage(&mut accumulated_usage, &response.usage);
|
||||
last_request_usage = Some(response.usage.clone());
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
tracing::debug!(
|
||||
@ -765,11 +860,44 @@ impl AgentLoop {
|
||||
"LLM response received"
|
||||
);
|
||||
|
||||
// If no tool calls, this is the final response
|
||||
// If no tool calls, this is normally the final response. When a
|
||||
// steering message arrived and another model iteration remains,
|
||||
// preserve this assistant message as an intermediate transcript
|
||||
// entry and continue with the user input. At the last iteration
|
||||
// there is no budget for another normal request, so close the
|
||||
// mailbox and let Session move any pending messages to its queue.
|
||||
if response.tool_calls.is_empty() {
|
||||
let mut assistant_message = ChatMessage::assistant(response.content);
|
||||
assistant_message.reasoning_content = response.reasoning_content;
|
||||
assistant_message.provider_state = response.provider_state;
|
||||
let steering = turn.as_ref().and_then(AgentTurnContext::steering);
|
||||
let pending = if last_iteration {
|
||||
if let Some(mailbox) = steering.as_ref() {
|
||||
mailbox.close();
|
||||
}
|
||||
None
|
||||
} else {
|
||||
steering.as_ref().map(|mailbox| mailbox.drain_or_close())
|
||||
};
|
||||
|
||||
if let Some(SteeringDrain::Messages(steering_messages)) = pending {
|
||||
let Some(turn_context) = turn.as_ref() else {
|
||||
unreachable!("steering messages require a turn context");
|
||||
};
|
||||
Self::annotate_message(&mut assistant_message, turn.as_ref(), iteration, false);
|
||||
messages.push(assistant_message.clone());
|
||||
emitted_messages.push(assistant_message);
|
||||
Self::append_steering_messages(
|
||||
&mut messages,
|
||||
&mut emitted_messages,
|
||||
&mut consumed_steering,
|
||||
steering_messages,
|
||||
turn_context,
|
||||
iteration,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
attach_reply_media(&mut assistant_message, &reply_media_refs);
|
||||
Self::annotate_message(&mut assistant_message, turn.as_ref(), iteration, true);
|
||||
emitted_messages.push(assistant_message.clone());
|
||||
@ -782,13 +910,17 @@ impl AgentLoop {
|
||||
emitted_messages,
|
||||
total_tokens: Some(accumulated_tokens),
|
||||
usage: Some(accumulated_usage),
|
||||
last_request_usage,
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(turn) = turn.as_ref() {
|
||||
turn.emitter
|
||||
if let Some(turn) = turn.as_ref()
|
||||
&& let Err(error) = turn
|
||||
.emitter
|
||||
.emit(TurnEvent::TextSegmentFinished { iteration })
|
||||
.map_err(|error| AgentError::Other(format!("turn event rejected: {error}")))?;
|
||||
{
|
||||
Self::restore_steering(Some(turn), consumed_steering);
|
||||
return Err(AgentError::Other(format!("turn event rejected: {error}")));
|
||||
}
|
||||
|
||||
// Execute tool calls. User-visible progress is emitted through the
|
||||
@ -818,14 +950,21 @@ impl AgentLoop {
|
||||
emitted_messages.push(assistant_message);
|
||||
|
||||
// Execute tools and add results to messages
|
||||
let tool_results = self
|
||||
let tool_results = match self
|
||||
.execute_tools(
|
||||
&response.tool_calls,
|
||||
iteration,
|
||||
turn.as_ref(),
|
||||
&tool_context,
|
||||
)
|
||||
.await?;
|
||||
.await
|
||||
{
|
||||
Ok(results) => results,
|
||||
Err(error) => {
|
||||
Self::restore_steering(turn.as_ref(), consumed_steering);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
|
||||
for result in &tool_results {
|
||||
extend_unique_media(&mut reply_media_refs, &result.reply_media_refs);
|
||||
@ -879,6 +1018,31 @@ impl AgentLoop {
|
||||
}
|
||||
}
|
||||
|
||||
// A complete tool batch is the first safe steering boundary. Do
|
||||
// not drain at the final available iteration: those inputs must
|
||||
// remain in the closed mailbox for Session to queue after this
|
||||
// turn rather than being silently consumed by the summary call.
|
||||
if let Some(mailbox) = turn.as_ref().and_then(AgentTurnContext::steering) {
|
||||
if last_iteration {
|
||||
mailbox.close();
|
||||
} else {
|
||||
let steering_messages = mailbox.drain();
|
||||
if !steering_messages.is_empty() {
|
||||
let Some(turn_context) = turn.as_ref() else {
|
||||
unreachable!("steering messages require a turn context");
|
||||
};
|
||||
Self::append_steering_messages(
|
||||
&mut messages,
|
||||
&mut emitted_messages,
|
||||
&mut consumed_steering,
|
||||
steering_messages,
|
||||
turn_context,
|
||||
iteration,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Loop continues to next iteration with updated messages
|
||||
#[cfg(debug_assertions)]
|
||||
tracing::debug!(
|
||||
@ -889,6 +1053,10 @@ impl AgentLoop {
|
||||
}
|
||||
|
||||
// Max iterations reached - ask LLM for a summary based on completed work
|
||||
// Any mailbox input still pending at this boundary cannot be consumed
|
||||
// without exceeding the configured tool-iteration budget. Keep it in
|
||||
// the closed mailbox for Session's next-turn fallback.
|
||||
Self::close_steering(turn.as_ref());
|
||||
tracing::warn!("Max iterations reached, requesting final summary from LLM");
|
||||
|
||||
// Add a message asking for summary
|
||||
@ -908,8 +1076,13 @@ impl AgentLoop {
|
||||
tools: None, // No tools in final summary call
|
||||
};
|
||||
|
||||
let summary_iteration = u32::try_from(self.max_iterations)
|
||||
.map_err(|_| AgentError::Other("tool iteration exceeds u32".to_string()))?;
|
||||
let summary_iteration = match u32::try_from(self.max_iterations) {
|
||||
Ok(iteration) => iteration,
|
||||
Err(_) => {
|
||||
Self::restore_steering(turn.as_ref(), consumed_steering);
|
||||
return Err(AgentError::Other("tool iteration exceeds u32".to_string()));
|
||||
}
|
||||
};
|
||||
match self
|
||||
.stream_completion(request, summary_iteration, turn.as_ref())
|
||||
.await
|
||||
@ -917,6 +1090,7 @@ impl AgentLoop {
|
||||
Ok(response) => {
|
||||
accumulated_tokens = accumulated_tokens.saturating_add(response.usage.total_tokens);
|
||||
merge_usage(&mut accumulated_usage, &response.usage);
|
||||
last_request_usage = Some(response.usage.clone());
|
||||
let mut assistant_message = ChatMessage::assistant(response.content);
|
||||
assistant_message.reasoning_content = response.reasoning_content;
|
||||
assistant_message.provider_state = response.provider_state;
|
||||
@ -937,6 +1111,7 @@ impl AgentLoop {
|
||||
emitted_messages,
|
||||
total_tokens: Some(accumulated_tokens),
|
||||
usage: Some(accumulated_usage),
|
||||
last_request_usage,
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
@ -946,8 +1121,9 @@ impl AgentLoop {
|
||||
"I reached the maximum number of tool call iterations ({}) without completing the task. The work done so far has been lost due to an error. Please try breaking the task into smaller steps.",
|
||||
self.max_iterations
|
||||
);
|
||||
if let Some(turn) = turn.as_ref() {
|
||||
turn.emitter
|
||||
if let Some(turn) = turn.as_ref()
|
||||
&& let Err(error) = turn
|
||||
.emitter
|
||||
.emit(TurnEvent::TextSegmentFinished {
|
||||
iteration: summary_iteration,
|
||||
})
|
||||
@ -957,19 +1133,17 @@ impl AgentLoop {
|
||||
delta: fallback.clone(),
|
||||
})
|
||||
})
|
||||
.map_err(|error| {
|
||||
AgentError::Other(format!("turn event rejected: {error}"))
|
||||
})?;
|
||||
{
|
||||
Self::restore_steering(Some(turn), consumed_steering);
|
||||
return Err(AgentError::Other(format!("turn event rejected: {error}")));
|
||||
}
|
||||
let mut final_message = ChatMessage::assistant(fallback);
|
||||
attach_reply_media(&mut final_message, &reply_media_refs);
|
||||
Self::annotate_message(&mut final_message, turn.as_ref(), summary_iteration, true);
|
||||
emitted_messages.push(final_message.clone());
|
||||
let turn_usage = (accumulated_usage.total_tokens > 0).then_some(&accumulated_usage);
|
||||
crate::observability::metrics::global_metrics().record_turn(
|
||||
turn_usage,
|
||||
turn_start.elapsed().as_millis() as u64,
|
||||
);
|
||||
crate::observability::metrics::global_metrics()
|
||||
.record_turn(turn_usage, turn_start.elapsed().as_millis() as u64);
|
||||
Ok(AgentProcessResult {
|
||||
final_response: final_message,
|
||||
emitted_messages,
|
||||
@ -979,6 +1153,7 @@ impl AgentLoop {
|
||||
None
|
||||
},
|
||||
usage: (accumulated_usage.total_tokens > 0).then_some(accumulated_usage),
|
||||
last_request_usage,
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -1170,6 +1345,7 @@ impl AgentLoop {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::agent::SteeringMailbox;
|
||||
use crate::observability::{MultiObserver, Observer};
|
||||
use crate::providers::{
|
||||
ChatCompletionResponse, FinishReason, ProviderChunk, ProviderStream, Usage,
|
||||
@ -1183,6 +1359,10 @@ mod tests {
|
||||
|
||||
struct StreamingTextProvider;
|
||||
|
||||
struct ErrorAfterFirstProvider {
|
||||
requests: std::sync::Mutex<usize>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl LLMProvider for StreamingTextProvider {
|
||||
async fn stream(
|
||||
@ -1228,6 +1408,54 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl LLMProvider for ErrorAfterFirstProvider {
|
||||
async fn stream(
|
||||
&self,
|
||||
_request: ChatCompletionRequest,
|
||||
) -> Result<ProviderStream, crate::providers::DynProviderError> {
|
||||
let request_number = {
|
||||
let mut requests = self.requests.lock().unwrap();
|
||||
*requests += 1;
|
||||
*requests
|
||||
};
|
||||
if request_number > 1 {
|
||||
return Err(Box::new(std::io::Error::other(
|
||||
"synthetic provider failure",
|
||||
)));
|
||||
}
|
||||
let chunks = vec![
|
||||
ProviderChunk::Metadata {
|
||||
id: "first".into(),
|
||||
model: "error-after-first".into(),
|
||||
},
|
||||
ProviderChunk::Text("first response".into()),
|
||||
ProviderChunk::Usage(Usage {
|
||||
prompt_tokens: 1,
|
||||
completion_tokens: 1,
|
||||
total_tokens: 2,
|
||||
..Usage::default()
|
||||
}),
|
||||
ProviderChunk::Done(FinishReason::Stop),
|
||||
];
|
||||
Ok(Box::pin(futures_util::stream::iter(
|
||||
chunks.into_iter().map(Ok),
|
||||
)))
|
||||
}
|
||||
|
||||
fn ptype(&self) -> &str {
|
||||
"test"
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
"error-after-first"
|
||||
}
|
||||
|
||||
fn model_id(&self) -> &str {
|
||||
"error-after-first"
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn process_streaming_emits_turn_blocks_and_stamps_durable_message() {
|
||||
let agent = AgentLoop::with_provider(
|
||||
@ -1280,6 +1508,87 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn steering_after_final_response_is_injected_at_next_model_boundary() {
|
||||
let agent = AgentLoop::with_provider(
|
||||
Arc::new(StreamingTextProvider),
|
||||
2,
|
||||
"streaming-test".into(),
|
||||
PathBuf::from("."),
|
||||
Vec::new(),
|
||||
);
|
||||
let mailbox = Arc::new(SteeringMailbox::new());
|
||||
mailbox
|
||||
.try_push(ChatMessage::user("please include the log summary"))
|
||||
.unwrap();
|
||||
let (controller, emitter, _) = TurnController::start("session", "assistant-id");
|
||||
let initial = controller.snapshot();
|
||||
let context = AgentTurnContext::new_with_steering(
|
||||
initial.id.0.clone(),
|
||||
initial.message_id.clone(),
|
||||
emitter,
|
||||
mailbox.clone(),
|
||||
);
|
||||
|
||||
let result = agent
|
||||
.process_streaming(vec![ChatMessage::user("hi")], context)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let positions: Vec<(usize, &str)> = result
|
||||
.emitted_messages
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, message)| (index, message.role.as_str()))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
positions,
|
||||
vec![(0, "assistant"), (1, "user"), (2, "assistant")]
|
||||
);
|
||||
assert_eq!(
|
||||
result.emitted_messages[1].content,
|
||||
"please include the log summary"
|
||||
);
|
||||
assert_eq!(result.final_response.content, "hello world");
|
||||
assert!(mailbox.is_closed());
|
||||
assert!(mailbox.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_error_after_steering_drain_restores_input_for_retry() {
|
||||
let agent = AgentLoop::with_provider(
|
||||
Arc::new(ErrorAfterFirstProvider {
|
||||
requests: std::sync::Mutex::new(0),
|
||||
}),
|
||||
2,
|
||||
"error-after-first".into(),
|
||||
PathBuf::from("."),
|
||||
Vec::new(),
|
||||
);
|
||||
let mailbox = Arc::new(SteeringMailbox::new());
|
||||
mailbox.try_push(ChatMessage::user("retry me")).unwrap();
|
||||
let (controller, emitter, _) = TurnController::start("session", "assistant-id");
|
||||
let initial = controller.snapshot();
|
||||
let context = AgentTurnContext::new_with_steering(
|
||||
initial.id.0.clone(),
|
||||
initial.message_id.clone(),
|
||||
emitter,
|
||||
mailbox.clone(),
|
||||
);
|
||||
|
||||
let error = agent
|
||||
.process_streaming(vec![ChatMessage::user("hi")], context)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(error, AgentError::LlmError(message) if message.contains("synthetic provider failure"))
|
||||
);
|
||||
assert!(!mailbox.is_closed());
|
||||
let restored = mailbox.take_pending();
|
||||
assert_eq!(restored.len(), 1);
|
||||
assert_eq!(restored[0].content, "retry me");
|
||||
}
|
||||
|
||||
impl TestObserver {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
@ -1527,6 +1836,70 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_media_remains_visible_when_steering_follows_tool_batch() {
|
||||
use std::io::Write;
|
||||
|
||||
let mut image = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
|
||||
image.write_all(b"\x89PNG\r\n\x1a\nminimal").unwrap();
|
||||
let provider = Arc::new(ToolMediaProvider {
|
||||
image_path: image.path().to_string_lossy().into_owned(),
|
||||
tool_name: "file_read".to_string(),
|
||||
requests: std::sync::Mutex::new(Vec::new()),
|
||||
});
|
||||
let tools = Arc::new(ToolRegistry::new());
|
||||
tools.register(FileReadTool::new());
|
||||
let agent = AgentLoop::with_provider_and_tools(
|
||||
provider.clone(),
|
||||
tools,
|
||||
2,
|
||||
"vision-test".to_string(),
|
||||
std::env::current_dir().unwrap(),
|
||||
vec!["text".to_string(), "image".to_string()],
|
||||
);
|
||||
|
||||
let mailbox = Arc::new(SteeringMailbox::new());
|
||||
mailbox
|
||||
.try_push(ChatMessage::user("also explain what you found"))
|
||||
.unwrap();
|
||||
let (controller, emitter, _) = TurnController::start("session", "assistant-id");
|
||||
let turn = controller.snapshot();
|
||||
let context = AgentTurnContext::new_with_steering(
|
||||
turn.id.0.clone(),
|
||||
turn.message_id.clone(),
|
||||
emitter,
|
||||
mailbox,
|
||||
);
|
||||
let result = agent
|
||||
.process_streaming(vec![ChatMessage::user("inspect the image")], context)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.final_response.content, "image seen");
|
||||
let requests = provider.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 2);
|
||||
let second_messages = &requests[1].messages;
|
||||
let tool_index = second_messages
|
||||
.iter()
|
||||
.position(|message| message.role == "tool")
|
||||
.unwrap();
|
||||
assert!(
|
||||
second_messages[tool_index]
|
||||
.content
|
||||
.iter()
|
||||
.any(|block| matches!(block, ContentBlock::ImageUrl { .. }))
|
||||
);
|
||||
assert!(
|
||||
second_messages[tool_index + 1..]
|
||||
.iter()
|
||||
.any(|message| message.role == "user"
|
||||
&& message.content.iter().any(|block| matches!(
|
||||
block,
|
||||
ContentBlock::Text { text } if text.contains("also explain what you found")
|
||||
)))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_execute_in_parallel_single_tool() {
|
||||
// Would need a proper setup with AgentLoop to test fully
|
||||
@ -1766,6 +2139,11 @@ mod tests {
|
||||
assert!(should_include_message_media(&messages, 4));
|
||||
|
||||
messages.push(ChatMessage::user("next turn"));
|
||||
// A same-turn steering message is a real user message, but it must
|
||||
// not hide media returned by the immediately preceding tool batch.
|
||||
assert!(should_include_message_media(&messages, 4));
|
||||
|
||||
messages.push(ChatMessage::assistant("final"));
|
||||
assert!(!should_include_message_media(&messages, 4));
|
||||
}
|
||||
}
|
||||
|
||||
@ -70,8 +70,8 @@ pub struct ContextCompressor {
|
||||
session_id: Option<String>,
|
||||
/// Message count sent in the last LLM call (used to split known/new history).
|
||||
last_sent_message_count: Option<usize>,
|
||||
/// Real total_tokens from the last API response.
|
||||
last_api_total_tokens: Option<u32>,
|
||||
/// Real prompt_tokens from the final API request in the last completed Turn.
|
||||
last_api_prompt_tokens: Option<u32>,
|
||||
}
|
||||
|
||||
/// Result of context compression.
|
||||
@ -85,7 +85,7 @@ pub struct TokenInfo {
|
||||
pub context_window: usize,
|
||||
pub threshold: usize,
|
||||
pub estimated_tokens: usize,
|
||||
pub last_api_tokens: Option<u32>,
|
||||
pub last_prompt_tokens: Option<u32>,
|
||||
pub cache_active: bool,
|
||||
}
|
||||
|
||||
@ -104,7 +104,7 @@ impl ContextCompressor {
|
||||
memory,
|
||||
session_id: None,
|
||||
last_sent_message_count: None,
|
||||
last_api_total_tokens: None,
|
||||
last_api_prompt_tokens: None,
|
||||
}
|
||||
}
|
||||
|
||||
@ -123,7 +123,7 @@ impl ContextCompressor {
|
||||
memory,
|
||||
session_id: None,
|
||||
last_sent_message_count: None,
|
||||
last_api_total_tokens: None,
|
||||
last_api_prompt_tokens: None,
|
||||
}
|
||||
}
|
||||
|
||||
@ -137,24 +137,28 @@ impl ContextCompressor {
|
||||
self.context_window = window;
|
||||
}
|
||||
|
||||
pub fn context_window(&self) -> usize {
|
||||
self.context_window
|
||||
}
|
||||
|
||||
/// Record the API's reported token usage from the last completed turn.
|
||||
/// `msg_count`: number of messages sent to LLM in that call.
|
||||
/// `tokens`: `total_tokens` from the API response.
|
||||
/// `tokens`: `prompt_tokens` from the final API request in the Turn.
|
||||
pub fn set_last_api_info(&mut self, msg_count: usize, tokens: Option<u32>) {
|
||||
self.last_sent_message_count = Some(msg_count);
|
||||
self.last_api_total_tokens = tokens;
|
||||
self.last_api_prompt_tokens = tokens;
|
||||
}
|
||||
|
||||
/// Invalidate the cached API token info — called after compression modifies messages.
|
||||
fn invalidate_token_cache(&mut self) {
|
||||
self.last_sent_message_count = None;
|
||||
self.last_api_total_tokens = None;
|
||||
self.last_api_prompt_tokens = None;
|
||||
}
|
||||
|
||||
/// Hybrid token estimation: API-reported tokens for known history +
|
||||
/// char/4 estimate for new messages since last API call.
|
||||
fn token_estimate_with_history(&self, messages: &[ChatMessage]) -> usize {
|
||||
match (self.last_api_total_tokens, self.last_sent_message_count) {
|
||||
match (self.last_api_prompt_tokens, self.last_sent_message_count) {
|
||||
(Some(known), Some(known_count)) if messages.len() > known_count => {
|
||||
let delta = &messages[known_count..];
|
||||
known as usize + estimate_tokens(delta)
|
||||
@ -175,8 +179,8 @@ impl ContextCompressor {
|
||||
context_window: self.context_window,
|
||||
threshold: self.threshold(),
|
||||
estimated_tokens: self.token_estimate_with_history(messages),
|
||||
last_api_tokens: self.last_api_total_tokens,
|
||||
cache_active: self.last_api_total_tokens.is_some(),
|
||||
last_prompt_tokens: self.last_api_prompt_tokens,
|
||||
cache_active: self.last_api_prompt_tokens.is_some(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -797,6 +801,21 @@ mod tests {
|
||||
assert_eq!(compressor.threshold(), 89_600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_prompt_usage_is_the_base_for_new_history_estimates() {
|
||||
let mut compressor =
|
||||
ContextCompressor::new(mock_provider(), 128_000, test_memory_manager());
|
||||
compressor.set_last_api_info(1, Some(100));
|
||||
let messages = vec![
|
||||
ChatMessage::user("known"),
|
||||
ChatMessage::assistant("new response"),
|
||||
];
|
||||
|
||||
let info = compressor.token_info(&messages);
|
||||
assert_eq!(info.last_prompt_tokens, Some(100));
|
||||
assert_eq!(info.estimated_tokens, 100 + estimate_tokens(&messages[1..]));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_compress_if_needed_fast_trims_tool_results() {
|
||||
// context_window=200 → threshold=100.
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
pub mod agent_loop;
|
||||
pub mod context_compressor;
|
||||
pub mod media_handler;
|
||||
pub mod steering;
|
||||
pub mod sub_agent;
|
||||
pub mod system_prompt;
|
||||
pub mod turn_event;
|
||||
|
||||
pub use agent_loop::{AgentError, AgentLoop, AgentProcessResult};
|
||||
pub use context_compressor::{ContextCompressor, estimate_tokens};
|
||||
pub use steering::{SteeringDrain, SteeringMailbox, SteeringPushError};
|
||||
pub use sub_agent::{
|
||||
DelegateContext, ExecutionMode, SubAgentConfig, SubAgentError, SubAgentManager, SubAgentResult,
|
||||
TaskNotification, TaskStatus,
|
||||
|
||||
489
src/agent/steering.rs
Normal file
489
src/agent/steering.rs
Normal file
@ -0,0 +1,489 @@
|
||||
//! Bounded, session-owned mailbox for same-turn user steering.
|
||||
//!
|
||||
//! A mailbox is intentionally separate from the session work queue. The
|
||||
//! gateway can accept a normal user message while a turn is running and place
|
||||
//! it here; [`AgentLoop`](super::AgentLoop) drains it only at safe model
|
||||
//! boundaries (after a complete tool batch, or before deciding that a
|
||||
//! response is final). The state transition performed by
|
||||
//! [`SteeringMailbox::drain_or_close`] is atomic with respect to producers,
|
||||
//! which means an input is either accepted by the active turn or rejected so
|
||||
//! the caller can put it on the next-turn queue -- never both and never
|
||||
//! neither.
|
||||
|
||||
use crate::bus::ChatMessage;
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Default maximum number of steering messages accepted by one active turn.
|
||||
pub const DEFAULT_MAX_STEERING_MESSAGES: usize = 32;
|
||||
/// Default aggregate UTF-8 byte budget for pending steering messages.
|
||||
pub const DEFAULT_MAX_STEERING_BYTES: usize = 64 * 1024;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum MailboxPhase {
|
||||
Accepting,
|
||||
Closed,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct MailboxState {
|
||||
phase: MailboxPhase,
|
||||
pending: VecDeque<ChatMessage>,
|
||||
pending_bytes: usize,
|
||||
/// Messages drained at a safe boundary but not yet committed to durable
|
||||
/// history. Keeping their count/size reserved prevents concurrent
|
||||
/// producers from filling the capacity that an error retry may need to
|
||||
/// restore.
|
||||
in_flight_messages: usize,
|
||||
in_flight_bytes: usize,
|
||||
/// Exact drained messages retained until commit. This lets Session
|
||||
/// recover a successful AgentLoop result if its subsequent persistence
|
||||
/// transaction fails.
|
||||
in_flight: VecDeque<ChatMessage>,
|
||||
}
|
||||
|
||||
/// Error returned when the active turn cannot accept a steering message.
|
||||
///
|
||||
/// The original message is returned in the error so the caller can enqueue it
|
||||
/// as a normal next-turn task without cloning or losing media metadata.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SteeringPushError {
|
||||
/// The turn has reached a terminal boundary. Route the message to the
|
||||
/// session's ordinary queue.
|
||||
Closed(Box<ChatMessage>),
|
||||
/// The mailbox is accepting input, but its bounded capacity is exhausted.
|
||||
/// Route the message to the ordinary queue (and normally notify the user).
|
||||
Full(Box<ChatMessage>),
|
||||
}
|
||||
|
||||
impl SteeringPushError {
|
||||
/// Recover the message that was rejected by [`SteeringMailbox::try_push`].
|
||||
pub fn into_message(self) -> ChatMessage {
|
||||
match self {
|
||||
Self::Closed(message) | Self::Full(message) => *message,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_closed(&self) -> bool {
|
||||
matches!(self, Self::Closed(_))
|
||||
}
|
||||
|
||||
pub fn is_full(&self) -> bool {
|
||||
matches!(self, Self::Full(_))
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of the atomic final-response boundary operation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SteeringDrain {
|
||||
/// One or more inputs were accepted and removed from the mailbox. The
|
||||
/// mailbox remains open for a subsequent safe boundary.
|
||||
Messages(Vec<ChatMessage>),
|
||||
/// No pending input existed. The mailbox is now closed; later producers
|
||||
/// receive [`SteeringPushError::Closed`].
|
||||
Closed,
|
||||
}
|
||||
|
||||
/// Shared state for user steering during one active AgentLoop execution.
|
||||
///
|
||||
/// Cloning a mailbox is cheap and shares the same mutex-protected state. In
|
||||
/// practice the session stores an `Arc<SteeringMailbox>` in its active-turn
|
||||
/// handle and gives another clone to [`AgentTurnContext`](super::AgentTurnContext).
|
||||
#[derive(Clone)]
|
||||
pub struct SteeringMailbox {
|
||||
state: Arc<Mutex<MailboxState>>,
|
||||
max_messages: usize,
|
||||
max_bytes: usize,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for SteeringMailbox {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let state = self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
formatter
|
||||
.debug_struct("SteeringMailbox")
|
||||
.field("phase", &state.phase)
|
||||
.field("pending_messages", &state.pending.len())
|
||||
.field("pending_bytes", &state.pending_bytes)
|
||||
.field("max_messages", &self.max_messages)
|
||||
.field("max_bytes", &self.max_bytes)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl SteeringMailbox {
|
||||
/// Construct a mailbox using the product defaults (32 messages/64 KiB).
|
||||
pub fn new() -> Self {
|
||||
Self::with_limits(DEFAULT_MAX_STEERING_MESSAGES, DEFAULT_MAX_STEERING_BYTES)
|
||||
}
|
||||
|
||||
/// Construct a mailbox with explicit bounded capacities. Zero limits are
|
||||
/// allowed and make every push return [`SteeringPushError::Full`].
|
||||
pub fn with_limits(max_messages: usize, max_bytes: usize) -> Self {
|
||||
Self {
|
||||
state: Arc::new(Mutex::new(MailboxState {
|
||||
phase: MailboxPhase::Accepting,
|
||||
pending: VecDeque::new(),
|
||||
pending_bytes: 0,
|
||||
in_flight_messages: 0,
|
||||
in_flight_bytes: 0,
|
||||
in_flight: VecDeque::new(),
|
||||
})),
|
||||
max_messages,
|
||||
max_bytes,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return an `Arc` suitable for storing in Session and AgentTurnContext.
|
||||
pub fn new_shared() -> Arc<Self> {
|
||||
Arc::new(Self::new())
|
||||
}
|
||||
|
||||
/// Return an `Arc` suitable for storing in Session with explicit limits.
|
||||
pub fn shared_with_limits(max_messages: usize, max_bytes: usize) -> Arc<Self> {
|
||||
Arc::new(Self::with_limits(max_messages, max_bytes))
|
||||
}
|
||||
|
||||
/// Try to accept one real user [`ChatMessage`].
|
||||
///
|
||||
/// This operation and the final close operation use the same mutex. A
|
||||
/// producer racing with `drain_or_close` therefore receives a deterministic
|
||||
/// result and can route a rejected message to the ordinary queue.
|
||||
pub fn try_push(&self, message: ChatMessage) -> Result<(), SteeringPushError> {
|
||||
let message_bytes = message_size_bytes(&message);
|
||||
let mut state = self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
if state.phase == MailboxPhase::Closed {
|
||||
return Err(SteeringPushError::Closed(Box::new(message)));
|
||||
}
|
||||
if state.pending.len().saturating_add(state.in_flight_messages) >= self.max_messages
|
||||
|| state
|
||||
.pending_bytes
|
||||
.saturating_add(state.in_flight_bytes)
|
||||
.saturating_add(message_bytes)
|
||||
> self.max_bytes
|
||||
{
|
||||
return Err(SteeringPushError::Full(Box::new(message)));
|
||||
}
|
||||
state.pending_bytes = state.pending_bytes.saturating_add(message_bytes);
|
||||
state.pending.push_back(message);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drain currently pending inputs while leaving the mailbox open.
|
||||
///
|
||||
/// This is used after a complete tool-call batch. It intentionally does
|
||||
/// not close the mailbox: another input may steer a later iteration.
|
||||
pub fn drain(&self) -> Vec<ChatMessage> {
|
||||
let mut state = self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
drain_pending_locked(&mut state)
|
||||
}
|
||||
|
||||
/// Atomically drain pending inputs, or close the mailbox if it is empty.
|
||||
pub fn drain_or_close(&self) -> SteeringDrain {
|
||||
let mut state = self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
if state.pending.is_empty() {
|
||||
state.phase = MailboxPhase::Closed;
|
||||
SteeringDrain::Closed
|
||||
} else {
|
||||
SteeringDrain::Messages(drain_pending_locked(&mut state))
|
||||
}
|
||||
}
|
||||
|
||||
/// Close acceptance without dropping pending messages. Session uses
|
||||
/// [`take_pending`](Self::take_pending) after AgentLoop returns to move
|
||||
/// those messages to the ordinary next-turn queue (for example when the
|
||||
/// maximum iteration budget is exhausted).
|
||||
pub fn close(&self) {
|
||||
let mut state = self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
state.phase = MailboxPhase::Closed;
|
||||
}
|
||||
|
||||
/// Close acceptance and return all pending messages. This is convenient
|
||||
/// for cancellation/error paths where the caller immediately owns the
|
||||
/// rejected messages. Any in-flight batch is intentionally discarded;
|
||||
/// `/stop` uses this method to preserve its queue-clearing semantics.
|
||||
pub fn close_and_take_pending(&self) -> Vec<ChatMessage> {
|
||||
let mut state = self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
state.phase = MailboxPhase::Closed;
|
||||
// Cancellation is an explicit discard boundary. Any in-flight
|
||||
// messages that were already drained belong to this cancelled turn
|
||||
// and must not reserve capacity forever.
|
||||
take_pending_locked(&mut state)
|
||||
}
|
||||
|
||||
/// Restore all messages drained by AgentLoop since the last commit. This
|
||||
/// is useful when the AgentLoop completed but Session's durable write then
|
||||
/// failed: the next retry/queue operation can replay the exact accepted
|
||||
/// inputs instead of silently losing them.
|
||||
pub fn restore_drained(&self) {
|
||||
let mut state = self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
restore_in_flight_locked(&mut state);
|
||||
}
|
||||
|
||||
/// Restore messages drained by AgentLoop when a provider/tool error makes
|
||||
/// the current invocation retry from persisted history. The messages are
|
||||
/// prepended in their original order and their reserved capacity is
|
||||
/// released. `drain()`/`drain_or_close()` reserve capacity while a batch is
|
||||
/// in-flight, so this operation cannot overflow a bounded mailbox due to a
|
||||
/// racing producer.
|
||||
pub fn restore_front(&self, messages: Vec<ChatMessage>) {
|
||||
if messages.is_empty() {
|
||||
return;
|
||||
}
|
||||
let restored_bytes = messages.iter().map(message_size_bytes).sum::<usize>();
|
||||
let mut state = self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
state.in_flight_messages = state.in_flight_messages.saturating_sub(messages.len());
|
||||
state.in_flight_bytes = state.in_flight_bytes.saturating_sub(restored_bytes);
|
||||
for _ in 0..messages.len() {
|
||||
state.in_flight.pop_front();
|
||||
}
|
||||
for message in messages.into_iter().rev() {
|
||||
state.pending.push_front(message);
|
||||
}
|
||||
state.pending_bytes = state.pending_bytes.saturating_add(restored_bytes);
|
||||
}
|
||||
|
||||
/// Mark all previously drained messages as durably committed. Session
|
||||
/// calls this only after the complete Turn persistence transaction
|
||||
/// succeeds. It is a no-op when no steering batch was consumed.
|
||||
pub fn commit_drained(&self) {
|
||||
let mut state = self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
state.in_flight_messages = 0;
|
||||
state.in_flight_bytes = 0;
|
||||
state.in_flight.clear();
|
||||
}
|
||||
|
||||
/// Take pending inputs without changing whether producers may still push.
|
||||
///
|
||||
/// Normally used after `close()`; keeping this method explicit makes it
|
||||
/// possible for Session to transfer accepted-but-unprocessed input to its
|
||||
/// FIFO queue without opening a race with a new turn.
|
||||
pub fn take_pending(&self) -> Vec<ChatMessage> {
|
||||
let mut state = self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
take_pending_locked(&mut state)
|
||||
}
|
||||
|
||||
pub fn is_closed(&self) -> bool {
|
||||
let state = self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
state.phase == MailboxPhase::Closed
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
let state = self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
state.pending.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
|
||||
pub fn max_messages(&self) -> usize {
|
||||
self.max_messages
|
||||
}
|
||||
|
||||
pub fn max_bytes(&self) -> usize {
|
||||
self.max_bytes
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SteeringMailbox {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Approximate the bounded payload size without serializing the complete
|
||||
/// message. Content, media paths/types, tool metadata and source fields are
|
||||
/// all untrusted input; counting their UTF-8 bytes gives a conservative enough
|
||||
/// guard while retaining the original message losslessly.
|
||||
fn message_size_bytes(message: &ChatMessage) -> usize {
|
||||
let mut bytes = message.id.len()
|
||||
+ message.role.len()
|
||||
+ message.content.len()
|
||||
+ message.reasoning_content.as_deref().map_or(0, str::len)
|
||||
+ message.turn_id.as_deref().map_or(0, str::len)
|
||||
+ message.tool_call_id.as_deref().map_or(0, str::len)
|
||||
+ message.tool_name.as_deref().map_or(0, str::len);
|
||||
for media in &message.media_refs {
|
||||
bytes = bytes.saturating_add(media.path.len() + media.media_type.len());
|
||||
}
|
||||
if let Some(tool_calls) = &message.tool_calls {
|
||||
for call in tool_calls {
|
||||
bytes = bytes
|
||||
.saturating_add(call.id.len())
|
||||
.saturating_add(call.name.len())
|
||||
.saturating_add(call.arguments.to_string().len());
|
||||
}
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
fn drain_pending_locked(state: &mut MailboxState) -> Vec<ChatMessage> {
|
||||
let messages: Vec<_> = state.pending.drain(..).collect();
|
||||
let bytes = messages.iter().map(message_size_bytes).sum::<usize>();
|
||||
state.pending_bytes = state.pending_bytes.saturating_sub(bytes);
|
||||
state.in_flight_messages = state.in_flight_messages.saturating_add(messages.len());
|
||||
state.in_flight_bytes = state.in_flight_bytes.saturating_add(bytes);
|
||||
state.in_flight.extend(messages.iter().cloned());
|
||||
messages
|
||||
}
|
||||
|
||||
fn take_pending_locked(state: &mut MailboxState) -> Vec<ChatMessage> {
|
||||
state.pending_bytes = 0;
|
||||
state.in_flight_messages = 0;
|
||||
state.in_flight_bytes = 0;
|
||||
state.in_flight.clear();
|
||||
state.pending.drain(..).collect()
|
||||
}
|
||||
|
||||
fn restore_in_flight_locked(state: &mut MailboxState) {
|
||||
if state.in_flight.is_empty() {
|
||||
return;
|
||||
}
|
||||
let messages: Vec<_> = state.in_flight.drain(..).collect();
|
||||
let bytes = messages.iter().map(message_size_bytes).sum::<usize>();
|
||||
state.in_flight_messages = 0;
|
||||
state.in_flight_bytes = 0;
|
||||
for message in messages.into_iter().rev() {
|
||||
state.pending.push_front(message);
|
||||
}
|
||||
state.pending_bytes = state.pending_bytes.saturating_add(bytes);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
|
||||
#[test]
|
||||
fn accepts_fifo_messages_and_clone_shares_state() {
|
||||
let mailbox = SteeringMailbox::with_limits(2, 100);
|
||||
let clone = mailbox.clone();
|
||||
mailbox.try_push(ChatMessage::user("one")).unwrap();
|
||||
clone.try_push(ChatMessage::user("two")).unwrap();
|
||||
assert_eq!(mailbox.len(), 2);
|
||||
let messages = mailbox.drain();
|
||||
assert_eq!(
|
||||
messages
|
||||
.iter()
|
||||
.map(|m| m.content.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["one", "two"]
|
||||
);
|
||||
assert!(!mailbox.is_closed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_full_message_without_losing_it() {
|
||||
let mailbox = SteeringMailbox::with_limits(1, 10_000);
|
||||
mailbox.try_push(ChatMessage::user("first")).unwrap();
|
||||
let second = ChatMessage::user("second");
|
||||
let error = mailbox.try_push(second.clone()).unwrap_err();
|
||||
assert!(error.is_full());
|
||||
assert_eq!(error.into_message().content, second.content);
|
||||
assert_eq!(mailbox.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn byte_limit_is_bounded() {
|
||||
let mailbox = SteeringMailbox::with_limits(8, 3);
|
||||
let message = ChatMessage::user("four");
|
||||
assert!(matches!(
|
||||
mailbox.try_push(message),
|
||||
Err(SteeringPushError::Full(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drain_or_close_is_atomic_and_preserves_close_race_semantics() {
|
||||
let mailbox = Arc::new(SteeringMailbox::new());
|
||||
let producer = mailbox.clone();
|
||||
let close_result = thread::spawn(move || producer.drain_or_close())
|
||||
.join()
|
||||
.unwrap();
|
||||
assert!(matches!(close_result, SteeringDrain::Closed));
|
||||
let message = ChatMessage::user("late");
|
||||
assert!(matches!(
|
||||
mailbox.try_push(message),
|
||||
Err(SteeringPushError::Closed(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drain_or_close_drains_but_keeps_accepting_when_non_empty() {
|
||||
let mailbox = SteeringMailbox::new();
|
||||
mailbox.try_push(ChatMessage::user("first")).unwrap();
|
||||
let result = mailbox.drain_or_close();
|
||||
assert!(matches!(result, SteeringDrain::Messages(_)));
|
||||
assert!(!mailbox.is_closed());
|
||||
mailbox.try_push(ChatMessage::user("second")).unwrap();
|
||||
assert_eq!(mailbox.drain()[0].content, "second");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_keeps_pending_for_next_turn_fallback() {
|
||||
let mailbox = SteeringMailbox::new();
|
||||
mailbox.try_push(ChatMessage::user("defer")).unwrap();
|
||||
mailbox.close();
|
||||
assert!(mailbox.try_push(ChatMessage::user("late")).is_err());
|
||||
assert_eq!(mailbox.take_pending()[0].content, "defer");
|
||||
assert!(mailbox.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drained_capacity_is_reserved_until_commit_or_restore() {
|
||||
let mailbox = SteeringMailbox::with_limits(1, 10_000);
|
||||
mailbox.try_push(ChatMessage::user("first")).unwrap();
|
||||
let drained = mailbox.drain();
|
||||
assert_eq!(drained.len(), 1);
|
||||
assert!(mailbox.try_push(ChatMessage::user("second")).is_err());
|
||||
mailbox.restore_front(drained);
|
||||
assert_eq!(mailbox.take_pending()[0].content, "first");
|
||||
|
||||
mailbox.try_push(ChatMessage::user("committed")).unwrap();
|
||||
let _ = mailbox.drain();
|
||||
mailbox.commit_drained();
|
||||
mailbox.try_push(ChatMessage::user("after commit")).unwrap();
|
||||
|
||||
let drained = mailbox.drain();
|
||||
assert_eq!(drained[0].content, "after commit");
|
||||
mailbox.restore_drained();
|
||||
assert_eq!(mailbox.take_pending()[0].content, "after commit");
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,7 @@ use std::sync::{Arc, Mutex};
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::agent::steering::SteeringMailbox;
|
||||
use crate::providers::ToolCall;
|
||||
|
||||
/// Presentation facts emitted while AgentLoop processes one model turn.
|
||||
@ -58,6 +59,9 @@ pub struct AgentTurnContext {
|
||||
pub turn_id: String,
|
||||
pub message_id: String,
|
||||
pub emitter: TurnEmitter,
|
||||
/// Same-turn user input accepted while this turn is active. Session owns
|
||||
/// the mailbox lifecycle; AgentLoop only drains it at safe boundaries.
|
||||
pub steering: Option<Arc<SteeringMailbox>>,
|
||||
}
|
||||
|
||||
impl AgentTurnContext {
|
||||
@ -70,8 +74,36 @@ impl AgentTurnContext {
|
||||
turn_id: turn_id.into(),
|
||||
message_id: message_id.into(),
|
||||
emitter,
|
||||
steering: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct a streaming context with a shared steering mailbox.
|
||||
pub fn new_with_steering(
|
||||
turn_id: impl Into<String>,
|
||||
message_id: impl Into<String>,
|
||||
emitter: TurnEmitter,
|
||||
steering: Arc<SteeringMailbox>,
|
||||
) -> Self {
|
||||
Self {
|
||||
turn_id: turn_id.into(),
|
||||
message_id: message_id.into(),
|
||||
emitter,
|
||||
steering: Some(steering),
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach a mailbox to an existing context. This builder keeps the old
|
||||
/// `AgentTurnContext::new` call sites source-compatible.
|
||||
pub fn with_steering(mut self, steering: Arc<SteeringMailbox>) -> Self {
|
||||
self.steering = Some(steering);
|
||||
self
|
||||
}
|
||||
|
||||
/// Return a clone of the shared mailbox, if steering is enabled.
|
||||
pub fn steering(&self) -> Option<Arc<SteeringMailbox>> {
|
||||
self.steering.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl TurnEmitter {
|
||||
|
||||
@ -408,6 +408,10 @@ pub struct InboundMessage {
|
||||
pub channel: String,
|
||||
pub sender_id: String,
|
||||
pub chat_id: String,
|
||||
/// Client-provided id for optimistic UI reconciliation. Channel-owned
|
||||
/// inputs that do not expose a client id leave this unset; the session
|
||||
/// layer may generate a durable id when it accepts the message.
|
||||
pub client_message_id: Option<String>,
|
||||
pub content: String,
|
||||
pub received_at: i64,
|
||||
pub media: Vec<MediaItem>,
|
||||
|
||||
@ -179,6 +179,7 @@ impl CliChatChannel {
|
||||
WsInbound::UserInput {
|
||||
content,
|
||||
upload_ids,
|
||||
client_message_id,
|
||||
chat_id,
|
||||
..
|
||||
} => {
|
||||
@ -187,8 +188,15 @@ impl CliChatChannel {
|
||||
if content.trim().is_empty() && upload_ids.is_empty() {
|
||||
return Err(ChannelError::Other("Message is empty".to_string()));
|
||||
}
|
||||
// `/queue` is deliberately allowed to carry attachments: it
|
||||
// is a message-routing directive whose payload remains a
|
||||
// normal user input. Other slash commands still reject
|
||||
// attachments because their handlers do not consume media.
|
||||
let slash_allows_attachments = crate::channels::parse_slash_command(&content)
|
||||
.is_some_and(|(name, _)| name.eq_ignore_ascii_case("queue"));
|
||||
if !upload_ids.is_empty()
|
||||
&& crate::channels::parse_slash_command(&content).is_some()
|
||||
&& !slash_allows_attachments
|
||||
{
|
||||
return Err(ChannelError::Other(
|
||||
"Attachments cannot be sent with slash commands".to_string(),
|
||||
@ -200,6 +208,18 @@ impl CliChatChannel {
|
||||
"Chat does not belong to this client".to_string(),
|
||||
));
|
||||
}
|
||||
let client_message_id =
|
||||
client_message_id.and_then(|raw| match uuid::Uuid::parse_str(&raw) {
|
||||
Ok(id) => Some(id.to_string()),
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
client_message_id = %raw,
|
||||
error = %error,
|
||||
"Ignoring invalid client message id"
|
||||
);
|
||||
None
|
||||
}
|
||||
});
|
||||
let uploads = self
|
||||
.uploads
|
||||
.take_many(&client.chat_id, &upload_ids)
|
||||
@ -210,6 +230,7 @@ impl CliChatChannel {
|
||||
channel: self.name().to_string(),
|
||||
sender_id: "cli".to_string(),
|
||||
chat_id: target_chat_id,
|
||||
client_message_id,
|
||||
content,
|
||||
received_at: crate::bus::message::current_timestamp(),
|
||||
media,
|
||||
@ -475,6 +496,25 @@ impl CliChatChannel {
|
||||
None => return Err(ChannelError::Other("Control channel closed".to_string())),
|
||||
}
|
||||
}
|
||||
WsInbound::GetSessionStats { session_id } => {
|
||||
let unified_id = Self::parse_client_session(&client, &session_id)?;
|
||||
let (reply_tx, mut reply_rx) = mpsc::channel(1);
|
||||
bus.publish_control(ControlMessage {
|
||||
op: SessionCommand::GetSessionStats {
|
||||
session_id: unified_id,
|
||||
},
|
||||
reply_tx,
|
||||
})
|
||||
.await?;
|
||||
match reply_rx.recv().await {
|
||||
Some(Ok(SessionEvent::SessionStats { stats })) => {
|
||||
let _ = client.sender.send(WsOutbound::SessionStats { stats }).await;
|
||||
}
|
||||
Some(Ok(_)) => {}
|
||||
Some(Err(error)) => return Err(error),
|
||||
None => return Err(ChannelError::Other("Control channel closed".to_string())),
|
||||
}
|
||||
}
|
||||
WsInbound::RenameSession { session_id, title } => {
|
||||
let target = session_id
|
||||
.or(current_session_guard.clone())
|
||||
@ -1040,8 +1080,9 @@ mod tests {
|
||||
.handle_ws_inbound(
|
||||
client,
|
||||
WsInbound::UserInput {
|
||||
content: "处理附件".into(),
|
||||
content: "/queue 处理附件".into(),
|
||||
upload_ids: vec!["upload-1".into()],
|
||||
client_message_id: Some("550e8400-e29b-41d4-a716-446655440000".into()),
|
||||
channel: None,
|
||||
chat_id: None,
|
||||
sender_id: None,
|
||||
@ -1054,6 +1095,42 @@ mod tests {
|
||||
assert_eq!(inbound.media.len(), 1);
|
||||
assert_eq!(inbound.media[0].path, "/tmp/report.pdf");
|
||||
assert_eq!(inbound.media[0].media_type, "file");
|
||||
assert_eq!(
|
||||
inbound.client_message_id.as_deref(),
|
||||
Some("550e8400-e29b-41d4-a716-446655440000")
|
||||
);
|
||||
assert_eq!(inbound.content, "/queue 处理附件");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_client_message_id_is_ignored_at_channel_boundary() {
|
||||
let channel = CliChatChannel::new();
|
||||
let bus = MessageBus::new(4);
|
||||
channel.start(bus.clone()).await.unwrap();
|
||||
let (sender, _receiver) = mpsc::channel(1);
|
||||
let client = Arc::new(Client {
|
||||
sender,
|
||||
chat_id: "client".into(),
|
||||
current_session_id: Mutex::new(None),
|
||||
});
|
||||
|
||||
channel
|
||||
.handle_ws_inbound(
|
||||
client,
|
||||
WsInbound::UserInput {
|
||||
content: "hello".into(),
|
||||
upload_ids: Vec::new(),
|
||||
client_message_id: Some("not-a-uuid".into()),
|
||||
channel: None,
|
||||
chat_id: None,
|
||||
sender_id: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let inbound = bus.consume_inbound().await.unwrap();
|
||||
assert_eq!(inbound.client_message_id, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@ -788,7 +788,7 @@ impl FeishuChannel {
|
||||
let result: UploadResp = serde_json::from_str(&body_text).map_err(|e| {
|
||||
ChannelError::Other(format!(
|
||||
"Parse upload response error: {} | body: {}",
|
||||
e, &body_text
|
||||
e, body_text
|
||||
))
|
||||
})?;
|
||||
|
||||
@ -876,7 +876,7 @@ impl FeishuChannel {
|
||||
let result: UploadResp = serde_json::from_str(&body_text).map_err(|e| {
|
||||
ChannelError::Other(format!(
|
||||
"Parse upload response error: {} | body: {}",
|
||||
e, &body_text
|
||||
e, body_text
|
||||
))
|
||||
})?;
|
||||
|
||||
@ -1419,6 +1419,7 @@ impl FeishuChannel {
|
||||
channel: "feishu".to_string(),
|
||||
sender_id: parsed.open_id.clone(),
|
||||
chat_id: parsed.chat_id.clone(),
|
||||
client_message_id: None,
|
||||
content: parsed.content,
|
||||
received_at: crate::bus::message::current_timestamp(),
|
||||
media: parsed.media,
|
||||
@ -2611,6 +2612,7 @@ fn render_feishu_turn(snapshot: &TurnSnapshot) -> String {
|
||||
let status = match status {
|
||||
ToolStatus::Running => "执行中",
|
||||
ToolStatus::Completed => "已完成",
|
||||
ToolStatus::Cancelled => "已停止",
|
||||
ToolStatus::Failed => "失败",
|
||||
};
|
||||
let mut section = format!("> 🔧 **{name}** · {status}");
|
||||
|
||||
@ -380,7 +380,9 @@ async fn handle_ws_message(app: &mut App, outbound: WsOutbound) {
|
||||
} => app.set_history(&session_id, messages),
|
||||
// The first Todo UI is WebUI-only. CLI keeps receiving ordinary task
|
||||
// notifications and may inspect plans through /todo.
|
||||
WsOutbound::SessionPlan { .. } | WsOutbound::PlanUpdated { .. } => {}
|
||||
WsOutbound::SessionPlan { .. }
|
||||
| WsOutbound::SessionStats { .. }
|
||||
| WsOutbound::PlanUpdated { .. } => {}
|
||||
WsOutbound::SessionRenamed { session_id, title } => {
|
||||
if let Some(session) = app
|
||||
.sessions
|
||||
|
||||
@ -114,6 +114,7 @@ pub async fn run_once(
|
||||
let input = WsInbound::UserInput {
|
||||
content: prompt,
|
||||
upload_ids: Vec::new(),
|
||||
client_message_id: None,
|
||||
channel: None,
|
||||
chat_id: None,
|
||||
sender_id: None,
|
||||
@ -193,6 +194,7 @@ where
|
||||
let stop = WsInbound::UserInput {
|
||||
content: "/stop".to_string(),
|
||||
upload_ids: Vec::new(),
|
||||
client_message_id: None,
|
||||
channel: None,
|
||||
chat_id: None,
|
||||
sender_id: None,
|
||||
@ -316,6 +318,7 @@ fn tool_status_name(status: ToolStatus) -> &'static str {
|
||||
match status {
|
||||
ToolStatus::Running => "running",
|
||||
ToolStatus::Completed => "completed",
|
||||
ToolStatus::Cancelled => "cancelled",
|
||||
ToolStatus::Failed => "failed",
|
||||
}
|
||||
}
|
||||
|
||||
@ -89,6 +89,7 @@ pub fn render(frame: &mut Frame, area: Rect, app: &App) {
|
||||
let status = match status {
|
||||
ToolStatus::Running => "执行中",
|
||||
ToolStatus::Completed => "已完成",
|
||||
ToolStatus::Cancelled => "已停止",
|
||||
ToolStatus::Failed => "失败",
|
||||
};
|
||||
lines.push(Line::from(Span::styled(
|
||||
|
||||
@ -206,6 +206,7 @@ async fn handle_input_key(app: &mut App, key: KeyEvent) {
|
||||
WsInbound::UserInput {
|
||||
content: input,
|
||||
upload_ids,
|
||||
client_message_id: None,
|
||||
channel: None,
|
||||
// Session routing is owned by the server. A full session
|
||||
// id is not a chat id and must never be sent here.
|
||||
|
||||
@ -588,9 +588,9 @@ pub struct LLMProviderConfig {
|
||||
impl LLMProviderConfig {
|
||||
pub fn cost_of(&self, prompt_tokens: u32, completion_tokens: u32) -> Option<f64> {
|
||||
match (self.price_input_per_million, self.price_output_per_million) {
|
||||
(Some(pi), Some(po)) => Some(
|
||||
prompt_tokens as f64 / 1e6 * pi + completion_tokens as f64 / 1e6 * po,
|
||||
),
|
||||
(Some(pi), Some(po)) => {
|
||||
Some(prompt_tokens as f64 / 1e6 * pi + completion_tokens as f64 / 1e6 * po)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@ -92,11 +92,17 @@ const EMBEDDED_FONTS: &[(&str, &[u8])] = &[
|
||||
),
|
||||
(
|
||||
"jetbrains-mono-400.woff2",
|
||||
include_bytes!(concat!(env!("OUT_DIR"), "/webui/fonts/jetbrains-mono-400.woff2")),
|
||||
include_bytes!(concat!(
|
||||
env!("OUT_DIR"),
|
||||
"/webui/fonts/jetbrains-mono-400.woff2"
|
||||
)),
|
||||
),
|
||||
(
|
||||
"jetbrains-mono-700.woff2",
|
||||
include_bytes!(concat!(env!("OUT_DIR"), "/webui/fonts/jetbrains-mono-700.woff2")),
|
||||
include_bytes!(concat!(
|
||||
env!("OUT_DIR"),
|
||||
"/webui/fonts/jetbrains-mono-700.woff2"
|
||||
)),
|
||||
),
|
||||
];
|
||||
|
||||
@ -824,7 +830,11 @@ pub async fn get_tools(State(state): State<Arc<GatewayState>>) -> Result<Json<Va
|
||||
let tools: Vec<Value> = entries
|
||||
.into_iter()
|
||||
.map(|(name, tool)| {
|
||||
let source = if name.contains("__") { "mcp" } else { "builtin" };
|
||||
let source = if name.contains("__") {
|
||||
"mcp"
|
||||
} else {
|
||||
"builtin"
|
||||
};
|
||||
json!({
|
||||
"name": name,
|
||||
"description": tool.description(),
|
||||
|
||||
@ -325,6 +325,11 @@ async fn handle_control_message(session_manager: &SessionManager, message: Contr
|
||||
.await
|
||||
.map(|plan| SessionEvent::TaskPlan { session_id, plan })
|
||||
.map_err(|error| ChannelError::Other(error.to_string())),
|
||||
GetSessionStats { session_id } => session_manager
|
||||
.get_session_stats(&session_id)
|
||||
.await
|
||||
.map(|stats| SessionEvent::SessionStats { stats })
|
||||
.map_err(|error| ChannelError::Other(error.to_string())),
|
||||
RenameDialog { session_id, title } => session_manager
|
||||
.rename_dialog(&session_id, &title)
|
||||
.await
|
||||
@ -415,6 +420,7 @@ mod tests {
|
||||
channel: "test".to_string(),
|
||||
sender_id: "user".to_string(),
|
||||
chat_id: "chat".to_string(),
|
||||
client_message_id: None,
|
||||
content: "hello".to_string(),
|
||||
received_at: 123,
|
||||
media: vec![],
|
||||
|
||||
@ -174,11 +174,7 @@ async fn handle_logs_socket(ws: WebSocket, query: WsLogsQuery) {
|
||||
let mut rx = tx.subscribe();
|
||||
let (mut ws_sender, mut ws_receiver) = ws.split();
|
||||
|
||||
let min_level = query
|
||||
.level
|
||||
.as_deref()
|
||||
.map(parse_min_level)
|
||||
.unwrap_or(0);
|
||||
let min_level = query.level.as_deref().map(parse_min_level).unwrap_or(0);
|
||||
let search = query
|
||||
.search
|
||||
.filter(|s| !s.is_empty())
|
||||
|
||||
@ -4,9 +4,9 @@ use tokio::sync::broadcast;
|
||||
use tracing::field::{Field, Visit};
|
||||
use tracing::{Event, Subscriber};
|
||||
use tracing_appender::rolling::{RollingFileAppender, Rotation};
|
||||
use tracing_subscriber::Layer;
|
||||
use tracing_subscriber::layer::Context;
|
||||
use tracing_subscriber::registry::LookupSpan;
|
||||
use tracing_subscriber::Layer;
|
||||
use tracing_subscriber::{
|
||||
EnvFilter, fmt, fmt::time::LocalTime, layer::SubscriberExt, util::SubscriberInitExt,
|
||||
};
|
||||
|
||||
@ -76,11 +76,15 @@ impl Metrics {
|
||||
pub fn record_turn(&self, usage: Option<&Usage>, latency_ms: u64) {
|
||||
self.turns.fetch_add(1, Relaxed);
|
||||
if let Some(u) = usage {
|
||||
self.tokens_in.fetch_add(u64::from(u.prompt_tokens), Relaxed);
|
||||
self.tokens_in
|
||||
.fetch_add(u64::from(u.prompt_tokens), Relaxed);
|
||||
self.tokens_out
|
||||
.fetch_add(u64::from(u.completion_tokens), Relaxed);
|
||||
}
|
||||
let mut q = self.turn_latencies.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut q = self
|
||||
.turn_latencies
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
q.push_back(latency_ms);
|
||||
while q.len() > WINDOW {
|
||||
q.pop_front();
|
||||
@ -143,7 +147,10 @@ impl Metrics {
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> MetricsSnapshot {
|
||||
let latencies = self.turn_latencies.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let latencies = self
|
||||
.turn_latencies
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let p95 = percentile_95(&latencies);
|
||||
let providers = self.providers.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut cost = 0.0;
|
||||
|
||||
@ -138,6 +138,10 @@ pub enum WsInbound {
|
||||
content: String,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
upload_ids: Vec<String>,
|
||||
/// Stable id generated by the client for optimistic-message
|
||||
/// reconciliation. It is optional for older clients and channels.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
client_message_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
channel: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@ -172,6 +176,8 @@ pub enum WsInbound {
|
||||
},
|
||||
#[serde(rename = "get_session_plan")]
|
||||
GetSessionPlan { session_id: String },
|
||||
#[serde(rename = "get_session_stats")]
|
||||
GetSessionStats { session_id: String },
|
||||
#[serde(rename = "rename_session")]
|
||||
RenameSession {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@ -249,6 +255,8 @@ pub enum WsOutbound {
|
||||
session_id: String,
|
||||
plan: Option<crate::work::TaskPlan>,
|
||||
},
|
||||
#[serde(rename = "session_stats")]
|
||||
SessionStats { stats: crate::session::SessionStats },
|
||||
#[serde(rename = "plan_updated")]
|
||||
PlanUpdated {
|
||||
session_id: String,
|
||||
@ -344,6 +352,36 @@ mod tests {
|
||||
assert_eq!(value["messages"][0]["id"], "message");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_input_preserves_optional_client_message_id() {
|
||||
let inbound = parse_inbound(
|
||||
r#"{"type":"user_input","content":"hello","client_message_id":"client-1"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
match inbound {
|
||||
WsInbound::UserInput {
|
||||
client_message_id,
|
||||
upload_ids,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(client_message_id.as_deref(), Some("client-1"));
|
||||
assert!(upload_ids.is_empty());
|
||||
}
|
||||
other => panic!("unexpected frame: {other:?}"),
|
||||
}
|
||||
|
||||
let serialized = serialize_inbound(&WsInbound::UserInput {
|
||||
content: "hello".to_string(),
|
||||
upload_ids: Vec::new(),
|
||||
client_message_id: Some("client-1".to_string()),
|
||||
channel: None,
|
||||
chat_id: None,
|
||||
sender_id: None,
|
||||
})
|
||||
.unwrap();
|
||||
assert!(serialized.contains(r#""client_message_id":"client-1""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_defaults_new_reasoning_fields_for_old_frames() {
|
||||
let message: HistoryMessage = serde_json::from_value(serde_json::json!({
|
||||
@ -362,4 +400,47 @@ mod tests {
|
||||
crate::bus::CompletionStatus::Completed
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_stats_request_and_response_use_structured_frames() {
|
||||
let inbound =
|
||||
parse_inbound(r#"{"type":"get_session_stats","session_id":"cli_chat:client:dialog"}"#)
|
||||
.unwrap();
|
||||
assert!(matches!(inbound, WsInbound::GetSessionStats { .. }));
|
||||
|
||||
let stats = crate::session::SessionStats {
|
||||
session_id: "cli_chat:client:dialog".into(),
|
||||
title: "stats".into(),
|
||||
provider: "provider".into(),
|
||||
model: "model".into(),
|
||||
user_message_count: 1,
|
||||
history_message_count: 2,
|
||||
lifetime_usage: crate::session::LifetimeUsage {
|
||||
input_tokens: 100,
|
||||
output_tokens: 20,
|
||||
total_tokens: 120,
|
||||
cached_input_tokens: Some(40),
|
||||
request_count: 1,
|
||||
turn_count: 1,
|
||||
tracked_since: Some(1),
|
||||
},
|
||||
context: crate::session::ContextUsage {
|
||||
configured_window_tokens: 128_000,
|
||||
effective_window_tokens: 128_000,
|
||||
used_tokens: 100,
|
||||
remaining_tokens: 127_900,
|
||||
compression_threshold_tokens: 89_600,
|
||||
source: crate::session::ContextUsageSource::Hybrid,
|
||||
last_observed_prompt_tokens: Some(90),
|
||||
observed_at: Some(1),
|
||||
},
|
||||
created_at: 1,
|
||||
last_active_at: 2,
|
||||
updated_at: 3,
|
||||
};
|
||||
let value = serde_json::to_value(WsOutbound::SessionStats { stats }).unwrap();
|
||||
assert_eq!(value["type"], "session_stats");
|
||||
assert_eq!(value["stats"]["context"]["source"], "hybrid");
|
||||
assert_eq!(value["stats"]["lifetime_usage"]["input_tokens"], 100);
|
||||
}
|
||||
}
|
||||
|
||||
@ -148,21 +148,58 @@ struct AnthropicMessage {
|
||||
}
|
||||
|
||||
fn convert_messages(messages: &[Message]) -> Vec<AnthropicMessage> {
|
||||
messages
|
||||
.iter()
|
||||
.map(|message| {
|
||||
let role = if message.role == "tool" {
|
||||
"user".to_string()
|
||||
} else {
|
||||
message.role.clone()
|
||||
};
|
||||
let content = if let Some(ref tool_call_id) = message.tool_call_id {
|
||||
vec![serde_json::json!({
|
||||
let mut converted = Vec::with_capacity(messages.len());
|
||||
let mut index = 0;
|
||||
|
||||
while index < messages.len() {
|
||||
let message = &messages[index];
|
||||
|
||||
// Anthropic requires all tool results for one assistant tool-use turn
|
||||
// to be carried in a single `role: user` content array. Steering is
|
||||
// represented as a normal user message in PicoBot history, so merge
|
||||
// any immediately-following user messages into that same array at
|
||||
// the provider boundary. Durable messages remain independent.
|
||||
if message.role == "tool" && message.tool_call_id.is_some() {
|
||||
let mut content = Vec::new();
|
||||
while index < messages.len()
|
||||
&& messages[index].role == "tool"
|
||||
&& messages[index].tool_call_id.is_some()
|
||||
{
|
||||
let tool = &messages[index];
|
||||
let tool_call_id = tool
|
||||
.tool_call_id
|
||||
.as_deref()
|
||||
.expect("tool_call_id checked above");
|
||||
content.push(serde_json::json!({
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tool_call_id,
|
||||
"content": convert_content_blocks(&message.content, false),
|
||||
})]
|
||||
} else if let Some(native) = native_anthropic_content(message) {
|
||||
"content": convert_content_blocks(&tool.content, false),
|
||||
}));
|
||||
index += 1;
|
||||
}
|
||||
|
||||
// One turn may receive more than one steering message before the
|
||||
// next model request. Keep their order while emitting one native
|
||||
// Anthropic user message alongside the tool_result blocks.
|
||||
while index < messages.len() && messages[index].role == "user" {
|
||||
let steering = &messages[index];
|
||||
if let Some(native) = native_anthropic_content(steering) {
|
||||
content.extend(native);
|
||||
} else {
|
||||
content.extend(convert_content_blocks(&steering.content, false));
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
|
||||
converted.push(AnthropicMessage {
|
||||
role: "user".to_string(),
|
||||
content,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let role = message.role.clone();
|
||||
let content = if let Some(native) = native_anthropic_content(message) {
|
||||
native
|
||||
} else {
|
||||
let mut blocks = convert_content_blocks(&message.content, message.role == "system");
|
||||
@ -182,9 +219,11 @@ fn convert_messages(messages: &[Message]) -> Vec<AnthropicMessage> {
|
||||
}
|
||||
blocks
|
||||
};
|
||||
AnthropicMessage { role, content }
|
||||
})
|
||||
.collect()
|
||||
converted.push(AnthropicMessage { role, content });
|
||||
index += 1;
|
||||
}
|
||||
|
||||
converted
|
||||
}
|
||||
|
||||
fn native_anthropic_content(message: &Message) -> Option<Vec<Value>> {
|
||||
@ -690,6 +729,35 @@ mod tests {
|
||||
assert_eq!(result["content"][1]["source"]["data"], "AAAA");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_results_and_following_steering_share_one_user_content_array() {
|
||||
let messages = vec![
|
||||
Message::tool("call_1", "lookup", "first result"),
|
||||
Message::tool("call_2", "lookup", "second result"),
|
||||
Message::user("用户补充指令"),
|
||||
Message::user("再补充一条"),
|
||||
Message::assistant("最终回答"),
|
||||
];
|
||||
|
||||
let converted = convert_messages(&messages);
|
||||
|
||||
assert_eq!(converted.len(), 2);
|
||||
assert_eq!(converted[0].role, "user");
|
||||
assert_eq!(converted[0].content.len(), 4);
|
||||
assert_eq!(converted[0].content[0]["type"], "tool_result");
|
||||
assert_eq!(converted[0].content[0]["tool_use_id"], "call_1");
|
||||
assert_eq!(converted[0].content[1]["tool_use_id"], "call_2");
|
||||
assert_eq!(
|
||||
converted[0].content[2],
|
||||
json!({"type": "text", "text": "用户补充指令"})
|
||||
);
|
||||
assert_eq!(
|
||||
converted[0].content[3],
|
||||
json!({"type": "text", "text": "再补充一条"})
|
||||
);
|
||||
assert_eq!(converted[1].role, "assistant");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_stream_decodes_thinking_signature_tools_usage_and_replay_state() {
|
||||
let events = [
|
||||
|
||||
@ -761,6 +761,46 @@ mod tests {
|
||||
assert_eq!(converted[1]["content"], "second image");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assistant_tools_precede_all_tool_results_and_following_steering() {
|
||||
let messages = vec![
|
||||
Message {
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentBlock::text("calling tools")],
|
||||
reasoning_content: None,
|
||||
provider_state: None,
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
tool_calls: Some(vec![
|
||||
ToolCall {
|
||||
id: "call_1".to_string(),
|
||||
name: "lookup".to_string(),
|
||||
arguments: json!({"q": "one"}),
|
||||
},
|
||||
ToolCall {
|
||||
id: "call_2".to_string(),
|
||||
name: "lookup".to_string(),
|
||||
arguments: json!({"q": "two"}),
|
||||
},
|
||||
]),
|
||||
},
|
||||
Message::tool("call_1", "lookup", "result"),
|
||||
Message::tool("call_2", "lookup", "second result"),
|
||||
Message::user("用户补充指令"),
|
||||
];
|
||||
|
||||
let converted = convert_messages(&messages);
|
||||
|
||||
assert_eq!(converted.len(), 4);
|
||||
assert_eq!(converted[0]["role"], "assistant");
|
||||
assert_eq!(converted[1]["role"], "tool");
|
||||
assert_eq!(converted[1]["tool_call_id"], "call_1");
|
||||
assert_eq!(converted[2]["role"], "tool");
|
||||
assert_eq!(converted[2]["tool_call_id"], "call_2");
|
||||
assert_eq!(converted[3]["role"], "user");
|
||||
assert_eq!(converted[3]["content"], "用户补充指令");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assistant_images_are_never_serialized_as_native_content_parts() {
|
||||
let converted = convert_messages(&[Message {
|
||||
|
||||
@ -46,10 +46,7 @@ impl SseFramer {
|
||||
|
||||
fn drain_frames(&mut self, finish: bool) -> Result<Vec<String>, std::string::FromUtf8Error> {
|
||||
let mut frames = Vec::new();
|
||||
loop {
|
||||
let Some((position, delimiter_len)) = find_sse_delimiter(&self.buffer) else {
|
||||
break;
|
||||
};
|
||||
while let Some((position, delimiter_len)) = find_sse_delimiter(&self.buffer) {
|
||||
let frame = self.buffer.drain(..position).collect::<Vec<_>>();
|
||||
self.buffer.drain(..delimiter_len);
|
||||
if let Some(data) = sse_data(frame)? {
|
||||
|
||||
@ -28,6 +28,8 @@ pub enum SessionCommand {
|
||||
},
|
||||
/// Load the active task plan for a dialog.
|
||||
GetTaskPlan { session_id: UnifiedSessionId },
|
||||
/// Load token totals and context-window state for a dialog.
|
||||
GetSessionStats { session_id: UnifiedSessionId },
|
||||
/// Get the current dialog for a chat
|
||||
GetCurrentDialog { channel: String, chat_id: String },
|
||||
/// Rename a dialog
|
||||
|
||||
@ -41,6 +41,8 @@ pub enum SessionEvent {
|
||||
session_id: UnifiedSessionId,
|
||||
plan: Option<crate::work::TaskPlan>,
|
||||
},
|
||||
/// Provider usage totals and current context-window state.
|
||||
SessionStats { stats: crate::session::SessionStats },
|
||||
/// Dialog renamed
|
||||
DialogRenamed {
|
||||
session_id: UnifiedSessionId,
|
||||
|
||||
@ -8,6 +8,7 @@ mod turn_input;
|
||||
#[allow(clippy::module_inception)]
|
||||
pub mod session;
|
||||
pub mod session_id;
|
||||
pub mod stats;
|
||||
pub mod turn;
|
||||
|
||||
pub use commands::SessionCommand;
|
||||
@ -15,6 +16,7 @@ pub use error::SessionError;
|
||||
pub use events::{DialogInfo, SessionEvent};
|
||||
pub use session::{SLASH_COMMANDS, Session, SessionManager, SessionManagerServices, SlashCommand};
|
||||
pub use session_id::UnifiedSessionId;
|
||||
pub use stats::{ContextUsage, ContextUsageSource, LifetimeUsage, SessionStats};
|
||||
pub use turn::{
|
||||
BlockId, ToolStatus, TurnBlock, TurnController, TurnId, TurnPhase, TurnSnapshot, TurnState,
|
||||
TurnStatus,
|
||||
|
||||
@ -10,6 +10,7 @@ use crate::{providers::Usage, session::TurnController};
|
||||
|
||||
async fn persist_added_messages(
|
||||
snapshots: Vec<Option<MessagePersistSnapshot>>,
|
||||
usage: Option<&crate::storage::TurnUsageRecord>,
|
||||
) -> Result<(), StorageError> {
|
||||
let mut storage = None;
|
||||
let mut session_id = None;
|
||||
@ -35,9 +36,15 @@ async fn persist_added_messages(
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
if let Some(usage) = usage {
|
||||
storage
|
||||
.persist_turn_batch_with_retry(&session_id, &messages, &final_meta, usage)
|
||||
.await
|
||||
} else {
|
||||
storage
|
||||
.persist_message_batch_with_retry(&session_id, &messages, &final_meta)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn append_persisted_messages(
|
||||
@ -69,6 +76,7 @@ pub(super) async fn append_active_turn_message(
|
||||
session,
|
||||
vec![message],
|
||||
VersionPolicy::PreserveForOwnedTurn(turn_id),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map(|_| ())
|
||||
@ -78,13 +86,22 @@ pub(super) async fn append_persisted_messages_with_meta(
|
||||
session: &Arc<Mutex<Session>>,
|
||||
messages: Vec<ChatMessage>,
|
||||
) -> Result<Vec<crate::storage::message::MessageMeta>, StorageError> {
|
||||
append_persisted_messages_inner(session, messages, VersionPolicy::Advance).await
|
||||
append_persisted_messages_inner(session, messages, VersionPolicy::Advance, None).await
|
||||
}
|
||||
|
||||
pub(super) async fn append_persisted_turn_messages(
|
||||
session: &Arc<Mutex<Session>>,
|
||||
messages: Vec<ChatMessage>,
|
||||
usage: crate::storage::TurnUsageRecord,
|
||||
) -> Result<Vec<crate::storage::message::MessageMeta>, StorageError> {
|
||||
append_persisted_messages_inner(session, messages, VersionPolicy::Advance, Some(usage)).await
|
||||
}
|
||||
|
||||
async fn append_persisted_messages_inner(
|
||||
session: &Arc<Mutex<Session>>,
|
||||
messages: Vec<ChatMessage>,
|
||||
version_policy: VersionPolicy,
|
||||
usage: Option<crate::storage::TurnUsageRecord>,
|
||||
) -> Result<Vec<crate::storage::message::MessageMeta>, StorageError> {
|
||||
if messages.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
@ -113,7 +130,7 @@ async fn append_persisted_messages_inner(
|
||||
.map(|(_, _, message, _)| message.clone())
|
||||
.collect();
|
||||
|
||||
if let Err(error) = persist_added_messages(snapshots).await {
|
||||
if let Err(error) = persist_added_messages(snapshots, usage.as_ref()).await {
|
||||
session
|
||||
.lock()
|
||||
.await
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
142
src/session/stats.rs
Normal file
142
src/session/stats.rs
Normal file
@ -0,0 +1,142 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SessionStats {
|
||||
pub session_id: String,
|
||||
pub title: String,
|
||||
pub provider: String,
|
||||
pub model: String,
|
||||
pub user_message_count: u64,
|
||||
pub history_message_count: u64,
|
||||
pub lifetime_usage: LifetimeUsage,
|
||||
pub context: ContextUsage,
|
||||
pub created_at: i64,
|
||||
pub last_active_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct LifetimeUsage {
|
||||
pub input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
pub total_tokens: u64,
|
||||
pub cached_input_tokens: Option<u64>,
|
||||
pub request_count: u64,
|
||||
pub turn_count: u64,
|
||||
pub tracked_since: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ContextUsage {
|
||||
pub configured_window_tokens: u64,
|
||||
pub effective_window_tokens: u64,
|
||||
pub used_tokens: u64,
|
||||
pub remaining_tokens: u64,
|
||||
pub compression_threshold_tokens: u64,
|
||||
pub source: ContextUsageSource,
|
||||
pub last_observed_prompt_tokens: Option<u64>,
|
||||
pub observed_at: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ContextUsageSource {
|
||||
Hybrid,
|
||||
Estimated,
|
||||
}
|
||||
|
||||
impl ContextUsageSource {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Hybrid => "混合估算",
|
||||
Self::Estimated => "字符估算",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SessionStats {
|
||||
pub fn render_text(&self) -> String {
|
||||
let percent = if self.context.effective_window_tokens == 0 {
|
||||
0.0
|
||||
} else {
|
||||
self.context.used_tokens as f64 / self.context.effective_window_tokens as f64 * 100.0
|
||||
};
|
||||
let created_at = format_timestamp(self.created_at);
|
||||
let last_active_at = format_timestamp(self.last_active_at);
|
||||
let tracked_since = self
|
||||
.lifetime_usage
|
||||
.tracked_since
|
||||
.map(format_timestamp)
|
||||
.unwrap_or_else(|| "尚无已完成模型请求".to_string());
|
||||
let cached = self
|
||||
.lifetime_usage
|
||||
.cached_input_tokens
|
||||
.map(format_tokens)
|
||||
.unwrap_or_else(|| "—".to_string());
|
||||
let observed = self
|
||||
.context
|
||||
.last_observed_prompt_tokens
|
||||
.map(format_tokens)
|
||||
.unwrap_or_else(|| "—".to_string());
|
||||
|
||||
format!(
|
||||
"会话\n 标题 {}\n ID {}\n 模型 {} / {}\n 消息 {} 条用户消息,{} 条历史消息\n 创建 {}\n 最后活跃 {}\n\nToken 用量 · 已提交 Turns\n 输入 {}\n 输出 {}\n 合计 {}\n 缓存输入 {}\n 请求 {}\n Turns {}\n 统计起点 {}\n\n上下文窗口 · {}\n 占用 {} / {}({:.1}%)\n 剩余 {}\n 压缩阈值 {}(70%)\n 最近实测 {}",
|
||||
self.title,
|
||||
self.session_id,
|
||||
self.provider,
|
||||
self.model,
|
||||
self.user_message_count,
|
||||
self.history_message_count,
|
||||
created_at,
|
||||
last_active_at,
|
||||
format_tokens(self.lifetime_usage.input_tokens),
|
||||
format_tokens(self.lifetime_usage.output_tokens),
|
||||
format_tokens(self.lifetime_usage.total_tokens),
|
||||
cached,
|
||||
format_tokens(self.lifetime_usage.request_count),
|
||||
format_tokens(self.lifetime_usage.turn_count),
|
||||
tracked_since,
|
||||
self.context.source.label(),
|
||||
format_tokens(self.context.used_tokens),
|
||||
format_tokens(self.context.effective_window_tokens),
|
||||
percent,
|
||||
format_tokens(self.context.remaining_tokens),
|
||||
format_tokens(self.context.compression_threshold_tokens),
|
||||
observed,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn format_timestamp(value: i64) -> String {
|
||||
chrono::DateTime::from_timestamp_millis(value)
|
||||
.map(|timestamp| {
|
||||
timestamp
|
||||
.with_timezone(&chrono::Local)
|
||||
.format("%Y-%m-%d %H:%M:%S")
|
||||
.to_string()
|
||||
})
|
||||
.unwrap_or_else(|| "—".to_string())
|
||||
}
|
||||
|
||||
fn format_tokens(value: u64) -> String {
|
||||
let digits = value.to_string();
|
||||
let mut formatted = String::with_capacity(digits.len() + digits.len() / 3);
|
||||
for (index, ch) in digits.chars().enumerate() {
|
||||
if index > 0 && (digits.len() - index).is_multiple_of(3) {
|
||||
formatted.push(',');
|
||||
}
|
||||
formatted.push(ch);
|
||||
}
|
||||
formatted
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn formats_large_token_counts() {
|
||||
assert_eq!(format_tokens(1_234_567), "1,234,567");
|
||||
assert_eq!(format_tokens(12), "12");
|
||||
}
|
||||
}
|
||||
@ -56,6 +56,7 @@ pub enum TurnPhase {
|
||||
pub enum ToolStatus {
|
||||
Running,
|
||||
Completed,
|
||||
Cancelled,
|
||||
Failed,
|
||||
}
|
||||
|
||||
@ -233,6 +234,18 @@ impl TurnControllerInner {
|
||||
return false;
|
||||
}
|
||||
self.text_segment_open = false;
|
||||
if status == TurnStatus::Cancelled {
|
||||
for block in &mut self.state.blocks {
|
||||
if let TurnBlock::Tool {
|
||||
status: tool_status,
|
||||
..
|
||||
} = block
|
||||
&& *tool_status == ToolStatus::Running
|
||||
{
|
||||
*tool_status = ToolStatus::Cancelled;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.state.status = status;
|
||||
self.state.phase = TurnPhase::Finalizing;
|
||||
self.state.usage = usage;
|
||||
@ -580,6 +593,34 @@ mod tests {
|
||||
assert_eq!(snapshot.revision, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelling_turn_marks_running_tools_cancelled() {
|
||||
let (controller, emitter, _receiver) = start();
|
||||
emitter
|
||||
.emit(TurnEvent::ToolStarted {
|
||||
iteration: 0,
|
||||
call: ToolCall {
|
||||
id: "sleep-call".into(),
|
||||
name: "sleep".into(),
|
||||
arguments: serde_json::json!({"seconds": 60}),
|
||||
},
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert!(controller.cancel(Some("stopped by user".into())));
|
||||
|
||||
let snapshot = controller.snapshot();
|
||||
assert_eq!(snapshot.status, TurnStatus::Cancelled);
|
||||
assert!(matches!(
|
||||
&snapshot.blocks[0],
|
||||
TurnBlock::Tool {
|
||||
id,
|
||||
status: ToolStatus::Cancelled,
|
||||
..
|
||||
} if id == "sleep-call"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failure_is_published_as_structured_terminal_state() {
|
||||
let (controller, _emitter, _) = start();
|
||||
|
||||
@ -4,10 +4,12 @@ pub mod memory;
|
||||
pub mod message;
|
||||
pub mod scheduler;
|
||||
pub mod session;
|
||||
pub mod usage;
|
||||
|
||||
pub use background_task::BackgroundTask;
|
||||
pub use error::StorageError;
|
||||
pub use scheduler::{DeliveryPolicy, JobKind, JobRun, ScheduledJob};
|
||||
pub use usage::{SessionUsageTotals, TurnUsageRecord};
|
||||
|
||||
use sqlx::sqlite::{
|
||||
SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteRow, SqliteSynchronous,
|
||||
@ -16,7 +18,7 @@ use sqlx::{Pool, Row, Sqlite};
|
||||
use std::path::Path;
|
||||
use tokio::time::{Duration, sleep};
|
||||
|
||||
const SCHEMA_VERSION: i64 = 4;
|
||||
const SCHEMA_VERSION: i64 = 5;
|
||||
const INSERT_MESSAGE_SQL: &str = r#"
|
||||
INSERT INTO messages (
|
||||
id, session_id, seq, role, content, reasoning_content, provider_state,
|
||||
@ -362,6 +364,33 @@ impl Storage {
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS session_turn_usage (
|
||||
turn_id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
prompt_tokens INTEGER NOT NULL,
|
||||
completion_tokens INTEGER NOT NULL,
|
||||
total_tokens INTEGER NOT NULL,
|
||||
cached_input_tokens INTEGER,
|
||||
request_count INTEGER NOT NULL,
|
||||
last_prompt_tokens INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE INDEX IF NOT EXISTS idx_session_turn_usage_session_created ON session_turn_usage(session_id, created_at)",
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Self::init_scheduler_schema(&self.pool).await?;
|
||||
self.migrate_schema().await?;
|
||||
|
||||
@ -467,6 +496,31 @@ impl Storage {
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS session_turn_usage (
|
||||
turn_id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
prompt_tokens INTEGER NOT NULL,
|
||||
completion_tokens INTEGER NOT NULL,
|
||||
total_tokens INTEGER NOT NULL,
|
||||
cached_input_tokens INTEGER,
|
||||
request_count INTEGER NOT NULL,
|
||||
last_prompt_tokens INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"CREATE INDEX IF NOT EXISTS idx_session_turn_usage_session_created ON session_turn_usage(session_id, created_at)",
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query(sqlx::AssertSqlSafe(format!(
|
||||
"PRAGMA user_version = {SCHEMA_VERSION}"
|
||||
)))
|
||||
@ -803,11 +857,12 @@ impl Storage {
|
||||
/// Atomically persist all messages produced by one logical turn together
|
||||
/// with the resulting session metadata. A turn is either fully visible
|
||||
/// after restart or not visible at all.
|
||||
pub async fn persist_message_batch(
|
||||
async fn persist_message_batch_inner(
|
||||
&self,
|
||||
session_id: &str,
|
||||
msgs: &[crate::storage::message::MessageMeta],
|
||||
meta: &crate::storage::session::SessionMeta,
|
||||
usage: Option<&crate::storage::TurnUsageRecord>,
|
||||
) -> Result<(), StorageError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
@ -848,10 +903,63 @@ impl Storage {
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if let Some(usage) = usage {
|
||||
debug_assert_eq!(session_id, usage.session_id);
|
||||
let request_count = msgs
|
||||
.iter()
|
||||
.filter_map(|message| message.iteration)
|
||||
.max()
|
||||
.map_or(1_i64, |iteration| iteration.saturating_add(1));
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO session_turn_usage (
|
||||
turn_id, session_id, provider, model, prompt_tokens,
|
||||
completion_tokens, total_tokens, cached_input_tokens,
|
||||
request_count, last_prompt_tokens, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(turn_id) DO NOTHING
|
||||
"#,
|
||||
)
|
||||
.bind(&usage.turn_id)
|
||||
.bind(&usage.session_id)
|
||||
.bind(&usage.provider)
|
||||
.bind(&usage.model)
|
||||
.bind(i64::from(usage.usage.prompt_tokens))
|
||||
.bind(i64::from(usage.usage.completion_tokens))
|
||||
.bind(i64::from(usage.usage.total_tokens))
|
||||
.bind(usage.usage.cached_tokens.map(i64::from))
|
||||
.bind(request_count)
|
||||
.bind(i64::from(usage.last_prompt_tokens))
|
||||
.bind(usage.created_at)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn persist_message_batch(
|
||||
&self,
|
||||
session_id: &str,
|
||||
msgs: &[crate::storage::message::MessageMeta],
|
||||
meta: &crate::storage::session::SessionMeta,
|
||||
) -> Result<(), StorageError> {
|
||||
self.persist_message_batch_inner(session_id, msgs, meta, None)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn persist_turn_batch(
|
||||
&self,
|
||||
session_id: &str,
|
||||
msgs: &[crate::storage::message::MessageMeta],
|
||||
meta: &crate::storage::session::SessionMeta,
|
||||
usage: &crate::storage::TurnUsageRecord,
|
||||
) -> Result<(), StorageError> {
|
||||
self.persist_message_batch_inner(session_id, msgs, meta, Some(usage))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Persist a turn with bounded retry. Retrying the whole transaction keeps
|
||||
/// message rows and metadata consistent on transient SQLite failures.
|
||||
pub async fn persist_message_batch_with_retry(
|
||||
@ -874,6 +982,80 @@ impl Storage {
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
pub async fn persist_turn_batch_with_retry(
|
||||
&self,
|
||||
session_id: &str,
|
||||
msgs: &[crate::storage::message::MessageMeta],
|
||||
meta: &crate::storage::session::SessionMeta,
|
||||
usage: &crate::storage::TurnUsageRecord,
|
||||
) -> Result<(), StorageError> {
|
||||
let delays = [100, 200, 300];
|
||||
for (attempt, delay) in delays.iter().enumerate() {
|
||||
match self.persist_turn_batch(session_id, msgs, meta, usage).await {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(error) if attempt < delays.len() - 1 && error.is_transient() => {
|
||||
tracing::warn!(attempt = attempt + 1, error = %error, "Turn persistence failed; retrying");
|
||||
sleep(Duration::from_millis(*delay)).await;
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
pub async fn get_session_usage_totals(
|
||||
&self,
|
||||
session_id: &str,
|
||||
) -> Result<crate::storage::SessionUsageTotals, StorageError> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
COALESCE(SUM(prompt_tokens), 0) AS prompt_tokens,
|
||||
COALESCE(SUM(completion_tokens), 0) AS completion_tokens,
|
||||
COALESCE(SUM(total_tokens), 0) AS total_tokens,
|
||||
SUM(cached_input_tokens) AS cached_input_tokens,
|
||||
COALESCE(SUM(request_count), 0) AS request_count,
|
||||
COUNT(*) AS turn_count,
|
||||
MIN(created_at) AS tracked_since
|
||||
FROM session_turn_usage
|
||||
WHERE session_id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(session_id)
|
||||
.fetch_one(self.pool())
|
||||
.await?;
|
||||
|
||||
let last = sqlx::query(
|
||||
r#"
|
||||
SELECT last_prompt_tokens, created_at
|
||||
FROM session_turn_usage
|
||||
WHERE session_id = ?
|
||||
ORDER BY created_at DESC, rowid DESC
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.bind(session_id)
|
||||
.fetch_optional(self.pool())
|
||||
.await?;
|
||||
|
||||
Ok(crate::storage::SessionUsageTotals {
|
||||
prompt_tokens: u64::try_from(row.get::<i64, _>("prompt_tokens")).unwrap_or_default(),
|
||||
completion_tokens: u64::try_from(row.get::<i64, _>("completion_tokens"))
|
||||
.unwrap_or_default(),
|
||||
total_tokens: u64::try_from(row.get::<i64, _>("total_tokens")).unwrap_or_default(),
|
||||
cached_input_tokens: row
|
||||
.get::<Option<i64>, _>("cached_input_tokens")
|
||||
.and_then(|value| u64::try_from(value).ok()),
|
||||
request_count: u64::try_from(row.get::<i64, _>("request_count")).unwrap_or_default(),
|
||||
turn_count: u64::try_from(row.get::<i64, _>("turn_count")).unwrap_or_default(),
|
||||
tracked_since: row.get("tracked_since"),
|
||||
last_prompt_tokens: last
|
||||
.as_ref()
|
||||
.and_then(|value| u64::try_from(value.get::<i64, _>("last_prompt_tokens")).ok()),
|
||||
last_observed_at: last.map(|value| value.get("created_at")),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn load_messages(
|
||||
&self,
|
||||
session_id: &str,
|
||||
@ -1382,6 +1564,80 @@ mod tests {
|
||||
assert!(orphan.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn committed_turn_usage_is_aggregated_and_idempotent() {
|
||||
let (storage, _dir) = create_test_storage().await;
|
||||
let meta = crate::storage::session::SessionMeta {
|
||||
id: "cli_chat:chat:dialog".to_string(),
|
||||
channel: "cli_chat".to_string(),
|
||||
chat_id: "chat".to_string(),
|
||||
dialog_id: "dialog".to_string(),
|
||||
title: "usage".to_string(),
|
||||
created_at: 1,
|
||||
last_active_at: 2,
|
||||
message_count: 1,
|
||||
routing_info: None,
|
||||
archived_at: None,
|
||||
deleted_at: None,
|
||||
last_consolidated_at: None,
|
||||
last_compressed_message_at: None,
|
||||
};
|
||||
let first = crate::storage::TurnUsageRecord {
|
||||
session_id: meta.id.clone(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
provider: "test".to_string(),
|
||||
model: "model".to_string(),
|
||||
usage: crate::providers::Usage {
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 20,
|
||||
total_tokens: 120,
|
||||
cached_tokens: Some(40),
|
||||
cache_read_input_tokens: None,
|
||||
cache_creation_input_tokens: None,
|
||||
},
|
||||
last_prompt_tokens: 75,
|
||||
created_at: 10,
|
||||
};
|
||||
storage
|
||||
.persist_turn_batch(&meta.id, &[], &meta, &first)
|
||||
.await
|
||||
.unwrap();
|
||||
storage
|
||||
.persist_turn_batch(&meta.id, &[], &meta, &first)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let second = crate::storage::TurnUsageRecord {
|
||||
turn_id: "turn-2".to_string(),
|
||||
usage: crate::providers::Usage {
|
||||
prompt_tokens: 50,
|
||||
completion_tokens: 10,
|
||||
total_tokens: 60,
|
||||
cached_tokens: None,
|
||||
cache_read_input_tokens: None,
|
||||
cache_creation_input_tokens: None,
|
||||
},
|
||||
last_prompt_tokens: 45,
|
||||
created_at: 20,
|
||||
..first
|
||||
};
|
||||
storage
|
||||
.persist_turn_batch(&meta.id, &[], &meta, &second)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let totals = storage.get_session_usage_totals(&meta.id).await.unwrap();
|
||||
assert_eq!(totals.prompt_tokens, 150);
|
||||
assert_eq!(totals.completion_tokens, 30);
|
||||
assert_eq!(totals.total_tokens, 180);
|
||||
assert_eq!(totals.cached_input_tokens, Some(40));
|
||||
assert_eq!(totals.request_count, 2);
|
||||
assert_eq!(totals.turn_count, 2);
|
||||
assert_eq!(totals.tracked_since, Some(10));
|
||||
assert_eq!(totals.last_prompt_tokens, Some(45));
|
||||
assert_eq!(totals.last_observed_at, Some(20));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reopening_database_does_not_rebuild_existing_fts_index() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@ -1629,7 +1885,7 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(schema_version, SCHEMA_VERSION);
|
||||
for table in ["task_plans", "task_items"] {
|
||||
for table in ["task_plans", "task_items", "session_turn_usage"] {
|
||||
let exists: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?",
|
||||
)
|
||||
|
||||
28
src/storage/usage.rs
Normal file
28
src/storage/usage.rs
Normal file
@ -0,0 +1,28 @@
|
||||
use crate::providers::Usage;
|
||||
|
||||
/// Provider-reported usage committed with one durable assistant Turn.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TurnUsageRecord {
|
||||
pub session_id: String,
|
||||
pub turn_id: String,
|
||||
pub provider: String,
|
||||
pub model: String,
|
||||
pub usage: Usage,
|
||||
/// Prompt usage from the final provider request in the Turn. Unlike
|
||||
/// `usage.prompt_tokens`, this is not accumulated across tool iterations.
|
||||
pub last_prompt_tokens: u32,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct SessionUsageTotals {
|
||||
pub prompt_tokens: u64,
|
||||
pub completion_tokens: u64,
|
||||
pub total_tokens: u64,
|
||||
pub cached_input_tokens: Option<u64>,
|
||||
pub request_count: u64,
|
||||
pub turn_count: u64,
|
||||
pub tracked_since: Option<i64>,
|
||||
pub last_prompt_tokens: Option<u64>,
|
||||
pub last_observed_at: Option<i64>,
|
||||
}
|
||||
@ -21,6 +21,7 @@ pub mod registry;
|
||||
pub mod reload_config;
|
||||
pub mod schema;
|
||||
pub mod send_message;
|
||||
pub mod sleep;
|
||||
pub mod todo;
|
||||
pub mod traits;
|
||||
pub mod web_fetch;
|
||||
@ -44,6 +45,7 @@ pub use pty::{PtyManager, PtyTool};
|
||||
pub use registry::ToolRegistry;
|
||||
pub use reload_config::ReloadConfigTool;
|
||||
pub use send_message::SendMessageTool;
|
||||
pub use sleep::SleepTool;
|
||||
pub use todo::TodoTool;
|
||||
pub use traits::{
|
||||
OutboundDelivery, OutboundMessenger, ProcessedToolOutput, Tool, ToolArtifact,
|
||||
@ -73,6 +75,7 @@ pub fn create_default_tools(
|
||||
) -> anyhow::Result<ToolRegistry> {
|
||||
let registry = ToolRegistry::new();
|
||||
registry.register(CalculatorTool::new());
|
||||
registry.register(SleepTool::new());
|
||||
registry.register(FileReadTool::new());
|
||||
registry.register(FileWriteTool::new());
|
||||
registry.register(FileEditTool::new());
|
||||
|
||||
@ -307,7 +307,7 @@ impl PtyManager {
|
||||
|
||||
let total = guard.output_total_lines;
|
||||
let buffer_len = guard.output_buffer.len();
|
||||
let start = 0_usize.max(offset);
|
||||
let start = offset;
|
||||
let skip_old = total.saturating_sub(buffer_len);
|
||||
let view_start = start.saturating_sub(skip_old);
|
||||
|
||||
|
||||
238
src/tools/sleep.rs
Normal file
238
src/tools/sleep.rs
Normal file
@ -0,0 +1,238 @@
|
||||
use super::traits::{Tool, ToolResult};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
use std::time::Duration;
|
||||
|
||||
const MAX_SLEEP_SECONDS: u64 = 86_400;
|
||||
|
||||
pub struct SleepTool;
|
||||
|
||||
impl SleepTool {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SleepTool {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_seconds(args: &serde_json::Value) -> Result<u64, String> {
|
||||
let seconds = args
|
||||
.get("seconds")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.ok_or_else(|| "seconds must be a non-negative integer".to_string())?;
|
||||
if seconds > MAX_SLEEP_SECONDS {
|
||||
return Err(format!(
|
||||
"seconds must not exceed {MAX_SLEEP_SECONDS} (24 hours)"
|
||||
));
|
||||
}
|
||||
Ok(seconds)
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for SleepTool {
|
||||
fn name(&self) -> &str {
|
||||
"sleep"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Pause the current agent execution for a specified number of whole seconds."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"seconds": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": MAX_SLEEP_SECONDS,
|
||||
"description": "Number of whole seconds to wait, up to 24 hours."
|
||||
}
|
||||
},
|
||||
"required": ["seconds"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let seconds = match parse_seconds(&args) {
|
||||
Ok(seconds) => seconds,
|
||||
Err(error) => {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
tokio::time::sleep(Duration::from_secs(seconds)).await;
|
||||
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output: format!("Slept for {seconds} second(s)."),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::agent::TurnEvent;
|
||||
use crate::providers::ToolCall;
|
||||
use crate::session::{ToolStatus, TurnBlock, TurnController, TurnStatus};
|
||||
use crate::tools::Tool;
|
||||
use serde_json::json;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn exposes_sleep_metadata_and_schema() {
|
||||
let tool = SleepTool::new();
|
||||
let schema = tool.parameters_schema();
|
||||
|
||||
assert_eq!(tool.name(), "sleep");
|
||||
assert!(tool.description().contains("current agent execution"));
|
||||
assert!(tool.description().contains("whole seconds"));
|
||||
assert_eq!(schema["type"], "object");
|
||||
assert_eq!(schema["required"], json!(["seconds"]));
|
||||
assert_eq!(schema["properties"]["seconds"]["type"], "integer");
|
||||
assert_eq!(schema["properties"]["seconds"]["minimum"], 0);
|
||||
assert_eq!(
|
||||
schema["properties"]["seconds"]["maximum"],
|
||||
MAX_SLEEP_SECONDS
|
||||
);
|
||||
assert!(schema.get("additionalProperties").is_none());
|
||||
assert!(!tool.read_only());
|
||||
assert!(!tool.concurrency_safe());
|
||||
assert!(!tool.exclusive());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn zero_seconds_returns_exact_success() {
|
||||
let result = SleepTool::new()
|
||||
.execute(json!({"seconds": 0}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.success);
|
||||
assert_eq!(result.output, "Slept for 0 second(s).");
|
||||
assert_eq!(result.error, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_invalid_seconds() {
|
||||
let invalid_args = [
|
||||
json!({}),
|
||||
json!({"seconds": -1}),
|
||||
json!({"seconds": 0.5}),
|
||||
json!({"seconds": "1"}),
|
||||
json!({"seconds": 18_446_744_073_709_552_000.0_f64}),
|
||||
json!({"seconds": MAX_SLEEP_SECONDS + 1}),
|
||||
];
|
||||
|
||||
for args in invalid_args {
|
||||
let result = SleepTool::new().execute(args).await.unwrap();
|
||||
assert!(!result.success);
|
||||
assert!(result.output.is_empty());
|
||||
assert!(result.error.is_some());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_24_hour_boundary() {
|
||||
assert_eq!(
|
||||
parse_seconds(&json!({"seconds": MAX_SLEEP_SECONDS})),
|
||||
Ok(MAX_SLEEP_SECONDS)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn waits_for_requested_seconds() {
|
||||
let handle = tokio::spawn(async { SleepTool::new().execute(json!({"seconds": 2})).await });
|
||||
tokio::task::yield_now().await;
|
||||
tokio::time::advance(Duration::from_secs(1)).await;
|
||||
tokio::task::yield_now().await;
|
||||
assert!(!handle.is_finished());
|
||||
tokio::time::advance(Duration::from_secs(1)).await;
|
||||
tokio::task::yield_now().await;
|
||||
assert!(handle.await.unwrap().unwrap().success);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn waits_up_to_24_hour_boundary() {
|
||||
let handle = tokio::spawn(async {
|
||||
SleepTool::new()
|
||||
.execute(json!({"seconds": MAX_SLEEP_SECONDS}))
|
||||
.await
|
||||
});
|
||||
tokio::task::yield_now().await;
|
||||
tokio::time::advance(Duration::from_secs(MAX_SLEEP_SECONDS - 1)).await;
|
||||
tokio::task::yield_now().await;
|
||||
assert!(!handle.is_finished());
|
||||
tokio::time::advance(Duration::from_secs(1)).await;
|
||||
tokio::task::yield_now().await;
|
||||
assert!(handle.await.unwrap().unwrap().success);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn cancellation_drops_an_active_sleep() {
|
||||
let handle = tokio::spawn(async {
|
||||
SleepTool::new()
|
||||
.execute(json!({"seconds": MAX_SLEEP_SECONDS}))
|
||||
.await
|
||||
});
|
||||
tokio::task::yield_now().await;
|
||||
assert!(!handle.is_finished());
|
||||
handle.abort();
|
||||
assert!(handle.await.unwrap_err().is_cancelled());
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn user_cancellation_stops_sleep_and_terminalizes_its_tool_block() {
|
||||
let (controller, emitter, receiver) =
|
||||
TurnController::start("cli:test:sleep", "assistant-message");
|
||||
emitter
|
||||
.emit(TurnEvent::ToolStarted {
|
||||
iteration: 0,
|
||||
call: ToolCall {
|
||||
id: "sleep-call".into(),
|
||||
name: "sleep".into(),
|
||||
arguments: json!({"seconds": MAX_SLEEP_SECONDS}),
|
||||
},
|
||||
})
|
||||
.unwrap();
|
||||
let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel::<()>();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
let tool = SleepTool::new();
|
||||
tokio::select! {
|
||||
result = tool.execute(json!({"seconds": MAX_SLEEP_SECONDS})) => {
|
||||
result.unwrap();
|
||||
false
|
||||
}
|
||||
_ = cancel_rx => {
|
||||
controller.cancel(Some("stopped by user".into()));
|
||||
true
|
||||
}
|
||||
}
|
||||
});
|
||||
tokio::task::yield_now().await;
|
||||
drop(cancel_tx);
|
||||
|
||||
assert!(handle.await.unwrap());
|
||||
let snapshot = receiver.borrow().clone();
|
||||
assert_eq!(snapshot.status, TurnStatus::Cancelled);
|
||||
assert!(matches!(
|
||||
&snapshot.blocks[0],
|
||||
TurnBlock::Tool {
|
||||
id,
|
||||
status: ToolStatus::Cancelled,
|
||||
..
|
||||
} if id == "sleep-call"
|
||||
));
|
||||
}
|
||||
}
|
||||
@ -167,6 +167,7 @@ fn test_user_input_accepts_upload_ids_and_old_payloads() {
|
||||
let message = WsInbound::UserInput {
|
||||
content: "处理文件".to_string(),
|
||||
upload_ids: vec!["upload-1".to_string()],
|
||||
client_message_id: None,
|
||||
channel: None,
|
||||
chat_id: None,
|
||||
sender_id: None,
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="theme-color" content="#f5f5f5" />
|
||||
<meta name="theme-color" content="#fafafa" />
|
||||
<script src="/theme-init.js"></script>
|
||||
<title>PicoBot Workspace</title>
|
||||
</head>
|
||||
|
||||
4
webui/package-lock.json
generated
4
webui/package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "picobot-webui",
|
||||
"version": "1.4.0",
|
||||
"version": "1.5.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "picobot-webui",
|
||||
"version": "1.4.0",
|
||||
"version": "1.5.1",
|
||||
"dependencies": {
|
||||
"bits-ui": "^2.0.0",
|
||||
"dompurify": "^3.4.12",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "picobot-webui",
|
||||
"private": true,
|
||||
"version": "1.4.0",
|
||||
"version": "1.5.1",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
}
|
||||
|
||||
function toolStatus(value) {
|
||||
return ({ running: "执行中", completed: "已完成", failed: "失败" })[value] || value;
|
||||
return ({ running: "执行中", completed: "已完成", cancelled: "已停止", failed: "失败" })[value] || value;
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@ -3,6 +3,8 @@ import { clientId } from "./api.js";
|
||||
class ChatClient {
|
||||
connected = $state(false);
|
||||
turn = $state(null); // 最新 turn 快照(任意 session),供活动脊
|
||||
currentSessionId = $state(null);
|
||||
statsBySession = $state({});
|
||||
#socket = null;
|
||||
#handlers = new Set();
|
||||
#reconnectTimer = null;
|
||||
@ -33,6 +35,9 @@ class ChatClient {
|
||||
let frame;
|
||||
try { frame = JSON.parse(event.data); } catch { return; }
|
||||
if (frame.type === "turn_updated" && frame.snapshot) this.turn = frame.snapshot;
|
||||
if (frame.type === "session_stats" && frame.stats?.session_id) {
|
||||
this.statsBySession[frame.stats.session_id] = frame.stats;
|
||||
}
|
||||
this.#dispatch(frame);
|
||||
};
|
||||
}
|
||||
@ -53,6 +58,10 @@ class ChatClient {
|
||||
return false;
|
||||
}
|
||||
|
||||
get currentStats() {
|
||||
return this.currentSessionId ? this.statsBySession[this.currentSessionId] ?? null : null;
|
||||
}
|
||||
|
||||
subscribe(handler) {
|
||||
this.#handlers.add(handler);
|
||||
return () => this.#handlers.delete(handler);
|
||||
|
||||
@ -2,51 +2,154 @@
|
||||
import { chat } from "../chat.svelte.js";
|
||||
|
||||
let { version = "" } = $props();
|
||||
let lastTokens = null; // { at, completion } — plain bookkeeping, NOT reactive
|
||||
let rate = $state(null);
|
||||
|
||||
$effect(() => {
|
||||
const turn = chat.turn;
|
||||
if (!turn || turn.status !== "running") { rate = null; lastTokens = null; return; }
|
||||
const completion = turn.usage?.completion_tokens;
|
||||
const now = Date.now();
|
||||
if (completion != null && lastTokens && now > lastTokens.at) {
|
||||
const delta = completion - lastTokens.completion;
|
||||
const secs = (now - lastTokens.at) / 1000;
|
||||
if (delta >= 0 && secs > 0) rate = Math.round(delta / secs);
|
||||
const running = $derived(chat.turn?.status === "running" && chat.turn?.session_id === chat.currentSessionId);
|
||||
const turnLabel = $derived(running ? `Turn ${String(chat.turn?.id ?? "").slice(0, 6).toUpperCase()}` : "");
|
||||
const stats = $derived(chat.currentStats);
|
||||
const context = $derived(stats?.context ?? null);
|
||||
const lifetime = $derived(stats?.lifetime_usage ?? null);
|
||||
const percent = $derived(context?.effective_window_tokens
|
||||
? context.used_tokens / context.effective_window_tokens * 100
|
||||
: 0);
|
||||
const boundedPercent = $derived(Math.max(0, Math.min(percent, 100)));
|
||||
const pressure = $derived(percent >= 90 ? "danger" : percent >= 70 ? "warning" : "normal");
|
||||
|
||||
function compactTokens(value) {
|
||||
if (!Number.isFinite(value)) return "—";
|
||||
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(value >= 10_000_000 ? 1 : 2)}M`;
|
||||
if (value >= 1_000) return `${(value / 1_000).toFixed(value >= 100_000 ? 0 : 1)}k`;
|
||||
return String(value);
|
||||
}
|
||||
if (completion != null) lastTokens = { at: now, completion };
|
||||
});
|
||||
|
||||
const running = $derived(chat.turn?.status === "running");
|
||||
const turnLabel = $derived(chat.turn ? `Turn ${String(chat.turn.id ?? "").slice(0, 6).toUpperCase()}` : "");
|
||||
const ctx = $derived(chat.turn?.usage?.prompt_tokens != null
|
||||
? `${(chat.turn.usage.prompt_tokens / 1000).toFixed(1)}k` : null);
|
||||
function exactTokens(value) {
|
||||
return Number.isFinite(value) ? new Intl.NumberFormat("zh-CN").format(value) : "—";
|
||||
}
|
||||
|
||||
function sourceLabel(value) {
|
||||
return value === "hybrid" ? "混合估算" : "字符估算";
|
||||
}
|
||||
|
||||
function trackedSince(value) {
|
||||
if (!Number.isFinite(value)) return "尚无已提交 Turn";
|
||||
return `自 ${new Date(value).toLocaleString("zh-CN", { hour12: false })}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="spine mono" title={version}>
|
||||
<details class="spine-shell mono" data-pressure={pressure} title={version}>
|
||||
<summary class="spine">
|
||||
{#if running}
|
||||
<span class="spine-turn active"><i class="pulse-dot active"></i>{turnLabel} · 生成中</span>
|
||||
{#if rate != null}<span class="spine-rate">▲ {rate} tok/s</span>{/if}
|
||||
{#if ctx}<span>ctx {ctx}</span>{/if}
|
||||
{:else if chat.turn}
|
||||
<span class="spine-turn idle"><i class="pulse-dot idle"></i>空闲</span>
|
||||
<span class="spine-context">最近 {turnLabel}</span>
|
||||
{:else}
|
||||
<span class="spine-turn idle"><i class="pulse-dot idle"></i>就绪</span>
|
||||
<span class="spine-turn idle"><i class="pulse-dot idle"></i>{chat.connected ? "空闲" : "重连中"}</span>
|
||||
{/if}
|
||||
<span class="spine-right">
|
||||
<span class:spine-ok={chat.connected} class:spine-down={!chat.connected}>{chat.connected ? "已连接" : "重连中"}</span>
|
||||
|
||||
{#if context}
|
||||
<span class="context-readout">
|
||||
<span class="context-label">上下文</span>
|
||||
<span
|
||||
class="context-rail"
|
||||
role="progressbar"
|
||||
aria-label="当前上下文窗口占用"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
aria-valuenow={Math.round(boundedPercent)}
|
||||
style={`--context-fill: ${boundedPercent}%`}
|
||||
><i></i><b></b></span>
|
||||
<span class="context-value">{compactTokens(context.used_tokens)} / {compactTokens(context.effective_window_tokens)}</span>
|
||||
<strong class="context-percent">{percent.toFixed(1)}%</strong>
|
||||
</span>
|
||||
</div>
|
||||
<span class="usage-summary"><span>↑ {compactTokens(lifetime?.input_tokens)}</span><span>↓ {compactTokens(lifetime?.output_tokens)}</span></span>
|
||||
{:else}
|
||||
<span class="context-pending">等待会话统计</span>
|
||||
{/if}
|
||||
|
||||
<span class="spine-right" class:spine-ok={chat.connected} class:spine-down={!chat.connected}>
|
||||
{chat.connected ? "已连接" : "重连中"}
|
||||
</span>
|
||||
</summary>
|
||||
|
||||
{#if stats && context && lifetime}
|
||||
<section class="stats-panel" aria-label="会话 Token 与上下文详情">
|
||||
<header>
|
||||
<div><span>当前上下文</span><strong>{percent.toFixed(1)}%</strong></div>
|
||||
<small>{sourceLabel(context.source)}</small>
|
||||
</header>
|
||||
<div class="detail-rail" role="presentation" style={`--context-fill: ${boundedPercent}%`}><i></i><b></b></div>
|
||||
<dl class="context-grid">
|
||||
<div><dt>占用</dt><dd>{exactTokens(context.used_tokens)} / {exactTokens(context.effective_window_tokens)}</dd></div>
|
||||
<div><dt>剩余</dt><dd>{exactTokens(context.remaining_tokens)}</dd></div>
|
||||
<div><dt>压缩阈值</dt><dd>{exactTokens(context.compression_threshold_tokens)} · 70%</dd></div>
|
||||
<div><dt>最近实测</dt><dd>{exactTokens(context.last_observed_prompt_tokens)}</dd></div>
|
||||
</dl>
|
||||
|
||||
<div class="usage-heading"><span>会话累计</span><small>Provider 报告 · 已提交 Turns</small></div>
|
||||
<dl class="usage-grid">
|
||||
<div><dt>输入</dt><dd>{exactTokens(lifetime.input_tokens)}</dd></div>
|
||||
<div><dt>输出</dt><dd>{exactTokens(lifetime.output_tokens)}</dd></div>
|
||||
<div><dt>缓存输入</dt><dd>{exactTokens(lifetime.cached_input_tokens)}</dd></div>
|
||||
<div><dt>请求 / Turns</dt><dd>{exactTokens(lifetime.request_count)} / {exactTokens(lifetime.turn_count)}</dd></div>
|
||||
</dl>
|
||||
<footer>{stats.provider} / {stats.model}<span>{trackedSince(lifetime.tracked_since)}</span></footer>
|
||||
</section>
|
||||
{/if}
|
||||
</details>
|
||||
|
||||
<style>
|
||||
.spine { display: flex; align-items: center; gap: 10px; min-height: 32px; margin-left: auto; padding: 0 11px; border: 1px solid var(--line); border-radius: 6px; font-size: 11.5px; color: var(--text-soft); background: var(--panel-2); font-variant-numeric: tabular-nums; }
|
||||
.spine-turn { display: inline-flex; align-items: center; gap: 7px; font-weight: 700; }
|
||||
.spine-turn.active, .spine-rate { color: var(--accent); }
|
||||
.spine-shell { position: relative; margin-left: auto; min-width: 0; }
|
||||
.spine-shell summary { list-style: none; }
|
||||
.spine-shell summary::-webkit-details-marker { display: none; }
|
||||
.spine { display: flex; align-items: center; gap: 10px; min-height: 32px; padding: 0 11px; border: 1px solid var(--line); border-radius: 6px; font-size: 11px; color: var(--text-soft); background: var(--panel-2); font-variant-numeric: tabular-nums; cursor: pointer; user-select: none; }
|
||||
.spine:hover { border-color: var(--line-strong); background: var(--color-neutral-background-4); }
|
||||
.spine:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.spine-turn { display: inline-flex; align-items: center; gap: 7px; font-weight: 700; white-space: nowrap; }
|
||||
.spine-turn.active { color: var(--accent); }
|
||||
.spine-turn.idle, .spine-ok { color: var(--signal); }
|
||||
.spine-right { display: inline-flex; gap: 10px; padding-left: 10px; border-left: 1px solid var(--line); color: var(--muted); }
|
||||
.context-readout { display: inline-flex; align-items: center; gap: 7px; white-space: nowrap; }
|
||||
.context-label { color: var(--muted); }
|
||||
.context-rail, .detail-rail { position: relative; overflow: hidden; background: var(--color-neutral-background-4); }
|
||||
.context-rail { width: 74px; height: 6px; border-radius: 99px; }
|
||||
.context-rail i, .detail-rail i { position: absolute; inset: 0 auto 0 0; width: var(--context-fill); background: var(--accent); }
|
||||
.context-rail b, .detail-rail b { position: absolute; inset: 0 auto 0 70%; width: 1px; background: var(--warning); }
|
||||
.context-value { color: var(--text-soft); }
|
||||
.context-percent { color: var(--accent); font-weight: 700; }
|
||||
[data-pressure="warning"] .context-percent, [data-pressure="warning"] .context-rail i, [data-pressure="warning"] .detail-rail i { color: var(--warning); background: var(--warning); }
|
||||
[data-pressure="danger"] .context-percent, [data-pressure="danger"] .context-rail i, [data-pressure="danger"] .detail-rail i { color: var(--danger); background: var(--danger); }
|
||||
.usage-summary { display: inline-flex; gap: 8px; padding-left: 10px; border-left: 1px solid var(--line); color: var(--muted); white-space: nowrap; }
|
||||
.spine-right { padding-left: 10px; border-left: 1px solid var(--line); white-space: nowrap; }
|
||||
.spine-down { color: var(--danger); }
|
||||
@media (max-width: 1040px) { .spine-context, .spine-rate, .spine > span:not(.spine-turn):not(.spine-right) { display: none; } }
|
||||
@media (max-width: 680px) { .spine { display: none; } }
|
||||
.context-pending { color: var(--muted); }
|
||||
|
||||
.stats-panel { position: absolute; z-index: 30; top: calc(100% + 8px); right: 0; width: min(390px, calc(100vw - 28px)); padding: 17px; border: 1px solid var(--line-strong); border-radius: 10px; color: var(--text); background: var(--surface-acrylic); backdrop-filter: var(--acrylic-blur); -webkit-backdrop-filter: var(--acrylic-blur); box-shadow: var(--shadow-16); font-family: var(--font-ui); }
|
||||
.stats-panel header, .usage-heading, .stats-panel footer { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
|
||||
.stats-panel header div { display: flex; align-items: baseline; gap: 10px; }
|
||||
.stats-panel header span, .usage-heading span { font-size: 12px; font-weight: 700; }
|
||||
.stats-panel header strong { font-family: var(--font-mono); font-size: 21px; letter-spacing: -0.04em; }
|
||||
.stats-panel small, .stats-panel footer { color: var(--muted); font-size: 10px; }
|
||||
.detail-rail { height: 8px; margin: 12px 0 15px; border-radius: 99px; }
|
||||
.context-grid, .usage-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; margin: 0; overflow: hidden; border: 1px solid var(--line); border-radius: 8px; background: var(--line); }
|
||||
.context-grid div, .usage-grid div { min-width: 0; padding: 9px 10px; background: var(--panel); }
|
||||
dt { color: var(--muted); font-size: 10px; }
|
||||
dd { margin: 3px 0 0; overflow: hidden; font-family: var(--font-mono); font-size: 11px; font-variant-numeric: tabular-nums; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.usage-heading { margin: 16px 0 8px; }
|
||||
.stats-panel footer { margin-top: 12px; padding-top: 11px; border-top: 1px solid var(--line); }
|
||||
.stats-panel footer span { text-align: right; }
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.context-value, .usage-summary { display: none; }
|
||||
.context-rail { width: 58px; }
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.spine-shell { margin-left: 0; }
|
||||
.spine { padding: 0 9px; }
|
||||
.spine-turn, .context-label, .context-rail, .spine-right, .context-pending { display: none; }
|
||||
.context-readout { gap: 4px; }
|
||||
.context-percent::before { content: "上下文 "; color: var(--muted); font-weight: 400; }
|
||||
.stats-panel { position: fixed; top: auto; right: 14px; bottom: 14px; left: 14px; width: auto; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.context-rail i, .detail-rail i { transition: none; }
|
||||
}
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
.stats-panel { background: var(--overlay); backdrop-filter: none; -webkit-backdrop-filter: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -99,7 +99,9 @@
|
||||
.mode-grid > button { position: relative; padding: 10px; border: 1px solid var(--line-strong); border-radius: 8px; color: var(--text); background: var(--panel); text-align: left; cursor: pointer; }
|
||||
.mode-grid > button:hover { background: var(--panel-2); }
|
||||
.mode-grid > button.selected { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
|
||||
.mode-preview { position: relative; height: 80px; display: block; overflow: hidden; border: 1px solid #d1d1d1; border-radius: 5px; background: #f5f5f5; }
|
||||
/* Mode thumbnails are intentionally theme-independent illustrations: they depict both
|
||||
light and dark surfaces at once, so they use fixed Fluent palette values instead of tokens. */
|
||||
.mode-preview { position: relative; height: 80px; display: block; overflow: hidden; border: 1px solid #d1d1d1; border-radius: 6px; background: #f5f5f5; }
|
||||
.mode-preview::before { content: ""; position: absolute; inset: 0 auto 0 0; width: 24%; border-right: 1px solid #d1d1d1; background: #fafafa; }
|
||||
.mode-preview i, .mode-preview b, .mode-preview em { position: absolute; display: block; border-radius: 2px; }
|
||||
.mode-preview i { top: 12px; left: 31%; width: 46%; height: 7px; background: var(--accent); }
|
||||
@ -116,25 +118,25 @@
|
||||
.accent-grid button { min-width: 0; display: flex; align-items: center; gap: 10px; padding: 10px; border: 1px solid var(--line); border-radius: 6px; color: var(--text); background: var(--panel); text-align: left; cursor: pointer; }
|
||||
.accent-grid button:hover { border-color: var(--line-strong); background: var(--panel-2); }
|
||||
.accent-grid button.selected { border-color: var(--accent); background: var(--accent-soft); }
|
||||
.swatch { width: 30px; height: 30px; flex: 0 0 30px; display: grid; place-items: center; border: 1px solid rgb(0 0 0 / 12%); border-radius: 6px; color: #fff; background: var(--swatch); }
|
||||
.swatch { width: 30px; height: 30px; flex: 0 0 30px; display: grid; place-items: center; border: 1px solid var(--line); border-radius: 6px; color: #fff; background: var(--swatch); }
|
||||
.accent-grid strong, .accent-grid small { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.accent-grid strong { font-size: 12px; }
|
||||
.accent-grid small { margin-top: 2px; color: var(--muted); font-size: 10px; }
|
||||
.storage-note { margin: -2px 2px 0; color: var(--muted); font-size: 11px; line-height: 17px; }
|
||||
.preview-card { position: sticky; top: 0; overflow: hidden; }
|
||||
.preview-card { position: sticky; top: calc(var(--topbar-h) + 20px); overflow: hidden; }
|
||||
.preview-heading { display: flex; align-items: center; justify-content: space-between; padding: 13px 14px; border-bottom: 1px solid var(--line); font-size: 12px; font-weight: 600; }
|
||||
.preview-status { display: inline-flex; align-items: center; gap: 5px; color: var(--signal); font-size: 10px; }
|
||||
.preview-status i { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||
.preview-window { margin: 14px; overflow: hidden; border: 1px solid var(--line-strong); border-radius: 7px; background: var(--bg); box-shadow: var(--shadow-8); }
|
||||
.preview-window { margin: 14px; overflow: hidden; border: 1px solid var(--line-strong); border-radius: 8px; background: var(--bg); box-shadow: var(--shadow-8); }
|
||||
.preview-titlebar { height: 38px; display: flex; align-items: center; gap: 7px; padding: 0 9px; border-bottom: 1px solid var(--line); background: var(--panel); font-size: 11px; }
|
||||
.preview-logo { width: 23px; height: 23px; display: grid; place-items: center; border-radius: 5px; color: var(--accent-contrast); background: var(--accent); }
|
||||
.preview-logo { width: 23px; height: 23px; display: grid; place-items: center; border-radius: 6px; color: var(--accent-contrast); background: var(--accent); }
|
||||
.window-dots { margin-left: auto; color: var(--muted); letter-spacing: 2px; }
|
||||
.preview-body { height: 180px; display: grid; grid-template-columns: 48px 1fr; }
|
||||
.preview-nav { display: grid; align-content: start; gap: 8px; padding: 12px 8px; border-right: 1px solid var(--line); background: var(--panel); }
|
||||
.preview-nav span { height: 7px; border-radius: 3px; background: var(--line-strong); }
|
||||
.preview-nav span.active { height: 20px; border-left: 2px solid var(--accent); border-radius: 3px; background: var(--accent-soft); }
|
||||
.preview-nav span { height: 7px; border-radius: 4px; background: var(--line-strong); }
|
||||
.preview-nav span.active { height: 20px; border-left: 2px solid var(--accent); border-radius: 4px; background: var(--accent-soft); }
|
||||
.preview-content { padding: 18px 14px; }
|
||||
.preview-line { height: 7px; border-radius: 3px; background: var(--line-strong); }
|
||||
.preview-line { height: 7px; border-radius: 4px; background: var(--line-strong); }
|
||||
.preview-line.wide { width: 70%; }
|
||||
.preview-line.short { width: 42%; margin-top: 7px; opacity: .65; }
|
||||
.preview-message { display: flex; align-items: center; gap: 7px; margin-top: 24px; color: var(--accent); }
|
||||
|
||||
@ -16,8 +16,8 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tile { display: flex; flex-direction: column; gap: 5px; padding: 15px 17px; transition: border-color .1s ease; }
|
||||
.tile:hover { border-color: var(--line-strong); }
|
||||
.tile { display: flex; flex-direction: column; gap: 5px; padding: 15px 17px; transition: border-color .15s ease, box-shadow .15s ease; }
|
||||
.tile:hover { border-color: var(--line-strong); box-shadow: var(--shadow-8); }
|
||||
.value { font-size: 28px; font-weight: 600; line-height: 1.2; letter-spacing: -0.02em; color: var(--text); font-variant-numeric: tabular-nums; }
|
||||
.sub { font-size: 12px; color: var(--muted); }
|
||||
.spark { height: 28px; margin-top: 4px; }
|
||||
|
||||
@ -38,7 +38,7 @@ export function applyTheme(theme) {
|
||||
document.documentElement.style.colorScheme = next;
|
||||
document
|
||||
.querySelector('meta[name="theme-color"]')
|
||||
?.setAttribute("content", next === "dark" ? "#1f1f1f" : "#f5f5f5");
|
||||
?.setAttribute("content", next === "dark" ? "#1f1f1f" : "#fafafa");
|
||||
localStorage.setItem(STORAGE_KEY, next);
|
||||
notifyAppearance();
|
||||
}
|
||||
|
||||
@ -55,14 +55,22 @@
|
||||
|
||||
function handleFrame(frame) {
|
||||
switch (frame.type) {
|
||||
case "session_established": currentId = frame.session_id; activeTurn = null; historyRevision = 0; break;
|
||||
case "session_established":
|
||||
currentId = frame.session_id;
|
||||
chat.currentSessionId = currentId;
|
||||
activeTurn = null;
|
||||
historyRevision = 0;
|
||||
send({ type: "get_session_stats", session_id: currentId });
|
||||
break;
|
||||
case "session_list":
|
||||
sessions = frame.sessions || [];
|
||||
if (frame.current_session_id) currentId = frame.current_session_id;
|
||||
chat.currentSessionId = currentId;
|
||||
if (currentId && messages.length === 0) loadSession(currentId);
|
||||
break;
|
||||
case "session_created":
|
||||
currentId = frame.session_id;
|
||||
chat.currentSessionId = currentId;
|
||||
messages = [];
|
||||
activeTurn = null;
|
||||
historyRevision = 0;
|
||||
@ -70,10 +78,12 @@
|
||||
break;
|
||||
case "session_loaded":
|
||||
currentId = frame.session_id;
|
||||
chat.currentSessionId = currentId;
|
||||
activeTurn = null;
|
||||
historyRevision = 0;
|
||||
send({ type: "get_session_history", session_id: currentId, limit: 1000 });
|
||||
send({ type: "get_session_plan", session_id: currentId });
|
||||
send({ type: "get_session_stats", session_id: currentId });
|
||||
break;
|
||||
case "session_history":
|
||||
if (frame.session_id === currentId) {
|
||||
@ -118,13 +128,16 @@
|
||||
if (currentId) send({ type: "get_session_history", session_id: currentId, limit: 1000 });
|
||||
}
|
||||
send({ type: "list_sessions", include_archived: false });
|
||||
if (currentId) send({ type: "get_session_stats", session_id: currentId });
|
||||
break;
|
||||
case "turn_updated": {
|
||||
const next = frame.snapshot;
|
||||
if (!next || next.session_id !== currentId) break;
|
||||
if (activeTurn?.id === next.id && activeTurn.revision >= next.revision) break;
|
||||
const firstSnapshotForTurn = activeTurn?.id !== next.id;
|
||||
activeTurn = next;
|
||||
thinking = next.status === "running";
|
||||
if (firstSnapshotForTurn) send({ type: "get_session_stats", session_id: currentId });
|
||||
if (next.status !== "running" && messages.some((message) => message.id === next.message_id)) {
|
||||
activeTurn = null;
|
||||
}
|
||||
@ -139,21 +152,40 @@
|
||||
}
|
||||
case "turn_committed": {
|
||||
if (frame.session_id !== currentId || frame.history_revision <= historyRevision) break;
|
||||
const byId = new Map(messages.map((message) => [message.id, message]));
|
||||
for (const message of frame.messages || []) byId.set(message.id, message);
|
||||
messages = [...byId.values()];
|
||||
const committed = frame.messages || [];
|
||||
const committedIds = new Set(committed.map((message) => message.id));
|
||||
const firstOptimisticMatch = messages.findIndex((message) => committedIds.has(message.id));
|
||||
const retained = messages.filter((message) => !committedIds.has(message.id));
|
||||
const insertionIndex = firstOptimisticMatch < 0
|
||||
? retained.length
|
||||
: messages.slice(0, firstOptimisticMatch)
|
||||
.filter((message) => !committedIds.has(message.id)).length;
|
||||
// Replace optimistic steering at its original position with the whole
|
||||
// durable batch. A Map#set replacement would keep only the user's
|
||||
// old slot and incorrectly place earlier tool messages after it.
|
||||
messages = [
|
||||
...retained.slice(0, insertionIndex),
|
||||
...committed,
|
||||
...retained.slice(insertionIndex)
|
||||
];
|
||||
historyRevision = frame.history_revision;
|
||||
if (activeTurn?.status !== "running"
|
||||
&& (frame.messages || []).some((message) => message.id === activeTurn?.message_id)) {
|
||||
activeTurn = null;
|
||||
}
|
||||
scrollToBottom();
|
||||
send({ type: "get_session_stats", session_id: currentId });
|
||||
break;
|
||||
}
|
||||
case "session_stats": break;
|
||||
case "system_notification":
|
||||
if (!frame.session_id || frame.session_id === currentId) appendMessage("assistant", frame.content);
|
||||
break;
|
||||
case "command_executed": thinking = false; appendMessage("assistant", frame.message); break;
|
||||
case "command_executed":
|
||||
thinking = false;
|
||||
appendMessage("assistant", frame.message);
|
||||
if (currentId) send({ type: "get_session_stats", session_id: currentId });
|
||||
break;
|
||||
case "error": thinking = false; notify(frame.message || frame.code, true); break;
|
||||
}
|
||||
}
|
||||
@ -171,6 +203,7 @@
|
||||
function loadSession(id) {
|
||||
if (!id) return;
|
||||
currentId = id;
|
||||
chat.currentSessionId = id;
|
||||
historyRevision = 0;
|
||||
clearPendingUploads();
|
||||
messages = [];
|
||||
@ -196,15 +229,21 @@
|
||||
const content = draft.trim();
|
||||
const ready = pendingUploads.filter((upload) => upload.status === "ready");
|
||||
if ((!content && !ready.length) || !chat.connected || pendingUploads.some((upload) => upload.status === "uploading")) return;
|
||||
const clientMessageId = randomId();
|
||||
appendMessage("user", content, ready.map((upload, index) => ({
|
||||
index,
|
||||
name: upload.name,
|
||||
media_type: upload.media_type,
|
||||
mime_type: upload.mime_type,
|
||||
local_url: upload.localUrl
|
||||
})));
|
||||
})), clientMessageId);
|
||||
thinking = true;
|
||||
send({ type: "user_input", content, upload_ids: ready.map((upload) => upload.upload_id) });
|
||||
send({
|
||||
type: "user_input",
|
||||
content,
|
||||
upload_ids: ready.map((upload) => upload.upload_id),
|
||||
client_message_id: clientMessageId
|
||||
});
|
||||
draft = "";
|
||||
pendingUploads = [];
|
||||
commandMenuDismissed = false;
|
||||
@ -399,7 +438,7 @@
|
||||
</button>
|
||||
{/if}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger class="icon-button" aria-label="刷新会话" onclick={() => { send({ type: "list_sessions", include_archived: false }); if (currentId) send({ type: "get_session_plan", session_id: currentId }); }}><Icon name="refresh" size={18} /></Tooltip.Trigger>
|
||||
<Tooltip.Trigger class="icon-button" aria-label="刷新会话" onclick={() => { send({ type: "list_sessions", include_archived: false }); if (currentId) { send({ type: "get_session_plan", session_id: currentId }); send({ type: "get_session_stats", session_id: currentId }); } }}><Icon name="refresh" size={18} /></Tooltip.Trigger>
|
||||
<Tooltip.Portal><Tooltip.Content class="tooltip" sideOffset={7}>刷新会话<Tooltip.Arrow class="tooltip-arrow" /></Tooltip.Content></Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
|
||||
@ -174,29 +174,6 @@
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.chips {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
color: var(--muted);
|
||||
border-radius: 4px;
|
||||
padding: 5px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.chip.active {
|
||||
background: var(--accent-soft);
|
||||
border-color: var(--accent-border);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -215,14 +192,10 @@
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
animation: pulse 2s infinite;
|
||||
animation: dot-pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
@keyframes dot-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
@ -162,8 +162,6 @@
|
||||
.card-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.card > .card-actions { margin-top: 14px; }
|
||||
.danger-outline { border-color: var(--danger-border); color: var(--danger); }
|
||||
.danger-solid { border-radius: 4px; padding: 5px 12px; font-weight: 600; cursor: pointer; border: 1px solid var(--danger); background: var(--danger); color: #fff; }
|
||||
.danger-solid:hover { filter: brightness(1.08); }
|
||||
.edit-field { display: grid; gap: 6px; margin-top: 12px; }
|
||||
.edit-field > span { color: var(--muted); font-size: 12px; }
|
||||
.edit-field textarea { width: 100%; min-height: 90px; resize: vertical; border: 1px solid var(--line-strong); border-radius: 4px; background: var(--panel); color: var(--text); padding: 10px; font: 14px/1.6 var(--font-ui); }
|
||||
|
||||
@ -60,7 +60,7 @@
|
||||
<span>离线 — {error}</span>
|
||||
</div>
|
||||
{:else if status}
|
||||
<div class="status-bar panel">
|
||||
<div class="status-strip panel">
|
||||
<div class="status-left">
|
||||
<span class="pulse-dot" class:active={running}></span>
|
||||
<strong>{running ? "运行中" : "空闲"}</strong>
|
||||
@ -168,13 +168,13 @@
|
||||
|
||||
<style>
|
||||
.offline { display: flex; align-items: center; gap: 10px; padding: 18px 22px; color: var(--danger); font-size: 14px; }
|
||||
.status-bar { display: flex; justify-content: space-between; align-items: center; padding: 14px 20px; margin-bottom: 14px; flex-wrap: wrap; gap: 10px; }
|
||||
.status-strip { display: flex; justify-content: space-between; align-items: center; padding: 14px 20px; margin-bottom: 14px; flex-wrap: wrap; gap: 10px; }
|
||||
.status-left { display: flex; align-items: center; gap: 10px; }
|
||||
.status-meta { display: flex; gap: 16px; font-size: 12px; color: var(--text-soft); flex-wrap: wrap; font-variant-numeric: tabular-nums; }
|
||||
.warn-bar { color: var(--accent); font-size: 12px; margin-bottom: 10px; }
|
||||
.tiles { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 14px; }
|
||||
.section { padding: 16px 18px; margin-bottom: 14px; display: flex; flex-direction: column; gap: 12px; overflow-x: auto; }
|
||||
.provider-table { width: 100%; min-width: 720px; border-collapse: collapse; font-size: 12.5px; font-variant-numeric: tabular-nums; }
|
||||
.provider-table { width: 100%; min-width: 720px; border-collapse: collapse; font-size: 12px; font-variant-numeric: tabular-nums; }
|
||||
.provider-table th { text-align: left; color: var(--muted); font-weight: 500; padding: 4px 8px; border-bottom: 1px solid var(--line); font-size: 11px; }
|
||||
.provider-table td { padding: 8px 8px; border-bottom: 1px solid var(--line); color: var(--text-soft); }
|
||||
.provider-table tr:last-child td { border-bottom: none; }
|
||||
|
||||
@ -146,6 +146,5 @@
|
||||
<style>
|
||||
.cron { font-size: 12px; color: var(--text-soft); background: var(--code-bg); padding: 1px 6px; border-radius: 4px; border: 1px solid var(--line); font-variant-numeric: tabular-nums; }
|
||||
.status-dots { display: flex; gap: 4px; margin-top: 6px; align-items: center; }
|
||||
.dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; }
|
||||
.elapsed { color: var(--info); font-family: var(--font-mono); font-size: 11px; font-variant-numeric: tabular-nums; }
|
||||
</style>
|
||||
|
||||
@ -170,9 +170,6 @@
|
||||
|
||||
<style>
|
||||
.filters-bar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
|
||||
.chips { display: flex; gap: 7px; flex-wrap: wrap; }
|
||||
.chip { border: 1px solid var(--line-strong); background: var(--panel); color: var(--muted); border-radius: 4px; padding: 5px 10px; font-size: 12px; font-weight: 600; cursor: pointer; }
|
||||
.chip.active { background: var(--accent-soft); border-color: var(--accent-border); color: var(--accent); }
|
||||
.legend { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; }
|
||||
.tool-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 11px; }
|
||||
.cap-row { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 10px; }
|
||||
|
||||
@ -23,9 +23,9 @@
|
||||
|
||||
/* Fluent 2 semantic tokens */
|
||||
--color-neutral-background-1: #ffffff;
|
||||
--color-neutral-background-2: #f5f5f5;
|
||||
--color-neutral-background-3: #f0f0f0;
|
||||
--color-neutral-background-4: #e8e8e8;
|
||||
--color-neutral-background-2: #fafafa;
|
||||
--color-neutral-background-3: #f5f5f5;
|
||||
--color-neutral-background-4: #ebebeb;
|
||||
--color-neutral-foreground-1: #242424;
|
||||
--color-neutral-foreground-2: #424242;
|
||||
--color-neutral-foreground-3: #616161;
|
||||
@ -48,7 +48,7 @@
|
||||
--panel: var(--color-neutral-background-1);
|
||||
--panel-2: var(--color-neutral-background-3);
|
||||
--sidebar: #fafafa;
|
||||
--header: rgb(255 255 255 / 94%);
|
||||
--header: rgb(255 255 255 / 82%);
|
||||
--line: var(--color-neutral-stroke-2);
|
||||
--line-strong: var(--color-neutral-stroke-1);
|
||||
--muted: var(--color-neutral-foreground-3);
|
||||
@ -68,10 +68,13 @@
|
||||
--info-soft: #ebf3fc;
|
||||
--info-border: #b4d6fa;
|
||||
--danger: var(--color-status-danger);
|
||||
--danger-hover: #9c0c19;
|
||||
--danger-pressed: #7c0a14;
|
||||
--danger-soft: #fdf3f4;
|
||||
--danger-border: #eeacb2;
|
||||
--warning: var(--color-status-warning);
|
||||
--warning-soft: #fff9f5;
|
||||
--warning-border: #f2bb8d;
|
||||
--success-soft: #f1faf1;
|
||||
--overlay: #ffffff;
|
||||
--code-bg: #f5f5f5;
|
||||
@ -82,7 +85,6 @@
|
||||
--shadow: var(--shadow-16);
|
||||
--shadow-sm: var(--shadow-2);
|
||||
--shadow-lift: var(--shadow-8);
|
||||
--edge: none;
|
||||
--radius: 8px;
|
||||
}
|
||||
|
||||
@ -110,7 +112,7 @@
|
||||
--panel: var(--color-neutral-background-1);
|
||||
--panel-2: var(--color-neutral-background-3);
|
||||
--sidebar: #242424;
|
||||
--header: rgb(41 41 41 / 94%);
|
||||
--header: rgb(41 41 41 / 82%);
|
||||
--line: var(--color-neutral-stroke-2);
|
||||
--line-strong: var(--color-neutral-stroke-1);
|
||||
--muted: var(--color-neutral-foreground-3);
|
||||
@ -130,10 +132,13 @@
|
||||
--info-soft: #0f2d46;
|
||||
--info-border: #0f6cbd;
|
||||
--danger: var(--color-status-danger);
|
||||
--danger-hover: #e17a83;
|
||||
--danger-pressed: #b74954;
|
||||
--danger-soft: #3b1219;
|
||||
--danger-border: #8f1d2c;
|
||||
--warning: var(--color-status-warning);
|
||||
--warning-soft: #3a2d00;
|
||||
--warning-border: #986f00;
|
||||
--success-soft: #183b18;
|
||||
--overlay: #333333;
|
||||
--code-bg: #1f1f1f;
|
||||
@ -155,6 +160,17 @@
|
||||
:root[data-theme="dark"][data-accent="orange"] { --color-brand-background: #f4a261; --color-brand-background-hover: #ffb57a; --color-brand-background-pressed: #db8747; --color-brand-foreground: #ffb57a; --color-brand-subtle: #402313; --accent-border: #c45d1d; --accent-contrast: #1f1f1f; }
|
||||
:root[data-theme="dark"][data-accent="magenta"] { --color-brand-background: #e36fbe; --color-brand-background-hover: #ef88cc; --color-brand-background-pressed: #c951a3; --color-brand-foreground: #ef88cc; --color-brand-subtle: #3d1830; --accent-border: #a4267a; --accent-contrast: #1f1f1f; }
|
||||
|
||||
/* Fluent materials. Mica: ambient brand-tinted backdrop. Acrylic: translucent blurred surfaces. */
|
||||
:root {
|
||||
--topbar-h: 68px;
|
||||
--mica:
|
||||
radial-gradient(1200px 560px at 76% -140px, color-mix(in srgb, var(--accent) 7%, transparent), transparent 64%),
|
||||
radial-gradient(900px 480px at -8% 108%, color-mix(in srgb, var(--accent) 4%, transparent), transparent 60%),
|
||||
var(--bg);
|
||||
--surface-acrylic: color-mix(in srgb, var(--overlay) 80%, transparent);
|
||||
--acrylic-blur: blur(20px) saturate(1.5);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; scrollbar-width: thin; scrollbar-color: var(--line-strong) transparent; }
|
||||
*::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
*::-webkit-scrollbar-track { background: transparent; }
|
||||
@ -162,7 +178,7 @@
|
||||
*::-webkit-scrollbar-thumb:hover { background-color: var(--muted); }
|
||||
::selection { color: var(--text); background: var(--accent-soft); }
|
||||
html, body, #app { width: 100%; min-width: 320px; height: 100%; }
|
||||
body { margin: 0; color: var(--text); background: var(--bg); font-size: 14px; }
|
||||
body { margin: 0; color: var(--text); background: var(--mica); font-size: 14px; }
|
||||
h1, h2, h3, h4 { font-family: "Segoe UI Variable Display", var(--font-ui); font-weight: 600; }
|
||||
strong, b { font-weight: 600; }
|
||||
button, input, textarea, select { font: inherit; }
|
||||
@ -173,7 +189,7 @@ button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-
|
||||
button:disabled { cursor: not-allowed; opacity: .45; }
|
||||
|
||||
/* Pairing */
|
||||
.pairing-screen { min-height: 100vh; display: grid; place-items: center; padding: 24px; background: linear-gradient(135deg, var(--bg), var(--panel-2)); }
|
||||
.pairing-screen { min-height: 100vh; display: grid; place-items: center; padding: 24px; }
|
||||
.pairing-card { width: min(100%, 440px); padding: 32px; border: 1px solid var(--line); border-radius: 12px; background: var(--panel); box-shadow: var(--shadow-16); }
|
||||
.pairing-brand { display: flex; align-items: center; gap: 10px; margin-bottom: 32px; }
|
||||
.pairing-brand strong { font-size: 16px; }
|
||||
@ -193,7 +209,7 @@ button:disabled { cursor: not-allowed; opacity: .45; }
|
||||
|
||||
/* Application shell */
|
||||
.shell { height: 100vh; display: grid; grid-template-columns: 248px minmax(0, 1fr); overflow: hidden; }
|
||||
.sidebar { z-index: 30; display: flex; flex-direction: column; min-width: 0; padding: 8px 12px 12px; border-right: 1px solid var(--line); background: var(--sidebar); }
|
||||
.sidebar { z-index: 30; display: flex; flex-direction: column; min-width: 0; padding: 8px 12px 12px; border-right: 1px solid var(--line); background: color-mix(in srgb, var(--sidebar) 72%, transparent); backdrop-filter: var(--acrylic-blur); -webkit-backdrop-filter: var(--acrylic-blur); }
|
||||
.brand { display: flex; align-items: center; gap: 11px; min-height: 60px; padding: 6px 8px; }
|
||||
.brand-mark, .empty-logo { display: grid; place-items: center; color: var(--accent-contrast); background: linear-gradient(145deg, var(--accent), var(--accent-pressed)); }
|
||||
.brand-mark { width: 36px; height: 36px; border-radius: 8px; box-shadow: var(--shadow-2); }
|
||||
@ -216,8 +232,8 @@ button:disabled { cursor: not-allowed; opacity: .45; }
|
||||
.gateway-status b, .gateway-status small { display: block; }
|
||||
.gateway-status b { font-size: 12px; line-height: 16px; }
|
||||
.gateway-status small { margin-top: 1px; color: var(--muted); font-size: 10px; line-height: 14px; }
|
||||
.app-main { min-width: 0; min-height: 0; height: 100vh; display: flex; flex-direction: column; background: var(--bg); }
|
||||
.topbar { z-index: 10; min-height: 68px; flex: 0 0 68px; display: flex; align-items: center; gap: 12px; padding: 0 24px; border-bottom: 1px solid var(--line); background: var(--header); backdrop-filter: blur(16px); }
|
||||
.app-main { position: relative; min-width: 0; min-height: 0; height: 100vh; display: flex; flex-direction: column; }
|
||||
.topbar { position: absolute; inset: 0 0 auto 0; z-index: 10; min-height: var(--topbar-h); display: flex; align-items: center; gap: 12px; padding: 0 24px; border-bottom: 1px solid var(--line); background: var(--header); backdrop-filter: var(--acrylic-blur); -webkit-backdrop-filter: var(--acrylic-blur); }
|
||||
.page-heading { min-width: 180px; }
|
||||
.topbar h1 { margin: 0; font-size: 20px; line-height: 26px; }
|
||||
.topbar p { margin: 1px 0 0; color: var(--muted); font-size: 12px; line-height: 16px; }
|
||||
@ -226,7 +242,7 @@ button:disabled { cursor: not-allowed; opacity: .45; }
|
||||
.theme-toggle:hover { color: var(--text); background: var(--panel-2); }
|
||||
.nav-scrim { display: none; }
|
||||
|
||||
/* Fluent controls */
|
||||
/* Fluent controls. 13px/20px control text is a deliberate dense ramp choice below Fluent Body1 14/20. */
|
||||
.primary, .secondary, .danger-solid { min-height: 32px; padding: 5px 12px; border-radius: 4px; font-size: 13px; line-height: 20px; font-weight: 600; cursor: pointer; transition: background-color .1s ease, border-color .1s ease; }
|
||||
.primary, .secondary, .danger-solid { display: inline-flex; align-items: center; justify-content: center; gap: 6px; }
|
||||
.primary { border: 1px solid var(--accent); color: var(--accent-contrast); background: var(--accent); }
|
||||
@ -235,6 +251,9 @@ button:disabled { cursor: not-allowed; opacity: .45; }
|
||||
.secondary { border: 1px solid var(--line-strong); color: var(--text); background: var(--panel); }
|
||||
.secondary:hover { background: var(--panel-2); }
|
||||
.secondary:active { background: var(--color-neutral-background-4); }
|
||||
.danger-solid { border: 1px solid var(--danger); color: var(--accent-contrast); background: var(--danger); }
|
||||
.danger-solid:hover:not(:disabled) { border-color: var(--danger-hover); background: var(--danger-hover); }
|
||||
.danger-solid:active:not(:disabled) { border-color: var(--danger-pressed); background: var(--danger-pressed); }
|
||||
.full { width: 100%; min-height: 36px; }
|
||||
.icon-button { width: 32px; height: 32px; display: inline-grid; place-items: center; padding: 0; border: 0; border-radius: 4px; color: var(--text-soft); background: transparent; cursor: pointer; }
|
||||
.icon-button:hover { color: var(--text); background: var(--panel-2); }
|
||||
@ -244,20 +263,25 @@ button:disabled { cursor: not-allowed; opacity: .45; }
|
||||
.search input { width: 100%; padding: 0 7px; border: 0; outline: 0; color: var(--text); background: transparent; font-size: 13px; }
|
||||
.filters select { height: 34px; padding: 0 28px 0 10px; border: 1px solid var(--line-strong); border-radius: 4px; color: var(--text); background: var(--panel); }
|
||||
.grow { flex: 1; max-width: 520px; }
|
||||
.chips { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.chip { padding: 5px 11px; border: 1px solid var(--line-strong); border-radius: 4px; color: var(--muted); background: var(--panel); font-size: 12px; font-weight: 600; cursor: pointer; transition: background-color .1s ease, border-color .1s ease, color .1s ease; }
|
||||
.chip:hover { border-color: var(--muted); color: var(--text); }
|
||||
.chip.active { color: var(--accent); border-color: var(--accent-border); background: var(--accent-soft); }
|
||||
.dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
|
||||
.tabs { display: flex; gap: 16px; min-height: 36px; border-bottom: 1px solid var(--line); }
|
||||
.tabs button { position: relative; padding: 0 2px 9px; border: 0; color: var(--muted); background: none; cursor: pointer; font-size: 13px; font-weight: 600; }
|
||||
.tabs button::after { content: ""; position: absolute; right: 0; bottom: -1px; left: 0; height: 2px; border-radius: 2px; background: var(--accent); transform: scaleX(0); }
|
||||
.tabs button:hover { color: var(--text); }
|
||||
.tabs button[data-state="active"] { color: var(--text); }
|
||||
.tabs button[data-state="active"]::after { transform: scaleX(1); }
|
||||
.tooltip { z-index: 50; padding: 6px 9px; border: 1px solid var(--line); border-radius: 4px; color: var(--text); background: var(--overlay); box-shadow: var(--shadow-8); font-size: 12px; }
|
||||
.tooltip { z-index: 50; padding: 6px 9px; border: 1px solid var(--line); border-radius: 4px; color: var(--text); background: var(--surface-acrylic); backdrop-filter: var(--acrylic-blur); -webkit-backdrop-filter: var(--acrylic-blur); box-shadow: var(--shadow-8); font-size: 12px; }
|
||||
.tooltip-arrow { fill: var(--overlay); }
|
||||
|
||||
/* Chat */
|
||||
.page { min-height: 0; flex: 1; animation: page-in .16s ease both; }
|
||||
.chat-layout { display: grid; grid-template-columns: 280px minmax(0, 1fr); }
|
||||
.chat-layout { display: grid; grid-template-columns: 280px minmax(0, 1fr); padding-top: var(--topbar-h); }
|
||||
.chat-layout.todo-open { grid-template-columns: 280px minmax(0, 1fr) 320px; }
|
||||
.sessions-panel { overflow: auto; padding: 16px 12px; border-right: 1px solid var(--line); background: var(--panel); }
|
||||
.sessions-panel { overflow: auto; margin-top: calc(-1 * var(--topbar-h)); padding: 16px 12px; padding-top: calc(var(--topbar-h) + 16px); scroll-padding-top: calc(var(--topbar-h) + 16px); border-right: 1px solid var(--line); background: var(--panel); }
|
||||
.sessions-panel > .search { margin-top: 12px; }
|
||||
.session-list-head { display: flex; align-items: center; justify-content: space-between; margin: 18px 8px 7px; }
|
||||
.session-count { color: var(--muted); font: 11px var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||
@ -335,7 +359,7 @@ button:disabled { cursor: not-allowed; opacity: .45; }
|
||||
.markdown-body h3 { font-size: 1.08em; }
|
||||
.markdown-body a { color: var(--accent); text-underline-offset: 2px; }
|
||||
.markdown-body blockquote { padding-left: 12px; border-left: 3px solid var(--accent); color: var(--muted); }
|
||||
.markdown-body code { padding: .12em .32em; border-radius: 3px; color: var(--text); background: var(--code-bg); font: .88em/1.5 var(--font-mono); }
|
||||
.markdown-body code { padding: .12em .32em; border-radius: 4px; color: var(--text); background: var(--code-bg); font: .88em/1.5 var(--font-mono); }
|
||||
.markdown-body pre { max-width: 100%; padding: 12px; overflow-x: auto; border: 1px solid var(--line); border-radius: 6px; background: var(--code-bg); }
|
||||
.markdown-body pre code { padding: 0; border: 0; background: transparent; }
|
||||
.markdown-body table { display: block; max-width: 100%; overflow-x: auto; border-collapse: collapse; }
|
||||
@ -379,8 +403,8 @@ button:disabled { cursor: not-allowed; opacity: .45; }
|
||||
.pending-upload strong { font-size: 11px; }
|
||||
.pending-upload small { color: var(--muted); font-size: 10px; }
|
||||
.pending-upload button { border: 0; color: var(--muted); background: none; cursor: pointer; }
|
||||
.command-menu { position: absolute; z-index: 5; right: 0; bottom: calc(100% + 8px); left: 0; max-height: min(360px, 48vh); overflow-y: auto; padding: 6px; border: 1px solid var(--line); border-radius: 8px; background: var(--overlay); box-shadow: var(--shadow-16); }
|
||||
.command-menu-heading { position: sticky; z-index: 1; top: -6px; display: flex; justify-content: space-between; gap: 16px; padding: 8px 9px; color: var(--muted); background: var(--overlay); font-size: 11px; }
|
||||
.command-menu { position: absolute; z-index: 40; right: 0; bottom: calc(100% + 8px); left: 0; max-height: min(360px, 48vh); overflow-y: auto; padding: 6px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface-acrylic); backdrop-filter: var(--acrylic-blur); -webkit-backdrop-filter: var(--acrylic-blur); box-shadow: var(--shadow-16); }
|
||||
.command-menu-heading { position: sticky; z-index: 1; top: -6px; display: flex; justify-content: space-between; gap: 16px; padding: 8px 9px; color: var(--muted); background: var(--surface-acrylic); font-size: 11px; }
|
||||
.command-menu-heading kbd { color: var(--muted); font: inherit; }
|
||||
.command-menu button { width: 100%; display: grid; grid-template-columns: minmax(100px, auto) 1fr; gap: 14px; padding: 8px 9px; border: 0; border-radius: 4px; color: var(--text); background: transparent; text-align: left; cursor: pointer; }
|
||||
.command-menu button:hover, .command-menu button.selected { background: var(--accent-soft); }
|
||||
@ -411,13 +435,13 @@ button:disabled { cursor: not-allowed; opacity: .45; }
|
||||
.todo-item p { margin: 5px 0 0; color: var(--text-soft); font-size: 11px; line-height: 16px; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
|
||||
/* Management pages */
|
||||
.content-page { overflow: auto; padding: 24px 28px 40px; }
|
||||
.content-page { overflow: auto; padding: calc(var(--topbar-h) + 24px) 28px 40px; scroll-padding-top: calc(var(--topbar-h) + 16px); }
|
||||
.toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 18px; }
|
||||
.filters { align-items: center; }
|
||||
.cards { display: grid; gap: 10px; }
|
||||
.card, .panel, .metric { border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel); box-shadow: var(--shadow-2); }
|
||||
.card { padding: 15px 17px; transition: border-color .1s ease; }
|
||||
.card:hover { border-color: var(--line-strong); }
|
||||
.card { padding: 15px 17px; transition: border-color .15s ease, box-shadow .15s ease; }
|
||||
.card:hover { border-color: var(--line-strong); box-shadow: var(--shadow-8); }
|
||||
.card-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; }
|
||||
.card h3 { margin: 0 0 6px; font-size: 14px; }
|
||||
.card p { margin: 5px 0; color: var(--text-soft); font-size: 13px; line-height: 20px; white-space: pre-wrap; }
|
||||
@ -449,7 +473,7 @@ button:disabled { cursor: not-allowed; opacity: .45; }
|
||||
.editor-head small { margin-top: 3px; color: var(--muted); font-size: 11px; }
|
||||
.editor-card textarea { width: 100%; height: calc(100vh - 275px); min-height: 420px; display: block; resize: vertical; padding: 18px; border: 0; outline: 0; color: var(--text); background: var(--code-bg); font: 13px/1.65 var(--font-mono); tab-size: 2; }
|
||||
.notice { padding: 10px 15px; border-top: 1px solid var(--line); color: var(--muted); font-size: 12px; }
|
||||
.cap { display: inline-flex; align-items: center; gap: 5px; padding: 3px 7px; border-radius: 4px; font-size: 10.5px; font-weight: 600; }
|
||||
.cap { display: inline-flex; align-items: center; gap: 5px; padding: 3px 7px; border-radius: 4px; font-size: 10px; font-weight: 600; }
|
||||
.cap.signal { border: 1px solid var(--signal-border); color: var(--signal); background: var(--signal-soft); }
|
||||
.cap.accent { border: 1px solid var(--accent-border); color: var(--accent); background: var(--accent-soft); }
|
||||
.cap.danger { border: 1px solid var(--danger-border); color: var(--danger); background: var(--danger-soft); }
|
||||
@ -483,17 +507,18 @@ code { color: var(--accent); }
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
:root { --topbar-h: 62px; }
|
||||
.shell { grid-template-columns: 1fr; }
|
||||
.sidebar { position: fixed; inset: 0 auto 0 0; width: 248px; transform: translateX(-100%); transition: transform .16s ease; box-shadow: var(--shadow-16); }
|
||||
.sidebar.open { transform: none; }
|
||||
.nav-scrim { position: fixed; z-index: 25; inset: 0; display: block; border: 0; background: rgb(0 0 0 / 42%); }
|
||||
.topbar { min-height: 62px; flex-basis: 62px; padding: 0 14px; }
|
||||
.topbar { padding: 0 14px; }
|
||||
.topbar .menu { display: inline-grid; }
|
||||
.page-heading p { display: none; }
|
||||
.chat-layout, .chat-layout.todo-open { grid-template-columns: 1fr; }
|
||||
.sessions-panel { display: none; }
|
||||
.todo-open .todo-panel { position: fixed; z-index: 20; top: 62px; right: 0; bottom: 0; width: min(340px, 92vw); box-shadow: var(--shadow-16); }
|
||||
.content-page { padding: 18px 16px 32px; }
|
||||
.todo-open .todo-panel { position: fixed; z-index: 20; top: var(--topbar-h); right: 0; bottom: 0; width: min(340px, 92vw); background: var(--surface-acrylic); backdrop-filter: var(--acrylic-blur); -webkit-backdrop-filter: var(--acrylic-blur); box-shadow: var(--shadow-16); }
|
||||
.content-page { padding: calc(var(--topbar-h) + 18px) 16px 32px; }
|
||||
.settings-grid { grid-template-columns: 1fr; }
|
||||
.settings-nav { flex-direction: row; overflow-x: auto; }
|
||||
.metrics { grid-template-columns: 1fr; }
|
||||
@ -513,3 +538,10 @@ code { color: var(--accent); }
|
||||
.toolbar .tabs { width: 100%; overflow-x: auto; }
|
||||
.card-row { gap: 10px; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
.sidebar { background: var(--sidebar); backdrop-filter: none; -webkit-backdrop-filter: none; }
|
||||
.topbar { background: var(--panel); backdrop-filter: none; -webkit-backdrop-filter: none; }
|
||||
.command-menu, .command-menu-heading, .tooltip { background: var(--overlay); backdrop-filter: none; -webkit-backdrop-filter: none; }
|
||||
.todo-open .todo-panel { background: var(--panel); backdrop-filter: none; -webkit-backdrop-filter: none; }
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user