成本配额与文件只读标记:task-cost/quota-status/task-files

This commit is contained in:
host committed 2026-10-30 05:11:06 +08:00
1 parent 2fd73c1121
commit 9d6430a4d0
5 files changed
+711 -14

No files matched your search

+121 -2
View File
@@ -1,6 +1,6 @@
"""任务内标记工具(marker tools):task-create / task-poll / task-read /
wait-threads / task-cancel / task-retry / todo-list / todo-create /
todo-update / task-list。
todo-update / task-list / task-cost / quota-status / task-files。
设计(与 peer_messages、task_spawn 同款原则,不另起一套):
@@ -56,11 +56,24 @@ todo-update / task-list。
任务 id 时也能分诊——查运行中与失败的任务、捡失败任务
重试。不传 status 时查全部状态并附各状态计数;只读,
跨项目任务不出现,报告最多列 limit 条(默认 20,上限 50)。
- task-cost / quota-status:只读成本与配额。task-cost 查单个
任务的成本(美元只计有价行,无价 token 单列);quota-status
查发送方任务创建人当月的配额、已用与无价 token——派生子
任务前先看配额,避免配额门在执行时才拦住。两者都不接受
查别人的参数:quota 永远按发送方创建人算,task-cost 只
放行派生链内或同创建人的目标(比服务端只要求 read 更严,
防止在项目内猜 id 看别人的花费)。
- task-files:读派生链内或同创建人任务的 worktree 文件。
不传 path 时列顶层,传相对路径读文件(上限 20000 字,
二进制文件拒绝);绝对路径、.. 逃逸、符号链接逃逸一律
拒绝;cloud 任务与 worktree 在别机的任务拒绝(文件不在
本机);目标没有本地 worktree 时按 404 报告,不编内容。
上限口径(本模块最终定的值,文档与代码一致):
- 每轮创建、轮询、取消、重试各最多 5 块;todo-list、
todo-create、todo-update、task-list 各最多 5 块;wait
todo-create、todo-update、task-list、task-cost、
quota-status、task-files 各最多 5 块;wait
每轮最多 2 块,每块最多 8 个目标。8 这个数是刻意与服务端
POST /tasks/{id}/wait 的“一次最多 8 个线程”对齐的;
spawn 分支原先的每块 10 个在此收紧到 8 个,不要再用 10。
@@ -94,6 +107,7 @@ import。DB 助手函数只用 ? 占位符,与 peer_messages 同款写法。
"""
import json
import os
import re
import time
import uuid
@@ -131,6 +145,12 @@ TASK_STATUSES = ("queued", "running", "completed", "failed",
MAX_TASK_LISTS_PER_TURN = 5
DEFAULT_TASK_LIST_LIMIT = 20
MAX_TASK_LIST_LIMIT = 50
MAX_TASK_COSTS_PER_TURN = 5
MAX_QUOTA_PER_TURN = 5
MAX_TASK_FILES_PER_TURN = 5
MAX_TASK_FILE_CHARS = 20000
MAX_TASK_FILE_LIST = 50
MAX_TASK_FILE_PATH_CHARS = 1000
_CREATE_RE = re.compile(r"```task-create\s*\n(.*?)\n```", re.DOTALL)
_POLL_RE = re.compile(
@@ -150,6 +170,12 @@ _TODO_UPDATE_RE = re.compile(
r"```(todo-update|todo_update)\s*\n(.*?)\n```", re.DOTALL)
_TASK_LIST_RE = re.compile(
r"```(task-list|task_list)\s*\n(.*?)\n```", re.DOTALL)
_TASK_COST_RE = re.compile(
r"```(task-cost|task_cost)\s*\n(.*?)\n```", re.DOTALL)
_QUOTA_RE = re.compile(
r"```(quota-status|quota_status)\s*\n(.*?)\n```", re.DOTALL)
_TASK_FILES_RE = re.compile(
r"```(task-files|task_files)\s*\n(.*?)\n```", re.DOTALL)
def row_get(row, name):
@@ -434,6 +460,83 @@ def extract_task_lists(text):
return out
def extract_task_costs(text):
"""提取 task-cost 块:{\"task_id\": \"...\"},与 task-cancel
同款别名口径(task_id / to / to_task_id / thread_id);
每轮最多 5 块。"""
return _extract_controls(text, _TASK_COST_RE)
def extract_quota_status(text):
"""提取 quota-status 块:块体必须是 JSON 对象(可为空对象),
不接受任何参数——配额永远按发送方任务的创建人算,块里写
别人 id 也无效,避免用标记块查别人的配额;每轮最多 5 块
(查询便宜,但多块只是重复报告)。"""
if not text:
return []
out = []
for match in _QUOTA_RE.finditer(str(text)):
if len(out) >= MAX_QUOTA_PER_TURN:
break
try:
payload = json.loads(match.group(2))
except (TypeError, ValueError):
continue
if not isinstance(payload, dict):
continue
out.append({})
return out
def extract_task_files(text):
"""提取 task-files 块:task_id 必填,path 可选(默认列顶层);
path 非字符串或超长时整块跳过;绝对路径与 .. 逃逸不在此
层跳过——worker 的 safe_worktree_path 会拒绝并在报告里写
清原因,让模型知道错在哪;每轮最多 5 块。"""
if not text:
return []
out = []
for match in _TASK_FILES_RE.finditer(str(text)):
if len(out) >= MAX_TASK_FILES_PER_TURN:
break
try:
payload = json.loads(match.group(2))
except (TypeError, ValueError):
continue
if not isinstance(payload, dict):
continue
task_id = payload.get("task_id")
if not isinstance(task_id, str) or not task_id.strip():
continue
path = payload.get("path", "")
if path is None:
path = ""
if not isinstance(path, str):
continue
if len(path) > MAX_TASK_FILE_PATH_CHARS:
continue
out.append({"task_id": task_id.strip(), "path": path})
return out
def safe_worktree_path(worktree, rel):
"""worktree 内相对路径的安全解析:与服务端
_safe_worktree_path 同款——绝对路径、.. 逃逸、符号链接
逃逸一律返回 None;空串返回 worktree 本身。"""
if not rel:
return worktree
if os.path.isabs(rel):
return None
base = os.path.realpath(worktree)
target = os.path.realpath(os.path.join(base, rel))
try:
if os.path.commonpath([base, target]) != base:
return None
except ValueError:
return None
return target
_SPAWN_SUBJECT_ZH = ("新建的任务", "派生任务", "新建任务",
"子任务", "子代理", "新任务", "新会话")
_SPAWN_SUBJECT_EN = ("child tasks", "spawned tasks", "new tasks",
@@ -895,6 +998,22 @@ def marker_report_header():
return "[任务工具报告]"
def format_cost(task_id, report):
"""成本单行:美元数只覆盖有价行,无价 actual token 单独列,
不会被静默算成 0 美元(与 CostStore._report 同款口径)。"""
usd = (report.get("usd_micros") or 0) / 1000000.0
line = ("任务 %s 成本:$%.4f,总 token %d(actual %d,"
"estimated %d),轮次 %d"
% (task_id, usd, report.get("total_tokens", 0),
report.get("actual_tokens", 0),
report.get("estimated_tokens", 0),
report.get("turns", 0)))
unpriced = report.get("unpriced_actual_tokens", 0)
if unpriced:
line += ",其中无价 actual token %d 未计入美元" % unpriced
return line
def format_todo(todo, max_title=100):
"""待办单行(报告与首轮注入共用):状态 + 标题(截断)+ id +
优先级,due_at 有值时附 UTC 时间;标题里的换行压成空格,