diff --git a/docs/reference/goal-acceptance-observations.md b/docs/reference/goal-acceptance-observations.md index 90f5329794..f383b03754 100644 --- a/docs/reference/goal-acceptance-observations.md +++ b/docs/reference/goal-acceptance-observations.md @@ -90,7 +90,19 @@ and check** once. A configured contract needs only **Check delivery**. The advanced JSON import remains available for technical migration; it is not a prerequisite for this path. -Agent preparation uses the existing typed action preview store: +In an explicitly enabled Goal LoopX conversation whose native tool advertises +`prepare_check`, the Agent can call `loopx_collaboration` with +`action=prepare_check`, a stable `operation_id` and the existing acceptance +`document`. The host binds the Goal, registered preparing Agent and runtime; +the model cannot replace those identities or roots. Paused, stopped, obsolete +Turns and revoked execution configuration cannot prepare a plan. A conflicting +retry retains the original proposal and requires reconciliation. + +This reuses the existing typed action preview store. It does not add a second +configuration owner or enable LoopX mode automatically. Native thread tool +schemas are fixed when created: an older thread may not advertise this action, +and an unfinished native Goal is never replaced just to add tools. The existing +registered-Agent CLI preparation remains available independently: ```bash loopx --format json goal-acceptance prepare --goal-id example-goal \ @@ -122,6 +134,10 @@ No-plan/default-off behavior remains unchanged, and no Lark action is introduced `prepare --execute` 只保存可审阅方案,不修改 Goal 规则、不执行检查。丢失回执先对账, 不能自动重跑;历史通过不能认证变化后的版本。交互次数从原始意图算起,前置缺失的意图或 授权不能从计数中省略。此入口沿用可信本机所有者边界,不证明远程身份、独立复核或实际采用。 +显式启用的 Goal LoopX 会话在原生工具声明 `prepare_check` 时,可直接准备相同方案; +Goal、Agent 与运行目录由宿主固定,暂停、过期 Turn 或撤销配置后不能继续准备。 +旧原生线程的工具声明不会自动升级,也不会为新增工具替换未完成线程;未声明时保留 CLI +准备路径。该入口不自动启用 LoopX、不配置或运行检查。 For advanced CLI configuration, inspect the current provider revision: diff --git a/examples/personal-workspace-browser/goal-acceptance-oracle-backend.py b/examples/personal-workspace-browser/goal-acceptance-oracle-backend.py index fa7fbc166b..e49372a31f 100644 --- a/examples/personal-workspace-browser/goal-acceptance-oracle-backend.py +++ b/examples/personal-workspace-browser/goal-acceptance-oracle-backend.py @@ -4,10 +4,15 @@ import json from pathlib import Path import sys +from threading import Event +from types import SimpleNamespace from loopx.chat_server import ChatHTTPServer, ChatRequestHandler from loopx.chat_action_store import ChatActionStore from loopx.chat_actions import ChatActionService +from loopx.chat_loopx_mode import TOOL +from loopx.chat_runtime import ChatRuntimeController +from loopx.chat_store import ChatSessionStore from loopx.control_plane.coordination.runtime_shadow import ( build_todo_runtime_shadow_projection, ) @@ -32,8 +37,21 @@ domain="acceptance", adapter_kind="generic_project_goal_v0", state_file=str(state), - registered_agents=["agent-a"], + registered_agents=["coordinator", "agent-a"], ) +registered = json.loads(registry.read_text()) +registered["goals"][0]["spawn_policy"] = { + "mode": "multi_subagent", "allowed": True, "max_children": 1, + "execution_config": ".loopx/config/delegations.json", +} +registry.write_text(json.dumps(registered)) +config = project / ".loopx/config/delegations.json" +config.parent.mkdir(parents=True) +config.write_text(json.dumps({"schema_version": "loopx_local_delegation_v0", "bindings": [{ + "id": "export", "agent_id": "agent-a", "todo_id": "todo_work", "requesters": ["coordinator"], + "workspace": str(project), "host_args": ["--host", "dsh"], "timeout_seconds": 60, + "output_refs": ["result.txt"], +}]})) todo = { "schema_version": "todo_item_v0", "todo_id": "todo_work", @@ -92,6 +110,23 @@ "bindings": [{"todo_id": "todo_work", "criterion_ids": ["export"]}], } (root / "owner-contract.json").write_text(json.dumps(document)) +# Exercise the admitted native tool callback, not a pre-seeded CLI proposal. +# Native transport/model interpretation remains scripted; no Host is launched. +store = ChatSessionStore(runtime) +controller = ChatRuntimeController(store=store, codex_bin="unused", registry_path=registry) +session = store.create_session(goal_id="multi-agent-projection", agent_id="codex", + channel_id="goal.multi-agent-projection", upstream_thread_id="fixture", upstream_mode="chat", + adapter_kind="codex_app_server") +sid, turn = session["session_id"], "admitted-preparation-turn" +store.update_session(sid, active_turn_id=turn, loopx_mode={"enabled": True, "paused": False, + "settings": {"agent_id": "coordinator", "token_budget": 10000}}) +adapter = SimpleNamespace(goal_driver=SimpleNamespace(stopped=Event()), session=SimpleNamespace()) +lock = controller.loopx_mode.prepare(sid, turn, adapter, lambda *_: {}, lambda *_: None) +for operation in ("older-export-plan", "prepare-export-check"): + prepared = adapter.session.read_tool_handler(TOOL["name"], { + "action": "prepare_check", "operation_id": operation, "document": document}) + if not prepared.get("ok"): + raise RuntimeError(f"Conversation preparation failed: {prepared}") server = ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler) server.registry_path, server.runtime_root = registry, runtime server.runtime_root_override, server.verbose = str(runtime), False @@ -101,7 +136,8 @@ server.action_service = ChatActionService(store=server.action_store, registry_path=registry) print( json.dumps( - {"port": server.server_port, "registry": str(registry), "runtime": str(runtime)} + {"port": server.server_port, "registry": str(registry), "runtime": str(runtime), + "prepared": prepared} ), flush=True, ) @@ -109,3 +145,5 @@ server.serve_forever() finally: server.server_close() + lock.__exit__(None, None, None) + controller.close() diff --git a/examples/personal-workspace-browser/goal-acceptance-oracle.mjs b/examples/personal-workspace-browser/goal-acceptance-oracle.mjs index 69994e5ea8..de04d8c3a6 100644 --- a/examples/personal-workspace-browser/goal-acceptance-oracle.mjs +++ b/examples/personal-workspace-browser/goal-acceptance-oracle.mjs @@ -40,13 +40,11 @@ try { assert.equal(read().goal_acceptance_contract.enabled, false); await page.locator(".personal-goal-link", {hasText: "Multi Agent Projection"}).click(); await page.getByRole("button", {name: "概览", exact: true}).click(); - // The Agent does the technical preparation. The owner does not enter settings, - // inspect revisions, upload JSON or confirm the same facts in a second form. - cli("prepare", "--agent-id", "agent-a", "--document", resolve(root, "owner-contract.json"), - "--operation-id", "older-export-plan", "--execute"); - const prepared = cli("prepare", "--agent-id", "agent-a", "--document", resolve(root, "owner-contract.json"), - "--operation-id", "prepare-export-check", "--execute"); + // The admitted conversation tool prepares the plan through its real owner. + // The model is scripted; the owner does not repeat the facts in a JSON form. + const prepared = authority.prepared; assert.equal(prepared.executed, false); + assert.equal(prepared.proposal.normalized_parameters.prepared_by, "coordinator"); assert.equal(read().goal_acceptance_contract.enabled, false, "preparation is not owner approval"); const panel = page.locator(".delivery-acceptance-contract"); const adopt = panel.getByRole("button", {name: "采用并检查", exact: true}); @@ -64,6 +62,7 @@ try { await panel.getByText("当前版本已核对", {exact: true}).waitFor(); assert.equal(applyCount, 1, "one owner action adopts and checks the prepared plan"); assert.equal(read().goal_acceptance_contract.criteria[0].oracle.state, "current"); + const firstOperation = read().goal_acceptance_contract.verification.operation_id; assert.equal(await panel.locator('input[type="file"]').isVisible(), false, "technical import is optional"); await panel.scrollIntoViewIfNeeded(); await page.screenshot({path: resolve(outputDir, "oracle-current-desktop.png"), animations: "disabled"}); @@ -79,6 +78,7 @@ try { assert.equal(await panel.getByText("产物检查通过", {exact: true}).count(), 0); const oldOperation = read().goal_acceptance_contract.verification.operation_id; assert.equal(applyCount, 1); + assert.equal(oldOperation, firstOperation, "readback does not execute a new check"); await run.click(); await panel.getByText("检查失败;修复交付物后可再次检查", {exact: true}).waitFor(); assert.notEqual(read().goal_acceptance_contract.verification.operation_id, oldOperation); @@ -104,7 +104,7 @@ try { const expected = "Failed to load resource: the server responded with a status of 409 (Conflict)"; const at = context.errors.indexOf(expected); if (at >= 0) context.errors.splice(at, 1); assert.deepEqual(context.errors.filter(value => !value.includes("404")), []); - console.log(JSON.stringify({status: "PASS", boundary: "Packaged UI with actual disposable SQLite/HTTP/CLI; Agent preparation is scripted, not live model or installed App evidence", checks: ["automatic prepared-plan discovery", "one contextual owner action", "no mandatory JSON/revision form", "exact native readback", "changed output clears green without execution", "failure/recovery", "advanced import rejection", "disable/default-off", "390px", "keyboard/reduced-motion"]})); + console.log(JSON.stringify({status: "PASS", boundary: "Packaged UI and admitted conversation tool with actual disposable SQLite/HTTP/CLI; model/tool transport is scripted, not live model or installed App evidence", checks: ["native conversation preparation without execution", "automatic prepared-plan discovery", "one contextual owner action", "no mandatory JSON/revision form", "exact native readback", "changed output clears green without execution", "failure/recovery", "advanced import rejection", "disable/default-off", "390px", "keyboard/reduced-motion"]})); } catch (error) { if (context?.page) { await mkdir(outputDir, {recursive: true}); diff --git a/loopx/chat_loopx_mode.py b/loopx/chat_loopx_mode.py index be9b1e292e..390fb1093d 100644 --- a/loopx/chat_loopx_mode.py +++ b/loopx/chat_loopx_mode.py @@ -1,7 +1,9 @@ """Owner-local conversation control over native continuation and scoped delegation. -The host binds the sender and execution configuration; models cannot select -identities, commands or filesystem roots. Task acceptance remains with Delegations. +The host binds the sender and worker execution configuration; models cannot +select worker identities, commands or filesystem roots. A prepared delivery +check is only a proposal for the existing local-owner acceptance action. +Task acceptance remains with Delegations. """ from __future__ import annotations @@ -42,19 +44,26 @@ "are accepted and the consumer has that exact uses input. Reading alone is not adoption. " "Read/wait/resume use the original operation_id. Running is not failure; do not duplicate it. " "After context loss, action=operations recovers this requester's durable work. Follow " - "next_cursor for more; unavailable means reconcile, not redispatch. Action=inspect with binding_id checks the actual Turn/profile before new dispatch; unknown availability is not readiness.", + "next_cursor for more; unavailable means reconcile, not redispatch. Action=inspect with binding_id checks the actual Turn/profile before new dispatch; unknown availability is not readiness. " + "Action=prepare_check takes a stable operation_id and an acceptance document prepared from the owner's requested outcome and current task/file facts. " + "The host supplies the Goal and preparing Agent. It saves only a reviewable goal.acceptance.check proposal; it does not configure acceptance or run commands. " + "The local owner can use that plan beside the delivery in the workspace.", "inputSchema": { "type": "object", "additionalProperties": False, "properties": { "action": { "type": "string", - "enum": ["bindings", "operations", "inspect", "start", "read", "wait", "resume", "revalidate", "adopt", "messages"], + "enum": ["bindings", "operations", "inspect", "start", "read", "wait", "resume", "revalidate", "adopt", "messages", "prepare_check"], }, "binding_id": {"type": "string"}, "operation_id": {"type": "string"}, "consumer_operation_id": {"type": "string"}, "brief": {"type": "object"}, + "document": { + "type": "object", + "description": "Existing acceptance document: objective (string), non_goals (strings), scope ({kind: selected_work, todo_ids: existing advancement Todo ids} or {kind: all_advancement}), criteria (id, description, validation_argv string array, optional validation_timeout_seconds), bindings (todo_id, criterion_ids). Checks use the registered Goal workspace or its pinned oracle snapshot only after local-owner apply. Use known requirements and approved scope; do not invent an oracle or authorization.", + }, "limit": {"type": "integer", "minimum": 1, "maximum": 50}, "cursor": {"type": "string"}, }, @@ -72,6 +81,9 @@ "For task_failure, repair the original artifact or validation environment before action=revalidate; it reruns checks without Host work. " "When current_use.state=unavailable, repair the declared source or receiver input and read again; do not propagate those artifacts as current facts. Read action=messages between work steps for owner inbox additions. " "Do not claim the whole canonical Goal is complete. Keep independent analysis substantive. " + "When the owner requests delivery checks and prepare_check is advertised by your tool, prepare the existing acceptance document from the known intent, tasks and files; " + "do not ask the owner to assemble JSON or internal identifiers. Reuse stated criteria and scope, and ask only for an unresolved consequential fact or authority. " + "Use prepare_check to save the plan, then explain its purpose and command effect. Preparation never approves or executes it, and a check never establishes independent review or adoption. " "Before completing or blocking, return a self-contained report with substantive accepted findings, " "exact artifact hashes and remaining gaps; do not require readers to reconstruct earlier streamed replies. " "A missing report-writing grant must not prevent returning the complete analysis in this conversation. " @@ -834,11 +846,14 @@ def dispatch(name, arguments): "operation_id", "consumer_operation_id", "brief", + "document", "limit", "cursor", }: raise ValueError("invalid collaboration arguments") action = arguments.get("action") + if action != "prepare_check" and "document" in arguments: + raise ValueError("document is only valid for prepare_check") if action != "operations" and ("limit" in arguments or "cursor" in arguments): raise ValueError("pagination is only valid for operations") if action == "operations" and set(arguments) - {"action", "limit", "cursor"}: @@ -846,7 +861,34 @@ def dispatch(name, arguments): if action != "adopt" and "consumer_operation_id" in arguments: raise ValueError("consumer_operation_id is only valid for adopt") operation_id = arguments.get("operation_id", "") - if action == "messages": + if action == "prepare_check": + if (set(arguments) != {"action", "operation_id", "document"} + or not isinstance(operation_id, str) or not operation_id.strip() + or not isinstance(arguments["document"], dict)): + raise ValueError("prepare_check requires only a stable operation_id and document") + from .control_plane.goals.acceptance_actions import prepare_acceptance_check + from .chat_action_store import ActionConflictError + + try: + result = prepare_acceptance_check( + registry_path=self.controller.registry_path, + runtime_root=str(self.store.root.parent), goal_id=current["goal_id"], + agent_id=settings["agent_id"], document=arguments["document"], + operation_id=operation_id, execute=True, + ) + except ActionConflictError: + return { + "ok": False, + "error": "collaboration_request_rejected", + "next_action": "Read the saved check plan and current Goal basis. Reuse its operation id only for an unchanged request; a new intent needs a new operation id.", + } + except ValueError as exc: + return { + "ok": False, + "error": "collaboration_request_rejected", + "next_action": f"The acceptance owner rejected the document ({exc}). Check its objective, scope, criteria, bindings and current task/file facts against the acceptance document contract; do not change authority to make it pass.", + } + elif action == "messages": rows = [ r for r in self.store.loopx_ingress(session_id) diff --git a/tests/control_plane/test_goal_acceptance_prepared_action.py b/tests/control_plane/test_goal_acceptance_prepared_action.py index 265853dc1a..f0db2d513d 100644 --- a/tests/control_plane/test_goal_acceptance_prepared_action.py +++ b/tests/control_plane/test_goal_acceptance_prepared_action.py @@ -2,6 +2,8 @@ import json from concurrent.futures import ThreadPoolExecutor +from threading import Event +from types import SimpleNamespace import pytest @@ -11,6 +13,147 @@ from test_goal_acceptance_cli import acceptance_goal as acceptance_goal +@pytest.fixture +def conversation_check(acceptance_goal): + """An admitted conversation tool over disposable, real File/SQLite owners.""" + from loopx.chat_loopx_mode import TOOL + from loopx.chat_runtime import ChatRuntimeController + from loopx.chat_store import ChatSessionStore + + project, document, cli, _ = acceptance_goal + root, registry = project.parent / "runtime", project.parent / "registry.json" + payload = json.loads(registry.read_text()) + payload["goals"][0]["coordination"] = { + "agent_model": "peer_v1", "registered_agents": ["coordinator", "agent-a"], + } + payload["goals"][0]["spawn_policy"] = { + "mode": "multi_subagent", "allowed": True, "max_children": 1, + "execution_config": ".loopx/config/delegations.json", + } + registry.write_text(json.dumps(payload)) + config = project / ".loopx/config/delegations.json" + config.parent.mkdir(parents=True) + config.write_text(json.dumps({"schema_version": "loopx_local_delegation_v0", "bindings": [{ + "id": "delivery", "agent_id": "agent-a", "todo_id": "todo_export", "requesters": ["coordinator"], + "workspace": str(project), "host_args": ["--host", "dsh"], + "timeout_seconds": 60, "output_refs": ["artifact.txt"], + }]})) + plan = json.loads(document.read_text()) + plan["criteria"][0]["validation_argv"] = ["python3", "check.py"] + (project / "check.py").write_text( + "from pathlib import Path\n" + "p=Path('runs.txt')\np.write_text(p.read_text()+'x' if p.exists() else 'x')\n" + "assert Path('artifact.txt').read_text() == 'accepted'\n" + ) + (project / "artifact.txt").write_text("accepted") + store = ChatSessionStore(root) + controller = ChatRuntimeController(store=store, codex_bin="unused", registry_path=registry) + session = store.create_session(goal_id="goal-acceptance", agent_id="codex", + channel_id="goal.goal-acceptance", upstream_thread_id="fixture", upstream_mode="chat", + adapter_kind="codex_app_server") + sid, turn = session["session_id"], "admitted-tool-turn" + store.update_session(sid, active_turn_id=turn, loopx_mode={"enabled": True, + "paused": False, "settings": {"agent_id": "coordinator", "token_budget": 10000}}) + adapter = SimpleNamespace(goal_driver=SimpleNamespace(stopped=Event()), session=SimpleNamespace()) + lock = controller.loopx_mode.prepare(sid, turn, adapter, lambda *_: {"read": True}, lambda *_: None) + def call(**extra): + return adapter.session.read_tool_handler(TOOL["name"], { + "action": "prepare_check", "operation_id": "conversation-check", "document": plan, **extra}) + try: + yield project, cli, controller, sid, plan, call, adapter + finally: + lock.__exit__(None, None, None) + controller.close() + + +def test_conversation_prepares_existing_owner_plan_without_execution(conversation_check): + project, cli, controller, _, plan, call, _ = conversation_check + before = cli("inspect")[1] + result = call() + assert result["ok"] is True, result + assert result["prepared"] is True and result["executed"] is False + proposal = result["proposal"] + assert proposal["context"] == {"kind": "goal", "goal_id": "goal-acceptance"} + assert proposal["normalized_parameters"]["prepared_by"] == "coordinator" + assert proposal["normalized_parameters"]["document"] == plan + assert call()["proposal"]["proposal_id"] == proposal["proposal_id"] + assert cli("inspect")[1] == before + assert not (project / "runs.txt").exists() + service = ChatActionService(store=ChatActionStore(project.parent / "runtime/chat/actions"), + registry_path=controller.registry_path) + applied = service.apply(proposal["proposal_id"]) + assert applied["proposal"]["receipt"]["checks_passed"] is True + assert (project / "runs.txt").read_text() == "x" + assert service.apply(proposal["proposal_id"]) == applied + assert (project / "runs.txt").read_text() == "x" + + +@pytest.mark.parametrize("extra", [ + {"goal_id": "other-goal"}, {"agent_id": "other-agent"}, + {"runtime_root": "other-runtime"}, {"binding_id": "delivery"}, + {"operation_id": ""}, {"document": {}}, {"action": "bindings"}, +]) +def test_conversation_check_rejects_invalid_scope_without_effect(conversation_check, extra): + project, cli, _, _, _, call, _ = conversation_check + before = cli("inspect")[1] + assert call(**extra)["error"] == "collaboration_request_rejected" + assert ChatActionStore(project.parent / "runtime/chat/actions").list() == [] + assert cli("inspect")[1] == before + assert not (project / "runs.txt").exists() + + +@pytest.mark.parametrize("fence", ["disabled", "paused", "other_turn", "stopped", "revoked"]) +def test_conversation_check_rechecks_live_admission(conversation_check, fence): + project, cli, controller, sid, _, call, adapter = conversation_check + before = cli("inspect")[1] + if fence == "stopped": + adapter.goal_driver.stopped.set() + elif fence == "other_turn": + controller.store.update_session(sid, active_turn_id="later-turn") + elif fence == "revoked": + (project / ".loopx/config/delegations.json").unlink() + else: + mode = controller.store.load_session(sid)["loopx_mode"] + mode["enabled" if fence == "disabled" else "paused"] = fence != "disabled" + controller.store.update_session(sid, loopx_mode=mode) + result = call() + assert result["error"] == ( + "collaboration_request_rejected" if fence == "revoked" else "conversation_execution_inactive" + ) + assert ChatActionStore(project.parent / "runtime/chat/actions").list() == [] + assert cli("inspect")[1] == before + assert not (project / "runs.txt").exists() + + +def test_conversation_check_keeps_original_intent_on_conflicting_retry(conversation_check): + project, cli, _, _, plan, call, _ = conversation_check + before = cli("inspect")[1] + proposal = call()["proposal"] + changed = json.loads(json.dumps(plan)) + changed["objective"] = "A different requested outcome" + refused = call(document=changed) + assert refused["error"] == "collaboration_request_rejected" + assert "saved check plan" in refused["next_action"] + assert ChatActionStore(project.parent / "runtime/chat/actions").list() == [proposal] + assert call()["proposal"] == proposal + assert cli("inspect")[1] == before + assert not (project / "runs.txt").exists() + + +def test_conversation_check_returns_document_repair_context(conversation_check): + project, cli, _, _, plan, call, _ = conversation_check + before = cli("inspect")[1] + invalid = json.loads(json.dumps(plan)) + invalid["objective"] = "" + refused = call(document=invalid) + assert refused["error"] == "collaboration_request_rejected" + assert "acceptance owner rejected" in refused["next_action"] + assert "objective, scope, criteria, bindings" in refused["next_action"] + assert ChatActionStore(project.parent / "runtime/chat/actions").list() == [] + assert cli("inspect")[1] == before + assert not (project / "runs.txt").exists() + + def prepare(acceptance_goal): project, document, cli, _ = acceptance_goal raw = json.loads(document.read_text()) diff --git a/tests/test_chat_loopx_mode.py b/tests/test_chat_loopx_mode.py index c66d3f0d72..0b0083e18e 100644 --- a/tests/test_chat_loopx_mode.py +++ b/tests/test_chat_loopx_mode.py @@ -526,6 +526,27 @@ def test_pause_cannot_enable_mode_and_ordinary_turn_is_not_execution(mode): assert service.store.load_turn(sid, ordinary["turn_id"])["status"] == "queued" +@pytest.mark.parametrize("enabled", [False, True]) +def test_preparation_is_advertised_only_in_explicit_loopx_toolset(mode, monkeypatch, enabled): + from loopx.chat_runtime import CodexAppServerAdapter + + service, sid, repo, _, _ = mode + captured, adapter = [], object() + monkeypatch.setattr(CodexAppServerAdapter, "start", lambda **kw: captured.append(kw) or adapter) + assert service.controller._start_adapter(agent_id="codex", work_dir=repo, + goal_id="research", objective="Check the delivery", project_coordination=True, + loopx_tools=enabled) is adapter + tools = {tool["name"]: tool for tool in captured[0]["dynamic_tools"]} + assert "loopx_context_read" in tools + assert (TOOL["name"] in tools) is enabled + if enabled: + schema = tools[TOOL["name"]]["inputSchema"] + assert "prepare_check" in schema["properties"]["action"]["enum"] + assert "document" in schema["properties"] + assert not {"goal_id", "agent_id", "runtime_root"} & schema["properties"].keys() + assert not service.snapshot(sid)["enabled"] + + def test_owner_team_readback_is_configured_scoped_and_does_not_start_a_turn(mode): service, sid, _, settings, calls = mode with pytest.raises(ValueError):