Compare commits

..

No commits in common. "93e74120ac2f4c37c0dd3c13ba3de616c061048d" and "ff3d0ef773fa3d98572fc2c27a998a501c56fd49" have entirely different histories.

33 changed files with 1569 additions and 4556 deletions

View File

@ -93,8 +93,6 @@ Scheduler → SessionManager scheduled execution → AgentLoop → Scheduler del
- **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, drains same-Turn steering only at safe model boundaries, calls LLM providers, executes tools, and returns one result
- **Context overflow recovery** is type-driven: before tool progress Session may commit one checkpoint and retry once; after any tool batch AgentLoop may retry the current Provider step once from its in-memory transcript, preserving current tool calls/results, and Session must never restart that Turn from durable history
- **Context compaction** keeps `messages` append-only and uses one active checkpoint per Session (`summary + first_retained_seq`) for deterministic Provider projection; `/compact`, Turn-boundary auto compaction, and overflow share the same compactor/CAS commit path, Session restoration never derives context from Timeline or calls a Provider, the Model `token_limit` (default 128K) is the hard window ceiling and an optional Agent `token_limit` can only narrow it via `min(agent, model)`, summary input is bounded from that effective window rather than a fixed cap, and the only automatic threshold is `context_tokens > context_window - effective_reserve`
- **AgentCatalog** is immutable per runtime generation; candidate preparation strictly validates trusted Markdown definitions, Provider profiles, tool/Skill allowlists, and delegation edges before activation. A definition that fails per-file validation (bad YAML, unknown provider/profile/model/tool/skill, or an explicit delegate edge to an absent target) is disabled for that generation only and reported via `load_errors` (exposed by `GET /api/agents`), never blocking startup or reload; config- and directory-trust-level failures remain fatal. Sub-Agent orchestration is an intrinsic, always-on mechanism (no feature switch). Named Agents support foreground (single/batch) and Root-initiated single background execution with durable run/inbox delivery; a built-in general-purpose definition is released to `~/.picobot/agents/` on first run. Background batches and nested background remain restricted
- **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

View File

@ -1,6 +1,6 @@
[package]
name = "picobot"
version = "1.20.0"
version = "1.18.0"
edition = "2024"
[dependencies]

View File

