From 48c101b32d7713b731c75759e705911f1eb92584 Mon Sep 17 00:00:00 2001 From: Tim Wong <280945586+TimWongUp@users.noreply.github.com> Date: Sat, 7 Nov 2026 00:32:16 +0800 Subject: [PATCH 01/17] =?UTF-8?q?docs:=20=E5=AE=9A=E4=B9=89=E5=8F=AF?= =?UTF-8?q?=E9=80=89=E7=94=9F=E5=9B=BE=E5=B7=A5=E4=BD=8D=E4=B8=8E=E5=AF=B9?= =?UTF-8?q?=E8=AF=9D=E6=A8=A1=E5=9E=8B=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CONTEXT.md | 4 ++++ README.en.md | 2 +- README.md | 2 +- docs/context/architecture.md | 2 ++ skills/agentnave-manager/SKILL.md | 6 ++++-- 5 files changed, 12 insertions(+), 4 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index eae84a96..48fd47bf 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -119,6 +119,10 @@ _Avoid_: 编排引擎、调度 编排中的两类工位:Explorer 只读,做探索、规划、评审;Worker 可写,做实现。各有简单、困难两档,Worker 另有界面简单、困难两档,用于页面、样式、交互和看图的实现,没设时沿用 Worker 同档;用户在 App 选 CLI、模型和可选档位,存于 `orchestration.json`;当前接入行启用编排时,`list_members` 的 `orchestration` 才返回工位及可用性,派发才套用角色默认值,boss 按任务难度选档,涉及界面的实现改用界面档。关闭保留已保存的选择。与宿主同类的工位标 `native=true`,交给宿主自带的子代理,评审工位同样如此。 +## 生图工位 + +编排中可选的图像生成工位,用户为它选定 CLI 及与之对话的模型;实际图像由该 CLI 的模型调用自身可用的生图服务生成。它独立于 Explorer/Worker 的难度档,未配置时没有默认绑定。 + ## 权限档位(Permission Tier) 每个 CLI 的派发权限:自动(缺省)或完全放开,用户在 App 中改选,存于独立的 `permissions.json`,私聊与会议共用,与模型无关。服务把它映射为该 CLI 的原生权限选项并在回执注明;调用方显式给出权限选项时不套用。「自动」交给 CLI 自带的自动审核,CLI 没有可用的自动审核时等同完全放开。Pi 没有审批步骤,不设档位。 diff --git a/README.en.md b/README.en.md index aab44ed3..a2d2a63b 100644 --- a/README.en.md +++ b/README.en.md @@ -44,7 +44,7 @@ On macOS and Linux, Claude runs interactively by default, which saves subscripti ### 🧭 Seats and review -Pick models for the Explorer and Worker seats in the App, with tiers for easy, hard and interface work, and the boss dispatches by difficulty. You can also add a [review before merging](skills/agentnave-review/SKILL.md): how much review a change gets depends on its risk, reviewers read but never edit, and their conclusions are kept as a record. +Pick models for the Explorer and Worker seats in the App, with tiers for easy, hard and interface work, and the boss dispatches by difficulty. An optional image generation seat selects a CLI and its conversation model, which calls its own image service. You can also add a [review before merging](skills/agentnave-review/SKILL.md): how much review a change gets depends on its risk, reviewers read but never edit, and their conclusions are kept as a record. ### 🤝 Continue in another chat diff --git a/README.md b/README.md index db36e1aa..0dfae27f 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ ### 🧭 工位和评审 -在 App 里给 Explorer、Worker 工位选好模型,简单、困难和界面任务各有档位,boss 按任务难度派活。还可以加一道[合并前评审](skills/agentnave-review/SKILL.md):按风险大小决定派谁来看,评审的员工只读不改,结论留作记录。 +在 App 里给 Explorer、Worker 工位选好模型,简单、困难和界面任务各有档位,boss 按任务难度派活。可选的生图工位只需选 CLI 和对话模型,由它调用自身的生图服务。还可以加一道[合并前评审](skills/agentnave-review/SKILL.md):按风险大小决定派谁来看,评审的员工只读不改,结论留作记录。 ### 🤝 换个聊天接着干 diff --git a/docs/context/architecture.md b/docs/context/architecture.md index 7610971f..d64f09ba 100644 --- a/docs/context/architecture.md +++ b/docs/context/architecture.md @@ -43,6 +43,8 @@ AgentNave 是供 Agent Manager 通过本地 STDIO MCP 使用的服务,在 Mana - `restricted_tools=true` 是显式受限配置,普通调用不继承;它收紧原生工具,但不等于操作系统沙箱。 - Provider 进程不继承 `CLAUDE_CODE_OAUTH_TOKEN`,Claude 子代理一律用 Claude Code 自己存的登录,不随 boss 宿主变化;AgentNave 不保存、不读取 Claude 凭据([ADR-0058](../adr/0058-claude-stored-login.md))。 +生图工位沿用普通 CLI 调用边界:App 选择对话 CLI 与模型,Provider CLI 拥有其生图工具、服务访问和产物生成;AgentNave 不维护生图模型目录或直接调用生图接口。 + ## Provider Adapter - 每个 Adapter 负责一个 CLI 的命令准备、选项校验、原生事件解析与终态归一化。Provider 专属知识留在 Adapter 内:流事件里的模型、运行后从 CLI 自身文件回读模型、隔离席位目录的准备,都通过 `ProviderAdapter` 的钩子提供;是否支持受限工具运行、受限时额外允许的选项和选项说明也由各 Adapter 声明。Adapter 可在准备的命令中声明需排除的继承环境变量,进程层在启动子进程时应用,不改服务自身环境。核心与历史层不按 Provider 名分支。 diff --git a/skills/agentnave-manager/SKILL.md b/skills/agentnave-manager/SKILL.md index 6fb10f01..b688d6af 100644 --- a/skills/agentnave-manager/SKILL.md +++ b/skills/agentnave-manager/SKILL.md @@ -38,18 +38,20 @@ description: 用户要求用 Claude Code、CodeBuddy Code、Codex CLI、Grok CLI ## 工位、续接与转发 -每条原生线程就是一个工位:新开的私聊任务是新工位,用 `session_id` 续接仍是原工位;会议的每个席位也是一个工位。`role` 是显示给用户看的职责,不影响身份,写这位员工这次具体做什么,如「接手方视角」。用评审角色名(如 `correctness-reviewer`)作 `role`,或用 `seat` 传 `explorer-easy`、`explorer-hard`、`worker-easy`、`worker-hard`、`worker-frontend-easy`、`worker-frontend-hard`,可省略 `provider`,由服务按 App 工位选 CLI;工位缺失、未启用、文件损坏或属于宿主自带子代理时会报错。最终 `provider` 是工位的 CLI 时,服务套用工位的模型和档位,显式传的 `model`、`effort` 优先。继承与推断的参数来源都列在回执 `defaults_applied` 中。 +每条原生线程就是一个工位:新开的私聊任务是新工位,用 `session_id` 续接仍是原工位;会议的每个席位也是一个工位。`role` 是显示给用户看的职责,不影响身份,写这位员工这次具体做什么,如「接手方视角」。用评审角色名(如 `correctness-reviewer`)作 `role`,或用 `seat` 传 `explorer-easy`、`explorer-hard`、`worker-easy`、`worker-hard`、`worker-frontend-easy`、`worker-frontend-hard` 或 `image-generator`,可省略 `provider`,由服务按 App 工位选 CLI;工位缺失、未启用、文件损坏或属于宿主自带子代理时会报错。最终 `provider` 是工位的 CLI 时,服务套用工位的模型和档位,显式传的 `model`、`effort` 优先。继承与推断的参数来源都列在回执 `defaults_applied` 中。 - 续接:`start_agent` 默认开新线程。接着同一线程干活时,传上次结果返回的 `session_id`,省略的 `provider`、`cwd`、`role`、`seat` 继承本 boss 历史中该原生会话最近一次任务,显式值优先;`access` 不继承,只换 `role` 不传 `seat` 时也不再沿用旧工位;不能换 provider。找不到历史或跨 provider 重名时补传 `provider` 和 `cwd`,其他 boss 的历史不提供默认值,`manager_session_id` 仍按上节传入。不能拿 `invocation_id` 当会话 ID;上下文里找不到时,用 `read_conversation` 读回任务的 `session_id`。任务已换阶段、旧细节不再需要时,写简报开新线程或新会议,暂停较久后尤其如此,因为缓存过期后续接要整段重建;仍需原始证据时继续续接。 - 同时运行上限:私聊任务与会议席位合计受 App 设置限制(默认 8),超出时调用被拒绝。等部分任务结束后再派,或减少并联席位;串联轮只占一个名额,但到最后一席启动前(包括等待审核时)一直占着。 - 转发:私聊默认不带任何工作区上下文。用 `forward` 引用 `wait`、`run_round`、`decide_answers` 回执或 `read_conversation` 里的 `message_id`,接收方把它当数据。只有成功的任务和已提交的会议条目能转发。 - 同类宿主:与宿主同类的 CLI 被排除出私聊,只用于会议室;需要同类 CLI 执行任务时优先用宿主自带的子代理能力。`list_members` 的 `review`、`orchestration` 把这类工位标为 `native=true`:交给宿主自带子代理,宿主允许时用所选模型。 -- 访问等级:`access` 声明 `read` 或 `write`,在 App 显示,`read` 时服务给 prompt 加只读说明;除了与工位矛盾时拒绝外不影响调用,也不改变 CLI 权限;真正的只读靠 Provider 原生权限。Explorer 和评审工位固定 `read`,Worker 工位固定 `write`,由服务补上,不用传,传了不一致的值会被拒绝;其他任务服务判断不了,每次要声明就传,续接也一样,不传就是没声明。未声明 `read` 而同一 `cwd` 已有其他运行中的 CLI 时,回执带 `warnings`:并行写入要换独立 worktree,只读就补声明 `read`。 +- 访问等级:`access` 声明 `read` 或 `write`,在 App 显示,`read` 时服务给 prompt 加只读说明;除了与工位矛盾时拒绝外不影响调用,也不改变 CLI 权限;真正的只读靠 Provider 原生权限。Explorer 和评审工位固定 `read`,Worker 和生图工位固定 `write`,由服务补上,不用传,传了不一致的值会被拒绝;其他任务服务判断不了,每次要声明就传,续接也一样,不传就是没声明。未声明 `read` 而同一 `cwd` 已有其他运行中的 CLI 时,回执带 `warnings`:并行写入要换独立 worktree,只读就补声明 `read`。 ## 选择调用方式 选谁执行:用户点名优先,否则 boss 按 `list_members` 自定。用户在 App 设了 Explorer(只读:探索、规划、评审)或 Worker(可写:实现)时,`orchestration` 给出各自简单、困难两档,按任务难度选一档;Worker 另有界面两档(`worker_frontend`),实现涉及界面时改用它,没设时服务沿用 Worker 同档。派发时 `seat` 写成对应的 `explorer-easy`、`worker-frontend-hard` 等,可省略 `provider`、`access` 和模型;`seat` 不显示,`role` 仍照常写具体职责。 +生图任务可使用 `orchestration.image_generation` 的可选工位,以 `seat="image-generator"` 派发,不分简单、困难档;未配置时不沿用 Worker。工位选择的是与 CLI 对话的模型,实际生图由它调用自身可用的服务,派发时写清图像需求与产物路径,不传生图模型或接口参数。CLI 是否具备生图能力由其原生工具与配置决定,保存工位不代表能力验证;同类宿主的 `native` 工位仍交给宿主自带能力。 + | 用户需要 | 调用方式 | | --- | --- | | 做任务、问意见、复核、分头做、择优、接力 | 一个或多个 `start_agent` + `wait`,结果由 boss 验收、需要时用 `forward` 交给下一位 | From 04939011ded985675644c97133e0fd9e73136e9d Mon Sep 17 00:00:00 2001 From: Tim Wong <280945586+TimWongUp@users.noreply.github.com> Date: Sat, 7 Nov 2026 00:35:59 +0800 Subject: [PATCH 02/17] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E5=8F=AF?= =?UTF-8?q?=E9=80=89=E7=94=9F=E5=9B=BE=E7=BC=96=E6=8E=92=E5=B7=A5=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/agentnave/mcp_runtime.py | 2 +- src/agentnave/mcp_tasks.py | 2 +- src/agentnave/members.py | 24 ++++++-- src/agentnave/task_start.py | 6 +- tests/test_entrypoints.py | 103 +++++++++++++++++++++++++++++++++++ tests/test_members.py | 18 ++++++ 6 files changed, 144 insertions(+), 11 deletions(-) diff --git a/src/agentnave/mcp_runtime.py b/src/agentnave/mcp_runtime.py index b1de0530..a05c01b0 100644 --- a/src/agentnave/mcp_runtime.py +++ b/src/agentnave/mcp_runtime.py @@ -276,7 +276,7 @@ async def call_tool( "do not redispatch to fix metadata. " "access: read for exploration/planning/review, write for file changes; shown in App. " "read adds a prompt instruction, never native permissions. Explorer/review seats supply read, " - "Worker seats write; omit access for them; contradictions reject. Never inherited on resume; " + "Worker/image-generator seats write; omit access for them; contradictions reject. Never inherited on resume; " "without a seat declare each time or leave undeclared. " "forward: ordered message_ids from wait/run_round/decide_answers/read_conversation; " "only succeeded tasks or committed entries in this manager workspace, at most 20, no repeats. " diff --git a/src/agentnave/mcp_tasks.py b/src/agentnave/mcp_tasks.py index 9c389885..20ab561c 100644 --- a/src/agentnave/mcp_tasks.py +++ b/src/agentnave/mcp_tasks.py @@ -135,7 +135,7 @@ async def start_agent( OrchestrationRole | None, Field( description=( - "App Explorer/Worker seat (worker-frontend-* for interface work): selects CLI if omitted, defaults if CLI matches and orchestration enabled; explicit options win; inherits on resume. Not shown; also pass role as the desk label." + "App task seat (worker-frontend-* for interface work, image-generator for image generation): selects CLI if omitted, defaults if CLI matches and orchestration enabled; explicit options win; inherits on resume. Not shown; also pass role as the desk label." ) ), ] = None, diff --git a/src/agentnave/members.py b/src/agentnave/members.py index 72d0dbab..425d5166 100644 --- a/src/agentnave/members.py +++ b/src/agentnave/members.py @@ -34,7 +34,7 @@ ] type Difficulty = Literal["easy", "hard"] type OrchestrationPreset = Literal["off", "split", "parallel", "custom"] -# Task roles naming an Explorer or Worker seat in orchestration.json. +# Task seats from orchestration.json. type OrchestrationRole = Literal[ "explorer-easy", "explorer-hard", @@ -42,6 +42,7 @@ "worker-hard", "worker-frontend-easy", "worker-frontend-hard", + "image-generator", ] @@ -182,7 +183,7 @@ class ModeCLI(StrictModel): class ReviewSeat(StrictModel): - """The CLI and model the user picked in the App for one agentnave-review role.""" + """The CLI and conversational model the user picked in the App for one seat.""" provider: ProviderName model: str = Field(pattern=r"\S") @@ -197,7 +198,7 @@ class ReviewFile(StrictModel): class OrchestrationFile(BaseModel): - """App-owned rule modes and saved Explorer/Worker seats; only enabled hosts use seats.""" + """App-owned rule modes and saved task seats; only enabled hosts use seats.""" model_config = ConfigDict(extra="ignore") @@ -209,6 +210,7 @@ class OrchestrationFile(BaseModel): worker: dict[Difficulty, ReviewSeat] = {} # A key of its own, which servers that predate it ignore instead of rejecting the file. worker_frontend: dict[Difficulty, ReviewSeat] = {} + image_generation: ReviewSeat | None = None def interface_workers(self) -> dict[Difficulty, ReviewSeat]: """The interface Worker seats, an unset one filled from the Worker seat of its difficulty.""" @@ -577,8 +579,8 @@ def add_cli_effort( def role_seat(role: str | None, host: Host, *, required: bool = False) -> ReviewSeat | None: - """The App seat a task role names: an agentnave-review role from review.json, or an - Explorer/Worker seat from orchestration.json. A missing or broken file names none; + """The App seat a task role names: an agentnave-review role from review.json, or a + task seat from orchestration.json. A missing or broken file names none; list_members reports it. Provider inference requires a usable seat instead.""" role = (role or "").strip() seat = None @@ -587,7 +589,6 @@ def role_seat(role: str | None, host: Host, *, required: bool = False) -> Review review = _read_app_file("review.json", ReviewFile, MemberFileError) seat = review.seats.get(cast(ReviewRole, role)) if review else None elif role in get_args(OrchestrationRole.__value__): - kind, _, key = role.rpartition("-") roles = _read_app_file("orchestration.json", OrchestrationFile, MemberFileError) if required and roles is not None and not roles.enabled(host): raise MemberFileError( @@ -596,7 +597,10 @@ def role_seat(role: str | None, host: Host, *, required: bool = False) -> Review ) if not roles or not roles.enabled(host): seat = None + elif role == "image-generator": + seat = roles.image_generation else: + kind, _, key = role.rpartition("-") seats = { "explorer": roles.explorer, "worker": roles.worker, @@ -684,6 +688,7 @@ class OrchestrationStatus(StrictModel): explorer: dict[Difficulty, ReviewSeatStatus] = {} worker: dict[Difficulty, ReviewSeatStatus] = {} worker_frontend: dict[Difficulty, ReviewSeatStatus] = {} + image_generation: ReviewSeatStatus | None = None class MemberList(StrictModel): @@ -848,6 +853,13 @@ def installed(provider: str) -> bool: worker_frontend=_review_status( roles.interface_workers(), modes["task"], excluded, host, installed ), + image_generation=_review_status( + {"image-generator": roles.image_generation} if roles.image_generation else {}, + modes["task"], + excluded, + host, + installed, + ).get("image-generator"), ) except MemberFileError as exc: orchestration_error = str(exc) diff --git a/src/agentnave/task_start.py b/src/agentnave/task_start.py index 1bf4c4d5..24a9ff10 100644 --- a/src/agentnave/task_start.py +++ b/src/agentnave/task_start.py @@ -71,16 +71,16 @@ def latest_session_task( def review_role(role: str | None) -> str: - """Only review role labels select a seat; Explorer/Worker names require seat.""" + """Only review role labels select a seat; orchestration names require seat.""" name = (role or "").strip() return name if name in get_args(ReviewRole.__value__) else "" def role_access(role: str | None) -> Literal["read", "write"] | None: - """The access a known duty carries: Explorers and reviewers read, Workers write.""" + """Explorers and reviewers read; Workers and the image generator write.""" name = (role or "").strip() if name in get_args(OrchestrationRole.__value__): - return "write" if name.startswith("worker-") else "read" + return "write" if name.startswith("worker-") or name == "image-generator" else "read" return "read" if name in get_args(ReviewRole.__value__) else None diff --git a/tests/test_entrypoints.py b/tests/test_entrypoints.py index 6461e46d..8b95b4e4 100644 --- a/tests/test_entrypoints.py +++ b/tests/test_entrypoints.py @@ -339,6 +339,109 @@ async def test_start_agent_provider_inference_reports_unusable_seats_and_require assert "built-in subagent" in str(native.content) +@pytest.mark.asyncio +async def test_image_generator_seat_defaults_access_resume_and_native( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from agentnave.host import Host + + _install_fake_claude(tmp_path, monkeypatch) + monkeypatch.setenv("AGENTNAVE_DATA_DIR", str(tmp_path)) + path = tmp_path / "orchestration.json" + seat = {"provider": "claude", "model": "chosen", "effort": "high"} + document: dict[str, object] = { + "version": 1, + "preset": "split", + "worker": {"easy": seat, "hard": seat}, + } + path.write_text(json.dumps(document)) + async with Client(mcp) as client: + listed = _payload((await client.call_tool("list_members", {})).structured_content) + assert "image_generation" not in _payload(listed["orchestration"]) + missing = await client.call_tool( + "start_agent", {"prompt": "ok", "cwd": str(tmp_path), "seat": "image-generator"} + ) + assert missing.is_error and "not configured" in str(missing.content) + + document["image_generation"] = seat + path.write_text(json.dumps(document)) + listed = _payload((await client.call_tool("list_members", {})).structured_content) + assert _payload(listed["orchestration"])["image_generation"] == seat + started = await _start_and_wait( + client, cwd=str(tmp_path), seat="image-generator", role="生成插图" + ) + assert started["provider"] == "claude" and started["model"] == "chosen" + assert started["effort"] == "high" and started["access"] == "write" + assert started["defaults_applied"] == [ + "image-generator seat", + "model", + "effort", + "permission_mode", + "permission_fallback", + "provider (image-generator seat)", + "access (image-generator seat)", + ] + resumed = await _start_and_wait(client, session_id="session-e2e") + assert resumed["conversation_id"] == started["conversation_id"] + assert resumed["model"] == "chosen" and resumed["access"] == "write" + assert "seat (session_id history)" in cast(list[str], resumed["defaults_applied"]) + explicit = await _start_and_wait( + client, + cwd=str(tmp_path), + seat="image-generator", + provider="claude", + provider_options={"model": "explicit", "effort": "medium"}, + access="write", + ) + assert explicit["model"] == "explicit" and explicit["effort"] == "medium" + assert explicit["defaults_applied"] == ["permission_mode", "permission_fallback"] + other_model = await _start_and_wait( + client, + cwd=str(tmp_path), + seat="image-generator", + provider_options={"model": "explicit"}, + ) + assert other_model["model"] == "explicit" and "effort" not in other_model + contradiction = await client.call_tool( + "start_agent", + {"prompt": "ok", "cwd": str(tmp_path), "seat": "image-generator", "access": "read"}, + ) + assert contradiction.is_error and "declares access=write" in str(contradiction.content) + + document["image_generation"] = {"provider": "codex", "model": "other-cli"} + path.write_text(json.dumps(document)) + replacement = await _start_and_wait( + client, cwd=str(tmp_path), seat="image-generator", provider="claude" + ) + assert "model" not in replacement and replacement["access"] == "write" + assert "image-generator seat" not in cast(list[str], replacement["defaults_applied"]) + + document["image_generation"] = seat + document["preset"] = "off" + path.write_text(json.dumps(document)) + listed = _payload((await client.call_tool("list_members", {})).structured_content) + assert listed["orchestration"] == {"enabled": False} + disabled = await client.call_tool( + "start_agent", {"prompt": "ok", "cwd": str(tmp_path), "seat": "image-generator"} + ) + assert disabled.is_error and "not enabled" in str(disabled.content) + bypass = await _start_and_wait( + client, cwd=str(tmp_path), seat="image-generator", provider="claude" + ) + assert "model" not in bypass and "effort" not in bypass + + document["preset"] = "split" + path.write_text(json.dumps(document)) + monkeypatch.setattr(mcp_runtime, "HOST", Host("claude", "env")) + monkeypatch.setattr(mcp_runtime, "EXCLUDED_PROVIDERS", frozenset({"claude"})) + listed = _payload((await client.call_tool("list_members", {})).structured_content) + assert _payload(listed["orchestration"])["image_generation"] == {**seat, "native": True} + native = await client.call_tool( + "start_agent", {"prompt": "ok", "cwd": str(tmp_path), "seat": "image-generator"} + ) + assert native.is_error and "built-in subagent" in str(native.content) + + @pytest.mark.asyncio async def test_mcp_blocker_interrupts_default_wait_with_bounded_public_output( tmp_path: Path, monkeypatch: pytest.MonkeyPatch diff --git a/tests/test_members.py b/tests/test_members.py index f9188415..156cbba0 100644 --- a/tests/test_members.py +++ b/tests/test_members.py @@ -20,6 +20,7 @@ ProviderName, ReviewFile, ReviewSeat, + ReviewSeatStatus, SettingsFileError, apply_member, list_members, @@ -56,6 +57,7 @@ def test_disabled_orchestration_hides_seats_and_does_not_apply_defaults( "preset": "off", "explorer": {"easy": seat}, "worker": {"hard": seat}, + "image_generation": seat, } def check(host: Host, enabled: bool) -> None: @@ -64,9 +66,11 @@ def check(host: Host, enabled: bool) -> None: assert status.enabled is enabled assert bool(status.explorer) is enabled assert bool(status.worker) is enabled + assert (status.image_generation is not None) is enabled assert (role_seat("explorer-easy", host) is not None) is enabled assert (role_seat("worker-hard", host) is not None) is enabled assert (role_seat("worker-frontend-hard", host) is not None) is enabled + assert (role_seat("image-generator", host) is not None) is enabled codex, claude = Host("codex", "env"), Host("claude", "env") check(codex, False) @@ -117,6 +121,16 @@ def test_interface_seat_falls_back_to_the_worker_seat_of_its_difficulty( assert cast(ReviewSeat, role_seat("worker-frontend-hard", host)).model == "general" assert cast(ReviewSeat, role_seat("worker-frontend-easy", host)).model == "interface" assert role_seat("worker-easy", host) is None + assert status.image_generation is None + assert role_seat("image-generator", host) is None + with pytest.raises(MemberFileError, match="not configured"): + role_seat("image-generator", host, required=True) + with pytest.raises(ValidationError): + OrchestrationFile.model_validate({**document, "image_generation": {"hard": general}}) + with pytest.raises(ValidationError): + OrchestrationFile.model_validate( + {**document, "image_generation": {"provider": "codex", "model": " "}} + ) @pytest.mark.parametrize( @@ -593,6 +607,7 @@ def installed(adapter: AdapterHooks) -> bool: "explorer": {"easy": unlisted, "hard": native}, "worker": {"hard": shared}, "worker_frontend": {"easy": shared}, + "image_generation": shared, } ) ) @@ -607,6 +622,7 @@ def installed(adapter: AdapterHooks) -> bool: result.orchestration.explorer["easy"], result.orchestration.worker["hard"], result.orchestration.worker_frontend["easy"], + cast(ReviewSeatStatus, result.orchestration.image_generation), ] for seat in unavailable: assert seat.available is False and seat.reason == "CLI is not installed" @@ -621,6 +637,8 @@ def installed(adapter: AdapterHooks) -> bool: refreshed = list_members(frozenset(), Host()) assert all(member.error is None for member in refreshed.members) assert all(seat.available is None for seat in refreshed.review.values()) + assert refreshed.orchestration.image_generation is not None + assert refreshed.orchestration.image_generation.available is None assert calls == dict.fromkeys(providers, 1) From bd51250befd209d4d3d6cd9b805aae39c36761b2 Mon Sep 17 00:00:00 2001 From: Tim Wong <280945586+TimWongUp@users.noreply.github.com> Date: Sat, 7 Nov 2026 00:35:40 +0800 Subject: [PATCH 03/17] =?UTF-8?q?feat(tray):=20=E9=AB=98=E7=BA=A7=E7=BC=96?= =?UTF-8?q?=E6=8E=92=E5=A2=9E=E5=8A=A0=E5=8F=AF=E9=80=89=E7=94=9F=E5=9B=BE?= =?UTF-8?q?=E5=B7=A5=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- apps/tray/src/members.rs | 9 ++++- apps/tray/src/rules.rs | 1 + apps/tray/src/rules/tests.rs | 15 ++++++++ apps/tray/ui/advanced.js | 41 +++++++++++--------- apps/tray/ui/tests/members.test.cjs | 58 +++++++++++++++++++++++++++++ 5 files changed, 105 insertions(+), 19 deletions(-) diff --git a/apps/tray/src/members.rs b/apps/tray/src/members.rs index f976aac7..73f65f73 100644 --- a/apps/tray/src/members.rs +++ b/apps/tray/src/members.rs @@ -290,8 +290,9 @@ fn validate_seat(seat: &Value, what: &str) -> Result<(), String> { /// orchestration.json: `{"version": 1, "preset": "off" | "split" | "parallel" | "custom", /// "custom": text, "apps": {row: mode}, "effective_apps": {row: mode}, "explorer": {"easy" | "hard": -/// seat}, "worker": {...}, "worker_frontend": {...}}`. Interface Workers have a key of their own so -/// servers that predate it ignore them rather than reject the file. The App writes the chosen +/// seat}, "worker": {...}, "worker_frontend": {...}, "image_generation": seat}`. Interface Workers +/// and the image seat have keys of their own so servers that predate them ignore them rather than +/// reject the file. The App writes the chosen /// rules into the boss apps' global rules and /// `effective_apps`, every connected row's mode as its group applies it, for the server. pub fn validate_orchestration(document: &Value) -> Result<(), String> { @@ -307,6 +308,7 @@ pub fn validate_orchestration(document: &Value) -> Result<(), String> { "explorer", "worker", "worker_frontend", + "image_generation", ], ORCHESTRATION_FILE, )?; @@ -346,6 +348,9 @@ pub fn validate_orchestration(document: &Value) -> Result<(), String> { validate_seat(seat, &format!("{role}.{difficulty}"))?; } } + if let Some(seat) = top.get("image_generation") { + validate_seat(seat, "image_generation")?; + } Ok(()) } diff --git a/apps/tray/src/rules.rs b/apps/tray/src/rules.rs index 99065e4b..8afa0eb7 100644 --- a/apps/tray/src/rules.rs +++ b/apps/tray/src/rules.rs @@ -18,6 +18,7 @@ const SHARED: &str = "## AgentNave 编排偏好 - Explorer(只读:探索、规划、评审)与 Worker(可写:实现)各有简单、困难两档,按任务难度二选一;涉及界面(页面、样式、交互、看截图或设计稿)的实现改用 Worker 的界面两档。取 `list_members` 的 `orchestration`:`native` 工位交给宿主自带子代理并尽量用该模型,它不经过服务,prompt 要写明非交互,Explorer 还要写明只读;其余派发时 `seat` 用 `explorer-easy`、`explorer-hard`、`worker-easy`、`worker-hard`、`worker-frontend-easy` 或 `worker-frontend-hard`,省略 `provider`、`access` 和模型,服务按 App 工位补齐;`role` 写具体职责,新任务必传 `cwd`。工位不可用时用 `list_members` 的 `replacement`,连同原来的 `seat` 或 `role` 一起传;没有工位时按任务组员选,都不可用时自己做。 - 选择本档即授权在已确认的任务内主动派发,包括实现,无需用户点名 CLI;小事自己做。 - 探索、规划派 Explorer,实现派 Worker。 +- 要生成图片且 `orchestration` 配了生图工位时派它,`seat` 用 `image-generator`,prompt 写清图像需求和产物路径,由它的对话模型自行调用生图服务;未配置时不改派 Worker。 - 同一组文件同一时间只有一个 Worker 在写;同一职责续接只传 `session_id`,其余由服务继承;换职责时传新的 `role` 或 `seat` 并给自足简报。 - 本偏好不决定何时评审,用户要求或其规则要求时才评审。评审时有 agentnave-review Skill 就按它定级、选工位,不另派 Explorer;没有时派 Explorer,不交给写这段代码的 Worker,尽量换模型。 - `succeeded` 不等于完成,boss 核对产物后验收;失败或取消时先核对现状,再决定续接、重派还是收尾。"; diff --git a/apps/tray/src/rules/tests.rs b/apps/tray/src/rules/tests.rs index 7b9c8180..3113febd 100644 --- a/apps/tray/src/rules/tests.rs +++ b/apps/tray/src/rules/tests.rs @@ -278,6 +278,21 @@ fn each_boss_gets_its_own_mode_and_apps_sharing_a_file_get_one() { assert!(valid(&document).is_ok()); assert!(valid(&json!({"version": 1, "preset": "off", "apps": {"codex": "fast"}})).is_err()); assert!(valid(&json!({"version": 1, "preset": "off", "apps": {"codex": "custom"}})).is_err()); + let image = |seat| json!({"version": 1, "preset": "off", "image_generation": seat}); + assert!( + valid(&image( + json!({"provider": "codex", "model": "m", "effort": "high"}) + )) + .is_ok() + ); + assert!(valid(&image(json!({"easy": {"provider": "codex", "model": "m"}}))).is_err()); + assert!(valid(&image(json!({"provider": "codex", "model": " "}))).is_err()); + assert!( + valid(&image( + json!({"provider": "codex", "model": "m", "effort": 1}) + )) + .is_err() + ); // Turning one boss off leaves the other's block alone. let document = json!({"preset": "off", "custom": "我的规则", "apps": {"codex": "custom"}}); diff --git a/apps/tray/ui/advanced.js b/apps/tray/ui/advanced.js index 1f695ca9..98e60eb2 100644 --- a/apps/tray/ui/advanced.js +++ b/apps/tray/ui/advanced.js @@ -4,9 +4,10 @@ // one that fails stays unsaved for another try while the others land. // Orchestration lives in orchestration.json: the rules text each boss gets, by mode (`apps`, with -// `preset` for rows without one, and bosses connected later), the user's own text, and the Explorer -// and Worker seats boss reads from list_members. The App writes the modes into the connected boss -// apps' global rules. The rules files are listed one row per file with every app that reads it. +// `preset` for rows without one, and bosses connected later), the user's own text, and the Explorer, +// Worker and image generation seats boss reads from list_members. The App writes the modes into the +// connected boss apps' global rules. The rules files are listed one row per file with every app that +// reads it. const ORCHESTRATION_EFFECTS = { off: AN.L("不写入全局规则,boss 按自己的习惯派发。", "Does not write global rules; the boss dispatches in its own way."), split: AN.L("写入所选 boss 的全局规则:boss 未被点名也会把探索、规划交给 Explorer,实现交给 Worker,会用到它们的额度。", "Writes to selected bosses' global rules: even when not named, the boss delegates exploration and planning to Explorer, and implementation to Worker, using their quota."), @@ -20,7 +21,10 @@ const ORCHESTRATION_ROLES = [ ["worker", "hard", AN.L("Worker · 困难", "Worker · Hard"), AN.L("可写:界面以外的实现", "Writable: implementation other than interfaces")], ["worker_frontend", "easy", AN.L("Worker · 界面 · 简单", "Worker · Interface · Easy"), AN.L("可写:页面、样式、交互、看图", "Writable: pages, styles, interactions, images")], ["worker_frontend", "hard", AN.L("Worker · 界面 · 困难", "Worker · Interface · Hard"), AN.L("可写:页面、样式、交互、看图", "Writable: pages, styles, interactions, images")], + // One seat with no difficulty, its model the one the CLI talks to, which calls the image service. + ["image_generation", null, AN.L("生图(可选)", "Image generation (optional)"), null, AN.L("未配置", "Not configured"), AN.L("生图的对话模型", "Chat model for image generation")], ]; +const orchestrationSeat = (doc, role, difficulty) => difficulty ? doc[role]?.[difficulty] : doc[role]; const ORCHESTRATION_NAMES = { off: AN.L("关闭", "Off"), split: AN.L("分工协作", "Split"), parallel: AN.L("并行分工", "Parallel"), custom: AN.L("自定义", "Custom") }; let orchestration = null; // { document, error, presets, files, bosses } from GET /app/orchestration: what is saved. let orchestrationDraft = null; // The document as picked, without the custom text, which the editor holds. @@ -70,8 +74,8 @@ async function loadOrchestration() { async function saveOrchestration() { const wanted = orchestrationWanted(); for (const [role, difficulty] of ORCHESTRATION_ROLES) { - const seat = wanted[role]?.[difficulty]; - if (seat && !sameJson(seat, orchestration.document[role]?.[difficulty])) await listSeatModel(seat); + const seat = orchestrationSeat(wanted, role, difficulty); + if (seat && !sameJson(seat, orchestrationSeat(orchestration.document, role, difficulty))) await listSeatModel(seat); } const response = await fetch("/app/orchestration", { method: "POST", @@ -156,11 +160,14 @@ function renderBossChips() { function setOrchestrationSeat(role, difficulty, seat) { const shown = orchestrationShown(); - const seats = { ...(shown[role] || {}) }; - if (seat) seats[difficulty] = seat; - else delete seats[difficulty]; - orchestrationDraft = { ...shown, [role]: seats }; - if (!Object.keys(seats).length) delete orchestrationDraft[role]; + let value = seat; + if (difficulty) { + value = { ...(shown[role] || {}) }; + if (seat) value[difficulty] = seat; + else delete value[difficulty]; + } + orchestrationDraft = { ...shown, [role]: value }; + if (!Object.keys(value || {}).length) delete orchestrationDraft[role]; renderOrchestration(); advancedEdited(); } @@ -186,14 +193,14 @@ function renderOrchestration() { roles.hidden = preset === "off" || !editable; if (!roles.hidden) { const focusKey = document.activeElement?.dataset?.focusKey; - roles.replaceChildren(...ORCHESTRATION_ROLES.map(([role, difficulty, name, hint]) => { - const seat = shown[role]?.[difficulty]; + roles.replaceChildren(...ORCHESTRATION_ROLES.map(([role, difficulty, name, hint, unsetName, modelLabel]) => { + const seat = orchestrationSeat(shown, role, difficulty); // An unset interface seat runs the Worker seat of its difficulty, as the server fills it. const general = role === "worker_frontend" && shown.worker?.[difficulty] && ORCHESTRATION_ROLES.find(([r, d]) => r === "worker" && d === difficulty); - const unset = general ? AN.L(`沿用 ${general[2]}`, `Same as ${general[2]}`) : undefined; + const unset = general ? AN.L(`沿用 ${general[2]}`, `Same as ${general[2]}`) : unsetName; return el("div", { className: "review-row" }, - el("div", { className: "review-role" }, el("strong", {}, name), el("small", {}, hint)), - ...seatSelects(`${role}/${difficulty}`, name, seat, (next) => setOrchestrationSeat(role, difficulty, next), unset)); + el("div", { className: "review-role" }, el("strong", {}, name), hint && el("small", {}, hint)), + ...seatSelects(`${role}/${difficulty}`, name, seat, (next) => setOrchestrationSeat(role, difficulty, next), unset, modelLabel)); })); if (focusKey) roles.querySelector(`[data-focus-key="${CSS.escape(focusKey)}"]`)?.focus({ preventScroll: true }); } @@ -304,7 +311,7 @@ function reviewSelect(key, label, value, options, onPick, disabled = false) { } // A seat's CLI, then one of that CLI's models, then an effort or the member's default. -function seatSelects(key, name, seat, onSet, unset = AN.L("自动", "Auto")) { +function seatSelects(key, name, seat, onSet, unset = AN.L("自动", "Auto"), modelLabel = AN.L(`${name}的模型`, `Model for ${name}`)) { const provider = seat?.provider || ""; // Only CLIs 员工 lets work alone, or a connected boss's own CLI, which its built-in subagent // runs; a disabled one stays shown while picked, so it can be changed. @@ -335,7 +342,7 @@ function seatSelects(key, name, seat, onSet, unset = AN.L("自动", "Auto")) { : [...(seat.effort ? [] : [["", AN.L("选一个档位", "Choose effort"), true]]), ...levels.map((l) => [l, l])]; return [ reviewSelect(`${key}/cli`, AN.L(`${name}的 CLI`, `CLI for ${name}`), provider, [["", unset], ...clis], pickCli), - reviewSelect(`${key}/model`, AN.L(`${name}的模型`, `Model for ${name}`), seat?.model || "", + reviewSelect(`${key}/model`, modelLabel, seat?.model || "", seat ? models.map((m) => [m, m]) : [["", "—"]], pickModel, !seat), reviewSelect(`${key}/effort`, AN.L(`${name}的思考档位`, `Effort for ${name}`), seat?.effort || "", efforts, pickEffort, !seat || !levels.length), ]; diff --git a/apps/tray/ui/tests/members.test.cjs b/apps/tray/ui/tests/members.test.cjs index 6e488cdc..f1ea5429 100644 --- a/apps/tray/ui/tests/members.test.cjs +++ b/apps/tray/ui/tests/members.test.cjs @@ -33,6 +33,64 @@ function editor(fetch = async () => { throw new Error("unexpected request"); }, return { run, listeners, doc: () => JSON.parse(run("JSON.stringify(currentDoc)")) }; } +test("the optional image seat picks, clears, discards, saves and reloads as one flat seat", async () => { + let saved = { version: 1, preset: "split" }, members; + const app = editor(async (url, options) => { + const body = options?.body && JSON.parse(options.body); + if (url === "/app/orchestration" && !body) { + return { ok: true, json: async () => ({ document: saved, error: null, bosses: [], presets: { split: "rule" }, files: [] }) }; + } + if (url === "/app/orchestration") saved = body; + else members = body; + return { ok: true, json: async () => ({ saved: true, files: [], bosses: [] }) }; + }); + app.run(` + AN.L = (zh) => zh; + refreshDirty = () => {}; + const nodes = new Map(); + const node = (tag) => ({ + tagName: tag, attrs: {}, children: [], dataset: {}, listeners: {}, classList: { toggle() {} }, + setAttribute(key, value) { this.attrs[key] = value; }, removeAttribute(key) { delete this.attrs[key]; }, + addEventListener(type, fn) { this.listeners[type] = fn; }, appendChild(child) { this.children.push(child); }, + replaceChildren(...kids) { this.children = kids; }, querySelectorAll: () => [], querySelector: () => null, + }); + document.createElement = node; + document.createTextNode = (text) => text; + document.getElementById = (id) => nodes.get(id) || nodes.set(id, node("div")).get(id); + currentDoc.modes.task.codex = { models: ["chat-model"], overrides: {}, default: "chat-model" }; + `); + app.run(readFileSync(`${__dirname}/../advanced.js`, "utf8")); + await app.run("loadOrchestration()"); + const row = () => app.run('$("orchestration-roles").children.at(-1)'); + const pick = (value) => row().children[1].listeners.change({ target: { value } }); + const seat = { provider: "codex", model: "chat-model" }; + + assert.equal(row().children[0].children[0].children[0], "生图(可选)"); + assert.equal(row().children[0].children.length, 1); + assert.equal(row().children[1].children[0].children[0], "未配置"); + assert.equal(row().children[2].attrs["aria-label"], "生图的对话模型"); + + pick("codex"); + assert.equal(JSON.stringify(app.run("orchestrationWanted().image_generation")), JSON.stringify(seat)); + pick(""); + assert.equal(app.run('"image_generation" in orchestrationWanted()'), false); + assert.equal(app.run("orchestrationChanged()"), false); + pick("codex"); + app.run("discardAdvanced()"); + assert.equal(app.run("orchestrationChanged()"), false); + + pick("codex"); + await app.run("saveOrchestration()"); + assert.equal(JSON.stringify(saved.image_generation), JSON.stringify(seat)); + assert.ok(members.clis.codex.models["chat-model"]); + await app.run("loadOrchestration()"); + assert.equal(row().children[2].children.find((option) => option.attrs.value === "chat-model").attrs.selected, ""); + + pick(""); + await app.run("saveOrchestration()"); + assert.equal(Object.hasOwn(saved, "image_generation"), false); +}); + test("editing a full list keeps both scenes explicit and newly detected CLIs off", () => { const app = editor(); app.run('setProviderEnabled("codex", false); setProviderEnabled("codex", true)'); From c2b48c54befca770834777a50729d5d718fef2d9 Mon Sep 17 00:00:00 2001 From: Tim Wong <280945586+TimWongUp@users.noreply.github.com> Date: Sat, 7 Nov 2026 00:43:18 +0800 Subject: [PATCH 04/17] =?UTF-8?q?test:=20=E5=AF=B9=E9=BD=90=E7=94=9F?= =?UTF-8?q?=E5=9B=BE=E5=B7=A5=E4=BD=8D=E7=9A=84=E5=8F=AF=E9=80=89=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E5=90=88=E5=90=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/tray/src/members.rs | 2 +- .../app-files/orchestration/invalid-image-seat.json | 10 ++++++++++ .../app-files/orchestration/valid-effective-apps.json | 3 ++- .../fixtures/app-files/orchestration/valid-seats.json | 5 +++++ 4 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/app-files/orchestration/invalid-image-seat.json diff --git a/apps/tray/src/members.rs b/apps/tray/src/members.rs index 73f65f73..75a7002c 100644 --- a/apps/tray/src/members.rs +++ b/apps/tray/src/members.rs @@ -348,7 +348,7 @@ pub fn validate_orchestration(document: &Value) -> Result<(), String> { validate_seat(seat, &format!("{role}.{difficulty}"))?; } } - if let Some(seat) = top.get("image_generation") { + if let Some(seat) = top.get("image_generation").filter(|seat| !seat.is_null()) { validate_seat(seat, "image_generation")?; } Ok(()) diff --git a/tests/fixtures/app-files/orchestration/invalid-image-seat.json b/tests/fixtures/app-files/orchestration/invalid-image-seat.json new file mode 100644 index 00000000..5acc060d --- /dev/null +++ b/tests/fixtures/app-files/orchestration/invalid-image-seat.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "preset": "split", + "image_generation": { + "easy": { + "provider": "codex", + "model": "gpt-6.1-sol" + } + } +} diff --git a/tests/fixtures/app-files/orchestration/valid-effective-apps.json b/tests/fixtures/app-files/orchestration/valid-effective-apps.json index 4ef774a1..75308062 100644 --- a/tests/fixtures/app-files/orchestration/valid-effective-apps.json +++ b/tests/fixtures/app-files/orchestration/valid-effective-apps.json @@ -10,5 +10,6 @@ "grok": "split", "codex": "off", "codex@.codex-work": "parallel" - } + }, + "image_generation": null } diff --git a/tests/fixtures/app-files/orchestration/valid-seats.json b/tests/fixtures/app-files/orchestration/valid-seats.json index 3829085e..e5824ff2 100644 --- a/tests/fixtures/app-files/orchestration/valid-seats.json +++ b/tests/fixtures/app-files/orchestration/valid-seats.json @@ -19,5 +19,10 @@ "provider": "antigravity", "model": "gemini-4-argon-medium" } + }, + "image_generation": { + "provider": "codex", + "model": "gpt-6.1-sol", + "effort": "medium" } } From fcf55125c17796f9d8972a7cbd2674588ccf3bce Mon Sep 17 00:00:00 2001 From: Tim Wong <280945586+TimWongUp@users.noreply.github.com> Date: Sat, 7 Nov 2026 01:00:04 +0800 Subject: [PATCH 05/17] =?UTF-8?q?fix:=20=E4=BB=85=E5=90=AF=E7=94=A8?= =?UTF-8?q?=E7=94=9F=E5=9B=BE=E5=B7=A5=E4=BD=8D=E6=97=B6=E6=8F=90=E4=BE=9B?= =?UTF-8?q?=E8=A7=84=E5=88=99=E4=B8=8E=E5=AE=BF=E4=B8=BB=E5=8F=8D=E9=A6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CONTEXT.md | 2 +- apps/tray/src/data/request.rs | 2 +- apps/tray/src/rules.rs | 28 ++++++++++++++-------- apps/tray/src/rules/tests.rs | 36 +++++++++++++++++++++++++++++ apps/tray/ui/advanced.js | 12 ++++++++-- apps/tray/ui/tests/members.test.cjs | 5 +++- skills/agentnave-manager/SKILL.md | 2 +- src/agentnave/mcp_runtime.py | 30 +++++++++++++++++------- src/agentnave/mcp_tasks.py | 2 +- tests/test_entrypoints.py | 4 ++++ 10 files changed, 98 insertions(+), 25 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 48fd47bf..9301d316 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -121,7 +121,7 @@ _Avoid_: 编排引擎、调度 ## 生图工位 -编排中可选的图像生成工位,用户为它选定 CLI 及与之对话的模型;实际图像由该 CLI 的模型调用自身可用的生图服务生成。它独立于 Explorer/Worker 的难度档,未配置时没有默认绑定。 +编排中可选的图像生成工位,用户为它选定 CLI 及与之对话的模型;实际图像由该 CLI 的模型调用自身可用的生图服务生成。它独立于 Explorer/Worker 的难度档,未配置时没有默认绑定;生图编排规则与工位反馈仅在选定后提供。 ## 权限档位(Permission Tier) diff --git a/apps/tray/src/data/request.rs b/apps/tray/src/data/request.rs index aebcdbd9..e8c0f807 100644 --- a/apps/tray/src/data/request.rs +++ b/apps/tray/src/data/request.rs @@ -198,7 +198,7 @@ impl Reader { }; let rules = self.rule_files(); Response::json(&json!({"document": document, "error": error, - "presets": crate::rules::presets(), "files": rules["files"], "bosses": rules["bosses"]})) + "presets": crate::rules::presets(&document), "files": rules["files"], "bosses": rules["bosses"]})) } pub(super) fn save_orchestration(&self, payload: &Value) -> Response { // The modes each row runs on are worked out here, never taken from the page. diff --git a/apps/tray/src/rules.rs b/apps/tray/src/rules.rs index 8afa0eb7..e4dc4ad0 100644 --- a/apps/tray/src/rules.rs +++ b/apps/tray/src/rules.rs @@ -18,11 +18,12 @@ const SHARED: &str = "## AgentNave 编排偏好 - Explorer(只读:探索、规划、评审)与 Worker(可写:实现)各有简单、困难两档,按任务难度二选一;涉及界面(页面、样式、交互、看截图或设计稿)的实现改用 Worker 的界面两档。取 `list_members` 的 `orchestration`:`native` 工位交给宿主自带子代理并尽量用该模型,它不经过服务,prompt 要写明非交互,Explorer 还要写明只读;其余派发时 `seat` 用 `explorer-easy`、`explorer-hard`、`worker-easy`、`worker-hard`、`worker-frontend-easy` 或 `worker-frontend-hard`,省略 `provider`、`access` 和模型,服务按 App 工位补齐;`role` 写具体职责,新任务必传 `cwd`。工位不可用时用 `list_members` 的 `replacement`,连同原来的 `seat` 或 `role` 一起传;没有工位时按任务组员选,都不可用时自己做。 - 选择本档即授权在已确认的任务内主动派发,包括实现,无需用户点名 CLI;小事自己做。 - 探索、规划派 Explorer,实现派 Worker。 -- 要生成图片且 `orchestration` 配了生图工位时派它,`seat` 用 `image-generator`,prompt 写清图像需求和产物路径,由它的对话模型自行调用生图服务;未配置时不改派 Worker。 - 同一组文件同一时间只有一个 Worker 在写;同一职责续接只传 `session_id`,其余由服务继承;换职责时传新的 `role` 或 `seat` 并给自足简报。 - 本偏好不决定何时评审,用户要求或其规则要求时才评审。评审时有 agentnave-review Skill 就按它定级、选工位,不另派 Explorer;没有时派 Explorer,不交给写这段代码的 Worker,尽量换模型。 - `succeeded` 不等于完成,boss 核对产物后验收;失败或取消时先核对现状,再决定续接、重派还是收尾。"; +const IMAGE_GENERATION: &str = "\n- 生成图片派生图工位,`seat` 用 `image-generator`,prompt 写清图像需求和产物路径,由它的对话模型自行调用生图服务。"; + const PARALLEL: &str = " - 计划确认后,互不依赖的写入步骤并行派给多个 Worker,在 Git 仓库里各用独立 worktree;无法隔离时串行。boss 合入当前分支,最后统一验收。 - 有实质取舍或分歧时进会议室出决定,会上不改代码。 @@ -30,8 +31,8 @@ const PARALLEL: &str = " - 没有新证据或待解问题时,不再追加讨论、重试和验证。"; /// Each preset's rules text, for the settings page and the custom editor's starting text. -pub fn presets() -> Value { - json!({"split": SHARED, "parallel": format!("{SHARED}{PARALLEL}")}) +pub fn presets(document: &Value) -> Value { + json!({"split": text_of(document, "split"), "parallel": text_of(document, "parallel"), "image_generation": IMAGE_GENERATION}) } /// The mode of apps sharing a file: the first set among them, or the default that rows without @@ -46,14 +47,21 @@ fn mode_of<'a>(document: &'a Value, group: &[&App]) -> &'a str { /// The text a mode puts in the rules files, if any. fn text_of(document: &Value, mode: &str) -> Option { - match mode { - "split" => Some(SHARED.into()), - "parallel" => Some(format!("{SHARED}{PARALLEL}")), - "custom" => Some(document["custom"].as_str()?.trim()) - .filter(|text| !text.is_empty()) - .map(Into::into), - _ => None, + let mut text = match mode { + "split" => SHARED.to_owned(), + "parallel" => format!("{SHARED}{PARALLEL}"), + "custom" => document["custom"].as_str()?.trim().to_owned(), + _ => return None, + }; + if text.is_empty() { + return None; + } + // Strip our generated line if it was copied into a custom preset before being cleared. + text = text.replace(IMAGE_GENERATION, ""); + if document["image_generation"].is_object() { + text.push_str(IMAGE_GENERATION); } + Some(text) } /// Custom text goes into every boss app's rules each turn, so it stays short and cannot end the diff --git a/apps/tray/src/rules/tests.rs b/apps/tray/src/rules/tests.rs index 3113febd..2fc0918b 100644 --- a/apps/tray/src/rules/tests.rs +++ b/apps/tray/src/rules/tests.rs @@ -343,3 +343,39 @@ fn a_held_sync_keeps_the_old_block_until_the_service_catches_up() { assert!(sync(&apps, &apps, h, &split, &[], &backup, false).is_empty()); assert!(sync(&apps, &apps, h, &split, &[], &backup, true).is_empty()); } + +#[test] +fn image_rules_follow_the_optional_seat_and_clear_copied_custom_text() { + let mut document = json!({"preset": "split"}); + for mode in ["split", "parallel"] { + assert!( + !text_of(&document, mode) + .unwrap() + .contains("image-generator") + ); + } + document["image_generation"] = json!({"provider": "codex", "model": "chat"}); + let generated = text_of(&document, "split").unwrap(); + assert_eq!(generated.matches("image-generator").count(), 1); + document["custom"] = json!(generated); + assert_eq!( + text_of(&document, "custom") + .unwrap() + .matches("image-generator") + .count(), + 1 + ); + document["image_generation"] = Value::Null; + assert!( + !text_of(&document, "custom") + .unwrap() + .contains("image-generator") + ); + assert!( + !presets(&document)["split"] + .as_str() + .unwrap() + .contains("image-generator") + ); + assert!(text_of(&document, "off").is_none()); +} diff --git a/apps/tray/ui/advanced.js b/apps/tray/ui/advanced.js index 98e60eb2..6cabab72 100644 --- a/apps/tray/ui/advanced.js +++ b/apps/tray/ui/advanced.js @@ -94,11 +94,19 @@ async function saveOrchestration() { if (failed.length) throw new Error(AN.L(`已保存,但 ${failed.map((f) => `${f.path}(${f.error})`).join("、")} 未能写入`, `Saved, but failed to write ${failed.map((f) => `${f.path} (${f.error})`).join(", ")}`)); } +function orchestrationPresetText(mode) { + const text = orchestration?.presets[mode]; + const image = orchestration?.presets.image_generation; + if (!text || !image) return text; + const base = text.replace(image, ""); + return orchestrationShown()?.image_generation ? base + image : base; +} + function viewMode(mode) { const editor = $("custom-text"); // The editor starts from the mode the user leaves; what it shows is what gets written. if (mode === "custom" && !editor.value.trim()) { - editor.value = orchestration.presets[orchestrationView === "off" ? "split" : orchestrationView]; + editor.value = orchestrationPresetText(orchestrationView === "off" ? "split" : orchestrationView); } orchestrationView = mode; renderOrchestration(); @@ -183,7 +191,7 @@ function renderOrchestration() { } $("orchestration-effect").textContent = orchestration ? ORCHESTRATION_EFFECTS[preset] : ""; renderBossChips(); - const text = orchestration?.presets[preset]; + const text = orchestrationPresetText(preset); $("orchestration-text").hidden = !text; $("orchestration-text").textContent = text || ""; // An unread or broken file cannot take edits: nothing here could be saved or discarded. diff --git a/apps/tray/ui/tests/members.test.cjs b/apps/tray/ui/tests/members.test.cjs index f1ea5429..d6280b22 100644 --- a/apps/tray/ui/tests/members.test.cjs +++ b/apps/tray/ui/tests/members.test.cjs @@ -38,7 +38,7 @@ test("the optional image seat picks, clears, discards, saves and reloads as one const app = editor(async (url, options) => { const body = options?.body && JSON.parse(options.body); if (url === "/app/orchestration" && !body) { - return { ok: true, json: async () => ({ document: saved, error: null, bosses: [], presets: { split: "rule" }, files: [] }) }; + return { ok: true, json: async () => ({ document: saved, error: null, bosses: [], presets: { split: "rule", image_generation: "\nimage-rule" }, files: [] }) }; } if (url === "/app/orchestration") saved = body; else members = body; @@ -70,10 +70,13 @@ test("the optional image seat picks, clears, discards, saves and reloads as one assert.equal(row().children[1].children[0].children[0], "未配置"); assert.equal(row().children[2].attrs["aria-label"], "生图的对话模型"); + assert.equal(app.run('orchestrationPresetText("split")'), "rule"); pick("codex"); + assert.equal(app.run('orchestrationPresetText("split")'), "rule\nimage-rule"); assert.equal(JSON.stringify(app.run("orchestrationWanted().image_generation")), JSON.stringify(seat)); pick(""); assert.equal(app.run('"image_generation" in orchestrationWanted()'), false); + assert.equal(app.run('orchestrationPresetText("split")'), "rule"); assert.equal(app.run("orchestrationChanged()"), false); pick("codex"); app.run("discardAdvanced()"); diff --git a/skills/agentnave-manager/SKILL.md b/skills/agentnave-manager/SKILL.md index b688d6af..abd537e7 100644 --- a/skills/agentnave-manager/SKILL.md +++ b/skills/agentnave-manager/SKILL.md @@ -50,7 +50,7 @@ description: 用户要求用 Claude Code、CodeBuddy Code、Codex CLI、Grok CLI 选谁执行:用户点名优先,否则 boss 按 `list_members` 自定。用户在 App 设了 Explorer(只读:探索、规划、评审)或 Worker(可写:实现)时,`orchestration` 给出各自简单、困难两档,按任务难度选一档;Worker 另有界面两档(`worker_frontend`),实现涉及界面时改用它,没设时服务沿用 Worker 同档。派发时 `seat` 写成对应的 `explorer-easy`、`worker-frontend-hard` 等,可省略 `provider`、`access` 和模型;`seat` 不显示,`role` 仍照常写具体职责。 -生图任务可使用 `orchestration.image_generation` 的可选工位,以 `seat="image-generator"` 派发,不分简单、困难档;未配置时不沿用 Worker。工位选择的是与 CLI 对话的模型,实际生图由它调用自身可用的服务,派发时写清图像需求与产物路径,不传生图模型或接口参数。CLI 是否具备生图能力由其原生工具与配置决定,保存工位不代表能力验证;同类宿主的 `native` 工位仍交给宿主自带能力。 +仅当 `list_members` 返回 `orchestration.image_generation` 时,生图任务使用这个可选工位,以 `seat="image-generator"` 派发,不分简单、困难档;未配置时不沿用 Worker。工位选择的是与 CLI 对话的模型,实际生图由它调用自身可用的服务,派发时写清图像需求与产物路径,不传生图模型或接口参数。CLI 是否具备生图能力由其原生工具与配置决定,保存工位不代表能力验证;同类宿主的 `native` 工位仍交给宿主自带能力。 | 用户需要 | 调用方式 | | --- | --- | diff --git a/src/agentnave/mcp_runtime.py b/src/agentnave/mcp_runtime.py index a05c01b0..836e09e7 100644 --- a/src/agentnave/mcp_runtime.py +++ b/src/agentnave/mcp_runtime.py @@ -45,6 +45,7 @@ ProviderName, SettingsFileError, data_root, + role_seat, ) from agentnave.models import ( InvocationError, @@ -219,14 +220,27 @@ def _check_arguments(name: str, arguments: dict[str, Any], schema: dict[str, Any class _Server(MCPServer[Runtime]): async def list_tools(self) -> list[Any]: - return [ - # Published schemas say what call_tool enforces: nothing beyond the listed arguments. - tool.model_copy( - update={"input_schema": {**tool.input_schema, "additionalProperties": False}} + image_enabled = role_seat("image-generator", HOST) is not None + published: list[Any] = [] + for tool in await super().list_tools(): + if tool.name in _HIDDEN_TOOLS: + continue + schema = json.loads(json.dumps(tool.input_schema)) + schema["additionalProperties"] = False + output = json.loads(json.dumps(tool.output_schema)) + if not image_enabled: + role = schema.get("$defs", {}).get("OrchestrationRole", {}) + if "enum" in role: + role["enum"] = [value for value in role["enum"] if value != "image-generator"] + if output: + status = output.get("$defs", {}).get("OrchestrationStatus", {}) + status.get("properties", {}).pop("image_generation", None) + # A connected client can cache this schema before the user enables the seat. + status["additionalProperties"] = True + published.append( + tool.model_copy(update={"input_schema": schema, "output_schema": output}) ) - for tool in await super().list_tools() - if tool.name not in _HIDDEN_TOOLS - ] + return published async def call_tool( self, name: str, arguments: dict[str, Any], context: Context[Runtime, Any] | None = None @@ -276,7 +290,7 @@ async def call_tool( "do not redispatch to fix metadata. " "access: read for exploration/planning/review, write for file changes; shown in App. " "read adds a prompt instruction, never native permissions. Explorer/review seats supply read, " - "Worker/image-generator seats write; omit access for them; contradictions reject. Never inherited on resume; " + "Worker seats write; omit access for them; contradictions reject. Never inherited on resume; " "without a seat declare each time or leave undeclared. " "forward: ordered message_ids from wait/run_round/decide_answers/read_conversation; " "only succeeded tasks or committed entries in this manager workspace, at most 20, no repeats. " diff --git a/src/agentnave/mcp_tasks.py b/src/agentnave/mcp_tasks.py index 20ab561c..c7adb918 100644 --- a/src/agentnave/mcp_tasks.py +++ b/src/agentnave/mcp_tasks.py @@ -135,7 +135,7 @@ async def start_agent( OrchestrationRole | None, Field( description=( - "App task seat (worker-frontend-* for interface work, image-generator for image generation): selects CLI if omitted, defaults if CLI matches and orchestration enabled; explicit options win; inherits on resume. Not shown; also pass role as the desk label." + "App task seat (worker-frontend-* for interface work): selects CLI if omitted, defaults if CLI matches and orchestration enabled; explicit options win; inherits on resume. Not shown; also pass role as the desk label." ) ), ] = None, diff --git a/tests/test_entrypoints.py b/tests/test_entrypoints.py index 8b95b4e4..87ac05a1 100644 --- a/tests/test_entrypoints.py +++ b/tests/test_entrypoints.py @@ -358,6 +358,8 @@ async def test_image_generator_seat_defaults_access_resume_and_native( async with Client(mcp) as client: listed = _payload((await client.call_tool("list_members", {})).structured_content) assert "image_generation" not in _payload(listed["orchestration"]) + catalog = str(await client.list_tools()) + str(client.instructions) + assert "image-generator" not in catalog and "image_generation" not in catalog missing = await client.call_tool( "start_agent", {"prompt": "ok", "cwd": str(tmp_path), "seat": "image-generator"} ) @@ -367,6 +369,7 @@ async def test_image_generator_seat_defaults_access_resume_and_native( path.write_text(json.dumps(document)) listed = _payload((await client.call_tool("list_members", {})).structured_content) assert _payload(listed["orchestration"])["image_generation"] == seat + assert "image-generator" in str(await client.list_tools()) started = await _start_and_wait( client, cwd=str(tmp_path), seat="image-generator", role="生成插图" ) @@ -421,6 +424,7 @@ async def test_image_generator_seat_defaults_access_resume_and_native( path.write_text(json.dumps(document)) listed = _payload((await client.call_tool("list_members", {})).structured_content) assert listed["orchestration"] == {"enabled": False} + assert "image-generator" not in str(await client.list_tools()) disabled = await client.call_tool( "start_agent", {"prompt": "ok", "cwd": str(tmp_path), "seat": "image-generator"} ) From 057a5a7f9c60860b689537e0d5ce4e923a4ac079 Mon Sep 17 00:00:00 2001 From: Tim Wong <280945586+TimWongUp@users.noreply.github.com> Date: Sat, 7 Nov 2026 07:15:05 +0800 Subject: [PATCH 06/17] =?UTF-8?q?fix:=20=E5=AF=B9=E5=90=8C=E7=B1=BB?= =?UTF-8?q?=E5=AE=BF=E4=B8=BB=E9=9A=90=E8=97=8F=E7=94=9F=E5=9B=BE=E5=B7=A5?= =?UTF-8?q?=E4=BD=8D=E4=B8=8E=E5=85=A8=E5=B1=80=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CONTEXT.md | 2 +- apps/tray/src/rules.rs | 24 ++++++++++++---- apps/tray/src/rules/tests.rs | 48 +++++++++++++++++++++++++++++++ docs/context/architecture.md | 2 +- skills/agentnave-manager/SKILL.md | 2 +- src/agentnave/members.py | 9 ++++-- tests/test_entrypoints.py | 5 ++-- tests/test_members.py | 12 ++++++-- 8 files changed, 89 insertions(+), 15 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 9301d316..6fd8cd87 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -121,7 +121,7 @@ _Avoid_: 编排引擎、调度 ## 生图工位 -编排中可选的图像生成工位,用户为它选定 CLI 及与之对话的模型;实际图像由该 CLI 的模型调用自身可用的生图服务生成。它独立于 Explorer/Worker 的难度档,未配置时没有默认绑定;生图编排规则与工位反馈仅在选定后提供。 +编排中可选的图像生成工位,用户为它选定 CLI 及与之对话的模型;实际图像由该 CLI 的模型调用自身可用的生图服务生成。它独立于 Explorer/Worker 的难度档,未配置时没有默认绑定;生图编排规则与工位反馈仅在选定后向不同类 CLI 的宿主提供;同类宿主不展示此工位。 ## 权限档位(Permission Tier) diff --git a/apps/tray/src/rules.rs b/apps/tray/src/rules.rs index e4dc4ad0..8e5ac9f9 100644 --- a/apps/tray/src/rules.rs +++ b/apps/tray/src/rules.rs @@ -275,12 +275,24 @@ pub fn effective(connected: &[App], home: &Path, document: &Value) -> Value { fn wanted(connected: &[App], home: &Path, document: &Value) -> Vec<(PathBuf, String)> { let mut wanted = Vec::new(); for group in groups(connected, home) { - if let Some(text) = text_of(document, mode_of(document, &group)) { - wanted.extend( - targets(&group, home) - .into_iter() - .map(|path| (path, text.clone())), - ); + for path in targets(&group, home) { + let mut scoped = document.clone(); + let provider = document["image_generation"]["provider"].as_str(); + // A shared rules file cannot give one of its readers a rule it must not see. + let own_reader = group.iter().any(|app| { + let cli = if app.id == "workbuddy" { + "codebuddy" + } else { + app.id + }; + provider == Some(cli) && would_read(app, home).contains(&path) + }); + if own_reader { + scoped["image_generation"] = Value::Null; + } + if let Some(text) = text_of(&scoped, mode_of(document, &group)) { + wanted.push((path, text)); + } } } wanted diff --git a/apps/tray/src/rules/tests.rs b/apps/tray/src/rules/tests.rs index 2fc0918b..23e8b1aa 100644 --- a/apps/tray/src/rules/tests.rs +++ b/apps/tray/src/rules/tests.rs @@ -379,3 +379,51 @@ fn image_rules_follow_the_optional_seat_and_clear_copied_custom_text() { ); assert!(text_of(&document, "off").is_none()); } + +#[test] +fn image_rule_is_hidden_from_its_own_cli_and_shared_readers() { + let home = tempfile::tempdir().unwrap(); + let h = home.path(); + let app = |id, root: &str| App { + key: id, + id, + label: id, + root: h.join(root), + }; + let apps = [ + app("codex", ".codex"), + app("claude", ".claude"), + app("grok", ".grok"), + app("workbuddy", ".workbuddy"), + ]; + let mut document = + json!({"preset": "split", "image_generation": {"provider": "codex", "model": "chat"}}); + let rows = wanted(&apps, h, &document); + for (path, text) in &rows { + assert_eq!( + text.contains("image-generator"), + !path.starts_with(h.join(".codex")) + ); + } + document["image_generation"]["provider"] = json!("claude"); + let rows = wanted(&apps, h, &document); + assert!( + rows.iter() + .find(|(p, _)| p.starts_with(h.join(".claude"))) + .is_some_and(|(_, text)| !text.contains("image-generator")) + ); + assert!( + rows.iter() + .find(|(p, _)| p.starts_with(h.join(".codex"))) + .unwrap() + .1 + .contains("image-generator") + ); + document["image_generation"]["provider"] = json!("codebuddy"); + assert!( + wanted(&apps, h, &document) + .iter() + .find(|(p, _)| p.starts_with(h.join(".workbuddy"))) + .is_some_and(|(_, text)| !text.contains("image-generator")) + ); +} diff --git a/docs/context/architecture.md b/docs/context/architecture.md index d64f09ba..8fbd5153 100644 --- a/docs/context/architecture.md +++ b/docs/context/architecture.md @@ -43,7 +43,7 @@ AgentNave 是供 Agent Manager 通过本地 STDIO MCP 使用的服务,在 Mana - `restricted_tools=true` 是显式受限配置,普通调用不继承;它收紧原生工具,但不等于操作系统沙箱。 - Provider 进程不继承 `CLAUDE_CODE_OAUTH_TOKEN`,Claude 子代理一律用 Claude Code 自己存的登录,不随 boss 宿主变化;AgentNave 不保存、不读取 Claude 凭据([ADR-0058](../adr/0058-claude-stored-login.md))。 -生图工位沿用普通 CLI 调用边界:App 选择对话 CLI 与模型,Provider CLI 拥有其生图工具、服务访问和产物生成;AgentNave 不维护生图模型目录或直接调用生图接口。 +生图工位沿用普通 CLI 调用边界:App 选择对话 CLI 与模型,Provider CLI 拥有其生图工具、服务访问和产物生成;AgentNave 不维护生图模型目录或直接调用生图接口。同类 CLI 的宿主不接收生图工位;生图规则也不写进它会读取的规则文件,共享文件按所有读取方约束。 ## Provider Adapter diff --git a/skills/agentnave-manager/SKILL.md b/skills/agentnave-manager/SKILL.md index abd537e7..55e68d6c 100644 --- a/skills/agentnave-manager/SKILL.md +++ b/skills/agentnave-manager/SKILL.md @@ -50,7 +50,7 @@ description: 用户要求用 Claude Code、CodeBuddy Code、Codex CLI、Grok CLI 选谁执行:用户点名优先,否则 boss 按 `list_members` 自定。用户在 App 设了 Explorer(只读:探索、规划、评审)或 Worker(可写:实现)时,`orchestration` 给出各自简单、困难两档,按任务难度选一档;Worker 另有界面两档(`worker_frontend`),实现涉及界面时改用它,没设时服务沿用 Worker 同档。派发时 `seat` 写成对应的 `explorer-easy`、`worker-frontend-hard` 等,可省略 `provider`、`access` 和模型;`seat` 不显示,`role` 仍照常写具体职责。 -仅当 `list_members` 返回 `orchestration.image_generation` 时,生图任务使用这个可选工位,以 `seat="image-generator"` 派发,不分简单、困难档;未配置时不沿用 Worker。工位选择的是与 CLI 对话的模型,实际生图由它调用自身可用的服务,派发时写清图像需求与产物路径,不传生图模型或接口参数。CLI 是否具备生图能力由其原生工具与配置决定,保存工位不代表能力验证;同类宿主的 `native` 工位仍交给宿主自带能力。 +仅当 `list_members` 返回 `orchestration.image_generation` 时,生图任务使用这个可选工位,以 `seat="image-generator"` 派发,不分简单、困难档;未配置时不沿用 Worker。工位选择的是与 CLI 对话的模型,实际生图由它调用自身可用的服务,派发时写清图像需求与产物路径,不传生图模型或接口参数。CLI 是否具备生图能力由其原生工具与配置决定,保存工位不代表能力验证;同类宿主不会收到这个生图工位;其自身生图能力由宿主原有规则处理。 | 用户需要 | 调用方式 | | --- | --- | diff --git a/src/agentnave/members.py b/src/agentnave/members.py index 425d5166..f318a19f 100644 --- a/src/agentnave/members.py +++ b/src/agentnave/members.py @@ -212,6 +212,11 @@ class OrchestrationFile(BaseModel): worker_frontend: dict[Difficulty, ReviewSeat] = {} image_generation: ReviewSeat | None = None + def image_seat(self, host: Host) -> ReviewSeat | None: + """The optional image CLI is only advertised to a different provider's host.""" + seat = self.image_generation + return seat if seat is not None and seat.provider != host.provider else None + def interface_workers(self) -> dict[Difficulty, ReviewSeat]: """The interface Worker seats, an unset one filled from the Worker seat of its difficulty.""" return {**self.worker, **self.worker_frontend} @@ -598,7 +603,7 @@ def role_seat(role: str | None, host: Host, *, required: bool = False) -> Review if not roles or not roles.enabled(host): seat = None elif role == "image-generator": - seat = roles.image_generation + seat = roles.image_seat(host) else: kind, _, key = role.rpartition("-") seats = { @@ -854,7 +859,7 @@ def installed(provider: str) -> bool: roles.interface_workers(), modes["task"], excluded, host, installed ), image_generation=_review_status( - {"image-generator": roles.image_generation} if roles.image_generation else {}, + {"image-generator": image} if (image := roles.image_seat(host)) else {}, modes["task"], excluded, host, diff --git a/tests/test_entrypoints.py b/tests/test_entrypoints.py index 87ac05a1..04c9c9a9 100644 --- a/tests/test_entrypoints.py +++ b/tests/test_entrypoints.py @@ -439,11 +439,12 @@ async def test_image_generator_seat_defaults_access_resume_and_native( monkeypatch.setattr(mcp_runtime, "HOST", Host("claude", "env")) monkeypatch.setattr(mcp_runtime, "EXCLUDED_PROVIDERS", frozenset({"claude"})) listed = _payload((await client.call_tool("list_members", {})).structured_content) - assert _payload(listed["orchestration"])["image_generation"] == {**seat, "native": True} + assert "image_generation" not in _payload(listed["orchestration"]) + assert "image-generator" not in str(await client.list_tools()) native = await client.call_tool( "start_agent", {"prompt": "ok", "cwd": str(tmp_path), "seat": "image-generator"} ) - assert native.is_error and "built-in subagent" in str(native.content) + assert native.is_error and "not configured" in str(native.content) @pytest.mark.asyncio diff --git a/tests/test_members.py b/tests/test_members.py index 156cbba0..0171d28a 100644 --- a/tests/test_members.py +++ b/tests/test_members.py @@ -66,12 +66,20 @@ def check(host: Host, enabled: bool) -> None: assert status.enabled is enabled assert bool(status.explorer) is enabled assert bool(status.worker) is enabled - assert (status.image_generation is not None) is enabled + assert (status.image_generation is not None) is (enabled and host.provider != "claude") assert (role_seat("explorer-easy", host) is not None) is enabled assert (role_seat("worker-hard", host) is not None) is enabled assert (role_seat("worker-frontend-hard", host) is not None) is enabled - assert (role_seat("image-generator", host) is not None) is enabled + assert (role_seat("image-generator", host) is not None) is ( + enabled and host.provider != "claude" + ) + assert ( + OrchestrationFile.model_validate( + {"version": 1, "image_generation": {"provider": "codebuddy", "model": "chat"}} + ).image_seat(Host("workbuddy")) + is None + ) codex, claude = Host("codex", "env"), Host("claude", "env") check(codex, False) document["apps"] = {"codex": "split"} From 3625d80bfe1910185770e376c140253597b189b3 Mon Sep 17 00:00:00 2001 From: Tim Wong <280945586+TimWongUp@users.noreply.github.com> Date: Sat, 7 Nov 2026 07:41:55 +0800 Subject: [PATCH 07/17] =?UTF-8?q?fix(tray):=20=E7=94=9F=E5=9B=BE=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E5=85=A5=E5=8F=A3=E4=B8=8D=E9=9A=8F=E7=BC=96=E6=8E=92?= =?UTF-8?q?=E6=A1=A3=E4=BD=8D=E9=9A=90=E8=97=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CONTEXT.md | 2 +- apps/tray/ui/advanced.js | 5 +++-- apps/tray/ui/tests/members.test.cjs | 5 +++++ 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 6fd8cd87..c4856daa 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -121,7 +121,7 @@ _Avoid_: 编排引擎、调度 ## 生图工位 -编排中可选的图像生成工位,用户为它选定 CLI 及与之对话的模型;实际图像由该 CLI 的模型调用自身可用的生图服务生成。它独立于 Explorer/Worker 的难度档,未配置时没有默认绑定;生图编排规则与工位反馈仅在选定后向不同类 CLI 的宿主提供;同类宿主不展示此工位。 +设置页常驻、独立于普通自定义角色的可选图像生成工位,用户为它选定 CLI 及与之对话的模型;实际图像由该 CLI 的模型调用自身可用的生图服务生成。它独立于 Explorer/Worker 的难度档,未配置时没有默认绑定;生图编排规则与工位反馈仅在选定后向不同类 CLI 的宿主提供;同类宿主不展示此工位。 ## 权限档位(Permission Tier) diff --git a/apps/tray/ui/advanced.js b/apps/tray/ui/advanced.js index 6cabab72..a9c9b4cc 100644 --- a/apps/tray/ui/advanced.js +++ b/apps/tray/ui/advanced.js @@ -198,10 +198,11 @@ function renderOrchestration() { const editable = orchestration && !orchestration.error; $("orchestration-custom").hidden = preset !== "custom" || !editable; const roles = $("orchestration-roles"); - roles.hidden = preset === "off" || !editable; + roles.hidden = !editable; if (!roles.hidden) { const focusKey = document.activeElement?.dataset?.focusKey; - roles.replaceChildren(...ORCHESTRATION_ROLES.map(([role, difficulty, name, hint, unsetName, modelLabel]) => { + const visibleRoles = preset === "off" ? ORCHESTRATION_ROLES.filter(([role]) => role === "image_generation") : ORCHESTRATION_ROLES; + roles.replaceChildren(...visibleRoles.map(([role, difficulty, name, hint, unsetName, modelLabel]) => { const seat = orchestrationSeat(shown, role, difficulty); // An unset interface seat runs the Worker seat of its difficulty, as the server fills it. const general = role === "worker_frontend" && shown.worker?.[difficulty] && ORCHESTRATION_ROLES.find(([r, d]) => r === "worker" && d === difficulty); diff --git a/apps/tray/ui/tests/members.test.cjs b/apps/tray/ui/tests/members.test.cjs index d6280b22..890deaf3 100644 --- a/apps/tray/ui/tests/members.test.cjs +++ b/apps/tray/ui/tests/members.test.cjs @@ -69,6 +69,11 @@ test("the optional image seat picks, clears, discards, saves and reloads as one assert.equal(row().children[0].children.length, 1); assert.equal(row().children[1].children[0].children[0], "未配置"); assert.equal(row().children[2].attrs["aria-label"], "生图的对话模型"); + app.run('viewMode("off")'); + assert.equal(app.run('$("orchestration-roles").hidden'), false); + assert.equal(app.run('$("orchestration-roles").children.length'), 1); + assert.equal(row().children[0].children[0].children[0], "生图(可选)"); + app.run('viewMode("split")'); assert.equal(app.run('orchestrationPresetText("split")'), "rule"); pick("codex"); From dacb7581d20bd532f636619f707ac5450dc8ff85 Mon Sep 17 00:00:00 2001 From: Tim Wong <280945586+TimWongUp@users.noreply.github.com> Date: Sat, 7 Nov 2026 12:18:41 +0800 Subject: [PATCH 08/17] =?UTF-8?q?docs:=20=E6=98=8E=E7=A1=AE=20CLI=20?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E6=B1=A0=E4=B8=8E=E7=BC=96=E6=8E=92=E4=BC=98?= =?UTF-8?q?=E5=85=88=E7=BA=A7=E5=90=88=E5=90=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/adr/0038-role-applies-app-seat.md | 1 + docs/adr/0053-disabled-cli-runs-for-no-seat.md | 1 + ...3-cli-model-pool-and-orchestration-precedence.md | 13 +++++++++++++ docs/adr/README.md | 2 ++ docs/context/architecture.md | 2 ++ 5 files changed, 19 insertions(+) create mode 100644 docs/adr/0063-cli-model-pool-and-orchestration-precedence.md diff --git a/docs/adr/0038-role-applies-app-seat.md b/docs/adr/0038-role-applies-app-seat.md index e740a930..b8dd13a1 100644 --- a/docs/adr/0038-role-applies-app-seat.md +++ b/docs/adr/0038-role-applies-app-seat.md @@ -2,6 +2,7 @@ - 状态:部分取代 - 日期:2026-10-30 +- CLI 模型边界与编排优先级由 [ADR-0063](0063-cli-model-pool-and-orchestration-precedence.md) 修订。 - 扩展 [ADR-0013](0013-members-defaults-and-host-identity.md) 的默认值套用,补充 [ADR-0030](0030-optional-review-gate-skill.md) 与 [ADR-0037](0037-app-writes-orchestration-rules.md):评审工位与 Explorer/Worker 工位原本只由 `list_members` 回报。 - 非交互与只读派发说明由 [ADR-0043](0043-service-prompt-instructions.md) 补充 - “role 兼当编排工位名的旧写法保留”由 [ADR-0050](0050-v1-compatibility-baseline.md) 取代:Explorer/Worker 只用 `seat`,评审角色名仍可用 `role`。 diff --git a/docs/adr/0053-disabled-cli-runs-for-no-seat.md b/docs/adr/0053-disabled-cli-runs-for-no-seat.md index 513ccfe0..3c44e9d2 100644 --- a/docs/adr/0053-disabled-cli-runs-for-no-seat.md +++ b/docs/adr/0053-disabled-cli-runs-for-no-seat.md @@ -2,6 +2,7 @@ - 状态:部分取代 - 日期:2026-11-04 +- CLI 模型边界与编排优先级由 [ADR-0063](0063-cli-model-pool-and-orchestration-precedence.md) 修订。 - 修订 [ADR-0038](0038-role-applies-app-seat.md) 中「工位不受单独干活的 CLI 名单约束」的条款;模型名单与档位范围仍不约束工位。 - 「CLI 默认装了就能用」由 [ADR-0054](0054-members-need-confirmation.md) 修订:有 App 时 CLI 要在员工里确认,只有没有 App 的宿主仍装了就能用。 diff --git a/docs/adr/0063-cli-model-pool-and-orchestration-precedence.md b/docs/adr/0063-cli-model-pool-and-orchestration-precedence.md new file mode 100644 index 00000000..08c9e46e --- /dev/null +++ b/docs/adr/0063-cli-model-pool-and-orchestration-precedence.md @@ -0,0 +1,13 @@ +# ADR-0063:CLI 共用模型池,编排替代普通任务目录 + +- 状态:已接受 +- 日期:2026-11-07 +- 修订 ADR-0038、ADR-0053 的工位模型名单边界。 + +普通任务与高级工位的两套默认值、分场景模型名单容易造成重复设置。当前宿主启用高级编排后,boss 的任务候选与默认值由编排工位提供,不再披露员工页的普通任务候选池;普通任务旧配置保留,关闭编排后恢复。会议仍独立开启,但共用 CLI 级可用模型名单。 + +CLI 的可用模型由员工页单一名单管理,普通任务、编排工位、评审绑定与会议均从其中选择;工位只选择默认模型和档位,不另建允许名单,不绕过 CLI 模型边界。缺少新名单时保留旧配置的既有行为;用户保存 CLI 级名单后,它优先于历史场景名单。禁止静默更换已不在可用名单中的绑定,界面和服务应报告冲突。 + +高级编排启用时,普通任务场景的模型默认值、场景名单与启停不再决定编排工位;工位从已确认的 CLI 配置及共用模型名单取得候选。权限档位、宿主排除、运行上限与 Provider 原生权限边界不变。未指定工位的显式普通调用不借用隐藏的普通任务默认值,调用方可明确提供参数,服务仍按 CLI 共用名单校验。 + +增加新的 CLI 级配置会使尚未升级的严格解析服务拒绝新字段;App 应沿用服务升级/重连要求,不承诺旧连接即时生效。配置兼容不等于运行时升级,未升级旧服务不能执行新合同。 diff --git a/docs/adr/README.md b/docs/adr/README.md index a9c1a1b8..c766970a 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -66,3 +66,5 @@ | 0060 | [托盘识别无人持有的运行记录](0060-tray-detects-orphaned-running-work.md) | 已接受 | — | | 0061 | [宿主配置指向固定启动器,升级不再改写宿主配置](0061-fixed-service-launcher.md) | 已接受 | — | | 0062 | [服务统一生成成员称呼](0062-service-owned-member-names.md) | 已接受 | — | + +| 0063 | [CLI 共用模型池,编排替代普通任务目录](0063-cli-model-pool-and-orchestration-precedence.md) | 已接受 | 修订 ADR-0038、ADR-0053 | diff --git a/docs/context/architecture.md b/docs/context/architecture.md index 8fbd5153..1970644e 100644 --- a/docs/context/architecture.md +++ b/docs/context/architecture.md @@ -107,3 +107,5 @@ App 与服务分别校验同一批文件;`tests/fixtures/app-files/` 中的样 - 口语摘要通过 ChatGPT 订阅登录、各平台 API Key、Magpie 本机网关或用户配置的 OpenAI 兼容接口生成。ChatGPT 登录与令牌由托盘 Rust 端拥有,直接请求 OpenAI API;各平台分别保存 API 密钥。Magpie 拥有供应商认证和模型路由,托盘只读其本机端口配置、核验网关身份并获取模型目录,不修改 Magpie 配置,也不把摘要模型接入为 Provider CLI。语音关闭时不联网读取摘要模型目录;启用语音和摘要后,长发言先发送给摘要模型,合成引擎只收到短口语;摘要失败时停止该条播报并报错。 - 语音直接复用右侧消息列表中已有的 boss 发言与员工回答正文,按消息时间共用摘要与播放队列。自动播报只来自用户主动开启的限时收听,页面拥有当前收听状态,重启不恢复;收听到期不打断正在播的消息,撤下后续收听内容并停止后续收听合成,手动点读与连播独立保留;模型 prompt 定义消息缩写规则,按小人家族及 boss 保存的人设定义性格、措辞与口吻,输出保留发言者本人的第一人称视角;两者仅供语音使用,不修改 CLI 任务或 Provider 参数。 - 托盘 App 独占写入数据根的 `voice.json`、`voice-keys.json`(含摘要平台密钥与 ChatGPT 登录令牌)和 `voice-cache/`;服务不读取这些文件。预览版的语音设置、密钥和缓存写入其独立的应用数据目录,不写正式版数据根。 + +CLI 级可用模型名单是任务、编排工位及会议共同的模型边界;当前宿主启用编排时不披露普通任务目录或套用其场景默认值,既有普通任务配置保留供关闭编排后恢复([ADR-0063](../adr/0063-cli-model-pool-and-orchestration-precedence.md))。 From 90f498afe3f4d68a582f2a6b50f6bceeb960fddd Mon Sep 17 00:00:00 2001 From: Tim Wong <280945586+TimWongUp@users.noreply.github.com> Date: Sat, 7 Nov 2026 12:20:21 +0800 Subject: [PATCH 09/17] =?UTF-8?q?docs:=20=E8=A1=A5=E5=85=85=20CLI=20?= =?UTF-8?q?=E5=85=B1=E7=94=A8=E6=A8=A1=E5=9E=8B=E6=B1=A0=E7=9A=84=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=E4=B8=8E=E9=85=8D=E7=BD=AE=E6=A0=B7=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CONTEXT.md | 4 +++ skills/agentnave-manager/SKILL.md | 2 ++ .../members/invalid-cli-pool-duplicate.json | 33 +++++++++++++++++++ .../members/invalid-cli-pool-empty.json | 30 +++++++++++++++++ .../members/invalid-cli-pool-unknown.json | 32 ++++++++++++++++++ .../valid-cli-pool-stale-scene-default.json | 32 ++++++++++++++++++ .../members/valid-cli-pool-unrestricted.json | 30 +++++++++++++++++ 7 files changed, 163 insertions(+) create mode 100644 tests/fixtures/app-files/members/invalid-cli-pool-duplicate.json create mode 100644 tests/fixtures/app-files/members/invalid-cli-pool-empty.json create mode 100644 tests/fixtures/app-files/members/invalid-cli-pool-unknown.json create mode 100644 tests/fixtures/app-files/members/valid-cli-pool-stale-scene-default.json create mode 100644 tests/fixtures/app-files/members/valid-cli-pool-unrestricted.json diff --git a/CONTEXT.md b/CONTEXT.md index c4856daa..440d1a13 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -158,3 +158,7 @@ _Avoid_: 控制台、桌面客户端 ## 语音播报 按模型家族音色朗读右侧消息摘要的可选功能,覆盖已有的 boss 发言与员工回答;可手动选择消息,点读后的连播仅限所选那场会议,包含其全部轮次,不串播其他会议。排队时一次只播一个 boss 的消息,播完再换下一个;手选的消息插到最前。每场会议或每段私聊是一张专辑,封面是其中所有参与者,与从哪条消息开始播放无关。播放不随切换视图、关闭对话栏或隐藏窗口中断。**收听**是用户主动开启、限时跟读一个工作区、一场会议或一段私聊的播放状态:一场会议或一段私聊从头听全,本次运行内再开从上次念完处继续;工作区只读开启之后的新消息;同一时间只收听一个,隐藏办公室或切换视图不影响收听,重启即结束。到期后当前正在念的消息念完,后续收听内容撤下,手动点读与连播不受影响。独立语音频道用头像区分主持人和发言人,不改变办公室的小人动画与任务状态。模型 prompt 定义消息缩写规则,小人人设定义性格、措辞与口吻;语音以发言者本人第一人称表达。boss 发言的头像和音色跟实际说话的 boss 走,接手工作区不改变已说过的话,新 boss 在会议里发言后才换。 + +## CLI 可用模型池 + +用户在员工页为每个 CLI 维护的共用模型边界,普通任务、编排工位与会议都从其中选择模型。工位绑定和场景默认值不再各自拥有允许名单;宿主启用编排时,普通任务候选与场景默认值退出 boss 的编排输入,关闭后恢复。 diff --git a/skills/agentnave-manager/SKILL.md b/skills/agentnave-manager/SKILL.md index 55e68d6c..d189a401 100644 --- a/skills/agentnave-manager/SKILL.md +++ b/skills/agentnave-manager/SKILL.md @@ -98,6 +98,8 @@ description: 用户要求用 Claude Code、CodeBuddy Code、Codex CLI、Grok CLI 用户指定字段覆盖默认,但不得超出允许范围;要求原生设置时须同时清除对应 App 默认字段。权限类参数(如 `dangerously_*`、`permission_mode`、`sandbox`、`approve_for_me`)只能来自用户在 App 中设定的默认 options、服务按 CLI 套用的权限档位(缺省「自动」,用户可在 App 改为「完全放开」;见 `list_members` 的 `permissions`),或用户本轮明确要求,boss 不得自行追加;显式传任一权限参数会让该 CLI 的档位整档不套用。App 默认值续接时也会套用。终态回执的 `warnings` 报告 CLI 报成功但有工具调用被拒,按未完成处理并告知用户可在 App 调整档位。模型不可用时报告原错误,不静默替换。 +当前宿主开启高级编排时,服务不再提供普通任务候选池或借用其场景默认值,优先按可见工位派发;会议仍独立配置。员工页 CLI 级可用模型名单共同约束普通任务、编排工位与会议,工位和会议只能选择其中的模型;绑定失效时先报告并修复配置,不自动扩大允许名单或换模型。关闭编排后恢复保留的普通任务配置。 + ## 目录与交接 新任务必须传 `cwd`,使用目标项目存在的绝对目录,通常选当前 checkout/worktree 根目录;服务不提供默认目录,续接时可从历史继承。CLI 不继承主对话;首次派发、切换 CLI 或交接未完成任务时,prompt 要写够本次所需内容。按下面的模板填,用不上的行删掉,不要留占位或写「无」: diff --git a/tests/fixtures/app-files/members/invalid-cli-pool-duplicate.json b/tests/fixtures/app-files/members/invalid-cli-pool-duplicate.json new file mode 100644 index 00000000..11b0f1d9 --- /dev/null +++ b/tests/fixtures/app-files/members/invalid-cli-pool-duplicate.json @@ -0,0 +1,33 @@ +{ + "version": 1, + "clis": { + "claude": { + "models": { + "sonnet": {}, + "haiku": {} + }, + "allowed_models": [ + "sonnet", + "sonnet" + ] + } + }, + "modes": { + "task": { + "claude": { + "models": [ + "haiku" + ], + "default": "haiku" + } + }, + "discussion": { + "claude": { + "models": [ + "haiku" + ], + "default": "haiku" + } + } + } +} diff --git a/tests/fixtures/app-files/members/invalid-cli-pool-empty.json b/tests/fixtures/app-files/members/invalid-cli-pool-empty.json new file mode 100644 index 00000000..0da80a44 --- /dev/null +++ b/tests/fixtures/app-files/members/invalid-cli-pool-empty.json @@ -0,0 +1,30 @@ +{ + "version": 1, + "clis": { + "claude": { + "models": { + "sonnet": {}, + "haiku": {} + }, + "allowed_models": [] + } + }, + "modes": { + "task": { + "claude": { + "models": [ + "haiku" + ], + "default": "haiku" + } + }, + "discussion": { + "claude": { + "models": [ + "haiku" + ], + "default": "haiku" + } + } + } +} diff --git a/tests/fixtures/app-files/members/invalid-cli-pool-unknown.json b/tests/fixtures/app-files/members/invalid-cli-pool-unknown.json new file mode 100644 index 00000000..787159f6 --- /dev/null +++ b/tests/fixtures/app-files/members/invalid-cli-pool-unknown.json @@ -0,0 +1,32 @@ +{ + "version": 1, + "clis": { + "claude": { + "models": { + "sonnet": {}, + "haiku": {} + }, + "allowed_models": [ + "not-configured" + ] + } + }, + "modes": { + "task": { + "claude": { + "models": [ + "haiku" + ], + "default": "haiku" + } + }, + "discussion": { + "claude": { + "models": [ + "haiku" + ], + "default": "haiku" + } + } + } +} diff --git a/tests/fixtures/app-files/members/valid-cli-pool-stale-scene-default.json b/tests/fixtures/app-files/members/valid-cli-pool-stale-scene-default.json new file mode 100644 index 00000000..e2435cdd --- /dev/null +++ b/tests/fixtures/app-files/members/valid-cli-pool-stale-scene-default.json @@ -0,0 +1,32 @@ +{ + "version": 1, + "clis": { + "claude": { + "models": { + "sonnet": {}, + "haiku": {} + }, + "allowed_models": [ + "sonnet" + ] + } + }, + "modes": { + "task": { + "claude": { + "models": [ + "haiku" + ], + "default": "haiku" + } + }, + "discussion": { + "claude": { + "models": [ + "haiku" + ], + "default": "haiku" + } + } + } +} diff --git a/tests/fixtures/app-files/members/valid-cli-pool-unrestricted.json b/tests/fixtures/app-files/members/valid-cli-pool-unrestricted.json new file mode 100644 index 00000000..51b370c3 --- /dev/null +++ b/tests/fixtures/app-files/members/valid-cli-pool-unrestricted.json @@ -0,0 +1,30 @@ +{ + "version": 1, + "clis": { + "claude": { + "models": { + "sonnet": {}, + "haiku": {} + }, + "allowed_models": null + } + }, + "modes": { + "task": { + "claude": { + "models": [ + "haiku" + ], + "default": "haiku" + } + }, + "discussion": { + "claude": { + "models": [ + "haiku" + ], + "default": "haiku" + } + } + } +} From 9e927a31ad7dae814f7a773900e8a3c85bd04974 Mon Sep 17 00:00:00 2001 From: Tim Wong <280945586+TimWongUp@users.noreply.github.com> Date: Sat, 7 Nov 2026 12:22:34 +0800 Subject: [PATCH 10/17] =?UTF-8?q?docs:=20=E5=AF=B9=E9=BD=90=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E6=B1=A0=E5=86=B3=E5=AE=9A=E7=9A=84=E7=B4=A2=E5=BC=95?= =?UTF-8?q?=E4=B8=8E=E4=BF=AE=E8=AE=A2=E5=85=B3=E7=B3=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/adr/README.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/adr/README.md b/docs/adr/README.md index c766970a..a2957354 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -41,7 +41,7 @@ | 0035 | [员工卡片一行设好一个 CLI,需要时再分开两个场景](0035-member-cards-one-row-per-cli.md) | 部分取代 | [ADR-0042](0042-pass-cli-default-effort.md)、[ADR-0050](0050-v1-compatibility-baseline.md) | | 0036 | [本服务的 Skill 只由 App 管理](0036-app-owns-service-skills.md) | 已接受 | — | | 0037 | [App 写入编排规则,Explorer 与 Worker 分简单、困难两档](0037-app-writes-orchestration-rules.md) | 部分取代 | [ADR-0038](0038-role-applies-app-seat.md)、[ADR-0041](0041-orchestration-mode-per-boss.md)、[ADR-0044](0044-meeting-voice-cloud-and-key-input.md)、[ADR-0051](0051-parallel-preset-without-recheck.md)、[ADR-0052](0052-orchestration-specialty-seats.md) | -| 0038 | [私聊的 role 指明工位时,服务套用 App 里的工位设置](0038-role-applies-app-seat.md) | 部分取代 | [ADR-0043](0043-service-prompt-instructions.md)、[ADR-0050](0050-v1-compatibility-baseline.md)、[ADR-0052](0052-orchestration-specialty-seats.md)、[ADR-0053](0053-disabled-cli-runs-for-no-seat.md) | +| 0038 | [私聊的 role 指明工位时,服务套用 App 里的工位设置](0038-role-applies-app-seat.md) | 部分取代 | [ADR-0043](0043-service-prompt-instructions.md)、[ADR-0050](0050-v1-compatibility-baseline.md)、[ADR-0052](0052-orchestration-specialty-seats.md)、[ADR-0053](0053-disabled-cli-runs-for-no-seat.md)、[ADR-0063](0063-cli-model-pool-and-orchestration-precedence.md) | | 0039 | [单席失败提醒一次,并联轮内可补跑](0039-retry-lost-seat-within-round.md) | 已接受 | — | | 0040 | [工作区按会话记录显示 boss 的入口,接入按配置文件划分](0040-boss-app-from-session-records.md) | 已接受 | — | | 0041 | [编排档位按 boss 选择,读同一文件的 boss 共用一档](0041-orchestration-mode-per-boss.md) | 已接受 | — | @@ -56,7 +56,7 @@ | 0050 | [1.0 兼容基线](0050-v1-compatibility-baseline.md) | 已接受 | — | | 0051 | [并行档不再逐次复核,评审时机交给用户](0051-parallel-preset-without-recheck.md) | 已接受 | — | | 0052 | [Worker 增加界面两档](0052-orchestration-specialty-seats.md) | 已接受 | — | -| 0053 | [员工里停用的 CLI 不能当工位](0053-disabled-cli-runs-for-no-seat.md) | 部分取代 | [ADR-0054](0054-members-need-confirmation.md) | +| 0053 | [员工里停用的 CLI 不能当工位](0053-disabled-cli-runs-for-no-seat.md) | 部分取代 | [ADR-0054](0054-members-need-confirmation.md)、[ADR-0063](0063-cli-model-pool-and-orchestration-precedence.md) | | 0054 | [CLI 要在员工里确认后才能用,接入 boss 时自动确认](0054-members-need-confirmation.md) | 已接受 | — | | 0055 | [App 在员工页更新已装的 CLI,安装只给命令](0055-app-updates-member-clis.md) | 已接受 | — | | 0056 | [Claude Code 改用交互模式运行](0056-claude-interactive-runtime.md) | 已接受 | — | @@ -66,5 +66,4 @@ | 0060 | [托盘识别无人持有的运行记录](0060-tray-detects-orphaned-running-work.md) | 已接受 | — | | 0061 | [宿主配置指向固定启动器,升级不再改写宿主配置](0061-fixed-service-launcher.md) | 已接受 | — | | 0062 | [服务统一生成成员称呼](0062-service-owned-member-names.md) | 已接受 | — | - | 0063 | [CLI 共用模型池,编排替代普通任务目录](0063-cli-model-pool-and-orchestration-precedence.md) | 已接受 | 修订 ADR-0038、ADR-0053 | From 2b5e6e419e2a023d5257cb993ffc44ed0e7e62aa Mon Sep 17 00:00:00 2001 From: Tim Wong <280945586+TimWongUp@users.noreply.github.com> Date: Sat, 7 Nov 2026 12:26:54 +0800 Subject: [PATCH 11/17] =?UTF-8?q?feat(tray):=20=E6=8A=8A=E5=8F=AF=E7=94=A8?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E6=94=B6=E5=88=B0=20CLI=20=E5=B1=82=E5=85=B1?= =?UTF-8?q?=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 普通单独干活、会议与工位都从 clis[provider].allowed_models 选模型; 旧配置的场景名单继续读取,编辑后由 CLI 名单接管。池外绑定保留并拒绝保存, 员工页按已保存的编排显示各 boss 的状态。 Co-Authored-By: Claude Opus 5.5 --- apps/tray/src/members.rs | 38 ++++++- apps/tray/src/members/tests.rs | 23 +++++ apps/tray/ui/advanced.js | 52 ++++++++-- apps/tray/ui/member-cards.js | 87 ++++++++++------ apps/tray/ui/member-models.js | 48 ++++++++- apps/tray/ui/settings-state.js | 11 +- apps/tray/ui/settings.css | 6 +- apps/tray/ui/settings.html | 2 + apps/tray/ui/tests/member-updates.test.cjs | 2 +- apps/tray/ui/tests/members.test.cjs | 114 ++++++++++++++++----- 10 files changed, 304 insertions(+), 79 deletions(-) diff --git a/apps/tray/src/members.rs b/apps/tray/src/members.rs index 75a7002c..cd794e15 100644 --- a/apps/tray/src/members.rs +++ b/apps/tray/src/members.rs @@ -164,7 +164,13 @@ pub fn validate(document: &Value) -> Result<(), String> { let default = default .as_str() .ok_or_else(|| format!("{what}.default 必须为字符串"))?; - if !known.contains_key(default) || !(listed.is_empty() || listed.contains(&default)) + // A CLI's own list replaces the scene's. A default outside it stays readable: the + // App reports it and the server refuses to run it. + let pooled = clis + .get(provider) + .is_some_and(|cli| cli.get("allowed_models").is_some()); + if !known.contains_key(default) + || !(pooled || listed.is_empty() || listed.contains(&default)) { return Err(format!("{what} 的默认模型 {default} 不在允许的模型中")); } @@ -617,7 +623,11 @@ fn validate_clis(clis: &Map) -> Result<(), String> { return Err(format!("不支持的 CLI:{provider}")); } let fields = object(cli, &format!("clis.{provider}"))?; - only(fields, &["models"], &format!("clis.{provider}"))?; + only( + fields, + &["models", "allowed_models"], + &format!("clis.{provider}"), + )?; let models = fields .get("models") .ok_or_else(|| format!("clis.{provider} 缺少 models"))?; @@ -629,6 +639,30 @@ fn validate_clis(clis: &Map) -> Result<(), String> { settings(shared, &what)?; effort_allowed(shared, None, &what)?; } + if let Some(pool) = fields.get("allowed_models").filter(|pool| !pool.is_null()) { + validate_pool(pool, object(models, "models")?, provider)?; + } + } + Ok(()) +} + +/// The models a CLI may run, for every scene and seat; absent or null means any. +fn validate_pool(pool: &Value, models: &Map, provider: &str) -> Result<(), String> { + let what = format!("clis.{provider}.allowed_models"); + let pool = pool + .as_array() + .filter(|pool| !pool.is_empty()) + .ok_or_else(|| format!("{what} 必须为非空数组"))?; + for (index, model) in pool.iter().enumerate() { + let name = model + .as_str() + .ok_or_else(|| format!("{what} 只能含字符串"))?; + if !models.contains_key(name) { + return Err(format!("{what} 引用了未配置的模型 {name}")); + } + if pool[..index].contains(model) { + return Err(format!("{what} 重复列出模型 {name}")); + } } Ok(()) } diff --git a/apps/tray/src/members/tests.rs b/apps/tray/src/members/tests.rs index 1ce52880..9c128c7f 100644 --- a/apps/tray/src/members/tests.rs +++ b/apps/tray/src/members/tests.rs @@ -228,6 +228,29 @@ fn accepts_the_contract_example_and_rejects_structural_or_reference_errors() { } } +#[test] +fn a_cli_pool_is_checked_and_replaces_the_scene_list_for_defaults() { + let mut pooled = document(); + pooled["clis"]["codex"]["allowed_models"] = json!(["gpt-6.1-sol"]); + validate(&pooled).unwrap(); + pooled["clis"]["codex"]["allowed_models"] = Value::Null; + validate(&pooled).unwrap(); + for pool in [ + json!([]), + json!("gpt-6.1-sol"), + json!([1]), + json!(["missing"]), + json!(["gpt-6.1-sol", "gpt-6.1-sol"]), + ] { + pooled["clis"]["codex"]["allowed_models"] = pool.clone(); + assert!(validate(&pooled).is_err(), "{pool}"); + } + // A default outside the pool or an older scene list stays readable for the App to report. + pooled["clis"]["codex"]["allowed_models"] = json!(["gpt-6.1-sol"]); + pooled["modes"]["task"]["codex"]["default"] = json!("gpt-6-luna"); + validate(&pooled).unwrap(); +} + #[test] fn saves_atomically_through_the_post_route_only() { let directory = tempfile::tempdir().unwrap(); diff --git a/apps/tray/ui/advanced.js b/apps/tray/ui/advanced.js index a9c9b4cc..0c3ec990 100644 --- a/apps/tray/ui/advanced.js +++ b/apps/tray/ui/advanced.js @@ -69,6 +69,8 @@ async function loadOrchestration() { // Review seats list the connected bosses' CLIs too. renderReviewSeats(); renderAdvancedBar(); + // Cards name the seats their pool left out. + renderCards(); } async function saveOrchestration() { @@ -180,7 +182,17 @@ function setOrchestrationSeat(role, difficulty, seat) { advancedEdited(); } +// Bosses on a saved mode dispatch through the seats, each boss by its own row. +function renderTaskScope() { + const on = orchestration && !orchestration.error + ? orchestration.bosses.filter((boss) => bossMode(orchestration.document, boss) !== "off") : []; + const names = on.flatMap((boss) => boss.labels).join(AN.L("、", ", ")); + $("task-scope").hidden = !on.length; + $("task-scope").textContent = on.length ? AN.L(`${names} 已启用编排,用“高级”里的工位派活;单独干活只提供给未启用编排的 boss。`, `${names} use orchestration and dispatch through the seats in Advanced; solo tasks are offered only to bosses without orchestration.`) : ""; +} + function renderOrchestration() { + renderTaskScope(); const shown = orchestrationShown(); const preset = orchestrationView; for (const button of $("orchestration-presets").querySelectorAll("button")) { @@ -266,8 +278,22 @@ let seatsDraft = null; // Review seats as picked; `reviewSeats` holds what is sa const seatsShown = () => seatsDraft || reviewSeats; const seatsChanged = () => !sameJson(seatsShown(), reviewSeats); +// Every seat in use with the model its CLI's pool left out. Explorer and Worker seats wait unused +// while no boss runs orchestration. +function seatConflicts() { + const review = REVIEW_ROLES.map(([role, name]) => ({ section: "review", name: AN.L(`${name}评审`, `${name} review`), seat: seatsShown()[role] })); + const doc = orchestrationShown(); + const live = Boolean(doc) && (doc.preset !== "off" || Object.values(doc.apps || {}).some((mode) => mode !== "off")); + const roles = !doc ? [] : ORCHESTRATION_ROLES.filter(([role]) => live || role === "image_generation") + .map(([role, difficulty, name]) => ({ section: "orchestration", name, seat: orchestrationSeat(doc, role, difficulty) })); + return [...review, ...roles].filter(({ seat }) => seat && outOfPool(seat.provider, seat.model)); +} +const seatConflictText = ({ name, seat }) => AN.L(`${name} 用的 ${seat.model} 不在 ${PROVIDER_NAMES[seat.provider]} 的可用模型里`, `${name} uses ${seat.model}, which is not in the available models of ${PROVIDER_NAMES[seat.provider]}`); +const memberSaveErrors = () => [...configurationErrors(), ...seatConflicts().map((conflict) => ({ message: seatConflictText(conflict) }))]; + // A picked model missing from that CLI's configured models is added to the last saved -// members.json, where the server looks for it; unsaved 员工 edits stay unsaved. +// members.json, where the server looks for it; unsaved 员工 edits stay unsaved. A CLI with a pool +// already configures all it may run, so a model it left out is refused instead. async function listSeatModel(seat) { const add = (doc) => { doc.clis ||= {}; @@ -279,6 +305,8 @@ async function listSeatModel(seat) { // Until 员工 is read there is nothing to add to: an empty document would replace the file. if (!savedSnapshot && !documentError) throw new Error(AN.L("员工配置还在读取,请稍后再保存", "Employee settings are still loading; please save in a moment")); const saved = JSON.parse(savedSnapshot || JSON.stringify({ version: 1, clis: {}, modes: {} })); + const pool = saved.clis?.[seat.provider]?.allowed_models; + if (pool && !pool.includes(seat.model)) throw new Error(AN.L(`${seat.model} 不在 ${PROVIDER_NAMES[seat.provider]} 的可用模型里`, `${seat.model} is not in the available models of ${PROVIDER_NAMES[seat.provider]}`)); if (Object.hasOwn(saved.clis?.[seat.provider]?.models || {}, seat.model)) return; if (documentError) throw new Error(AN.L("配置文件有错误,请先在“员工”里修复", "Configuration file has errors; fix them in “Employees” first")); await apiPostMembers(add(saved)); @@ -319,6 +347,8 @@ function reviewSelect(key, label, value, options, onPick, disabled = false) { }, options.map(([v, text, off]) => el("option", { value: v, selected: v === value, disabled: Boolean(off) }, text))); } +const seatModels = (provider) => listedPool(provider) ? [...listedPool(provider)] : getAllModelIds(provider); + // A seat's CLI, then one of that CLI's models, then an effort or the member's default. function seatSelects(key, name, seat, onSet, unset = AN.L("自动", "Auto"), modelLabel = AN.L(`${name}的模型`, `Model for ${name}`)) { const provider = seat?.provider || ""; @@ -327,11 +357,12 @@ function seatSelects(key, name, seat, onSet, unset = AN.L("自动", "Auto"), mod const bossClis = (orchestration?.bosses || []).flatMap((boss) => (boss.ids || []).map((id) => id === "workbuddy" ? "codebuddy" : id)); const usable = (p) => (known(p) && slot("task", p).enabled) || bossClis.includes(p); const note = (p) => !usable(p) ? AN.L("(员工里已停用)", " (disabled in Employees)") - : getAllModelIds(p).length ? "" : AN.L("(没有可选模型)", " (no models available)"); + : seatModels(p).length ? "" : AN.L("(没有可选模型)", " (no models available)"); const clis = PROVIDERS.filter((p) => usable(p) || p === provider) .map((p) => [p, PROVIDER_NAMES[p] + note(p), Boolean(note(p)) && p !== provider]); - const models = provider ? getAllModelIds(provider) : []; - if (seat?.model && !models.includes(seat.model)) models.unshift(seat.model); + const models = provider ? seatModels(provider) : []; + const excluded = seat?.model && outOfPool(provider, seat.model); + if (seat?.model && !excluded && !models.includes(seat.model)) models.unshift(seat.model); const levels = seat?.model ? effortLevels(provider, seat.model) : []; if (seat?.effort && !levels.includes(seat.effort)) levels.push(seat.effort); // A seat saves the level it runs at, as a member does. @@ -341,7 +372,7 @@ function seatSelects(key, name, seat, onSet, unset = AN.L("自动", "Auto"), mod }; const pickCli = (next) => { if (!next) return onSet(null); - const ids = getAllModelIds(next); + const ids = seatModels(next); const preferred = slot("task", next).model || nativeDefault(next).model; onSet(seatModel(next, ids.includes(preferred) ? preferred : ids[0])); }; @@ -352,7 +383,7 @@ function seatSelects(key, name, seat, onSet, unset = AN.L("自动", "Auto"), mod return [ reviewSelect(`${key}/cli`, AN.L(`${name}的 CLI`, `CLI for ${name}`), provider, [["", unset], ...clis], pickCli), reviewSelect(`${key}/model`, modelLabel, seat?.model || "", - seat ? models.map((m) => [m, m]) : [["", "—"]], pickModel, !seat), + seat ? [...(excluded ? [[seat.model, AN.L(`${seat.model}(不在可用模型里)`, `${seat.model} (not available)`), true]] : []), ...models.map((m) => [m, m])] : [["", "—"]], pickModel, !seat), reviewSelect(`${key}/effort`, AN.L(`${name}的思考档位`, `Effort for ${name}`), seat?.effort || "", efforts, pickEffort, !seat || !levels.length), ]; } @@ -383,17 +414,20 @@ function advancedChanges() { } const customEmpty = () => usesCustom(orchestrationShown()) && !$("custom-text").value.trim(); +// A changed section cannot save while one of its seats is outside its CLI's pool. +const blockedSeat = () => seatConflicts().find(({ section }) => (section === "review" ? seatsChanged() : orchestrationChanged())); function renderAdvancedBar() { const changes = advancedChanges(); - const invalid = orchestrationChanged() && customEmpty(); + const blocked = blockedSeat(); + const invalid = (orchestrationChanged() && customEmpty()) || Boolean(blocked); $("advanced-dirty").textContent = changes.length ? AN.L(`未保存:${changes.join("、")}`, `Unsaved: ${changes.join(", ")}`) : ""; $("advanced-badge").hidden = !changes.length; $("advanced-save").disabled = busy || !changes.length || invalid; $("advanced-discard").disabled = busy || !changes.length; const feedback = $("advanced-feedback"); if (invalid) { - feedback.textContent = AN.L("自定义规则不能为空;不想写入时选「关闭」", "Custom rules cannot be empty; choose “Off” if you don’t want to write rules"); + feedback.textContent = blocked ? seatConflictText(blocked) : AN.L("自定义规则不能为空;不想写入时选「关闭」", "Custom rules cannot be empty; choose “Off” if you don’t want to write rules"); feedback.className = "save-feedback error"; feedback.dataset.validation = "true"; } else if (feedback.dataset.validation === "true") { @@ -425,7 +459,7 @@ function discardAdvanced() { } async function saveAdvanced() { - if (busy || !advancedChanges().length || (orchestrationChanged() && customEmpty())) return; + if (busy || !advancedChanges().length || (orchestrationChanged() && customEmpty()) || blockedSeat()) return; busy = true; $("pane-advanced").inert = true; updateControls(); diff --git a/apps/tray/ui/member-cards.js b/apps/tray/ui/member-cards.js index aaab2481..1b0b5bad 100644 --- a/apps/tray/ui/member-cards.js +++ b/apps/tray/ui/member-cards.js @@ -5,6 +5,7 @@ function slotRows(provider, scene, split) { const native = nativeDefault(provider); const all = getAllModelIds(provider); if (native.model && !all.includes(native.model)) all.unshift(native.model); + const offered = current.allowed ? all.filter((m) => current.allowed.includes(m)) : all; const label = el("span", { className: "member-scene" }, split ? SCENES[scene] : L(`${SCENES.task}、${SCENES.discussion}`, `${SCENES.task}, ${SCENES.discussion}`)); const pick = (change) => changeSlot(provider, scene, change); // Turning a CLI off for both scenes asks first when it drops settings. @@ -18,16 +19,16 @@ function slotRows(provider, scene, split) { "aria-label": L("默认模型", "Default model"), onChange: (e) => pick({ enabled: true, model: e.target.value, effort: savedEffort(provider, e.target.value || native.model, "") }), }, el("option", { value: OFF, selected: true }, L("不用", "Off")), - ...(native.model ? [] : [el("option", { value: "" }, L("CLI 自己决定", "Follow CLI"))]), - ...all.map((m) => el("option", { value: m }, m === native.model ? L(`${m}(CLI 当前设置)`, `${m} (CLI current)`) : m))), el("span", { className: "member-reset-slot" })))]; + ...(native.model || current.allowed ? [] : [el("option", { value: "" }, L("CLI 自己决定", "Follow CLI"))]), + ...offered.map((m) => el("option", { value: m }, m === native.model ? L(`${m}(CLI 当前设置)`, `${m} (CLI current)`) : m))), el("span", { className: "member-reset-slot" })))]; } - const offered = current.allowed ? all.filter((m) => current.allowed.includes(m)) : all; const model = current.model || (current.allowed ? "" : native.model) || ""; const options = [ el("option", { value: OFF }, L("不用", "Off")), ...(current.allowed && !model ? [el("option", { value: "", selected: true, disabled: true }, L("选一个默认模型", "Choose default model"))] : native.model ? [] : [el("option", { value: "", selected: !model }, L("CLI 自己决定", "Follow CLI"))]), + ...(model && !offered.includes(model) ? [el("option", { value: model, selected: true, disabled: true }, L(`${model}(不在可用模型里)`, `${model} (not available)`))] : []), ...offered.map((m) => el("option", { value: m, selected: m === model }, m === native.model ? L(`${m}(CLI 当前设置)`, `${m} (CLI current)`) : m)), ]; const modelLine = el("div", { className: "member-line" }, @@ -42,7 +43,7 @@ function slotRows(provider, scene, split) { pick({ model: value, effort: savedEffort(provider, id, sceneEffort(scene || "task", provider, id)) }); }, }, ...options), - current.model && native.model && current.model !== native.model + current.model && offered.includes(native.model) && current.model !== native.model ? el("button", { type: "button", className: "member-reset", @@ -53,58 +54,80 @@ function slotRows(provider, scene, split) { }, "↺") : el("span", { className: "member-reset-slot" })); - const effortLine = slotEffortLine({ provider, scene, current, model, key, pick }); + return [el("div", { className: "member-row" }, label, modelLine, slotEffortLine({ provider, scene, current, model, key, pick }))]; +} - return slotAllowedRows({ provider, current, native, all, offered, model, key, pick, label, modelLine, effortLine }); +// What the pool row reads: scenes of an older file can still hold lists of their own. +function poolSummary(provider, all, pool) { + const count = (list) => (list ? L(`${list.length} 个`, `${list.length}`) : L(`全部 ${all.length} 个`, `All ${all.length}`)); + const scenes = SCENE_IDS.filter((s) => slot(s, provider).enabled); + const lists = scenes.map((s) => slot(s, provider).allowed); + if (!hasPool(provider) && lists.length > 1 && !sameJson(lists[0], lists[1])) { + return scenes.map((s, i) => `${SCENES[s]} ${count(lists[i])}`).join(L("、", ", ")); + } + if (!pool) return count(null); + const names = pool.slice(0, 3).join(L("、", ", ")); + return L(`${pool.length} 个:${names}${pool.length > 3 ? " 等" : ""}`, `${pool.length}: ${names}${pool.length > 3 ? ", etc." : ""}`); } -function slotAllowedRows({ provider, current, native, all, offered, model, key, pick, label, modelLine, effortLine }) { +// One list per CLI; every scene and seat of the CLI picks its model from it. +function poolRows(provider) { + const key = `${provider}/pool`; + const native = nativeDefault(provider); + const all = getAllModelIds(provider); + if (native.model && !all.includes(native.model)) all.unshift(native.model); + const pool = shownPool(provider); const open = pickerOpen === key; - const summary = current.allowed - ? L(`${offered.length} 个:${offered.slice(0, 3).join("、")}${offered.length > 3 ? " 等" : ""}`, `${offered.length}: ${offered.slice(0, 3).join(", ")}${offered.length > 3 ? ", etc." : ""}`) - : L(`全部 ${all.length} 个`, `All ${all.length}`); - const listButton = el("button", { - type: "button", - className: "member-list", - dataset: { focusKey: `${key}/list` }, - title: L("boss 可以选用的模型", "Models the boss may choose from"), - "aria-expanded": String(open), - onClick: () => { pickerOpen = open ? null : key; renderCards(); }, - }, el("span", { className: "member-muted" }, L("可选 ", "Allowed: ")), textNode(summary), el("span", { className: "member-muted" }, open ? " ▴" : " ▾")); - - const rows = [el("div", { className: "member-row" }, label, modelLine, listButton, effortLine)]; + const rows = [el("div", { className: "member-row" }, + el("span", { className: "member-scene" }, L("可用模型", "Available models")), + el("button", { + type: "button", + className: "member-list", + dataset: { focusKey: `${key}/list` }, + title: L("单独干活、开会和工位都从这里选", "Solo tasks, meetings and seats pick from these"), + "aria-expanded": String(open), + onClick: () => { pickerOpen = open ? null : key; renderCards(); }, + }, textNode(poolSummary(provider, all, pool)), el("span", { className: "member-muted" }, open ? " ▴" : " ▾")))]; if (open) { const toggle = (m, on) => { - const list = current.allowed || []; + const list = pool || []; const allowed = on ? [...list, m] : list.filter((x) => x !== m); - if (allowed.length) pick({ allowed }); + if (allowed.length) writePool(provider, allowed); else renderCards(); }; + const defaults = SCENE_IDS.map((s) => slot(s, provider).model); rows.push(el("div", { className: "member-picker" }, el("label", { className: "member-option member-option-all" }, el("input", { type: "checkbox", dataset: { focusKey: `${key}/all` }, - checked: !current.allowed, + checked: !pool, disabled: !all.length, - onChange: (e) => pick(e.target.checked - ? { allowed: null, model: current.model === native.model ? "" : current.model } - : { allowed: all.length ? [...all] : model ? [model] : [] }), + onChange: (e) => writePool(provider, e.target.checked ? null : [...all]), }), textNode(L("全部模型(以后新出的也算)", "All models (including future releases)"))), ...all.map((m) => el("label", { className: "member-option" }, el("input", { type: "checkbox", dataset: { focusKey: `${key}/allow/${m}` }, - checked: !current.allowed || current.allowed.includes(m), - disabled: !current.allowed || m === model, + checked: !pool || pool.includes(m), + disabled: !pool, onChange: (e) => toggle(m, e.target.checked), }), el("span", { className: "member-option-id" }, m), - m === model ? el("span", { className: "badge badge-muted" }, L("默认", "Default")) : null)))); + defaults.includes(m) ? el("span", { className: "badge badge-muted" }, L("默认", "Default")) : null)))); } return rows; } +// Bindings the pool no longer has stay as saved, each named on its CLI's card until changed. +function poolConflicts(provider) { + const scenes = SCENE_IDS.map((s) => [SCENES[s], slot(s, provider)]) + .filter(([, current]) => current.enabled && current.model && outOfPool(provider, current.model)) + .map(([name, current]) => L(`${name}的默认模型 ${current.model} 不在可用模型里`, `${name} default model ${current.model} is not in the available models`)); + const seats = seatConflicts().filter(({ seat }) => seat.provider === provider).map(seatConflictText); + return [...scenes, ...seats].map((text) => el("p", { className: "member-error" }, text)); +} + function renderMembership(provider, enabled) { return el("button", { type: "button", @@ -176,13 +199,13 @@ function renderCards() { const card = el("div", { className: `cli-card member-card${enabled ? "" : " off"}`, dataset: { provider } }, head); if (pendingDisable === provider) { card.appendChild(el("div", { className: "disable-confirm", role: "group", "aria-label": L(`确认移除 ${name}`, `Confirm removing ${name}`) }, - el("p", {}, L(`移除会清除 ${name} 的默认模型和可选模型。重新启用不会恢复;点击“保存设置”后才会生效。`, `Removing clears default and allowed models for ${name}. Re-enabling will not restore them; takes effect after clicking “Save settings”.`)), + el("p", {}, L(`移除会清除 ${name} 的默认模型。重新启用不会恢复;点击“保存设置”后才会生效。`, `Removing clears default models for ${name}. Re-enabling will not restore them; takes effect after clicking “Save settings”.`)), el("div", { className: "btn-group" }, el("button", { type: "button", dataset: { focusKey: `${provider}/cancel-disable` }, onClick: () => { pendingDisable = null; renderCards(); } }, L("保留", "Keep")), el("button", { type: "button", className: "danger", dataset: { focusKey: `${provider}/confirm-disable` }, onClick: () => setProviderEnabled(provider, false, true) }, L("确认移除", "Remove"))))); } const rows = split ? SCENE_IDS.flatMap((s) => slotRows(provider, s, true)) : slotRows(provider, null, false); - for (const row of rows) card.appendChild(row); + for (const row of [...poolRows(provider), ...rows, ...poolConflicts(provider)]) card.appendChild(row); const runtime = renderClaudeInteractive(provider); if (runtime) card.appendChild(runtime); if (info.error) card.appendChild(el("p", { className: "member-error" }, L(`读取模型列表失败:${info.error}`, `Failed to load model list: ${info.error}`))); @@ -213,7 +236,7 @@ function renderCards() { // Save members.json with race version checking async function saveMembers() { if (busy || !dirty || documentError || emptyScene()) return; - const errors = configurationErrors(); + const errors = memberSaveErrors(); if (errors.length) { showNotice(errors[0].message, true); updateControls(); diff --git a/apps/tray/ui/member-models.js b/apps/tray/ui/member-models.js index 5f6e5b99..c99ad506 100644 --- a/apps/tray/ui/member-models.js +++ b/apps/tray/ui/member-models.js @@ -1,6 +1,12 @@ "use strict"; const isRestricted = (scene) => Object.hasOwn(currentDoc.modes || {}, scene); +// The CLI's one model list, shared by its scenes and seats. Once present it replaces the scene +// lists of older files; null means any model, now or later. +const hasPool = (provider) => Object.hasOwn(currentDoc.clis?.[provider] || {}, "allowed_models"); +const listedPool = (provider) => currentDoc.clis?.[provider]?.allowed_models || null; +const outOfPool = (provider, model) => Boolean(listedPool(provider)) && !listedPool(provider).includes(model); + // A model the App starts configuring keeps the CLI's own default effort until one is chosen (ADR-0028). function ensureCliModel(provider, modelId) { if (!currentDoc.clis) currentDoc.clis = {}; @@ -86,11 +92,19 @@ function slot(scene, provider) { const enabled = isRestricted(scene) ? Boolean(selection) : Boolean(detected[provider]?.installed || currentDoc.defaults?.[scene]?.[provider]); const model = (selection ? selection.default : currentDoc.defaults?.[scene]?.[provider]) || ""; - const allowed = selection?.models?.length ? [...selection.models] : null; + const pool = hasPool(provider) ? listedPool(provider) : selection?.models; + const allowed = pool?.length ? [...pool] : null; const effort = enabled ? sceneEffort(scene, provider, model || nativeDefault(provider).model) : ""; return { enabled, model: enabled ? model : "", allowed, effort }; } +// The pool as the card shows it: an older file's scene lists together, unless a scene is open. +function shownPool(provider) { + if (hasPool(provider)) return listedPool(provider); + const lists = SCENE_IDS.map((s) => slot(s, provider)).filter((s) => s.enabled).map((s) => s.allowed); + return lists.length && lists.every(Boolean) ? [...new Set(lists.flat())] : null; +} + // Scenes that show the same model and effort, followed or chosen, need only one row. function shownSlot(scene, provider) { const current = slot(scene, provider); @@ -143,12 +157,38 @@ function writeSlot(scene, provider, next) { const model = next.model || (next.allowed ? (next.allowed.includes(native) ? native : next.allowed[0]) : ""); const selection = restrict(scene); const conf = (selection[provider] ||= { models: [], overrides: {} }); - conf.models = next.allowed ? [...new Set([...next.allowed, model])] : []; + conf.models = next.allowed && !hasPool(provider) ? [...new Set([...next.allowed, model])] : []; if (model) conf.default = model; else delete conf.default; for (const m of [model, ...(next.allowed || [])]) if (m) ensureCliModel(provider, m); } +// A scene that follows the CLI names the CLI's model once a pool has it, as a model list needs a +// default. Any other default stays as it is, reported until the user changes it. +function pinFollowed(provider, conf) { + const native = nativeDefault(provider).model; + if (conf.default || !listedPool(provider)?.includes(native)) return; + conf.default = native; + const shared = currentDoc.clis[provider].models[native]; + const effort = savedEffort(provider, native, ""); + if (effort && !shared.effort) shared.effort = effort; +} + +// Editing the pool takes over from the scene lists, so they cannot narrow it a second time. +function writePool(provider, allowed) { + for (const m of allowed || []) ensureCliModel(provider, m); + const cli = ((currentDoc.clis ||= {})[provider] ||= { models: {} }); + if (allowed) cli.allowed_models = [...allowed]; + else delete cli.allowed_models; + for (const scene of SCENE_IDS) { + const conf = restrict(scene)[provider]; + if (!conf) continue; + conf.models = []; + pinFollowed(provider, conf); + } + edited(); +} + // One model's effort in both scenes: shared when they agree, else a scene override. An override // cannot unset the shared level, so a scene that follows the CLI holds the shared one. function writeEfforts(provider, modelId, wants) { @@ -221,7 +261,7 @@ function setProviderEnabled(provider, enabled, confirmed = false) { pendingDisable = null; for (const scene of SCENE_IDS) { if (!enabled) delete restrict(scene)[provider]; - else restrict(scene)[provider] ||= { models: [], overrides: {} }; + else pinFollowed(provider, restrict(scene)[provider] ||= { models: [], overrides: {} }); } edited(); } @@ -247,4 +287,4 @@ function setSplit(provider, split) { edited(); } -let pickerOpen = null; // "provider/scene" whose model list is open +let pickerOpen = null; // "provider/pool" whose model list is open diff --git a/apps/tray/ui/settings-state.js b/apps/tray/ui/settings-state.js index 9738382c..cc8d7db9 100644 --- a/apps/tray/ui/settings-state.js +++ b/apps/tray/ui/settings-state.js @@ -435,7 +435,7 @@ function configurationErrors() { if (!Object.hasOwn(models, model)) add(provider, model, scope, L("模型尚未配置", "Model is not configured")); } if (Object.hasOwn(selection, "default") && - (!Object.hasOwn(models, selection.default) || (selection.models.length && !selection.models.includes(selection.default)))) { + (!Object.hasOwn(models, selection.default) || (!hasPool(provider) && selection.models.length && !selection.models.includes(selection.default)))) { add(provider, selection.default, scope, L("默认模型不在允许范围内", "Default model is not in the allowed list")); } for (const [model, override] of Object.entries(selection.overrides || {})) { @@ -458,6 +458,12 @@ function configurationErrors() { if (current.model && !current.effort && effortLevels(provider, current.model).length > 1) { add(provider, current.model, SCENES[scene], L("要选一个思考档位", "Choose an effort level")); } + if (current.enabled && listedPool(provider) && !current.model) { + add(provider, L("默认模型", "default model"), SCENES[scene], L("要选一个默认模型", "Choose a default model")); + } + if (current.enabled && current.model && outOfPool(provider, current.model)) { + add(provider, current.model, SCENES[scene], L("不在可用模型里", "Not in the available models")); + } } } return errors; @@ -471,7 +477,7 @@ function updateControls() { renderAdvancedBar(); const emptyRestricted = emptyScene(); - const errors = configurationErrors(); + const errors = memberSaveErrors(); const canSave = !busy && dirty && !documentError && !emptyRestricted && errors.length === 0; $("save-btn").disabled = !canSave; @@ -611,6 +617,7 @@ function setTab(tab) { if (tab === "voice") window.AgentNaveVoiceSettings?.load(); if (tab === "about") loadStatus().then(checkIfStale); if (tab === "members") { + loadOrchestration(); loadLimit(); window.AgentNaveAlertsSettings?.load(); } diff --git a/apps/tray/ui/settings.css b/apps/tray/ui/settings.css index 27becb6c..85b7d48c 100644 --- a/apps/tray/ui/settings.css +++ b/apps/tray/ui/settings.css @@ -176,6 +176,9 @@ .member-end > .segment-control, .member-row .member-efforts { display: flex; min-width: 0; } .member-end > .segment-control > .segment-btn, .member-efforts > .segment-btn { flex: 1 1 0; min-width: 0; padding-left: 2px; padding-right: 2px; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .member-hidden { visibility: hidden; } +.member-row > :nth-child(3) { grid-column: 3 / -1; } +.member-row > .member-list { grid-column: 2 / -1; } +.member-scope { margin: 0 0 10px; } .member-scene { color: var(--muted); font-size: 12.5px; } .member-tail { display: flex; align-items: center; gap: 8px; min-width: 0; } .member-tail > :first-child { flex: 1; } @@ -203,13 +206,12 @@ .member-card { --member-cols: 96px minmax(0, 1fr) minmax(0, 1fr); } .member-title { grid-column: 1 / -1; } .member-end > .segment-control { grid-column: 2; } - .member-row > :nth-child(4) { grid-column: 2 / -1; } } @media (max-width: 700px) { .member-card { --member-cols: minmax(0, 1fr); padding: 12px 14px; } .member-head, .member-row { padding-left: 0; gap: 8px; } .member-title { margin-left: 0; } - .member-end > .segment-control, .member-row > :nth-child(4) { grid-column: auto; } + .member-end > .segment-control, .member-row > :nth-child(3), .member-row > .member-list { grid-column: auto; } .member-row > .member-list { width: calc(100% - 34px); } .member-switch, .member-error { padding-left: 0; } .member-interactive { grid-column: auto; } diff --git a/apps/tray/ui/settings.html b/apps/tray/ui/settings.html index d9073841..5e6777b6 100644 --- a/apps/tray/ui/settings.html +++ b/apps/tray/ui/settings.html @@ -74,6 +74,8 @@