Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ MINICODE_MODEL=deepseek-v4-pro
OPENAI_BASE_URL=https://api.deepseek.com
MINICODE_MODEL_TIMEOUT=120
MINICODE_MAX_STEPS=12
# Optional cost controls; leave blank to disable either limit.
MINICODE_SESSION_TOKEN_BUDGET=
MINICODE_MAX_OUTPUT_TOKENS=
# MINICODE_SYSTEM_PROMPT=You are a careful local coding assistant.
MINICODE_CONTEXT_TOKENS=16000
MINICODE_CONTEXT_TRIGGER=0.8
Expand Down
25 changes: 21 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ MiniCode Rebuild 是一个从零、分阶段实现的本地终端 AI Coding Agen

## 当前状态

阶段 0“仓库初始化与工程基线”至阶段 10“可观测性、质量与发布准备”已经完成;阶段 11 已选择并实现独立高级能力“长期记忆与检索”。
阶段 0“仓库初始化与工程基线”至阶段 10“可观测性、质量与发布准备”已经完成;阶段 11 已实现“长期记忆与检索”,阶段 12 已实现独立高级能力“成本控制”。

目前已经具备:

Expand Down Expand Up @@ -38,11 +38,13 @@ MiniCode Rebuild 是一个从零、分阶段实现的本地终端 AI Coding Agen
- 将脱敏生命周期元数据写入工作区 JSONL 日志,并通过时间线查看运行过程;
- 在工作区本地显式保存长期记忆,并通过有界词法检索按需召回;
- 将记忆结果标记为不可信历史数据,模型写入和删除仍经过权限边界;
- 为会话设置可选 token 预算,并在请求前估算输入和工具协议成本;
- 按剩余额度限制单次模型输出,预算不足时不调用 Provider;
- 离线检查 Python、运行配置、Provider 配置、会话存储、Skills 与记忆存储 readiness;
- 使用 Ruff、Mypy、分支覆盖率、构建、安装和跨平台 CI 作为发布质量门禁;
- 执行自动化测试。

阶段 0 至阶段 10 的基础路线和阶段 11 的“长期记忆与检索”已经完成。其余高级能力仍必须单独选择、设计、测试和提交。
阶段 0 至阶段 10 的基础路线、阶段 11“长期记忆与检索”和阶段 12“成本控制”已经完成。其余高级能力仍必须单独选择、设计、测试和提交。

## 长期记忆与检索

Expand All @@ -61,6 +63,19 @@ MiniCode Rebuild 是一个从零、分阶段实现的本地终端 AI Coding Agen

存储最多 500 条记忆;单条内容、标签、查询、返回数量和结果预览均有上限。检索使用无网络、无第三方依赖的确定性词法评分,适合项目约定、用户明确偏好和长期任务事实,不等同于 embedding 语义搜索。检索结果始终带有“不可信历史数据”边界,不得覆盖当前系统或用户指令,也可能已经过时。

## Token 成本控制

成本控制是显式启用的 Provider 无关 token 门禁。可以限制整个持久化会话的累计 token 用量,也可以单独限制每次模型响应的最大输出:

```powershell
minicode-rebuild --token-budget 50000 --max-output-tokens 2000 "分析当前项目"
minicode-rebuild --interactive --resume latest --token-budget 50000
```

请求发出前,运行时会估算消息与工具声明占用的输入 token,并从会话剩余额度中扣除输入预留,再把允许的输出上限映射到 OpenAI-compatible `max_tokens`。如果请求至少需要的输入和一个输出 token 都无法容纳,Agent 以 `budget_exhausted` 停止,不调用 Provider。恢复会话时,门禁会继续使用已经持久化的 Provider token 统计。交互模式可用 `/budget` 查看限制、已用量和剩余额度。

该能力用于阻止失控的多步调用,不等同于精确账单上限。输入预算使用跨 Provider 启发式估算;服务端实际计费、缓存 token、推理 token 和价格规则由 Provider 决定。当前请求的真实用量只能在响应返回后得知,因此可能小幅越过估算值,但后续请求会使用更新后的服务端统计重新检查。若 Provider 不返回 usage,累计会话用量也无法精确增长;需要硬货币限额时仍应在 Provider 账户侧设置配额。

## 可观测性与 Readiness

