From 887c1352ad82cc435891f40a0e41744bab3ceda5 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sat, 19 Sep 2026 00:07:41 +0800 Subject: [PATCH 1/2] feat(collaboration): expose bound delegation to attached shell agents Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- loopx/cli.py | 4 + loopx/cli_commands/delegation.py | 71 +++++++++++++++++ loopx/collaboration_mcp.py | 23 ++++-- loopx/help_surface.py | 2 + tests/test_delegation_cli.py | 127 +++++++++++++++++++++++++++++++ 5 files changed, 219 insertions(+), 8 deletions(-) create mode 100644 loopx/cli_commands/delegation.py create mode 100644 tests/test_delegation_cli.py diff --git a/loopx/cli.py b/loopx/cli.py index ef53205bb6..82242be50c 100644 --- a/loopx/cli.py +++ b/loopx/cli.py @@ -8,6 +8,7 @@ from .cli_commands.agent_context import register_agent_context, handle_agent_context from .cli_commands.todo_continuation import register_todo_continuation, handle_todo_continuation from .cli_commands.manager_inbox import register_manager_inbox, handle_manager_inbox +from .cli_commands.delegation import register_delegation, handle_delegation from .capabilities.content_ops.cli import ( handle_content_ops_command, register_content_ops_commands, @@ -332,6 +333,7 @@ def build_parser() -> LoopXArgumentParser: register_project_lifecycle_commands(sub, add_subcommand_format) register_goal_channel_commands(sub, add_subcommand_format) register_manager_inbox(sub, add_subcommand_format) + register_delegation(sub, add_subcommand_format) register_agent_capabilities(sub, add_subcommand_format) register_agent_context(sub, add_subcommand_format) register_agent_directory(sub, add_subcommand_format) @@ -783,6 +785,8 @@ def main(argv: list[str] | None = None) -> int: if args.command == "manager-inbox": return handle_manager_inbox(args, registry_path, effective_runtime_root(registry_path, args.runtime_root)) + if args.command == "delegation": + return handle_delegation(args, registry_path, effective_runtime_root(registry_path, args.runtime_root)) lark_inbox_result = handle_lark_inbox_command( args, diff --git a/loopx/cli_commands/delegation.py b/loopx/cli_commands/delegation.py new file mode 100644 index 0000000000..341fc49146 --- /dev/null +++ b/loopx/cli_commands/delegation.py @@ -0,0 +1,71 @@ +"""Shell access to the same bound work used by the collaboration MCP tools. + +An existing attached Agent can use its current shell without replacing its +conversation or installing tools into an already running host session. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from ..control_plane.effect_runtime import EffectRuntimeRemoteError + + +def register_delegation(subparsers, add_format): + parser = subparsers.add_parser( + "delegation", help="Launch and recover authorized peer work; returns JSON." + ) + add_format(parser) + parser.add_argument("delegation_action", choices=("list", "start", "read", "wait", "resume")) + parser.add_argument("--goal-id", required=True) + parser.add_argument("--agent-id", required=True, help="Calling registered Agent, not the worker.") + parser.add_argument("--execution-config", type=Path, required=True, + help="Existing operator-owned local delegation bindings.") + parser.add_argument("--operation-id", help="Stable request identity; reuse after a lost response.") + parser.add_argument("--binding-id", help="For start: an authorized binding from list.") + parser.add_argument("--brief-file", type=Path, help="For start: collaboration_brief_v0 JSON file.") + parser.add_argument("--parent-request-id", help="For start: the request received by this coordinator.") + parser.add_argument("--execute", action="store_true", help="Required for start/resume; grants no additional authority.") + + +def handle_delegation(args, registry_path, runtime_root): + # The shared host is importable without the optional MCP server dependency. + from ..collaboration_mcp import Delegations + + action = args.delegation_action + try: + if action in {"start", "resume"} and not args.execute: + raise ValueError(f"delegation {action} requires --execute") + if action not in {"start", "resume"} and args.execute: + raise ValueError("--execute is only valid for start/resume") + if action != "list" and not args.operation_id: + raise ValueError(f"delegation {action} requires --operation-id") + if action == "list" and args.operation_id: + raise ValueError("list does not select an operation; use read") + if action != "start" and (args.binding_id or args.brief_file or args.parent_request_id): + raise ValueError("binding, brief and parent request are only supplied on start") + service = Delegations(runtime_root, registry_path, args.goal_id, args.agent_id, + args.execution_config.expanduser()) + if action == "start": + if not args.binding_id or not args.brief_file: + raise ValueError("start requires --binding-id and --brief-file") + with args.brief_file.expanduser().open("rb") as stream: + raw = stream.read(128_001) + if len(raw) > 128_000: + raise ValueError("delegation brief file exceeds 128000 bytes") + result = service.start(args.binding_id, args.operation_id, json.loads(raw), + args.parent_request_id) + elif action == "list": + result = service.directory() + elif action == "read": + result = service.read(args.operation_id) + elif action == "wait": + result = service.wait(args.operation_id) + else: + result = service.resume(args.operation_id) + payload = {"ok": True, **result} + except (OSError, ValueError, KeyError, EffectRuntimeRemoteError) as exc: + payload = {"ok": False, "error": str(exc)} + print(json.dumps(payload, ensure_ascii=False, indent=2)) + return 0 if payload["ok"] else 1 diff --git a/loopx/collaboration_mcp.py b/loopx/collaboration_mcp.py index 2b9823d353..823d65352a 100644 --- a/loopx/collaboration_mcp.py +++ b/loopx/collaboration_mcp.py @@ -19,9 +19,10 @@ import sys import time from pathlib import Path -from typing import Literal +from typing import TYPE_CHECKING, Literal -from mcp.server.fastmcp import FastMCP +if TYPE_CHECKING: + from mcp.server.fastmcp import FastMCP from .file_lock import exclusive_file_lock, LockAcquisitionPolicy, LockAcquireTimeoutError from .todos import list_goal_todos @@ -44,6 +45,8 @@ def create_server( root: Path, registry: Path, goal_id: str, agent_id: str, workspace: Path, execution_config: Path | None = None, ) -> FastMCP: + from mcp.server.fastmcp import FastMCP + server = FastMCP("loopx-collaboration") register_collaboration_tools(server, root, registry, goal_id, agent_id, workspace) if execution_config is not None: @@ -188,6 +191,15 @@ def resume(self, operation_id: str) -> dict: self._spawn(operation_id) return self.read(operation_id) + def wait(self, operation_id: str) -> dict: + """Observe for at most 15 seconds; waiting neither starts nor resumes work.""" + for _ in range(5): + result = self.read(operation_id) + if result["status"] in {"accepted", "rejected"} or result["recovery_required"]: + return result + time.sleep(3) + return self.read(operation_id) + def _bound(self, row: dict, *, require_active: bool = False) -> dict: binding = self.binding(row["identity"]["binding"]["id"], require_active=require_active) if row["identity"]["binding"] != binding: @@ -388,12 +400,7 @@ def read_delegation(operation_id: str) -> dict: @server.tool() async def wait_delegation(operation_id: str) -> dict: """Wait at most 15 seconds for an original operation; returning running is normal.""" - for _ in range(5): - result = await asyncio.to_thread(delegations.read, operation_id) - if result["status"] in {"accepted", "rejected"} or result["recovery_required"]: - break - await asyncio.sleep(3) - return result + return await asyncio.to_thread(delegations.wait, operation_id) @server.tool() def resume_delegation(operation_id: str) -> dict: diff --git a/loopx/help_surface.py b/loopx/help_surface.py index fed0103b6f..70d6eb27c0 100644 --- a/loopx/help_surface.py +++ b/loopx/help_surface.py @@ -335,6 +335,8 @@ "codex-cli-visible-local-driver-pilot", "codex-cli-visible-session-proof", "configure-goal", + "delegation", + "goal-acceptance", "content-ops", "decision-context", "dash", diff --git a/tests/test_delegation_cli.py b/tests/test_delegation_cli.py new file mode 100644 index 0000000000..956bca600a --- /dev/null +++ b/tests/test_delegation_cli.py @@ -0,0 +1,127 @@ +"""Attached callers use real CLI processes and the existing canonical acceptance.""" + +import json +import subprocess +import sys +import time + +from test_local_delegation import brief, demo, service as delegation_service + +service = delegation_service + + +def cli(runner, action, *args, actor=None): + command = [sys.executable, "-m", "loopx.cli", "--registry", str(runner.registry), + "--runtime-root", str(runner.root), "--format", "json", "delegation", action, + "--goal-id", runner.goal_id, "--agent-id", actor or runner.agent_id, + "--execution-config", str(runner.config), *args] + completed = subprocess.run(command, capture_output=True, text=True, timeout=40) + return completed.returncode, json.loads(completed.stdout) + + +def test_attached_cli_disconnect_retry_and_verified_return(service): + root, runner = service + (root / "hold").touch() + source = root / "brief.json" + source.write_text(json.dumps(brief())) + status, listing = cli(runner, "list") + assert status == 0 + assert listing["bindings"] == [{"id": "analysis", "agent_id": "analyst", "todo_id": "todo_analyst-initial"}] + assert not runner.path("cli-work").exists() + args = ["--binding-id", "analysis", "--operation-id", "cli-work", "--brief-file", str(source)] + status, result = cli(runner, "start", *args) + assert status == 1 and "--execute" in result["error"] + assert not runner.path("cli-work").exists() + status, first = cli(runner, "start", *args, "--execute") + assert status == 0 + deadline = time.monotonic() + 45 + while not (root / "host-started").exists() and time.monotonic() < deadline: + time.sleep(0.1) + assert (root / "host-started").exists() + try: + # Each CLI has exited. Work belongs to the original detached operation, + # and a new client resumes/reads it without creating another host turn. + status, observed = cli(runner, "read", "--operation-id", "cli-work") + assert status == 0 and observed["request_id"] == first["request_id"] + status, replay = cli(runner, "start", *args, "--execute") + assert status == 0 and replay["request_id"] == first["request_id"] + status, resumed = cli(runner, "resume", "--operation-id", "cli-work", "--execute") + assert status == 0 and resumed["request_id"] == first["request_id"] + finally: + (root / "release").touch() + deadline = time.monotonic() + 100 + while time.monotonic() < deadline: + status, result = cli(runner, "wait", "--operation-id", "cli-work") + assert status == 0, result + if result["status"] in {"accepted", "rejected"}: + break + assert result["status"] == "accepted", result + assert (root / "analyst" / "initial" / "host-invocations").read_text() == "1" + assert demo.canonical_tasks(root)["todo_analyst-initial"]["done"] + assert result["artifacts"][0]["sha256"] + + status, denied = cli(runner, "read", "--operation-id", "cli-work", actor="reviewer") + assert status == 1 and not denied["ok"] + # Changing an accepted artifact cannot be hidden behind the saved result. + output = root / "analyst" / "initial" / "output.json" + output.write_text("{}") + status, stale = cli(runner, "read", "--operation-id", "cli-work") + assert status == 1 and not stale["ok"] + + +def test_cli_invalid_inputs_do_not_launch_work(service): + root, runner = service + bad = root / "bad.json" + bad.write_text("{") + status, result = cli(runner, "start", "--execute", "--binding-id", "analysis", + "--operation-id", "invalid", "--brief-file", str(bad)) + assert status == 1 and not result["ok"] + assert not runner.path("invalid").exists() + bad.write_text(" " * 128_001) + status, result = cli(runner, "start", "--execute", "--binding-id", "analysis", + "--operation-id", "oversize", "--brief-file", str(bad)) + assert status == 1 and "128000" in result["error"] + status, result = cli(runner, "resume", "--operation-id", "missing") + assert status == 1 and "--execute" in result["error"] + assert not (root / "host-started").exists() + + +def test_shared_execution_host_does_not_require_optional_mcp(): + script = """ +import importlib.abc, sys +class NoMCP(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + if fullname == 'mcp' or fullname.startswith('mcp.'): + raise ImportError('MCP is intentionally absent') +sys.meta_path.insert(0, NoMCP()) +from loopx.collaboration_mcp import Delegations +from loopx.cli import build_parser +build_parser().parse_args(['delegation', 'list', '--goal-id', 'goal', '--agent-id', 'lead', '--execution-config', 'bindings.json']) +""" + completed = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True) + assert completed.returncode == 0, completed.stderr + + +def test_example_prepare_exposes_grants_without_starting_a_lead(tmp_path): + root = tmp_path / "attached-team" + prepared = subprocess.run( + [sys.executable, str(demo.HERE / "research_team.py"), "prepare", str(root), + "--model", "fixture-model", "--environment-id", "fixture-environment"], + capture_output=True, text=True, timeout=60, + ) + assert prepared.returncode == 0, prepared.stderr + result = json.loads(prepared.stdout) + assert result["execution_started"] is False + assert all(not row["done"] for row in demo.canonical_tasks(root).values()) + assert not list((root / "runtime").glob("goals/*/turns/*.json")) + listed = subprocess.run( + [sys.executable, "-m", "loopx.cli", "--registry", result["registry"], + "--runtime-root", result["runtime_root"], "delegation", "list", + "--goal-id", result["goal_id"], "--agent-id", result["agent_id"], + "--execution-config", result["execution_config"]], + capture_output=True, text=True, timeout=30, + ) + assert listed.returncode == 0, listed.stderr + assert {row["id"] for row in json.loads(listed.stdout)["bindings"]} == { + "local-analyst/initial", "cloud-reviewer/initial", "cloud-analyst/corrected", + } From bbeab4cfd88e4cc3c6fe814a461c080f907a46a9 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sat, 19 Sep 2026 00:07:41 +0800 Subject: [PATCH 2/2] docs(collaboration): rehearse teams from an existing lead conversation Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../rfcs/agent-session-execution-modes-v0.md | 1 + .../agent-session-execution-modes-v0.zh-CN.md | 1 + .../rfcs/loopx-overall-roadmap-v0.md | 2 + .../rfcs/loopx-overall-roadmap-v0.zh-CN.md | 2 + docs/reference/local-delegation.md | 66 +++++++++++++++++++ examples/managed-research-team/README.md | 34 ++++++++++ .../managed-research-team/research_team.py | 33 +++++++--- examples/managed-research-team/scenario.py | 3 + examples/managed-research-team/server.py | 6 +- 9 files changed, 139 insertions(+), 9 deletions(-) diff --git a/docs/architecture/rfcs/agent-session-execution-modes-v0.md b/docs/architecture/rfcs/agent-session-execution-modes-v0.md index 8fd7d730f4..5c21ad26f5 100644 --- a/docs/architecture/rfcs/agent-session-execution-modes-v0.md +++ b/docs/architecture/rfcs/agent-session-execution-modes-v0.md @@ -182,6 +182,7 @@ Audited at `6c3da75ca`. These are current facts, not proposed behavior. | Attached broker | [`loopx/attached_session.py`](../../../loopx/attached_session.py) implements bind, claim, and complete under `loopx_attached_agent_session_broker_v0`, adapter kind `attached_host_session`, upstream mode `host_broker`, with a bounded claim wait of 1800 seconds, duplicate-safe claim and completion receipts, and per-binding file locks. | | Runtime fencing | [`loopx/chat_runtime.py`](../../../loopx/chat_runtime.py) never starts a managed adapter for an attached session and fails closed with typed errors such as `attached_session_live_steering_unavailable`, `live_steering_requires_active_turn`, and `live_steering_session_not_attached`. | | CLI surface | `loopx worker-bridge attached-session-bind`, `-list`, `-claim`, and `-complete` exist in [`loopx/cli_commands/worker_bridge.py`](../../../loopx/cli_commands/worker_bridge.py), documented in the [broker guide](../../integrations/attached-agent-session-broker.md) and the [worker-bridge install contract](../../integrations/worker-bridge-install-contract.md). | +| Existing-session delegation | [`loopx delegation`](../../reference/local-delegation.md#use-an-existing-agent-conversation-through-its-shell) exposes the same explicitly bound work as MCP to an existing shell-capable Agent. It retains the caller conversation and original operation on reconnect; it does not provision an Agent, migrate a host or install an automatic wake policy. | | Focused tests | [`tests/test_attached_session_cli.py`](../../../tests/test_attached_session_cli.py) and `tests/test_chat_codex_home.py::test_attached_session_uses_existing_host_not_managed_adapter` cover bind/claim/complete and the no-managed-adapter fence. | | Product-level proposal | The [Desktop execution frontends RFC](desktop-execution-frontends-v0.md) owns the Mode A/Mode B product comparison, the connector and event-source orthogonality, and the Desktop non-goals. | | Host-side loop guidance | [Codex CLI TUI loop](../../product/runtimes/codex-cli/codex-cli-tui-loop.md) documents session-attached automation and resume options for one visible host. | diff --git a/docs/architecture/rfcs/agent-session-execution-modes-v0.zh-CN.md b/docs/architecture/rfcs/agent-session-execution-modes-v0.zh-CN.md index a76c8b16dc..a76bf4fdf0 100644 --- a/docs/architecture/rfcs/agent-session-execution-modes-v0.zh-CN.md +++ b/docs/architecture/rfcs/agent-session-execution-modes-v0.zh-CN.md @@ -141,6 +141,7 @@ LoopX 启动,另一种已经属于其他宿主。当绑定没有说明自己 | 挂接 broker | [`loopx/attached_session.py`](../../../loopx/attached_session.py) 在 `loopx_attached_agent_session_broker_v0` 下实现 bind/claim/complete,适配器类型 `attached_host_session`,上游模式 `host_broker`,claim 等待上限 1800 秒,claim 与完成回执去重,并按绑定加文件锁。 | | 运行时围栏 | [`loopx/chat_runtime.py`](../../../loopx/chat_runtime.py) 绝不为挂接会话启动托管适配器,并以类型化错误失败关闭,例如 `attached_session_live_steering_unavailable`、`live_steering_requires_active_turn`、`live_steering_session_not_attached`。 | | CLI 面 | `loopx worker-bridge attached-session-bind`、`-list`、`-claim`、`-complete` 存在于 [`loopx/cli_commands/worker_bridge.py`](../../../loopx/cli_commands/worker_bridge.py),并在 [broker 指南](../../integrations/attached-agent-session-broker.md) 与 [worker-bridge 安装契约](../../integrations/worker-bridge-install-contract.md) 中记录。 | +| 原会话委派 | [`loopx delegation`](../../reference/local-delegation.md#use-an-existing-agent-conversation-through-its-shell) 让有 shell 能力的原 Agent 使用与 MCP 相同的显式执行绑定;重连保留原对话和操作身份,不创建 Agent、不迁移宿主,也不安装自动唤醒策略。 | | 聚焦测试 | [`tests/test_attached_session_cli.py`](../../../tests/test_attached_session_cli.py) 与 `tests/test_chat_codex_home.py::test_attached_session_uses_existing_host_not_managed_adapter` 覆盖 bind/claim/complete 与"不启动托管适配器"的围栏。 | | 产品级提案 | [桌面执行前端 RFC](desktop-execution-frontends-v0.zh-CN.md) 拥有 Mode A/Mode B 的产品对比、连接器与事件源正交性,以及桌面端非目标。 | | 宿主侧循环指引 | [Codex CLI TUI loop](../../product/runtimes/codex-cli/codex-cli-tui-loop.md) 记录了一个可见宿主的会话挂接自动化与恢复选项。 | diff --git a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md index ea0e1350d0..3391342d74 100644 --- a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md +++ b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md @@ -282,6 +282,8 @@ sessions, generic Agent creation, dynamic governed work derivation, complete inbox/queue/steer, authenticated remote authority and packaged frontend/Lark companion work remain R2/R3/R4/R6 boundaries. Existing Goals are not promoted. +An existing shell-capable coordinator can now use `delegation list/start/read/wait/resume` without replacing its session or loading new MCP tools. The synthetic example's `prepare` path creates only isolated operator bindings; the existing Agent chooses and starts the work. This completes the attached-caller entrypoint over the existing execution owner. Dynamic identity/profile provisioning, unattended lead wakeup and full inbox/queue/steer remain separate R2/R3 requirements; fixed binding readback is not fleet readiness. + ### R3: Semantic Requests and Automatic Return - **Owner:** manager RFC M2/M3; migrate existing `manager_context` request/tracking/return into one typed collaboration transaction, incorporating the #4094 adapter. diff --git a/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md b/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md index db710f49cd..5c5f3b93f3 100644 --- a/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md +++ b/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md @@ -263,6 +263,8 @@ Todo 完成入口分别执行当前 pinned 检查,accepted 返回读 canonical 完成。长期 attached 会话、通用 Agent 创建、动态受治理工作派生、完整 inbox/queue/steer、 认证远端权威与 packaged frontend/Lark 配套仍归 R2/R3/R4/R6;不晋升已有 Goal。 +有 shell 能力的原 coordinator 现在可通过 `delegation list/start/read/wait/resume` 调用已有执行 owner,无需替换会话或重新加载 MCP 工具。合成示例的 `prepare` 只准备隔离绑定,由原 Agent 自行选择并启动工作。这闭合原会话调用入口;动态身份/profile 创建、无人值守唤醒和完整 inbox/queue/steer 仍按 R2/R3 推进,固定绑定读回不等于团队全部就绪。 + ### R3:语义请求与自动回报 - **Owner:** 管家 RFC M2/M3;从已有 `manager_context` request/tracking/return 迁移到单一 typed collaboration 事务,纳入 #4094 adapter。 diff --git a/docs/reference/local-delegation.md b/docs/reference/local-delegation.md index 98058656d8..37a10d0283 100644 --- a/docs/reference/local-delegation.md +++ b/docs/reference/local-delegation.md @@ -34,6 +34,72 @@ select `generic-cli`, `fresh`, and the optional adapter's `--config` invocation. Profiles, executables, workspace isolation and credential custody remain the operator's responsibility. No model tool accepts those values. +## Use an existing Agent conversation through its shell + +An attached Codex or other shell-capable Agent can use the same execution +bindings without opening a replacement conversation or adding MCP tools to a +running session. Use its registered requester identity and the exact registry, +runtime and operator configuration; this trusted local CLI is not a remote +authentication boundary. + +```bash +delegate() { + loopx --registry "$REGISTRY" --runtime-root "$RUNTIME_ROOT" --format json \ + delegation "$@" --goal-id "$GOAL_ID" --agent-id "$AGENT_ID" \ + --execution-config "$DELEGATION_CONFIG" +} + +delegate list +delegate start --binding-id independent-review --operation-id review-round-1 \ + --brief-file request.json --execute +delegate read --operation-id review-round-1 +delegate wait --operation-id review-round-1 +``` + +`request.json` contains the same `collaboration_brief_v0` used by MCP: + +```json +{ + "schema_version": "collaboration_brief_v0", + "purpose": "Independently check the current analysis", + "context": "Reconcile the corrected source with the earlier conclusion.", + "constraints": ["Use only the supplied material; no external actions"], + "inputs": [], + "acceptance": ["Satisfy the task's pinned independent acceptance"], + "return_requirement": "Return evidence, uncertainty and the checked artifact" +} +``` + +The Agent chooses questions, sequencing and synthesis. After `start` returns, +it can continue its own investigation; closing that CLI process does not stop +the worker. Another invocation reads the original operation. `wait` observes +for a bounded interval and does not start, resume or accept work. `ok: true` +means the command succeeded; inspect `status`, `recovery_required`, `error` and +the independently checked artifacts to determine the work result. Neither a +`running` result nor a saved peer opinion means accepted completion. + +After a lost start response, repeat the same start with the same operation id +and brief. If readback reports `recovery_required`, use: + +```bash +delegate resume --operation-id review-round-1 --execute +``` + +Resume keeps the original operation and Turn; it cannot silently retarget +work. A new scope or repair round requires a new operation, still subject to +the configured task, quota and acceptance owners. A member coordinating its +own authorized peers supplies `--parent-request-id` on start. CLI and MCP +share grant validation, detached execution, wait/readback and recovery rather +than maintaining separate rules. + +This entrypoint does not create Agents, grant bindings or wake an idle Codex +conversation. The existing host/LoopX continuation policy owns the next lead +turn. The conversation remains persistent independently of whether autonomous +LoopX mode is enabled. Current Dashboard/Lark setup is unchanged; those surfaces +keep their existing conversation and runtime owners. + +## Use the same bindings through MCP + Start the existing stdio server with the explicit opt-in: ```bash diff --git a/examples/managed-research-team/README.md b/examples/managed-research-team/README.md index 68da7bdf7a..5deeded9ea 100644 --- a/examples/managed-research-team/README.md +++ b/examples/managed-research-team/README.md @@ -61,6 +61,40 @@ uv run --no-sync --extra test python -m loopx.cli \ ## Collaboration path +### Keep an existing Codex or other local lead + +Use `prepare` instead of `run` to provision only the disposable fixture and +operator bindings. It makes no model call and does not start another lead +session. Keep the provider setup above, including the existing Environment: + +```bash +uv run --no-sync --extra test python examples/managed-research-team/research_team.py \ + prepare "$DEMO_ROOT" --model "$ARK_MODEL_ID" --environment-id "$ARK_ENVIRONMENT_ID" +export LOOPX_RESEARCH_DEMO_ROOT="$DEMO_ROOT" + +uv run --no-sync --extra test loopx --registry "$DEMO_ROOT/registry.json" \ + --runtime-root "$DEMO_ROOT/runtime" --format json delegation list \ + --goal-id synthetic-managed-research --agent-id lead \ + --execution-config "$DEMO_ROOT/delegation-config.json" +``` + +The existing Agent then uses [delegation start/read/wait/resume](../../docs/reference/local-delegation.md#use-an-existing-agent-conversation-through-its-shell) +for the listed bindings, writing its own briefs. It reads the synthetic +`input.json` files and returned artifacts, chooses the work order and continues +its own analysis while members run. The nested cloud analyst still requests +its local reviewer through the same service. No business phase argument is +introduced. + +After reading all four canonical completions and exact artifact hashes, the +lead writes `lead/report.json` with the fields described by `scenario.py` and +the acceptance table below. Run `validate-report`, then complete the report +through ordinary `todo complete --todo-id todo_lead-report --agent-id lead +--no-follow-up` against this disposable registry/runtime. That command reruns +the bound validator. Retain the original conversation; preparation does not +attach, resume, migrate or impersonate any existing production Agent. + +### Member relationships + The primary `local-led` profile has four independently accepted member tasks: - The local lead delegates initial-filing analysis to local DSH `local-analyst`. diff --git a/examples/managed-research-team/research_team.py b/examples/managed-research-team/research_team.py index bdd7698423..9c536e8fa0 100644 --- a/examples/managed-research-team/research_team.py +++ b/examples/managed-research-team/research_team.py @@ -85,14 +85,15 @@ def git(*args: str) -> None: for actor, revision in pairs: identity = todo_id(actor, revision) text = ( - "Use the research_team MCP tools. Read the assignment with read_assignment. Organize the registered " + "Use the research_team MCP tools or the same delegation CLI from an existing local session. " + "Read the assignment with read_assignment, or inspect the synthetic team/input files. Organize the registered " "members with list_execution_bindings/start_delegation/wait_delegation to analyze their authorized revisions. " "Use stable operation ids and collaboration_brief_v0 (purpose, context, constraints, inputs, acceptance, return_requirement). " "Complete local-analyst before requesting cloud-reviewer, who must adopt its exact artifact. " "Cloud-analyst is responsible for delegating its local-reviewer prerequisite through the same tools. " "You can start independent branches concurrently. A running operation is not failure; wait for its original result. " - "Read all final artifacts with read_accepted_evidence. Decide questions and order yourself. Review their " - "accepted results, resolve differences, then write_report with all four evidence hashes. " + "Read all final artifacts with read_accepted_evidence or canonical CLI readback. Decide questions and order yourself. Review their " + "accepted results, resolve differences, then write_report or lead/report.json with all four evidence hashes. " "Only return validated_progress after write_report confirms independent checks." if actor == "lead" else "Read TASK.md and DELEGATION.json, or use read_input/write_output. Read context and assess_request before working. " @@ -153,9 +154,7 @@ def launch(root: Path, model: str, environment_id: str, dsh_model: str, topology raise ValueError("install_loopx_deepseek_harness_extra_in_this_interpreter") if not os.environ.get("ARK_API_KEY") or not os.environ.get("DEEPSEEK_API_KEY"): raise ValueError("ARK_API_KEY_and_DEEPSEEK_API_KEY_required") - prepare(root, topology=topology) - write(root / "settings.json", {"dsh_model": dsh_model, "ark_model": model, "environment_id": environment_id}) - configure_delegations(root) + prepare_execution(root, model, environment_id, dsh_model, topology) os.environ["LOOPX_RESEARCH_DEMO_ROOT"] = str(root) result = turn(root, "lead", "report", root / "lead", [sys.executable, str(HERE / "research_team.py"), "validate-report", str(root)], host_arguments(root, "lead", "report", host="dsh" if topology == "local-led" else "ark"), 1200) @@ -170,9 +169,23 @@ def launch(root: Path, model: str, environment_id: str, dsh_model: str, topology return summary +def prepare_execution(root: Path, model: str, environment_id: str, dsh_model: str, + topology: str = "local-led") -> dict: + """Prepare a fresh operator fixture without starting a replacement lead.""" + prepare(root, topology=topology) + write(root / "settings.json", {"dsh_model": dsh_model, "ark_model": model, "environment_id": environment_id}) + config = configure_delegations(root) + return {"goal_id": GOAL, "agent_id": "lead", "registry": str(root / "registry.json"), + "runtime_root": str(root / "runtime"), "execution_config": str(config), + "workspace": str(root / "lead"), "execution_started": False, + "next_action": "Use delegation list/start/read/wait from the existing Agent session. " + "Supply LOOPX_RESEARCH_DEMO_ROOT and the configured credentials when starting work. " + "Independent task acceptance remains bound; prepare does not complete any task."} + + def main() -> None: p = argparse.ArgumentParser(description=__doc__) - p.add_argument("command", choices=["run", "validate-worker", "validate-report"]) + p.add_argument("command", choices=["prepare", "run", "validate-worker", "validate-report"]) p.add_argument("root", type=Path) p.add_argument("--revision", choices=REVISIONS) p.add_argument("--model", default=os.environ.get("ARK_MODEL_ID")) @@ -180,9 +193,13 @@ def main() -> None: p.add_argument("--dsh-model", default="deepseek-v4-flash") p.add_argument("--topology", choices=["local-led", "cloud-led"], default="local-led") args = p.parse_args() - if args.command == "run": + if args.command in {"prepare", "run"}: if not args.model or not args.environment_id: p.error("explicit model and existing environment required") + if args.command == "prepare": + print(json.dumps(prepare_execution(args.root.resolve(), args.model, args.environment_id, + args.dsh_model, args.topology))) + return result = launch(args.root.resolve(), args.model, args.environment_id, args.dsh_model, args.topology) print(json.dumps(result)) if result.get("status") != "committed" or result.get("result_kind") != "validated_progress": diff --git a/examples/managed-research-team/scenario.py b/examples/managed-research-team/scenario.py index f018d96c0d..6464d424f2 100644 --- a/examples/managed-research-team/scenario.py +++ b/examples/managed-research-team/scenario.py @@ -75,6 +75,9 @@ def task(revision: str, question: str) -> str: "source_id values from issuer, prior and repost; cite the prior filing for the period-comparability check), " "reason (short). Count independent_source_families only for corroboration of CURRENT-period " "figures; prior-period comparison material is not current-period corroboration. " + "If read_input returns an upstream artifact, independently check it and include adopted_dependencies " + "in output.json: an object mapping upstream.identity to its full upstream.artifact_sha256. " + "Matching its numbers or mentioning a hash in prose does not record adoption. " "Do not use network, read another worker, modify Goal state, commit, or trade. " "Write only output.json. Return the normal Turn candidate after writing the artifact." ) diff --git a/examples/managed-research-team/server.py b/examples/managed-research-team/server.py index b06b0e13bb..99a5890189 100644 --- a/examples/managed-research-team/server.py +++ b/examples/managed-research-team/server.py @@ -112,7 +112,11 @@ def read_input() -> dict: @worker_server.tool() def write_output(output: dict) -> dict: - """Write only this assignment's output.json; return independent domain-check feedback.""" + """Submit output.json, including adopted_dependencies for any read_input upstream. + + That object maps upstream.identity to the full upstream.artifact_sha256. + Return the normal Turn JSON candidate only after artifact_checks_passed. + """ workspace, revision = worker_workspace() if len(json.dumps(output)) > 16_000: raise ValueError("output_too_large")