per-user 额度按用户扣的最后一环:三 worker 入口从「读全局 env NEWAPI_KEY」改「优先 job.userToken、缺失回落全局 env key(WARN 脱敏)」;新失败因 quota_exhausted 干净失败。 §3.7 F(取 job token,默认关时字节不变): - cheap_service_driver._resolve_key(user_token) 优先 per-user token、缺失回落 env+WARN; drive_cheap_generation 从 job.userToken 取、透传给凭据体;token 日志脱敏(前后各 4 位)。 - worker_service 整 job(含 userToken)透传给 driver;do_POST 记 userToken 存否(脱敏)。 - wg1 _client.get_api_key/get_client/chat 支持 user_token override、回落 env(WARN 一进程一次)。 §3.9(分辨两类失效,one-api 惯例初值,确切 message 阶段2 待验): - result_out 新增 quota_exhausted 入枚举 + _map_failure_reason 认 summary.failureReason + classify_newapi_failure_reason/newapi_error_status_text(402/403→quota,401→llm_error 可重试)。 - cheap_service_app collector 崩溃路 best-effort 分辨 402/403→写 sidecar failureReason, driver→result_out 落 quota_exhausted;凭据失效/其他维持 llm_error,纯旁路不咬生成。 - wg1 _client.chat 额度耗尽抛 NewapiQuotaExhaustedError(不重试);service.handle_job 映射 quota_exhausted。注:两 worker 主生成经 AgentScope 模型封装调网关,402 埋框架内,主路 额度耗尽分辨待阶段2 拦模型封装;本次覆盖直连 _client.chat 路 + cheap 崩溃路分辨。 自证:test_result_out 25/25、test_worker_service 17/17(stdlib 直跑);driver/wg1 token 与 分辨逻辑 standalone 断言全过;八文件 py_compile 绿。test_cheap_service_driver 需 pytest (本机未装),已同步桩签名待阶段2 e2e 真验(per-user token 调网关/used_quota 按用户增/耗尽落 quota_exhausted)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
511 lines
35 KiB
Python
511 lines
35 KiB
Python
"""cheap_service_app.py — 便宜档 · 独立 Agent Service 服务壳(镜像 tier2 service/app.py,cheap 各起进程;决策②)。
|
||
|
||
把便宜档从「裸 HTTP /generate + 进程内 for-resume」归并到 AgentScope Service /chat:六工具/续修/软预算/trace
|
||
经工厂 per-turn 注入,续修由阶段一① 的 RepairMiddleware 在 finish 点拦九门续跑(取代进程内 resume 循环)。
|
||
与 tier2 Service 各起独立进程:cheap credential 走 OpenAI 兼容路(base+/v1)、预算 soft+¥10 软目标(成本上界靠
|
||
max_repairs 优雅终止、三闸=150 失控兜底)、六工具面、并发按 session 从进程内端口池派生(避撞固定 4320/9222)。
|
||
两工厂经会话注册表 sidecar 把框架分配的 session_id 解析回后端 gameId(C2:产物目录/评门/回调统一后端 gameId)。
|
||
|
||
【惰性 import 红线】顶层绝不 import agentscope/redis/fastapi;重依赖只在 build_cheap_app / 工厂体内 import。
|
||
【代理旁路必保】build_cheap_app 启动时 client.resolve_base_url() 装 NO_PROXY,否则 M3 请求被 clash fake-ip 转走 → 502。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import queue as _queue
|
||
import sys
|
||
import threading
|
||
from pathlib import Path
|
||
from typing import TYPE_CHECKING
|
||
|
||
# cheap-worker/ 加进 sys.path(_bootstrap 再把 tier2/gen-worker 加进去,worker.*/service.*/observability.* 可 import)。
|
||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||
import _bootstrap # noqa: E402,F401 仅加 sys.path + key(import 时不触网)
|
||
|
||
if TYPE_CHECKING: # pragma: no cover
|
||
from fastapi import FastAPI
|
||
|
||
# 服务标题(OpenAPI docs 显示)。
|
||
SERVICE_TITLE = "cheap-littlejs-agent-service"
|
||
|
||
# ── 并发端口池(决策③):便宜档并发≤15,play 用固定 4320/9222 会撞;按 session 从池派生唯一端口对 ──
|
||
# 与 tier2(4330/9322)、旧 cheap 串行默认(4320/9222)错开,避免同机多 Service 撞端口。池大小 16 > 上限 15。
|
||
# 续修 check 每次 acquire 一对、finally release;单局各 check 串行(一 reply 内),并发多局各持一对,池 16 足够。
|
||
_CHEAP_PORT_BASE = int(os.environ.get("CHEAP_GATE_PORT_BASE", "4400"))
|
||
_CHEAP_CDP_BASE = int(os.environ.get("CHEAP_GATE_CDP_BASE", "9400"))
|
||
_CHEAP_PORT_POOL_SIZE = int(os.environ.get("CHEAP_GATE_POOL_SIZE", "16"))
|
||
_cheap_port_pool: "_queue.Queue | None" = None
|
||
_cheap_port_pool_lock = threading.Lock()
|
||
|
||
|
||
def _port_pool() -> "_queue.Queue":
|
||
"""惰性建端口池(线程安全):16 对 (port, cdp) 从 base 递增派生。
|
||
|
||
用 LifoQueue(栈式)而非 FIFO Queue:release 归还的端口对下一次 acquire 优先复用(最近释放先取),
|
||
这样「释放→再取」拿回的就是刚归还的那对,便于验证不泄漏(池验收契约 test_port_pool_derives_distinct_pairs);
|
||
FIFO 会先派发池底未用过的对、掩盖泄漏。LifoQueue is-a Queue,对外 get/put 接口不变、并发正确性不受影响。
|
||
"""
|
||
global _cheap_port_pool
|
||
with _cheap_port_pool_lock:
|
||
if _cheap_port_pool is None:
|
||
pool: "_queue.Queue" = _queue.LifoQueue()
|
||
for i in range(_CHEAP_PORT_POOL_SIZE):
|
||
pool.put((_CHEAP_PORT_BASE + i, _CHEAP_CDP_BASE + i))
|
||
_cheap_port_pool = pool
|
||
return _cheap_port_pool
|
||
|
||
|
||
def acquire_ports(timeout: float = 900.0) -> tuple:
|
||
"""取一对空闲端口(并发满时阻塞至有空,timeout 兜底;上游并发≤15 < 池 16,正常不阻塞)。"""
|
||
return _port_pool().get(timeout=timeout)
|
||
|
||
|
||
def release_ports(pair) -> None:
|
||
"""归还端口对(best-effort,绝不抛)。"""
|
||
try:
|
||
_port_pool().put_nowait(pair)
|
||
except Exception: # noqa: BLE001 —— 归还失败只丢一对端口,不连累生成
|
||
pass
|
||
|
||
|
||
def _read_session_cfg(session_id: str) -> dict:
|
||
"""读本 session 的会话注册表 sidecar(driver 在 /chat 前按 session_id 写;C2 收口)。
|
||
|
||
因 AgentScope 工厂签名固定 (user_id, agent_id, session_id)、拿不到后端 gameId 也拿不到 session 记录,
|
||
且 AgentData/SessionConfig 无自由字段,改用 worker↔Service 同机共享 FS sidecar
|
||
game-runtime/games/_cheap-sessions/<session_id>.json 把「session_id → 后端 gameId + 本 session 配置」传给工厂。
|
||
缺失 / 坏 JSON → {}(工厂据此回落 game_id=session_id;正常路 driver 恒在 /chat 前写、不会缺;best-effort,绝不抛)。
|
||
"""
|
||
import json # noqa: PLC0415
|
||
|
||
import cheap_run # noqa: PLC0415
|
||
|
||
p = cheap_run.session_cfg_path(session_id)
|
||
if not p.exists():
|
||
return {}
|
||
try:
|
||
obj = json.loads(p.read_text(encoding="utf-8"))
|
||
return obj if isinstance(obj, dict) else {}
|
||
except Exception as e: # noqa: BLE001 —— 坏 sidecar 回落 session_id,不阻断
|
||
print(f"[cheap-service] session-cfg 读失败(回落 game_id=session_id):{type(e).__name__}: {e}", flush=True)
|
||
return {}
|
||
|
||
|
||
def _resolve_external_game_id(session_id: str, cfg: dict) -> str:
|
||
"""把框架分配的 session_id 解析回后端 gameId(C2:产物目录/评门/回调统一后端 gameId)。
|
||
|
||
cfg.external_game_id 存在即用它;缺失(sidecar 未写/坏)→ 回落 session_id —— 此时产物目录与 driver scaffold
|
||
的后端 gameId 目录不一致,生成会尽早失败(agent read 不到起点 / write 越界),是响亮失败、不静默用错目录。
|
||
"""
|
||
ext = cfg.get("external_game_id")
|
||
if ext:
|
||
return str(ext)
|
||
print(f"[cheap-service] ⚠ 会话注册表缺 external_game_id(session={session_id}),回落 game_id=session_id;"
|
||
"产物目录可能与 scaffold 不一致、生成将失败——正常路 driver 恒在 /chat 前写映射。", flush=True)
|
||
return session_id
|
||
|
||
|
||
def _resolve_write_whitelist(cfg: dict):
|
||
"""据会话注册表算写边界白名单(I1 fail-closed)。
|
||
|
||
create 路(非 restricted)→ None(不收窄,全 L3 自由写);reskin/modify 路(restricted:true)→ 收窄成
|
||
write_whitelist 的 set;**restricted 为真但白名单缺失/空 → 空集**(禁写 L3,绝不回落 create 的不收窄)——
|
||
受限会话的 sidecar 若被写坏/漏字段,宁可禁写也不放开写边界(Codex I1:受限模式缺失 fail-closed)。
|
||
"""
|
||
if not cfg.get("restricted"):
|
||
return None # create 路:不收窄
|
||
wl = cfg.get("write_whitelist")
|
||
return set(wl) if isinstance(wl, list) and wl else set() # restricted 缺白名单 → 空集(禁写)
|
||
|
||
|
||
async def _cheap_tools_factory(user_id: str, agent_id: str, session_id: str) -> list:
|
||
"""extra_agent_tools 工厂:每回合产便宜档六工具(game_id=后端 gameId,经 sidecar 解析;C2)。
|
||
|
||
C2:六工具的写/评门目录必须与 driver scaffold 的目录、system prompt 里的 ⟦G⟧ 路径一致——都用**后端 gameId**
|
||
(driver scaffold 在 amgen-<后端 gameId>、prompt 写 amgen-<后端 gameId>)。工厂只拿到 session_id,故经会话注册表
|
||
sidecar 把 session_id 解析回 external_game_id(后端 gameId)再绑 CheapSession,避免「prompt 指 A、工具锁 B」的写越界。
|
||
write_whitelist 走 I1 fail-closed helper。复用 tier2 service.app._extract_function_tools 从 build_toolkit 的 Toolkit
|
||
抠 FunctionTool(丢 group.mcps,九门不做 MCP)。
|
||
"""
|
||
from service.app import _extract_function_tools # noqa: PLC0415 —— 复用 tier2 抠取(纯函数,6c6g 安全)
|
||
|
||
from cheap_toolkit import CheapSession, build_toolkit # noqa: PLC0415
|
||
|
||
cfg = _read_session_cfg(session_id)
|
||
game_id = _resolve_external_game_id(session_id, cfg) # C2:session_id → 后端 gameId
|
||
write_whitelist = _resolve_write_whitelist(cfg) # I1:create None / restricted 缺白名单则空集(禁写)
|
||
session = CheapSession(game_id=game_id) # 六工具据后端 gameId 管 amgen-<后端 gameId> 目录(与 scaffold/prompt 一致)
|
||
toolkit = build_toolkit(session, write_whitelist=write_whitelist)
|
||
return _extract_function_tools(toolkit)
|
||
|
||
|
||
def _get_collector_cls():
|
||
"""惰性定义 collector middleware 类(subclass MiddlewareBase,缓存一次;顶层不 import agentscope 保 6c6g)。"""
|
||
global _COLLECTOR_CLS
|
||
if _COLLECTOR_CLS is not None:
|
||
return _COLLECTOR_CLS
|
||
from agentscope.middleware import MiddlewareBase # noqa: PLC0415
|
||
|
||
class _CheapRunCollector(MiddlewareBase):
|
||
"""便宜档收口采集(cheap-only,不碰 tier2 共用类):把成本/trace/续修数写 evidence sidecar 供 driver 跨进程读。
|
||
|
||
归并后 breaker/tracer/repair 都在 Service 进程内(per-turn),worker 侧 driver 拿不到它们的内存态;
|
||
本 middleware 把 costRmb/rmbGate/repairs/budgetSoftTripped/traceSummary 写 game_dir(后端 gameId)/evidence/
|
||
service-run-summary.json,driver 读它 + 九门 verdict 组 result-out(result_out 的 trace 需 costRmb/repairs 承重,
|
||
D11 就绪分据此算)。**C1 竞态修复**:在 ReplyEndEvent 流经时**同步 flush**(在 yield 它给下游 SSE publisher 前),
|
||
关闭「driver 读到 REPLY_END 时 Service 侧 finally 还没 flush → costRmb=0」竞态;finally 仍兜底(降级三路见 on_reply)。
|
||
纯旁路 best-effort:采集/写盘任何异常只告警、不影响生成;放注入序最外层,finally 在内层全跑完后执行。
|
||
"""
|
||
|
||
def __init__(self, *, game_id, breaker, repair, tracer) -> None:
|
||
self._game_id = game_id # C2:后端 gameId(经 sidecar 解析),与 driver 读 evidence 的目录一致
|
||
self._breaker = breaker
|
||
self._repair = repair
|
||
self._tracer = tracer
|
||
self._flush_logged = False # 工单 f:幂等打印标记——多次 _flush 只打一行「收口采集落盘」(落盘覆盖语义不变)
|
||
# 面二观测:本 run 累计 token(从 ModelCallEndEvent 只读嗅探)。生产 Service 路 in/out 可得;cached 拿不到
|
||
# (ModelCallEndEvent 不带 cached,唯一经手 cached 的是 tier2 共用 breaker,不动它)→ 发 metric 时 cached 传 None 降级。
|
||
self._tok_in = 0
|
||
self._tok_out = 0
|
||
self._metric_emitted = False # 面二 metric 只发一次(_flush 三条降级路会调多次,防重复计数)
|
||
# WU2 §3.9:模型调用抛错(new-api 非 2xx)时按 one-api 惯例分辨的失败因;仅 quota_exhausted 才写进
|
||
# 收口 sidecar 供 driver→result_out 落成 quota_exhausted(凭据失效/其他维持 llm_error,不透传)。默认 None。
|
||
self._failure_reason = None
|
||
|
||
async def on_reply(self, agent, input_kwargs, next_handler):
|
||
# C1:框架 _agent.py:615 先 yield ReplyEndEvent 再 yield finish Msg 再收尾;collector 是最外层 on_reply,
|
||
# 在把 ReplyEndEvent yield 给 SSE publisher 前先落盘(flush 同步无 await)→ SSE 侧收到 REPLY_END 时盘上必有,
|
||
# 关闭 driver 的 read-before-flush 竞态。此时最后一次模型调用已 accumulate、breaker.spent_rmb 为终值。
|
||
try:
|
||
async for evt in next_handler(**input_kwargs):
|
||
# 面二观测:只读嗅探每次模型调用的 token 用量(in/out);best-effort,绝不改事件、绝不抛。
|
||
# 在既有事件透传循环里嗅探,不新挂 on_model_call(不碰模型调用流式关键路径,零咬主链风险)。
|
||
if type(evt).__name__ == "ModelCallEndEvent":
|
||
try:
|
||
self._tok_in += int(getattr(evt, "input_tokens", 0) or 0)
|
||
self._tok_out += int(getattr(evt, "output_tokens", 0) or 0)
|
||
except Exception: # noqa: BLE001 —— 嗅探纯旁路,解析异常忽略,绝不咬生成
|
||
pass
|
||
if type(evt).__name__ == "ReplyEndEvent":
|
||
self._flush() # 正常路:REPLY_END 前落盘
|
||
yield evt # 纯旁路透传,不拦截、不修改
|
||
except Exception as e: # noqa: BLE001 —— T5 合成终结事件:run 崩快失败出口(600s 慢失败封口)
|
||
# run 崩(压缩崩 / 熔断 / 框架异常):框架 ChatService.run 只 log 吞异常、**不向 bus 发任何
|
||
# 终结事件**(app/_service/_chat.py:166-176)→ SSE 消费方等不到 REPLY_END、只能 600s 总超时
|
||
# 慢失败。这里在异常穿出前先 yield 一枚【合成 ReplyEndEvent】——消费侧 _run_impl 的 async-for
|
||
# 先拿到并 publish 到 bus(异常在下一次推进时才抛),driver 立刻收到回合终结、按盘面组 failed
|
||
# summary 快速失败;异常原样重抛,不掩盖失败(框架日志照记 exception)。合成失败(极端:事件类
|
||
# 构造变化)只告警、退回慢失败路,绝不遮原异常。
|
||
# WU2 §3.9:run 崩多因 new-api 非 2xx(额度耗尽 402/403 是其一)。best-effort 按 one-api 惯例分辨
|
||
# ——仅额度耗尽写 self._failure_reason,随 _flush 落进收口 sidecar,让 driver→result_out 落成
|
||
# quota_exhausted(而非含糊 llm_error);凭据失效/其他维持 llm_error。纯旁路,任何异常绝不遮原异常。
|
||
try:
|
||
import result_out as _ro # noqa: PLC0415 —— 复用单一分辨口径(one-api 惯例,阶段2 精确 message)
|
||
_reason = _ro.classify_newapi_failure_reason(*_ro.newapi_error_status_text(e))
|
||
if _reason == "quota_exhausted":
|
||
self._failure_reason = _reason
|
||
print(f"[cheap-service] 模型调用非 2xx 按 §3.9 分辨为额度耗尽 game={self._game_id}"
|
||
f"(quota_exhausted):{type(e).__name__}: {str(e)[:160]}", flush=True)
|
||
except Exception: # noqa: BLE001 —— 分辨纯旁路,失败不影响崩溃收口与原异常上抛
|
||
pass
|
||
self._flush() # 崩溃路也先落盘(部分成本/repairs 可回收)
|
||
try:
|
||
from agentscope.event import ReplyEndEvent # noqa: PLC0415
|
||
|
||
st = getattr(agent, "state", None)
|
||
synth = ReplyEndEvent(
|
||
session_id=str(getattr(st, "session_id", "") or ""),
|
||
reply_id=str(getattr(st, "reply_id", "") or ""),
|
||
)
|
||
print(f"[cheap-service] run 崩({type(e).__name__}: {str(e)[:200]})→ 发合成 ReplyEndEvent"
|
||
f"(session={synth.session_id})快终结,异常继续上抛。", flush=True)
|
||
yield synth
|
||
except Exception as synth_err: # noqa: BLE001 —— 合成兜底自身失败:退回慢失败,不遮原异常
|
||
print(f"[cheap-service] 合成终结事件失败(退回慢失败路):{type(synth_err).__name__}: "
|
||
f"{synth_err}", flush=True)
|
||
raise
|
||
finally:
|
||
# 降级三路:① 正常 REPLY_END 已 flush(此处幂等覆盖、不怕重复);② breaker 硬熔断(execute_chain 异常
|
||
# 沿 async-for 上抛)→ 没走到 REPLY_END,except 支已 flush+合成终结,finally 幂等再兜;③ 进程中途崩
|
||
# (未走 finally)→ 盘上无 sidecar,driver 有界轮询后诚实降级 costRmb=0/degraded(不伪造)。
|
||
self._flush()
|
||
|
||
def _flush(self) -> None:
|
||
try:
|
||
import json # noqa: PLC0415
|
||
|
||
import cheap_run # noqa: PLC0415
|
||
|
||
b = self._breaker
|
||
summary = {
|
||
"costRmb": round(float(getattr(b, "spent_rmb", 0.0) or 0.0), 4),
|
||
"rmbGate": "active" if getattr(b, "_rmb_gate_active", False) else "degraded",
|
||
"repairs": getattr(self._repair, "repairs", 0),
|
||
"budgetSoftTripped": bool(getattr(b, "budget_soft_tripped", False)),
|
||
"traceSummary": self._tracer.summary(),
|
||
}
|
||
# WU2 §3.9:仅当模型调用非 2xx 被分辨为额度耗尽时才带 failureReason(driver 据它落 quota_exhausted);
|
||
# 正常/其他失败不带此键 → driver 不透传 → result_out 走既有 llm_error 兜底,不误标。
|
||
if self._failure_reason:
|
||
summary["failureReason"] = self._failure_reason
|
||
ev = cheap_run.game_dir(self._game_id) / "evidence"
|
||
ev.mkdir(parents=True, exist_ok=True)
|
||
(ev / "service-run-summary.json").write_text(
|
||
json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
# 工单 f:_flush 在 on_reply 的 REPLY_END / except / finally 三条降级路会被调 ≥2 次,落盘每次覆盖
|
||
# 是幂等的(采集语义不变、driver 恒读到最新),但成功日志只需一行——重复打「收口采集落盘」纯观感噪声。
|
||
# 用实例标记只在首次成功落盘时打,后续静默覆盖(失败行不受此门、每次都报,便于诊断)。
|
||
if not self._flush_logged:
|
||
self._flush_logged = True
|
||
print(f"[cheap-service] 收口采集落盘 game={self._game_id} costRmb={summary['costRmb']} "
|
||
f"repairs={summary['repairs']} softTripped={summary['budgetSoftTripped']}", flush=True)
|
||
except Exception as e: # noqa: BLE001 —— 采集 best-effort,失败绝不影响生成
|
||
print(f"[cheap-service] 收口采集失败(忽略,不影响生成):{type(e).__name__}: {e}", flush=True)
|
||
# 面二观测:发 token 计数 metric(独立于落盘成败、只发一次)。
|
||
self._emit_metrics()
|
||
|
||
def _emit_metrics(self) -> None:
|
||
"""面二:发 llm_tokens{kind} 计数(默认关 / best-effort / 只发一次,防 _flush 多调重复计数)。
|
||
|
||
生产 Service 路 cached 拿不到(见 on_reply 嗅探注)→ cached_tokens 传 None,record_llm_token_metrics
|
||
据此只发 input/output 计数、不发 cached 计数与 cache_hit_rate(如实降级,不伪造命中率)。
|
||
取不到 CHEAP_OTLP_ENDPOINT 即 no-op(与 trace sink 默认关同源);面五不发 token 计数,不双计;
|
||
发送任何异常吞掉,绝不影响生成。
|
||
"""
|
||
if self._metric_emitted:
|
||
return
|
||
self._metric_emitted = True
|
||
try:
|
||
import cheap_otlp_sink # noqa: PLC0415 惰性 import(顶层不牵 otel)
|
||
|
||
cheap_otlp_sink.record_llm_token_metrics(self._tok_in, self._tok_out, None)
|
||
except Exception as e: # noqa: BLE001 —— 面二 metric best-effort,绝不影响生成主链
|
||
print(f"[cheap-service] 面二 metric 发送异常(忽略,不影响生成):{type(e).__name__}: {e}",
|
||
flush=True)
|
||
|
||
_COLLECTOR_CLS = _CheapRunCollector
|
||
return _COLLECTOR_CLS
|
||
|
||
|
||
_COLLECTOR_CLS = None
|
||
|
||
|
||
async def _cheap_middlewares_factory(user_id: str, agent_id: str, session_id: str) -> list:
|
||
"""extra_agent_middlewares 工厂:每回合产 [collector, trace, 续修, 软预算熔断] 四件(镜像 tier2,cheap 独立参数)。
|
||
|
||
续修 check = finish 点独立跑便宜档九门(cheap_gates.run_cheap_gates 经 to_thread,端口按 session 从池派生)→
|
||
judge_cheap_verdict;异常兜底放工厂闭包 try(T2-b:镜像 tier2 app.py:210-216,不下沉 RepairMiddleware 共用类)。
|
||
breaker soft_budget=True(决策①:¥10 转软目标、超预算软停交尽力产物);成本上界靠 max_repairs=6 优雅终止,
|
||
三道次数/轮数闸(max_tool_calls/max_model_calls/max_iters)抬到 150 当纯失控兜底(Codex C4:不设 max_tool_calls
|
||
会吃默认 60 先撞);超时按最坏九门 ~390s 放宽(C3)。评门 game_id = 后端 gameId(经 sidecar 解析,C2)。
|
||
注入序(外→内):collector / tracer / repair / breaker;框架在其外另前置 InboxMiddleware(on_reasoning 每轮
|
||
drain inbox 后透传全部 evt、不吞 finish),故 on_reasoning 链 = [Inbox, tracer, repair],repair 仍最内层能拦原始 finish。
|
||
"""
|
||
import asyncio # noqa: PLC0415
|
||
|
||
import cheap_budget # noqa: PLC0415 —— 预算两段式同源工厂(与 CLI cheap_studio 读同一配置源)
|
||
import cheap_gates # noqa: PLC0415
|
||
import cheap_run # noqa: PLC0415
|
||
from worker import genconfig # noqa: PLC0415
|
||
from worker.gate_judge import GateJudgment, judge_cheap_verdict # noqa: PLC0415
|
||
from worker.middleware import ( # noqa: PLC0415
|
||
RepairMiddleware,
|
||
Tier2TraceMiddleware,
|
||
)
|
||
|
||
# C2:评门/collector/trace 全绑后端 gameId(经会话注册表 sidecar 把 session_id 解析回后端 gameId;与 driver
|
||
# scaffold、system prompt 的 ⟦G⟧、六工具写目录一致)。缺映射则回落 session_id(响亮失败,见 _resolve_external_game_id)。
|
||
_cfg = _read_session_cfg(session_id)
|
||
game_id = _resolve_external_game_id(session_id, _cfg)
|
||
# 面四断点③:从 sidecar 取 worker 这跳写下的 W3C traceparent(driver 恒在 /chat 前写),组入站 carrier
|
||
# 传给 build_trace_sink → 让 OTLP 生成 span 挂到 Java→worker 这条入站 trace 下当子 span(不再私有 root)。
|
||
# reply 在框架后台任务里跑、与收 /chat 的 HTTP 请求解耦,读不到出站 header 的实时 context,故靠 driver 写的
|
||
# sidecar 跨进程桥 traceparent(与 external_game_id 同一 C2 桥)。无 traceparent(默认关)→ None,退回私有 root。
|
||
_tp = _cfg.get("traceparent")
|
||
parent_carrier = None
|
||
if _tp:
|
||
parent_carrier = {"traceparent": _tp}
|
||
_ts = _cfg.get("tracestate")
|
||
if _ts:
|
||
parent_carrier["tracestate"] = _ts
|
||
|
||
# trace sink:落 game_dir/trace.jsonl(与旧 cheap_studio 同路径);import/构造失败回落无 sink(非阻塞)。
|
||
trace_sink = None
|
||
try:
|
||
from observability.trace import make_jsonl_sink # noqa: PLC0415
|
||
|
||
trace_sink = make_jsonl_sink(cheap_run.game_dir(game_id) / "trace.jsonl")
|
||
except Exception as e: # noqa: BLE001 —— trace 非阻塞:接线失败只告警、回落无 sink
|
||
print(f"[cheap-service] trace sink 接线失败(回落无 sink,不阻断):{type(e).__name__}: {e}", flush=True)
|
||
# 阶段四观测 波③ T3-1:jsonl 落盘之外并接一个 OTLP span sink(发 mini-infra Collector,service.name=cheap-gen-worker)。
|
||
# **默认关**(env CHEAP_OTLP_ENDPOINT 未设)→ build_trace_sink 原样返回上面的 jsonl sink,现有行为字节不变;
|
||
# 开则包一层 fan-out(jsonl 先落盘照旧、OTLP 额外发一份,两侧各 best-effort 互不连累)。观测挂掉绝不咬生成(设计 §8)。
|
||
# 惰性 import:cheap_otlp_sink 顶层零重依赖(otel 只在其函数体内 import),不违 build_cheap_app 惰性红线。
|
||
try:
|
||
import cheap_otlp_sink # noqa: PLC0415
|
||
|
||
trace_sink = cheap_otlp_sink.build_trace_sink(trace_sink, trace_id=game_id,
|
||
parent_carrier=parent_carrier)
|
||
except Exception as e: # noqa: BLE001 —— OTLP 并接非阻塞:失败回落原 jsonl sink、只告警
|
||
print(f"[cheap-service] OTLP sink 并接失败(回落原 jsonl sink,不阻断):{type(e).__name__}: {e}", flush=True)
|
||
tracer = Tier2TraceMiddleware(trace_id=game_id, sink=trace_sink) # C2:trace_id 用后端 gameId(与 trace 文件目录/trace.gameId 一致)
|
||
|
||
max_repairs = genconfig.get("iteration", "max_resumes", 6) # 成本上界=优雅终止(决策①)
|
||
# C3:单次九门收口最坏 ~390s(cheap_run 超时上界 stage≤90 + smoke≤120 + play≤180);门跑期间 to_thread 阻塞、
|
||
# reply 不产 AgentEvent、SSE 只剩心跳。故 gate 基准 ≥390(取 420),单步静默/墙钟按它放宽,绝不用旧 200(< 390 会误熔断)。
|
||
gate_timeout_s = genconfig.get("budget", "cheap_gate_timeout_s", 420) # ≥ 最坏单次门跑,别再取 200
|
||
repair_step_s = genconfig.get("budget", "cheap_repair_step_timeout_s", 600) # > 单次门跑 + 推理余量(治慢门误熔断);driver idle 同源派生
|
||
repair_wall_s = genconfig.get("budget", "cheap_repair_wall_timeout_s",
|
||
(max_repairs + 1) * (gate_timeout_s + 180) + 300) # (轮数)×(门+推理)+余量,容 N 次串行真门
|
||
# 预算两段式(W-ARCH②,创始人 2026-07-03 裁决;取代旧「soft 无地板」):软停线 ¥10(越线只许收尾类
|
||
# 动作,on_acting 拦新增生成调用)+ RMB 硬地板 ¥15(×1.5 fail-closed,数学封死上界——软停不等于无界)。
|
||
# 经 cheap_budget.build_cheap_breaker 同源工厂,与 CLI(cheap_studio)读同一 genconfig 配置源。
|
||
# Codex C4:三道次数/轮数闸抬到 150 当纯失控兜底(非成本尺;成本上界=硬地板+max_repairs 优雅终止)。
|
||
# max_tool_calls 不显式设会吃默认 60(middleware.py)→ 7 轮 ReAct 工具步先撞,故必须显式给 150。
|
||
cheap_max_tool_calls = genconfig.get("budget", "cheap_max_tool_calls", 150)
|
||
cheap_max_model_calls = genconfig.get("budget", "cheap_max_model_calls", 150)
|
||
breaker = cheap_budget.build_cheap_breaker(
|
||
max_tool_calls=cheap_max_tool_calls, # C4 失控兜底:显式 150(不设吃默认 60、会先撞)
|
||
max_model_calls=cheap_max_model_calls, # 失控兜底 150(非成本尺;成本上界=两段式硬地板)
|
||
wall_timeout_s=repair_wall_s, # 墙钟容纳 (max_repairs+1) 次串行真门(C3)
|
||
step_timeout_s=repair_step_s, # 单步静默阈值 > 单次门跑最坏(门跑期间无 evt 不误杀,C3)
|
||
)
|
||
|
||
async def _cheap_check(agent):
|
||
# 独立跑九门:端口按 session 从池派生(决策③避撞),subprocess 经 to_thread 防阻塞事件循环;
|
||
# 异常兜底(T2-b 工厂闭包 try,镜像 tier2 app.py:210-216):任何异常判未过续修,绝不穿透 on_reasoning。
|
||
# #3:acquire_ports 池耗尽会阻塞(Queue.get),不能在事件循环里同步等——经 to_thread 卸到线程池,
|
||
# 避免冻死整 Service 事件循环(端口满时其他会话的 SSE/续修全被拖死)。下方 run_cheap_gates 已是 to_thread。
|
||
pair = await asyncio.to_thread(acquire_ports)
|
||
try:
|
||
try:
|
||
verdict = await asyncio.to_thread(cheap_gates.run_cheap_gates, game_id, pair[0], pair[1])
|
||
except Exception as e: # noqa: BLE001 —— 门执行任何异常都判未过续修
|
||
print(f"[cheap-repair] 九门执行异常(判未过、续修):{type(e).__name__}: {e}", flush=True)
|
||
return GateJudgment(passed=False, failed_gates=["cheap_gates_error"],
|
||
feedback=f"九门执行异常,请确认工程可 check/build/运行后再交付:{type(e).__name__}: {e}")
|
||
finally:
|
||
release_ports(pair)
|
||
# C6 接线(W-S1 单③):传 game_id + staged 目录,反馈带 phaseNow/driverType/game-log 摘要,
|
||
# 并把结构化反馈落 staged evidence/verdict-feedback.json(证据留痕,gate_judge 内 best-effort)。
|
||
return judge_cheap_verdict(verdict, game_id=game_id,
|
||
staged_dir=cheap_run.wg1_game_dir(game_id))
|
||
|
||
repair = RepairMiddleware(
|
||
check=_cheap_check,
|
||
max_repairs=max_repairs,
|
||
# 实测已花 ¥ 超上限(非预估软停标记):配① 的 repairs>0 保护,首个未绿 finish 必先修一次再因预算放行。
|
||
budget_exhausted=lambda: (
|
||
breaker._rmb_gate_active and breaker.spent_rmb >= breaker.rmb_hard_limit),
|
||
)
|
||
|
||
# 收口采集(cost/trace/repairs 跨进程回收):reply 收尾写 evidence/service-run-summary.json,供 T3 driver 组 result-out。
|
||
collector = _get_collector_cls()(game_id=game_id, breaker=breaker, repair=repair, tracer=tracer)
|
||
return [collector, tracer, repair, breaker]
|
||
|
||
|
||
def build_cheap_app(*, title: str = SERVICE_TITLE) -> "FastAPI":
|
||
"""组装便宜档独立 Agent Service(create_app + Redis storage/message_bus + 本地 workspace;决策②:与 tier2 各起进程)。
|
||
|
||
部署入口:`from cheap_service_app import build_cheap_app; app = build_cheap_app()` 再 uvicorn(见 main)。
|
||
重依赖(agentscope.app / redis)在此惰性 import;代理旁路必保(I2):**先 client.resolve_base_url() 装 NO_PROXY,
|
||
再 import agentscope.app**(agentscope 牵出 httpx,trust_env=True 默认吃 HTTP_PROXY,装旁路必须先于 import,client.py:51)。
|
||
"""
|
||
from worker import client # noqa: PLC0415 —— 先导 client(不牵 httpx),装旁路
|
||
# I2 代理旁路必保:装 NO_PROXY(内网 M3 请求经 clash fake-ip 会 502),**必须在 import agentscope/httpx 前**(红线)。
|
||
client.resolve_base_url()
|
||
|
||
# B浅(cutover 接缝):Service 守护进程注入 NEWAPI_KEY 兜底 —— 成本子系统 / 活取价读 env 的地方兜底。
|
||
# 生成本身走 per-POST 凭据(工厂 per-turn 装 model)、不依赖它;但 CircuitBreaker 的 ¥ 累进硬闸 + newapi_pricing
|
||
# 活取价经 client.get_api_key() 读 env NEWAPI_KEY,进程无 key 会降级(costRmb=0/rmbGate=degraded、¥ 硬闸失效)。
|
||
# B深层已让成本子系统优先用 per-POST 凭据 key(middleware._extract_credential_key),env 仅作回落兜底,故此处
|
||
# best-effort:ensure_api_key_env 从 docs/内网凭据与端点.md 解析注入(内网单一事实源),失败也不阻断 Service 启动。
|
||
try:
|
||
_bootstrap.ensure_api_key_env()
|
||
except Exception as e: # noqa: BLE001 —— key 兜底失败不阻断启动(per-POST 凭据仍可取价;成本子系统至多降级)
|
||
print(f"[cheap-service] NEWAPI_KEY 进程注入兜底失败(忽略,per-POST 凭据仍可取价):{type(e).__name__}: {e}", flush=True)
|
||
|
||
from agentscope.app import create_app # noqa: PLC0415 —— 旁路已装,此后 import 才安全
|
||
from agentscope.app.message_bus import RedisMessageBus # noqa: PLC0415
|
||
from agentscope.app.storage import RedisStorage # noqa: PLC0415
|
||
from agentscope.app.workspace_manager import LocalWorkspaceManager # noqa: PLC0415
|
||
from service import infra_config # noqa: PLC0415 —— 复用 tier2 Redis 参数(mini-infra)
|
||
|
||
# fix400 纵深兜底:M3 经 new-api 的并行 tool call 不区分流式 chunk index,agentscope 2.0.2 聚合会把第二个
|
||
# call 的 arguments 拼进首桶(非法 JSON → 服务端丢 call → 孤儿 tool result → 400 code 2013 → run 崩、
|
||
# SSE 无终结、driver 慢失败)。主防线在 driver(session parameters parallel_tool_calls=False 源头禁并行);
|
||
# 此补丁兜「网关/模型不尊重该参数仍并行」的残余路径:同 index 异 id 按 id 分桶。
|
||
# 【钉 agentscope==2.0.3,升级必须复核】运行时 monkeypatch、绝不改 venv 文件本体(细则见 m3_stream_patch)。
|
||
from m3_stream_patch import apply_m3_stream_patch # noqa: PLC0415
|
||
apply_m3_stream_patch()
|
||
|
||
# T5 压缩崩封口:M3 对压缩摘要 schema 遵从不稳('important_discoveries' required 4 次实证)→ 压缩
|
||
# ValidationError 穿透 reply → run 崩 → SSE 无终结 → driver 600s 慢失败。fail-open 补丁让压缩失败
|
||
# 只跳过不杀主链(连续 2 次断路防白烧);快失败出口另由 collector 合成终结事件兜。
|
||
# 【钉 agentscope==2.0.3,升级必须复核】纪律同 m3_stream_patch(不改 venv 本体)。
|
||
from ctx_compress_patch import apply_ctx_compress_patch # noqa: PLC0415
|
||
apply_ctx_compress_patch()
|
||
|
||
# 工具面封口:2.0.2 Service 路把 workspace 内建(Bash/Edit/Glob/Grep/Read/Write)无条件并入 agent
|
||
# 工具面 → 便宜档六工具写白名单被内建 Write/Bash 旁路 + M3 拿内建 Bash 相对路径死圈(80009/80011
|
||
# 生产实证)。本进程只跑便宜档 agent,进程内关内建作用域恰好;tier2 服务独立进程不受影响。
|
||
# 【钉 agentscope==2.0.3,升级必须复核】纪律同 m3_stream_patch(不改 venv 本体)。
|
||
from ws_builtin_tools_patch import apply_ws_builtin_tools_patch # noqa: PLC0415
|
||
apply_ws_builtin_tools_patch()
|
||
|
||
# 工具面再封口(工单 h):2.0.2 get_toolkit 除内建外还无条件并入 Planning(Task*)/Team(Team*·AgentCreate),
|
||
# 并按 session model 挂 Schedule(schedule_tools 组)——便宜档单机单游戏用不到,纯工具面噪声(80011 flail:
|
||
# 12+ 工具语义重叠是根因之一)。本进程只跑便宜档 agent,进程内收窄作用域恰好;tier2 独立进程不受影响。
|
||
# 【钉 agentscope==2.0.3,升级必须复核】纪律同 ws_builtin_tools_patch(不改 venv 本体)。
|
||
from planning_team_tools_patch import apply_planning_team_tools_patch # noqa: PLC0415
|
||
apply_planning_team_tools_patch()
|
||
|
||
storage_params = infra_config.redis_params(for_message_bus=False)
|
||
bus_params = infra_config.redis_params(for_message_bus=True)
|
||
# cheap 独立 workspace 根(与 tier2 的 _service-workspaces 分开;本线生成产物另落 game-runtime/games/amgen-<id>)。
|
||
ws_basedir = os.environ.get(
|
||
"CHEAP_SERVICE_WORKSPACES",
|
||
str(Path(__file__).resolve().parent / "_cheap-service-workspaces"),
|
||
)
|
||
print(f"[cheap-service] build_cheap_app: redis storage db{storage_params['db']} bus db{bus_params['db']} "
|
||
f"auth={'on' if storage_params['password'] else 'off'} ws_basedir={ws_basedir}", flush=True)
|
||
|
||
return create_app(
|
||
storage=RedisStorage(**storage_params),
|
||
message_bus=RedisMessageBus(**bus_params),
|
||
workspace_manager=LocalWorkspaceManager(basedir=ws_basedir),
|
||
extra_agent_tools=_cheap_tools_factory,
|
||
extra_agent_middlewares=_cheap_middlewares_factory,
|
||
custom_subagent_templates=[],
|
||
custom_agent_cls=None,
|
||
title=title,
|
||
)
|
||
|
||
|
||
def main() -> None:
|
||
"""部署入口:起 uvicorn 跑便宜档 Agent Service(默认 0.0.0.0:8300,与 tier2 8200 错开)。只在 mini-desktop 跑。"""
|
||
import uvicorn # noqa: PLC0415
|
||
|
||
host = os.environ.get("CHEAP_SERVICE_HOST", "0.0.0.0")
|
||
port = int(os.environ.get("CHEAP_SERVICE_PORT", "8300"))
|
||
print(f"[cheap-service] 启动 Agent Service:http://{host}:{port}", flush=True)
|
||
|
||
# genconfig 热源接线(配置控制面阶段二·步骤4,与 tier2 service/app.py:main 对称):TIER2_GENCONFIG_NACOS=1
|
||
# 才启用,默认关 = 行为字节不变。工厂自带启用旗判断 + best-effort(失败只告警不抛、不 attach),订阅
|
||
# cheap 档 dataId(gen-hot-params-cheap)——与 tier2 各进程各热源,天然不串档(worker.genconfig 注释详解)。
|
||
from worker import genconfig_nacos # noqa: PLC0415 —— 惰性 import,默认关时不牵 nacos 依赖
|
||
genconfig_nacos.build_and_attach_hot_source(tier="cheap")
|
||
|
||
# 知识包激活生效链接线(W-CFG-KB K3,与 genconfig 热源对称):TIER2_KB_ROOT 未配 → no-op(默认关、read_file
|
||
# 字节不变);已配 → 启动期按激活版物化知识根 +(Nacos 启用时)订阅知识包 dataId,激活推送即重新物化。best-effort:
|
||
# 任何异常只告警、不阻断 Service 启动 / 生成。
|
||
from worker import kb_nacos # noqa: PLC0415 —— 惰性 import,默认关时不牵 nacos 依赖
|
||
kb_nacos.setup_knowledge_activation()
|
||
|
||
uvicorn.run(build_cheap_app(), host=host, port=port)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|