@ -61,7 +61,6 @@ Gateway 首次启动时会把模板释放到 `~/.picobot/config.example.json`。
"model_id": "gpt-4o",
"temperature": 0.7,
"max_tokens": 4096,
"token_limit": 128000,
"input_type": ["text", "image"]
}
},
@ -69,7 +68,8 @@ Gateway 首次启动时会把模板释放到 `~/.picobot/config.example.json`。
"default": {
"provider": "openai",
"model": "gpt-4o",
"max_tool_iterations": 99
"max_tool_iterations": 99,
"token_limit": 128000
}
},
"workspace_dir": "~/.picobot/workspace"
@ -306,7 +306,7 @@ Session ID 使用三段式:
| `/switch <dialog_id>` | 切换 dialog |
| `/rename <title>` | 重命名当前 dialog |
| `/delete` | 删除当前 dialog 并创建新 dialog |
| `/compact` | 强制把可压缩的旧完整 Turn 汇总为活动 checkpoint不改写原始历史 |
| `/compact` | 手动压缩上下文 |
| `/info [--json]` | 查看当前 dialog、累计 Token 与上下文窗口信息;可选 JSON 输出 |
| `/dump` | 导出当前 dialog 为 Markdown |
| `/mcp` | 查看 MCP 服务器和工具状态 |
@ -326,9 +326,7 @@ PicoBot 有两类记忆:
| Knowledge | 偏好、事实、项目规则、长期可复用信息 | 长期保留,手动删除 |
| Timeline | 长对话压缩后的历史摘要 | 默认保留 90 天 |
每轮处理用户消息时MemoryManager 会按用户输入召回 Knowledge并作为运行时上下文附加到本轮用户消息。当前召回上限固定为 5`memory.recall_limit` 已支持解析但尚未接入 worker。长会话使用一个活动 checkpoint累计摘要加 `first_retained_seq` 之后的原始消息尾部构成模型上下文原始消息、工具调用结果、ID 和 seq 均不会被压缩改写。旧工具结果会保留在原始历史中,但 checkpoint 边界推进后不再永久占用 Provider 上下文。成功的语义摘要还会 best-effort 保存为 Timeline`timeline_recall` 检索Timeline 不参与会话恢复正确性。Scheduler 默认创建一个每日维护巡检,按 `memory.timeline_retention_days` 清理过期 TimelineKnowledge 不会被自动删除。
模型的 `models.<name>.token_limit` 给出上下文窗口上限,未配置时默认为 128,000Agent 的 `agents.<name>.token_limit` 是可选的收紧上限,两者都有配置时有效窗口取二者最小值,因此 Agent 不能扩大模型窗口。自动压缩使用保留量阈值 `context_tokens > context_window - effective_reserve`,默认 reserve 为 16,384 tokens并尽量原样保留最近 20,000 tokens。小窗口会自动把 reserve 限制为窗口的一半、把近期保留量限制为有效阈值的一半。摘要请求不使用固定 32K 输入上限,而是按有效窗口扣除摘要输出、提示词和安全余量;超大历史只在摘要请求副本中按“已有 checkpoint + 最新消息优先”生成有界 head/tail 转录SQLite 原文不变。手动 `/compact` 跳过自动阈值;换成小模型后若发送前预检已发现硬超限,或首次请求返回真实 context overflow语义摘要不可用时才使用明确标记的确定性降级裁剪正式请求最多重试一次。若 overflow 发生在工具已经执行之后AgentLoop 只在当前内存转录上裁掉旧完整 Turn 并重试当前模型步骤一次,不会从数据库历史重跑工具。
每轮处理用户消息时MemoryManager 会按用户输入召回 Knowledge并作为运行时上下文附加到本轮用户消息。当前召回上限固定为 5`memory.recall_limit` 已支持解析但尚未接入 worker。上下文压缩产生的摘要会保存为 Timeline后续可通过 `timeline_recall` 工具检索。Scheduler 默认创建一个每日维护巡检,按 `memory.timeline_retention_days` 清理过期 TimelineKnowledge 不会被自动删除。
### 工具
@ -376,7 +374,6 @@ Skill 是包含 `SKILL.md` 的目录。加载优先级从高到低:
| `providers` | LLM Provider 配置 |
| `models` | 模型参数与输入能力 |
| `agents` | Agent 使用哪个 provider/model |
| `context_compaction` | 上下文自动压缩开关、预留 token 与近期原样保留量 |
| `agent_orchestration` | 具名子 Agent 定义目录与编排上限 |
| `gateway` | HTTP/WebSocket、数据库、调度器、后台任务限制 |
| `client` | CLI 客户端默认 Gateway URL |
@ -396,9 +393,6 @@ Skill 是包含 `SKILL.md` 的目录。加载优先级从高到低:
| `gateway.max_concurrent_background_tasks` | `10` |
| `gateway.scheduler.enabled` | `true` |
| `client.gateway_url` | `ws://127.0.0.1:19876/ws` |
| `context_compaction.enabled` | `true` |
| `context_compaction.reserve_tokens` | `16384` |
| `context_compaction.keep_recent_tokens` | `20000` |
| `memory.recall_limit` | `5`(当前运行时固定为 5 |
| `memory.timeline_retention_days` | `90` |
| `mcp.tool_timeout_secs` | `180` |

View File

@ -12,7 +12,6 @@
"model_id": "qwen-plus",
"temperature": 0.0,
"max_tokens": 100,
"token_limit": 128000,
"input_type": ["text"]
}
},
@ -20,7 +19,8 @@
"default": {
"provider": "aliyun",
"model": "qwen-plus",
"max_tool_iterations": 20
"max_tool_iterations": 20,
"token_limit": 128000
}
},
"agent_orchestration": {

View File

@ -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)。已实施的 checkpoint 上下文压缩、pi 风格 reserve 阈值、统一编排和 overflow 失败语义见 [CONTEXT_COMPACTION_DESIGN.md](CONTEXT_COMPACTION_DESIGN.md)。配置运行代、重载边界和失败语义见 [CONFIG_HOT_RELOAD_DESIGN.md](CONFIG_HOT_RELOAD_DESIGN.md)。具名子 Agent、委托图、后台收件箱、结果传递机制与 `queue`/`steer` 信号的设计见 [SUB_AGENT_DESIGN.md](SUB_AGENT_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` 信号的设计见 [SUB_AGENT_DESIGN.md](SUB_AGENT_DESIGN.md)。
## 1. 设计目标
@ -204,7 +204,7 @@ Session ID 格式为:
当前 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、可选的 active plan 摘要,以及由活动 `ContextCheckpoint` 投影出的会话历史。`messages` 始终是 append-only 原始日志;每个 Session 最多有一个活动 checkpoint模型历史确定为“一条累计摘要 + `seq >= first_retained_seq` 的原始尾部”Timeline 只是 checkpoint 提交后的 best-effort 检索副本,恢复流程不读取 Timeline、也不调用 Provider。所选 Model 的可选 `token_limit` 定义上下文硬上限,缺失时固定回退 128000Agent 的可选 `token_limit` 只允许收紧该上限,有效窗口为 `min(agent_token_limit, model_token_limit_or_128K)`Agent 不能扩大模型窗口。主 Agent Profile、具名 Agent 的内联 Provider/Model 和 `llm_profile` 引用必须使用同一规则definition 上的显式值也只能收紧已解析 Profile。`session::turn_input` 只在 Session 锁外并行读取 Knowledge 和 active plan完整请求草稿随后使用同一个 reserve 预算评估,自动触发公式唯一为 `context_tokens > context_window - effective_reserve`。手动、自动和首次 context-overflow 共用唯一的 `compact_session_context` 编排与 checkpoint CAS 提交路径candidate 自带快照 generation提交成功后必须从当前 raw log 重新投影,不能返回摘要前的旧尾部向量。摘要请求输入预算由有效 `token_limit` 扣除动态摘要输出、固定提示词和安全余量得到;超大压缩源只在 request-local 副本中保留已有 checkpoint 和最新材料、对单条内容做确定性 head/tail 截取,不能用固定 32K 上限拒绝压缩,也不能改写 durable raw log。每个 Turn 最多进行一次语义摘要;换模后的发送前预检若已硬超限,可在摘要失败时直接生成明确标记的确定性降级 checkpoint避免先发送必然失败的普通请求首次 Provider 请求 overflow 最多正式重试一次。AgentLoop 把 Provider overflow 转成类型化错误:工具尚未执行时交回 Session工具已执行后只在同一个 AgentLoop 中删除旧完整 Turn 的请求副本并重试当前 Provider step 一次,保留本 Turn tool call/result绝不从 durable history 重启并重复副作用工具。首次请求与重试必须复用同一个 runtime assembly不能复制系统提示、丢失 mailbox steering 或把压缩投影写回原始历史。Provider prompt usage 仅在 provider/model/checkpoint generation/raw seq 和完整请求摘要都匹配时复用,否则完整保守估算;不再按消息数外推。普通闲聊 session 没有 plan 摘要;计划状态由 `WorkManager` 从 SQLite 读取,因此不以自然语言摘要作为权威来源。`AgentLoop` 接收完整输入执行一次模型/工具循环,本身不拥有会话状态,但通过本 Turn 的 mailbox 在安全边界接收追加用户输入。执行工具时额外传递只包含 session/turn 身份的 `ToolExecutionContext`;无状态工具使用默认实现忽略它,有状态外部适配器用它路由资源,但可按明确的单用户配置跨 dialog 共享,且不能自行反向查询 SessionManager。完整压缩设计见 [CONTEXT_COMPACTION_DESIGN.md](CONTEXT_COMPACTION_DESIGN.md)。
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 基础已接入:候选运行代从受信任配置目录严格加载不可变 `AgentCatalog`Definition 固定 Provider/Model内联或 `llm_profile`)、工具/Skill allowlist、委托边和执行限制工具集完全由定义文件决定单个定义校验失败坏 YAML、未知 provider/profile/model/tool/skill、或显式委托到缺失目标仅停用该定义并记入 `load_errors``GET /api/agents` 返回),不会阻塞启动或热重载,配置与目录信任级错误仍然致命;`delegate` 使用 `foreground/background` 两个 canonical 生命周期词,批量并发与生命周期正交。具名 foreground 支持批量并发、显式 `AgentExecutionContext`、祖先环路和委托边校验Root 对具名 Agent 的 background单任务或批量批量并发、每个 run 独立 completion 事件)走 durable run/inbox + continuation 投递,空闲时完成即返回。内置 general-purpose 定义随二进制释放WebUI「子 Agent」页可增删改与启停定义。旧匿名 general 兼容路径已移除。自动标题属于非关键派生工作Turn 持久化完成后由 `TaskSupervisor` 调度Session worker 不等待模型生成;同一 Session 同时最多有一个标题任务,提交时仍校验标题保持默认值,避免覆盖用户改名。
@ -219,7 +219,7 @@ SessionManager 负责组装会话上下文系统提示、Skills、召回的 K
- 5 秒 busy timeout。
- schema version 迁移。
持久化范围包括 sessions、messages、context checkpoints、session turn usage、memories、task plans/items、scheduled jobs、job runs、agent run/inbox/session state。checkpoint 插入、活动指针切换和 `context_generation` 递增在同一事务中完成;`/clear` 在删除消息的事务内使活动 checkpoint 失效,旧 checkpoint 行仅作为审计记录保留。消息保存可展示 `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 时应:
持久化范围包括 sessions、messages、session turn usage、memories、task plans/items、scheduled jobs、job runs、agent run/inbox/session state。消息保存可展示 `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. 保留已有数据库的升级路径。
@ -256,7 +256,7 @@ 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` 帧刷新,计划变化不会写入聊天历史;活动状态栏通过结构化 `session_stats` 展示当前 session 的已提交 Turn 用量与上下文占用,累计量来自 Provider usage窗口占用明确区分精确匹配的 Provider 实测与完整字符估算;斜杠命令补全通过 `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 上下文会为所有媒体注入内部路径清单,客户端响应不得暴露该路径。

File diff suppressed because it is too large Load Diff

View File

@ -2,8 +2,6 @@
编写日期2026-06-17
> 历史说明:本文的 `ContextCompressor`、Timeline 回填和时间戳边界描述是 1.19.0 之前的基线。当前上下文恢复由单一活动 checkpoint 和 durable `seq` 边界驱动Timeline 只是提交后的派生检索记录;以 [CONTEXT_COMPACTION_DESIGN.md](CONTEXT_COMPACTION_DESIGN.md) 和 [ARCHITECTURE.md](ARCHITECTURE.md) 为准。
## 背景
PicoBot 当前已经具备最基础的记忆能力:

View File

@ -23,8 +23,7 @@
"qwen-plus": {
"model_id": "qwen-plus",
"temperature": 0.0,
"max_tokens": 8192,
"token_limit": 128000
"max_tokens": 8192
},
"gpt-4o": {
"model_id": "gpt-4o",
@ -43,14 +42,10 @@
"default": {
"provider": "aliyun",
"model": "qwen-plus",
"max_tool_iterations": 99
"max_tool_iterations": 99,
"token_limit": 128000
}
},
"context_compaction": {
"enabled": true,
"reserve_tokens": 16384,
"keep_recent_tokens": 20000
},
"gateway": {
"host": "127.0.0.1",
"port": 19876,

View File

@ -77,7 +77,11 @@ Scheduler → SessionManager.handle_cron_message → AgentLoop → send_message
## 上下文压缩
`messages` 是 append-only 原始日志压缩不会改写消息、工具结果、ID 或 seq。每个 session 最多有一个活动 `ContextCheckpoint`Provider 历史确定为“一条累计摘要 + `seq >= first_retained_seq` 的原始尾部”。Model `token_limit` 是上下文硬上限,缺失时默认 128K可选 Agent `token_limit` 只能收紧它,有效窗口取二者最小值。自动压缩使用 `context_tokens > context_window - effective_reserve`,默认 reserve 16,384、近期原样保留 20,000 tokens小窗口会自适应缩小两者。摘要输入预算由有效窗口扣除摘要输出、提示词和安全余量得到超大历史生成 checkpoint 加最新材料优先的有界 request-local 转录,不受固定 32K 上限约束。`/compact`、自动入口和首次 overflow 复用同一压缩编排和 checkpoint 原子提交路径,每次最多一次摘要调用。真实 Provider overflow 或换模后发送前已检测到的硬超限,能在摘要不可用时生成明确标记的确定性降级 checkpoint并且正式 Provider 请求只重试一次。工具已经执行后若发生 overflow只在同一个 AgentLoop 中保留本 Turn 工具链、裁掉旧完整 Turn 的请求副本并重试当前模型步骤一次,不从 durable history 重跑工具。
当上下文接近 token 限制时触发:
1. **快速裁剪**:合并连续同角色消息,截断工具输出
2. **硬截断**:移除过老消息
3. 压缩后保留用户消息确保结构完整
## Skill 系统
@ -144,7 +148,7 @@ Worker 的处理原则:
2. 释放锁后执行消息持久化、记忆召回、上下文压缩、LLM 和工具等慢操作。
3. 提交由旧快照产生的结果前重新验证 generation/version防止 `/stop``/clear``/delete` 后写回陈旧状态。
4. Session 持久化由独立 `persistence_lock` 串行化;批量消息使用原子写入,失败时精确回滚内存后缀。
5. 首次请求上下文溢出且尚未执行工具时,按 Provider 返回的真实限制提交 checkpoint 并正式重试一次;工具执行后的溢出只能在原 AgentLoop 内保留当前工具链进行一次请求级恢复
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 输入和当前工具结果。
@ -158,9 +162,8 @@ WebUI/TUI 的 Active Turn 使用 `send_message(files=...)` 向自身 session 投
### 会话恢复
从 Storage 恢复 session 时:
- 加载全部原始消息及 Session 的活动 checkpoint
- 有 checkpoint 时确定性投影累计摘要和 `first_retained_seq` 之后的原始尾部;没有 checkpoint 时投影全部原始消息
- Timeline 和 `last_compressed_message_at` 不参与恢复边界判断,恢复过程不调用 Provider
- 若 `last_compressed_message_at` 存在:先加载近 3 条 Timeline 记忆作为 `[Previous Context]`,再加载压缩标记后的原始消息
- 若无压缩记录:正常加载全部消息
- 自动修复断链的工具调用gateway 崩溃中途重启导致)
---
@ -214,9 +217,13 @@ WebUI/TUI 的 Active Turn 使用 `send_message(files=...)` 向自身 session 投
### 上下文压缩与 Timeline
自动压缩在完整请求占用满足 `context_tokens > context_window - effective_reserve` 时触发。压缩器从尾部按完整 Turn 尽量保留近期历史,用一次 LLM 调用生成累计摘要,并以 `first_retained_seq` 记录精确尾部边界。摘要请求按当前模型窗口动态限制输入;若待压缩源更大,只在请求副本中保留已有 checkpoint、最新消息和确定性 head/tail 摘录原始内容仍完整持久化。checkpoint 与 Session 活动指针在同一 SQLite 事务中提交;提交失败继续使用旧投影。原始消息与工具结果始终保留,旧内容只是不再进入 Provider context。
LLM 对话上下文接近 token 限制 (默认 128K × 70%) 时自动触发压缩:
语义 checkpoint 提交后会 best-effort 写入一条 **Timeline**importance 0.3)供主动检索,但 Timeline 不是恢复权威。真实 context overflow或换模/改配置后在发送前已检测到的硬超限,能使用无语义 breadcrumb 降级,且只重试一次正式 Provider 请求;低于硬窗口的普通自动压缩和 `/compact` 失败时不会裁掉历史。
1. **快速裁剪**:工具输出 ≥ 2000 字符时截断
2. **LLM 摘要**:最多 3 轮,每轮找连续用户消息对,将中间的 assistant/tool 消息压缩为摘要 → 摘要作为 **Timeline 记忆** 持久化importance 0.3
3. **硬截断**:若仍超 90%,只保留前 N + 后 N 条消息
压缩后 `last_compressed_message_at` 标记边界,后续恢复时从标记点加载原始消息,以 Timeline 提供更早的上下文。
### 关键集成点
@ -224,9 +231,9 @@ WebUI/TUI 的 Active Turn 使用 `send_message(files=...)` 向自身 session 投
|------|------|
| 每次消息处理 | `memory_manager.recall()` 提取 Knowledge 上下文 |
| 系统提示构建 | `MemorySection` 渲染记忆工具指南;匹配的 Knowledge 附加到本轮 user message |
| 有活动 checkpoint 时 | 累计摘要和精确 raw tail 组成 Provider 历史 |
| 语义 checkpoint 提交后 | 摘要 best-effort 存储为 Timeline 记忆 |
| 会话恢复 | 从 checkpoint 与原始 seq 确定性重建,不读取 Timeline |
| 有压缩历史时 | `HistorySection` 提示 LLM 使用 `timeline_recall` |
| 压缩完成后 | 摘要自动存储为 Timeline 记忆 |
| 会话恢复 | 加载最近 Timeline 和压缩边界后的原始消息 |
`memory.recall_limit``idle_consolidation_minutes``timeline_retention_days``max_failures_before_degrade` 当前会被配置解析;其中每轮 Knowledge 召回在 worker 中仍固定为 5其余自动维护策略尚未接入运行循环。不要把“配置可解析”误认为“行为已生效”。

View File

@ -12,7 +12,6 @@ Gateway WebUI 的“配置”页可以编辑实际加载的配置文件。读取
"providers": {}, // LLM 提供商配置
"models": {}, // 模型配置
"agents": {}, // Provider/Model profile
"context_compaction": {}, // 上下文 reserve 预算与近期保留量
"agent_orchestration": {}, // 具名子 Agent Definition 与编排上限
"gateway": {}, // 网关配置
"client": {}, // 客户端配置
@ -42,7 +41,6 @@ Gateway WebUI 的“配置”页可以编辑实际加载的配置文件。读取
| `model_id` | 模型标识名称 |
| `temperature` | 采样温度,可选 |
| `max_tokens` | 最大输出 token 数,可选 |
| `token_limit` | 模型上下文窗口硬上限,可选;未配置时默认为 128000Agent 只能进一步收紧 |
| `input_type` | 模型支持的输入类型,如 `["text"]``["text", "image"]`,默认 `["text"]`. 纯内部使用,不会传递给 LLM API |
## agents 字段
@ -52,17 +50,7 @@ Gateway WebUI 的“配置”页可以编辑实际加载的配置文件。读取
| `provider` | string | - | 提供商名称(对应 providers key |
| `model` | string | - | 模型名称(对应 models key |
| `max_tool_iterations` | int | 99 | 最大工具调用轮数 |
| `token_limit` | int | 使用模型上限 | 可选的 Agent 上限;有效窗口取 Agent 与模型(模型未配置时为 128000的最小值 |
## context_compaction 字段
自动压缩只使用 reserve 公式 `context_tokens > context_window - effective_reserve`。小窗口下 `effective_reserve = min(reserve_tokens, context_window / 2)`,近期原样保留量最多为有效阈值的一半。
| 字段 | 默认 | 说明 |
|------|------|------|
| `enabled` | true | 只控制 Turn 前自动压缩;不禁用 `/compact` 或 overflow 恢复 |
| `reserve_tokens` | 16384 | 为输出、工具迭代和估算误差预留的输入窗口 |
| `keep_recent_tokens` | 20000 | checkpoint 后尽量原样保留的近期历史 token |
| `token_limit` | int | 128000 | 上下文 token 限制 |
## agent_orchestration 字段

View File

@ -2,7 +2,7 @@
数据库为 SQLite默认位于配置目录`~/.picobot``data/` 下的 `picobot.db`,与 workspace 相互独立。
连接启用 WAL、`synchronous=NORMAL`、foreign keys、5 秒 busy timeout连接池最多 8 个连接。当前 `PRAGMA user_version=10`;启动时会在事务内补齐旧库字段和索引,遇到比程序更新的 schema version 会拒绝启动。
连接启用 WAL、`synchronous=NORMAL`、foreign keys、5 秒 busy timeout连接池最多 8 个连接。当前 `PRAGMA user_version=8`;启动时会在事务内补齐旧库字段和索引,遇到比程序更新的 schema version 会拒绝启动。
## sessions 表
@ -22,9 +22,7 @@
| `archived_at` | INTEGER | 归档时间Unix 毫秒NULL 表示未归档 |
| `deleted_at` | INTEGER | 软删除时间戳 |
| `last_consolidated_at` | INTEGER | 上次记忆归并时间 |
| `last_compressed_message_at` | INTEGER | 最近 checkpoint 时间戳(兼容/诊断字段,不作为恢复边界) |
| `active_context_checkpoint_id` | TEXT | 当前 Provider 历史投影使用的 checkpoint IDNULL 表示使用全部原始消息 |
| `context_generation` | INTEGER | checkpoint CAS 提交代;历史清空/改写时递增并清除活动指针 |
| `last_compressed_message_at` | INTEGER | 上次上下文压缩边界时间戳 |
| `delivery_context` | TEXT | 渠道声明的可跨 Turn 复用投递上下文 JSON如飞书 thread/root 身份);一次性 reply/reaction ID 永不写入 |
| `delivery_context_updated_at` | INTEGER | delivery_context 最后更新时间 |
@ -55,27 +53,7 @@
| `client_visibility` | TEXT | `visible` / `hidden`,默认 visiblehidden 只供模型回放continuation 内部触发),客户端历史/投影/投递一律过滤 |
| `turn_origin` | TEXT | `user` / `agent_continuation` / `scheduled`,默认 user客户端据此渲染"后台结果处理"标签而不创建用户气泡 |
`(session_id, seq)` 有唯一索引,防止并发写入重复序号。物理删除 session 会通过外键级联删除 messages普通对话删除使用 `deleted_at` 软删除,因此保留关联行。索引 `(session_id, client_visibility, seq)` 支撑按可见性分层查询。
## context_checkpoints 表schema v10
checkpoint 只保存累计摘要和精确 raw-tail 边界,不复制或删除原始消息。每个 Session 的 `active_context_checkpoint_id` 最多指向其中一行;历史行保留用于审计。
| 字段 | 说明 |
|------|------|
| `id` | checkpoint ID主键 |
| `session_id` / `generation` | 所属 Session 与单调提交代;组合唯一 |
| `parent_checkpoint_id` | 上一个累计 checkpoint审计链 |
| `summary` | 不含 Provider 私有 reasoning 的累计摘要 |
| `first_retained_seq` | Provider 原样保留尾部的第一条 durable seq |
| `source_max_seq` | 生成候选时快照的最大 seq |
| `trigger_reason` | manual / auto / overflow |
| `provider_kind` / `model` | 生成摘要的 Provider/model |
| `tokens_before` / `tokens_after` | 候选验证和诊断数据 |
| `degraded` | 是否为 Provider overflow 或发送前硬超限的确定性无语义降级 |
| `created_at` | 创建时间 |
checkpoint 插入、Session 活动指针更新与 `context_generation` 递增在同一事务内完成。`/clear` 原子删除 messages 并使活动 checkpoint 失效;只有物理删除 Session 才会通过外键级联删除全部 checkpoint普通 `/delete` 软删除会保留 checkpoint 行。
`(session_id, seq)` 有唯一索引,防止并发写入重复序号。删除 session 会通过外键级联删除 messages。索引 `(session_id, client_visibility, seq)` 支撑按可见性分层查询。
## agent_runs 表schema v8Agent 编排)

View File

@ -50,7 +50,7 @@ Skill 安装后默认启用,可在 WebUI「工具 → Skills」页用开关禁
## Q: 上下文压缩是什么意思?
对话接近模型 token 限制时PicoBot 用一份累计 checkpoint 摘要替代 Provider 上下文中的旧前缀,并原样保留近期消息尾部。原始消息和工具结果仍永久保存在聊天历史/SQLite 中,只是不再永久占用模型上下文;语义摘要还可通过 `timeline_recall` 检索。Model 的 `token_limit` 是窗口硬上限,未配置时为 128KAgent 的可选 `token_limit` 只能收紧它,两者都有时取最小值。自动阈值采用窗口减 reserve 的机制,摘要输入按有效窗口动态限制而非固定 32K换成更小模型后若历史已经超限会在首次普通模型请求前压缩或明确降级。`/compact` 可在阈值前手动强制执行
对话历史过长超出模型 token 限制时,系统自动精简历史消息。压缩后旧消息可通过 `timeline_recall` 工具检索
## Q: 如何修改 gateway 监听端口?

View File

@ -23,8 +23,7 @@
"qwen-plus": {
"model_id": "qwen-plus",
"temperature": 0.0,
"max_tokens": 8192,
"token_limit": 128000
"max_tokens": 8192
},
"gpt-4o": {
"model_id": "gpt-4o",
@ -43,14 +42,10 @@
"default": {
"provider": "aliyun",
"model": "qwen-plus",
"max_tool_iterations": 99
"max_tool_iterations": 99,
"token_limit": 128000
}
},
"context_compaction": {
"enabled": true,
"reserve_tokens": 16384,
"keep_recent_tokens": 20000
},
"agent_orchestration": {
"definitions_dir": "agents",
"max_tree_depth": 4,

View File

@ -1,7 +1,4 @@
use crate::agent::context_compaction::{
context_request_digest, estimate_tokens, is_context_overflow_error,
parse_context_limit_from_error,
};
use crate::agent::context_compressor::estimate_tokens;
use crate::agent::media_handler::MediaHandlerRegistry;
use crate::agent::steering::{SteeringDrain, TurnInput};
use crate::agent::system_prompt::build_system_prompt;
@ -222,55 +219,6 @@ fn attach_reply_media(message: &mut ChatMessage, reply_media_refs: &[MediaRef])
extend_unique_media(&mut message.media_refs, reply_media_refs);
}
/// Drop only old complete Turns from the request-local history. Messages
/// appended by this AgentLoop invocation live at or after `history_len` and
/// are never removed, so already executed tool calls/results remain available
/// to the retry and cannot be executed a second time.
fn trim_old_complete_turns_for_overflow(
messages: &mut Vec<ChatMessage>,
history_len: usize,
target_tokens: usize,
) -> usize {
let history_len = history_len.min(messages.len());
let protected_start = messages[..history_len]
.iter()
.rposition(|message| message.role == "user");
let Some(protected_start) = protected_start else {
return 0;
};
let system_end = messages[..history_len]
.iter()
.take_while(|message| message.role == "system")
.count();
let first_turn_start = messages[system_end..history_len]
.iter()
.position(|message| message.role == "user")
.map(|index| system_end + index)
.unwrap_or(system_end);
let mut chosen_cut = None;
for cut in (first_turn_start.saturating_add(1)..=protected_start)
.filter(|index| messages[*index].role == "user")
{
chosen_cut = Some(cut);
let projected = messages[..system_end]
.iter()
.chain(messages[cut..].iter())
.cloned()
.collect::<Vec<_>>();
if estimate_tokens(&projected) <= target_tokens {
break;
}
}
let Some(cut) = chosen_cut else {
return 0;
};
let dropped = cut.saturating_sub(system_end);
messages.drain(system_end..cut);
dropped
}
/// Loop detection result.
#[derive(Debug, Clone, PartialEq, Eq)]
enum LoopDetectionResult {
@ -393,7 +341,7 @@ pub struct AgentLoop {
max_iterations: usize,
workspace_dir: PathBuf,
model_name: String,
context_trim_threshold: usize,
context_window: usize,
input_types: Vec<String>,
media_registry: MediaHandlerRegistry,
/// Optional sink receiving a clone of every message appended to
@ -411,9 +359,6 @@ pub struct AgentProcessResult {
/// the correct basis for context-window occupancy; `usage` is accumulated
/// across every tool iteration.
pub last_request_usage: Option<crate::providers::Usage>,
/// Digest of the exact ChatMessage/tool-definition request associated
/// with `last_request_usage`.
pub last_request_digest: Option<u64>,
}
fn merge_usage(total: &mut crate::providers::Usage, next: &crate::providers::Usage) {
@ -455,7 +400,7 @@ impl AgentLoop {
provider: Arc::from(provider),
tools: Arc::new(ToolRegistry::new()),
observer: None,
context_trim_threshold: 0,
context_window: 0,
max_iterations,
workspace_dir,
model_name,
@ -481,7 +426,7 @@ impl AgentLoop {
provider: Arc::from(provider),
tools,
observer: None,
context_trim_threshold: 0,
context_window: 0,
max_iterations,
workspace_dir,
model_name,
@ -503,7 +448,7 @@ impl AgentLoop {
provider,
tools: Arc::new(ToolRegistry::new()),
observer: None,
context_trim_threshold: 0,
context_window: 0,
max_iterations,
workspace_dir,
model_name,
@ -526,7 +471,7 @@ impl AgentLoop {
provider,
tools,
observer: None,
context_trim_threshold: 0,
context_window: 0,
max_iterations,
workspace_dir,
model_name,
@ -538,14 +483,7 @@ impl AgentLoop {
/// Set the context window size for preemptive trimming.
pub fn with_context_window(mut self, window: usize) -> Self {
self.context_trim_threshold = window.saturating_sub(16_384.min(window / 2));
self
}
/// Override the request-local tool trimming threshold with the same
/// effective reserve threshold used by Session context compaction.
pub fn with_context_trim_threshold(mut self, threshold: usize) -> Self {
self.context_trim_threshold = threshold;
self.context_window = window;
self
}
@ -683,15 +621,7 @@ impl AgentLoop {
start.elapsed().as_millis() as u64,
true,
);
let message = error.to_string();
if is_context_overflow_error(&message) {
return Err(AgentError::ContextOverflow {
parsed_window: parse_context_limit_from_error(&message),
message,
tool_progress: false,
});
}
return Err(AgentError::LlmError(message));
return Err(AgentError::LlmError(error.to_string()));
}
};
let mut accumulator = ProviderResponseAccumulator::default();
@ -722,15 +652,7 @@ impl AgentLoop {
start.elapsed().as_millis() as u64,
true,
);
let message = error.to_string();
if is_context_overflow_error(&message) {
return Err(AgentError::ContextOverflow {
parsed_window: parse_context_limit_from_error(&message),
message,
tool_progress: false,
});
}
return Err(AgentError::LlmError(message));
return Err(AgentError::LlmError(error.to_string()));
}
};
if let Some(turn) = turn {
@ -950,10 +872,6 @@ impl AgentLoop {
let mut accumulated_tokens: u32 = 0;
let mut accumulated_usage = crate::providers::Usage::default();
let mut last_request_usage = None;
let mut last_request_digest = None;
let initial_history_len = messages.len();
let mut completed_tool_batches = 0usize;
let mut local_overflow_retry_used = false;
for iteration in 0..self.max_iterations {
if cancellation.is_cancelled() {
@ -964,17 +882,18 @@ impl AgentLoop {
tracing::debug!(iteration, "Agent iteration started");
let last_iteration = iteration.saturating_add(1) >= self.max_iterations;
// Request-local safety check: use the same reserve threshold as
// Session compaction, while leaving durable tool results intact.
if self.context_trim_threshold > 0 {
// Preemptive context check: trim old tool results if token estimate
// exceeds 80% of context window to prevent mid-loop overflow.
if self.context_window > 0 {
let estimated = estimate_tokens(&messages);
if estimated > self.context_trim_threshold {
let danger = (self.context_window as f64 * 0.8) as usize;
if estimated > danger {
let trimmed = self.preemptive_trim_old_tool_results(&mut messages, 2000, 4);
if trimmed > 0 {
#[cfg(debug_assertions)]
tracing::debug!(
estimated,
threshold = self.context_trim_threshold,
danger,
trimmed_msgs = trimmed,
"Preemptive tool-result trim applied in loop"
);
@ -982,6 +901,23 @@ impl AgentLoop {
}
}
// Convert messages to LLM format
let messages_for_llm = self.messages_for_llm(&messages);
// Build request
let tools = if self.tools.has_tools() {
Some(self.tools.get_definitions())
} else {
None
};
let request = ChatCompletionRequest {
messages: messages_for_llm,
temperature: None,
max_tokens: None,
tools,
};
// Call LLM
let iteration = match u32::try_from(iteration) {
Ok(iteration) => iteration,
@ -990,67 +926,14 @@ impl AgentLoop {
return Err(AgentError::Other("tool iteration exceeds u32".to_string()));
}
};
let (response, successful_request_digest) = loop {
let tools = self.tools.has_tools().then(|| self.tools.get_definitions());
let tool_signature = serde_json::to_string(&tools).unwrap_or_default();
let request_digest = context_request_digest(&messages, &tool_signature);
let request = ChatCompletionRequest {
messages: self.messages_for_llm(&messages),
temperature: None,
max_tokens: None,
tools,
};
let response = {
let _provider_permit =
Self::acquire_provider_permit(&tool_context, &cancellation).await?;
match self
.stream_completion(request, iteration, turn.as_ref(), &cancellation)
.await
{
Ok(response) => break (response, request_digest),
Err(AgentError::ContextOverflow {
message,
parsed_window,
..
}) if completed_tool_batches > 0 && !local_overflow_retry_used => {
let parsed_threshold = parsed_window
.map(|window| window.saturating_sub(16_384.min(window / 2)));
let target_tokens = match (self.context_trim_threshold, parsed_threshold) {
(0, Some(parsed)) => parsed,
(configured, Some(parsed)) => configured.min(parsed),
(configured, None) => configured,
};
let dropped = trim_old_complete_turns_for_overflow(
&mut messages,
initial_history_len,
target_tokens,
);
if dropped == 0 {
Self::restore_steering(turn.as_ref(), consumed_steering);
return Err(AgentError::ContextOverflow {
message,
parsed_window,
tool_progress: true,
});
}
local_overflow_retry_used = true;
tracing::warn!(
dropped_messages = dropped,
target_tokens,
"Retrying context overflow in the same AgentLoop after tool progress"
);
}
Err(AgentError::ContextOverflow {
message,
parsed_window,
..
}) => {
Self::restore_steering(turn.as_ref(), consumed_steering);
return Err(AgentError::ContextOverflow {
message,
parsed_window,
tool_progress: completed_tool_batches > 0,
});
}
Ok(response) => response,
Err(error) => {
// The invocation may be retried from persisted history.
// Restore every steering message consumed by an earlier
@ -1065,7 +948,6 @@ impl AgentLoop {
accumulated_tokens = accumulated_tokens.saturating_add(response.usage.total_tokens);
merge_usage(&mut accumulated_usage, &response.usage);
last_request_usage = Some(response.usage.clone());
last_request_digest = Some(successful_request_digest);
#[cfg(debug_assertions)]
tracing::debug!(
@ -1128,7 +1010,6 @@ impl AgentLoop {
total_tokens: Some(accumulated_tokens),
usage: Some(accumulated_usage),
last_request_usage,
last_request_digest,
});
}
@ -1238,7 +1119,6 @@ impl AgentLoop {
}
}
}
completed_tool_batches = completed_tool_batches.saturating_add(1);
// A complete tool batch is the first safe steering boundary. Do
// not drain at the final available iteration: those inputs must
@ -1288,6 +1168,16 @@ impl AgentLoop {
);
messages.push(summary_request);
// Convert messages to LLM format
let messages_for_llm = self.messages_for_llm(&messages);
let request = ChatCompletionRequest {
messages: messages_for_llm,
temperature: None,
max_tokens: None,
tools: None, // No tools in final summary call
};
let summary_iteration = match u32::try_from(self.max_iterations) {
Ok(iteration) => iteration,
Err(_) => {
@ -1295,72 +1185,17 @@ impl AgentLoop {
return Err(AgentError::Other("tool iteration exceeds u32".to_string()));
}
};
let summary_result = loop {
let request_digest = context_request_digest(&messages, "null");
let request = ChatCompletionRequest {
messages: self.messages_for_llm(&messages),
temperature: None,
max_tokens: None,
tools: None,
};
let summary_result = {
let _provider_permit =
Self::acquire_provider_permit(&tool_context, &cancellation).await?;
match self
.stream_completion(request, summary_iteration, turn.as_ref(), &cancellation)
self.stream_completion(request, summary_iteration, turn.as_ref(), &cancellation)
.await
{
Ok(response) => break Ok((response, request_digest)),
Err(AgentError::ContextOverflow {
message,
parsed_window,
..
}) if completed_tool_batches > 0 && !local_overflow_retry_used => {
let parsed_threshold =
parsed_window.map(|window| window.saturating_sub(16_384.min(window / 2)));
let target_tokens = match (self.context_trim_threshold, parsed_threshold) {
(0, Some(parsed)) => parsed,
(configured, Some(parsed)) => configured.min(parsed),
(configured, None) => configured,
};
let dropped = trim_old_complete_turns_for_overflow(
&mut messages,
initial_history_len,
target_tokens,
);
if dropped == 0 {
break Err(AgentError::ContextOverflow {
message,
parsed_window,
tool_progress: true,
});
}
local_overflow_retry_used = true;
tracing::warn!(
dropped_messages = dropped,
target_tokens,
"Retrying final-summary context overflow in the same AgentLoop"
);
}
Err(AgentError::ContextOverflow {
message,
parsed_window,
..
}) => {
break Err(AgentError::ContextOverflow {
message,
parsed_window,
tool_progress: completed_tool_batches > 0,
});
}
Err(error) => break Err(error),
}
};
match summary_result {
Ok((response, summary_request_digest)) => {
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());
last_request_digest = Some(summary_request_digest);
let mut assistant_message = ChatMessage::assistant(response.content);
assistant_message.reasoning_content = response.reasoning_content;
assistant_message.provider_state = response.provider_state;
@ -1383,13 +1218,8 @@ impl AgentLoop {
total_tokens: Some(accumulated_tokens),
usage: Some(accumulated_usage),
last_request_usage,
last_request_digest,
})
}
Err(error @ AgentError::ContextOverflow { .. }) => {
Self::restore_steering(turn.as_ref(), consumed_steering);
Err(error)
}
Err(e) => {
// Fallback if summary call fails
tracing::error!(error = %e, "Failed to get summary from LLM");
@ -1431,7 +1261,6 @@ impl AgentLoop {
},
usage: (accumulated_usage.total_tokens > 0).then_some(accumulated_usage),
last_request_usage,
last_request_digest,
})
}
}
@ -1657,131 +1486,6 @@ mod tests {
requests: std::sync::Mutex<usize>,
}
struct OverflowAfterToolProvider {
requests: std::sync::Mutex<Vec<ChatCompletionRequest>>,
fail_local_retry: bool,
}
struct AlwaysOverflowProvider {
requests: std::sync::atomic::AtomicUsize,
}
#[async_trait::async_trait]
impl LLMProvider for AlwaysOverflowProvider {
async fn stream(
&self,
_request: ChatCompletionRequest,
) -> Result<ProviderStream, crate::providers::DynProviderError> {
self.requests
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Err(Box::new(std::io::Error::other(
"maximum context length is 4096 tokens",
)))
}
fn ptype(&self) -> &str {
"test"
}
fn name(&self) -> &str {
"always-overflow"
}
fn model_id(&self) -> &str {
"always-overflow"
}
}
struct CountingSideEffectTool {
executions: Arc<std::sync::atomic::AtomicUsize>,
}
#[async_trait::async_trait]
impl Tool for CountingSideEffectTool {
fn name(&self) -> &str {
"side_effect"
}
fn description(&self) -> &str {
"increments a test counter"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({ "type": "object" })
}
fn read_only(&self) -> bool {
false
}
async fn execute(&self, _args: serde_json::Value) -> anyhow::Result<ToolResult> {
self.executions
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Ok(ToolResult {
success: true,
output: "side effect completed".to_string(),
error: None,
})
}
}
#[async_trait::async_trait]
impl LLMProvider for OverflowAfterToolProvider {
async fn stream(
&self,
request: ChatCompletionRequest,
) -> Result<ProviderStream, crate::providers::DynProviderError> {
let request_number = {
let mut requests = self.requests.lock().unwrap();
requests.push(request);
requests.len()
};
if request_number == 2 || (request_number == 3 && self.fail_local_retry) {
return Err(Box::new(std::io::Error::other(
"maximum context length is 4096 tokens",
)));
}
let response = if request_number == 1 {
ChatCompletionResponse {
id: "tool-call".to_string(),
model: "overflow-after-tool".to_string(),
content: String::new(),
reasoning_content: None,
provider_state: None,
tool_calls: vec![ToolCall {
id: "call-side-effect".to_string(),
name: "side_effect".to_string(),
arguments: serde_json::json!({}),
}],
usage: Usage::default(),
}
} else {
ChatCompletionResponse {
id: "final".to_string(),
model: "overflow-after-tool".to_string(),
content: "completed without repeating the tool".to_string(),
reasoning_content: None,
provider_state: None,
tool_calls: Vec::new(),
usage: Usage::default(),
}
};
Ok(crate::providers::provider_stream_for_test(response))
}
fn ptype(&self) -> &str {
"test"
}
fn name(&self) -> &str {
"overflow-after-tool"
}
fn model_id(&self) -> &str {
"overflow-after-tool"
}
}
#[async_trait::async_trait]
impl LLMProvider for StreamingTextProvider {
async fn stream(
@ -2022,181 +1726,6 @@ mod tests {
assert_eq!(restored.user_inputs[0].content, "retry me");
}
#[tokio::test]
async fn overflow_after_tool_progress_retries_locally_without_reexecuting_tool() {
let provider = Arc::new(OverflowAfterToolProvider {
requests: std::sync::Mutex::new(Vec::new()),
fail_local_retry: false,
});
let executions = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let tools = Arc::new(ToolRegistry::new());
tools.register(CountingSideEffectTool {
executions: executions.clone(),
});
let agent = AgentLoop::with_provider_and_tools(
provider.clone(),
tools,
3,
"overflow-after-tool".to_string(),
PathBuf::from("."),
Vec::new(),
)
.with_context_window(4_096);
let result = agent
.process(vec![
ChatMessage::user("old turn"),
ChatMessage::assistant("old answer"),
ChatMessage::user("current turn"),
])
.await
.unwrap();
assert_eq!(
result.final_response.content,
"completed without repeating the tool"
);
assert_eq!(executions.load(std::sync::atomic::Ordering::SeqCst), 1);
let requests = provider.requests.lock().unwrap();
assert_eq!(requests.len(), 3);
assert!(requests[1].messages.iter().any(|message| {
message
.content
.iter()
.any(|block| matches!(block, ContentBlock::Text { text } if text == "old turn"))
}));
assert!(!requests[2].messages.iter().any(|message| {
message
.content
.iter()
.any(|block| matches!(block, ContentBlock::Text { text } if text == "old turn"))
}));
assert!(
requests[2]
.messages
.iter()
.any(|message| message.role == "tool")
);
}
#[tokio::test]
async fn second_overflow_after_tool_progress_does_not_issue_third_retry() {
let provider = Arc::new(OverflowAfterToolProvider {
requests: std::sync::Mutex::new(Vec::new()),
fail_local_retry: true,
});
let executions = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let tools = Arc::new(ToolRegistry::new());
tools.register(CountingSideEffectTool {
executions: executions.clone(),
});
let agent = AgentLoop::with_provider_and_tools(
provider.clone(),
tools,
3,
"overflow-after-tool".to_string(),
PathBuf::from("."),
Vec::new(),
)
.with_context_window(4_096);
let error = agent
.process(vec![
ChatMessage::user("old turn"),
ChatMessage::assistant("old answer"),
ChatMessage::user("current turn"),
])
.await
.unwrap_err();
assert!(matches!(
error,
AgentError::ContextOverflow {
tool_progress: true,
..
}
));
assert_eq!(executions.load(std::sync::atomic::Ordering::SeqCst), 1);
assert_eq!(provider.requests.lock().unwrap().len(), 3);
}
#[tokio::test]
async fn final_summary_overflow_after_tool_progress_uses_same_safe_retry() {
let provider = Arc::new(OverflowAfterToolProvider {
requests: std::sync::Mutex::new(Vec::new()),
fail_local_retry: false,
});
let executions = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let tools = Arc::new(ToolRegistry::new());
tools.register(CountingSideEffectTool {
executions: executions.clone(),
});
let agent = AgentLoop::with_provider_and_tools(
provider.clone(),
tools,
1,
"overflow-after-tool".to_string(),
PathBuf::from("."),
Vec::new(),
)
.with_context_window(4_096);
let result = agent
.process(vec![
ChatMessage::user("old turn"),
ChatMessage::assistant("old answer"),
ChatMessage::user("current turn"),
])
.await
.unwrap();
assert_eq!(
result.final_response.content,
"completed without repeating the tool"
);
assert_eq!(executions.load(std::sync::atomic::Ordering::SeqCst), 1);
let requests = provider.requests.lock().unwrap();
assert_eq!(requests.len(), 3);
assert!(
requests[2]
.messages
.iter()
.any(|message| message.role == "tool")
);
}
#[tokio::test]
async fn initial_overflow_is_typed_for_session_checkpoint_recovery() {
let provider = Arc::new(AlwaysOverflowProvider {
requests: std::sync::atomic::AtomicUsize::new(0),
});
let agent = AgentLoop::with_provider(
provider.clone(),
2,
"always-overflow".to_string(),
PathBuf::from("."),
Vec::new(),
);
let error = agent
.process(vec![ChatMessage::user("current turn")])
.await
.unwrap_err();
assert!(matches!(
error,
AgentError::ContextOverflow {
parsed_window: Some(4_096),
tool_progress: false,
..
}
));
assert_eq!(
provider.requests.load(std::sync::atomic::Ordering::SeqCst),
1
);
}
impl TestObserver {
fn new() -> Self {
Self {
@ -3033,15 +2562,6 @@ mod tests {
pub enum AgentError {
ProviderCreation(String),
LlmError(String),
/// The Provider rejected a request because it exceeded its context
/// window. `tool_progress` is true once this invocation has executed a
/// tool, in which case Session must never restart the Turn from durable
/// history because doing so could repeat side effects.
ContextOverflow {
message: String,
parsed_window: Option<usize>,
tool_progress: bool,
},
/// The run was cancelled by `/stop`, a parent run, timeout ownership or
/// shutdown. Terminal state must be decided by this variant, never by
/// matching error strings.
@ -3062,9 +2582,6 @@ impl std::fmt::Display for AgentError {
match self {
AgentError::ProviderCreation(e) => write!(f, "Provider creation error: {}", e),
AgentError::LlmError(e) => write!(f, "LLM error: {}", e),
AgentError::ContextOverflow { message, .. } => {
write!(f, "LLM context overflow: {message}")
}
AgentError::Cancelled => write!(f, "agent run cancelled"),
AgentError::TimedOut => write!(f, "agent run timed out"),
AgentError::Other(e) => write!(f, "{}", e),

View File

@ -4,7 +4,6 @@ use std::sync::Arc;
use crate::config::{
AgentOrchestrationConfig, LLMProviderConfig, ModelConfig, ProviderConfig, expand_path,
resolve_token_limit,
};
use crate::skills::SkillsLoader;
use crate::tools::ToolRegistry;
@ -428,7 +427,7 @@ fn resolve_provider(
max_tokens: model.max_tokens,
model_extra: model.extra.clone(),
max_tool_iterations: spec.max_tool_iterations.unwrap_or(99),
token_limit: resolve_token_limit(spec.token_limit, model.token_limit),
token_limit: spec.token_limit.unwrap_or(128_000),
workspace_dir: workspace_dir.to_path_buf(),
input_types: model.input_type.clone(),
price_input_per_million: None,
@ -436,14 +435,13 @@ fn resolve_provider(
});
}
let profile = spec.llm_profile.as_deref().unwrap_or_default();
let mut resolved = provider_profiles.get(profile).cloned().ok_or_else(|| {
AgentCatalogError::UnknownProfile {
provider_profiles
.get(profile)
.cloned()
.ok_or_else(|| AgentCatalogError::UnknownProfile {
agent: spec.id.clone(),
profile: profile.to_string(),
}
})?;
resolved.token_limit = resolve_token_limit(spec.token_limit, Some(resolved.token_limit));
Ok(resolved)
})
}
fn validate_definition_tools(
@ -641,150 +639,6 @@ mod tests {
assert_eq!(catalog.runtime_generation(), 7);
}
#[test]
fn inline_agent_context_window_uses_agent_model_minimum_then_default() {
let providers = HashMap::from([(
"provider".to_string(),
ProviderConfig {
provider_type: "openai".to_string(),
base_url: "https://example.invalid/v1".to_string(),
api_key: "test".to_string(),
extra_headers: HashMap::new(),
},
)]);
let mut models = HashMap::from([(
"model".to_string(),
ModelConfig {
model_id: "model-id".to_string(),
temperature: None,
max_tokens: None,
token_limit: Some(64_000),
input_type: vec!["text".to_string()],
extra: HashMap::new(),
},
)]);
let mut spec = ProviderSpec {
id: "inline".to_string(),
llm_profile: None,
provider: Some("provider".to_string()),
model: Some("model".to_string()),
token_limit: None,
max_tool_iterations: None,
enabled: true,
};
assert_eq!(
resolve_provider(
&spec,
&HashMap::new(),
&providers,
&models,
Path::new("/tmp")
)
.unwrap()
.token_limit,
64_000
);
spec.token_limit = Some(32_000);
assert_eq!(
resolve_provider(
&spec,
&HashMap::new(),
&providers,
&models,
Path::new("/tmp")
)
.unwrap()
.token_limit,
32_000
);
spec.token_limit = Some(128_000);
assert_eq!(
resolve_provider(
&spec,
&HashMap::new(),
&providers,
&models,
Path::new("/tmp")
)
.unwrap()
.token_limit,
64_000
);
spec.token_limit = None;
models.get_mut("model").unwrap().token_limit = None;
assert_eq!(
resolve_provider(
&spec,
&HashMap::new(),
&providers,
&models,
Path::new("/tmp")
)
.unwrap()
.token_limit,
128_000
);
}
#[test]
fn profile_agent_context_window_can_only_narrow_resolved_profile() {
let profiles = HashMap::from([("profile".to_string(), provider())]);
let mut spec = ProviderSpec {
id: "profile-agent".to_string(),
llm_profile: Some("profile".to_string()),
provider: None,
model: None,
token_limit: None,
max_tool_iterations: None,
enabled: true,
};
assert_eq!(
resolve_provider(
&spec,
&profiles,
&HashMap::new(),
&HashMap::new(),
Path::new("/tmp")
)
.unwrap()
.token_limit,
4_096
);
spec.token_limit = Some(2_048);
assert_eq!(
resolve_provider(
&spec,
&profiles,
&HashMap::new(),
&HashMap::new(),
Path::new("/tmp")
)
.unwrap()
.token_limit,
2_048
);
spec.token_limit = Some(8_192);
assert_eq!(
resolve_provider(
&spec,
&profiles,
&HashMap::new(),
&HashMap::new(),
Path::new("/tmp")
)
.unwrap()
.token_limit,
4_096
);
}
#[test]
fn delegation_semantics_default_empty_any_and_list() {
let root = tempfile::tempdir().unwrap();

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
pub mod agent_loop;
pub mod builtin;
pub mod catalog;
pub mod context_compaction;
pub mod context_compressor;
pub mod coordinator;
pub mod definition;
pub mod gate;
@ -16,11 +16,7 @@ pub mod turn_event;
pub use agent_loop::{AgentError, AgentLoop, AgentProcessResult};
pub use catalog::{AgentCatalog, AgentCatalogError, CatalogEntryError};
pub use context_compaction::{
CompactionCandidate, CompactionReason, ContextBudget, ContextBudgetParts, ContextCompactor,
ContextRequestKey, ContextUsageTracker, PreviousCheckpoint, SequencedMessage,
context_request_digest, estimate_tokens,
};
pub use context_compressor::{ContextCompressor, estimate_tokens};
pub use coordinator::{AgentCoordinator, CoordinatorError};
pub use definition::{AgentDefinition, AgentLimits};
pub use gate::ExecutionGate;

View File

@ -66,8 +66,6 @@ pub struct Config {
pub browser: BrowserConfig,
#[serde(default)]
pub agent_orchestration: AgentOrchestrationConfig,
#[serde(default)]
pub context_compaction: ContextCompactionConfig,
}
fn default_workspace_dir() -> String {
@ -159,9 +157,6 @@ pub struct ModelConfig {
pub temperature: Option<f32>,
#[serde(default)]
pub max_tokens: Option<u32>,
/// Model context-window capacity. Agent profiles may narrow it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_limit: Option<usize>,
#[serde(default = "default_input_type")]
pub input_type: Vec<String>,
#[serde(flatten)]
@ -178,10 +173,8 @@ pub struct AgentConfig {
pub model: String,
#[serde(default = "default_max_tool_iterations")]
pub max_tool_iterations: usize,
/// Optional per-Agent context-window cap. It may narrow but never expand
/// the selected model's limit; a missing model limit falls back to 128K.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_limit: Option<usize>,
#[serde(default = "default_token_limit")]
pub token_limit: usize,
}
fn default_max_tool_iterations() -> usize {
@ -192,39 +185,6 @@ fn default_token_limit() -> usize {
128_000
}
/// Resolve the effective context window. Agent values are optional caps: they
/// may narrow a model window but can never expand it. A missing model window
/// is the product's conservative 128K default.
pub(crate) fn resolve_token_limit(
agent_token_limit: Option<usize>,
model_token_limit: Option<usize>,
) -> usize {
let model_token_limit = model_token_limit.unwrap_or_else(default_token_limit);
agent_token_limit.map_or(model_token_limit, |agent| agent.min(model_token_limit))
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct ContextCompactionConfig {
/// Controls proactive Turn-boundary compaction only. Manual compaction and
/// overflow recovery remain available when this is false.
pub enabled: bool,
/// Input headroom reserved for model output and request-estimation error.
pub reserve_tokens: usize,
/// Approximate number of recent history tokens retained verbatim.
pub keep_recent_tokens: usize,
}
impl Default for ContextCompactionConfig {
fn default() -> Self {
Self {
enabled: true,
reserve_tokens: 16_384,
keep_recent_tokens: 20_000,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct AgentOrchestrationConfig {
@ -931,7 +891,7 @@ impl Config {
max_tokens: model.max_tokens,
model_extra: model.extra.clone(),
max_tool_iterations: agent.max_tool_iterations,
token_limit: resolve_token_limit(agent.token_limit, model.token_limit),
token_limit: agent.token_limit,
workspace_dir: expand_path(&self.workspace_dir),
input_types: model.input_type.clone(),
price_input_per_million: None,
@ -1209,38 +1169,6 @@ mod tests {
assert_eq!(provider_config.name, "aliyun");
assert_eq!(provider_config.model_id, "qwen-plus");
assert_eq!(provider_config.temperature, Some(0.0));
assert_eq!(provider_config.token_limit, 128_000);
}
#[test]
fn context_window_uses_agent_model_minimum_then_128k_default() {
let file = write_test_config();
let mut config = Config::load(file.path().to_str().unwrap()).unwrap();
config.models.get_mut("qwen-plus").unwrap().token_limit = Some(64_000);
assert_eq!(
config.get_provider_config("default").unwrap().token_limit,
64_000
);
config.agents.get_mut("default").unwrap().token_limit = Some(32_000);
assert_eq!(
config.get_provider_config("default").unwrap().token_limit,
32_000
);
config.agents.get_mut("default").unwrap().token_limit = Some(128_000);
assert_eq!(
config.get_provider_config("default").unwrap().token_limit,
64_000
);
config.agents.get_mut("default").unwrap().token_limit = None;
config.models.get_mut("qwen-plus").unwrap().token_limit = None;
assert_eq!(
config.get_provider_config("default").unwrap().token_limit,
128_000
);
}
#[test]
@ -1265,18 +1193,6 @@ mod tests {
.profile_dir
.ends_with("browser/profiles")
);
assert!(config.context_compaction.enabled);
assert_eq!(config.context_compaction.reserve_tokens, 16_384);
assert_eq!(config.context_compaction.keep_recent_tokens, 20_000);
}
#[test]
fn context_compaction_config_is_strict_and_defaults_are_stable() {
let config: ContextCompactionConfig = serde_json::from_str("{}").unwrap();
assert!(config.enabled);
assert_eq!(config.reserve_tokens, 16_384);
assert_eq!(config.keep_recent_tokens, 20_000);
assert!(serde_json::from_str::<ContextCompactionConfig>(r#"{"unknown":1}"#).is_err());
}
#[test]

View File

@ -1013,13 +1013,7 @@ pub async fn get_agent_options(
.config
.models
.iter()
.map(|(name, model)| {
json!({
"name": name,
"model_id": model.model_id,
"token_limit": model.token_limit,
})
})
.map(|(name, model)| json!({ "name": name, "model_id": model.model_id }))
.collect();
let registry = state.session_manager.tools();
let mut tools: Vec<Value> = registry

View File

@ -212,7 +212,6 @@ impl GatewayState {
// Create SessionManager with bus injection
let session_manager = SessionManager::new(
provider_config.clone(),
config.context_compaction.clone(),
AgentCatalogPreparation {
provider_profiles,
providers: config.providers.clone(),

View File

@ -629,21 +629,12 @@ mod tests {
context: crate::session::ContextUsage {
configured_window_tokens: 128_000,
effective_window_tokens: 128_000,
configured_reserve_tokens: 16_384,
reserve_tokens: 16_384,
configured_keep_recent_tokens: 20_000,
effective_keep_recent_tokens: 20_000,
used_tokens: 100,
remaining_tokens: 127_900,
compression_threshold_tokens: 111_616,
source: crate::session::ContextUsageSource::Observed,
compression_threshold_tokens: 89_600,
source: crate::session::ContextUsageSource::Hybrid,
last_observed_prompt_tokens: Some(90),
observed_at: Some(1),
active_checkpoint_id: None,
checkpoint_generation: 0,
checkpoint_tokens_before: None,
checkpoint_tokens_after: None,
checkpoint_degraded: false,
},
created_at: 1,
last_active_at: 2,
@ -651,7 +642,7 @@ mod tests {
};
let value = serde_json::to_value(WsOutbound::SessionStats { stats }).unwrap();
assert_eq!(value["type"], "session_stats");
assert_eq!(value["stats"]["context"]["source"], "observed");
assert_eq!(value["stats"]["context"]["source"], "hybrid");
assert_eq!(value["stats"]["lifetime_usage"]["input_tokens"], 100);
}

View File

@ -284,10 +284,7 @@ mod tests {
None,
String::new(),
"test".to_string(),
super::super::session::SessionContextServices {
memory_manager,
compaction_config: crate::config::ContextCompactionConfig::default(),
},
memory_manager,
)
.await
.unwrap(),

File diff suppressed because it is too large Load Diff

View File

@ -30,34 +30,25 @@ pub struct LifetimeUsage {
pub struct ContextUsage {
pub configured_window_tokens: u64,
pub effective_window_tokens: u64,
pub configured_reserve_tokens: u64,
pub reserve_tokens: u64,
pub configured_keep_recent_tokens: u64,
pub effective_keep_recent_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>,
pub active_checkpoint_id: Option<String>,
pub checkpoint_generation: u64,
pub checkpoint_tokens_before: Option<u64>,
pub checkpoint_tokens_after: Option<u64>,
pub checkpoint_degraded: bool,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ContextUsageSource {
Observed,
Hybrid,
Estimated,
}
impl ContextUsageSource {
pub fn label(self) -> &'static str {
match self {
Self::Observed => "Provider 实测",
Self::Hybrid => "混合估算",
Self::Estimated => "字符估算",
}
}
@ -87,35 +78,9 @@ impl SessionStats {
.last_observed_prompt_tokens
.map(format_tokens)
.unwrap_or_else(|| "".to_string());
let checkpoint = self
.context
.active_checkpoint_id
.as_deref()
.map(|id| {
let before = self
.context
.checkpoint_tokens_before
.map(format_tokens)
.unwrap_or_else(|| "".to_string());
let after = self
.context
.checkpoint_tokens_after
.map(format_tokens)
.unwrap_or_else(|| "".to_string());
let degraded = if self.context.checkpoint_degraded {
"overflow 降级"
} else {
""
};
format!(
"{}generation {}{} → {}{}",
id, self.context.checkpoint_generation, before, after, degraded
)
})
.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 预留 {}(配置 {}\n 近期保留 {}(配置 {}\n 自动压缩阈值 {}\n 最近实测 {}\n Checkpoint {}",
"会话\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,
@ -136,13 +101,8 @@ impl SessionStats {
format_tokens(self.context.effective_window_tokens),
percent,
format_tokens(self.context.remaining_tokens),
format_tokens(self.context.reserve_tokens),
format_tokens(self.context.configured_reserve_tokens),
format_tokens(self.context.effective_keep_recent_tokens),
format_tokens(self.context.configured_keep_recent_tokens),
format_tokens(self.context.compression_threshold_tokens),
observed,
checkpoint,
)
}
}

View File

@ -1,5 +1,6 @@
use std::sync::Arc;
use crate::agent::ContextCompressor;
use crate::agent::system_prompt::build_runtime_context;
use crate::bus::ChatMessage;
use crate::memory::{MemoryCategory, MemoryManager};
@ -10,8 +11,6 @@ use crate::work::WorkManager;
pub(super) struct TurnRuntimeContext {
system_prompt: String,
runtime_context: String,
memory_tokens: usize,
active_plan_tokens: usize,
}
impl TurnRuntimeContext {
@ -26,25 +25,30 @@ impl TurnRuntimeContext {
}
history
}
pub(super) fn budget_hints(&self) -> (usize, usize) {
(self.memory_tokens, self.active_plan_tokens)
}
}
/// Builds runtime-only context outside the Session lock. Durable history
/// projection and compaction are orchestrated by SessionManager after these
/// variable-size sources are known, so the budget covers the complete request.
pub(super) async fn prepare_turn_runtime(
pub(super) struct PreparedTurnInput {
pub(super) messages: Vec<ChatMessage>,
pub(super) runtime: TurnRuntimeContext,
pub(super) created_timelines: bool,
}
/// Builds the complete cross-turn provider input outside the Session lock.
/// Independent context sources and compression are fetched concurrently.
pub(super) async fn prepare_turn_input(
memory_manager: Arc<MemoryManager>,
work_manager: Arc<WorkManager>,
session_id: &str,
query: &str,
system_prompt: String,
) -> TurnRuntimeContext {
compressor: &mut ContextCompressor,
history: Vec<ChatMessage>,
) -> PreparedTurnInput {
let memory_future = memory_manager.recall(query, 5, Some(MemoryCategory::Knowledge), None);
let work_future = work_manager.active_plan(session_id);
let (memory_result, work_result) = tokio::join!(memory_future, work_future);
let compression_future = compressor.compress_if_needed(history.clone());
let (memory_result, work_result, compression_result) =
tokio::join!(memory_future, work_future, compression_future);
let memory_context = match memory_result {
Ok(entries) if !entries.is_empty() => Some(
@ -68,21 +72,29 @@ pub(super) async fn prepare_turn_runtime(
None
}
};
TurnRuntimeContext {
let compression = match compression_result {
Ok(result) => result,
Err(error) => {
tracing::warn!(error = %error, "Context compression failed while preparing turn input");
crate::agent::context_compressor::CompressionResult {
history,
created_timelines: false,
}
}
};
let runtime = TurnRuntimeContext {
system_prompt,
memory_tokens: memory_context
.as_deref()
.map(crate::agent::context_compaction::estimate_text_tokens)
.unwrap_or_default(),
active_plan_tokens: work_context
.as_deref()
.map(crate::agent::context_compaction::estimate_text_tokens)
.unwrap_or_default(),
runtime_context: build_runtime_context(
Some(session_id),
memory_context.as_deref(),
work_context.as_deref(),
),
};
PreparedTurnInput {
messages: runtime.assemble(compression.history),
runtime,
created_timelines: compression.created_timelines,
}
}
@ -106,8 +118,6 @@ mod tests {
let runtime = TurnRuntimeContext {
system_prompt: "system".to_string(),
runtime_context: "runtime".to_string(),
memory_tokens: 0,
active_plan_tokens: 0,
};
let messages = runtime.assemble(vec![
ChatMessage::user("old"),
@ -125,8 +135,6 @@ mod tests {
let runtime = TurnRuntimeContext {
system_prompt: "system".to_string(),
runtime_context: "runtime".to_string(),
memory_tokens: 0,
active_plan_tokens: 0,
};
let first = runtime.assemble(vec![ChatMessage::user("question")]);

View File

@ -1169,13 +1169,13 @@ mod tests {
}
#[tokio::test]
async fn fresh_database_creates_current_agent_tables() {
async fn fresh_database_creates_schema_v9_agent_tables() {
let (storage, _dir) = create_test_storage().await;
let version: i64 = sqlx::query_scalar("PRAGMA user_version")
.fetch_one(storage.pool())
.await
.unwrap();
assert_eq!(version, 10);
assert_eq!(version, 9);
for table in [
"agent_runs",
"agent_session_state",

View File

@ -1,325 +0,0 @@
use sqlx::Row;
use super::{Storage, StorageError};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContextCheckpoint {
pub id: String,
pub session_id: String,
pub generation: i64,
pub parent_checkpoint_id: Option<String>,
pub summary: String,
pub first_retained_seq: i64,
pub source_max_seq: i64,
pub trigger_reason: String,
pub provider_kind: String,
pub model: String,
pub tokens_before: i64,
pub tokens_after: i64,
pub degraded: bool,
pub created_at: i64,
}
#[derive(Debug, Clone)]
pub struct NewContextCheckpoint {
pub id: String,
pub parent_checkpoint_id: Option<String>,
pub summary: String,
pub first_retained_seq: i64,
pub source_max_seq: i64,
pub trigger_reason: String,
pub provider_kind: String,
pub model: String,
pub tokens_before: i64,
pub tokens_after: i64,
pub degraded: bool,
pub created_at: i64,
}
#[derive(Debug, Clone)]
pub struct ContextCheckpointState {
pub generation: i64,
pub active_checkpoint_id: Option<String>,
pub checkpoint: Option<ContextCheckpoint>,
}
impl Storage {
pub async fn load_context_checkpoint_state(
&self,
session_id: &str,
) -> Result<ContextCheckpointState, StorageError> {
let session = sqlx::query(
r#"
SELECT s.context_generation,
s.active_context_checkpoint_id,
c.id AS checkpoint_id,
c.session_id AS checkpoint_session_id,
c.generation AS checkpoint_generation,
c.parent_checkpoint_id,
c.summary,
c.first_retained_seq,
c.source_max_seq,
c.trigger_reason,
c.provider_kind,
c.model,
c.tokens_before,
c.tokens_after,
c.degraded,
c.created_at
FROM sessions s
LEFT JOIN context_checkpoints c
ON c.id = s.active_context_checkpoint_id
AND c.session_id = s.id
WHERE s.id = ?
"#,
)
.bind(session_id)
.fetch_optional(self.pool())
.await?
.ok_or_else(|| StorageError::NotFound(session_id.to_string()))?;
let generation = session.get("context_generation");
let active_checkpoint_id: Option<String> = session.get("active_context_checkpoint_id");
let checkpoint_id: Option<String> = session.get("checkpoint_id");
let checkpoint = checkpoint_id.map(|id| ContextCheckpoint {
id,
session_id: session.get("checkpoint_session_id"),
generation: session.get("checkpoint_generation"),
parent_checkpoint_id: session.get("parent_checkpoint_id"),
summary: session.get("summary"),
first_retained_seq: session.get("first_retained_seq"),
source_max_seq: session.get("source_max_seq"),
trigger_reason: session.get("trigger_reason"),
provider_kind: session.get("provider_kind"),
model: session.get("model"),
tokens_before: session.get("tokens_before"),
tokens_after: session.get("tokens_after"),
degraded: session.get("degraded"),
created_at: session.get("created_at"),
});
Ok(ContextCheckpointState {
generation,
active_checkpoint_id,
checkpoint,
})
}
pub async fn commit_context_checkpoint(
&self,
session_id: &str,
expected_generation: i64,
checkpoint: &NewContextCheckpoint,
) -> Result<ContextCheckpoint, StorageError> {
if checkpoint.summary.trim().is_empty() {
return Err(StorageError::Serialization(
"context checkpoint summary cannot be empty".to_string(),
));
}
if checkpoint.first_retained_seq < 1
|| checkpoint.source_max_seq < checkpoint.first_retained_seq
{
return Err(StorageError::Serialization(
"context checkpoint sequence boundary is invalid".to_string(),
));
}
let generation = expected_generation.saturating_add(1);
let mut tx = self.pool().begin().await?;
let updated = sqlx::query(
r#"
UPDATE sessions
SET active_context_checkpoint_id = ?,
context_generation = context_generation + 1,
last_compressed_message_at = ?
WHERE id = ? AND context_generation = ? AND deleted_at IS NULL
"#,
)
.bind(&checkpoint.id)
.bind(checkpoint.created_at)
.bind(session_id)
.bind(expected_generation)
.execute(&mut *tx)
.await?;
if updated.rows_affected() != 1 {
tx.rollback().await?;
return Err(StorageError::Conflict(format!(
"stale context checkpoint generation for session {session_id}"
)));
}
sqlx::query(
r#"
INSERT INTO context_checkpoints (
id, session_id, generation, parent_checkpoint_id, summary,
first_retained_seq, source_max_seq, trigger_reason,
provider_kind, model, tokens_before, tokens_after,
degraded, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"#,
)
.bind(&checkpoint.id)
.bind(session_id)
.bind(generation)
.bind(&checkpoint.parent_checkpoint_id)
.bind(&checkpoint.summary)
.bind(checkpoint.first_retained_seq)
.bind(checkpoint.source_max_seq)
.bind(&checkpoint.trigger_reason)
.bind(&checkpoint.provider_kind)
.bind(&checkpoint.model)
.bind(checkpoint.tokens_before)
.bind(checkpoint.tokens_after)
.bind(checkpoint.degraded)
.bind(checkpoint.created_at)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(ContextCheckpoint {
id: checkpoint.id.clone(),
session_id: session_id.to_string(),
generation,
parent_checkpoint_id: checkpoint.parent_checkpoint_id.clone(),
summary: checkpoint.summary.clone(),
first_retained_seq: checkpoint.first_retained_seq,
source_max_seq: checkpoint.source_max_seq,
trigger_reason: checkpoint.trigger_reason.clone(),
provider_kind: checkpoint.provider_kind.clone(),
model: checkpoint.model.clone(),
tokens_before: checkpoint.tokens_before,
tokens_after: checkpoint.tokens_after,
degraded: checkpoint.degraded,
created_at: checkpoint.created_at,
})
}
pub async fn invalidate_context_checkpoint(
&self,
session_id: &str,
) -> Result<(), StorageError> {
let updated = sqlx::query(
r#"
UPDATE sessions
SET active_context_checkpoint_id = NULL,
context_generation = context_generation + 1,
last_compressed_message_at = NULL
WHERE id = ?
"#,
)
.bind(session_id)
.execute(self.pool())
.await?;
if updated.rows_affected() != 1 {
return Err(StorageError::NotFound(session_id.to_string()));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
async fn test_storage() -> (Storage, tempfile::TempDir) {
let directory = tempfile::tempdir().unwrap();
let storage = Storage::new(&directory.path().join("checkpoint.db"))
.await
.unwrap();
sqlx::query(
r#"
INSERT INTO sessions (
id, channel, chat_id, dialog_id, title, created_at, last_active_at
) VALUES ('session', 'cli', 'chat', 'dialog', 'checkpoint', 1, 1)
"#,
)
.execute(storage.pool())
.await
.unwrap();
for seq in 1..=4 {
sqlx::query(
"INSERT INTO messages (id, session_id, seq, role, content, created_at) VALUES (?, 'session', ?, 'user', ?, ?)",
)
.bind(format!("message-{seq}"))
.bind(seq)
.bind(format!("message {seq}"))
.bind(seq)
.execute(storage.pool())
.await
.unwrap();
}
(storage, directory)
}
fn checkpoint(id: &str) -> NewContextCheckpoint {
NewContextCheckpoint {
id: id.to_string(),
parent_checkpoint_id: None,
summary: "durable summary".to_string(),
first_retained_seq: 2,
source_max_seq: 4,
trigger_reason: "manual".to_string(),
provider_kind: "test".to_string(),
model: "test-model".to_string(),
tokens_before: 100,
tokens_after: 25,
degraded: false,
created_at: 10,
}
}
#[tokio::test]
async fn checkpoint_commit_is_cas_guarded_and_clear_invalidates_projection() {
let (storage, _directory) = test_storage().await;
let committed = storage
.commit_context_checkpoint("session", 0, &checkpoint("cp-1"))
.await
.unwrap();
assert_eq!(committed.generation, 1);
let state = storage
.load_context_checkpoint_state("session")
.await
.unwrap();
assert_eq!(state.generation, 1);
assert_eq!(state.active_checkpoint_id.as_deref(), Some("cp-1"));
assert_eq!(state.checkpoint, Some(committed));
let raw_count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM messages WHERE session_id = 'session'")
.fetch_one(storage.pool())
.await
.unwrap();
assert_eq!(raw_count, 4);
sqlx::query(
"INSERT INTO messages (id, session_id, seq, role, content, created_at) VALUES ('message-5', 'session', 5, 'assistant', 'tail', 5)",
)
.execute(storage.pool())
.await
.unwrap();
assert_eq!(storage.get_max_message_seq("session").await.unwrap(), 5);
let stale = storage
.commit_context_checkpoint("session", 0, &checkpoint("cp-stale"))
.await
.unwrap_err();
assert!(matches!(stale, StorageError::Conflict(_)));
storage.clear_messages("session").await.unwrap();
let cleared = storage
.load_context_checkpoint_state("session")
.await
.unwrap();
assert_eq!(cleared.generation, 2);
assert!(cleared.active_checkpoint_id.is_none());
assert!(cleared.checkpoint.is_none());
let retained_audit_rows: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM context_checkpoints WHERE session_id = 'session'",
)
.fetch_one(storage.pool())
.await
.unwrap();
assert_eq!(retained_audit_rows, 1);
}
}

View File

@ -1,6 +1,5 @@
pub mod agent_inbox;
pub mod agent_run;
pub mod context_checkpoint;
pub mod error;
pub mod memory;
pub mod message;
@ -8,7 +7,6 @@ pub mod scheduler;
pub mod session;
pub mod usage;
pub use context_checkpoint::{ContextCheckpoint, ContextCheckpointState, NewContextCheckpoint};
pub use error::StorageError;
pub use scheduler::{DeliveryPolicy, JobKind, JobRun, ScheduledJob};
pub use usage::{SessionUsageTotals, TurnUsageRecord};
@ -20,7 +18,7 @@ use sqlx::{Pool, Row, Sqlite};
use std::path::Path;
use tokio::time::{Duration, sleep};
const SCHEMA_VERSION: i64 = 10;
const SCHEMA_VERSION: i64 = 9;
const INSERT_MESSAGE_SQL: &str = r#"
INSERT INTO messages (
id, session_id, seq, role, content, reasoning_content, provider_state,
@ -137,8 +135,6 @@ impl Storage {
deleted_at INTEGER,
last_consolidated_at INTEGER,
last_compressed_message_at INTEGER,
active_context_checkpoint_id TEXT,
context_generation INTEGER NOT NULL DEFAULT 0,
delivery_context TEXT,
delivery_context_updated_at INTEGER,
UNIQUE(channel, chat_id, dialog_id)
@ -148,36 +144,6 @@ impl Storage {
.execute(&self.pool)
.await?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS context_checkpoints (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
generation INTEGER NOT NULL,
parent_checkpoint_id TEXT,
summary TEXT NOT NULL,
first_retained_seq INTEGER NOT NULL,
source_max_seq INTEGER NOT NULL,
trigger_reason TEXT NOT NULL,
provider_kind TEXT NOT NULL,
model TEXT NOT NULL,
tokens_before INTEGER NOT NULL,
tokens_after INTEGER NOT NULL,
degraded INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
FOREIGN KEY(session_id) REFERENCES sessions(id) ON DELETE CASCADE,
UNIQUE(session_id, generation)
)
"#,
)
.execute(&self.pool)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_context_checkpoints_session_created ON context_checkpoints(session_id, created_at DESC)",
)
.execute(&self.pool)
.await?;
sqlx::query(
r#"
CREATE INDEX IF NOT EXISTS idx_sessions_chat
@ -490,16 +456,6 @@ impl Storage {
"last_compressed_message_at",
"last_compressed_message_at INTEGER",
),
(
"sessions",
"active_context_checkpoint_id",
"active_context_checkpoint_id TEXT",
),
(
"sessions",
"context_generation",
"context_generation INTEGER NOT NULL DEFAULT 0",
),
("sessions", "delivery_context", "delivery_context TEXT"),
(
"sessions",
@ -597,35 +553,6 @@ impl Storage {
)
.execute(&mut *tx)
.await?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS context_checkpoints (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
generation INTEGER NOT NULL,
parent_checkpoint_id TEXT,
summary TEXT NOT NULL,
first_retained_seq INTEGER NOT NULL,
source_max_seq INTEGER NOT NULL,
trigger_reason TEXT NOT NULL,
provider_kind TEXT NOT NULL,
model TEXT NOT NULL,
tokens_before INTEGER NOT NULL,
tokens_after INTEGER NOT NULL,
degraded INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
FOREIGN KEY(session_id) REFERENCES sessions(id) ON DELETE CASCADE,
UNIQUE(session_id, generation)
)
"#,
)
.execute(&mut *tx)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_context_checkpoints_session_created ON context_checkpoints(session_id, created_at DESC)",
)
.execute(&mut *tx)
.await?;
for statement in agent_run::AGENT_SCHEMA_STATEMENTS {
sqlx::query(*statement).execute(&mut *tx).await?;
}
@ -1485,28 +1412,10 @@ impl Storage {
}
pub async fn clear_messages(&self, session_id: &str) -> Result<(), StorageError> {
let mut tx = self.pool.begin().await?;
sqlx::query(r#"DELETE FROM messages WHERE session_id = ?"#)
.bind(session_id)
.execute(&mut *tx)
.execute(self.pool())
.await?;
let updated = sqlx::query(
r#"
UPDATE sessions
SET active_context_checkpoint_id = NULL,
context_generation = context_generation + 1,
last_compressed_message_at = NULL
WHERE id = ?
"#,
)
.bind(session_id)
.execute(&mut *tx)
.await?;
if updated.rows_affected() != 1 {
tx.rollback().await?;
return Err(StorageError::NotFound(session_id.to_string()));
}
tx.commit().await?;
Ok(())
}
@ -1852,8 +1761,6 @@ mod tests {
"archived_at",
"last_consolidated_at",
"last_compressed_message_at",
"active_context_checkpoint_id",
"context_generation",
"delivery_context",
"delivery_context_updated_at",
],
@ -1888,7 +1795,6 @@ mod tests {
"agent_runs",
"agent_session_state",
"agent_inbox_events",
"context_checkpoints",
] {
let exists: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?",
@ -2030,7 +1936,7 @@ mod tests {
let run = storage.get_agent_run("run-1").await.unwrap();
assert!(
run.is_some(),
"v8 agent run must survive the current upgrade without a rebuild"
"v8 agent run must survive the v9 upgrade without a rebuild"
);
assert_eq!(run.unwrap().status.as_str(), "completed");
@ -2038,7 +1944,7 @@ mod tests {
.fetch_one(storage.pool())
.await
.unwrap();
assert_eq!(version, SCHEMA_VERSION);
assert_eq!(version, 9);
let exists: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'agent_run_messages'",
)

View File

@ -1,12 +1,12 @@
{
"name": "picobot-webui",
"version": "1.20.0",
"version": "1.18.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picobot-webui",
"version": "1.20.0",
"version": "1.18.0",
"dependencies": {
"bits-ui": "^2.0.0",
"dompurify": "^3.4.12",

View File

@ -1,7 +1,7 @@
{
"name": "picobot-webui",
"private": true,
"version": "1.20.0",
"version": "1.18.0",
"type": "module",
"engines": {
"node": ">=20"

View File

@ -12,11 +12,7 @@
? context.used_tokens / context.effective_window_tokens * 100
: 0);
const boundedPercent = $derived(Math.max(0, Math.min(percent, 100)));
const thresholdPercent = $derived(context?.effective_window_tokens
? context.compression_threshold_tokens / context.effective_window_tokens * 100
: 100);
const boundedThresholdPercent = $derived(Math.max(0, Math.min(thresholdPercent, 100)));
const pressure = $derived(percent >= 100 ? "danger" : percent > thresholdPercent ? "warning" : "normal");
const pressure = $derived(percent >= 90 ? "danger" : percent >= 70 ? "warning" : "normal");
function compactTokens(value) {
if (!Number.isFinite(value)) return "—";
@ -30,7 +26,7 @@
}
function sourceLabel(value) {
return value === "observed" ? "Provider 实测" : "字符估算";
return value === "hybrid" ? "混合估算" : "字符估算";
}
function trackedSince(value) {
@ -57,7 +53,7 @@
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow={Math.round(boundedPercent)}
style={`--context-fill: ${boundedPercent}%; --context-threshold: ${boundedThresholdPercent}%`}
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>
@ -78,17 +74,12 @@
<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}%; --context-threshold: ${boundedThresholdPercent}%`}><i></i><b></b></div>
<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.reserve_tokens)} / {exactTokens(context.configured_reserve_tokens)}</dd></div>
<div><dt>近期保留(有效 / 配置)</dt><dd>{exactTokens(context.effective_keep_recent_tokens)} / {exactTokens(context.configured_keep_recent_tokens)}</dd></div>
<div><dt>压缩阈值</dt><dd>{exactTokens(context.compression_threshold_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>
{#if context.active_checkpoint_id}
<div><dt>Checkpoint</dt><dd title={context.active_checkpoint_id}>#{context.checkpoint_generation} · {exactTokens(context.checkpoint_tokens_before)} {exactTokens(context.checkpoint_tokens_after)}{context.checkpoint_degraded ? " · 降级" : ""}</dd></div>
{/if}
</dl>
<div class="usage-heading"><span>会话累计</span><small>Provider 报告 · 已提交 Turns</small></div>
@ -118,7 +109,7 @@
.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 var(--context-threshold); width: 1px; background: var(--warning); }
.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); }

View File

@ -338,13 +338,13 @@
<label>Model
<select bind:value={editing.model}>
<option value="">(选择)</option>
{#each options.models as m (m.name)}<option value={m.name}>{m.name}{m.token_limit ? ` · ${m.token_limit} tokens` : " · 默认 128K"}</option>{/each}
{#each options.models as m (m.name)}<option value={m.name}>{m.name}</option>{/each}
</select>
</label>
</div>
<div class="form-row">
<label>token_limit(可选上限)
<input type="number" bind:value={editing.token_limit} placeholder="留空使用模型;不能超过模型上限" />
<label>token_limit
<input type="number" bind:value={editing.token_limit} placeholder="128000" />
</label>
<label>max_tool_iterations
<input type="number" bind:value={editing.max_tool_iterations} placeholder="99" />