diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 14363d1..e9dc45c 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -150,7 +150,7 @@ def check_permission(state, cls, name, args): # 决策 + ```bash python agent.py --selfcheck # 零依赖、零网络、零 key。改完先跑这个 -python -m pytest tests/ -q # 343 条。CI 跑 ubuntu/windows × 3.10/3.13 +python -m pytest tests/ -q # 344 条。CI 跑 ubuntu/windows × 3.10/3.13 ``` `--selfcheck` 覆盖 4 个工具的读/写/改、`edit_file` 的"找不到 / 不唯一"两种报错、 @@ -190,7 +190,7 @@ set TALOS_MAX_STEPS=12 | **行为类** | **模型读到守卫那条消息之后干什么** | ❌ 必须用你真在用的那个 | 「拒绝之后会不会改用 `python -c` 绕路」是那个具体模型的性格,换了模型不算数。 -而**行为类正是 live 测试唯一还值钱的部分** —— 管道通不通,343 条判据已经免费覆盖了。 +而**行为类正是 live 测试唯一还值钱的部分** —— 管道通不通,344 条判据已经免费覆盖了。 按这条界,**目标闸的 live 判据是行为类的**:离线判据已经证明判断器拿得到 `read_file`、 越界会被驳回、判不出来时不假装成功;它们证明不了的是**这个具体模型拿到只读工具之后 diff --git a/README.md b/README.md index 5682255..8f4809b 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![tests](https://github.com/Jerry-TZ/Talos/actions/workflows/test.yml/badge.svg)](https://github.com/Jerry-TZ/Talos/actions/workflows/test.yml) -**一个你能完整读完的编程 agent。** 5435 行 Python,343 条离线判据,一份不糊弄人的安全说明。 +**一个你能完整读完的编程 agent。** 5441 行 Python,344 条离线判据,一份不糊弄人的安全说明。 Talos 终端界面:动盘之前先弹确认框 @@ -19,7 +19,7 @@ | 文件 | 行数 | 职责 | |---|---|---| -| `agent.py` | 4312 | 循环 + 工具 + 权限门 + 自学习 | +| `agent.py` | 4318 | 循环 + 工具 + 权限门 + 自学习 | | `console_ui.py` | 235 | 终端界面(可整体替换) | | `recall.py` | 582 | 联想记忆:扩散激活检索 | | `session.py` | 306 | 会话持久化(想换 SQLite 只改这个) | @@ -33,7 +33,7 @@ 极简 agent 赛道很挤,有人用 Zig 做到 678KB 二进制。**Talos 不比谁小,它比谁都好读。** - **能读完** — 四个文件,注释解释的是*为什么*,不是*是什么*。几乎每条防御旁边都写着它挡的那次真实翻车。 -- **能验证** — 343 个测试,**离线、免 API key、几秒跑完**,CI 在 Linux/Windows × Python 3.10/3.13 上都跑。clone 下来立刻知道它没坏。 +- **能验证** — 344 个测试,**离线、免 API key、几秒跑完**,CI 在 Linux/Windows × Python 3.10/3.13 上都跑。clone 下来立刻知道它没坏。 - **不吹牛** — [`SECURITY.md`](SECURITY.md) 明写 `create_tool` 就是进程内 RCE、正则黑名单只是减速带。**没有沙箱就是没有沙箱** —— 真要隔离,[三条现成方案](SECURITY.md#真要隔离怎么办)按代价从低到高列在那儿。 - **有考卷** — [`EXAM.md`](EXAM.md) / [`EXAM2.md`](EXAM2.md) 是两份可复现的能力测试,带标准答案和作弊检测(比如逐个核验 arXiv ID 真伪,防止编造引用)。记录的是"我怎么验证它真的有用",不是功能列表。 - **有实测** — [`FINDINGS.md`](FINDINGS.md) 记了二十二个真实任务量出来的东西:哪条提示词生效、哪条从头到尾没生效、六次翻车、检索改动的前后数字,以及**两个被数据否掉的自己的方案**。样本小,局限写在最前面。 @@ -202,7 +202,7 @@ Ctrl+C 停下当前这轮 —— 做过的都留着,可以直接说 ```bash .venv\Scripts\python.exe -m pip install -r requirements-dev.txt -.venv\Scripts\python.exe -m pytest tests/ -q # 343 条,约 8 秒,不联网、不需要 key +.venv\Scripts\python.exe -m pytest tests/ -q # 344 条,约 8 秒,不联网、不需要 key python agent.py --selfcheck # 免依赖的冒烟检查 ``` diff --git a/agent.py b/agent.py index c905893..e8b4ea4 100644 --- a/agent.py +++ b/agent.py @@ -2412,12 +2412,18 @@ def _reasoning(msg) -> str: return str(extra.get("reasoning_content") or extra.get("reasoning") or "") def _usage(resp): - """Token usage from a response (0s if the provider doesn't report it). -> (in, out, cached).""" + """Token usage from a response (0s if the provider doesn't report it). -> (in, out, cached). + + 缓存命中先认 OpenAI 那一处;没有再认两家自己的老字段 —— DeepSeek 的 + `prompt_cache_hit_tokens`、Kimi 顶层的 `cached_tokens`。现在的文档里两家也都填 + OpenAI 那处,但只带老字段的响应还有(旧接口、中转网关),那时命中静悄悄地记成 0。""" u = getattr(resp, "usage", None) if not u: return (0, 0, 0) d = getattr(u, "prompt_tokens_details", None) - cached = (getattr(d, "cached_tokens", 0) or 0) if d is not None else 0 + cached = (((getattr(d, "cached_tokens", 0) if d is not None else 0) + or getattr(u, "prompt_cache_hit_tokens", 0) or getattr(u, "cached_tokens", 0)) + or 0) return (getattr(u, "prompt_tokens", 0) or 0, getattr(u, "completion_tokens", 0) or 0, cached) CHAT_TRIES = 4 # 一次调用最多试几趟(含第一趟) diff --git a/tests/test_loop.py b/tests/test_loop.py index a537731..fe21388 100644 --- a/tests/test_loop.py +++ b/tests/test_loop.py @@ -2830,6 +2830,32 @@ def test_stream_usage_is_read_from_wherever_the_provider_puts_it(monkeypatch): assert A._usage(A._collect(kimi_style)) == (100, 20, 60), "Kimi 放在 choices[0] 里的用量没认出来" +def test_cached_tokens_are_read_from_whichever_field_the_provider_fills(): + """缓存命中数原来只认 OpenAI 那一处(`prompt_tokens_details.cached_tokens`)。 + + 按现在的文档,DeepSeek、Kimi、GLM、Gemini 也都填这一处 —— FINDINGS 里那些实测命中率 + (81.5%、94%)就是这么量出来的,所以**日常不是 0**。但 DeepSeek 和 Kimi 各有自己的老字段, + 而只带老字段的响应还在(旧接口、中转网关;any-llm、Operit 都报过 DeepSeek 命中被丢成 0): + DeepSeek 的 `prompt_cache_hit_tokens`,Kimi 顶层的 `cached_tokens`。 + 撞上的时候命中**静悄悄地记成 0** —— 不报错,只是账不对,而 cache_trace 正是拿来 + 判断缓存优化有没有效的样本。""" + import agent as A + U = types.SimpleNamespace + def resp(**u): + return U(usage=U(prompt_tokens=100, completion_tokens=20, **u)) + assert A._usage(resp(prompt_tokens_details=U(cached_tokens=60))) == (100, 20, 60) + assert A._usage(resp(prompt_tokens_details=None, prompt_cache_hit_tokens=60, + prompt_cache_miss_tokens=40)) == (100, 20, 60), "DeepSeek 的老字段没认" + assert A._usage(resp(cached_tokens=60)) == (100, 20, 60), "Kimi 顶层的 cached_tokens 没认" + # SDK 给的 details 可能在、但 cached_tokens 是 None + assert A._usage(resp(prompt_tokens_details=U(cached_tokens=None), + prompt_cache_hit_tokens=60)) == (100, 20, 60) + # 流式里 Kimi 那份用量是原样 dict,经 `_ns` 之后同样认 + assert A._usage(U(usage=A._ns({"prompt_tokens": 100, "completion_tokens": 20, + "cached_tokens": 60}))) == (100, 20, 60) + assert A._usage(resp()) == (100, 20, 0), "哪家都没报就是 0,不是报错" + + def test_a_stream_that_breaks_midway_starts_over_instead_of_splicing(monkeypatch): """流到一半断了:整次重来,不接着拼。