006 真跑实证:服务态 writer 拿空 design_text → 富游戏三门(tripleLink/economy/latch)全挂、 4 次 resume 不收敛(产物结构合法能渲染,但三系统联动没接对)。CLI 线 run_studio 先跑工作室设计 阶段再写、故更易过门;服务路径此前整段跳过设计(app.py 文件头标的 followup)。 修:bootstrap 加 _run_design_phase——在控制面进程内(有 agentscope+模型)复用 studio._design_stage (工作室星形多 agent 团队 + 失败 degrade 回单 agent)产出 design_text,喂进 AgentRecord.system_prompt (roles.writer_system 第二参)。每局建 agent 时跑一次,不随 resume 重跑。best-effort:设计失败返 "" 不阻断生成。do_design 经 drive_generation 透传(control_plane --no-design 可关,对照用)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
350 lines
20 KiB
Python
350 lines
20 KiB
Python
"""service/bootstrap.py —— tier2 Agent Service 的 session 三路初始化客户端(经 REST 编排,不旁路框架)。
|
|
|
|
【为什么是 REST 客户端而不是直接调内部函数】
|
|
create_app 的 chat 是 fire-and-forget 的 REST 服务(_router/_chat.py):前端/控制面经 HTTP 触发一个
|
|
chat run,事件从 SSE 流收。session 三路初始化属于「服务消费方」的编排——先建凭据/agent/session,再触发
|
|
chat。本模块就是这层编排的参考实现 + 口径说明:用 httpx 打 create_app 暴露的现成端点,**不自造端点、
|
|
不旁路框架的装配点**。这样三路最终都汇到 _service/_chat.py 的同一 agent 装配
|
|
(extra_agent_tools 注入九工具 + extra_agent_middlewares 注入熔断/trace + session 的模型/系统提示词)。
|
|
|
|
【三路对照(全部经现成 REST 端点)】
|
|
① 新建(模板起手):
|
|
POST /credential 注册 M3 凭据(若尚未注册;anthropic_credential,base_url=new-api host 根)
|
|
→ POST /agent 建单写 agent(system_prompt=roles.writer_system,react_config 放开轮数,
|
|
context_config=tier2 历史压缩)
|
|
→ POST /sessions 建新 session(新 workspace_id;引用上面的模型凭据)
|
|
→ POST /chat 发 kick 文本(引导 agent scaffold_init 起手 → write_source→build→run_gates→finish)
|
|
② 续接会话(从 SessionRecord.state resume):
|
|
对已存在的 (agent_id, session_id) 直接 POST /chat;框架自动 reload 该 session 的 AgentState
|
|
(含已写历史 / cur_iter / tasks)接着跑。无需自写 resume 逻辑——这是 _chat.py:315 的原生行为。
|
|
③ 加载已有工程迭代:
|
|
绑到「该工程 workdir 对应的 agent_id」(AgentScope workdir=basedir/agent_id),POST /sessions 新开
|
|
(或复用)该 agent_id 的 session → POST /chat 发「迭代指令」。一款游戏工程 = 一个 agent_id;
|
|
迭代 = 在该 agent_id 上继续。
|
|
|
|
【诚实差距(见 app.py 文件头 + 本文件 followups)】
|
|
- 模型凭据:_chat.py 要求 session.chat_model_config 引用一个已注册的 Credential。本模块的 ensure_*
|
|
把 M3 凭据(MiniMax-M3 经 new-api 走 Anthropic 原生)注册成 anthropic_credential。凭据参数(base_url /
|
|
key)从 docs/内网凭据与端点.md 取;此处用 worker.client 解析(与 CLI 线同口径),避免硬编码。
|
|
- kick 文本沿用 studio.py 的同一段(引导 scaffold_init→...→finish),保证服务态与 CLI 态对 agent 的
|
|
指令一致。但「agent 过早停下再踹回去续修」的有界 resume 在服务态由消费方决定是否再 POST /chat
|
|
(前端/控制面轮询 verdict 决定续不续)——这是 followup:控制面需实现「读 SSE 末态 verdict→未 finish
|
|
且门未绿且有预算→再触发一轮」的循环,等价 CLI 的 max_resumes。
|
|
|
|
【惰性 import】httpx / worker.client 在函数体内 import(6c6g 未必装 httpx;worker.client 牵出 agentscope)。
|
|
本模块顶层零重依赖,6c6g 可 py_compile + import。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
|
|
# 单写 agent 的 kick 文本(与 worker/agent_loop/studio.py 同口径,保证服务态与 CLI 态指令一致)。
|
|
WRITER_KICK_TEXT = (
|
|
"开始实现这款富游戏。先 scaffold_init 起手,然后在循环里 write_source→(validate_datatable)→"
|
|
"build→run_gates→read_verdict→针对失败门 write_source 修→再 run_gates……门全绿后调 finish 交付。"
|
|
"切记:run_gates 的 verdict 是机器判的,看到 decision=fix 不要停下来收尾,要按失败门继续修。"
|
|
)
|
|
|
|
|
|
def _user_input(text: str) -> dict[str, Any]:
|
|
"""组装 POST /chat/ 的 input(一条 user Msg)。
|
|
|
|
ChatRequest.input 是 agentscope.message.Msg;Msg.content 必须是【内容块列表】而非裸字符串
|
|
(2026-06-24 活服务 smoke 实证:发裸字符串 content 返 422「Input should be a valid list」——
|
|
Python 端 UserMsg(content=str) 的字符串自动包块只发生在构造器内,经 REST 传 JSON 不走那条,
|
|
故须显式发 [TextBlock])。TextBlock JSON 形状 = {type:"text", text:...}(源码 message/_block.py:11)。
|
|
"""
|
|
return {"name": "user", "role": "user", "content": [{"type": "text", "text": text}]}
|
|
|
|
|
|
def _resolve_m3_credential_payload(model_name: str = "MiniMax-M3") -> dict[str, Any]:
|
|
"""组装注册 M3 凭据(POST /credential/)的请求体——base_url/key 经 worker.client 解析,绝不硬编码。
|
|
|
|
M3 走 Anthropic 原生 /v1/messages(thinking 分离),故凭据 type = anthropic_credential,base_url = new-api
|
|
host 根(不带 /v1,SDK 自拼 /v1/messages;与 worker.config.build_model 同口径)。key = NEWAPI_KEY
|
|
(worker.client.get_api_key 从 env/.env 读;凭据档授权内网入仓)。
|
|
|
|
请求体形状(2026-06-24 对活服务 smoke 实证后定稿,取代此前的扁平猜测):
|
|
CreateCredentialRequest = {data: dict}(源码 app/_router/_schema/_credential.py:CreateCredentialRequest),
|
|
凭据载荷整体嵌在 data 下。Anthropic 凭据 data 的字段以活服务 GET /credential/schemas 的「Anthropic API」
|
|
schema 为准 = {type: const "anthropic_credential", api_key: 必填, base_url: 可选};**model 不是凭据字段**
|
|
(模型名在 session.chat_model_config.model 指定,见 start_new_game)。模型类由 get_model 经
|
|
CredentialFactory.from_dict(data).get_chat_model_class() 据 data.type 决定(app/_service/_model.py:39-40),
|
|
故 data.type 必须是 "anthropic_credential"。model_name 参数在本函数已不入凭据体(保留签名兼容,实际不用)。
|
|
"""
|
|
# 惰性 import:worker.client 解析代理旁路时会牵出环境(且 worker 包顶层会 import agentscope)。
|
|
from worker import client # noqa: PLC0415
|
|
|
|
base_url = client.resolve_base_url() # new-api host 根(已装代理旁路)
|
|
api_key = client.get_api_key() # NEWAPI_KEY(凭据档 / env)
|
|
# CreateCredentialRequest.data:Anthropic 凭据最小集(type 常量 + api_key 必填 + base_url 可选)。
|
|
return {
|
|
"data": {
|
|
"type": "anthropic_credential",
|
|
"api_key": api_key,
|
|
"base_url": base_url,
|
|
}
|
|
}
|
|
|
|
|
|
async def _post(client: Any, base: str, path: str, json_body: dict, *, user_header: str) -> dict:
|
|
"""打一个 POST 到运行中的 Agent Service,返回 JSON;非 2xx 抛(带可追溯日志)。
|
|
|
|
user_header:Agent Service 经依赖注入解析 user_id(get_current_user_id);多租户下需带用户标识头。
|
|
具体头名以 app/deps.py 的 get_current_user_id 实现为准(可能是某个 header / 默认匿名)——本函数留
|
|
一个口子,部署 smoke 时按真实 deps 调整(列入 followup:对齐鉴权头)。
|
|
"""
|
|
headers = {"X-User-Id": user_header} if user_header else {}
|
|
resp = await client.post(f"{base}{path}", json=json_body, headers=headers, timeout=30.0)
|
|
if resp.status_code >= 300:
|
|
# 可追溯日志:哪个端点、什么码、返回体前 300 字(排障用)。
|
|
print(f"[tier2-bootstrap] POST {path} -> {resp.status_code}: {resp.text[:300]}", flush=True)
|
|
resp.raise_for_status()
|
|
return resp.json()
|
|
|
|
|
|
async def _patch(client: Any, base: str, path: str, json_body: dict, *,
|
|
user_header: str, params: dict | None = None) -> dict:
|
|
"""打一个 PATCH 到运行中的 Agent Service(部分更新),返回 JSON;非 2xx 抛(带可追溯日志)。"""
|
|
headers = {"X-User-Id": user_header} if user_header else {}
|
|
resp = await client.patch(f"{base}{path}", json=json_body, headers=headers,
|
|
params=params or {}, timeout=30.0)
|
|
if resp.status_code >= 300:
|
|
print(f"[tier2-bootstrap] PATCH {path} -> {resp.status_code}: {resp.text[:300]}", flush=True)
|
|
resp.raise_for_status()
|
|
return resp.json()
|
|
|
|
|
|
async def _set_permission_bypass(client: Any, base: str, session_id: str, agent_id: str,
|
|
*, user_header: str) -> None:
|
|
"""把会话权限设为 BYPASS(沙箱无人值守档)——服务态等价 CLI studio.py 的 _bypass_state()。
|
|
|
|
为什么必须设(2026-06-24 实证):tier2 九工具的 FunctionTool 默认 check_permissions 返回 ASK
|
|
(_adapters.py:99),服务态没有人确认。不设 BYPASS,agent 第一次调工具(scaffold_init)就进
|
|
ToolCallState.ASKING、等一个永远不来的确认事件,chat run 抛「Agent is waiting for 1 tool calls...
|
|
but received no event」(agent/_agent.py:931)→ 服务态生成零产出。PATCH /sessions/{id} 把
|
|
AgentState.permission_context.mode 设成 "bypass"(app/_router/_session.py:334-343 的更新路径;
|
|
PermissionMode.BYPASS 值="bypass"),跳过 ASK,与 CLI 单写口径一致(均运行在受控生成沙箱)。
|
|
|
|
注:PATCH /sessions/{session_id} 与 SSE stream 同样【要 query 参数 agent_id】(app 路由约定;
|
|
2026-06-24 smoke 实证不带会 422 missing query agent_id),故必须把 agent_id 作为 query 传。
|
|
"""
|
|
await _patch(client, base, f"/sessions/{session_id}",
|
|
{"permission_mode": "bypass"}, user_header=user_header,
|
|
params={"agent_id": agent_id})
|
|
print(f"[tier2-bootstrap] 会话 {session_id} 权限已设 BYPASS(跳过工具 ASK,无人值守自治)。", flush=True)
|
|
|
|
|
|
async def _run_design_phase(brief: str, model_name: str, *, use_team: bool = True) -> str:
|
|
"""阶段 1 设计:在控制面进程内跑「工作室设计团队」产出富游戏 design_text,喂给服务态 writer 系统提示词。
|
|
|
|
为什么必须有(2026-06-24 cp-smoke-006 真跑实证):服务态此前 writer 拿【空 design_text】
|
|
(start_new_game 传 ""),M3 单写缺富游戏设计指引 → 富游戏三门(tripleLink/economy/latch)全挂、
|
|
4 次有界 resume 不收敛(产物结构合法、能渲染,但三系统联动没接对)。CLI 线 run_studio 先跑设计阶段
|
|
再写、故更易过门。本函数把【同一道设计阶段】接进服务路径:复用 studio._design_stage(工作室星形多 agent
|
|
团队 + 失败 degrade 回单 agent),在控制面进程内(有 agentscope + 模型)直接跑,产出 design_text 写进
|
|
AgentRecord.system_prompt(roles.writer_system 的第二参)。设计阶段每局只跑一次(建 agent 时),不随 resume 重跑。
|
|
|
|
best-effort:任何失败都返回 ""(绝不阻断生成——拿不到设计也让 writer 试,只是少指引),并记日志。
|
|
"""
|
|
import functools # noqa: PLC0415
|
|
from worker import config # noqa: PLC0415
|
|
from worker.agent_loop import studio as _studio # noqa: PLC0415 —— 牵出 agentscope,控制面进程内可用
|
|
|
|
try:
|
|
# model_factory:零参偏函数,每次返回新 M3 客户端(team 给 leader/各专家各取一个;degrade 单 agent 也取一个)。
|
|
model_factory = functools.partial(config.build_model, model_name)
|
|
models: list = [] # 设计阶段造的模型客户端(成本可在此汇总;服务态暂不并入 run 台账,列 followup)
|
|
design_text = await _studio._design_stage(brief, model_factory, model_sink=models, use_team=use_team)
|
|
print(f"[tier2-bootstrap] 设计阶段产出 design_text {len(design_text or '')} 字(喂服务态 writer)。",
|
|
flush=True)
|
|
return design_text or ""
|
|
except Exception as e: # noqa: BLE001 —— 设计阶段绝不阻断主链(拿不到设计也让 writer 试)
|
|
print(f"[tier2-bootstrap] ⚠ 设计阶段失败(writer 将拿空 design,降级继续):"
|
|
f"{type(e).__name__}: {e}", flush=True)
|
|
return ""
|
|
|
|
|
|
async def start_new_game(
|
|
base_url: str,
|
|
brief: str,
|
|
*,
|
|
user_id: str = "tier2",
|
|
model_name: str = "MiniMax-M3",
|
|
writer_max_iters: int = 40,
|
|
credential_id: str | None = None,
|
|
do_design: bool = True,
|
|
) -> dict:
|
|
"""【路①】新建:注册凭据(若需)→ 建 agent → 建 session → 发 kick,启动一款新富游戏的生成。
|
|
|
|
经 create_app 现成 REST 端点完成,最终汇到 _chat.py 的同一装配点(九工具 + 熔断/trace 经工厂注入)。
|
|
|
|
Args:
|
|
base_url: 运行中的 Agent Service 根地址(如 http://100.64.0.7:8200)。
|
|
brief: 一句话题面。
|
|
user_id: 多租户用户标识(经 X-User-Id 头传;默认 tier2)。
|
|
model_name: M3 模型名(默认 MiniMax-M3;经 new-api 走 Anthropic 原生)。
|
|
writer_max_iters: 单写 ReAct 放开的最大轮数(写进 AgentRecord.react_config)。
|
|
credential_id: 已注册的模型凭据 id;None → 本函数先 POST /credential 注册一个 M3 凭据。
|
|
|
|
Returns:
|
|
{credential_id, agent_id, session_id, chat: <chat 触发响应>}。后续可凭 session_id 订阅
|
|
GET /sessions/{session_id}/stream 收事件,或凭 (agent_id, session_id) 走 resume_session 续跑。
|
|
"""
|
|
# 惰性 import:httpx(6c6g 未必装)+ worker.config(组装 system_prompt/context_config,牵出 agentscope)。
|
|
import httpx # noqa: PLC0415
|
|
from worker import config, roles # noqa: PLC0415
|
|
|
|
async with httpx.AsyncClient() as http:
|
|
# ① 注册 M3 凭据(若未提供 credential_id)。
|
|
if credential_id is None:
|
|
cred = await _post(http, base_url, "/credential/", _resolve_m3_credential_payload(model_name),
|
|
user_header=user_id)
|
|
credential_id = cred.get("credential_id") or cred.get("id")
|
|
|
|
# ②a 设计阶段(2026-06-24 接进服务路径修收敛):先在控制面进程内跑工作室设计团队产出 design_text,
|
|
# 喂给 writer 系统提示词——否则 writer 拿空 design、富游戏三门全挂不收敛(006 实证)。best-effort。
|
|
design_text = await _run_design_phase(brief, model_name) if do_design else ""
|
|
|
|
# ②b 建单写 agent:system_prompt=roles.writer_system(题面 + 富游戏 design_text),
|
|
# react_config 放开轮数,context_config=tier2 富游戏历史压缩(防长程多文件丢约定)。
|
|
# context_config / react_config 经 model_dump 转 JSON 传(端点 schema 收 ContextConfig/ReActConfig)。
|
|
ctx_cfg = config.build_context_config()
|
|
agent_body = {
|
|
"name": "tier2-writer",
|
|
"system_prompt": roles.writer_system(brief, design_text, fixture_hint=""),
|
|
"context_config": ctx_cfg.model_dump(mode="json"),
|
|
"react_config": {"max_iters": writer_max_iters},
|
|
}
|
|
agent = await _post(http, base_url, "/agent/", agent_body, user_header=user_id)
|
|
agent_id = agent.get("agent_id") or agent.get("id")
|
|
|
|
# ③ 建新 session(新 workspace_id 由框架分配;引用模型凭据)。
|
|
# chat_model_config 的字段对齐 ChatModelConfig(type/credential_id/model/parameters)。
|
|
session_body = {
|
|
"agent_id": agent_id,
|
|
"chat_model_config": {
|
|
"type": "anthropic_credential",
|
|
"credential_id": credential_id,
|
|
"model": model_name,
|
|
# parameters:M3 thinking 分离 + max_tokens(口径同 worker.config.build_model;
|
|
# max_tokens 必须 > thinking_budget)。
|
|
"parameters": {"max_tokens": 16000, "thinking_enable": True, "thinking_budget": 8000},
|
|
},
|
|
}
|
|
session = await _post(http, base_url, "/sessions/", session_body, user_header=user_id)
|
|
session_id = session.get("session_id") or session.get("id")
|
|
# 设会话权限 = BYPASS:tier2 九工具默认 ASK,服务态无人确认,不设则首个工具调用即卡死(实证)。
|
|
await _set_permission_bypass(http, base_url, session_id, agent_id, user_header=user_id)
|
|
|
|
# ④ 发 kick 触发首轮 chat run(fire-and-forget;事件从 SSE 收)。input 为 UserMsg 形状。
|
|
chat_body = {
|
|
"agent_id": agent_id,
|
|
"session_id": session_id,
|
|
"input": _user_input(WRITER_KICK_TEXT),
|
|
}
|
|
chat = await _post(http, base_url, "/chat/", chat_body, user_header=user_id)
|
|
|
|
return {"credential_id": credential_id, "agent_id": agent_id,
|
|
"session_id": session_id, "chat": chat}
|
|
|
|
|
|
async def resume_session(
|
|
base_url: str,
|
|
agent_id: str,
|
|
session_id: str,
|
|
*,
|
|
user_id: str = "tier2",
|
|
feedback: str | None = None,
|
|
) -> dict:
|
|
"""【路②】续接会话:对已存在的 (agent_id, session_id) 再发一轮 chat,从 SessionRecord.state resume。
|
|
|
|
框架自动 reload 该 session 的 AgentState(_chat.py:315)续跑——无需自写 resume。feedback 为 None 时
|
|
发一条「继续」指令;给了 feedback(如上一轮 verdict 失败门摘要)则把它作为本轮输入(等价 CLI 的
|
|
「带 verdict 反馈踹回去续修」,只是触发权在消费方/控制面)。
|
|
|
|
Args:
|
|
feedback: 续跑指令文本;None → 用默认「继续按失败门修,门绿再 finish」。
|
|
"""
|
|
import httpx # noqa: PLC0415
|
|
|
|
text = feedback or (
|
|
"继续。如果上一轮 run_gates 的门还没全绿,按失败门 write_source 针对性修,build→run_gates,"
|
|
"直到门绿再 finish;若已 finish 则无需再改。"
|
|
)
|
|
chat_body = {
|
|
"agent_id": agent_id,
|
|
"session_id": session_id,
|
|
"input": _user_input(text),
|
|
}
|
|
async with httpx.AsyncClient() as http:
|
|
chat = await _post(http, base_url, "/chat/", chat_body, user_header=user_id)
|
|
return {"agent_id": agent_id, "session_id": session_id, "chat": chat}
|
|
|
|
|
|
async def iterate_existing_project(
|
|
base_url: str,
|
|
agent_id: str,
|
|
iterate_instruction: str,
|
|
*,
|
|
user_id: str = "tier2",
|
|
model_name: str = "MiniMax-M3",
|
|
credential_id: str | None = None,
|
|
) -> dict:
|
|
"""【路③】加载已有工程迭代:在「工程所在 workdir 对应的 agent_id」上新开 session 并发迭代指令。
|
|
|
|
AgentScope LocalWorkspaceManager 的 workdir = basedir/agent_id(按 agent_id,不按 workspace_id),故
|
|
一款游戏工程 = 一个 agent_id;迭代 = 在该 agent_id 上开新 session(或复用其既有 session)继续生成。
|
|
本函数针对「在该 agent_id 上开一个新 session 做新一轮迭代」——若想在同一 session 续接,用 resume_session。
|
|
|
|
注:tier2 九工具的工程目录是 game-runtime/games/_tier2-gen/<session_id>(按 session_id=game_id),与
|
|
AgentScope workspace(按 agent_id)是两套目录。要让「迭代」真正读到上一版源文件,九工具侧需按
|
|
「同一工程 → 同一 game_id」复用目录——这要求迭代时复用同一 session_id(走 resume_session)而非新开
|
|
session。**故路③在「真改上一版文件」语义下,优先用 resume_session(同 session_id=同 game_id 目录);
|
|
本函数的「新 session 迭代」适用于「同一 agent 的工作区里另起一款变体」。** 这个 game_id↔工程目录的
|
|
绑定差异列入 followup(见文末),是服务态与 CLI 态最需要校准的一处。
|
|
|
|
Args:
|
|
agent_id: 既有工程对应的 agent_id。
|
|
iterate_instruction: 迭代需求(如「把订单耐心调长、加一个连击系统」)。
|
|
"""
|
|
import httpx # noqa: PLC0415
|
|
|
|
async with httpx.AsyncClient() as http:
|
|
if credential_id is None:
|
|
cred = await _post(http, base_url, "/credential/", _resolve_m3_credential_payload(model_name),
|
|
user_header=user_id)
|
|
credential_id = cred.get("credential_id") or cred.get("id")
|
|
|
|
session_body = {
|
|
"agent_id": agent_id,
|
|
"chat_model_config": {
|
|
"type": "anthropic_credential",
|
|
"credential_id": credential_id,
|
|
"model": model_name,
|
|
"parameters": {"max_tokens": 16000, "thinking_enable": True, "thinking_budget": 8000},
|
|
},
|
|
}
|
|
session = await _post(http, base_url, "/sessions/", session_body, user_header=user_id)
|
|
session_id = session.get("session_id") or session.get("id")
|
|
# 设会话权限 = BYPASS:tier2 九工具默认 ASK,服务态无人确认,不设则首个工具调用即卡死(实证)。
|
|
await _set_permission_bypass(http, base_url, session_id, agent_id, user_header=user_id)
|
|
|
|
# 迭代指令:先 scaffold_init 起手(已存在文件不覆盖),再按 iterate_instruction 改/加,跑门到绿 finish。
|
|
text = (
|
|
"这是对一款已有富游戏工程的迭代。先 scaffold_init 起手(已存在的文件不会被覆盖),"
|
|
f"然后按以下迭代需求改/加源文件:\n{iterate_instruction}\n"
|
|
"改完 build→run_gates→read_verdict,门绿后 finish 交付新版本。"
|
|
)
|
|
chat_body = {
|
|
"agent_id": agent_id,
|
|
"session_id": session_id,
|
|
"input": _user_input(text),
|
|
}
|
|
chat = await _post(http, base_url, "/chat/", chat_body, user_header=user_id)
|
|
|
|
return {"credential_id": credential_id, "agent_id": agent_id,
|
|
"session_id": session_id, "chat": chat}
|