feat(config): add model context window limits

This commit is contained in:
xiaoxixi 2026-08-20 17:25:07 +08:00
parent 2d7bd090fc
commit 93e74120ac
17 changed files with 237 additions and 35 deletions

View File

@ -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

View File

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

View File

@ -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` 清理过期 TimelineKnowledge 不会被自动删除。
自动压缩使用保留量阈值 `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.<name>.token_limit` 给出上下文窗口上限,未配置时默认为 128,000Agent 的 `agents.<name>.token_limit` 是可选的收紧上限,两者都有配置时有效窗口取二者最小值,因此 Agent 不能扩大模型窗口。自动压缩使用保留量阈值 `context_tokens > context_window - effective_reserve`,默认 reserve 为 16,384 tokens并尽量原样保留最近 20,000 tokens。小窗口会自动把 reserve 限制为窗口的一半、把近期保留量限制为有效阈值的一半。摘要请求不使用固定 32K 输入上限,而是按有效窗口扣除摘要输出、提示词和安全余量;超大历史只在摘要请求副本中按“已有 checkpoint + 最新消息优先”生成有界 head/tail 转录SQLite 原文不变。手动 `/compact` 跳过自动阈值;换成小模型后若发送前预检已发现硬超限,或首次请求返回真实 context overflow语义摘要不可用时才使用明确标记的确定性降级裁剪正式请求最多重试一次。若 overflow 发生在工具已经执行之后AgentLoop 只在当前内存转录上裁掉旧完整 Turn 并重试当前模型步骤一次,不会从数据库历史重跑工具。
### 工具

View File

@ -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": {

View File

@ -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` 定义上下文硬上限,缺失时固定回退 128000Agent 的可选 `token_limit` 只允许收紧该上限,有效窗口为 `min(agent_token_limit, model_token_limit_or_128K)`Agent 不能扩大模型窗口。主 Agent Profile、具名 Agent 的内联 Provider/Model 和 `llm_profile` 引用必须使用同一规则definition 上的显式值也只能收紧已解析 Profile。`session::turn_input` 只在 Session 锁外并行读取 Knowledge 和 active plan完整请求草稿随后使用同一个 reserve 预算评估,自动触发公式唯一为 `context_tokens > context_window - effective_reserve`。手动、自动和首次 context-overflow 共用唯一的 `compact_session_context` 编排与 checkpoint CAS 提交路径candidate 自带快照 generation提交成功后必须从当前 raw log 重新投影,不能返回摘要前的旧尾部向量。摘要请求输入预算由有效 `token_limit` 扣除动态摘要输出、固定提示词和安全余量得到;超大压缩源只在 request-local 副本中保留已有 checkpoint 和最新材料、对单条内容做确定性 head/tail 截取,不能用固定 32K 上限拒绝压缩,也不能改写 durable raw log。每个 Turn 最多进行一次语义摘要;换模后的发送前预检若已硬超限,可在摘要失败时直接生成明确标记的确定性降级 checkpoint避免先发送必然失败的普通请求首次 Provider 请求 overflow 最多正式重试一次。AgentLoop 把 Provider overflow 转成类型化错误:工具尚未执行时交回 Session工具已执行后只在同一个 AgentLoop 中删除旧完整 Turn 的请求副本并重试当前 Provider step 一次,保留本 Turn tool call/result绝不从 durable history 重启并重复副作用工具。首次请求与重试必须复用同一个 runtime assembly不能复制系统提示、丢失 mailbox steering 或把压缩投影写回原始历史。Provider prompt usage 仅在 provider/model/checkpoint generation/raw seq 和完整请求摘要都匹配时复用,否则完整保守估算;不再按消息数外推。普通闲聊 session 没有 plan 摘要;计划状态由 `WorkManager` 从 SQLite 读取,因此不以自然语言摘要作为权威来源。`AgentLoop` 接收完整输入执行一次模型/工具循环,本身不拥有会话状态,但通过本 Turn 的 mailbox 在安全边界接收追加用户输入。执行工具时额外传递只包含 session/turn 身份的 `ToolExecutionContext`;无状态工具使用默认实现忽略它,有状态外部适配器用它路由资源,但可按明确的单用户配置跨 dialog 共享,且不能自行反向查询 SessionManager。完整压缩设计见 [CONTEXT_COMPACTION_DESIGN.md](CONTEXT_COMPACTION_DESIGN.md)。
当前 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 同时最多有一个标题任务,提交时仍校验标题保持默认值,避免覆盖用户改名。

View File

@ -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. 最终取舍

View File

@ -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": {

View File

@ -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 系统

View File

@ -42,6 +42,7 @@ Gateway WebUI 的“配置”页可以编辑实际加载的配置文件。读取
| `model_id` | 模型标识名称 |
| `temperature` | 采样温度,可选 |
| `max_tokens` | 最大输出 token 数,可选 |
| `token_limit` | 模型上下文窗口硬上限,可选;未配置时默认为 128000Agent 只能进一步收紧 |
| `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 字段

View File

@ -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` 是窗口硬上限,未配置时为 128KAgent 的可选 `token_limit` 只能收紧它,两者都有时取最小值。自动阈值采用窗口减 reserve 的机制,摘要输入按有效窗口动态限制而非固定 32K换成更小模型后若历史已经超限会在首次普通模型请求前压缩或明确降级。`/compact` 可在阈值前手动强制执行。
## Q: 如何修改 gateway 监听端口?

View File

@ -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": {

View File

@ -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();

View File

@ -159,6 +159,9 @@ pub struct ModelConfig {
pub temperature: Option<f32>,
#[serde(default)]
pub max_tokens: Option<u32>,
/// Model context-window capacity. Agent profiles may narrow it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_limit: Option<usize>,
#[serde(default = "default_input_type")]
pub input_type: Vec<String>,
#[serde(flatten)]
@ -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<usize>,
}
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<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 {
@ -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]

View File

@ -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<Value> = registry

View File

@ -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",

View File

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

View File

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