每次 CLI 会话默认把生命周期元数据追加到工作区 `.minicode-rebuild/events.jsonl`。日志只包含时间、事件名、session ID、工具名、成功状态、错误代码和停止原因;不保存用户提示、工具参数、工具输出或 API Key。该目录已从 Git 和模型通用文件工具中隔离。
Expand Down Expand Up @@ -144,6 +159,8 @@ python -m minicode_rebuild --help
| `OPENAI_BASE_URL` | `https://api.deepseek.com` | API 基址或完整 `/chat/completions` 地址 |
| `MINICODE_MODEL_TIMEOUT` | `120` | 请求超时秒数,必须是正整数 |
| `MINICODE_MAX_STEPS` | `12` | 每轮最大模型调用步数,必须是正整数 |
| `MINICODE_SESSION_TOKEN_BUDGET` | 无 | 可选的持久化会话累计 token 预算,必须是正整数 |
| `MINICODE_MAX_OUTPUT_TOKENS` | 无 | 可选的单次模型响应 token 上限,必须是正整数 |
| `MINICODE_SYSTEM_PROMPT` | 内置安全提示 | 覆盖本进程使用的系统提示 |
| `MINICODE_CONTEXT_TOKENS` | `16000` | 单轮输入的启发式上下文预算 |
| `MINICODE_CONTEXT_TRIGGER` | `0.8` | 达到预算比例后自动压缩,范围 `(0, 1]` |
Expand Down Expand Up @@ -190,11 +207,11 @@ minicode-rebuild --interactive --resume latest
minicode-rebuild --resume <session-id> "继续上次任务"
```

交互模式提供 `/help`、`/session`、`/sessions`、`/transcript`、`/checkpoints`、`/rewind-preview [checkpoint-id]`、`/rewind [checkpoint-id]`、`/skills`、`/memory`、`/timeline`、`/stats`、`/compact` 和 `/exit`。`/rewind` 总会先显示预览,只有随后完整输入 `yes` 才修改文件;发现 Agent 写入后又有外部修改时会拒绝覆盖。
交互模式提供 `/help`、`/session`、`/sessions`、`/transcript`、`/checkpoints`、`/rewind-preview [checkpoint-id]`、`/rewind [checkpoint-id]`、`/skills`、`/memory`、`/timeline`、`/budget`、`/stats`、`/compact` 和 `/exit`。`/rewind` 总会先显示预览,只有随后完整输入 `yes` 才修改文件;发现 Agent 写入后又有外部修改时会拒绝覆盖。

会话 JSON、长期记忆和事件日志位于工作区 `.minicode-rebuild/`,已从 Git 与内置通用文件工具中隔离。Checkpoint 只覆盖 `write_file`、`edit_file` 和 `patch_file` 的 UTF-8 文件变更;`run_command` 的任意副作用和专用记忆存储不在 Rewind 范围内。写文件、运行命令和模型发起的记忆变更仍会显示风险与操作详情,并要求选择一次允许、会话允许或拒绝。Headless 模式默认拒绝所有变更;只有明确传入 `--allow-mutations` 才会在本次运行内逐项自动批准,并在标准错误输出警告。

每轮会输出模型步数、工具次数、模型返回的 token 用量和压缩次数。上下文估算是跨 Provider 的保守启发式,不等同于服务端精确 tokenizer;工具结果会优先裁剪,旧轮次按用户输入边界摘要,并始终保留最近完整轮次和主系统提示。会话恢复加载的是受预算约束的工作历史,`/transcript` 则保留完整、未压缩的用户消息、assistant 工具调用和工具结果。
每轮会输出模型步数、工具次数、模型返回的 token 用量和压缩次数。上下文估算是跨 Provider 的保守启发式,不等同于服务端精确 tokenizer;工具结果会优先裁剪,旧轮次按用户输入边界摘要,并始终保留最近完整轮次和主系统提示。会话恢复加载的是受上下文预算约束的工作历史,并继续累计可选的会话 token 成本预算;`/transcript` 则保留完整、未压缩的用户消息、assistant 工具调用和工具结果。

最小的库调用边界如下:

Expand Down
80 changes: 74 additions & 6 deletions docs/REBUILD_LOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@

| 项目 | 内容 |
|---|---|
| 当前阶段 | 阶段 11:长期记忆与检索(已完成) |
| 最近完成 | 阶段 11:长期记忆与检索 |
| 当前分支 | `codex/phase-11-memory` |
| 最新阶段实现提交 | `d06ad40 feat(phase-11): add workspace long-term memory` |
| 测试状态 | 阶段相关回归 `61 passed, 1 skipped`;全量回归 `276 passed, 3 skipped`;分支覆盖率 `85.47%` |
| 下一步 | 审核并由用户合并 Draft PR #5;其他高级能力继续保持独立阶段 |
| 当前阶段 | 阶段 12:成本控制(已完成) |
| 最近完成 | 阶段 12:Token 成本控制 |
| 当前分支 | `codex/phase-12-cost-control` |
| 最新阶段实现提交 | `fa243dc feat(phase-12): add token cost controls` |
| 测试状态 | 阶段相关回归 `107 passed`;全量回归 `298 passed, 3 skipped`;分支覆盖率 `85.58%` |
| 下一步 | 审核并由用户合并 Draft PR #6;其他高级能力继续保持独立阶段 |

## 总体架构

Expand Down Expand Up @@ -1803,3 +1803,71 @@ Draft PR #5 的首轮 Windows/Ubuntu、Python 3.11/3.13 四组任务都在同一
修复只在 `MemoryStore` 构造阶段区分该稳定错误码,继续拒绝操作,并增加不依赖主机符号链接权限的错误映射单元测试。修复后本地完整发布门禁为 `276 passed, 3 skipped`、覆盖率 `85.47%`,Ruff、Mypy、编译、构建与 Mock 演示全部通过;最终跨平台结果以重新触发的 PR #5 CI 为准。

`ee6f237` 推送后,GitHub Actions 的 Ubuntu 3.11、Ubuntu 3.13、Windows 3.11、Windows 3.13 四组任务全部通过。PR #5 保持 Draft、以 `master` 为基线且可合并;阶段 11 不自动修改或合并主分支。

## 阶段 12:成本控制

### 1. 开发前计划

- 只实现阶段 11 高级能力清单中的“成本控制”,不同时引入模型路由、MCP、多 Agent、Worktree 编排或 TUI。
- 使用 Provider 无关的 token 数量作为稳定控制单位,不内置会随时间变化的模型价格,也不宣称计算精确货币账单。
- 支持可选的持久化会话累计预算和单次响应输出上限;两者都未配置时保持此前行为。
- 在每次 Provider 调用前估算消息与工具声明的输入 token,预算不足时失败关闭,不发送网络请求。
- 将当前剩余额度映射为规范化 `ModelRequest.max_output_tokens`,OpenAI-compatible 适配器再写入 `max_tokens`。
- 复用阶段 6 的会话 token 统计,使恢复会话继续消费同一预算;交互 CLI 通过 `/budget` 展示当前状态。
- 为配置校验、请求估算、输出收紧、调用前拒绝、跨模型步骤累计、会话恢复、CLI 和适配器序列化补齐测试。

### 2. 威胁模型与非目标

- 防止失控循环持续调用模型:每个模型步骤都重新执行预算门禁,而不是只在一轮开始时检查一次。
- 防止“大上下文 + 大输出上限”突破预留:输入估算先占用剩余额度,输出上限只能使用其余空间。
- 防止无效配置静默失效:环境变量和 CLI 参数都只接受正整数,零、负数和非整数直接返回配置错误。
- 防止恢复会话绕过累计限制:门禁使用持久化的 input/output token 统计作为已用量。
- 本阶段不维护 Provider 价格表,不计算人民币或美元,不解析缓存、推理等厂商专有 token,也不替代 Provider 账户配额。

### 3. 预算模型

`TokenBudgetPolicy` 包含两个独立可选限制:`session_tokens` 控制一个持久化会话累计的 Provider 报告 token,`max_output_tokens` 控制每次响应的最大输出。`estimate_request_tokens()` 复用阶段 7 的中英文启发式,并额外计入工具名称、描述和 JSON Schema。

每次模型调用前计算:

```text
remaining = session_budget - persisted_and_current_usage
available_output = remaining - estimated_input
request.max_output_tokens = min(configured_output_limit, available_output)
```

若 `available_output < 1`,Agent 以 `budget_exhausted` 停止,模型适配器不会收到请求。若只配置输出上限,则每个请求都使用固定上限;若只配置会话预算,则输出上限根据剩余额度动态收紧。

### 4. Agent、会话与 CLI 集成

`run_agent_turn()` 在消息压缩完成、构造 Provider 请求之后执行门禁,因此估算针对实际即将发送的消息。工具返回后进入下一模型步骤时会再次检查,并计入本轮前序响应的 usage。门禁拒绝不执行新的 Provider 请求,也不伪造模型回答。

`AgentSession` 把已经持久化的 input/output token 作为本轮起始用量,因而 `--resume` 无法重置会话预算。`/budget` 直接显示预算、Provider 报告用量、剩余额度和单次输出上限,不调用模型。Headless 和交互模式都可使用 `--token-budget`、`--max-output-tokens`,也可通过 `MINICODE_SESSION_TOKEN_BUDGET`、`MINICODE_MAX_OUTPUT_TOKENS` 配置。

### 5. 验收与安全回归

- 阶段相关回归:`107 passed`。
- 全量回归:`298 passed, 3 skipped`。
- 分支覆盖率:`85.58%`,达到 `85%` 门槛。
- Ruff:`All checks passed!`。
- Mypy:`Success: no issues found in 29 source files`。
- 测试证明预算不足时 Provider 零调用、单次输出上限正确下传、多步工具循环重新检查、恢复会话沿用已报告用量、无效配置被拒绝。

当前 Windows 环境的 `3 skipped` 仍是缺少目录符号链接权限的安全测试;Ubuntu CI 会执行真实符号链接路径。该环境限制与成本控制无关。

### 6. 限制与后续边界

输入 token 是确定但近似的跨 Provider 估算,不是服务端 tokenizer。实际 usage 只能在响应后获得,所以单次请求可能因估算偏差略微越过会话预算;之后的模型步骤会使用更新后的真实统计拒绝继续调用。若 Provider 不返回 usage,累计统计无法精确增长,但单次输出上限和请求前估算仍然生效。

不同 Provider 对 `max_tokens`、隐藏推理 token、缓存命中和计费规则的解释可能不同。需要不可突破的货币限额时,必须同时使用 Provider 账户侧预算、限流或预付额度。本阶段不通过硬编码价格或猜测隐藏用量制造虚假的精确性。

### 7. Git 记录

- 基线:阶段 11 的 PR #5 已合并至 `master`,合并提交为 `4bc6e26`。
- 分支:`codex/phase-12-cost-control`。
- 实现提交:`fa243dc feat(phase-12): add token cost controls`。
- 文档收口使用独立提交;分支推送后创建以 `master` 为基线的 Draft PR,不自动合并。

### 8. 跨平台验证记录

阶段 12 分支推送后创建 Draft PR #6。GitHub Actions 运行 `32097364115` 的 Ubuntu 3.11、Ubuntu 3.13、Windows 3.11、Windows 3.13 四组任务全部通过;PR 以 `master` 为基线并保持 Draft,不自动合并主分支。
30 changes: 30 additions & 0 deletions src/minicode_rebuild/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
TokenUsage,
ToolCall,
)
from minicode_rebuild.cost import TokenBudgetPolicy, evaluate_budget
from minicode_rebuild.tooling import ToolContext, ToolRegistry, ToolResult

DEFAULT_MAX_STEPS = 12
Expand All @@ -30,6 +31,7 @@ class AgentStopReason(str, Enum):
EMPTY_RESPONSE = "empty_response"
MAX_STEPS = "max_steps"
MODEL_ERROR = "model_error"
BUDGET_EXHAUSTED = "budget_exhausted"


@dataclass(frozen=True, slots=True)
Expand Down Expand Up @@ -136,6 +138,8 @@ def run_agent_turn(
max_steps: int = DEFAULT_MAX_STEPS,
tool_observer: ToolObserver | None = None,
message_preparer: MessagePreparer | None = None,
token_budget: TokenBudgetPolicy | None = None,
used_tokens: int = 0,
) -> AgentResult:
"""Run one bounded turn until final text or an explicit stop condition."""

Expand All @@ -144,6 +148,13 @@ def run_agent_turn(
raise TypeError("tools must be a ToolRegistry")
if not isinstance(context, ToolContext):
raise TypeError("context must be a ToolContext")
if isinstance(used_tokens, bool) or not isinstance(used_tokens, int):
raise TypeError("used_tokens must be an integer")
if used_tokens < 0:
raise ValueError("used_tokens must not be negative")
budget = token_budget or TokenBudgetPolicy()
if not isinstance(budget, TokenBudgetPolicy):
raise TypeError("token_budget must be a TokenBudgetPolicy")

messages = _initial_messages(
user_message=user_message,
Expand All @@ -168,6 +179,25 @@ def run_agent_turn(
messages=request_messages,
tools=tools.model_tools(),
)
decision = evaluate_budget(
request,
budget,
used_tokens=used_tokens + usage.total_tokens,
)
if not decision.allowed:
return _result(
content=decision.reason or "Token budget exhausted.",
stop_reason=AgentStopReason.BUDGET_EXHAUSTED,
messages=messages,
steps=step - 1,
tool_calls=tool_call_count,
usage=usage,
)
request = ModelRequest(
messages=request_messages,
tools=request.tools,
max_output_tokens=decision.max_output_tokens,
)
try:
response = model.complete(request)
if not isinstance(response, ModelResponse):
Expand Down
37 changes: 35 additions & 2 deletions src/minicode_rebuild/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,16 @@
EXIT_INTERRUPTED = 130


def _positive_int(value: str) -> int:
try:
parsed = int(value)
except ValueError as exc:
raise argparse.ArgumentTypeError("must be an integer") from exc
if parsed < 1:
raise argparse.ArgumentTypeError("must be greater than zero")
return parsed


def build_parser() -> argparse.ArgumentParser:
"""Create the command-line parser without performing side effects."""

Expand Down Expand Up @@ -86,6 +96,20 @@ def build_parser() -> argparse.ArgumentParser:
metavar="N",
help="maximum model steps per turn (or MINICODE_MAX_STEPS)",
)
parser.add_argument(
"--token-budget",
type=_positive_int,
default=None,
metavar="N",
help="maximum reported tokens for this session (or MINICODE_SESSION_TOKEN_BUDGET)",
)
parser.add_argument(
"--max-output-tokens",
type=_positive_int,
default=None,
metavar="N",
help="maximum output tokens per model request (or MINICODE_MAX_OUTPUT_TOKENS)",
)
parser.add_argument(
"--system-prompt",
default=None,
Expand Down Expand Up @@ -137,6 +161,12 @@ def _runtime_settings(
settings = replace(settings, max_steps=args.max_steps)
if args.system_prompt is not None:
settings = replace(settings, system_prompt=args.system_prompt)
budget = settings.token_budget_policy
if args.token_budget is not None:
budget = replace(budget, session_tokens=args.token_budget)
if args.max_output_tokens is not None:
budget = replace(budget, max_output_tokens=args.max_output_tokens)
settings = replace(settings, token_budget_policy=budget)
return settings


Expand Down Expand Up @@ -238,7 +268,7 @@ def _run_interactive(
f"Session: {session.session_id}\n"
"Commands: /help, /session, /sessions, /transcript, /checkpoints, "
"/rewind-preview [id], /rewind [id], /skills, /memory, /timeline, "
"/stats, /compact, /exit\n"
"/budget, /stats, /compact, /exit\n"
)
output.flush()
while True:
Expand All @@ -258,7 +288,7 @@ def _run_interactive(
output.write(
"Commands: /help, /session, /sessions, /transcript, /checkpoints, "
"/rewind-preview [id], /rewind [id], /skills, /memory, /timeline, "
"/stats, /compact, /exit\n"
"/budget, /stats, /compact, /exit\n"
)
continue
if user_message == "/session":
Expand Down Expand Up @@ -361,6 +391,9 @@ def _run_interactive(
if user_message == "/timeline":
output.write(session.timeline() + "\n")
continue
if user_message == "/budget":
output.write(f"[budget] {session.budget_status()}\n")
continue
if user_message == "/rewind-preview" or user_message.startswith("/rewind-preview "):
checkpoint_id = user_message[len("/rewind-preview") :].strip() or None
try:
Expand Down
Loading
Loading