From 93e74120ac2f4c37c0dd3c13ba3de616c061048d Mon Sep 17 00:00:00 2001 From: xiaoxixi Date: Thu, 20 Aug 2026 17:25:07 +0800 Subject: [PATCH] feat(config): add model context window limits --- AGENTS.md | 2 +- Cargo.toml | 2 +- README.md | 6 +- config.json | 4 +- docs/ARCHITECTURE.md | 2 +- docs/CONTEXT_COMPACTION_DESIGN.md | 5 +- .../about-picobot/assets/config.example.json | 6 +- .../about-picobot/references/architecture.md | 2 +- .../skills/about-picobot/references/config.md | 3 +- .../skills/about-picobot/references/faq.md | 2 +- resources/templates/config.example.json | 6 +- src/agent/catalog.rs | 158 +++++++++++++++++- src/config/mod.rs | 54 +++++- src/gateway/http.rs | 8 +- webui/package-lock.json | 4 +- webui/package.json | 2 +- .../lib/components/SubAgentDefinitions.svelte | 6 +- 17 files changed, 237 insertions(+), 35 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 634834c..1642065 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,7 +94,7 @@ Scheduler → SessionManager scheduled execution → AgentLoop → Scheduler del - **Scheduler** supports legacy direct-delivery jobs and managed `task`/`monitor` jobs; managed agents cannot call `send_message`, and `on_alert` suppresses only healthy informational results - **AgentLoop** is stateless across turns; it receives prepared history, drains same-Turn steering only at safe model boundaries, calls LLM providers, executes tools, and returns one result - **Context overflow recovery** is type-driven: before tool progress Session may commit one checkpoint and retry once; after any tool batch AgentLoop may retry the current Provider step once from its in-memory transcript, preserving current tool calls/results, and Session must never restart that Turn from durable history -- **Context compaction** keeps `messages` append-only and uses one active checkpoint per Session (`summary + first_retained_seq`) for deterministic Provider projection; `/compact`, Turn-boundary auto compaction, and overflow share the same compactor/CAS commit path, Session restoration never derives context from Timeline or calls a Provider, summary input is bounded from the current model `token_limit` rather than a fixed cap, and the only automatic threshold is `context_tokens > context_window - effective_reserve` +- **Context compaction** keeps `messages` append-only and uses one active checkpoint per Session (`summary + first_retained_seq`) for deterministic Provider projection; `/compact`, Turn-boundary auto compaction, and overflow share the same compactor/CAS commit path, Session restoration never derives context from Timeline or calls a Provider, the Model `token_limit` (default 128K) is the hard window ceiling and an optional Agent `token_limit` can only narrow it via `min(agent, model)`, summary input is bounded from that effective window rather than a fixed cap, and the only automatic threshold is `context_tokens > context_window - effective_reserve` - **AgentCatalog** is immutable per runtime generation; candidate preparation strictly validates trusted Markdown definitions, Provider profiles, tool/Skill allowlists, and delegation edges before activation. A definition that fails per-file validation (bad YAML, unknown provider/profile/model/tool/skill, or an explicit delegate edge to an absent target) is disabled for that generation only and reported via `load_errors` (exposed by `GET /api/agents`), never blocking startup or reload; config- and directory-trust-level failures remain fatal. Sub-Agent orchestration is an intrinsic, always-on mechanism (no feature switch). Named Agents support foreground (single/batch) and Root-initiated single background execution with durable run/inbox delivery; a built-in general-purpose definition is released to `~/.picobot/agents/` on first run. Background batches and nested background remain restricted - **WebUI management APIs** only expose allowlisted config/profile/log/storage operations; keep response limits, secret redaction, atomic config writes, and profile path allowlists intact - **WebUI styling** uses the local Fluent 2 semantic tokens in `webui/src/styles.css`; page and component styles should consume the aliases instead of introducing independent hard-coded palettes, and must preserve selectable light/dark and brand-color themes, keyboard focus, responsive layout, and reduced-motion behavior. Browser-only appearance settings belong in `lib/theme.js`/`localStorage`, and `public/theme-init.js` must restore them before Svelte mounts diff --git a/Cargo.toml b/Cargo.toml index 02d50ca..13cb9f4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "picobot" -version = "1.19.0" +version = "1.20.0" edition = "2024" [dependencies] diff --git a/README.md b/README.md index 3753e4a..18ab5a0 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ Gateway 首次启动时会把模板释放到 `~/.picobot/config.example.json`。 "model_id": "gpt-4o", "temperature": 0.7, "max_tokens": 4096, + "token_limit": 128000, "input_type": ["text", "image"] } }, @@ -68,8 +69,7 @@ Gateway 首次启动时会把模板释放到 `~/.picobot/config.example.json`。 "default": { "provider": "openai", "model": "gpt-4o", - "max_tool_iterations": 99, - "token_limit": 128000 + "max_tool_iterations": 99 } }, "workspace_dir": "~/.picobot/workspace" @@ -328,7 +328,7 @@ PicoBot 有两类记忆: 每轮处理用户消息时,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 不会被自动删除。 -自动压缩使用保留量阈值 `context_tokens > context_window - effective_reserve`,默认 reserve 为 16,384 tokens,并尽量原样保留最近 20,000 tokens。小窗口会自动把 reserve 限制为窗口的一半、把近期保留量限制为有效阈值的一半。摘要请求不使用固定 32K 输入上限,而是按当前模型的 `token_limit` 扣除摘要输出、提示词和安全余量;超大历史只在摘要请求副本中按“已有 checkpoint + 最新消息优先”生成有界 head/tail 转录,SQLite 原文不变。手动 `/compact` 跳过自动阈值;换成小模型后若发送前预检已发现硬超限,或首次请求返回真实 context overflow,语义摘要不可用时才使用明确标记的确定性降级裁剪,正式请求最多重试一次。若 overflow 发生在工具已经执行之后,AgentLoop 只在当前内存转录上裁掉旧完整 Turn 并重试当前模型步骤一次,不会从数据库历史重跑工具。 +模型的 `models..token_limit` 给出上下文窗口上限,未配置时默认为 128,000;Agent 的 `agents..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 并重试当前模型步骤一次,不会从数据库历史重跑工具。 ### 工具 diff --git a/config.json b/config.json index 134bbdf..f668a90 100644 --- a/config.json +++ b/config.json @@ -12,6 +12,7 @@ "model_id": "qwen-plus", "temperature": 0.0, "max_tokens": 100, + "token_limit": 128000, "input_type": ["text"] } }, @@ -19,8 +20,7 @@ "default": { "provider": "aliyun", "model": "qwen-plus", - "max_tool_iterations": 20, - "token_limit": 128000 + "max_tool_iterations": 20 } }, "agent_orchestration": { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e1e9bb8..2704bee 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -204,7 +204,7 @@ Session ID 格式为: 当前 WebUI/TUI Turn 通过 `send_message(files=...)` 向自身 session 投递文件时,文件先进入 task-local Turn delivery 暂存区,成功结束后附加到最终 assistant 消息,与工具链一起原子提交;因此持久化和刷新后的顺序都是工具调用/结果在前、携带附件的最终回复在后,也不会生成带 `[message from ...]` 的自投递气泡。其他同 Turn 自投递仍是受控例外:只有 task-local Turn ID 仍匹配该 session 的 active Turn,写入才允许不递增 `state_version`。跨 Turn、跨 session 以及无法证明所有权的写入仍必须递增版本。Provider 回放历史附件时,只有 user 输入和当前工具结果可生成模型原生媒体块;assistant/system 附件只回放文本清单,避免把图片放到供应商不接受的角色。 -SessionManager 负责组装会话上下文:系统提示、Skills、召回的 Knowledge、可选的 active plan 摘要,以及由活动 `ContextCheckpoint` 投影出的会话历史。`messages` 始终是 append-only 原始日志;每个 Session 最多有一个活动 checkpoint,模型历史确定为“一条累计摘要 + `seq >= first_retained_seq` 的原始尾部”,Timeline 只是 checkpoint 提交后的 best-effort 检索副本,恢复流程不读取 Timeline、也不调用 Provider。`session::turn_input` 只在 Session 锁外并行读取 Knowledge 和 active plan;完整请求草稿随后使用同一个 reserve 预算评估,自动触发公式唯一为 `context_tokens > context_window - effective_reserve`。手动、自动和首次 context-overflow 共用唯一的 `compact_session_context` 编排与 checkpoint CAS 提交路径;candidate 自带快照 generation,提交成功后必须从当前 raw log 重新投影,不能返回摘要前的旧尾部向量。摘要请求输入预算由当前模型 `token_limit` 扣除动态摘要输出、固定提示词和安全余量得到;超大压缩源只在 request-local 副本中保留已有 checkpoint 和最新材料、对单条内容做确定性 head/tail 截取,不能用固定 32K 上限拒绝压缩,也不能改写 durable raw log。每个 Turn 最多进行一次语义摘要;换模后的发送前预检若已硬超限,可在摘要失败时直接生成明确标记的确定性降级 checkpoint,避免先发送必然失败的普通请求;首次 Provider 请求 overflow 最多正式重试一次。AgentLoop 把 Provider overflow 转成类型化错误:工具尚未执行时交回 Session;工具已执行后只在同一个 AgentLoop 中删除旧完整 Turn 的请求副本并重试当前 Provider step 一次,保留本 Turn tool call/result,绝不从 durable history 重启并重复副作用工具。首次请求与重试必须复用同一个 runtime assembly,不能复制系统提示、丢失 mailbox steering 或把压缩投影写回原始历史。Provider prompt usage 仅在 provider/model/checkpoint generation/raw seq 和完整请求摘要都匹配时复用,否则完整保守估算;不再按消息数外推。普通闲聊 session 没有 plan 摘要;计划状态由 `WorkManager` 从 SQLite 读取,因此不以自然语言摘要作为权威来源。`AgentLoop` 接收完整输入执行一次模型/工具循环,本身不拥有会话状态,但通过本 Turn 的 mailbox 在安全边界接收追加用户输入。执行工具时额外传递只包含 session/turn 身份的 `ToolExecutionContext`;无状态工具使用默认实现忽略它,有状态外部适配器用它路由资源,但可按明确的单用户配置跨 dialog 共享,且不能自行反向查询 SessionManager。完整压缩设计见 [CONTEXT_COMPACTION_DESIGN.md](CONTEXT_COMPACTION_DESIGN.md)。 +SessionManager 负责组装会话上下文:系统提示、Skills、召回的 Knowledge、可选的 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 同时最多有一个标题任务,提交时仍校验标题保持默认值,避免覆盖用户改名。 diff --git a/docs/CONTEXT_COMPACTION_DESIGN.md b/docs/CONTEXT_COMPACTION_DESIGN.md index 3cb2451..c638a40 100644 --- a/docs/CONTEXT_COMPACTION_DESIGN.md +++ b/docs/CONTEXT_COMPACTION_DESIGN.md @@ -1,6 +1,6 @@ # PicoBot 上下文压缩架构设计与实施方案 -> 状态:已于 PicoBot 1.19.0 实施;2026-08-20 完成实现符合性修正 +> 状态:已于 PicoBot 1.19.0 实施;1.20.0 增加 Model 级窗口配置与 Agent 上限收紧 > 编写日期:2026-08-20 > 本文定义并记录 PicoBot 上下文压缩的架构、数据模型、触发规则、失败语义、迁移步骤和验收标准。运行时事实仍以代码和测试为准;第 2 节保留 1.19.0 之前的基线,便于解释迁移动机。 @@ -529,7 +529,7 @@ summary_source_budget = context_window - summary_safety ``` -`context_window` 来自当前 Agent 配置的 `token_limit`;Provider 在 overflow 错误中返回更小真实窗口时使用校准后的值。摘要源超过 `summary_source_budget` 时不能拒绝压缩,也不做多轮/递归摘要,而是只对发送给摘要 Provider 的副本执行有界序列化:已有 checkpoint 优先保留一部分,再从待压缩前缀的最新消息向前选择;装不下的单条记录保留 head/tail,并记录源消息数、省略数、截短数和策略。原始数据库历史保持不变。 +`context_window` 使用当前 Agent 的有效窗口:Model 的 `token_limit` 缺失时先回退 128000,再与可选 Agent `token_limit` 取最小值;Agent 因而只能收紧、不能扩大模型窗口。Provider 在 overflow 错误中返回更小真实窗口时使用校准后的值。摘要源超过 `summary_source_budget` 时不能拒绝压缩,也不做多轮/递归摘要,而是只对发送给摘要 Provider 的副本执行有界序列化:已有 checkpoint 优先保留一部分,再从待压缩前缀的最新消息向前选择;装不下的单条记录保留 head/tail,并记录源消息数、省略数、截短数和策略。原始数据库历史保持不变。 若窗口小到连最小摘要提示和材料都无法容纳,语义摘要视为失败:手动入口保留旧 checkpoint 并返回错误;自动入口在完整请求仍低于硬窗口时保留旧投影,已经硬超限时直接进入确定性 overflow 降级;overflow 入口同样进入确定性降级。 @@ -990,6 +990,7 @@ cargo build 11. 普通压缩失败不会无声丢弃历史;只有 overflow 使用明确标记的整 Turn 降级。 12. 运行时不存在第二套按 70%/90% 比例判断的旧压缩路径。 13. 摘要输入上限从当前模型窗口推导,超大历史通过 request-local 有界转录进入一次摘要调用,不因固定上限拒绝压缩。 +14. 主 Agent 与具名 Agent 都先以 Model 配置或 128K 默认值确定硬上限,再与可选 Agent 上限取最小值。 ## 22. 最终取舍 diff --git a/resources/skills/about-picobot/assets/config.example.json b/resources/skills/about-picobot/assets/config.example.json index 1ec2ebe..31ad56e 100644 --- a/resources/skills/about-picobot/assets/config.example.json +++ b/resources/skills/about-picobot/assets/config.example.json @@ -23,7 +23,8 @@ "qwen-plus": { "model_id": "qwen-plus", "temperature": 0.0, - "max_tokens": 8192 + "max_tokens": 8192, + "token_limit": 128000 }, "gpt-4o": { "model_id": "gpt-4o", @@ -42,8 +43,7 @@ "default": { "provider": "aliyun", "model": "qwen-plus", - "max_tool_iterations": 99, - "token_limit": 128000 + "max_tool_iterations": 99 } }, "context_compaction": { diff --git a/resources/skills/about-picobot/references/architecture.md b/resources/skills/about-picobot/references/architecture.md index 5276bae..8c51147 100644 --- a/resources/skills/about-picobot/references/architecture.md +++ b/resources/skills/about-picobot/references/architecture.md @@ -77,7 +77,7 @@ Scheduler → SessionManager.handle_cron_message → AgentLoop → send_message ## 上下文压缩 -`messages` 是 append-only 原始日志,压缩不会改写消息、工具结果、ID 或 seq。每个 session 最多有一个活动 `ContextCheckpoint`;Provider 历史确定为“一条累计摘要 + `seq >= first_retained_seq` 的原始尾部”。自动压缩使用 `context_tokens > context_window - effective_reserve`,默认 reserve 16,384、近期原样保留 20,000 tokens;小窗口会自适应缩小两者。摘要输入预算由当前模型 `token_limit` 扣除摘要输出、提示词和安全余量得到;超大历史生成 checkpoint 加最新材料优先的有界 request-local 转录,不受固定 32K 上限约束。`/compact`、自动入口和首次 overflow 复用同一压缩编排和 checkpoint 原子提交路径,每次最多一次摘要调用。真实 Provider overflow 或换模后发送前已检测到的硬超限,能在摘要不可用时生成明确标记的确定性降级 checkpoint,并且正式 Provider 请求只重试一次。工具已经执行后若发生 overflow,只在同一个 AgentLoop 中保留本 Turn 工具链、裁掉旧完整 Turn 的请求副本并重试当前模型步骤一次,不从 durable history 重跑工具。 +`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 重跑工具。 ## Skill 系统 diff --git a/resources/skills/about-picobot/references/config.md b/resources/skills/about-picobot/references/config.md index 72c2119..17c3687 100644 --- a/resources/skills/about-picobot/references/config.md +++ b/resources/skills/about-picobot/references/config.md @@ -42,6 +42,7 @@ Gateway WebUI 的“配置”页可以编辑实际加载的配置文件。读取 | `model_id` | 模型标识名称 | | `temperature` | 采样温度,可选 | | `max_tokens` | 最大输出 token 数,可选 | +| `token_limit` | 模型上下文窗口硬上限,可选;未配置时默认为 128000,Agent 只能进一步收紧 | | `input_type` | 模型支持的输入类型,如 `["text"]` 或 `["text", "image"]`,默认 `["text"]`. 纯内部使用,不会传递给 LLM API | ## agents 字段 @@ -51,7 +52,7 @@ Gateway WebUI 的“配置”页可以编辑实际加载的配置文件。读取 | `provider` | string | - | 提供商名称(对应 providers key) | | `model` | string | - | 模型名称(对应 models key) | | `max_tool_iterations` | int | 99 | 最大工具调用轮数 | -| `token_limit` | int | 128000 | 上下文 token 限制 | +| `token_limit` | int | 使用模型上限 | 可选的 Agent 上限;有效窗口取 Agent 与模型(模型未配置时为 128000)的最小值 | ## context_compaction 字段 diff --git a/resources/skills/about-picobot/references/faq.md b/resources/skills/about-picobot/references/faq.md index 172b822..2e589d0 100644 --- a/resources/skills/about-picobot/references/faq.md +++ b/resources/skills/about-picobot/references/faq.md @@ -50,7 +50,7 @@ Skill 安装后默认启用,可在 WebUI「工具 → Skills」页用开关禁 ## Q: 上下文压缩是什么意思? -对话接近模型 token 限制时,PicoBot 用一份累计 checkpoint 摘要替代 Provider 上下文中的旧前缀,并原样保留近期消息尾部。原始消息和工具结果仍永久保存在聊天历史/SQLite 中,只是不再永久占用模型上下文;语义摘要还可通过 `timeline_recall` 检索。自动阈值采用窗口减 reserve 的机制,摘要输入按当前模型窗口动态限制而非固定 32K;换成更小模型后若历史已经超限,会在首次普通模型请求前压缩或明确降级。`/compact` 可在阈值前手动强制执行。 +对话接近模型 token 限制时,PicoBot 用一份累计 checkpoint 摘要替代 Provider 上下文中的旧前缀,并原样保留近期消息尾部。原始消息和工具结果仍永久保存在聊天历史/SQLite 中,只是不再永久占用模型上下文;语义摘要还可通过 `timeline_recall` 检索。Model 的 `token_limit` 是窗口硬上限,未配置时为 128K;Agent 的可选 `token_limit` 只能收紧它,两者都有时取最小值。自动阈值采用窗口减 reserve 的机制,摘要输入按有效窗口动态限制而非固定 32K;换成更小模型后若历史已经超限,会在首次普通模型请求前压缩或明确降级。`/compact` 可在阈值前手动强制执行。 ## Q: 如何修改 gateway 监听端口? diff --git a/resources/templates/config.example.json b/resources/templates/config.example.json index 669f9f6..a7419c7 100644 --- a/resources/templates/config.example.json +++ b/resources/templates/config.example.json @@ -23,7 +23,8 @@ "qwen-plus": { "model_id": "qwen-plus", "temperature": 0.0, - "max_tokens": 8192 + "max_tokens": 8192, + "token_limit": 128000 }, "gpt-4o": { "model_id": "gpt-4o", @@ -42,8 +43,7 @@ "default": { "provider": "aliyun", "model": "qwen-plus", - "max_tool_iterations": 99, - "token_limit": 128000 + "max_tool_iterations": 99 } }, "context_compaction": { diff --git a/src/agent/catalog.rs b/src/agent/catalog.rs index 7fc841f..c216282 100644 --- a/src/agent/catalog.rs +++ b/src/agent/catalog.rs @@ -4,6 +4,7 @@ use std::sync::Arc; use crate::config::{ AgentOrchestrationConfig, LLMProviderConfig, ModelConfig, ProviderConfig, expand_path, + resolve_token_limit, }; use crate::skills::SkillsLoader; use crate::tools::ToolRegistry; @@ -427,7 +428,7 @@ fn resolve_provider( max_tokens: model.max_tokens, model_extra: model.extra.clone(), max_tool_iterations: spec.max_tool_iterations.unwrap_or(99), - token_limit: 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(), input_types: model.input_type.clone(), price_input_per_million: None, @@ -435,13 +436,14 @@ fn resolve_provider( }); } let profile = spec.llm_profile.as_deref().unwrap_or_default(); - provider_profiles - .get(profile) - .cloned() - .ok_or_else(|| AgentCatalogError::UnknownProfile { + let mut resolved = provider_profiles.get(profile).cloned().ok_or_else(|| { + AgentCatalogError::UnknownProfile { agent: spec.id.clone(), profile: profile.to_string(), - }) + } + })?; + resolved.token_limit = resolve_token_limit(spec.token_limit, Some(resolved.token_limit)); + Ok(resolved) } fn validate_definition_tools( @@ -639,6 +641,150 @@ mod tests { assert_eq!(catalog.runtime_generation(), 7); } + #[test] + fn inline_agent_context_window_uses_agent_model_minimum_then_default() { + let providers = HashMap::from([( + "provider".to_string(), + ProviderConfig { + provider_type: "openai".to_string(), + base_url: "https://example.invalid/v1".to_string(), + api_key: "test".to_string(), + extra_headers: HashMap::new(), + }, + )]); + let mut models = HashMap::from([( + "model".to_string(), + ModelConfig { + model_id: "model-id".to_string(), + temperature: None, + max_tokens: None, + token_limit: Some(64_000), + input_type: vec!["text".to_string()], + extra: HashMap::new(), + }, + )]); + let mut spec = ProviderSpec { + id: "inline".to_string(), + llm_profile: None, + provider: Some("provider".to_string()), + model: Some("model".to_string()), + token_limit: None, + max_tool_iterations: None, + enabled: true, + }; + + assert_eq!( + resolve_provider( + &spec, + &HashMap::new(), + &providers, + &models, + Path::new("/tmp") + ) + .unwrap() + .token_limit, + 64_000 + ); + + spec.token_limit = Some(32_000); + assert_eq!( + resolve_provider( + &spec, + &HashMap::new(), + &providers, + &models, + Path::new("/tmp") + ) + .unwrap() + .token_limit, + 32_000 + ); + + spec.token_limit = Some(128_000); + assert_eq!( + resolve_provider( + &spec, + &HashMap::new(), + &providers, + &models, + Path::new("/tmp") + ) + .unwrap() + .token_limit, + 64_000 + ); + + spec.token_limit = None; + models.get_mut("model").unwrap().token_limit = None; + assert_eq!( + resolve_provider( + &spec, + &HashMap::new(), + &providers, + &models, + Path::new("/tmp") + ) + .unwrap() + .token_limit, + 128_000 + ); + } + + #[test] + fn profile_agent_context_window_can_only_narrow_resolved_profile() { + let profiles = HashMap::from([("profile".to_string(), provider())]); + let mut spec = ProviderSpec { + id: "profile-agent".to_string(), + llm_profile: Some("profile".to_string()), + provider: None, + model: None, + token_limit: None, + max_tool_iterations: None, + enabled: true, + }; + + assert_eq!( + resolve_provider( + &spec, + &profiles, + &HashMap::new(), + &HashMap::new(), + Path::new("/tmp") + ) + .unwrap() + .token_limit, + 4_096 + ); + + spec.token_limit = Some(2_048); + assert_eq!( + resolve_provider( + &spec, + &profiles, + &HashMap::new(), + &HashMap::new(), + Path::new("/tmp") + ) + .unwrap() + .token_limit, + 2_048 + ); + + spec.token_limit = Some(8_192); + assert_eq!( + resolve_provider( + &spec, + &profiles, + &HashMap::new(), + &HashMap::new(), + Path::new("/tmp") + ) + .unwrap() + .token_limit, + 4_096 + ); + } + #[test] fn delegation_semantics_default_empty_any_and_list() { let root = tempfile::tempdir().unwrap(); diff --git a/src/config/mod.rs b/src/config/mod.rs index e4fe9af..4736cf7 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -159,6 +159,9 @@ pub struct ModelConfig { pub temperature: Option, #[serde(default)] pub max_tokens: Option, + /// Model context-window capacity. Agent profiles may narrow it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub token_limit: Option, #[serde(default = "default_input_type")] pub input_type: Vec, #[serde(flatten)] @@ -175,8 +178,10 @@ pub struct AgentConfig { pub model: String, #[serde(default = "default_max_tool_iterations")] pub max_tool_iterations: usize, - #[serde(default = "default_token_limit")] - pub token_limit: usize, + /// Optional per-Agent context-window cap. It may narrow but never expand + /// the selected model's limit; a missing model limit falls back to 128K. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub token_limit: Option, } fn default_max_tool_iterations() -> usize { @@ -187,6 +192,17 @@ fn default_token_limit() -> usize { 128_000 } +/// Resolve the effective context window. Agent values are optional caps: they +/// may narrow a model window but can never expand it. A missing model window +/// is the product's conservative 128K default. +pub(crate) fn resolve_token_limit( + agent_token_limit: Option, + model_token_limit: Option, +) -> 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 { @@ -915,7 +931,7 @@ impl Config { max_tokens: model.max_tokens, model_extra: model.extra.clone(), 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), input_types: model.input_type.clone(), price_input_per_million: None, @@ -1193,6 +1209,38 @@ mod tests { assert_eq!(provider_config.name, "aliyun"); assert_eq!(provider_config.model_id, "qwen-plus"); assert_eq!(provider_config.temperature, Some(0.0)); + assert_eq!(provider_config.token_limit, 128_000); + } + + #[test] + fn context_window_uses_agent_model_minimum_then_128k_default() { + let file = write_test_config(); + let mut config = Config::load(file.path().to_str().unwrap()).unwrap(); + + config.models.get_mut("qwen-plus").unwrap().token_limit = Some(64_000); + assert_eq!( + config.get_provider_config("default").unwrap().token_limit, + 64_000 + ); + + config.agents.get_mut("default").unwrap().token_limit = Some(32_000); + assert_eq!( + config.get_provider_config("default").unwrap().token_limit, + 32_000 + ); + + config.agents.get_mut("default").unwrap().token_limit = Some(128_000); + assert_eq!( + config.get_provider_config("default").unwrap().token_limit, + 64_000 + ); + + config.agents.get_mut("default").unwrap().token_limit = None; + config.models.get_mut("qwen-plus").unwrap().token_limit = None; + assert_eq!( + config.get_provider_config("default").unwrap().token_limit, + 128_000 + ); } #[test] diff --git a/src/gateway/http.rs b/src/gateway/http.rs index eaa5a32..86aa3d2 100644 --- a/src/gateway/http.rs +++ b/src/gateway/http.rs @@ -1013,7 +1013,13 @@ pub async fn get_agent_options( .config .models .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(); let registry = state.session_manager.tools(); let mut tools: Vec = registry diff --git a/webui/package-lock.json b/webui/package-lock.json index 1657946..4e03945 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -1,12 +1,12 @@ { "name": "picobot-webui", - "version": "1.19.0", + "version": "1.20.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "picobot-webui", - "version": "1.19.0", + "version": "1.20.0", "dependencies": { "bits-ui": "^2.0.0", "dompurify": "^3.4.12", diff --git a/webui/package.json b/webui/package.json index 35bd797..9e319eb 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,7 +1,7 @@ { "name": "picobot-webui", "private": true, - "version": "1.19.0", + "version": "1.20.0", "type": "module", "engines": { "node": ">=20" diff --git a/webui/src/lib/components/SubAgentDefinitions.svelte b/webui/src/lib/components/SubAgentDefinitions.svelte index a637756..3e7f881 100644 --- a/webui/src/lib/components/SubAgentDefinitions.svelte +++ b/webui/src/lib/components/SubAgentDefinitions.svelte @@ -338,13 +338,13 @@
-