Compare commits
2 Commits
ff3d0ef773
...
93e74120ac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93e74120ac | ||
|
|
2d7bd090fc |
@ -93,6 +93,8 @@ 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
|
- **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
|
- **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
|
- **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
|
- **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 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
|
- **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
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "picobot"
|
name = "picobot"
|
||||||
version = "1.18.0"
|
version = "1.20.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
14
README.md
14
README.md
@ -61,6 +61,7 @@ Gateway 首次启动时会把模板释放到 `~/.picobot/config.example.json`。
|
|||||||
"model_id": "gpt-4o",
|
"model_id": "gpt-4o",
|
||||||
"temperature": 0.7,
|
"temperature": 0.7,
|
||||||
"max_tokens": 4096,
|
"max_tokens": 4096,
|
||||||
|
"token_limit": 128000,
|
||||||
"input_type": ["text", "image"]
|
"input_type": ["text", "image"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -68,8 +69,7 @@ Gateway 首次启动时会把模板释放到 `~/.picobot/config.example.json`。
|
|||||||
"default": {
|
"default": {
|
||||||
"provider": "openai",
|
"provider": "openai",
|
||||||
"model": "gpt-4o",
|
"model": "gpt-4o",
|
||||||
"max_tool_iterations": 99,
|
"max_tool_iterations": 99
|
||||||
"token_limit": 128000
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"workspace_dir": "~/.picobot/workspace"
|
"workspace_dir": "~/.picobot/workspace"
|
||||||
@ -306,7 +306,7 @@ Session ID 使用三段式:
|
|||||||
| `/switch <dialog_id>` | 切换 dialog |
|
| `/switch <dialog_id>` | 切换 dialog |
|
||||||
| `/rename <title>` | 重命名当前 dialog |
|
| `/rename <title>` | 重命名当前 dialog |
|
||||||
| `/delete` | 删除当前 dialog 并创建新 dialog |
|
| `/delete` | 删除当前 dialog 并创建新 dialog |
|
||||||
| `/compact` | 手动压缩上下文 |
|
| `/compact` | 强制把可压缩的旧完整 Turn 汇总为活动 checkpoint;不改写原始历史 |
|
||||||
| `/info [--json]` | 查看当前 dialog、累计 Token 与上下文窗口信息;可选 JSON 输出 |
|
| `/info [--json]` | 查看当前 dialog、累计 Token 与上下文窗口信息;可选 JSON 输出 |
|
||||||
| `/dump` | 导出当前 dialog 为 Markdown |
|
| `/dump` | 导出当前 dialog 为 Markdown |
|
||||||
| `/mcp` | 查看 MCP 服务器和工具状态 |
|
| `/mcp` | 查看 MCP 服务器和工具状态 |
|
||||||
@ -326,7 +326,9 @@ PicoBot 有两类记忆:
|
|||||||
| Knowledge | 偏好、事实、项目规则、长期可复用信息 | 长期保留,手动删除 |
|
| Knowledge | 偏好、事实、项目规则、长期可复用信息 | 长期保留,手动删除 |
|
||||||
| Timeline | 长对话压缩后的历史摘要 | 默认保留 90 天 |
|
| Timeline | 长对话压缩后的历史摘要 | 默认保留 90 天 |
|
||||||
|
|
||||||
每轮处理用户消息时,MemoryManager 会按用户输入召回 Knowledge,并作为运行时上下文附加到本轮用户消息。当前召回上限固定为 5;`memory.recall_limit` 已支持解析但尚未接入 worker。上下文压缩产生的摘要会保存为 Timeline,后续可通过 `timeline_recall` 工具检索。Scheduler 默认创建一个每日维护巡检,按 `memory.timeline_retention_days` 清理过期 Timeline;Knowledge 不会被自动删除。
|
每轮处理用户消息时,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` 清理过期 Timeline;Knowledge 不会被自动删除。
|
||||||
|
|
||||||
|
模型的 `models.<name>.token_limit` 给出上下文窗口上限,未配置时默认为 128,000;Agent 的 `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 并重试当前模型步骤一次,不会从数据库历史重跑工具。
|
||||||
|
|
||||||
### 工具
|
### 工具
|
||||||
|
|
||||||
@ -374,6 +376,7 @@ Skill 是包含 `SKILL.md` 的目录。加载优先级从高到低:
|
|||||||
| `providers` | LLM Provider 配置 |
|
| `providers` | LLM Provider 配置 |
|
||||||
| `models` | 模型参数与输入能力 |
|
| `models` | 模型参数与输入能力 |
|
||||||
| `agents` | Agent 使用哪个 provider/model |
|
| `agents` | Agent 使用哪个 provider/model |
|
||||||
|
| `context_compaction` | 上下文自动压缩开关、预留 token 与近期原样保留量 |
|
||||||
| `agent_orchestration` | 具名子 Agent 定义目录与编排上限 |
|
| `agent_orchestration` | 具名子 Agent 定义目录与编排上限 |
|
||||||
| `gateway` | HTTP/WebSocket、数据库、调度器、后台任务限制 |
|
| `gateway` | HTTP/WebSocket、数据库、调度器、后台任务限制 |
|
||||||
| `client` | CLI 客户端默认 Gateway URL |
|
| `client` | CLI 客户端默认 Gateway URL |
|
||||||
@ -393,6 +396,9 @@ Skill 是包含 `SKILL.md` 的目录。加载优先级从高到低:
|
|||||||
| `gateway.max_concurrent_background_tasks` | `10` |
|
| `gateway.max_concurrent_background_tasks` | `10` |
|
||||||
| `gateway.scheduler.enabled` | `true` |
|
| `gateway.scheduler.enabled` | `true` |
|
||||||
| `client.gateway_url` | `ws://127.0.0.1:19876/ws` |
|
| `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.recall_limit` | `5`(当前运行时固定为 5) |
|
||||||
| `memory.timeline_retention_days` | `90` |
|
| `memory.timeline_retention_days` | `90` |
|
||||||
| `mcp.tool_timeout_secs` | `180` |
|
| `mcp.tool_timeout_secs` | `180` |
|
||||||
|
|||||||
@ -12,6 +12,7 @@
|
|||||||
"model_id": "qwen-plus",
|
"model_id": "qwen-plus",
|
||||||
"temperature": 0.0,
|
"temperature": 0.0,
|
||||||
"max_tokens": 100,
|
"max_tokens": 100,
|
||||||
|
"token_limit": 128000,
|
||||||
"input_type": ["text"]
|
"input_type": ["text"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -19,8 +20,7 @@
|
|||||||
"default": {
|
"default": {
|
||||||
"provider": "aliyun",
|
"provider": "aliyun",
|
||||||
"model": "qwen-plus",
|
"model": "qwen-plus",
|
||||||
"max_tool_iterations": 20,
|
"max_tool_iterations": 20
|
||||||
"token_limit": 128000
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"agent_orchestration": {
|
"agent_orchestration": {
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
本文档描述 PicoBot 当前实现的运行时边界、数据流、并发模型和演进约束。它面向维护者和后续参与改进的 Agent,是代码架构的主入口;行为细节仍以代码和测试为最终依据。
|
本文档描述 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)。具名子 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)。已实施的 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)。
|
||||||
|
|
||||||
## 1. 设计目标
|
## 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 附件只回放文本清单,避免把图片放到供应商不接受的角色。
|
当前 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 拼接逻辑,也不得丢失已经从 mailbox 取出的 steering。普通闲聊 session 没有 plan 摘要;计划状态由 `WorkManager` 从 SQLite 读取,因此不以自然语言摘要作为权威来源。`AgentLoop` 接收完整输入执行一次模型/工具循环,本身不拥有会话状态,但通过本 Turn 的 mailbox 在安全边界接收追加用户输入。执行工具时额外传递只包含 session/turn 身份的 `ToolExecutionContext`;无状态工具使用默认实现忽略它,有状态外部适配器用它路由资源,但可按明确的单用户配置跨 dialog 共享,且不能自行反向查询 SessionManager。
|
SessionManager 负责组装会话上下文:系统提示、Skills、召回的 Knowledge、可选的 active plan 摘要,以及由活动 `ContextCheckpoint` 投影出的会话历史。`messages` 始终是 append-only 原始日志;每个 Session 最多有一个活动 checkpoint,模型历史确定为“一条累计摘要 + `seq >= first_retained_seq` 的原始尾部”,Timeline 只是 checkpoint 提交后的 best-effort 检索副本,恢复流程不读取 Timeline、也不调用 Provider。所选 Model 的可选 `token_limit` 定义上下文硬上限,缺失时固定回退 128000;Agent 的可选 `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)。
|
||||||
|
|
||||||
当前 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 同时最多有一个标题任务,提交时仍校验标题保持默认值,避免覆盖用户改名。
|
当前 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。
|
- 5 秒 busy timeout。
|
||||||
- schema version 迁移。
|
- schema version 迁移。
|
||||||
|
|
||||||
持久化范围包括 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 时应:
|
持久化范围包括 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 时应:
|
||||||
|
|
||||||
1. 更新集中式 schema/迁移逻辑。
|
1. 更新集中式 schema/迁移逻辑。
|
||||||
2. 保留已有数据库的升级路径。
|
2. 保留已有数据库的升级路径。
|
||||||
@ -256,7 +256,7 @@ Turn delivery 使用 `spawn_graceful`。全局取消发生时先停止读取快
|
|||||||
|
|
||||||
### WebUI 与管理 API
|
### 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,窗口占用明确区分 API 基准上的混合估算与纯字符估算;斜杠命令补全通过 `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,窗口占用明确区分精确匹配的 Provider 实测与完整字符估算;斜杠命令补全通过 `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 上下文会为所有媒体注入内部路径清单,客户端响应不得暴露该路径。
|
WebUI/TUI 文件字节通过受鉴权的 HTTP 接口流式传输,WebSocket 只携带短期 `upload_id` 和结构化附件描述。`UploadRegistry` 在内存中按 `cli_chat` chat scope 校验并消费待发送上传;消息继续以 `media_refs` 保存 Gateway 本地路径,不建立永久附件资产。下载接口必须通过 client、session、message 和附件序号反查路径,不能接受客户端路径。历史附件路径失效属于正常状态,不得影响历史文本读取。待发送但未进入消息的上传由 `TaskSupervisor` 所有的限时清理任务回收。Agent 上下文会为所有媒体注入内部路径清单,客户端响应不得暴露该路径。
|
||||||
|
|
||||||
|
|||||||
1008
docs/CONTEXT_COMPACTION_DESIGN.md
Normal file
1008
docs/CONTEXT_COMPACTION_DESIGN.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
编写日期:2026-06-17
|
编写日期: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 当前已经具备最基础的记忆能力:
|
PicoBot 当前已经具备最基础的记忆能力:
|
||||||
|
|||||||
@ -23,7 +23,8 @@
|
|||||||
"qwen-plus": {
|
"qwen-plus": {
|
||||||
"model_id": "qwen-plus",
|
"model_id": "qwen-plus",
|
||||||
"temperature": 0.0,
|
"temperature": 0.0,
|
||||||
"max_tokens": 8192
|
"max_tokens": 8192,
|
||||||
|
"token_limit": 128000
|
||||||
},
|
},
|
||||||
"gpt-4o": {
|
"gpt-4o": {
|
||||||
"model_id": "gpt-4o",
|
"model_id": "gpt-4o",
|
||||||
@ -42,10 +43,14 @@
|
|||||||
"default": {
|
"default": {
|
||||||
"provider": "aliyun",
|
"provider": "aliyun",
|
||||||
"model": "qwen-plus",
|
"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": {
|
"gateway": {
|
||||||
"host": "127.0.0.1",
|
"host": "127.0.0.1",
|
||||||
"port": 19876,
|
"port": 19876,
|
||||||
|
|||||||
@ -77,11 +77,7 @@ Scheduler → SessionManager.handle_cron_message → AgentLoop → send_message
|
|||||||
|
|
||||||
## 上下文压缩
|
## 上下文压缩
|
||||||
|
|
||||||
当上下文接近 token 限制时触发:
|
`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 重跑工具。
|
||||||
|
|
||||||
1. **快速裁剪**:合并连续同角色消息,截断工具输出
|
|
||||||
2. **硬截断**:移除过老消息
|
|
||||||
3. 压缩后保留用户消息确保结构完整
|
|
||||||
|
|
||||||
## Skill 系统
|
## Skill 系统
|
||||||
|
|
||||||
@ -148,7 +144,7 @@ Worker 的处理原则:
|
|||||||
2. 释放锁后执行消息持久化、记忆召回、上下文压缩、LLM 和工具等慢操作。
|
2. 释放锁后执行消息持久化、记忆召回、上下文压缩、LLM 和工具等慢操作。
|
||||||
3. 提交由旧快照产生的结果前重新验证 generation/version,防止 `/stop`、`/clear` 或 `/delete` 后写回陈旧状态。
|
3. 提交由旧快照产生的结果前重新验证 generation/version,防止 `/stop`、`/clear` 或 `/delete` 后写回陈旧状态。
|
||||||
4. Session 持久化由独立 `persistence_lock` 串行化;批量消息使用原子写入,失败时精确回滚内存后缀。
|
4. Session 持久化由独立 `persistence_lock` 串行化;批量消息使用原子写入,失败时精确回滚内存后缀。
|
||||||
5. 上下文溢出时按 Provider 返回的真实限制重新压缩并重试。
|
5. 首次请求上下文溢出且尚未执行工具时,按 Provider 返回的真实限制提交 checkpoint 并正式重试一次;工具执行后的溢出只能在原 AgentLoop 内保留当前工具链进行一次请求级恢复。
|
||||||
6. mailbox 的接收与关闭原子互斥;所有输入在 Session 锁内取得单调序号,未消费 steering 与普通队列按该序号恢复,不能丢失或互相超越。
|
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 输入和当前工具结果。
|
WebUI/TUI 的 Active Turn 使用 `send_message(files=...)` 向自身 session 投递附件时,附件暂存到 task-local Turn delivery,成功结束后并入最终 assistant 消息,因此工具链始终排在附件回复之前且不会出现自引用来源前缀。其他自投递要求 task-local Turn ID 与 session 的 active Turn 匹配;历史中的 assistant/system 附件只作为文本清单提供给模型,原生媒体块仅用于 user 输入和当前工具结果。
|
||||||
@ -162,8 +158,9 @@ WebUI/TUI 的 Active Turn 使用 `send_message(files=...)` 向自身 session 投
|
|||||||
### 会话恢复
|
### 会话恢复
|
||||||
|
|
||||||
从 Storage 恢复 session 时:
|
从 Storage 恢复 session 时:
|
||||||
- 若 `last_compressed_message_at` 存在:先加载近 3 条 Timeline 记忆作为 `[Previous Context]`,再加载压缩标记后的原始消息
|
- 加载全部原始消息及 Session 的活动 checkpoint
|
||||||
- 若无压缩记录:正常加载全部消息
|
- 有 checkpoint 时确定性投影累计摘要和 `first_retained_seq` 之后的原始尾部;没有 checkpoint 时投影全部原始消息
|
||||||
|
- Timeline 和 `last_compressed_message_at` 不参与恢复边界判断,恢复过程不调用 Provider
|
||||||
- 自动修复断链的工具调用(gateway 崩溃中途重启导致)
|
- 自动修复断链的工具调用(gateway 崩溃中途重启导致)
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -217,13 +214,9 @@ WebUI/TUI 的 Active Turn 使用 `send_message(files=...)` 向自身 session 投
|
|||||||
|
|
||||||
### 上下文压缩与 Timeline
|
### 上下文压缩与 Timeline
|
||||||
|
|
||||||
LLM 对话上下文接近 token 限制 (默认 128K × 70%) 时自动触发压缩:
|
自动压缩在完整请求占用满足 `context_tokens > context_window - effective_reserve` 时触发。压缩器从尾部按完整 Turn 尽量保留近期历史,用一次 LLM 调用生成累计摘要,并以 `first_retained_seq` 记录精确尾部边界。摘要请求按当前模型窗口动态限制输入;若待压缩源更大,只在请求副本中保留已有 checkpoint、最新消息和确定性 head/tail 摘录,原始内容仍完整持久化。checkpoint 与 Session 活动指针在同一 SQLite 事务中提交;提交失败继续使用旧投影。原始消息与工具结果始终保留,旧内容只是不再进入 Provider context。
|
||||||
|
|
||||||
1. **快速裁剪**:工具输出 ≥ 2000 字符时截断
|
语义 checkpoint 提交后会 best-effort 写入一条 **Timeline**(importance 0.3)供主动检索,但 Timeline 不是恢复权威。真实 context overflow,或换模/改配置后在发送前已检测到的硬超限,能使用无语义 breadcrumb 降级,且只重试一次正式 Provider 请求;低于硬窗口的普通自动压缩和 `/compact` 失败时不会裁掉历史。
|
||||||
2. **LLM 摘要**:最多 3 轮,每轮找连续用户消息对,将中间的 assistant/tool 消息压缩为摘要 → 摘要作为 **Timeline 记忆** 持久化(importance 0.3)
|
|
||||||
3. **硬截断**:若仍超 90%,只保留前 N + 后 N 条消息
|
|
||||||
|
|
||||||
压缩后 `last_compressed_message_at` 标记边界,后续恢复时从标记点加载原始消息,以 Timeline 提供更早的上下文。
|
|
||||||
|
|
||||||
### 关键集成点
|
### 关键集成点
|
||||||
|
|
||||||
@ -231,9 +224,9 @@ LLM 对话上下文接近 token 限制 (默认 128K × 70%) 时自动触发压
|
|||||||
|------|------|
|
|------|------|
|
||||||
| 每次消息处理 | `memory_manager.recall()` 提取 Knowledge 上下文 |
|
| 每次消息处理 | `memory_manager.recall()` 提取 Knowledge 上下文 |
|
||||||
| 系统提示构建 | `MemorySection` 渲染记忆工具指南;匹配的 Knowledge 附加到本轮 user message |
|
| 系统提示构建 | `MemorySection` 渲染记忆工具指南;匹配的 Knowledge 附加到本轮 user message |
|
||||||
| 有压缩历史时 | `HistorySection` 提示 LLM 使用 `timeline_recall` |
|
| 有活动 checkpoint 时 | 累计摘要和精确 raw tail 组成 Provider 历史 |
|
||||||
| 压缩完成后 | 摘要自动存储为 Timeline 记忆 |
|
| 语义 checkpoint 提交后 | 摘要 best-effort 存储为 Timeline 记忆 |
|
||||||
| 会话恢复 | 加载最近 Timeline 和压缩边界后的原始消息 |
|
| 会话恢复 | 从 checkpoint 与原始 seq 确定性重建,不读取 Timeline |
|
||||||
|
|
||||||
`memory.recall_limit`、`idle_consolidation_minutes`、`timeline_retention_days` 和 `max_failures_before_degrade` 当前会被配置解析;其中每轮 Knowledge 召回在 worker 中仍固定为 5,其余自动维护策略尚未接入运行循环。不要把“配置可解析”误认为“行为已生效”。
|
`memory.recall_limit`、`idle_consolidation_minutes`、`timeline_retention_days` 和 `max_failures_before_degrade` 当前会被配置解析;其中每轮 Knowledge 召回在 worker 中仍固定为 5,其余自动维护策略尚未接入运行循环。不要把“配置可解析”误认为“行为已生效”。
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,7 @@ Gateway WebUI 的“配置”页可以编辑实际加载的配置文件。读取
|
|||||||
"providers": {}, // LLM 提供商配置
|
"providers": {}, // LLM 提供商配置
|
||||||
"models": {}, // 模型配置
|
"models": {}, // 模型配置
|
||||||
"agents": {}, // Provider/Model profile
|
"agents": {}, // Provider/Model profile
|
||||||
|
"context_compaction": {}, // 上下文 reserve 预算与近期保留量
|
||||||
"agent_orchestration": {}, // 具名子 Agent Definition 与编排上限
|
"agent_orchestration": {}, // 具名子 Agent Definition 与编排上限
|
||||||
"gateway": {}, // 网关配置
|
"gateway": {}, // 网关配置
|
||||||
"client": {}, // 客户端配置
|
"client": {}, // 客户端配置
|
||||||
@ -41,6 +42,7 @@ Gateway WebUI 的“配置”页可以编辑实际加载的配置文件。读取
|
|||||||
| `model_id` | 模型标识名称 |
|
| `model_id` | 模型标识名称 |
|
||||||
| `temperature` | 采样温度,可选 |
|
| `temperature` | 采样温度,可选 |
|
||||||
| `max_tokens` | 最大输出 token 数,可选 |
|
| `max_tokens` | 最大输出 token 数,可选 |
|
||||||
|
| `token_limit` | 模型上下文窗口硬上限,可选;未配置时默认为 128000,Agent 只能进一步收紧 |
|
||||||
| `input_type` | 模型支持的输入类型,如 `["text"]` 或 `["text", "image"]`,默认 `["text"]`. 纯内部使用,不会传递给 LLM API |
|
| `input_type` | 模型支持的输入类型,如 `["text"]` 或 `["text", "image"]`,默认 `["text"]`. 纯内部使用,不会传递给 LLM API |
|
||||||
|
|
||||||
## agents 字段
|
## agents 字段
|
||||||
@ -50,7 +52,17 @@ Gateway WebUI 的“配置”页可以编辑实际加载的配置文件。读取
|
|||||||
| `provider` | string | - | 提供商名称(对应 providers key) |
|
| `provider` | string | - | 提供商名称(对应 providers key) |
|
||||||
| `model` | string | - | 模型名称(对应 models key) |
|
| `model` | string | - | 模型名称(对应 models key) |
|
||||||
| `max_tool_iterations` | int | 99 | 最大工具调用轮数 |
|
| `max_tool_iterations` | int | 99 | 最大工具调用轮数 |
|
||||||
| `token_limit` | int | 128000 | 上下文 token 限制 |
|
| `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 |
|
||||||
|
|
||||||
## agent_orchestration 字段
|
## agent_orchestration 字段
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
数据库为 SQLite,默认位于配置目录(`~/.picobot`)`data/` 下的 `picobot.db`,与 workspace 相互独立。
|
数据库为 SQLite,默认位于配置目录(`~/.picobot`)`data/` 下的 `picobot.db`,与 workspace 相互独立。
|
||||||
|
|
||||||
连接启用 WAL、`synchronous=NORMAL`、foreign keys、5 秒 busy timeout,连接池最多 8 个连接。当前 `PRAGMA user_version=8`;启动时会在事务内补齐旧库字段和索引,遇到比程序更新的 schema version 会拒绝启动。
|
连接启用 WAL、`synchronous=NORMAL`、foreign keys、5 秒 busy timeout,连接池最多 8 个连接。当前 `PRAGMA user_version=10`;启动时会在事务内补齐旧库字段和索引,遇到比程序更新的 schema version 会拒绝启动。
|
||||||
|
|
||||||
## sessions 表
|
## sessions 表
|
||||||
|
|
||||||
@ -22,7 +22,9 @@
|
|||||||
| `archived_at` | INTEGER | 归档时间(Unix 毫秒),NULL 表示未归档 |
|
| `archived_at` | INTEGER | 归档时间(Unix 毫秒),NULL 表示未归档 |
|
||||||
| `deleted_at` | INTEGER | 软删除时间戳 |
|
| `deleted_at` | INTEGER | 软删除时间戳 |
|
||||||
| `last_consolidated_at` | INTEGER | 上次记忆归并时间 |
|
| `last_consolidated_at` | INTEGER | 上次记忆归并时间 |
|
||||||
| `last_compressed_message_at` | INTEGER | 上次上下文压缩边界时间戳 |
|
| `last_compressed_message_at` | INTEGER | 最近 checkpoint 时间戳(兼容/诊断字段,不作为恢复边界) |
|
||||||
|
| `active_context_checkpoint_id` | TEXT | 当前 Provider 历史投影使用的 checkpoint ID;NULL 表示使用全部原始消息 |
|
||||||
|
| `context_generation` | INTEGER | checkpoint CAS 提交代;历史清空/改写时递增并清除活动指针 |
|
||||||
| `delivery_context` | TEXT | 渠道声明的可跨 Turn 复用投递上下文 JSON(如飞书 thread/root 身份);一次性 reply/reaction ID 永不写入 |
|
| `delivery_context` | TEXT | 渠道声明的可跨 Turn 复用投递上下文 JSON(如飞书 thread/root 身份);一次性 reply/reaction ID 永不写入 |
|
||||||
| `delivery_context_updated_at` | INTEGER | delivery_context 最后更新时间 |
|
| `delivery_context_updated_at` | INTEGER | delivery_context 最后更新时间 |
|
||||||
|
|
||||||
@ -53,7 +55,27 @@
|
|||||||
| `client_visibility` | TEXT | `visible` / `hidden`,默认 visible;hidden 只供模型回放(continuation 内部触发),客户端历史/投影/投递一律过滤 |
|
| `client_visibility` | TEXT | `visible` / `hidden`,默认 visible;hidden 只供模型回放(continuation 内部触发),客户端历史/投影/投递一律过滤 |
|
||||||
| `turn_origin` | TEXT | `user` / `agent_continuation` / `scheduled`,默认 user;客户端据此渲染"后台结果处理"标签而不创建用户气泡 |
|
| `turn_origin` | TEXT | `user` / `agent_continuation` / `scheduled`,默认 user;客户端据此渲染"后台结果处理"标签而不创建用户气泡 |
|
||||||
|
|
||||||
`(session_id, seq)` 有唯一索引,防止并发写入重复序号。删除 session 会通过外键级联删除 messages。索引 `(session_id, client_visibility, seq)` 支撑按可见性分层查询。
|
`(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 行。
|
||||||
|
|
||||||
## agent_runs 表(schema v8,Agent 编排)
|
## agent_runs 表(schema v8,Agent 编排)
|
||||||
|
|
||||||
|
|||||||
@ -50,7 +50,7 @@ Skill 安装后默认启用,可在 WebUI「工具 → Skills」页用开关禁
|
|||||||
|
|
||||||
## Q: 上下文压缩是什么意思?
|
## Q: 上下文压缩是什么意思?
|
||||||
|
|
||||||
对话历史过长超出模型 token 限制时,系统自动精简历史消息。压缩后旧消息可通过 `timeline_recall` 工具检索。
|
对话接近模型 token 限制时,PicoBot 用一份累计 checkpoint 摘要替代 Provider 上下文中的旧前缀,并原样保留近期消息尾部。原始消息和工具结果仍永久保存在聊天历史/SQLite 中,只是不再永久占用模型上下文;语义摘要还可通过 `timeline_recall` 检索。Model 的 `token_limit` 是窗口硬上限,未配置时为 128K;Agent 的可选 `token_limit` 只能收紧它,两者都有时取最小值。自动阈值采用窗口减 reserve 的机制,摘要输入按有效窗口动态限制而非固定 32K;换成更小模型后若历史已经超限,会在首次普通模型请求前压缩或明确降级。`/compact` 可在阈值前手动强制执行。
|
||||||
|
|
||||||
## Q: 如何修改 gateway 监听端口?
|
## Q: 如何修改 gateway 监听端口?
|
||||||
|
|
||||||
|
|||||||
@ -23,7 +23,8 @@
|
|||||||
"qwen-plus": {
|
"qwen-plus": {
|
||||||
"model_id": "qwen-plus",
|
"model_id": "qwen-plus",
|
||||||
"temperature": 0.0,
|
"temperature": 0.0,
|
||||||
"max_tokens": 8192
|
"max_tokens": 8192,
|
||||||
|
"token_limit": 128000
|
||||||
},
|
},
|
||||||
"gpt-4o": {
|
"gpt-4o": {
|
||||||
"model_id": "gpt-4o",
|
"model_id": "gpt-4o",
|
||||||
@ -42,10 +43,14 @@
|
|||||||
"default": {
|
"default": {
|
||||||
"provider": "aliyun",
|
"provider": "aliyun",
|
||||||
"model": "qwen-plus",
|
"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": {
|
"agent_orchestration": {
|
||||||
"definitions_dir": "agents",
|
"definitions_dir": "agents",
|
||||||
"max_tree_depth": 4,
|
"max_tree_depth": 4,
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
use crate::agent::context_compressor::estimate_tokens;
|
use crate::agent::context_compaction::{
|
||||||
|
context_request_digest, estimate_tokens, is_context_overflow_error,
|
||||||
|
parse_context_limit_from_error,
|
||||||
|
};
|
||||||
use crate::agent::media_handler::MediaHandlerRegistry;
|
use crate::agent::media_handler::MediaHandlerRegistry;
|
||||||
use crate::agent::steering::{SteeringDrain, TurnInput};
|
use crate::agent::steering::{SteeringDrain, TurnInput};
|
||||||
use crate::agent::system_prompt::build_system_prompt;
|
use crate::agent::system_prompt::build_system_prompt;
|
||||||
@ -219,6 +222,55 @@ fn attach_reply_media(message: &mut ChatMessage, reply_media_refs: &[MediaRef])
|
|||||||
extend_unique_media(&mut message.media_refs, reply_media_refs);
|
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.
|
/// Loop detection result.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
enum LoopDetectionResult {
|
enum LoopDetectionResult {
|
||||||
@ -341,7 +393,7 @@ pub struct AgentLoop {
|
|||||||
max_iterations: usize,
|
max_iterations: usize,
|
||||||
workspace_dir: PathBuf,
|
workspace_dir: PathBuf,
|
||||||
model_name: String,
|
model_name: String,
|
||||||
context_window: usize,
|
context_trim_threshold: usize,
|
||||||
input_types: Vec<String>,
|
input_types: Vec<String>,
|
||||||
media_registry: MediaHandlerRegistry,
|
media_registry: MediaHandlerRegistry,
|
||||||
/// Optional sink receiving a clone of every message appended to
|
/// Optional sink receiving a clone of every message appended to
|
||||||
@ -359,6 +411,9 @@ pub struct AgentProcessResult {
|
|||||||
/// the correct basis for context-window occupancy; `usage` is accumulated
|
/// the correct basis for context-window occupancy; `usage` is accumulated
|
||||||
/// across every tool iteration.
|
/// across every tool iteration.
|
||||||
pub last_request_usage: Option<crate::providers::Usage>,
|
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) {
|
fn merge_usage(total: &mut crate::providers::Usage, next: &crate::providers::Usage) {
|
||||||
@ -400,7 +455,7 @@ impl AgentLoop {
|
|||||||
provider: Arc::from(provider),
|
provider: Arc::from(provider),
|
||||||
tools: Arc::new(ToolRegistry::new()),
|
tools: Arc::new(ToolRegistry::new()),
|
||||||
observer: None,
|
observer: None,
|
||||||
context_window: 0,
|
context_trim_threshold: 0,
|
||||||
max_iterations,
|
max_iterations,
|
||||||
workspace_dir,
|
workspace_dir,
|
||||||
model_name,
|
model_name,
|
||||||
@ -426,7 +481,7 @@ impl AgentLoop {
|
|||||||
provider: Arc::from(provider),
|
provider: Arc::from(provider),
|
||||||
tools,
|
tools,
|
||||||
observer: None,
|
observer: None,
|
||||||
context_window: 0,
|
context_trim_threshold: 0,
|
||||||
max_iterations,
|
max_iterations,
|
||||||
workspace_dir,
|
workspace_dir,
|
||||||
model_name,
|
model_name,
|
||||||
@ -448,7 +503,7 @@ impl AgentLoop {
|
|||||||
provider,
|
provider,
|
||||||
tools: Arc::new(ToolRegistry::new()),
|
tools: Arc::new(ToolRegistry::new()),
|
||||||
observer: None,
|
observer: None,
|
||||||
context_window: 0,
|
context_trim_threshold: 0,
|
||||||
max_iterations,
|
max_iterations,
|
||||||
workspace_dir,
|
workspace_dir,
|
||||||
model_name,
|
model_name,
|
||||||
@ -471,7 +526,7 @@ impl AgentLoop {
|
|||||||
provider,
|
provider,
|
||||||
tools,
|
tools,
|
||||||
observer: None,
|
observer: None,
|
||||||
context_window: 0,
|
context_trim_threshold: 0,
|
||||||
max_iterations,
|
max_iterations,
|
||||||
workspace_dir,
|
workspace_dir,
|
||||||
model_name,
|
model_name,
|
||||||
@ -483,7 +538,14 @@ impl AgentLoop {
|
|||||||
|
|
||||||
/// Set the context window size for preemptive trimming.
|
/// Set the context window size for preemptive trimming.
|
||||||
pub fn with_context_window(mut self, window: usize) -> Self {
|
pub fn with_context_window(mut self, window: usize) -> Self {
|
||||||
self.context_window = window;
|
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
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -621,7 +683,15 @@ impl AgentLoop {
|
|||||||
start.elapsed().as_millis() as u64,
|
start.elapsed().as_millis() as u64,
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
return Err(AgentError::LlmError(error.to_string()));
|
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));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let mut accumulator = ProviderResponseAccumulator::default();
|
let mut accumulator = ProviderResponseAccumulator::default();
|
||||||
@ -652,7 +722,15 @@ impl AgentLoop {
|
|||||||
start.elapsed().as_millis() as u64,
|
start.elapsed().as_millis() as u64,
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
return Err(AgentError::LlmError(error.to_string()));
|
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));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if let Some(turn) = turn {
|
if let Some(turn) = turn {
|
||||||
@ -872,6 +950,10 @@ impl AgentLoop {
|
|||||||
let mut accumulated_tokens: u32 = 0;
|
let mut accumulated_tokens: u32 = 0;
|
||||||
let mut accumulated_usage = crate::providers::Usage::default();
|
let mut accumulated_usage = crate::providers::Usage::default();
|
||||||
let mut last_request_usage = None;
|
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 {
|
for iteration in 0..self.max_iterations {
|
||||||
if cancellation.is_cancelled() {
|
if cancellation.is_cancelled() {
|
||||||
@ -882,18 +964,17 @@ impl AgentLoop {
|
|||||||
tracing::debug!(iteration, "Agent iteration started");
|
tracing::debug!(iteration, "Agent iteration started");
|
||||||
let last_iteration = iteration.saturating_add(1) >= self.max_iterations;
|
let last_iteration = iteration.saturating_add(1) >= self.max_iterations;
|
||||||
|
|
||||||
// Preemptive context check: trim old tool results if token estimate
|
// Request-local safety check: use the same reserve threshold as
|
||||||
// exceeds 80% of context window to prevent mid-loop overflow.
|
// Session compaction, while leaving durable tool results intact.
|
||||||
if self.context_window > 0 {
|
if self.context_trim_threshold > 0 {
|
||||||
let estimated = estimate_tokens(&messages);
|
let estimated = estimate_tokens(&messages);
|
||||||
let danger = (self.context_window as f64 * 0.8) as usize;
|
if estimated > self.context_trim_threshold {
|
||||||
if estimated > danger {
|
|
||||||
let trimmed = self.preemptive_trim_old_tool_results(&mut messages, 2000, 4);
|
let trimmed = self.preemptive_trim_old_tool_results(&mut messages, 2000, 4);
|
||||||
if trimmed > 0 {
|
if trimmed > 0 {
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
estimated,
|
estimated,
|
||||||
danger,
|
threshold = self.context_trim_threshold,
|
||||||
trimmed_msgs = trimmed,
|
trimmed_msgs = trimmed,
|
||||||
"Preemptive tool-result trim applied in loop"
|
"Preemptive tool-result trim applied in loop"
|
||||||
);
|
);
|
||||||
@ -901,23 +982,6 @@ 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
|
// Call LLM
|
||||||
let iteration = match u32::try_from(iteration) {
|
let iteration = match u32::try_from(iteration) {
|
||||||
Ok(iteration) => iteration,
|
Ok(iteration) => iteration,
|
||||||
@ -926,14 +990,67 @@ impl AgentLoop {
|
|||||||
return Err(AgentError::Other("tool iteration exceeds u32".to_string()));
|
return Err(AgentError::Other("tool iteration exceeds u32".to_string()));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let response = {
|
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 _provider_permit =
|
let _provider_permit =
|
||||||
Self::acquire_provider_permit(&tool_context, &cancellation).await?;
|
Self::acquire_provider_permit(&tool_context, &cancellation).await?;
|
||||||
match self
|
match self
|
||||||
.stream_completion(request, iteration, turn.as_ref(), &cancellation)
|
.stream_completion(request, iteration, turn.as_ref(), &cancellation)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(response) => response,
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
// The invocation may be retried from persisted history.
|
// The invocation may be retried from persisted history.
|
||||||
// Restore every steering message consumed by an earlier
|
// Restore every steering message consumed by an earlier
|
||||||
@ -948,6 +1065,7 @@ impl AgentLoop {
|
|||||||
accumulated_tokens = accumulated_tokens.saturating_add(response.usage.total_tokens);
|
accumulated_tokens = accumulated_tokens.saturating_add(response.usage.total_tokens);
|
||||||
merge_usage(&mut accumulated_usage, &response.usage);
|
merge_usage(&mut accumulated_usage, &response.usage);
|
||||||
last_request_usage = Some(response.usage.clone());
|
last_request_usage = Some(response.usage.clone());
|
||||||
|
last_request_digest = Some(successful_request_digest);
|
||||||
|
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
@ -1010,6 +1128,7 @@ impl AgentLoop {
|
|||||||
total_tokens: Some(accumulated_tokens),
|
total_tokens: Some(accumulated_tokens),
|
||||||
usage: Some(accumulated_usage),
|
usage: Some(accumulated_usage),
|
||||||
last_request_usage,
|
last_request_usage,
|
||||||
|
last_request_digest,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1119,6 +1238,7 @@ impl AgentLoop {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
completed_tool_batches = completed_tool_batches.saturating_add(1);
|
||||||
|
|
||||||
// A complete tool batch is the first safe steering boundary. Do
|
// A complete tool batch is the first safe steering boundary. Do
|
||||||
// not drain at the final available iteration: those inputs must
|
// not drain at the final available iteration: those inputs must
|
||||||
@ -1168,16 +1288,6 @@ impl AgentLoop {
|
|||||||
);
|
);
|
||||||
messages.push(summary_request);
|
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) {
|
let summary_iteration = match u32::try_from(self.max_iterations) {
|
||||||
Ok(iteration) => iteration,
|
Ok(iteration) => iteration,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
@ -1185,17 +1295,72 @@ impl AgentLoop {
|
|||||||
return Err(AgentError::Other("tool iteration exceeds u32".to_string()));
|
return Err(AgentError::Other("tool iteration exceeds u32".to_string()));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let summary_result = {
|
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 _provider_permit =
|
let _provider_permit =
|
||||||
Self::acquire_provider_permit(&tool_context, &cancellation).await?;
|
Self::acquire_provider_permit(&tool_context, &cancellation).await?;
|
||||||
self.stream_completion(request, summary_iteration, turn.as_ref(), &cancellation)
|
match self
|
||||||
|
.stream_completion(request, summary_iteration, turn.as_ref(), &cancellation)
|
||||||
.await
|
.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 {
|
match summary_result {
|
||||||
Ok(response) => {
|
Ok((response, summary_request_digest)) => {
|
||||||
accumulated_tokens = accumulated_tokens.saturating_add(response.usage.total_tokens);
|
accumulated_tokens = accumulated_tokens.saturating_add(response.usage.total_tokens);
|
||||||
merge_usage(&mut accumulated_usage, &response.usage);
|
merge_usage(&mut accumulated_usage, &response.usage);
|
||||||
last_request_usage = Some(response.usage.clone());
|
last_request_usage = Some(response.usage.clone());
|
||||||
|
last_request_digest = Some(summary_request_digest);
|
||||||
let mut assistant_message = ChatMessage::assistant(response.content);
|
let mut assistant_message = ChatMessage::assistant(response.content);
|
||||||
assistant_message.reasoning_content = response.reasoning_content;
|
assistant_message.reasoning_content = response.reasoning_content;
|
||||||
assistant_message.provider_state = response.provider_state;
|
assistant_message.provider_state = response.provider_state;
|
||||||
@ -1218,8 +1383,13 @@ impl AgentLoop {
|
|||||||
total_tokens: Some(accumulated_tokens),
|
total_tokens: Some(accumulated_tokens),
|
||||||
usage: Some(accumulated_usage),
|
usage: Some(accumulated_usage),
|
||||||
last_request_usage,
|
last_request_usage,
|
||||||
|
last_request_digest,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
Err(error @ AgentError::ContextOverflow { .. }) => {
|
||||||
|
Self::restore_steering(turn.as_ref(), consumed_steering);
|
||||||
|
Err(error)
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// Fallback if summary call fails
|
// Fallback if summary call fails
|
||||||
tracing::error!(error = %e, "Failed to get summary from LLM");
|
tracing::error!(error = %e, "Failed to get summary from LLM");
|
||||||
@ -1261,6 +1431,7 @@ impl AgentLoop {
|
|||||||
},
|
},
|
||||||
usage: (accumulated_usage.total_tokens > 0).then_some(accumulated_usage),
|
usage: (accumulated_usage.total_tokens > 0).then_some(accumulated_usage),
|
||||||
last_request_usage,
|
last_request_usage,
|
||||||
|
last_request_digest,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1486,6 +1657,131 @@ mod tests {
|
|||||||
requests: std::sync::Mutex<usize>,
|
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]
|
#[async_trait::async_trait]
|
||||||
impl LLMProvider for StreamingTextProvider {
|
impl LLMProvider for StreamingTextProvider {
|
||||||
async fn stream(
|
async fn stream(
|
||||||
@ -1726,6 +2022,181 @@ mod tests {
|
|||||||
assert_eq!(restored.user_inputs[0].content, "retry me");
|
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 {
|
impl TestObserver {
|
||||||
fn new() -> Self {
|
fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@ -2562,6 +3033,15 @@ mod tests {
|
|||||||
pub enum AgentError {
|
pub enum AgentError {
|
||||||
ProviderCreation(String),
|
ProviderCreation(String),
|
||||||
LlmError(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
|
/// The run was cancelled by `/stop`, a parent run, timeout ownership or
|
||||||
/// shutdown. Terminal state must be decided by this variant, never by
|
/// shutdown. Terminal state must be decided by this variant, never by
|
||||||
/// matching error strings.
|
/// matching error strings.
|
||||||
@ -2582,6 +3062,9 @@ impl std::fmt::Display for AgentError {
|
|||||||
match self {
|
match self {
|
||||||
AgentError::ProviderCreation(e) => write!(f, "Provider creation error: {}", e),
|
AgentError::ProviderCreation(e) => write!(f, "Provider creation error: {}", e),
|
||||||
AgentError::LlmError(e) => write!(f, "LLM 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::Cancelled => write!(f, "agent run cancelled"),
|
||||||
AgentError::TimedOut => write!(f, "agent run timed out"),
|
AgentError::TimedOut => write!(f, "agent run timed out"),
|
||||||
AgentError::Other(e) => write!(f, "{}", e),
|
AgentError::Other(e) => write!(f, "{}", e),
|
||||||
|
|||||||
@ -4,6 +4,7 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use crate::config::{
|
use crate::config::{
|
||||||
AgentOrchestrationConfig, LLMProviderConfig, ModelConfig, ProviderConfig, expand_path,
|
AgentOrchestrationConfig, LLMProviderConfig, ModelConfig, ProviderConfig, expand_path,
|
||||||
|
resolve_token_limit,
|
||||||
};
|
};
|
||||||
use crate::skills::SkillsLoader;
|
use crate::skills::SkillsLoader;
|
||||||
use crate::tools::ToolRegistry;
|
use crate::tools::ToolRegistry;
|
||||||
@ -427,7 +428,7 @@ fn resolve_provider(
|
|||||||
max_tokens: model.max_tokens,
|
max_tokens: model.max_tokens,
|
||||||
model_extra: model.extra.clone(),
|
model_extra: model.extra.clone(),
|
||||||
max_tool_iterations: spec.max_tool_iterations.unwrap_or(99),
|
max_tool_iterations: spec.max_tool_iterations.unwrap_or(99),
|
||||||
token_limit: spec.token_limit.unwrap_or(128_000),
|
token_limit: resolve_token_limit(spec.token_limit, model.token_limit),
|
||||||
workspace_dir: workspace_dir.to_path_buf(),
|
workspace_dir: workspace_dir.to_path_buf(),
|
||||||
input_types: model.input_type.clone(),
|
input_types: model.input_type.clone(),
|
||||||
price_input_per_million: None,
|
price_input_per_million: None,
|
||||||
@ -435,13 +436,14 @@ fn resolve_provider(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
let profile = spec.llm_profile.as_deref().unwrap_or_default();
|
let profile = spec.llm_profile.as_deref().unwrap_or_default();
|
||||||
provider_profiles
|
let mut resolved = provider_profiles.get(profile).cloned().ok_or_else(|| {
|
||||||
.get(profile)
|
AgentCatalogError::UnknownProfile {
|
||||||
.cloned()
|
|
||||||
.ok_or_else(|| AgentCatalogError::UnknownProfile {
|
|
||||||
agent: spec.id.clone(),
|
agent: spec.id.clone(),
|
||||||
profile: profile.to_string(),
|
profile: profile.to_string(),
|
||||||
})
|
}
|
||||||
|
})?;
|
||||||
|
resolved.token_limit = resolve_token_limit(spec.token_limit, Some(resolved.token_limit));
|
||||||
|
Ok(resolved)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_definition_tools(
|
fn validate_definition_tools(
|
||||||
@ -639,6 +641,150 @@ mod tests {
|
|||||||
assert_eq!(catalog.runtime_generation(), 7);
|
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]
|
#[test]
|
||||||
fn delegation_semantics_default_empty_any_and_list() {
|
fn delegation_semantics_default_empty_any_and_list() {
|
||||||
let root = tempfile::tempdir().unwrap();
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
1490
src/agent/context_compaction.rs
Normal file
1490
src/agent/context_compaction.rs
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,7 +1,7 @@
|
|||||||
pub mod agent_loop;
|
pub mod agent_loop;
|
||||||
pub mod builtin;
|
pub mod builtin;
|
||||||
pub mod catalog;
|
pub mod catalog;
|
||||||
pub mod context_compressor;
|
pub mod context_compaction;
|
||||||
pub mod coordinator;
|
pub mod coordinator;
|
||||||
pub mod definition;
|
pub mod definition;
|
||||||
pub mod gate;
|
pub mod gate;
|
||||||
@ -16,7 +16,11 @@ pub mod turn_event;
|
|||||||
|
|
||||||
pub use agent_loop::{AgentError, AgentLoop, AgentProcessResult};
|
pub use agent_loop::{AgentError, AgentLoop, AgentProcessResult};
|
||||||
pub use catalog::{AgentCatalog, AgentCatalogError, CatalogEntryError};
|
pub use catalog::{AgentCatalog, AgentCatalogError, CatalogEntryError};
|
||||||
pub use context_compressor::{ContextCompressor, estimate_tokens};
|
pub use context_compaction::{
|
||||||
|
CompactionCandidate, CompactionReason, ContextBudget, ContextBudgetParts, ContextCompactor,
|
||||||
|
ContextRequestKey, ContextUsageTracker, PreviousCheckpoint, SequencedMessage,
|
||||||
|
context_request_digest, estimate_tokens,
|
||||||
|
};
|
||||||
pub use coordinator::{AgentCoordinator, CoordinatorError};
|
pub use coordinator::{AgentCoordinator, CoordinatorError};
|
||||||
pub use definition::{AgentDefinition, AgentLimits};
|
pub use definition::{AgentDefinition, AgentLimits};
|
||||||
pub use gate::ExecutionGate;
|
pub use gate::ExecutionGate;
|
||||||
|
|||||||
@ -66,6 +66,8 @@ pub struct Config {
|
|||||||
pub browser: BrowserConfig,
|
pub browser: BrowserConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub agent_orchestration: AgentOrchestrationConfig,
|
pub agent_orchestration: AgentOrchestrationConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub context_compaction: ContextCompactionConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_workspace_dir() -> String {
|
fn default_workspace_dir() -> String {
|
||||||
@ -157,6 +159,9 @@ pub struct ModelConfig {
|
|||||||
pub temperature: Option<f32>,
|
pub temperature: Option<f32>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub max_tokens: Option<u32>,
|
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")]
|
#[serde(default = "default_input_type")]
|
||||||
pub input_type: Vec<String>,
|
pub input_type: Vec<String>,
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
@ -173,8 +178,10 @@ pub struct AgentConfig {
|
|||||||
pub model: String,
|
pub model: String,
|
||||||
#[serde(default = "default_max_tool_iterations")]
|
#[serde(default = "default_max_tool_iterations")]
|
||||||
pub max_tool_iterations: usize,
|
pub max_tool_iterations: usize,
|
||||||
#[serde(default = "default_token_limit")]
|
/// Optional per-Agent context-window cap. It may narrow but never expand
|
||||||
pub token_limit: usize,
|
/// 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>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_max_tool_iterations() -> usize {
|
fn default_max_tool_iterations() -> usize {
|
||||||
@ -185,6 +192,39 @@ fn default_token_limit() -> usize {
|
|||||||
128_000
|
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)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
#[serde(default, deny_unknown_fields)]
|
#[serde(default, deny_unknown_fields)]
|
||||||
pub struct AgentOrchestrationConfig {
|
pub struct AgentOrchestrationConfig {
|
||||||
@ -891,7 +931,7 @@ impl Config {
|
|||||||
max_tokens: model.max_tokens,
|
max_tokens: model.max_tokens,
|
||||||
model_extra: model.extra.clone(),
|
model_extra: model.extra.clone(),
|
||||||
max_tool_iterations: agent.max_tool_iterations,
|
max_tool_iterations: agent.max_tool_iterations,
|
||||||
token_limit: agent.token_limit,
|
token_limit: resolve_token_limit(agent.token_limit, model.token_limit),
|
||||||
workspace_dir: expand_path(&self.workspace_dir),
|
workspace_dir: expand_path(&self.workspace_dir),
|
||||||
input_types: model.input_type.clone(),
|
input_types: model.input_type.clone(),
|
||||||
price_input_per_million: None,
|
price_input_per_million: None,
|
||||||
@ -1169,6 +1209,38 @@ mod tests {
|
|||||||
assert_eq!(provider_config.name, "aliyun");
|
assert_eq!(provider_config.name, "aliyun");
|
||||||
assert_eq!(provider_config.model_id, "qwen-plus");
|
assert_eq!(provider_config.model_id, "qwen-plus");
|
||||||
assert_eq!(provider_config.temperature, Some(0.0));
|
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]
|
#[test]
|
||||||
@ -1193,6 +1265,18 @@ mod tests {
|
|||||||
.profile_dir
|
.profile_dir
|
||||||
.ends_with("browser/profiles")
|
.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]
|
#[test]
|
||||||
|
|||||||
@ -1013,7 +1013,13 @@ pub async fn get_agent_options(
|
|||||||
.config
|
.config
|
||||||
.models
|
.models
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(name, model)| json!({ "name": name, "model_id": model.model_id }))
|
.map(|(name, model)| {
|
||||||
|
json!({
|
||||||
|
"name": name,
|
||||||
|
"model_id": model.model_id,
|
||||||
|
"token_limit": model.token_limit,
|
||||||
|
})
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let registry = state.session_manager.tools();
|
let registry = state.session_manager.tools();
|
||||||
let mut tools: Vec<Value> = registry
|
let mut tools: Vec<Value> = registry
|
||||||
|
|||||||
@ -212,6 +212,7 @@ impl GatewayState {
|
|||||||
// Create SessionManager with bus injection
|
// Create SessionManager with bus injection
|
||||||
let session_manager = SessionManager::new(
|
let session_manager = SessionManager::new(
|
||||||
provider_config.clone(),
|
provider_config.clone(),
|
||||||
|
config.context_compaction.clone(),
|
||||||
AgentCatalogPreparation {
|
AgentCatalogPreparation {
|
||||||
provider_profiles,
|
provider_profiles,
|
||||||
providers: config.providers.clone(),
|
providers: config.providers.clone(),
|
||||||
|
|||||||
@ -629,12 +629,21 @@ mod tests {
|
|||||||
context: crate::session::ContextUsage {
|
context: crate::session::ContextUsage {
|
||||||
configured_window_tokens: 128_000,
|
configured_window_tokens: 128_000,
|
||||||
effective_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,
|
used_tokens: 100,
|
||||||
remaining_tokens: 127_900,
|
remaining_tokens: 127_900,
|
||||||
compression_threshold_tokens: 89_600,
|
compression_threshold_tokens: 111_616,
|
||||||
source: crate::session::ContextUsageSource::Hybrid,
|
source: crate::session::ContextUsageSource::Observed,
|
||||||
last_observed_prompt_tokens: Some(90),
|
last_observed_prompt_tokens: Some(90),
|
||||||
observed_at: Some(1),
|
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,
|
created_at: 1,
|
||||||
last_active_at: 2,
|
last_active_at: 2,
|
||||||
@ -642,7 +651,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
let value = serde_json::to_value(WsOutbound::SessionStats { stats }).unwrap();
|
let value = serde_json::to_value(WsOutbound::SessionStats { stats }).unwrap();
|
||||||
assert_eq!(value["type"], "session_stats");
|
assert_eq!(value["type"], "session_stats");
|
||||||
assert_eq!(value["stats"]["context"]["source"], "hybrid");
|
assert_eq!(value["stats"]["context"]["source"], "observed");
|
||||||
assert_eq!(value["stats"]["lifetime_usage"]["input_tokens"], 100);
|
assert_eq!(value["stats"]["lifetime_usage"]["input_tokens"], 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -284,7 +284,10 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
String::new(),
|
String::new(),
|
||||||
"test".to_string(),
|
"test".to_string(),
|
||||||
|
super::super::session::SessionContextServices {
|
||||||
memory_manager,
|
memory_manager,
|
||||||
|
compaction_config: crate::config::ContextCompactionConfig::default(),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -30,25 +30,34 @@ pub struct LifetimeUsage {
|
|||||||
pub struct ContextUsage {
|
pub struct ContextUsage {
|
||||||
pub configured_window_tokens: u64,
|
pub configured_window_tokens: u64,
|
||||||
pub effective_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 used_tokens: u64,
|
||||||
pub remaining_tokens: u64,
|
pub remaining_tokens: u64,
|
||||||
pub compression_threshold_tokens: u64,
|
pub compression_threshold_tokens: u64,
|
||||||
pub source: ContextUsageSource,
|
pub source: ContextUsageSource,
|
||||||
pub last_observed_prompt_tokens: Option<u64>,
|
pub last_observed_prompt_tokens: Option<u64>,
|
||||||
pub observed_at: Option<i64>,
|
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)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum ContextUsageSource {
|
pub enum ContextUsageSource {
|
||||||
Hybrid,
|
Observed,
|
||||||
Estimated,
|
Estimated,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ContextUsageSource {
|
impl ContextUsageSource {
|
||||||
pub fn label(self) -> &'static str {
|
pub fn label(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::Hybrid => "混合估算",
|
Self::Observed => "Provider 实测",
|
||||||
Self::Estimated => "字符估算",
|
Self::Estimated => "字符估算",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -78,9 +87,35 @@ impl SessionStats {
|
|||||||
.last_observed_prompt_tokens
|
.last_observed_prompt_tokens
|
||||||
.map(format_tokens)
|
.map(format_tokens)
|
||||||
.unwrap_or_else(|| "—".to_string());
|
.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!(
|
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 最近实测 {}",
|
"会话\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 {}",
|
||||||
self.title,
|
self.title,
|
||||||
self.session_id,
|
self.session_id,
|
||||||
self.provider,
|
self.provider,
|
||||||
@ -101,8 +136,13 @@ impl SessionStats {
|
|||||||
format_tokens(self.context.effective_window_tokens),
|
format_tokens(self.context.effective_window_tokens),
|
||||||
percent,
|
percent,
|
||||||
format_tokens(self.context.remaining_tokens),
|
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),
|
format_tokens(self.context.compression_threshold_tokens),
|
||||||
observed,
|
observed,
|
||||||
|
checkpoint,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::agent::ContextCompressor;
|
|
||||||
use crate::agent::system_prompt::build_runtime_context;
|
use crate::agent::system_prompt::build_runtime_context;
|
||||||
use crate::bus::ChatMessage;
|
use crate::bus::ChatMessage;
|
||||||
use crate::memory::{MemoryCategory, MemoryManager};
|
use crate::memory::{MemoryCategory, MemoryManager};
|
||||||
@ -11,6 +10,8 @@ use crate::work::WorkManager;
|
|||||||
pub(super) struct TurnRuntimeContext {
|
pub(super) struct TurnRuntimeContext {
|
||||||
system_prompt: String,
|
system_prompt: String,
|
||||||
runtime_context: String,
|
runtime_context: String,
|
||||||
|
memory_tokens: usize,
|
||||||
|
active_plan_tokens: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TurnRuntimeContext {
|
impl TurnRuntimeContext {
|
||||||
@ -25,30 +26,25 @@ impl TurnRuntimeContext {
|
|||||||
}
|
}
|
||||||
history
|
history
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(super) fn budget_hints(&self) -> (usize, usize) {
|
||||||
|
(self.memory_tokens, self.active_plan_tokens)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) struct PreparedTurnInput {
|
/// Builds runtime-only context outside the Session lock. Durable history
|
||||||
pub(super) messages: Vec<ChatMessage>,
|
/// projection and compaction are orchestrated by SessionManager after these
|
||||||
pub(super) runtime: TurnRuntimeContext,
|
/// variable-size sources are known, so the budget covers the complete request.
|
||||||
pub(super) created_timelines: bool,
|
pub(super) async fn prepare_turn_runtime(
|
||||||
}
|
|
||||||
|
|
||||||
/// 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>,
|
memory_manager: Arc<MemoryManager>,
|
||||||
work_manager: Arc<WorkManager>,
|
work_manager: Arc<WorkManager>,
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
query: &str,
|
query: &str,
|
||||||
system_prompt: String,
|
system_prompt: String,
|
||||||
compressor: &mut ContextCompressor,
|
) -> TurnRuntimeContext {
|
||||||
history: Vec<ChatMessage>,
|
|
||||||
) -> PreparedTurnInput {
|
|
||||||
let memory_future = memory_manager.recall(query, 5, Some(MemoryCategory::Knowledge), None);
|
let memory_future = memory_manager.recall(query, 5, Some(MemoryCategory::Knowledge), None);
|
||||||
let work_future = work_manager.active_plan(session_id);
|
let work_future = work_manager.active_plan(session_id);
|
||||||
let compression_future = compressor.compress_if_needed(history.clone());
|
let (memory_result, work_result) = tokio::join!(memory_future, work_future);
|
||||||
let (memory_result, work_result, compression_result) =
|
|
||||||
tokio::join!(memory_future, work_future, compression_future);
|
|
||||||
|
|
||||||
let memory_context = match memory_result {
|
let memory_context = match memory_result {
|
||||||
Ok(entries) if !entries.is_empty() => Some(
|
Ok(entries) if !entries.is_empty() => Some(
|
||||||
@ -72,29 +68,21 @@ pub(super) async fn prepare_turn_input(
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let compression = match compression_result {
|
TurnRuntimeContext {
|
||||||
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,
|
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(
|
runtime_context: build_runtime_context(
|
||||||
Some(session_id),
|
Some(session_id),
|
||||||
memory_context.as_deref(),
|
memory_context.as_deref(),
|
||||||
work_context.as_deref(),
|
work_context.as_deref(),
|
||||||
),
|
),
|
||||||
};
|
|
||||||
|
|
||||||
PreparedTurnInput {
|
|
||||||
messages: runtime.assemble(compression.history),
|
|
||||||
runtime,
|
|
||||||
created_timelines: compression.created_timelines,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -118,6 +106,8 @@ mod tests {
|
|||||||
let runtime = TurnRuntimeContext {
|
let runtime = TurnRuntimeContext {
|
||||||
system_prompt: "system".to_string(),
|
system_prompt: "system".to_string(),
|
||||||
runtime_context: "runtime".to_string(),
|
runtime_context: "runtime".to_string(),
|
||||||
|
memory_tokens: 0,
|
||||||
|
active_plan_tokens: 0,
|
||||||
};
|
};
|
||||||
let messages = runtime.assemble(vec![
|
let messages = runtime.assemble(vec![
|
||||||
ChatMessage::user("old"),
|
ChatMessage::user("old"),
|
||||||
@ -135,6 +125,8 @@ mod tests {
|
|||||||
let runtime = TurnRuntimeContext {
|
let runtime = TurnRuntimeContext {
|
||||||
system_prompt: "system".to_string(),
|
system_prompt: "system".to_string(),
|
||||||
runtime_context: "runtime".to_string(),
|
runtime_context: "runtime".to_string(),
|
||||||
|
memory_tokens: 0,
|
||||||
|
active_plan_tokens: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
let first = runtime.assemble(vec![ChatMessage::user("question")]);
|
let first = runtime.assemble(vec![ChatMessage::user("question")]);
|
||||||
|
|||||||
@ -1169,13 +1169,13 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn fresh_database_creates_schema_v9_agent_tables() {
|
async fn fresh_database_creates_current_agent_tables() {
|
||||||
let (storage, _dir) = create_test_storage().await;
|
let (storage, _dir) = create_test_storage().await;
|
||||||
let version: i64 = sqlx::query_scalar("PRAGMA user_version")
|
let version: i64 = sqlx::query_scalar("PRAGMA user_version")
|
||||||
.fetch_one(storage.pool())
|
.fetch_one(storage.pool())
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(version, 9);
|
assert_eq!(version, 10);
|
||||||
for table in [
|
for table in [
|
||||||
"agent_runs",
|
"agent_runs",
|
||||||
"agent_session_state",
|
"agent_session_state",
|
||||||
|
|||||||
325
src/storage/context_checkpoint.rs
Normal file
325
src/storage/context_checkpoint.rs
Normal file
@ -0,0 +1,325 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
pub mod agent_inbox;
|
pub mod agent_inbox;
|
||||||
pub mod agent_run;
|
pub mod agent_run;
|
||||||
|
pub mod context_checkpoint;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod memory;
|
pub mod memory;
|
||||||
pub mod message;
|
pub mod message;
|
||||||
@ -7,6 +8,7 @@ pub mod scheduler;
|
|||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod usage;
|
pub mod usage;
|
||||||
|
|
||||||
|
pub use context_checkpoint::{ContextCheckpoint, ContextCheckpointState, NewContextCheckpoint};
|
||||||
pub use error::StorageError;
|
pub use error::StorageError;
|
||||||
pub use scheduler::{DeliveryPolicy, JobKind, JobRun, ScheduledJob};
|
pub use scheduler::{DeliveryPolicy, JobKind, JobRun, ScheduledJob};
|
||||||
pub use usage::{SessionUsageTotals, TurnUsageRecord};
|
pub use usage::{SessionUsageTotals, TurnUsageRecord};
|
||||||
@ -18,7 +20,7 @@ use sqlx::{Pool, Row, Sqlite};
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use tokio::time::{Duration, sleep};
|
use tokio::time::{Duration, sleep};
|
||||||
|
|
||||||
const SCHEMA_VERSION: i64 = 9;
|
const SCHEMA_VERSION: i64 = 10;
|
||||||
const INSERT_MESSAGE_SQL: &str = r#"
|
const INSERT_MESSAGE_SQL: &str = r#"
|
||||||
INSERT INTO messages (
|
INSERT INTO messages (
|
||||||
id, session_id, seq, role, content, reasoning_content, provider_state,
|
id, session_id, seq, role, content, reasoning_content, provider_state,
|
||||||
@ -135,6 +137,8 @@ impl Storage {
|
|||||||
deleted_at INTEGER,
|
deleted_at INTEGER,
|
||||||
last_consolidated_at INTEGER,
|
last_consolidated_at INTEGER,
|
||||||
last_compressed_message_at INTEGER,
|
last_compressed_message_at INTEGER,
|
||||||
|
active_context_checkpoint_id TEXT,
|
||||||
|
context_generation INTEGER NOT NULL DEFAULT 0,
|
||||||
delivery_context TEXT,
|
delivery_context TEXT,
|
||||||
delivery_context_updated_at INTEGER,
|
delivery_context_updated_at INTEGER,
|
||||||
UNIQUE(channel, chat_id, dialog_id)
|
UNIQUE(channel, chat_id, dialog_id)
|
||||||
@ -144,6 +148,36 @@ impl Storage {
|
|||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await?;
|
.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(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
CREATE INDEX IF NOT EXISTS idx_sessions_chat
|
CREATE INDEX IF NOT EXISTS idx_sessions_chat
|
||||||
@ -456,6 +490,16 @@ impl Storage {
|
|||||||
"last_compressed_message_at",
|
"last_compressed_message_at",
|
||||||
"last_compressed_message_at INTEGER",
|
"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", "delivery_context", "delivery_context TEXT"),
|
||||||
(
|
(
|
||||||
"sessions",
|
"sessions",
|
||||||
@ -553,6 +597,35 @@ impl Storage {
|
|||||||
)
|
)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.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 {
|
for statement in agent_run::AGENT_SCHEMA_STATEMENTS {
|
||||||
sqlx::query(*statement).execute(&mut *tx).await?;
|
sqlx::query(*statement).execute(&mut *tx).await?;
|
||||||
}
|
}
|
||||||
@ -1412,10 +1485,28 @@ impl Storage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn clear_messages(&self, session_id: &str) -> Result<(), StorageError> {
|
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 = ?"#)
|
sqlx::query(r#"DELETE FROM messages WHERE session_id = ?"#)
|
||||||
.bind(session_id)
|
.bind(session_id)
|
||||||
.execute(self.pool())
|
.execute(&mut *tx)
|
||||||
.await?;
|
.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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1761,6 +1852,8 @@ mod tests {
|
|||||||
"archived_at",
|
"archived_at",
|
||||||
"last_consolidated_at",
|
"last_consolidated_at",
|
||||||
"last_compressed_message_at",
|
"last_compressed_message_at",
|
||||||
|
"active_context_checkpoint_id",
|
||||||
|
"context_generation",
|
||||||
"delivery_context",
|
"delivery_context",
|
||||||
"delivery_context_updated_at",
|
"delivery_context_updated_at",
|
||||||
],
|
],
|
||||||
@ -1795,6 +1888,7 @@ mod tests {
|
|||||||
"agent_runs",
|
"agent_runs",
|
||||||
"agent_session_state",
|
"agent_session_state",
|
||||||
"agent_inbox_events",
|
"agent_inbox_events",
|
||||||
|
"context_checkpoints",
|
||||||
] {
|
] {
|
||||||
let exists: i64 = sqlx::query_scalar(
|
let exists: i64 = sqlx::query_scalar(
|
||||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?",
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?",
|
||||||
@ -1936,7 +2030,7 @@ mod tests {
|
|||||||
let run = storage.get_agent_run("run-1").await.unwrap();
|
let run = storage.get_agent_run("run-1").await.unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
run.is_some(),
|
run.is_some(),
|
||||||
"v8 agent run must survive the v9 upgrade without a rebuild"
|
"v8 agent run must survive the current upgrade without a rebuild"
|
||||||
);
|
);
|
||||||
assert_eq!(run.unwrap().status.as_str(), "completed");
|
assert_eq!(run.unwrap().status.as_str(), "completed");
|
||||||
|
|
||||||
@ -1944,7 +2038,7 @@ mod tests {
|
|||||||
.fetch_one(storage.pool())
|
.fetch_one(storage.pool())
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(version, 9);
|
assert_eq!(version, SCHEMA_VERSION);
|
||||||
let exists: i64 = sqlx::query_scalar(
|
let exists: i64 = sqlx::query_scalar(
|
||||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'agent_run_messages'",
|
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'agent_run_messages'",
|
||||||
)
|
)
|
||||||
|
|||||||
4
webui/package-lock.json
generated
4
webui/package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "picobot-webui",
|
"name": "picobot-webui",
|
||||||
"version": "1.18.0",
|
"version": "1.20.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picobot-webui",
|
"name": "picobot-webui",
|
||||||
"version": "1.18.0",
|
"version": "1.20.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bits-ui": "^2.0.0",
|
"bits-ui": "^2.0.0",
|
||||||
"dompurify": "^3.4.12",
|
"dompurify": "^3.4.12",
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "picobot-webui",
|
"name": "picobot-webui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.18.0",
|
"version": "1.20.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
|
|||||||
@ -12,7 +12,11 @@
|
|||||||
? context.used_tokens / context.effective_window_tokens * 100
|
? context.used_tokens / context.effective_window_tokens * 100
|
||||||
: 0);
|
: 0);
|
||||||
const boundedPercent = $derived(Math.max(0, Math.min(percent, 100)));
|
const boundedPercent = $derived(Math.max(0, Math.min(percent, 100)));
|
||||||
const pressure = $derived(percent >= 90 ? "danger" : percent >= 70 ? "warning" : "normal");
|
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");
|
||||||
|
|
||||||
function compactTokens(value) {
|
function compactTokens(value) {
|
||||||
if (!Number.isFinite(value)) return "—";
|
if (!Number.isFinite(value)) return "—";
|
||||||
@ -26,7 +30,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sourceLabel(value) {
|
function sourceLabel(value) {
|
||||||
return value === "hybrid" ? "混合估算" : "字符估算";
|
return value === "observed" ? "Provider 实测" : "字符估算";
|
||||||
}
|
}
|
||||||
|
|
||||||
function trackedSince(value) {
|
function trackedSince(value) {
|
||||||
@ -53,7 +57,7 @@
|
|||||||
aria-valuemin="0"
|
aria-valuemin="0"
|
||||||
aria-valuemax="100"
|
aria-valuemax="100"
|
||||||
aria-valuenow={Math.round(boundedPercent)}
|
aria-valuenow={Math.round(boundedPercent)}
|
||||||
style={`--context-fill: ${boundedPercent}%`}
|
style={`--context-fill: ${boundedPercent}%; --context-threshold: ${boundedThresholdPercent}%`}
|
||||||
><i></i><b></b></span>
|
><i></i><b></b></span>
|
||||||
<span class="context-value">{compactTokens(context.used_tokens)} / {compactTokens(context.effective_window_tokens)}</span>
|
<span class="context-value">{compactTokens(context.used_tokens)} / {compactTokens(context.effective_window_tokens)}</span>
|
||||||
<strong class="context-percent">{percent.toFixed(1)}%</strong>
|
<strong class="context-percent">{percent.toFixed(1)}%</strong>
|
||||||
@ -74,12 +78,17 @@
|
|||||||
<div><span>当前上下文</span><strong>{percent.toFixed(1)}%</strong></div>
|
<div><span>当前上下文</span><strong>{percent.toFixed(1)}%</strong></div>
|
||||||
<small>{sourceLabel(context.source)}</small>
|
<small>{sourceLabel(context.source)}</small>
|
||||||
</header>
|
</header>
|
||||||
<div class="detail-rail" role="presentation" style={`--context-fill: ${boundedPercent}%`}><i></i><b></b></div>
|
<div class="detail-rail" role="presentation" style={`--context-fill: ${boundedPercent}%; --context-threshold: ${boundedThresholdPercent}%`}><i></i><b></b></div>
|
||||||
<dl class="context-grid">
|
<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.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.remaining_tokens)}</dd></div>
|
||||||
<div><dt>压缩阈值</dt><dd>{exactTokens(context.compression_threshold_tokens)} · 70%</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.last_observed_prompt_tokens)}</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>
|
</dl>
|
||||||
|
|
||||||
<div class="usage-heading"><span>会话累计</span><small>Provider 报告 · 已提交 Turns</small></div>
|
<div class="usage-heading"><span>会话累计</span><small>Provider 报告 · 已提交 Turns</small></div>
|
||||||
@ -109,7 +118,7 @@
|
|||||||
.context-rail, .detail-rail { position: relative; overflow: hidden; background: var(--color-neutral-background-4); }
|
.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 { 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 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-rail b, .detail-rail b { position: absolute; inset: 0 auto 0 var(--context-threshold); width: 1px; background: var(--warning); }
|
||||||
.context-value { color: var(--text-soft); }
|
.context-value { color: var(--text-soft); }
|
||||||
.context-percent { color: var(--accent); font-weight: 700; }
|
.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="warning"] .context-percent, [data-pressure="warning"] .context-rail i, [data-pressure="warning"] .detail-rail i { color: var(--warning); background: var(--warning); }
|
||||||
|
|||||||
@ -338,13 +338,13 @@
|
|||||||
<label>Model
|
<label>Model
|
||||||
<select bind:value={editing.model}>
|
<select bind:value={editing.model}>
|
||||||
<option value="">(选择)</option>
|
<option value="">(选择)</option>
|
||||||
{#each options.models as m (m.name)}<option value={m.name}>{m.name}</option>{/each}
|
{#each options.models as m (m.name)}<option value={m.name}>{m.name}{m.token_limit ? ` · ${m.token_limit} tokens` : " · 默认 128K"}</option>{/each}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>token_limit
|
<label>token_limit(可选上限)
|
||||||
<input type="number" bind:value={editing.token_limit} placeholder="128000" />
|
<input type="number" bind:value={editing.token_limit} placeholder="留空使用模型;不能超过模型上限" />
|
||||||
</label>
|
</label>
|
||||||
<label>max_tool_iterations
|
<label>max_tool_iterations
|
||||||
<input type="number" bind:value={editing.max_tool_iterations} placeholder="99" />
|
<input type="number" bind:value={editing.max_tool_iterations} placeholder="99" />
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user